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).
129 lines
4.0 KiB
TypeScript
129 lines
4.0 KiB
TypeScript
/**
|
|
* 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();
|