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
+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>