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