Files
jellytau/src/routes/profiles/+page.svelte
T
dtourolle 27a995f877 feat(profiles): manage PINs from settings
Settings had only an ask-on-start toggle and a button that bounced to the
picker, so there was nowhere to actually turn a PIN on or off -- the one
part of this feature that genuinely is configuration rather than a
front-door action.

Scoped to the active profile on purpose. Letting a signed-in session set
a PIN on someone else's profile is an escalation path with no real use:
a PIN-less profile could be locked by whoever is standing at the device,
and its owner pushed down the password route to get back into their own
account. Other profiles are listed read-only so a parent can see which
are protected; adding and removing them stays on the picker, which
settings now deep-links into with ?manage=1 rather than growing a second
copy of the tile list.

Form validation is extracted to pinForm.ts and unit-tested -- shape only,
so the form can say what is wrong before submitting. Rust validates again
and remains the only thing that ever compares a PIN.
2026-08-30 19:42:35 +02:00

452 lines
15 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
"Who's watching" — the profile picker.
Everything here is presentation. Which profiles exist, whether one is locked,
whether a code was right and how many guesses are left all arrive from Rust;
this page renders them. In particular there is no notion of an adult or a child
account anywhere in this file — a child's profile is simply one whose
`unlockMethod` is "none", which is one tap.
TRACES: UR-082, UR-083, UR-084 | DR-276
-->
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { profiles } from "$lib/stores/profiles";
import { isAuthenticated } from "$lib/stores/auth";
import { navigateBack } from "$lib/utils/navigation";
import type { Profile, UnlockOutcome } from "$lib/api/bindings";
import PinPad from "$lib/components/PinPad.svelte";
import { orderProfiles, initialsFor, tileColour, lockoutMessage } from "$lib/utils/profileTiles";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("ProfilePicker");
type Mode = "picker" | "pin" | "password" | "add" | "manage";
let mode = $state<Mode>("picker");
let selected = $state<Profile | null>(null);
let pin = $state("");
let password = $state("");
let entryError = $state<string | null>(null);
let busy = $state(false);
// Add-profile form
let newUsername = $state("");
let newPassword = $state("");
let newPin = $state("");
let usePinForNew = $state(false);
const ordered = $derived(orderProfiles($profiles.profiles));
onMount(() => {
// Settings deep-links here for add/remove rather than duplicating the tile
// grid: the tiles are the natural place to act on a profile, and two copies
// of that list would drift.
if ($page.url.searchParams.get("manage") === "1") mode = "manage";
void profiles.refresh();
});
function reset() {
mode = "picker";
selected = null;
pin = "";
password = "";
entryError = null;
newUsername = "";
newPassword = "";
newPin = "";
usePinForNew = false;
}
function renderOutcome(outcome: UnlockOutcome): boolean {
switch (outcome.type) {
case "ok":
return true;
case "wrongPin":
pin = "";
entryError =
outcome.attemptsRemaining === 1
? "Wrong PIN. One more try before this profile locks."
: `Wrong PIN. ${outcome.attemptsRemaining} tries left.`;
return false;
case "lockedOut":
pin = "";
entryError = `Too many wrong PINs. ${lockoutMessage(outcome.until)}`;
return false;
case "needsPassword":
mode = "password";
entryError = "Sign in with your password to continue.";
return false;
}
}
async function choose(profile: Profile) {
if (profile.unlockMethod === "pin") {
selected = profile;
pin = "";
entryError = null;
mode = "pin";
return;
}
await enter(profile, null);
}
async function enter(profile: Profile, code: string | null) {
busy = true;
entryError = null;
try {
const outcome = await profiles.unlock(profile.userId, code);
if (renderOutcome(outcome)) {
reset();
await goto("/");
}
} catch (error) {
log.error("Unlock failed:", error);
entryError = error instanceof Error ? error.message : "Could not switch profile";
} finally {
busy = false;
}
}
async function enterWithPassword(event: Event) {
event.preventDefault();
if (!selected) return;
busy = true;
entryError = null;
try {
const outcome = await profiles.unlockWithPassword(selected.userId, password);
if (renderOutcome(outcome)) {
reset();
await goto("/");
}
} catch (error) {
log.error("Password unlock failed:", error);
entryError = error instanceof Error ? error.message : "Sign-in failed";
} finally {
busy = false;
}
}
async function addProfile(event: Event) {
event.preventDefault();
busy = true;
entryError = null;
try {
await profiles.add(newUsername, newPassword, usePinForNew ? newPin : null);
reset();
} catch (error) {
log.error("Add profile failed:", error);
entryError = error instanceof Error ? error.message : "Could not add profile";
} finally {
busy = false;
}
}
async function removeProfile(profile: Profile) {
busy = true;
entryError = null;
try {
await profiles.remove(profile.userId);
} catch (error) {
entryError = error instanceof Error ? error.message : "Could not remove profile";
} finally {
busy = false;
}
}
</script>
<div class="min-h-full flex items-center justify-center p-6">
<div class="w-full max-w-3xl">
<!--
Reached from the account menu, the picker must not be a one-way door: a
signed-in viewer who opens it and changes their mind (or fails a PIN on
someone else's tile) needs a way back to the session they still have. At
startup there is no session behind it, so there is nothing to go back to
and this stays hidden. (DR-276)
-->
{#if $isAuthenticated && mode !== "pin" && mode !== "password"}
<button
type="button"
onclick={() => navigateBack("/")}
class="mb-6 text-sm text-gray-400 hover:text-white flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
Back
</button>
{/if}
{#if mode === "picker" || mode === "manage"}
<h1 class="text-3xl font-semibold text-center mb-2">
{mode === "manage" ? "Manage profiles" : "Who's watching?"}
</h1>
<p class="text-gray-400 text-center mb-10 text-sm">
{#if mode === "manage"}
Removing a profile only affects this device. It does not sign that person out elsewhere.
{:else}
Everyone here signs in to the same server.
{/if}
</p>
{#if $profiles.error}
<div class="mb-6 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
{$profiles.error}
</div>
{/if}
{#if entryError}
<div class="mb-6 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
{entryError}
</div>
{/if}
<div class="flex flex-wrap justify-center gap-8">
{#each ordered as profile (profile.userId)}
<div class="flex flex-col items-center gap-3">
<button
type="button"
onclick={() => (mode === "manage" ? undefined : choose(profile))}
disabled={busy || mode === "manage"}
class="relative w-28 h-28 rounded-2xl flex items-center justify-center text-3xl font-semibold text-white/90 transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-white disabled:hover:scale-100"
style="background-color: {tileColour(profile.userId)}"
aria-label="Switch to {profile.username}"
>
{initialsFor(profile.username)}
{#if profile.unlockMethod === "pin"}
<span
class="absolute bottom-2 right-2 bg-black/50 rounded-full p-1.5"
aria-label="PIN required"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</span>
{/if}
</button>
<span class="text-sm text-gray-300">{profile.username}</span>
{#if mode === "manage" && !profile.isActive}
<button
type="button"
onclick={() => removeProfile(profile)}
disabled={busy}
class="text-xs text-red-400 hover:text-red-300"
>
Remove
</button>
{:else if mode === "manage"}
<span class="text-xs text-gray-500">In use</span>
{/if}
</div>
{/each}
{#if mode === "picker"}
<div class="flex flex-col items-center gap-3">
<button
type="button"
onclick={() => {
entryError = null;
mode = "add";
}}
class="w-28 h-28 rounded-2xl border-2 border-dashed border-gray-600 hover:border-gray-400 flex items-center justify-center text-gray-500 hover:text-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-white"
aria-label="Add a profile"
>
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
<span class="text-sm text-gray-500">Add profile</span>
</div>
{/if}
</div>
<div class="text-center mt-12">
<button
type="button"
onclick={() => {
entryError = null;
mode = mode === "manage" ? "picker" : "manage";
}}
class="text-sm text-gray-400 hover:text-white"
>
{mode === "manage" ? "Done" : "Manage profiles"}
</button>
</div>
{:else if mode === "pin" && selected}
<div class="flex flex-col items-center gap-8">
<div class="flex flex-col items-center gap-3">
<div
class="w-20 h-20 rounded-2xl flex items-center justify-center text-2xl font-semibold text-white/90"
style="background-color: {tileColour(selected.userId)}"
>
{initialsFor(selected.username)}
</div>
<h1 class="text-xl font-medium">{selected.username}</h1>
</div>
<PinPad
bind:value={pin}
error={entryError}
disabled={busy}
onsubmit={(code) => selected && enter(selected, code)}
oncancel={reset}
/>
<button
type="button"
onclick={() => {
entryError = null;
password = "";
mode = "password";
}}
class="text-sm text-gray-400 hover:text-white underline"
>
Forgot your PIN? Use your password
</button>
</div>
{:else if mode === "password" && selected}
<form onsubmit={enterWithPassword} class="max-w-sm mx-auto space-y-4">
<h1 class="text-2xl font-semibold text-center mb-6">
Sign in as {selected.username}
</h1>
<div>
<label for="profile-password" class="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<!-- svelte-ignore a11y_autofocus -->
<input
id="profile-password"
type="password"
bind:value={password}
autofocus
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
disabled={busy}
/>
</div>
{#if entryError}
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
{entryError}
</div>
{/if}
<div class="flex gap-3">
<button
type="button"
onclick={reset}
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)]"
>
Cancel
</button>
<button
type="submit"
disabled={busy || !password}
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 font-medium"
>
Sign in
</button>
</div>
</form>
{:else if mode === "add"}
<form onsubmit={addProfile} class="max-w-sm mx-auto space-y-4">
<h1 class="text-2xl font-semibold text-center mb-2">Add a profile</h1>
<p class="text-gray-400 text-sm text-center mb-6">
Another account on the server you are already connected to.
</p>
<div>
<label for="new-username" class="block text-sm font-medium text-gray-300 mb-2">
Username
</label>
<input
id="new-username"
type="text"
bind:value={newUsername}
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
disabled={busy}
/>
</div>
<div>
<label for="new-password" class="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<input
id="new-password"
type="password"
bind:value={newPassword}
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
disabled={busy}
/>
</div>
<label class="flex items-start gap-3 text-sm text-gray-300">
<input type="checkbox" bind:checked={usePinForNew} class="mt-1" disabled={busy} />
<span>
Protect this profile with a PIN
<span class="block text-gray-500 text-xs mt-1">
Leave this off for a child's profile so it opens with one tap. A PIN controls who can
switch to an account — what each account may watch is set on the Jellyfin server.
</span>
</span>
</label>
{#if usePinForNew}
<div>
<label for="new-pin" class="block text-sm font-medium text-gray-300 mb-2">
PIN (48 digits)
</label>
<input
id="new-pin"
type="password"
inputmode="numeric"
bind:value={newPin}
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
disabled={busy}
/>
</div>
{/if}
{#if entryError}
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
{entryError}
</div>
{/if}
<div class="flex gap-3">
<button
type="button"
onclick={reset}
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)]"
>
Cancel
</button>
<button
type="submit"
disabled={busy || !newUsername || (usePinForNew && newPin.length < 4)}
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 font-medium"
>
Add
</button>
</div>
</form>
{/if}
</div>
</div>