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
+174
View File
@@ -1826,6 +1826,107 @@ async playlistGetItems(handle: string, playlistId: string) : Promise<PlaylistEnt
async playlistAddItems(handle: string, playlistId: string, itemIds: string[]) : Promise<null> {
return await TAURI_INVOKE("playlist_add_items", { handle, playlistId, itemIds });
},
/**
* Add another account from the **current** server to this device.
*
* Takes no server URL. That is the same-server constraint expressed as a
* signature rather than as form validation: there is no way to ask this command
* for an account somewhere else.
*
* TRACES: UR-082 | DR-267
*/
async profilesAdd(username: string, password: string, pinCode: string | null, deviceId: string) : Promise<Profile> {
return await TAURI_INVOKE("profiles_add", { username, password, pinCode, deviceId });
},
/**
* Read the "ask who's watching on start" setting.
*
* Separate from [`profiles_startup_target`] on purpose: the target can be
* `Picker` for reasons that have nothing to do with this setting — a
* PIN-protected last profile always asks — so deriving the toggle's position
* from it would show the user a switch that does not describe what it controls.
*
* TRACES: UR-082 | DR-274
*/
async profilesGetAskOnStart() : Promise<boolean> {
return await TAURI_INVOKE("profiles_get_ask_on_start");
},
/**
* List the accounts this device knows for the current server.
*
* TRACES: UR-082 | DR-267
*/
async profilesList() : Promise<Profile[]> {
return await TAURI_INVOKE("profiles_list");
},
/**
* Forget a profile on this device.
*
* Does not call Jellyfin's logout endpoint: removing an account from the family
* TV should not sign that person out on their phone. The stored token is
* deleted locally, which is the part that actually belongs to this device.
*
* TRACES: UR-082 | DR-267
*/
async profilesRemove(userId: string) : Promise<null> {
return await TAURI_INVOKE("profiles_remove", { userId });
},
/**
* Turn "ask who's watching on start" on or off.
*
* TRACES: UR-082 | DR-274
*/
async profilesSetAskOnStart(enabled: boolean) : Promise<null> {
return await TAURI_INVOKE("profiles_set_ask_on_start", { enabled });
},
/**
* Set, change, or clear a profile's PIN.
*
* Changing an existing PIN requires the current one. Clearing it (`new_pin =
* None`) does too — otherwise the lock could be removed by whoever is standing
* in front of the unlocked device, which is exactly who it exists to stop.
*
* TRACES: UR-083 | DR-268
*/
async profilesSetPin(userId: string, currentPin: string | null, newPin: string | null) : Promise<null> {
return await TAURI_INVOKE("profiles_set_pin", { userId, currentPin, newPin });
},
/**
* Whether startup should resume an account or ask who is watching.
*
* The decision is backend state, so the frontend asks rather than computes it.
*
* TRACES: UR-082 | DR-274
*/
async profilesStartupTarget() : Promise<StartupTarget> {
return await TAURI_INVOKE("profiles_startup_target");
},
/**
* Enter a profile, with its PIN if it has one.
*
* A profile with no PIN ignores whatever `pin` was passed — the frontend cannot
* invent a lock the backend does not have, and cannot skip one it does.
*
* TRACES: UR-082, UR-083 | DR-267, DR-268, DR-270
*/
async profilesUnlock(userId: string, pinCode: string | null) : Promise<UnlockOutcome> {
return await TAURI_INVOKE("profiles_unlock", { userId, pinCode });
},
/**
* Enter a profile with its Jellyfin password, for someone who has forgotten
* their PIN.
*
* There is deliberately no reset token and no recovery secret: the account's
* own password is already the authority over it, and a second credential
* guarding the same thing would only be a weaker one. A successful password
* entry also clears the lockout, which is what makes a forgotten PIN a
* detour rather than a dead end.
*
* TRACES: UR-084 | DR-269
*/
async profilesUnlockWithPassword(userId: string, password: string, deviceId: string) : Promise<UnlockOutcome> {
return await TAURI_INVOKE("profiles_unlock_with_password", { userId, password, deviceId });
},
/**
* Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs)
*/
@@ -3198,6 +3299,16 @@ alreadyDownloaded: number;
* Number of tracks skipped (no jellyfin ID or other reasons)
*/
skipped: number }
/**
* A switchable account on this device.
*
* TRACES: UR-082 | DR-267
*/
export type Profile = { userId: string; username: string; serverId: string;
/**
* Jellyfin's primary-image tag, for the tile. `None` renders initials.
*/
avatarTag: string | null; unlockMethod: UnlockMethod; lastUsedAt: string | null; isActive: boolean }
/**
* One rung of the quality picker, as it applies to *this* media source.
*
@@ -3358,6 +3469,25 @@ export type SleepTimerState = { mode: SleepTimerMode; remainingSeconds: number }
* SmartCache statistics
*/
export type SmartCacheStats = { total_size: number; storage_limit: number; available_space: number; items_count: number; config: CacheConfig }
/**
* What the app should do when it starts.
*
* The decision is backend state (profile count, PIN presence, a stored
* setting), so the frontend asks rather than computes. A single account with no
* PIN always resumes, which is what keeps this feature invisible until it is
* wanted.
*
* TRACES: UR-082 | DR-274
*/
export type StartupTarget =
/**
* Resume this profile without asking.
*/
{ type: "resume"; userId: string } |
/**
* Show the picker.
*/
{ type: "picker" }
/**
* Storage statistics for downloads
*/
@@ -3558,6 +3688,50 @@ export type Transport =
* server standing in front of one.
*/
{ type: "localFile" }
/**
* How a profile is entered.
*
* Deliberately not "adult"/"child": the app has no way to know a person's age
* and no business encoding one. It knows whether a code is set.
*
* TRACES: UR-083 | DR-276
*/
export type UnlockMethod =
/**
* One tap. No code set.
*/
"none" |
/**
* A numeric code gates the switch.
*/
"pin"
/**
* The result of an unlock attempt.
*
* Note the explicit field renames. tauri-specta emits tagged-union *fields*
* with their Rust names rather than camelCasing them, so a field that would
* differ between the two conventions is renamed here by hand — the same trap
* that produced `new_url` on the frontend once already.
*
* TRACES: UR-083, UR-084 | DR-268, DR-269
*/
export type UnlockOutcome =
/**
* Switched. The profile is now active.
*/
{ type: "ok"; userId: string } |
/**
* Wrong code, attempts left.
*/
{ type: "wrongPin"; attemptsRemaining: number } |
/**
* Too many wrong codes; refused until this RFC3339 instant.
*/
{ type: "lockedOut"; until: string } |
/**
* No code is recoverable from here — sign in with the account password.
*/
{ type: "needsPassword" }
/**
* User information
*/
+118
View File
@@ -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>
+61
View File
@@ -606,6 +606,66 @@ function createAuthStore() {
}
}
/**
* Rebuild this store's view of the world after the backend has switched
* profiles.
*
* The backend already flipped the active user, adopted the new session and
* destroyed the old repository handle — in that order, which is the part that
* matters. What is left is the half only the frontend owns: a `RepositoryClient`
* bound to the new session, and the player's reporting configuration.
*
* Deliberately *not* a login: no password is involved and no token is minted,
* because switching must leave both profiles able to come back with one tap.
*
* TRACES: UR-082 | DR-270
*/
async function adoptSwitchedSession() {
const session = await commands.authGetSession();
if (!session) throw new Error("No session after profile switch");
if (repository) {
try {
await repository.destroy();
} catch (error) {
log.error("Failed to destroy repository during switch:", error);
}
}
repository = new RepositoryClient();
await repository.create(
session.serverUrl,
session.userId,
session.accessToken,
session.serverId,
);
try {
const deviceId = await getDeviceId();
await commands.playerConfigureJellyfin(
session.serverUrl,
session.accessToken,
session.userId,
deviceId,
);
} catch (error) {
log.error("Failed to reconfigure player after switch:", error);
}
set({
isAuthenticated: true,
isLoading: false,
user: { id: session.userId, name: session.username } as User,
serverUrl: session.serverUrl,
serverName: session.serverName,
error: null,
securityWarning: null,
needsReauth: false,
isVerifying: false,
sessionVerified: session.verified,
});
}
return {
subscribe,
initialize,
@@ -620,6 +680,7 @@ function createAuthStore() {
getUserId,
getServerUrl,
retryVerification,
adoptSwitchedSession,
cleanupEventListeners,
};
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Profile switching — a thin wrapper over the Rust `profiles_*` commands.
*
* There is deliberately no logic here worth the name. This store does not
* compare a PIN, count an attempt, decide whether a profile is locked, or work
* out whether the picker should appear at startup. All of that is backend state
* and arrives as an opaque `unlockMethod`, an `UnlockOutcome` or a
* `StartupTarget`. A store that re-derived any of it would be a gate the webview
* could skip.
*
* TRACES: UR-082, UR-083, UR-084 | DR-267, DR-269, DR-274, DR-276
*/
import { writable, get } from "svelte/store";
import { commands } from "$lib/api/bindings";
import type { Profile, StartupTarget, UnlockOutcome } from "$lib/api/bindings";
import { getDeviceId } from "$lib/services/deviceId";
import { auth } from "./auth";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("Profiles");
interface ProfilesState {
profiles: Profile[];
isLoading: boolean;
error: string | null;
}
function createProfilesStore() {
const { subscribe, set, update } = writable<ProfilesState>({
profiles: [],
isLoading: false,
error: null,
});
async function refresh(): Promise<Profile[]> {
update((s) => ({ ...s, isLoading: true, error: null }));
try {
const profiles = await commands.profilesList();
set({ profiles, isLoading: false, error: null });
return profiles;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error("Failed to list profiles:", error);
set({ profiles: [], isLoading: false, error: message });
return [];
}
}
/**
* Where the app should go on launch. Asked, never computed — the answer
* depends on PIN presence and a stored setting, neither of which the frontend
* should be reasoning about.
*/
async function startupTarget(): Promise<StartupTarget> {
return await commands.profilesStartupTarget();
}
/**
* Enter a profile. On success the backend has already switched; this only
* rebuilds the frontend's repository handle.
*/
async function unlock(userId: string, pin: string | null): Promise<UnlockOutcome> {
const outcome = await commands.profilesUnlock(userId, pin);
if (outcome.type === "ok") {
await auth.adoptSwitchedSession();
await refresh();
}
return outcome;
}
/**
* The way back in for someone who has forgotten their PIN. Their ordinary
* Jellyfin password is the authority over their own account, so there is
* nothing else to reset.
*/
async function unlockWithPassword(userId: string, password: string): Promise<UnlockOutcome> {
const deviceId = await getDeviceId();
const outcome = await commands.profilesUnlockWithPassword(userId, password, deviceId);
if (outcome.type === "ok") {
await auth.adoptSwitchedSession();
await refresh();
}
return outcome;
}
/** Add another account from the server already connected. */
async function add(username: string, password: string, pin: string | null): Promise<Profile> {
const deviceId = await getDeviceId();
const profile = await commands.profilesAdd(username, password, pin, deviceId);
await refresh();
return profile;
}
async function setPin(userId: string, currentPin: string | null, newPin: string | null) {
await commands.profilesSetPin(userId, currentPin, newPin);
await refresh();
}
async function remove(userId: string) {
await commands.profilesRemove(userId);
await refresh();
}
async function setAskOnStart(enabled: boolean) {
await commands.profilesSetAskOnStart(enabled);
}
/** Whether this device has more than one account — what makes the UI worth showing at all. */
function isShared(): boolean {
return get({ subscribe }).profiles.length > 1;
}
return {
subscribe,
refresh,
startupTarget,
unlock,
unlockWithPassword,
add,
setPin,
remove,
setAskOnStart,
isShared,
};
}
export const profiles = createProfilesStore();
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
import { orderProfiles, initialsFor, tileColour, lockoutMessage } from "./profileTiles";
import type { Profile } from "$lib/api/bindings";
function profile(overrides: Partial<Profile> = {}): Profile {
return {
userId: "u1",
username: "Dad",
serverId: "s1",
avatarTag: null,
unlockMethod: "none",
lastUsedAt: null,
isActive: false,
...overrides,
};
}
describe("orderProfiles", () => {
it("puts the most recently used profile first", () => {
const ordered = orderProfiles([
profile({ userId: "a", lastUsedAt: "2026-01-01T00:00:00Z" }),
profile({ userId: "b", lastUsedAt: "2026-03-01T00:00:00Z" }),
]);
expect(ordered.map((p) => p.userId)).toEqual(["b", "a"]);
});
it("sorts never-used profiles after used ones, then by name", () => {
const ordered = orderProfiles([
profile({ userId: "z", username: "Zoe", lastUsedAt: null }),
profile({ userId: "a", username: "Ann", lastUsedAt: null }),
profile({ userId: "u", username: "Used", lastUsedAt: "2026-01-01T00:00:00Z" }),
]);
expect(ordered.map((p) => p.username)).toEqual(["Used", "Ann", "Zoe"]);
});
it("does not mutate its input", () => {
const input = [
profile({ userId: "a", lastUsedAt: "2026-01-01T00:00:00Z" }),
profile({ userId: "b", lastUsedAt: "2026-03-01T00:00:00Z" }),
];
orderProfiles(input);
expect(input.map((p) => p.userId)).toEqual(["a", "b"]);
});
});
describe("initialsFor", () => {
it("takes two letters from a single name", () => {
expect(initialsFor("Dad")).toBe("DA");
});
it("takes first and last initials from a multi-part name", () => {
expect(initialsFor("Anna Marie Smith")).toBe("AS");
});
it("splits on the separators usernames actually use", () => {
expect(initialsFor("anna_smith")).toBe("AS");
expect(initialsFor("anna.smith")).toBe("AS");
expect(initialsFor("anna-smith")).toBe("AS");
});
it("survives an empty or whitespace name", () => {
expect(initialsFor("")).toBe("?");
expect(initialsFor(" ")).toBe("?");
});
});
describe("tileColour", () => {
it("is stable for the same user", () => {
expect(tileColour("user-123")).toBe(tileColour("user-123"));
});
it("is derived from the id, not the name, so renaming keeps the colour", () => {
// Same id, different display names — the caller only passes the id, which is
// the point: a rename cannot move someone's tile colour.
expect(tileColour("user-123")).toBe(tileColour("user-123"));
expect(tileColour("user-456")).not.toBe("");
});
});
describe("lockoutMessage", () => {
const now = new Date("2026-01-01T00:00:00Z");
it("rounds up to whole minutes", () => {
expect(lockoutMessage("2026-01-01T00:02:30Z", now)).toBe("Try again in 3 minutes");
});
it("phrases under a minute without a number", () => {
expect(lockoutMessage("2026-01-01T00:00:30Z", now)).toBe("Try again in less than a minute");
});
it("treats an elapsed lockout as over", () => {
expect(lockoutMessage("2025-12-31T23:59:00Z", now)).toBe("Try again now");
});
it("does not render NaN when the timestamp is unusable", () => {
expect(lockoutMessage("not-a-date", now)).toBe("Try again now");
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* Presentation helpers for the profile picker.
*
* Pure, and separate from the component, because this is the half worth testing
* and a component cannot be unit-tested without mounting it.
*
* Note what is *not* here: nothing decides whether a profile is locked, whether
* a PIN is correct, or how many attempts remain. Those come from the backend as
* an opaque `unlockMethod` and an `UnlockOutcome`. A child's profile is simply
* one with no PIN — the app models no roles and infers no ages.
*
* TRACES: UR-082, UR-083 | DR-276
*/
import type { Profile } from "$lib/api/bindings";
/**
* Order tiles as people expect to find them: whoever used the device last is
* leftmost, and profiles that have never been used sort after those that have.
*
* TRACES: UR-082 | DR-276
*/
export function orderProfiles(profiles: Profile[]): Profile[] {
return [...profiles].sort((a, b) => {
if (a.lastUsedAt && b.lastUsedAt) return b.lastUsedAt.localeCompare(a.lastUsedAt);
if (a.lastUsedAt) return -1;
if (b.lastUsedAt) return 1;
return a.username.localeCompare(b.username);
});
}
/**
* Initials for a tile with no avatar. At most two letters, because three fills
* a circle badly at tile size.
*
* TRACES: UR-082 | DR-276
*/
export function initialsFor(username: string): string {
const words = username
.trim()
.split(/[\s._-]+/)
.filter(Boolean);
if (words.length === 0) return "?";
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
}
/**
* A stable tile colour per profile, so a family learns to recognise their tile
* by colour before they read the name. Derived from the user id rather than the
* name, so renaming does not move someone's colour.
*
* TRACES: UR-082 | DR-276
*/
const TILE_COLOURS = ["#7b68ee", "#00a4dc", "#e8734a", "#3ba55d", "#d95f8e", "#c9a227"] as const;
export function tileColour(userId: string): string {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = (hash * 31 + userId.charCodeAt(i)) >>> 0;
}
return TILE_COLOURS[hash % TILE_COLOURS.length];
}
/**
* How long a lockout has left, phrased for a person rather than a log line.
*
* TRACES: UR-083 | DR-276
*/
export function lockoutMessage(until: string, now: Date = new Date()): string {
const remainingMs = new Date(until).getTime() - now.getTime();
if (!Number.isFinite(remainingMs) || remainingMs <= 0) return "Try again now";
const minutes = Math.ceil(remainingMs / 60000);
if (minutes <= 1) return "Try again in less than a minute";
return `Try again in ${minutes} minutes`;
}