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.
452 lines
15 KiB
Svelte
452 lines
15 KiB
Svelte
<!--
|
||
"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 (4–8 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>
|