feat(profiles): multi-user profiles with PIN switching
A shared device can hold several accounts from the same server and switch between them in a couple of taps. A profile can be locked behind a 4-8 digit PIN; one without a PIN is one tap away. Forgetting a PIN falls through to the account's own Jellyfin password, so there is no reset flow and no recovery secret to store. Opt-in by construction: a single account with no PIN starts, plays and downloads exactly as before, and never sees a picker. Two decisions worth keeping: - Switching is not logging out. auth_logout invalidates the token server-side, which is precisely what a switch must not do, or every switch back would cost a password. The switch runs as a plan (profiles/switch.rs) so the teardown *ordering* is unit-testable with no player and no server -- a straggler reporting after the active user flips would attribute one account's viewing to another, silently. - The PIN gates switching, not the token at rest. Wrapping each token with its PIN would leave a locked profile unable to resume its own downloads or drain its own sync queue until somebody typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. auth_initialize does refuse to restore a PIN-protected session, so the gate is on the session rather than on which screen is shown. "Child account" is not modelled anywhere -- a child's profile is simply one with no PIN. The frontend renders an opaque unlockMethod and never compares a PIN, counts an attempt or infers a role. Migration 024 adds user_pins, user_item_visibility, user_libraries and download_grants, and backfills the existing user so an upgrade does not blank its library. The visibility and grant tables are the schema half of the cache-scoping and shared-download work; the read-path enforcement is still to come (see docs/specs/multi-user-profiles.md).
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
<!--
|
||||
"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 { profiles } from "$lib/stores/profiles";
|
||||
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(() => {
|
||||
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">
|
||||
{#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>
|
||||
Reference in New Issue
Block a user