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:
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user