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:
parent
00fa9cd9c7
commit
303383ea4e
21 changed files with 1614 additions and 72 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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("link") {
|
||||
LinkScreen(api, store) {
|
||||
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("link") { inclusive = true } }
|
||||
nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
|
||||
},
|
||||
)
|
||||
}
|
||||
composable("link") {
|
||||
LinkScreen(
|
||||
api = api,
|
||||
store = store,
|
||||
onChangeServer = {
|
||||
nav.navigate("server") {
|
||||
popUpTo("server") { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
) {
|
||||
authed = true
|
||||
hasProfile = false
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
val key = "$catalogId|$sort"
|
||||
if (loadedKey != key || items.isEmpty()) {
|
||||
load(reset = true, jumpLetter = null)
|
||||
gridState.scrollToItem(0)
|
||||
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,29 +403,36 @@ 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),
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
letter,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
|
||||
color = if (isSelected) McColors.Brand else Color(0xCCFFFFFF),
|
||||
fontSize = 11.sp,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
||||
lineHeight = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("link") {
|
||||
LinkScreen(api, store) {
|
||||
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("link") { inclusive = true } }
|
||||
nav.navigate("profiles") { popUpTo("server") { inclusive = true } }
|
||||
},
|
||||
)
|
||||
}
|
||||
composable("link") {
|
||||
LinkScreen(
|
||||
api = api,
|
||||
store = store,
|
||||
onChangeServer = {
|
||||
nav.navigate("server") {
|
||||
popUpTo("server") { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
) {
|
||||
authed = true
|
||||
hasProfile = false
|
||||
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,28 +326,55 @@ fun McNav() {
|
|||
},
|
||||
)
|
||||
}
|
||||
composable("movie/{id}", arguments = listOf(navArgument("id") { type = NavType.StringType })) { entry ->
|
||||
val id = entry.arguments?.getString("id") ?: return@composable
|
||||
// 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")
|
||||
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
|
||||
}
|
||||
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")
|
||||
nav.navigate("player/$mediaFileId/EPISODE/-/$episodeId/$id/0") {
|
||||
popUpTo("series/$id") { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(
|
||||
"player/{mediaFileId}/{type}/{movieId}/{episodeId}/{seriesId}/{startMs}",
|
||||
arguments = listOf(
|
||||
|
|
@ -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
|
||||
// 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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
224
apps/ios/VMC/UI/ServerAuthScreens.swift
Normal file
224
apps/ios/VMC/UI/ServerAuthScreens.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue