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:
@@ -0,0 +1,118 @@
|
||||
<!--
|
||||
Numeric PIN entry.
|
||||
|
||||
Presentation only: it collects digits and hands them up. It does not know the
|
||||
PIN, does not compare anything, and does not count attempts — the backend
|
||||
returns an `UnlockOutcome` and this renders whatever it says. A pad that could
|
||||
decide would be a lock the webview could pick.
|
||||
|
||||
Sized for a living room: large targets, usable with a remote's arrow keys as
|
||||
well as a touchscreen.
|
||||
|
||||
TRACES: UR-083 | DR-276
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
/** Digits entered so far. */
|
||||
value: string;
|
||||
/** Message under the dots — wrong PIN, lockout, etc. */
|
||||
error?: string | null;
|
||||
/** Blocks input while an attempt is in flight or the profile is locked out. */
|
||||
disabled?: boolean;
|
||||
maxLength?: number;
|
||||
onsubmit: (pin: string) => void;
|
||||
oncancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
error = null,
|
||||
disabled = false,
|
||||
maxLength = 8,
|
||||
onsubmit,
|
||||
oncancel,
|
||||
}: Props = $props();
|
||||
|
||||
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "⌫"];
|
||||
|
||||
function press(key: string) {
|
||||
if (disabled) return;
|
||||
if (key === "⌫") {
|
||||
value = value.slice(0, -1);
|
||||
} else if (key && value.length < maxLength) {
|
||||
value = value + key;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (disabled) return;
|
||||
if (/^[0-9]$/.test(event.key)) {
|
||||
event.preventDefault();
|
||||
press(event.key);
|
||||
} else if (event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
press("⌫");
|
||||
} else if (event.key === "Enter" && value.length >= 4) {
|
||||
event.preventDefault();
|
||||
onsubmit(value);
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
oncancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<!-- Entered digits, shown as dots. -->
|
||||
<div class="flex gap-3 h-4 items-center" aria-live="polite" aria-label="PIN entry">
|
||||
{#each Array(Math.max(value.length, 4)) as _, i (i)}
|
||||
<div
|
||||
class="rounded-full transition-all {i < value.length
|
||||
? 'w-3 h-3 bg-white'
|
||||
: 'w-3 h-3 bg-gray-600'}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400 text-sm text-center max-w-xs" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{#each KEYS as key (key)}
|
||||
{#if key === ""}
|
||||
<div></div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => press(key)}
|
||||
{disabled}
|
||||
class="w-18 h-18 min-w-[4.5rem] min-h-[4.5rem] rounded-full bg-[var(--color-surface)] hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-[var(--color-jellyfin)] disabled:opacity-40 disabled:cursor-not-allowed text-2xl font-light transition-colors"
|
||||
aria-label={key === "⌫" ? "Delete" : key}
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 w-full max-w-xs">
|
||||
<button
|
||||
type="button"
|
||||
onclick={oncancel}
|
||||
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onsubmit(value)}
|
||||
disabled={disabled || value.length < 4}
|
||||
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user