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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user