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:
2026-08-30 19:03:59 +02:00
parent 8a04a6fad0
commit da762da55d
22 changed files with 3392 additions and 5 deletions
+25 -4
View File
@@ -3,6 +3,7 @@
import { goto } from "$app/navigation";
import { platform } from "@tauri-apps/plugin-os";
import { auth, isAuthenticated } from "$lib/stores/auth";
import { profiles } from "$lib/stores/profiles";
import { home } from "$lib/stores/home";
import { library, libraries } from "$lib/stores/library";
import { isServerReachable } from "$lib/stores/connectivity";
@@ -29,11 +30,31 @@
let previousServerReachable = false;
let isAndroid = $state(false);
// Redirect to login if not authenticated
// Where an unauthenticated app goes depends on what this device holds. A
// single account with no PIN goes straight to login exactly as before; a
// device with several profiles, or one whose last profile is PIN-protected,
// goes to the picker instead. The decision is the backend's — the frontend
// asks rather than counting profiles itself, because it also turns on a stored
// setting and on which profiles have a PIN. (DR-274)
let routingAway = false;
$effect(() => {
if (!$isAuthenticated) {
goto("/login");
}
if ($isAuthenticated || routingAway) return;
routingAway = true;
void (async () => {
try {
const target = await profiles.startupTarget();
const found = await profiles.refresh();
// The picker is only a picker when there is something to pick. With no
// profiles stored it would render an empty room, so first run still
// goes to login.
await goto(target.type === "picker" && found.length > 0 ? "/profiles" : "/login");
} catch (error) {
log.error("Could not resolve startup target:", error);
await goto("/login");
} finally {
routingAway = false;
}
})();
});
// Load home sections when authenticated
+420
View File
@@ -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 (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>
+51
View File
@@ -1,7 +1,9 @@
<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057, UR-076 | DR-030, DR-048, DR-077, DR-086, DR-132, DR-209 -->
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings";
import { profiles } from "$lib/stores/profiles";
import type {
AudioSettings,
CacheConfig,
@@ -147,8 +149,13 @@
// Promise and Svelte would never invoke it as a teardown.
onDestroy(unsubscribeNativeVideo);
// Mirrors the stored setting; the picker itself always appears for a
// PIN-protected profile regardless of this. (DR-274)
let askOnStart = $state(false);
onMount(async () => {
await loadSettings();
askOnStart = await commands.profilesGetAskOnStart();
supportsNativeVideo = (await getPlaybackCapabilities()).supportsNativeVideo;
// Which update story this platform gets. Android cannot install its own
@@ -985,6 +992,50 @@
{/if}
</div>
<!-- Profiles. Deliberately minimal here: adding, removing and PIN changes
all live on the picker itself, where the tiles are. What belongs in
settings is the one device-wide preference and a way in.
TRACES: UR-082, UR-083 | DR-274, DR-276 -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-2xl font-bold text-white mb-4">Profiles</h2>
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-5">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-white mb-1">Ask who's watching</h3>
<p class="text-sm text-gray-400">
Show the profile picker when the app starts. A profile with a PIN always asks,
whatever this is set to.
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer shrink-0">
<input
type="checkbox"
bind:checked={askOnStart}
onchange={() => profiles.setAskOnStart(askOnStart)}
class="sr-only peer"
/>
<div
class="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--color-jellyfin)]"
></div>
</label>
</div>
<div class="border-t border-gray-700 pt-5">
<p class="text-sm text-gray-400 mb-3">
A PIN controls who can switch to an account on this device. What each account is
allowed to watch is set on the Jellyfin server, not here.
</p>
<button
onclick={() => goto("/profiles")}
class="px-4 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium transition-colors"
>
Switch or manage profiles
</button>
</div>
</div>
</div>
<!-- Search Settings -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-2xl font-bold text-white mb-4">Search</h2>