Fix profile PIN verification and restore TV tab underline style.

verifyPassword args were swapped after the avatar rewrite, causing argon2 to throw on PIN select.
This commit is contained in:
Jos Vooges | STH 2026-09-13 03:12:32 +02:00
parent e4aba1950b
commit 82742a1ea3
3 changed files with 76 additions and 60 deletions

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 105 versionCode = 106
versionName = "0.14.42" versionName = "0.14.43"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -8,13 +8,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -54,15 +52,16 @@ import androidx.compose.ui.layout.ContentScale
/** /**
* Berekende hoogte van de Home overlay-topnav (geen subtabs). * Berekende hoogte van de Home overlay-topnav (geen subtabs).
* *
* TopNavBar overlay-row (compact, zonder underline): * TopNavBar overlay-row:
* - vertical padding: 10.dp + 10.dp * - vertical padding: 14.dp + 14.dp
* - tab-tekst ~21.dp → veilig 56.dp * - tab-inhoud: 8.dp + ~21.dp tekst + 4.dp + 3.dp underline + 8.dp ≈ 44.dp
* - totaal ≈ 72.dp; TV Surface min. interactive ≈ 48.dp → veilig 76.dp
*/ */
object HomeChrome { object HomeChrome {
val TopNavHeight = 56.dp val TopNavHeight = 76.dp
/** Lucht tussen onderkant menu en badge. */ /** Lucht tussen onderkant menu en badge. */
val BadgeGap = 12.dp val BadgeGap = 12.dp
val BadgeTop = TopNavHeight + BadgeGap val BadgeTop = TopNavHeight + BadgeGap // 88.dp
/** Horizontale schermmarge (links = rechts). */ /** Horizontale schermmarge (links = rechts). */
val EdgeInset = 22.dp val EdgeInset = 22.dp
} }
@ -119,7 +118,7 @@ fun TopNavBar(
compact -> 0.42f compact -> 0.42f
else -> 1f else -> 1f
} }
val vPad = if (compact && !overlay) 6.dp else 10.dp val vPad = if (compact && !overlay) 8.dp else 14.dp
val downLink = Modifier.then( val downLink = Modifier.then(
if (contentDownFocus != null) { if (contentDownFocus != null) {
Modifier.focusProperties { down = contentDownFocus } Modifier.focusProperties { down = contentDownFocus }
@ -178,16 +177,13 @@ fun TopNavBar(
color = McColors.Brand, color = McColors.Brand,
fontSize = if (compact && !overlay) 22.sp else 28.sp, fontSize = if (compact && !overlay) 22.sp else 28.sp,
fontWeight = FontWeight.Black, fontWeight = FontWeight.Black,
modifier = Modifier modifier = Modifier.padding(end = 16.dp),
.height(if (compact && !overlay) 32.dp else 36.dp)
.padding(end = 12.dp)
.wrapContentHeight(Alignment.CenterVertically),
) )
textTabs.forEach { tab -> textTabs.forEach { tab ->
val active = selected == tab val active = selected == tab
val color by animateColorAsState( val color by animateColorAsState(
when { when {
active -> McColors.Brand active -> Color.White
overlay -> Color(0xFFE8E8E8) overlay -> Color(0xFFE8E8E8)
else -> Color(0xFF808080) else -> Color(0xFF808080)
}, },
@ -196,24 +192,21 @@ fun TopNavBar(
val fr = tabFocus[tab] val fr = tabFocus[tab]
Surface( Surface(
onClick = { onSelect(tab) }, onClick = { onSelect(tab) },
modifier = downLink modifier = downLink.then(
.height(if (compact && !overlay) 32.dp else 36.dp) if (fr != null) Modifier.focusRequester(fr) else Modifier,
.then(if (fr != null) Modifier.focusRequester(fr) else Modifier), ),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent, containerColor = if (active) Color(0x33FFFFFF) else Color.Transparent,
focusedContainerColor = Color(0x33FFFFFF), focusedContainerColor = Color(0x44FFFFFF),
contentColor = color, contentColor = color,
// Actieve tab blijft rood, ook met focus focusedContentColor = Color.White,
focusedContentColor = if (active) McColors.Brand else Color.White,
), ),
) { ) {
Box( Column(
Modifier Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
.fillMaxHeight() horizontalAlignment = Alignment.CenterHorizontally,
.padding(horizontal = 8.dp),
contentAlignment = Alignment.Center,
) { ) {
Text( Text(
tab.label, tab.label,
@ -221,6 +214,14 @@ fun TopNavBar(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = if (active) FontWeight.Bold else FontWeight.Medium, fontWeight = if (active) FontWeight.Bold else FontWeight.Medium,
) )
Spacer(Modifier.height(4.dp))
Box(
Modifier
.width(if (active) 28.dp else 0.dp)
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(if (active) McColors.Brand else Color.Transparent),
)
} }
} }
} }
@ -228,29 +229,33 @@ fun TopNavBar(
val searchActive = selected == MainTab.Search val searchActive = selected == MainTab.Search
Surface( Surface(
onClick = onSearchClick, onClick = onSearchClick,
modifier = downLink modifier = downLink.focusRequester(searchFocus),
.height(if (compact && !overlay) 32.dp else 36.dp)
.focusRequester(searchFocus),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent, containerColor = if (searchActive) Color(0x33FFFFFF) else Color.Transparent,
focusedContainerColor = Color(0x33FFFFFF), focusedContainerColor = Color(0x44FFFFFF),
contentColor = if (searchActive) McColors.Brand else Color.White, contentColor = Color.White,
focusedContentColor = if (searchActive) McColors.Brand else Color.White, focusedContentColor = Color.White,
), ),
) { ) {
Box( Column(
Modifier Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
.fillMaxHeight() horizontalAlignment = Alignment.CenterHorizontally,
.padding(horizontal = 8.dp),
contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Search, imageVector = Icons.Filled.Search,
contentDescription = "Zoeken", contentDescription = "Zoeken",
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = Color.Unspecified, tint = Color.White,
)
Spacer(Modifier.height(4.dp))
Box(
Modifier
.width(if (searchActive) 18.dp else 0.dp)
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(if (searchActive) McColors.Brand else Color.Transparent),
) )
} }
} }
@ -259,29 +264,33 @@ fun TopNavBar(
val settingsActive = selected == MainTab.Settings val settingsActive = selected == MainTab.Settings
Surface( Surface(
onClick = onSettingsClick, onClick = onSettingsClick,
modifier = downLink modifier = downLink.focusRequester(settingsFocus),
.height(if (compact && !overlay) 32.dp else 36.dp)
.focusRequester(settingsFocus),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent, containerColor = if (settingsActive) Color(0x33FFFFFF) else Color.Transparent,
focusedContainerColor = Color(0x33FFFFFF), focusedContainerColor = Color(0x44FFFFFF),
contentColor = if (settingsActive) McColors.Brand else Color.White, contentColor = Color.White,
focusedContentColor = if (settingsActive) McColors.Brand else Color.White, focusedContentColor = Color.White,
), ),
) { ) {
Box( Column(
Modifier Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
.fillMaxHeight() horizontalAlignment = Alignment.CenterHorizontally,
.padding(horizontal = 8.dp),
contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Settings, imageVector = Icons.Filled.Settings,
contentDescription = "Instellingen", contentDescription = "Instellingen",
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = Color.Unspecified, tint = Color.White,
)
Spacer(Modifier.height(4.dp))
Box(
Modifier
.width(if (settingsActive) 18.dp else 0.dp)
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(if (settingsActive) McColors.Brand else Color.Transparent),
) )
} }
} }

View file

@ -388,14 +388,21 @@ export class ViewerProfileService {
let bucket = pinAttempts.get(key); let bucket = pinAttempts.get(key);
if (!bucket || bucket.resetAt < now) { if (!bucket || bucket.resetAt < now) {
bucket = { count: 0, resetAt: now + PIN_ATTEMPT_WINDOW_MS }; bucket = { count: 0, resetAt: now + PIN_ATTEMPT_WINDOW_MS };
pinAttempts.set(key, bucket);
} }
if (bucket.count >= PIN_MAX_ATTEMPTS) { if (bucket.count >= PIN_MAX_ATTEMPTS) {
throw new AppError("RATE_LIMIT", "Te veel PIN-pogingen, probeer later", 429); throw new AppError(
"RATE_LIMITED",
"Te veel PIN-pogingen. Probeer later opnieuw.",
429
);
} }
const ok = pin ? await verifyPassword(String(pin), profile.pinHash) : false;
const ok = pin
? await verifyPassword(profile.pinHash, String(pin).replace(/\D/g, ""))
: false;
if (!ok) { if (!ok) {
bucket.count += 1; bucket.count += 1;
pinAttempts.set(key, bucket);
throw new AppError("FORBIDDEN", "Onjuiste PIN", 403); throw new AppError("FORBIDDEN", "Onjuiste PIN", 403);
} }
pinAttempts.delete(key); pinAttempts.delete(key);
@ -403,7 +410,7 @@ export class ViewerProfileService {
await prisma.viewerDevice.update({ await prisma.viewerDevice.update({
where: { id: deviceId }, where: { id: deviceId },
data: { activeProfileId: profileId, lastSeenAt: new Date() }, data: { activeProfileId: profile.id, lastSeenAt: new Date() },
}); });
return { profile: publicProfile(profile) }; return { profile: publicProfile(profile) };
} }