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:
@@ -0,0 +1,197 @@
|
||||
//! Multi-user profiles on one device.
|
||||
//!
|
||||
//! A "profile" is an account on the *currently connected* Jellyfin server that
|
||||
//! this device has signed into at least once. The rows have existed since the
|
||||
//! first schema (`users`), and so have `storage_get_users` /
|
||||
//! `storage_set_active_user`; what was missing was never the storage but the
|
||||
//! decision of who may switch to what — which is domain logic, and stays here.
|
||||
//!
|
||||
//! Two things this module is careful about:
|
||||
//!
|
||||
//! - **Switching is not logging out.** `auth_logout` calls Jellyfin's logout
|
||||
//! endpoint, which invalidates the token server-side. That is precisely the
|
||||
//! behaviour a switch must not have, or every switch back would need a
|
||||
//! password. Nothing here calls it.
|
||||
//! - **"Child account" is not modelled.** A child's profile is simply one with
|
||||
//! no PIN. The frontend receives an opaque [`UnlockMethod`] and renders it; it
|
||||
//! never infers a role, and no role taxonomy is invented on either side.
|
||||
//!
|
||||
//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268
|
||||
|
||||
pub mod pin;
|
||||
pub mod store;
|
||||
pub mod switch;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 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
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UnlockMethod {
|
||||
/// One tap. No code set.
|
||||
None,
|
||||
/// A numeric code gates the switch.
|
||||
Pin,
|
||||
}
|
||||
|
||||
/// A switchable account on this device.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Profile {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub server_id: String,
|
||||
/// Jellyfin's primary-image tag, for the tile. `None` renders initials.
|
||||
pub avatar_tag: Option<String>,
|
||||
pub unlock_method: UnlockMethod,
|
||||
pub last_used_at: Option<String>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
/// 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
|
||||
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum UnlockOutcome {
|
||||
/// Switched. The profile is now active.
|
||||
Ok {
|
||||
#[serde(rename = "userId")]
|
||||
user_id: String,
|
||||
},
|
||||
/// Wrong code, attempts left.
|
||||
WrongPin {
|
||||
#[serde(rename = "attemptsRemaining")]
|
||||
attempts_remaining: u32,
|
||||
},
|
||||
/// Too many wrong codes; refused until this RFC3339 instant.
|
||||
LockedOut { until: String },
|
||||
/// No code is recoverable from here — sign in with the account password.
|
||||
NeedsPassword,
|
||||
}
|
||||
|
||||
/// 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
|
||||
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum StartupTarget {
|
||||
/// Resume this profile without asking.
|
||||
Resume {
|
||||
#[serde(rename = "userId")]
|
||||
user_id: String,
|
||||
},
|
||||
/// Show the picker.
|
||||
Picker,
|
||||
}
|
||||
|
||||
/// Decide the startup target from the profiles present and the user's setting.
|
||||
///
|
||||
/// Pure, because the rule is worth testing and the inputs are trivial to state:
|
||||
///
|
||||
/// - No profiles at all → picker (which renders as the first-run login).
|
||||
/// - The last-used profile has a PIN → picker, regardless of the setting. A code
|
||||
/// that could be skipped by relaunching is not a code.
|
||||
/// - More than one profile and "ask who's watching" is on → picker.
|
||||
/// - Otherwise → resume, exactly as the app behaved before profiles existed.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
pub fn startup_target(profiles: &[Profile], ask_on_start: bool) -> StartupTarget {
|
||||
let last_used = profiles
|
||||
.iter()
|
||||
.max_by(|a, b| a.last_used_at.cmp(&b.last_used_at));
|
||||
|
||||
match last_used {
|
||||
None => StartupTarget::Picker,
|
||||
Some(p) if p.unlock_method == UnlockMethod::Pin => StartupTarget::Picker,
|
||||
Some(_) if ask_on_start && profiles.len() > 1 => StartupTarget::Picker,
|
||||
Some(p) => StartupTarget::Resume {
|
||||
user_id: p.user_id.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile(id: &str, unlock: UnlockMethod, last_used: Option<&str>) -> Profile {
|
||||
Profile {
|
||||
user_id: id.to_string(),
|
||||
username: id.to_string(),
|
||||
server_id: "server-1".to_string(),
|
||||
avatar_tag: None,
|
||||
unlock_method: unlock,
|
||||
last_used_at: last_used.map(|s| s.to_string()),
|
||||
is_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// UT: the pre-profiles install — one account, no PIN — never sees a picker.
|
||||
#[test]
|
||||
fn single_pinless_profile_resumes() {
|
||||
let profiles = vec![profile(
|
||||
"u1",
|
||||
UnlockMethod::None,
|
||||
Some("2026-01-01T00:00:00Z"),
|
||||
)];
|
||||
assert_eq!(
|
||||
startup_target(&profiles, true),
|
||||
StartupTarget::Resume {
|
||||
user_id: "u1".to_string()
|
||||
},
|
||||
"a lone profile resumes even with the setting on"
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: a PIN is not skippable by relaunching the app.
|
||||
#[test]
|
||||
fn pinned_last_profile_always_asks() {
|
||||
let profiles = vec![profile(
|
||||
"u1",
|
||||
UnlockMethod::Pin,
|
||||
Some("2026-01-01T00:00:00Z"),
|
||||
)];
|
||||
assert_eq!(startup_target(&profiles, false), StartupTarget::Picker);
|
||||
}
|
||||
|
||||
/// UT: several pinless profiles resume the last one unless asked to ask.
|
||||
#[test]
|
||||
fn multiple_profiles_follow_the_setting() {
|
||||
let profiles = vec![
|
||||
profile("u1", UnlockMethod::None, Some("2026-01-01T00:00:00Z")),
|
||||
profile("u2", UnlockMethod::None, Some("2026-02-01T00:00:00Z")),
|
||||
];
|
||||
assert_eq!(
|
||||
startup_target(&profiles, false),
|
||||
StartupTarget::Resume {
|
||||
user_id: "u2".to_string()
|
||||
},
|
||||
"resumes the most recently used"
|
||||
);
|
||||
assert_eq!(startup_target(&profiles, true), StartupTarget::Picker);
|
||||
}
|
||||
|
||||
/// UT: nothing signed in yet.
|
||||
#[test]
|
||||
fn no_profiles_shows_the_picker() {
|
||||
assert_eq!(startup_target(&[], false), StartupTarget::Picker);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user