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
+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();