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:
+25
-4
@@ -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
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
<!--
|
||||
"Who's watching" — the profile picker.
|
||||
|
||||
Everything here is presentation. Which profiles exist, whether one is locked,
|
||||
whether a code was right and how many guesses are left all arrive from Rust;
|
||||
this page renders them. In particular there is no notion of an adult or a child
|
||||
account anywhere in this file — a child's profile is simply one whose
|
||||
`unlockMethod` is "none", which is one tap.
|
||||
|
||||
TRACES: UR-082, UR-083, UR-084 | DR-276
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { profiles } from "$lib/stores/profiles";
|
||||
import type { Profile, UnlockOutcome } from "$lib/api/bindings";
|
||||
import PinPad from "$lib/components/PinPad.svelte";
|
||||
import { orderProfiles, initialsFor, tileColour, lockoutMessage } from "$lib/utils/profileTiles";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("ProfilePicker");
|
||||
|
||||
type Mode = "picker" | "pin" | "password" | "add" | "manage";
|
||||
|
||||
let mode = $state<Mode>("picker");
|
||||
let selected = $state<Profile | null>(null);
|
||||
let pin = $state("");
|
||||
let password = $state("");
|
||||
let entryError = $state<string | null>(null);
|
||||
let busy = $state(false);
|
||||
|
||||
// Add-profile form
|
||||
let newUsername = $state("");
|
||||
let newPassword = $state("");
|
||||
let newPin = $state("");
|
||||
let usePinForNew = $state(false);
|
||||
|
||||
const ordered = $derived(orderProfiles($profiles.profiles));
|
||||
|
||||
onMount(() => {
|
||||
void profiles.refresh();
|
||||
});
|
||||
|
||||
function reset() {
|
||||
mode = "picker";
|
||||
selected = null;
|
||||
pin = "";
|
||||
password = "";
|
||||
entryError = null;
|
||||
newUsername = "";
|
||||
newPassword = "";
|
||||
newPin = "";
|
||||
usePinForNew = false;
|
||||
}
|
||||
|
||||
function renderOutcome(outcome: UnlockOutcome): boolean {
|
||||
switch (outcome.type) {
|
||||
case "ok":
|
||||
return true;
|
||||
case "wrongPin":
|
||||
pin = "";
|
||||
entryError =
|
||||
outcome.attemptsRemaining === 1
|
||||
? "Wrong PIN. One more try before this profile locks."
|
||||
: `Wrong PIN. ${outcome.attemptsRemaining} tries left.`;
|
||||
return false;
|
||||
case "lockedOut":
|
||||
pin = "";
|
||||
entryError = `Too many wrong PINs. ${lockoutMessage(outcome.until)}`;
|
||||
return false;
|
||||
case "needsPassword":
|
||||
mode = "password";
|
||||
entryError = "Sign in with your password to continue.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function choose(profile: Profile) {
|
||||
if (profile.unlockMethod === "pin") {
|
||||
selected = profile;
|
||||
pin = "";
|
||||
entryError = null;
|
||||
mode = "pin";
|
||||
return;
|
||||
}
|
||||
await enter(profile, null);
|
||||
}
|
||||
|
||||
async function enter(profile: Profile, code: string | null) {
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
const outcome = await profiles.unlock(profile.userId, code);
|
||||
if (renderOutcome(outcome)) {
|
||||
reset();
|
||||
await goto("/");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Unlock failed:", error);
|
||||
entryError = error instanceof Error ? error.message : "Could not switch profile";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enterWithPassword(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!selected) return;
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
const outcome = await profiles.unlockWithPassword(selected.userId, password);
|
||||
if (renderOutcome(outcome)) {
|
||||
reset();
|
||||
await goto("/");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Password unlock failed:", error);
|
||||
entryError = error instanceof Error ? error.message : "Sign-in failed";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addProfile(event: Event) {
|
||||
event.preventDefault();
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
await profiles.add(newUsername, newPassword, usePinForNew ? newPin : null);
|
||||
reset();
|
||||
} catch (error) {
|
||||
log.error("Add profile failed:", error);
|
||||
entryError = error instanceof Error ? error.message : "Could not add profile";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProfile(profile: Profile) {
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
await profiles.remove(profile.userId);
|
||||
} catch (error) {
|
||||
entryError = error instanceof Error ? error.message : "Could not remove profile";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-full flex items-center justify-center p-6">
|
||||
<div class="w-full max-w-3xl">
|
||||
{#if mode === "picker" || mode === "manage"}
|
||||
<h1 class="text-3xl font-semibold text-center mb-2">
|
||||
{mode === "manage" ? "Manage profiles" : "Who's watching?"}
|
||||
</h1>
|
||||
<p class="text-gray-400 text-center mb-10 text-sm">
|
||||
{#if mode === "manage"}
|
||||
Removing a profile only affects this device. It does not sign that person out elsewhere.
|
||||
{:else}
|
||||
Everyone here signs in to the same server.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if $profiles.error}
|
||||
<div class="mb-6 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{$profiles.error}
|
||||
</div>
|
||||
{/if}
|
||||
{#if entryError}
|
||||
<div class="mb-6 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{entryError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-8">
|
||||
{#each ordered as profile (profile.userId)}
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mode === "manage" ? undefined : choose(profile))}
|
||||
disabled={busy || mode === "manage"}
|
||||
class="relative w-28 h-28 rounded-2xl flex items-center justify-center text-3xl font-semibold text-white/90 transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-white disabled:hover:scale-100"
|
||||
style="background-color: {tileColour(profile.userId)}"
|
||||
aria-label="Switch to {profile.username}"
|
||||
>
|
||||
{initialsFor(profile.username)}
|
||||
{#if profile.unlockMethod === "pin"}
|
||||
<span
|
||||
class="absolute bottom-2 right-2 bg-black/50 rounded-full p-1.5"
|
||||
aria-label="PIN required"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<span class="text-sm text-gray-300">{profile.username}</span>
|
||||
{#if mode === "manage" && !profile.isActive}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeProfile(profile)}
|
||||
disabled={busy}
|
||||
class="text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
{:else if mode === "manage"}
|
||||
<span class="text-xs text-gray-500">In use</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if mode === "picker"}
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
entryError = null;
|
||||
mode = "add";
|
||||
}}
|
||||
class="w-28 h-28 rounded-2xl border-2 border-dashed border-gray-600 hover:border-gray-400 flex items-center justify-center text-gray-500 hover:text-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-white"
|
||||
aria-label="Add a profile"
|
||||
>
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-sm text-gray-500">Add profile</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-12">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
entryError = null;
|
||||
mode = mode === "manage" ? "picker" : "manage";
|
||||
}}
|
||||
class="text-sm text-gray-400 hover:text-white"
|
||||
>
|
||||
{mode === "manage" ? "Done" : "Manage profiles"}
|
||||
</button>
|
||||
</div>
|
||||
{:else if mode === "pin" && selected}
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div
|
||||
class="w-20 h-20 rounded-2xl flex items-center justify-center text-2xl font-semibold text-white/90"
|
||||
style="background-color: {tileColour(selected.userId)}"
|
||||
>
|
||||
{initialsFor(selected.username)}
|
||||
</div>
|
||||
<h1 class="text-xl font-medium">{selected.username}</h1>
|
||||
</div>
|
||||
|
||||
<PinPad
|
||||
bind:value={pin}
|
||||
error={entryError}
|
||||
disabled={busy}
|
||||
onsubmit={(code) => selected && enter(selected, code)}
|
||||
oncancel={reset}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
entryError = null;
|
||||
password = "";
|
||||
mode = "password";
|
||||
}}
|
||||
class="text-sm text-gray-400 hover:text-white underline"
|
||||
>
|
||||
Forgot your PIN? Use your password
|
||||
</button>
|
||||
</div>
|
||||
{:else if mode === "password" && selected}
|
||||
<form onsubmit={enterWithPassword} class="max-w-sm mx-auto space-y-4">
|
||||
<h1 class="text-2xl font-semibold text-center mb-6">
|
||||
Sign in as {selected.username}
|
||||
</h1>
|
||||
|
||||
<div>
|
||||
<label for="profile-password" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
id="profile-password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
autofocus
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if entryError}
|
||||
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{entryError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={reset}
|
||||
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !password}
|
||||
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 font-medium"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else if mode === "add"}
|
||||
<form onsubmit={addProfile} class="max-w-sm mx-auto space-y-4">
|
||||
<h1 class="text-2xl font-semibold text-center mb-2">Add a profile</h1>
|
||||
<p class="text-gray-400 text-sm text-center mb-6">
|
||||
Another account on the server you are already connected to.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label for="new-username" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="new-username"
|
||||
type="text"
|
||||
bind:value={newUsername}
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-3 text-sm text-gray-300">
|
||||
<input type="checkbox" bind:checked={usePinForNew} class="mt-1" disabled={busy} />
|
||||
<span>
|
||||
Protect this profile with a PIN
|
||||
<span class="block text-gray-500 text-xs mt-1">
|
||||
Leave this off for a child's profile so it opens with one tap. A PIN controls who can
|
||||
switch to an account — what each account may watch is set on the Jellyfin server.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{#if usePinForNew}
|
||||
<div>
|
||||
<label for="new-pin" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
PIN (4–8 digits)
|
||||
</label>
|
||||
<input
|
||||
id="new-pin"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
bind:value={newPin}
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if entryError}
|
||||
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{entryError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={reset}
|
||||
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !newUsername || (usePinForNew && newPin.length < 4)}
|
||||
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 font-medium"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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