From da762da55d139549ec75b3f6570850409f4d8a23 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 30 Aug 2026 19:03:59 +0200 Subject: [PATCH] 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). --- docs/requirements.md | 17 + docs/specs/multi-user-profiles.md | 405 +++++++++++++++++++++ src-tauri/Cargo.lock | 48 +++ src-tauri/Cargo.toml | 8 +- src-tauri/src/commands/auth.rs | 33 ++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/profiles.rs | 544 +++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 19 + src-tauri/src/profiles/mod.rs | 197 +++++++++++ src-tauri/src/profiles/pin.rs | 290 +++++++++++++++ src-tauri/src/profiles/store.rs | 206 +++++++++++ src-tauri/src/profiles/switch.rs | 225 ++++++++++++ src-tauri/src/storage/schema.rs | 248 +++++++++++++ src/lib/api/bindings.ts | 174 +++++++++ src/lib/components/PinPad.svelte | 118 +++++++ src/lib/stores/auth.ts | 61 ++++ src/lib/stores/profiles.ts | 128 +++++++ src/lib/utils/profileTiles.test.ts | 98 ++++++ src/lib/utils/profileTiles.ts | 76 ++++ src/routes/+page.svelte | 29 +- src/routes/profiles/+page.svelte | 420 ++++++++++++++++++++++ src/routes/settings/+page.svelte | 51 +++ 22 files changed, 3392 insertions(+), 5 deletions(-) create mode 100644 docs/specs/multi-user-profiles.md create mode 100644 src-tauri/src/commands/profiles.rs create mode 100644 src-tauri/src/profiles/mod.rs create mode 100644 src-tauri/src/profiles/pin.rs create mode 100644 src-tauri/src/profiles/store.rs create mode 100644 src-tauri/src/profiles/switch.rs create mode 100644 src/lib/components/PinPad.svelte create mode 100644 src/lib/stores/profiles.ts create mode 100644 src/lib/utils/profileTiles.test.ts create mode 100644 src/lib/utils/profileTiles.ts create mode 100644 src/routes/profiles/+page.svelte diff --git a/docs/requirements.md b/docs/requirements.md index 7a990efa..d5ff2b5f 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -91,6 +91,9 @@ For a narrative overview of the system design, see | UR-079 | The app decides *what stream to play* and says so. Playing a video used to mean asking the server to re-encode it, always — a decision made nowhere, written down nowhere, and re-derived downstream by whoever needed it: the player worked out whether it had been handed a playlist by looking for `.m3u8` in the URL. So a viewer paid for a transcode of a file their device could have played untouched, and the app could not tell them which it was. Now one negotiation produces one self-describing answer — direct play, remux, or transcode; over a playlist, a plain HTTP file, or a local one — and every renderer consumes that same answer instead of guessing from a string. On Android, where the player decodes almost everything the library holds, this stops around 85% of plays from starting a transcode nobody needed | Medium | Done | | UR-080 | Video on the desktop plays as itself. The picture was drawn by a webview `