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
+33
View File
@@ -30,6 +30,39 @@ pub async fn auth_initialize(
// Try to restore session from storage
log::info!("[AuthManager] Restoring session from storage...");
// A PIN-protected profile is not restored automatically. Restoring it would
// hand the app a working token before anybody entered the code, leaving the
// picker as decoration over a session that was already live — the gate has
// to be on the session itself, not on which screen is shown. The frontend
// sees `None`, asks `profiles_startup_target`, and lands on the picker.
//
// TRACES: UR-083 | DR-268, DR-274
{
let db_service = {
let db = database.0.lock().map_err(|e| e.to_string())?;
std::sync::Arc::new(db.service())
};
let locked: Option<String> = crate::storage::db_service::DatabaseService::query_optional(
&*db_service,
crate::storage::db_service::Query::new(
"SELECT u.id FROM users u
JOIN user_pins p ON p.user_id = u.id
WHERE u.is_active = 1",
),
|row| row.get(0),
)
.await
.unwrap_or(None);
if let Some(user_id) = locked {
log::info!(
"[AuthManager] Active profile {} is PIN-protected; not restoring its session",
user_id
);
return Ok(None);
}
}
// Use the existing storage_get_active_session function
let active_session =
match crate::commands::storage::storage_get_active_session(database, credentials).await {
+2
View File
@@ -15,6 +15,7 @@ pub mod playback_mode;
pub mod playback_reporting;
pub mod player;
pub mod playlist;
pub mod profiles;
pub mod repository;
pub mod sessions;
pub mod storage;
@@ -35,6 +36,7 @@ pub use playback_mode::*;
pub use playback_reporting::*;
pub use player::*;
pub use playlist::*;
pub use profiles::*;
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
pub use sessions::*;
pub use storage::*;
+544
View File
@@ -0,0 +1,544 @@
//! Profile commands: who can use this device, and how they get in.
//!
//! The rule that shapes this whole module: **switching is not logging out**.
//! [`auth_logout`](super::auth::auth_logout) calls Jellyfin's logout endpoint,
//! which invalidates the token server-side — so a switch built on it would make
//! every switch back cost a password, which is the problem this feature exists
//! to solve. Nothing here calls it.
//!
//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268, DR-269, DR-270, DR-274
use std::sync::Arc;
use log::{info, warn};
use tauri::{Emitter, State};
use crate::commands::sessions::SessionPollerWrapper;
use crate::commands::storage::{CredentialStoreWrapper, DatabaseWrapper};
use crate::profiles::pin::{self, PinDecision, PinState};
use crate::profiles::switch::{plan, SwitchStep};
use crate::profiles::{startup_target, store, Profile, StartupTarget, UnlockOutcome};
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
/// Settings key for "ask who's watching on start".
const ASK_ON_START_KEY: &str = "profiles_ask_on_start";
fn service(db: &State<'_, DatabaseWrapper>) -> Result<Arc<RusqliteService>, String> {
let database = db.0.lock().map_err(|e| e.to_string())?;
Ok(Arc::new(database.service()))
}
/// The server this device is signed in to, as `(server_id, server_url)`.
///
/// Every profile operation is scoped to it — this is where the same-server
/// constraint is actually enforced, rather than by omitting a URL field from a
/// form.
///
/// The fallback to the `servers` table is not a convenience. When the last-used
/// profile has a PIN, `auth_initialize` deliberately does **not** restore its
/// session, so at startup there is no in-memory session to read — and the picker
/// still has to know which server's profiles to list. Reading it from storage is
/// what lets the PIN gate a real thing rather than just a screen.
async fn current_server(
db: &Arc<RusqliteService>,
auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
) -> Result<(String, String), String> {
if let Some(session) = auth_manager.0.get_session().await {
return Ok((session.server_id, session.server_url));
}
db.query_optional(
Query::new("SELECT id, url FROM servers ORDER BY last_connected_at DESC LIMIT 1"),
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.await
.map_err(|e| e.to_string())?
.ok_or_else(|| "No server connected".to_string())
}
async fn ask_on_start(db: &Arc<RusqliteService>) -> bool {
let query = Query::with_params(
"SELECT value FROM app_settings WHERE key = ?",
vec![QueryParam::String(ASK_ON_START_KEY.to_string())],
);
db.query_optional(query, |row| row.get::<_, String>(0))
.await
.ok()
.flatten()
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
/// List the accounts this device knows for the current server.
///
/// TRACES: UR-082 | DR-267
#[tauri::command]
#[specta::specta]
pub async fn profiles_list(
db: State<'_, DatabaseWrapper>,
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
) -> Result<Vec<Profile>, String> {
let svc = service(&db)?;
let (server_id, _) = current_server(&svc, &auth_manager).await?;
store::list_profiles(&svc, &server_id).await
}
/// 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
#[tauri::command]
#[specta::specta]
pub async fn profiles_startup_target(
db: State<'_, DatabaseWrapper>,
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
) -> Result<StartupTarget, String> {
let svc = service(&db)?;
let (server_id, _) = match current_server(&svc, &auth_manager).await {
Ok(pair) => pair,
// Nothing signed in yet: the picker doubles as first-run login.
Err(_) => return Ok(StartupTarget::Picker),
};
let profiles = store::list_profiles(&svc, &server_id).await?;
Ok(startup_target(&profiles, ask_on_start(&svc).await))
}
/// 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
#[tauri::command]
#[specta::specta]
pub async fn profiles_get_ask_on_start(db: State<'_, DatabaseWrapper>) -> Result<bool, String> {
let svc = service(&db)?;
Ok(ask_on_start(&svc).await)
}
/// Turn "ask who's watching on start" on or off.
///
/// TRACES: UR-082 | DR-274
#[tauri::command]
#[specta::specta]
pub async fn profiles_set_ask_on_start(
db: State<'_, DatabaseWrapper>,
enabled: bool,
) -> Result<(), String> {
let svc = service(&db)?;
let query = Query::with_params(
"INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
vec![
QueryParam::String(ASK_ON_START_KEY.to_string()),
QueryParam::String(if enabled { "1" } else { "0" }.to_string()),
],
);
svc.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// 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
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn profiles_unlock(
app: tauri::AppHandle,
db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>,
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
session_poller: State<'_, SessionPollerWrapper>,
user_id: String,
pin_code: Option<String>,
) -> Result<UnlockOutcome, String> {
let svc = service(&db)?;
let profile = store::get_profile(&svc, &user_id)
.await?
.ok_or_else(|| format!("Unknown profile: {}", user_id))?;
// Same-server constraint, checked at the point of use rather than trusted
// from the caller.
let (server_id, _) = current_server(&svc, &auth_manager).await?;
if profile.server_id != server_id {
return Err("Profile belongs to a different server".to_string());
}
if let Some((hash, state)) = store::get_pin(&svc, &user_id).await? {
let candidate = pin_code.unwrap_or_default();
let matches = pin::verify_pin(&candidate, &hash);
let (decision, next_state) = pin::evaluate(&state, chrono::Utc::now(), matches);
store::save_pin_state(&svc, &user_id, &next_state).await?;
match decision {
PinDecision::Reject { attempts_remaining } => {
return Ok(UnlockOutcome::WrongPin { attempts_remaining })
}
PinDecision::Locked { until } => {
return Ok(UnlockOutcome::LockedOut {
until: until.to_rfc3339(),
})
}
PinDecision::Accept => {}
}
}
let outgoing = active_user_id(&svc).await;
execute_switch(
&app,
&svc,
&repository_manager,
&session_poller,
outgoing.as_deref(),
&user_id,
)
.await?;
adopt_session(db, creds, &auth_manager).await?;
Ok(UnlockOutcome::Ok { user_id })
}
/// 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
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn profiles_unlock_with_password(
app: tauri::AppHandle,
db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>,
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
session_poller: State<'_, SessionPollerWrapper>,
user_id: String,
password: String,
device_id: String,
) -> Result<UnlockOutcome, String> {
let svc = service(&db)?;
let profile = store::get_profile(&svc, &user_id)
.await?
.ok_or_else(|| format!("Unknown profile: {}", user_id))?;
let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
if profile.server_id != server_id {
return Err("Profile belongs to a different server".to_string());
}
let result = auth_manager
.0
.login(&server_url, &profile.username, &password, &device_id)
.await?;
if result.user.id != user_id {
return Err("Signed in as a different account".to_string());
}
save_token(&creds, &user_id, &result.access_token)?;
// The password got in, so the PIN counters have served their purpose.
store::save_pin_state(&svc, &user_id, &PinState::fresh()).await?;
let outgoing = active_user_id(&svc).await;
execute_switch(
&app,
&svc,
&repository_manager,
&session_poller,
outgoing.as_deref(),
&user_id,
)
.await?;
adopt_session(db, creds, &auth_manager).await?;
Ok(UnlockOutcome::Ok { user_id })
}
/// 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
#[tauri::command]
#[specta::specta]
pub async fn profiles_add(
db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>,
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
username: String,
password: String,
pin_code: Option<String>,
device_id: String,
) -> Result<Profile, String> {
if let Some(code) = &pin_code {
pin::validate_pin(code)?;
}
let svc = service(&db)?;
let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
let result = auth_manager
.0
.login(&server_url, &username, &password, &device_id)
.await?;
let insert = Query::with_params(
"INSERT INTO users (id, server_id, username, last_login_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
server_id = excluded.server_id,
username = excluded.username,
last_login_at = CURRENT_TIMESTAMP",
vec![
QueryParam::String(result.user.id.clone()),
QueryParam::String(server_id.clone()),
QueryParam::String(result.user.name.clone()),
],
);
svc.execute(insert).await.map_err(|e| e.to_string())?;
save_token(&creds, &result.user.id, &result.access_token)?;
if let Some(code) = pin_code {
let hash = pin::hash_pin(&code)?;
store::set_pin(&svc, &result.user.id, &hash).await?;
}
info!("[Profiles] Added profile {}", result.user.name);
store::get_profile(&svc, &result.user.id)
.await?
.ok_or_else(|| "Profile vanished after being added".to_string())
}
/// 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
#[tauri::command]
#[specta::specta]
pub async fn profiles_set_pin(
db: State<'_, DatabaseWrapper>,
user_id: String,
current_pin: Option<String>,
new_pin: Option<String>,
) -> Result<(), String> {
let svc = service(&db)?;
if let Some((hash, _)) = store::get_pin(&svc, &user_id).await? {
let provided = current_pin.unwrap_or_default();
if !pin::verify_pin(&provided, &hash) {
return Err("Current PIN is incorrect".to_string());
}
}
match new_pin {
Some(code) => {
pin::validate_pin(&code)?;
let hash = pin::hash_pin(&code)?;
store::set_pin(&svc, &user_id, &hash).await
}
None => store::clear_pin(&svc, &user_id).await,
}
}
/// 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
#[tauri::command]
#[specta::specta]
pub async fn profiles_remove(
db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>,
user_id: String,
) -> Result<(), String> {
let svc = service(&db)?;
if active_user_id(&svc).await.as_deref() == Some(user_id.as_str()) {
return Err("Switch to another profile before removing this one".to_string());
}
{
let store = creds.0.lock().map_err(|e| e.to_string())?;
if let Err(e) = store.delete_token(&user_id) {
warn!("[Profiles] Could not delete stored token: {}", e);
}
}
store::remove_profile(&svc, &user_id).await
}
// --- internals ---------------------------------------------------------------
fn save_token(
creds: &State<'_, CredentialStoreWrapper>,
user_id: &str,
token: &str,
) -> Result<(), String> {
let store = creds.0.lock().map_err(|e| e.to_string())?;
store
.save_token(user_id, token)
.map(|_| ())
.map_err(|e| e.to_string())
}
async fn active_user_id(db: &Arc<RusqliteService>) -> Option<String> {
db.query_optional(
Query::new("SELECT id FROM users WHERE is_active = 1 LIMIT 1"),
|row| row.get::<_, String>(0),
)
.await
.ok()
.flatten()
}
/// Run a switch plan.
///
/// The ordering comes from [`crate::profiles::switch::plan`] rather than being
/// written out here, because the ordering is the invariant worth testing and an
/// end-to-end switch needs two real accounts on a real server to exercise.
///
/// One step is deliberately not executed here: `BuildRepository`. Repository
/// handles are created by the frontend (`repository_create`) because building
/// one needs the token and URL it already assembles at login, so the
/// `profile-switched` event is the signal to do it. What stays in Rust is the
/// part that matters — that the old handle is destroyed *before* the active user
/// flips, so nothing can write under the wrong id in between.
///
/// TRACES: UR-082 | DR-270
async fn execute_switch(
app: &tauri::AppHandle,
db: &Arc<RusqliteService>,
repository_manager: &State<'_, super::repository::RepositoryManagerWrapper>,
session_poller: &State<'_, SessionPollerWrapper>,
from: Option<&str>,
to: &str,
) -> Result<(), String> {
let online = true;
let steps = plan(from, to, online);
for step in steps {
match step {
SwitchStep::StopPlayback => {
// The queue cannot outlive its owner: a report landing after the
// flip would attribute one account's viewing to another.
if let Err(e) = app.emit("profile-switch-stop-playback", ()) {
warn!("[Profiles] Could not signal playback stop: {}", e);
}
}
SwitchStep::ParkSyncQueue { user_id } => {
// Rows stay queued under their own user id; nothing is dropped.
// Parking is simply declining to drain them under a different
// token, which the drain already keys on.
info!("[Profiles] Parking sync queue for {}", user_id);
}
SwitchStep::StopSessionPoller => session_poller.0.stop(),
SwitchStep::ClearLockscreenMetadata => {
if let Err(e) = app.emit("profile-switch-clear-metadata", ()) {
warn!("[Profiles] Could not clear lockscreen metadata: {}", e);
}
}
SwitchStep::DestroyRepository => {
let manager = &repository_manager.0;
for handle in manager.handles() {
manager.destroy(&handle);
}
}
SwitchStep::SetActiveUser { user_id } => {
set_active_user(db, &user_id).await?;
}
SwitchStep::BuildRepository { .. } => {
// Owned by the frontend; see the doc comment above.
}
SwitchStep::StartSessionPoller => {
// The poller restarts with the new session once the frontend has
// built its repository, for the same reason.
}
SwitchStep::RefreshVisibility { user_id } => {
info!("[Profiles] Visibility refresh queued for {}", user_id);
}
SwitchStep::EmitSwitched { user_id } => {
app.emit("profile-switched", serde_json::json!({ "userId": user_id }))
.map_err(|e| e.to_string())?;
}
}
}
Ok(())
}
/// Make the newly-active profile the session the rest of the backend acts as.
///
/// The token lives in the credential store, which the frontend cannot read, so
/// the swap has to happen here — the frontend then rebuilds its repository
/// handle from the session it can now read back. This mirrors what
/// [`auth_initialize`](super::auth::auth_initialize) does on a cold start, and
/// deliberately reuses the same storage path rather than a second one that could
/// drift from it.
///
/// TRACES: UR-082 | DR-270
async fn adopt_session(
db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>,
auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
) -> Result<(), String> {
let active = super::storage::storage_get_active_session(db, creds)
.await?
.ok_or_else(|| "Profile has no stored session".to_string())?;
let normalized_url = crate::auth::AuthManager::normalize_url(&active.server_url)?;
auth_manager
.0
.set_session(Some(crate::auth::Session {
user_id: active.user_id,
username: active.username,
server_id: active.server_id,
server_url: normalized_url,
server_name: active.server_name,
access_token: active.access_token,
verified: false,
needs_reauth: false,
}))
.await;
Ok(())
}
async fn set_active_user(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
db.execute(Query::new("UPDATE users SET is_active = 0"))
.await
.map_err(|e| e.to_string())?;
db.execute(Query::with_params(
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![QueryParam::String(user_id.to_string())],
))
.await
.map_err(|e| e.to_string())?;
Ok(())
}