Add server URL choice, email login, and login lock controls.

Clients can use VMC or a custom server with email/password or device code; API locks after 3 failed logins with admin unlock.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-10 01:32:35 +02:00
parent 00fa9cd9c7
commit 303383ea4e
21 changed files with 1614 additions and 72 deletions

View file

@ -62,6 +62,9 @@ type Viewer = {
devices: Device[];
iptv?: ViewerIptv | null;
addonTokens?: AddonTokenRow[];
loginFailedCount?: number;
loginLocked?: boolean;
loginLockedUntil?: string | null;
};
type IptvFormState = {
@ -516,6 +519,26 @@ export default function ViewersPage() {
}
}
async function unlockLogin(v: Viewer) {
setActionId(`unlock-${v.id}`);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}/unlock-login`, {
method: "POST",
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Ontgrendelen mislukt");
return;
}
setMsg(`Login ontgrendeld voor ${v.email}`);
load();
} finally {
setActionId(null);
}
}
async function deleteViewer(v: Viewer) {
const label = v.name || v.email;
if (
@ -708,6 +731,14 @@ export default function ViewersPage() {
<span className="pill muted-pill">
{v.deviceCount} apparaat{v.deviceCount === 1 ? "" : "en"}
</span>
{v.loginLocked ? (
<span className="pill bad">Login vergrendeld</span>
) : (v.loginFailedCount ?? 0) > 0 ? (
<span className="pill warn">
{v.loginFailedCount} foute login
{(v.loginFailedCount ?? 0) === 1 ? "" : "s"}
</span>
) : null}
</div>
<span className="chevron" aria-hidden>
{open ? "▴" : "▾"}
@ -760,6 +791,28 @@ export default function ViewersPage() {
<p className="muted detail-copy">
Bepaalt of deze gebruiker kan inloggen en de app-inhoud mag gebruiken.
</p>
{(v.loginLocked || (v.loginFailedCount ?? 0) > 0) && (
<div className="login-lock-box">
<p>
{v.loginLocked
? `Login vergrendeld tot ${
v.loginLockedUntil
? new Date(v.loginLockedUntil).toLocaleString("nl-NL")
: "…"
} (3 foute pogingen).`
: `${v.loginFailedCount} foute wachtwoordpoging${
(v.loginFailedCount ?? 0) === 1 ? "" : "en"
} (lock na 3).`}
</p>
<button
type="button"
disabled={actionId === `unlock-${v.id}`}
onClick={() => void unlockLogin(v)}
>
Login ontgrendelen
</button>
</div>
)}
<div className="toggle-rows">
<div className="toggle-row">
<div>
@ -1281,6 +1334,22 @@ export default function ViewersPage() {
gap: 0.55rem;
align-content: start;
}
.login-lock-box {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.9rem;
border-radius: 10px;
border: 1px solid rgba(248, 113, 113, 0.35);
background: rgba(248, 113, 113, 0.08);
}
.login-lock-box p {
margin: 0;
flex: 1;
min-width: 200px;
font-size: 0.88rem;
}
.detail-block h3 {
margin: 0;
font-size: 0.92rem;

View file

@ -70,6 +70,33 @@ class ApiClient(
}
}
suspend fun login(
email: String,
password: String,
deviceName: String,
platform: String = "android",
): PollResult.Linked = withContext(Dispatchers.IO) {
val body = JSONObject()
.put("email", email.trim())
.put("password", password)
.put("deviceName", deviceName)
.put("platform", platform)
.toString()
.toRequestBody(jsonMedia)
val req = Request.Builder()
.url("$baseUrl/api/v1/client/login")
.post(body)
.build()
parseObject(execute(req)) { o ->
PollResult.Linked(
refreshToken = o.getString("refreshToken"),
viewerName = o.optJSONObject("viewer")?.optString("name")
?: o.optJSONObject("viewer")?.optString("email")
?: "Kijker",
)
}
}
suspend fun home(): HomePayload = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/home").get().build()
parseObject(execute(req)) { o ->

View file

@ -0,0 +1,176 @@
package nl.vonas.mediacluster.tv.ui
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun AuthChoiceScreen(
onEmailLogin: () -> Unit,
onDeviceCode: () -> Unit,
onChangeServer: () -> Unit,
) {
Box(
Modifier
.fillMaxSize()
.background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f))
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.widthIn(max = 420.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(McColors.BgElevated.copy(alpha = 0.95f))
.padding(28.dp),
) {
Text("VMC", color = McColors.Accent, fontSize = 16.sp, fontWeight = FontWeight.Black, letterSpacing = 2.sp)
Text("Sign in", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold)
Text(
"Use your account email, or link this device with a code",
color = McColors.TextDim,
fontSize = 14.sp,
)
Spacer(Modifier.height(6.dp))
McButton("Sign in with email", primary = true, modifier = Modifier.fillMaxWidth()) { onEmailLogin() }
McButton("Link with code", modifier = Modifier.fillMaxWidth()) { onDeviceCode() }
McButton("Change server", modifier = Modifier.fillMaxWidth()) { onChangeServer() }
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun PasswordLoginScreen(
api: ApiClient,
store: SessionStore,
platform: String = "android",
onBack: () -> Unit,
onLoggedIn: () -> Unit,
) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val fieldColors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = McColors.Accent,
unfocusedBorderColor = Color(0x44FFFFFF),
focusedLabelColor = McColors.Accent,
unfocusedLabelColor = McColors.TextDim,
cursorColor = Color.White,
)
fun submit() {
if (busy) return
if (email.isBlank() || password.isEmpty()) {
error = "E-mail and password required"
return
}
busy = true
error = null
scope.launch {
try {
val base = store.apiBase.first()
api.configure(base, null)
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android" }
val linked = api.login(email.trim(), password, deviceName, platform)
store.setToken(linked.refreshToken)
api.configure(base, linked.refreshToken)
onLoggedIn()
} catch (e: Exception) {
error = e.message ?: "Login failed"
} finally {
busy = false
}
}
}
Box(
Modifier
.fillMaxSize()
.background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f))
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.widthIn(max = 420.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(McColors.BgElevated.copy(alpha = 0.95f))
.padding(28.dp),
) {
Text("Sign in with email", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
OutlinedTextField(
value = email,
onValueChange = { email = it; error = null },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { androidx.compose.material3.Text("Email") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
colors = fieldColors,
)
OutlinedTextField(
value = password,
onValueChange = { password = it; error = null },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { androidx.compose.material3.Text("Password") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
colors = fieldColors,
)
if (error != null) Text(error!!, color = McColors.Danger, fontSize = 13.sp)
McButton(if (busy) "Signing in…" else "Sign in", primary = true, modifier = Modifier.fillMaxWidth()) { submit() }
McButton("Back", modifier = Modifier.fillMaxWidth()) { onBack() }
}
}
}

View file

@ -138,7 +138,7 @@ fun McNav() {
NavHost(
navController = nav,
startDestination = when {
!authed -> "link"
!authed -> "server"
!hasProfile -> "profiles"
else -> "main"
},
@ -147,11 +147,50 @@ fun McNav() {
popEnterTransition = { McTransitions.popEnter },
popExitTransition = { McTransitions.popExit },
) {
composable("server") {
ServerChoiceScreen(api, store) {
nav.navigate("auth") { popUpTo("server") { inclusive = false } }
}
}
composable("auth") {
AuthChoiceScreen(
onEmailLogin = { nav.navigate("password") },
onDeviceCode = { nav.navigate("link") },
onChangeServer = {
nav.navigate("server") {
popUpTo("server") { inclusive = true }
launchSingleTop = true
}
},
)
}
composable("password") {
PasswordLoginScreen(
api = api,
store = store,
platform = "android",
onBack = { nav.popBackStack() },
onLoggedIn = {
authed = true
hasProfile = false
nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
},
)
}
composable("link") {
LinkScreen(api, store) {
LinkScreen(
api = api,
store = store,
onChangeServer = {
nav.navigate("server") {
popUpTo("server") { inclusive = true }
launchSingleTop = true
}
},
) {
authed = true
hasProfile = false
nav.navigate("profiles") { popUpTo("link") { inclusive = true } }
nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
}
}
composable("profiles") {
@ -214,7 +253,7 @@ fun McNav() {
api.configure(store.apiBase.first(), null)
authed = false
hasProfile = false
nav.navigate("link") { popUpTo(0) { inclusive = true } }
nav.navigate("server") { popUpTo(0) { inclusive = true } }
}
},
)
@ -614,14 +653,21 @@ private fun Splash() {
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit) {
private fun LinkScreen(
api: ApiClient,
store: SessionStore,
onChangeServer: () -> Unit = {},
onLinked: () -> Unit,
) {
var code by remember { mutableStateOf<String?>(null) }
var linkUrl by remember { mutableStateOf("") }
var status by remember { mutableStateOf("Verbinden…") }
var error by remember { mutableStateOf<String?>(null) }
var serverLabel by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
val base = store.apiBase.first()
serverLabel = base
api.configure(base, null)
try {
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android" }
@ -652,7 +698,7 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit
}
}
} catch (e: Exception) {
error = e.message ?: "Kon master niet bereiken"
error = e.message ?: "Kon server niet bereiken"
}
}
@ -679,10 +725,14 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit
color = McColors.TextDim,
fontSize = 14.sp,
)
if (serverLabel.isNotBlank()) {
Text(serverLabel, color = Color(0xFF9AB0D0), fontSize = 12.sp)
}
Text(code ?: "····-····", color = McColors.Accent, fontSize = 40.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp)
if (linkUrl.isNotBlank()) Text(linkUrl, color = Color.White, fontSize = 18.sp)
Text(status, color = McColors.TextDim, fontSize = 14.sp)
if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp)
McButton("Change server", modifier = Modifier.fillMaxWidth()) { onChangeServer() }
}
}
}

View file

@ -0,0 +1,178 @@
package nl.vonas.mediacluster.tv.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.BuildConfig
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors
fun normalizeServerUrl(raw: String, fallback: String = BuildConfig.DEFAULT_API_BASE): String {
var s = raw.trim().trimEnd('/')
if (s.isBlank()) return fallback.trimEnd('/')
if (!s.startsWith("http://", ignoreCase = true) && !s.startsWith("https://", ignoreCase = true)) {
s = "https://$s"
}
return s.trimEnd('/')
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun ServerChoiceScreen(
api: ApiClient,
store: SessionStore,
onServerReady: () -> Unit,
) {
var customMode by remember { mutableStateOf(false) }
var urlInput by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
val current = store.apiBase.first()
if (current != BuildConfig.DEFAULT_API_BASE) {
urlInput = current
}
}
fun useVmc() {
if (busy) return
busy = true
error = null
scope.launch {
val base = BuildConfig.DEFAULT_API_BASE.trimEnd('/')
store.setApiBase(base)
api.configure(base, null)
busy = false
onServerReady()
}
}
fun useCustom() {
if (busy) return
if (urlInput.isBlank()) {
error = "Enter a server URL"
return
}
busy = true
error = null
scope.launch {
val base = normalizeServerUrl(urlInput)
store.setApiBase(base)
api.configure(base, null)
busy = false
onServerReady()
}
}
Box(
Modifier
.fillMaxSize()
.background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f))
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.widthIn(max = 420.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(McColors.BgElevated.copy(alpha = 0.95f))
.padding(28.dp),
) {
Text("VMC", color = McColors.Accent, fontSize = 16.sp, fontWeight = FontWeight.Black, letterSpacing = 2.sp)
Text("Connect to your media", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold)
Text(
"VMC account or your own self-hosted server",
color = McColors.TextDim,
fontSize = 14.sp,
)
Spacer(Modifier.height(6.dp))
if (!customMode) {
McButton("Continue with VMC", primary = true, modifier = Modifier.fillMaxWidth()) { useVmc() }
McButton("Connect to your server", modifier = Modifier.fillMaxWidth()) {
customMode = true
error = null
}
} else {
OutlinedTextField(
value = urlInput,
onValueChange = {
urlInput = it
error = null
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { androidx.compose.material3.Text("Server URL") },
placeholder = { androidx.compose.material3.Text("https://media.example.com") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { useCustom() }),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = McColors.Accent,
unfocusedBorderColor = Color(0x44FFFFFF),
focusedLabelColor = McColors.Accent,
unfocusedLabelColor = McColors.TextDim,
cursorColor = Color.White,
),
)
McButton(
if (busy) "Connecting…" else "Continue",
primary = true,
modifier = Modifier.fillMaxWidth(),
) { useCustom() }
McButton("Back", modifier = Modifier.fillMaxWidth()) {
customMode = false
error = null
}
}
if (error != null) {
Text(error!!, color = McColors.Danger, fontSize = 13.sp)
}
}
}
}

View file

@ -70,6 +70,33 @@ class ApiClient(
}
}
suspend fun login(
email: String,
password: String,
deviceName: String,
platform: String = "android_tv",
): PollResult.Linked = withContext(Dispatchers.IO) {
val body = JSONObject()
.put("email", email.trim())
.put("password", password)
.put("deviceName", deviceName)
.put("platform", platform)
.toString()
.toRequestBody(jsonMedia)
val req = Request.Builder()
.url("$baseUrl/api/v1/client/login")
.post(body)
.build()
parseObject(execute(req)) { o ->
PollResult.Linked(
refreshToken = o.getString("refreshToken"),
viewerName = o.optJSONObject("viewer")?.optString("name")
?: o.optJSONObject("viewer")?.optString("email")
?: "Kijker",
)
}
}
suspend fun home(): HomePayload = withContext(Dispatchers.IO) {
val req = authed("$baseUrl/api/v1/client/home").get().build()
parseObject(execute(req)) { o ->

View file

@ -0,0 +1,237 @@
package nl.vonas.mediacluster.tv.ui
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun AuthChoiceScreen(
onEmailLogin: () -> Unit,
onDeviceCode: () -> Unit,
onChangeServer: () -> Unit,
) {
val primaryFocus = remember { FocusRequester() }
LaunchedEffect(Unit) {
delay(80)
runCatching { primaryFocus.requestFocus() }
}
Box(
Modifier
.fillMaxSize()
.background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier
.width(560.dp)
.clip(RoundedCornerShape(24.dp))
.background(McColors.BgElevated.copy(alpha = 0.92f))
.padding(40.dp),
) {
Text("VMC", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Black, letterSpacing = 3.sp)
Text("Sign in", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Text(
"Use your account email, or link this device with a code",
color = McColors.TextDim,
fontSize = 15.sp,
)
Spacer(Modifier.height(8.dp))
McButton(
"Sign in with email",
primary = true,
focusRequester = primaryFocus,
modifier = Modifier.fillMaxWidth(),
) { onEmailLogin() }
McButton(
"Link with code",
modifier = Modifier.fillMaxWidth(),
) { onDeviceCode() }
McButton("Change server", modifier = Modifier.fillMaxWidth()) { onChangeServer() }
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun PasswordLoginScreen(
api: ApiClient,
store: SessionStore,
platform: String = "android_tv",
onBack: () -> Unit,
onLoggedIn: () -> Unit,
) {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val emailFocus = remember { FocusRequester() }
LaunchedEffect(Unit) {
delay(80)
runCatching { emailFocus.requestFocus() }
}
fun submit() {
if (busy) return
if (email.isBlank() || password.isEmpty()) {
error = "E-mail and password required"
return
}
busy = true
error = null
scope.launch {
try {
val base = store.apiBase.first()
api.configure(base, null)
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" }
val linked = api.login(email.trim(), password, deviceName, platform)
store.setToken(linked.refreshToken)
api.configure(base, linked.refreshToken)
onLoggedIn()
} catch (e: Exception) {
error = e.message ?: "Login failed"
} finally {
busy = false
}
}
}
Box(
Modifier
.fillMaxSize()
.background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.widthIn(max = 560.dp)
.width(520.dp)
.clip(RoundedCornerShape(24.dp))
.background(McColors.BgElevated.copy(alpha = 0.92f))
.padding(36.dp),
) {
Text("Sign in with email", color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.Bold)
Text("Your VMC account credentials", color = McColors.TextDim, fontSize = 14.sp)
TvField(
value = email,
onValueChange = { email = it; error = null },
placeholder = "email@example.com",
keyboardType = KeyboardType.Email,
modifier = Modifier.focusRequester(emailFocus),
imeAction = ImeAction.Next,
)
TvField(
value = password,
onValueChange = { password = it; error = null },
placeholder = "Password",
keyboardType = KeyboardType.Password,
password = true,
imeAction = ImeAction.Done,
onDone = { submit() },
)
if (error != null) {
Text(error!!, color = McColors.Danger, fontSize = 14.sp)
}
McButton(
if (busy) "Signing in…" else "Sign in",
primary = true,
modifier = Modifier.fillMaxWidth(),
) { submit() }
McButton("Back", modifier = Modifier.fillMaxWidth()) { onBack() }
}
}
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun TvField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
keyboardType: KeyboardType,
modifier: Modifier = Modifier,
password: Boolean = false,
imeAction: ImeAction = ImeAction.Next,
onDone: (() -> Unit)? = null,
) {
Box(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color(0x22FFFFFF))
.padding(horizontal = 16.dp, vertical = 14.dp)
.then(modifier),
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
cursorBrush = SolidColor(Color.White),
textStyle = TextStyle(color = Color.White, fontSize = 16.sp),
visualTransformation = if (password) PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction),
keyboardActions = KeyboardActions(onDone = { onDone?.invoke() }),
modifier = Modifier.fillMaxWidth(),
decorationBox = { inner ->
if (value.isEmpty()) {
Text(placeholder, color = Color(0x66FFFFFF), fontSize = 16.sp)
}
inner()
},
)
}
}

View file

@ -15,9 +15,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -26,13 +26,16 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@ -55,7 +58,7 @@ import nl.vonas.mediacluster.tv.ui.theme.McColors
private val AzLetters = listOf("#") + ('A'..'Z').map { it.toString() }
@OptIn(ExperimentalTvMaterial3Api::class)
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun BrowseScreen(
api: ApiClient,
@ -86,18 +89,20 @@ fun BrowseScreen(
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var loadingMore by remember { mutableStateOf(false) }
var sort by remember(catalogId) { mutableStateOf(if (isContinue) "recent" else defaultSort) }
var activeLetter by remember(catalogId) { mutableStateOf<String?>(null) }
var sort by rememberSaveable(catalogId) { mutableStateOf(if (isContinue) "recent" else defaultSort) }
var activeLetter by rememberSaveable(catalogId) { mutableStateOf<String?>(null) }
var loadedKey by rememberSaveable(catalogId) { mutableStateOf<String?>(null) }
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
var menuReady by remember { mutableStateOf(false) }
val menuFocus = remember { FocusRequester() }
val gridState = rememberLazyGridState()
val gridState = rememberSaveable(catalogId, sort, saver = LazyGridState.Saver) { LazyGridState() }
val scope = rememberCoroutineScope()
val showBrowseTools = !isContinue
suspend fun load(reset: Boolean, jumpLetter: String? = null) {
if (reset) {
loading = true
// Bestaande items houden het grid in compositie → focus blijft bij terugkeer
if (items.isEmpty()) loading = true
error = null
} else {
loadingMore = true
@ -132,8 +137,11 @@ fun BrowseScreen(
}
LaunchedEffect(catalogId, sort) {
load(reset = true, jumpLetter = null)
gridState.scrollToItem(0)
val key = "$catalogId|$sort"
if (loadedKey != key || items.isEmpty()) {
load(reset = true, jumpLetter = null)
loadedKey = key
}
}
LaunchedEffect(gridState, items.size, total, listOffset) {
@ -248,7 +256,9 @@ fun BrowseScreen(
contentPadding = PaddingValues(start = 8.dp, top = 12.dp, end = 8.dp, bottom = 40.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.focusRestorer(),
) {
items(items, key = { "${it.type}:${it.id}:${it.progressId}" }) { item ->
PosterCard(
@ -295,10 +305,11 @@ fun BrowseScreen(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.padding(vertical = 72.dp, horizontal = 6.dp),
.padding(vertical = 16.dp, horizontal = 4.dp),
onLetter = { letter ->
scope.launch {
load(reset = true, jumpLetter = letter)
loadedKey = "$catalogId|$sort"
gridState.scrollToItem(0)
}
},
@ -392,28 +403,35 @@ private fun AzScrubber(
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.width(28.dp),
verticalArrangement = Arrangement.SpaceEvenly,
modifier = modifier.width(30.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AzLetters.forEach { letter ->
val isSelected = selected.equals(letter, ignoreCase = true)
Surface(
onClick = { onLetter(letter) },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.25f),
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(3.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.35f),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
focusedContainerColor = Color(0x44FFFFFF),
focusedContainerColor = Color(0x55FFFFFF),
),
) {
Text(
letter,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
color = if (isSelected) McColors.Brand else Color(0xCCFFFFFF),
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
)
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
letter,
color = if (isSelected) McColors.Brand else Color(0xCCFFFFFF),
fontSize = 10.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
lineHeight = 10.sp,
)
}
}
}
}

View file

@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@ -44,11 +45,13 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Brush
@ -69,6 +72,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.dialog
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.tv.material3.ClickableSurfaceDefaults
@ -171,7 +175,7 @@ fun McNav() {
NavHost(
navController = nav,
startDestination = when {
!authed -> "link"
!authed -> "server"
!hasProfile -> "profiles"
else -> "main"
},
@ -180,11 +184,50 @@ fun McNav() {
popEnterTransition = { McTransitions.popEnter },
popExitTransition = { McTransitions.popExit },
) {
composable("server") {
ServerChoiceScreen(api, store) {
nav.navigate("auth") { popUpTo("server") { inclusive = false } }
}
}
composable("auth") {
AuthChoiceScreen(
onEmailLogin = { nav.navigate("password") },
onDeviceCode = { nav.navigate("link") },
onChangeServer = {
nav.navigate("server") {
popUpTo("server") { inclusive = true }
launchSingleTop = true
}
},
)
}
composable("password") {
PasswordLoginScreen(
api = api,
store = store,
platform = "android_tv",
onBack = { nav.popBackStack() },
onLoggedIn = {
authed = true
hasProfile = false
nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
},
)
}
composable("link") {
LinkScreen(api, store) {
LinkScreen(
api = api,
store = store,
onChangeServer = {
nav.navigate("server") {
popUpTo("server") { inclusive = true }
launchSingleTop = true
}
},
) {
authed = true
hasProfile = false
nav.navigate("profiles") { popUpTo("link") { inclusive = true } }
nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
}
}
composable("profiles") {
@ -255,7 +298,7 @@ fun McNav() {
api.configure(store.apiBase.first(), null)
authed = false
hasProfile = false
nav.navigate("link") { popUpTo(0) { inclusive = true } }
nav.navigate("server") { popUpTo(0) { inclusive = true } }
}
},
)
@ -283,27 +326,54 @@ fun McNav() {
},
)
}
composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
val id = entry.arguments?.getString("id") ?: return@composable
MovieDetailScreen(
api = api,
movieId = id,
onBack = { nav.popBackStack() },
onPlay = { mediaFileId, _, startMs ->
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs")
},
)
// dialog i.p.v. composable: Home/Browse blijven gecomposeerd → scroll+focus behouden
dialog(
route = "movie/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
dialogProperties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false,
),
) { entry ->
val id = entry.arguments?.getString("id") ?: return@dialog
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
MovieDetailScreen(
api = api,
movieId = id,
onBack = { nav.popBackStack() },
onPlay = { mediaFileId, _, startMs ->
nav.navigate("player/$mediaFileId/MOVIE/$id/-/-/$startMs") {
popUpTo("movie/$id") { inclusive = true }
}
},
)
}
}
composable("series/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
val id = entry.arguments?.getString("id") ?: return@composable
SeriesDetailScreen(
api = api,
seriesId = id,
onBack = { nav.popBackStack() },
onPlay = { mediaFileId, episodeId, _ ->
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0")
},
)
dialog(
route = "series/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
dialogProperties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false,
),
) { entry ->
val id = entry.arguments?.getString("id") ?: return@dialog
Box(Modifier.fillMaxSize().background(McColors.Bg)) {
SeriesDetailScreen(
api = api,
seriesId = id,
onBack = { nav.popBackStack() },
onPlay = { mediaFileId, episodeId, _ ->
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0") {
popUpTo("series/$id") { inclusive = true }
}
},
)
}
}
composable(
"player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}",
@ -380,14 +450,15 @@ private fun MainHub(
val homeContentFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() }
var exitReady by remember { mutableStateOf(false) }
val homeListState = rememberLazyListState()
val homeListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() }
var prevTabName by rememberSaveable { mutableStateOf<String?>(null) }
val hubScope = rememberCoroutineScope()
val activity = LocalContext.current as? Activity
var shelfPrefs by remember { mutableStateOf<List<ShelfPref>>(emptyList()) }
var movieCatalog by remember { mutableStateOf("movies") }
var seriesCatalog by remember { mutableStateOf("series") }
var movieSubId by remember { mutableStateOf("movies") }
var seriesSubId by remember { mutableStateOf("series") }
var movieCatalog by rememberSaveable { mutableStateOf("movies") }
var seriesCatalog by rememberSaveable { mutableStateOf("series") }
var movieSubId by rememberSaveable { mutableStateOf("movies") }
var seriesSubId by rememberSaveable { mutableStateOf("series") }
LaunchedEffect(exitConfirm) {
if (exitConfirm) {
@ -501,12 +572,15 @@ private fun MainHub(
}
}
// Reset scroll wanneer je terugkomt op Home (tab-wissel)
// Reset scroll alleen bij echte tab-wissel naar Home (niet bij terugkeer uit detail/player)
LaunchedEffect(tab) {
if (tab == MainTab.Home) {
val name = tab.name
val switched = prevTabName != null && prevTabName != name
if (tab == MainTab.Home && switched) {
homeListState.scrollToItem(0)
homeChromeCompact = false
}
prevTabName = name
}
val homeOverlay = tab == MainTab.Home
@ -737,14 +811,21 @@ private fun Splash() {
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit) {
private fun LinkScreen(
api: ApiClient,
store: SessionStore,
onChangeServer: () -> Unit = {},
onLinked: () -> Unit,
) {
var code by remember { mutableStateOf<String?>(null) }
var linkUrl by remember { mutableStateOf("") }
var status by remember { mutableStateOf("Verbinden…") }
var error by remember { mutableStateOf<String?>(null) }
var serverLabel by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
val base = store.apiBase.first()
serverLabel = base
api.configure(base, null)
try {
val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim().ifBlank { "Android TV" }
@ -775,7 +856,7 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit
}
}
} catch (e: Exception) {
error = e.message ?: "Kon master niet bereiken"
error = e.message ?: "Kon server niet bereiken"
}
}
@ -797,10 +878,15 @@ private fun LinkScreen(api: ApiClient, store: SessionStore, onLinked: () -> Unit
Text("VMC", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Black, letterSpacing = 3.sp)
Text("Koppel dit apparaat", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text("Open de link op je telefoon, log in en voer de code in.", color = McColors.TextDim, fontSize = 15.sp)
if (serverLabel.isNotBlank()) {
Text(serverLabel, color = Color(0xFF9AB0D0), fontSize = 13.sp)
}
Text(code ?: "····-····", color = McColors.Accent, fontSize = 52.sp, fontWeight = FontWeight.Bold, letterSpacing = 3.sp)
if (linkUrl.isNotBlank()) Text(linkUrl, color = Color.White, fontSize = 18.sp)
Text(status, color = McColors.TextDim, fontSize = 14.sp)
if (error != null) Text(error!!, color = McColors.Danger, fontSize = 14.sp)
Spacer(Modifier.height(4.dp))
McButton("Change server") { onChangeServer() }
}
}
}
@ -824,6 +910,7 @@ private fun HomeScreen(
var loading by remember { mutableStateOf(true) }
var continueMenu by remember { mutableStateOf<MediaItem?>(null) }
var menuReady by remember { mutableStateOf(false) }
var didInitialHomeFocus by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val menuFocus = remember { FocusRequester() }
val context = LocalContext.current
@ -865,12 +952,13 @@ private fun HomeScreen(
}
}
// Focus op Afspelen zit BUITEN de LazyColumn → geen bring-into-view scroll-jump
// Alleen eerste keer focus op Afspelen — niet bij terugkeer uit film/serie
LaunchedEffect(home) {
if (home == null) return@LaunchedEffect
if (home == null || didInitialHomeFocus) return@LaunchedEffect
delay(120)
runCatching { contentFocusRequester.requestFocus() }
onContentFocused()
didInitialHomeFocus = true
}
val spotlight = home?.spotlight.orEmpty().ifEmpty {
@ -958,8 +1046,13 @@ private fun HomeScreen(
shelvesFocusRequester = firstShelfFocus,
onContentFocused = {
onContentFocused()
// Rest-offset na terugscrollen: hero weer volledig onder menu
scope.launch { listState.scrollToItem(0) }
// Alleen terug naar top als we al bovenaan zitten met rest-offset;
// niet forceren bij focus-events na terugkeer uit detail.
if (listState.firstVisibleItemIndex == 0 &&
listState.firstVisibleItemScrollOffset in 1..160
) {
scope.launch { listState.scrollToItem(0) }
}
},
onPlay = { item ->
onContentFocused()
@ -1281,6 +1374,7 @@ private fun SpotlightHero(
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun ShelfBlock(
title: String,
@ -1341,6 +1435,7 @@ private fun ShelfBlock(
}
}
LazyRow(
modifier = Modifier.focusRestorer(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
// Extra ruimte zodat focusedScale (1.10) posters niet afknipt
contentPadding = PaddingValues(start = 48.dp, end = 48.dp, top = 28.dp, bottom = 28.dp),

View file

@ -0,0 +1,214 @@
package nl.vonas.mediacluster.tv.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import nl.vonas.mediacluster.tv.BuildConfig
import nl.vonas.mediacluster.tv.data.ApiClient
import nl.vonas.mediacluster.tv.data.SessionStore
import nl.vonas.mediacluster.tv.ui.components.McButton
import nl.vonas.mediacluster.tv.ui.theme.McColors
/** Normaliseer server-URL: trim, strip trailing slash, voeg https:// toe indien nodig. */
fun normalizeServerUrl(raw: String, fallback: String = BuildConfig.DEFAULT_API_BASE): String {
var s = raw.trim().trimEnd('/')
if (s.isBlank()) return fallback.trimEnd('/')
if (!s.startsWith("http://", ignoreCase = true) && !s.startsWith("https://", ignoreCase = true)) {
s = "https://$s"
}
return s.trimEnd('/')
}
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun ServerChoiceScreen(
api: ApiClient,
store: SessionStore,
onServerReady: () -> Unit,
) {
var customMode by remember { mutableStateOf(false) }
var urlInput by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val primaryFocus = remember { FocusRequester() }
val urlFocus = remember { FocusRequester() }
LaunchedEffect(Unit) {
val current = store.apiBase.first()
if (current != BuildConfig.DEFAULT_API_BASE) {
urlInput = current
}
delay(80)
runCatching { primaryFocus.requestFocus() }
}
LaunchedEffect(customMode) {
if (customMode) {
delay(80)
runCatching { urlFocus.requestFocus() }
}
}
fun useVmc() {
if (busy) return
busy = true
error = null
scope.launch {
val base = BuildConfig.DEFAULT_API_BASE.trimEnd('/')
store.setApiBase(base)
api.configure(base, null)
busy = false
onServerReady()
}
}
fun useCustom() {
if (busy) return
val base = normalizeServerUrl(urlInput)
if (urlInput.isBlank()) {
error = "Voer een server-URL in"
return
}
busy = true
error = null
scope.launch {
store.setApiBase(base)
api.configure(base, null)
busy = false
onServerReady()
}
}
Box(
Modifier
.fillMaxSize()
.background(Brush.radialGradient(listOf(Color(0xFF12203A), McColors.Bg), radius = 900f)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier
.widthIn(max = 640.dp)
.width(560.dp)
.clip(RoundedCornerShape(24.dp))
.background(McColors.BgElevated.copy(alpha = 0.92f))
.padding(40.dp),
) {
Text("VMC", color = McColors.Accent, fontSize = 18.sp, fontWeight = FontWeight.Black, letterSpacing = 3.sp)
Text("Connect to your media", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Bold)
Text(
"VMC account or your own self-hosted server",
color = McColors.TextDim,
fontSize = 15.sp,
)
Spacer(Modifier.height(8.dp))
if (!customMode) {
McButton(
"Continue with VMC",
primary = true,
focusRequester = primaryFocus,
modifier = Modifier.fillMaxWidth(),
) { useVmc() }
McButton(
"Connect to your server",
modifier = Modifier.fillMaxWidth(),
) {
customMode = true
error = null
}
} else {
Text("Server URL", color = McColors.TextDim, fontSize = 13.sp)
Box(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color(0x22FFFFFF))
.padding(horizontal = 16.dp, vertical = 14.dp),
) {
BasicTextField(
value = urlInput,
onValueChange = {
urlInput = it
error = null
},
singleLine = true,
cursorBrush = SolidColor(Color.White),
textStyle = TextStyle(color = Color.White, fontSize = 16.sp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { useCustom() }),
modifier = Modifier
.fillMaxWidth()
.focusRequester(urlFocus),
decorationBox = { inner ->
if (urlInput.isEmpty()) {
Text("https://media.example.com", color = Color(0x66FFFFFF), fontSize = 16.sp)
}
inner()
},
)
}
McButton(
if (busy) "Connecting…" else "Continue",
primary = true,
focusRequester = primaryFocus,
modifier = Modifier.fillMaxWidth(),
) { useCustom() }
McButton(
"Back",
modifier = Modifier.fillMaxWidth(),
) {
customMode = false
error = null
}
}
if (error != null) {
Text(error!!, color = McColors.Danger, fontSize = 14.sp)
}
}
}
}

View file

@ -20,6 +20,7 @@
D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5E450343884C11B0920888 /* MainShell.swift */; };
1EF110B9F82E440F999ECE37 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B20E94E6B4034B1EF282B /* Components.swift */; };
E3C7ACAC5AC74107B90C6892 /* LinkScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19304FA5C7624099B8E97E1F /* LinkScreen.swift */; };
C9A1B2D3E4F5061728394A5B /* ServerAuthScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* ServerAuthScreens.swift */; };
EEB314675D0049E6B289021D /* ProfilesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFD53AA4D5C9400383EE7DC7 /* ProfilesScreen.swift */; };
AE1CD4524BFF41A0ACB1FDDA /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5561954288524F7C9E41CA40 /* HomeScreen.swift */; };
E2F8D0CF0F39406FB37884A5 /* BrowseScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E44909AE0F474047ADB06E27 /* BrowseScreen.swift */; };
@ -48,6 +49,7 @@
BF5E450343884C11B0920888 /* MainShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainShell.swift; sourceTree = "<group>"; };
314B20E94E6B4034B1EF282B /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
19304FA5C7624099B8E97E1F /* LinkScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkScreen.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5C /* ServerAuthScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerAuthScreens.swift; sourceTree = "<group>"; };
EFD53AA4D5C9400383EE7DC7 /* ProfilesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilesScreen.swift; sourceTree = "<group>"; };
5561954288524F7C9E41CA40 /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; };
E44909AE0F474047ADB06E27 /* BrowseScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseScreen.swift; sourceTree = "<group>"; };
@ -156,6 +158,7 @@
BF5E450343884C11B0920888 /* MainShell.swift */,
314B20E94E6B4034B1EF282B /* Components.swift */,
19304FA5C7624099B8E97E1F /* LinkScreen.swift */,
A1B2C3D4E5F60718293A4B5C /* ServerAuthScreens.swift */,
EFD53AA4D5C9400383EE7DC7 /* ProfilesScreen.swift */,
5561954288524F7C9E41CA40 /* HomeScreen.swift */,
E44909AE0F474047ADB06E27 /* BrowseScreen.swift */,
@ -253,6 +256,7 @@
D5D29939C5A64A23AF7AD046 /* MainShell.swift in Sources */,
1EF110B9F82E440F999ECE37 /* Components.swift in Sources */,
E3C7ACAC5AC74107B90C6892 /* LinkScreen.swift in Sources */,
C9A1B2D3E4F5061728394A5B /* ServerAuthScreens.swift in Sources */,
EEB314675D0049E6B289021D /* ProfilesScreen.swift in Sources */,
AE1CD4524BFF41A0ACB1FDDA /* HomeScreen.swift in Sources */,
E2F8D0CF0F39406FB37884A5 /* BrowseScreen.swift in Sources */,

View file

@ -54,6 +54,25 @@ actor ApiClient {
}
}
func login(email: String, password: String, deviceName: String) async throws -> (refreshToken: String, viewerName: String) {
struct LoginResponse: Decodable {
let refreshToken: String
let viewer: ViewerLite?
struct ViewerLite: Decodable {
let name: String?
let email: String?
}
}
let res: LoginResponse = try await post("/api/v1/client/login", json: [
"email": email,
"password": password,
"deviceName": deviceName,
"platform": "ios",
], authed: false)
let name = res.viewer?.name ?? res.viewer?.email ?? "Kijker"
return (res.refreshToken, name)
}
func home() async throws -> HomePayload { try await get("/api/v1/client/home") }
func search(query: String) async throws -> [MediaItem] {

View file

@ -6,6 +6,9 @@ import Combine
final class AppModel: ObservableObject {
enum Phase: Equatable {
case boot
case server
case auth
case password
case link
case profiles
case main
@ -70,7 +73,7 @@ final class AppModel: ObservableObject {
bootError = nil
await api.configure(baseUrl: store.apiBase, token: store.refreshToken)
guard store.isAuthed else {
phase = .link
phase = .server
return
}
do {
@ -97,7 +100,7 @@ final class AppModel: ObservableObject {
store.setToken(nil)
favorites.bind(profileId: nil)
await api.configure(baseUrl: store.apiBase, token: nil)
phase = .link
phase = .server
}
}
@ -138,7 +141,7 @@ final class AppModel: ObservableObject {
await api.configure(baseUrl: store.apiBase, token: nil)
favorites.bind(profileId: nil)
iptvEnabled = false
phase = .link
phase = .server
}
static var deviceName: String {

View file

@ -70,6 +70,11 @@ struct LinkScreen: View {
Button("Opnieuw") { Task { await startLink() } }
.buttonStyle(McPrimaryButtonStyle())
}
Button("Change server") { app.phase = .server }
.buttonStyle(McSecondaryButtonStyle())
Button("Back") { app.phase = .auth }
.buttonStyle(McSecondaryButtonStyle())
}
.padding()
.frame(maxWidth: 420)

View file

@ -9,6 +9,12 @@ struct RootView: View {
switch app.phase {
case .boot:
SplashView()
case .server:
ServerChoiceScreen()
case .auth:
AuthChoiceScreen()
case .password:
PasswordLoginScreen()
case .link:
LinkScreen()
case .profiles:

View file

@ -0,0 +1,224 @@
import SwiftUI
enum ServerUrl {
static func normalize(_ raw: String, fallback: String = SessionStore.defaultApiBase) -> String {
var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if s.isEmpty { return fallback.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
if !s.lowercased().hasPrefix("http://") && !s.lowercased().hasPrefix("https://") {
s = "https://\(s)"
}
return s.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
}
}
private struct AuthBackground: View {
var body: some View {
LinearGradient(
colors: [
Color(red: 0.12, green: 0.04, blue: 0.06),
McColors.bg,
Color(red: 0.04, green: 0.05, blue: 0.1),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
}
}
struct ServerChoiceScreen: View {
@EnvironmentObject private var app: AppModel
@State private var customMode = false
@State private var urlInput = ""
@State private var error: String?
@State private var busy = false
var body: some View {
ZStack {
AuthBackground()
VStack(spacing: 16) {
Text("VMC")
.font(McFonts.brand)
.foregroundStyle(McColors.accent)
Text("Connect to your media")
.font(McFonts.title)
.foregroundStyle(McColors.text)
Text("VMC account or your own self-hosted server")
.font(McFonts.body)
.foregroundStyle(McColors.textDim)
.multilineTextAlignment(.center)
if !customMode {
Button("Continue with VMC") { useVmc() }
.buttonStyle(McPrimaryButtonStyle())
.disabled(busy)
Button("Connect to your server") {
customMode = true
error = nil
if app.store.apiBase != SessionStore.defaultApiBase {
urlInput = app.store.apiBase
}
}
.buttonStyle(McSecondaryButtonStyle())
} else {
TextField("https://media.example.com", text: $urlInput)
.textInputAutocapitalization(.never)
.keyboardType(.URL)
.autocorrectionDisabled()
.padding(14)
.background(McColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.foregroundStyle(McColors.text)
Button(busy ? "Connecting…" : "Continue") { useCustom() }
.buttonStyle(McPrimaryButtonStyle())
.disabled(busy)
Button("Back") {
customMode = false
error = nil
}
.buttonStyle(McSecondaryButtonStyle())
}
if let error {
Text(error).font(McFonts.body).foregroundStyle(McColors.danger)
}
}
.padding(28)
.frame(maxWidth: 420)
.background(McColors.bgElevated.opacity(0.92), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.padding()
}
}
private func useVmc() {
busy = true
error = nil
app.store.setApiBase(SessionStore.defaultApiBase)
Task {
await app.api.configure(baseUrl: app.store.apiBase, token: nil)
busy = false
app.phase = .auth
}
}
private func useCustom() {
guard !urlInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
error = "Enter a server URL"
return
}
busy = true
error = nil
app.store.setApiBase(ServerUrl.normalize(urlInput))
Task {
await app.api.configure(baseUrl: app.store.apiBase, token: nil)
busy = false
app.phase = .auth
}
}
}
struct AuthChoiceScreen: View {
@EnvironmentObject private var app: AppModel
var body: some View {
ZStack {
AuthBackground()
VStack(spacing: 16) {
Text("VMC")
.font(McFonts.brand)
.foregroundStyle(McColors.accent)
Text("Sign in")
.font(McFonts.title)
.foregroundStyle(McColors.text)
Text("Use your account email, or link this device with a code")
.font(McFonts.body)
.foregroundStyle(McColors.textDim)
.multilineTextAlignment(.center)
Button("Sign in with email") { app.phase = .password }
.buttonStyle(McPrimaryButtonStyle())
Button("Link with code") { app.phase = .link }
.buttonStyle(McSecondaryButtonStyle())
Button("Change server") { app.phase = .server }
.buttonStyle(McSecondaryButtonStyle())
}
.padding(28)
.frame(maxWidth: 420)
.background(McColors.bgElevated.opacity(0.92), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.padding()
}
}
}
struct PasswordLoginScreen: View {
@EnvironmentObject private var app: AppModel
@State private var email = ""
@State private var password = ""
@State private var error: String?
@State private var busy = false
var body: some View {
ZStack {
AuthBackground()
VStack(spacing: 14) {
Text("Sign in with email")
.font(McFonts.title)
.foregroundStyle(McColors.text)
Text("Your VMC account credentials")
.font(McFonts.caption)
.foregroundStyle(McColors.textDim)
TextField("Email", text: $email)
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
.autocorrectionDisabled()
.padding(14)
.background(McColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.foregroundStyle(McColors.text)
SecureField("Password", text: $password)
.padding(14)
.background(McColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.foregroundStyle(McColors.text)
if let error {
Text(error).font(McFonts.body).foregroundStyle(McColors.danger).multilineTextAlignment(.center)
}
Button(busy ? "Signing in…" : "Sign in") { Task { await submit() } }
.buttonStyle(McPrimaryButtonStyle())
.disabled(busy)
Button("Back") { app.phase = .auth }
.buttonStyle(McSecondaryButtonStyle())
}
.padding(28)
.frame(maxWidth: 420)
.background(McColors.bgElevated.opacity(0.92), in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.padding()
}
}
private func submit() async {
guard !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !password.isEmpty else {
error = "E-mail and password required"
return
}
busy = true
error = nil
await app.api.configure(baseUrl: app.store.apiBase, token: nil)
do {
let result = try await app.api.login(
email: email.trimmingCharacters(in: .whitespacesAndNewlines),
password: password,
deviceName: AppModel.deviceName
)
await app.didLink(token: result.refreshToken)
} catch {
self.error = error.localizedDescription
}
busy = false
}
}

View file

@ -60,6 +60,17 @@ struct SettingsScreen: View {
}
}
Section("Server") {
LabeledContent("URL", value: app.store.apiBase)
Button {
Task { await app.logout() }
} label: {
Label("Server / account wisselen", systemImage: "server.rack")
.foregroundStyle(McColors.text)
}
.listRowBackground(McColors.bgElevated)
}
Section {
Button(role: .destructive) {
Task { await app.logout() }

View file

@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "viewer_login_locks" (
"email" TEXT NOT NULL,
"failed_count" INTEGER NOT NULL DEFAULT 0,
"locked_until" TIMESTAMP(3),
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "viewer_login_locks_pkey" PRIMARY KEY ("email")
);

View file

@ -101,6 +101,16 @@ model ViewerUser {
@@map("viewer_users")
}
/** Bruteforce-bescherming op e-mail/wachtwoord (claim + direct login). */
model ViewerLoginLock {
email String @id
failedCount Int @default(0) @map("failed_count")
lockedUntil DateTime? @map("locked_until")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("viewer_login_locks")
}
/** Netflix-stijl kijkprofiel onder één ViewerUser (voortgang/prefs per profiel). */
model ViewerProfile {
id String @id @default(uuid())

View file

@ -334,6 +334,42 @@ export function registerViewerRoutes(
}
);
/** Directe app-login (e-mail + wachtwoord) → refresh token. */
app.post(
"/api/v1/client/login",
{
config: {
rateLimit: { max: 20, timeWindow: "1 minute" },
},
},
async (request) => {
const body = (request.body || {}) as {
email?: string;
password?: string;
deviceName?: string;
platform?: string;
};
if (!body.email?.trim() || !body.password) {
throw new AppError("INVALID_REQUEST", "E-mail en wachtwoord verplicht", 400);
}
return viewers.loginWithPassword({
email: body.email,
password: body.password,
deviceName: body.deviceName,
platform: body.platform,
});
}
);
app.post(
"/api/v1/admin/viewers/:id/unlock-login",
{ preHandler: requireAdmin },
async (request) => {
const { id } = request.params as { id: string };
return viewers.unlockViewerLogin(id);
}
);
// ——— Authenticated client ———
app.get("/api/v1/client/me", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization);

View file

@ -28,6 +28,10 @@ const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const POLL_INTERVAL_SEC = 2;
/** Max titels in Recent-/4K-catalogi (niet de hele bibliotheek). */
const RECENT_CATALOG_CAP = 120;
/** Foute wachtwoordpogingen vóór tijdelijke lock. */
const LOGIN_MAX_FAILURES = 3;
/** Lock-duur na te veel foute logins. */
const LOGIN_LOCK_MS = 30 * 60 * 1000;
export type AuthedViewer = {
viewerId: string;
@ -147,7 +151,18 @@ export class ViewerService {
},
},
});
return rows.map((v) => ({
const emails = rows.map((v) => v.email.toLowerCase());
const locks = emails.length
? await prisma.viewerLoginLock.findMany({ where: { email: { in: emails } } })
: [];
const lockByEmail = new Map(locks.map((l) => [l.email, l]));
const now = Date.now();
return rows.map((v) => {
const lock = lockByEmail.get(v.email.toLowerCase());
const lockedUntil = lock?.lockedUntil ?? null;
const loginLocked = !!(lockedUntil && lockedUntil.getTime() > now);
return {
id: v.id,
email: v.email,
name: v.name,
@ -187,7 +202,11 @@ export class ViewerService {
createdAt: v.createdAt,
deviceCount: v._count.devices,
devices: v.devices,
}));
loginFailedCount: lock?.failedCount ?? 0,
loginLocked,
loginLockedUntil: loginLocked ? lockedUntil : null,
};
});
}
async deleteViewer(id: string) {
@ -332,9 +351,21 @@ export class ViewerService {
code: string;
}) {
const email = opts.email.trim().toLowerCase();
await this.assertLoginAllowed(email);
const viewer = await prisma.viewerUser.findUnique({ where: { email } });
if (!viewer || !(await verifyPassword(viewer.passwordHash, opts.password))) {
throw new AppError("INVALID_CREDENTIALS", "Ongeldige login", 401);
const passwordOk =
!!viewer && (await verifyPassword(viewer.passwordHash, opts.password));
if (!viewer || !passwordOk) {
const lock = await this.recordFailedLogin(email);
const remaining = Math.max(0, LOGIN_MAX_FAILURES - lock.failedCount);
throw new AppError(
"INVALID_CREDENTIALS",
remaining > 0
? `Ongeldige login (${remaining} poging${remaining === 1 ? "" : "en"} over)`
: "Account tijdelijk geblokkeerd na te veel foute pogingen",
401
);
}
if (!viewer.enabled || !viewer.appAccess) {
throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403);
@ -358,6 +389,7 @@ export class ViewerService {
claimTokenHash: hashToken(claimToken),
},
});
await this.clearLoginLock(email);
return {
ok: true,
@ -366,6 +398,98 @@ export class ViewerService {
};
}
/**
* Directe app-login met e-mail/wachtwoord (zonder TV-code).
* Maakt een ViewerDevice + refresh token aan.
*/
async loginWithPassword(opts: {
email: string;
password: string;
deviceName?: string;
platform?: string;
}) {
const email = opts.email.trim().toLowerCase();
await this.assertLoginAllowed(email);
const viewer = await prisma.viewerUser.findUnique({ where: { email } });
const passwordOk =
!!viewer && (await verifyPassword(viewer.passwordHash, opts.password));
if (!viewer || !passwordOk) {
const lock = await this.recordFailedLogin(email);
const remaining = Math.max(0, LOGIN_MAX_FAILURES - lock.failedCount);
throw new AppError(
"INVALID_CREDENTIALS",
remaining > 0
? `Ongeldige login (${remaining} poging${remaining === 1 ? "" : "en"} over)`
: "Account tijdelijk geblokkeerd na te veel foute pogingen",
401
);
}
if (!viewer.enabled || !viewer.appAccess) {
throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403);
}
const refreshToken = generateSecureToken(32);
const device = await prisma.viewerDevice.create({
data: {
viewerUserId: viewer.id,
name: (opts.deviceName || "App").trim().slice(0, 80) || "App",
platform: (opts.platform || "unknown").trim().slice(0, 40) || "unknown",
refreshTokenHash: hashToken(refreshToken),
lastSeenAt: new Date(),
},
});
await this.clearLoginLock(email);
await ensureOwnerProfile(viewer.id, viewer.name);
return {
refreshToken,
device: { id: device.id, name: device.name, platform: device.platform },
viewer: { id: viewer.id, email: viewer.email, name: viewer.name },
};
}
async unlockViewerLogin(viewerId: string) {
const viewer = await prisma.viewerUser.findUnique({ where: { id: viewerId } });
if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404);
await this.clearLoginLock(viewer.email.toLowerCase());
return { ok: true, email: viewer.email };
}
private async assertLoginAllowed(email: string) {
const lock = await prisma.viewerLoginLock.findUnique({ where: { email } });
if (lock?.lockedUntil && lock.lockedUntil.getTime() > Date.now()) {
const mins = Math.ceil((lock.lockedUntil.getTime() - Date.now()) / 60_000);
throw new AppError(
"LOGIN_LOCKED",
`Te veel foute pogingen. Probeer over ${mins} minuut${mins === 1 ? "" : "en"} opnieuw, of vraag de beheerder om te ontgrendelen.`,
429
);
}
}
private async recordFailedLogin(email: string) {
const existing = await prisma.viewerLoginLock.findUnique({ where: { email } });
const now = Date.now();
// Verlopen lock → teller opnieuw beginnen
const baseCount =
existing?.lockedUntil && existing.lockedUntil.getTime() <= now
? 0
: existing?.failedCount ?? 0;
const failedCount = baseCount + 1;
const lockedUntil =
failedCount >= LOGIN_MAX_FAILURES ? new Date(now + LOGIN_LOCK_MS) : null;
return prisma.viewerLoginLock.upsert({
where: { email },
create: { email, failedCount, lockedUntil },
update: { failedCount, lockedUntil },
});
}
private async clearLoginLock(email: string) {
await prisma.viewerLoginLock.deleteMany({ where: { email } });
}
async authFromBearer(authHeader?: string): Promise<AuthedViewer> {
if (!authHeader?.startsWith("Bearer ")) {
throw new AppError("UNAUTHORIZED", "Bearer token verplicht", 401);