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
+206
View File
@@ -0,0 +1,206 @@
//! Database access for profiles.
//!
//! Everything here takes an explicit `user_id`. There is no ambient "current
//! user" in this module — the caller has to say who it means, which is what
//! stops a switch half-applying and writing one profile's state under another's
//! id.
//!
//! TRACES: UR-082, UR-083 | DR-267, DR-268
use std::sync::Arc;
use chrono::{DateTime, Utc};
use super::pin::PinState;
use super::{Profile, UnlockMethod};
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
/// List every profile known for a server, most recently used first.
///
/// A profile's unlock method is derived from the presence of a `user_pins` row
/// rather than stored twice, so the two can never disagree.
///
/// TRACES: UR-082 | DR-267
pub async fn list_profiles(
db: &Arc<RusqliteService>,
server_id: &str,
) -> Result<Vec<Profile>, String> {
let query = Query::with_params(
"SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at,
CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin
FROM users u
LEFT JOIN user_pins p ON p.user_id = u.id
WHERE u.server_id = ?
ORDER BY u.last_login_at DESC",
vec![QueryParam::String(server_id.to_string())],
);
db.query_many(query, |row| {
let has_pin: i32 = row.get(5)?;
Ok(Profile {
user_id: row.get(0)?,
username: row.get(1)?,
server_id: row.get(2)?,
avatar_tag: None,
unlock_method: if has_pin != 0 {
UnlockMethod::Pin
} else {
UnlockMethod::None
},
last_used_at: row.get(4)?,
is_active: row.get::<_, i32>(3)? != 0,
})
})
.await
.map_err(|e| e.to_string())
}
/// Fetch a single profile, or `None` if this device does not know it.
///
/// TRACES: UR-082 | DR-267
pub async fn get_profile(
db: &Arc<RusqliteService>,
user_id: &str,
) -> Result<Option<Profile>, String> {
let query = Query::with_params(
"SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at,
CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin
FROM users u
LEFT JOIN user_pins p ON p.user_id = u.id
WHERE u.id = ?",
vec![QueryParam::String(user_id.to_string())],
);
db.query_optional(query, |row| {
let has_pin: i32 = row.get(5)?;
Ok(Profile {
user_id: row.get(0)?,
username: row.get(1)?,
server_id: row.get(2)?,
avatar_tag: None,
unlock_method: if has_pin != 0 {
UnlockMethod::Pin
} else {
UnlockMethod::None
},
last_used_at: row.get(4)?,
is_active: row.get::<_, i32>(3)? != 0,
})
})
.await
.map_err(|e| e.to_string())
}
/// The stored PIN hash and attempt counters, or `None` when no PIN is set.
///
/// TRACES: UR-083 | DR-268
pub async fn get_pin(
db: &Arc<RusqliteService>,
user_id: &str,
) -> Result<Option<(String, PinState)>, String> {
let query = Query::with_params(
"SELECT pin_hash, failed_count, locked_until FROM user_pins WHERE user_id = ?",
vec![QueryParam::String(user_id.to_string())],
);
let row: Option<(String, i64, Option<String>)> = db
.query_optional(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.await
.map_err(|e| e.to_string())?;
Ok(row.map(|(hash, failed, locked)| {
let locked_until = locked
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&Utc));
(
hash,
PinState {
failed_count: failed.max(0) as u32,
locked_until,
},
)
}))
}
/// Store (or replace) a profile's PIN, resetting its counters.
///
/// TRACES: UR-083 | DR-268
pub async fn set_pin(
db: &Arc<RusqliteService>,
user_id: &str,
pin_hash: &str,
) -> Result<(), String> {
let query = Query::with_params(
"INSERT INTO user_pins (user_id, pin_hash, failed_count, locked_until, updated_at)
VALUES (?, ?, 0, NULL, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
pin_hash = excluded.pin_hash,
failed_count = 0,
locked_until = NULL,
updated_at = CURRENT_TIMESTAMP",
vec![
QueryParam::String(user_id.to_string()),
QueryParam::String(pin_hash.to_string()),
],
);
db.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Remove a profile's PIN, making it a one-tap profile.
///
/// TRACES: UR-083 | DR-268
pub async fn clear_pin(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
let query = Query::with_params(
"DELETE FROM user_pins WHERE user_id = ?",
vec![QueryParam::String(user_id.to_string())],
);
db.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Persist the counter state produced by [`super::pin::evaluate`].
///
/// This is what makes a lockout survive a restart: the deadline is on disk, not
/// in a process-lifetime counter that closing the app would clear.
///
/// TRACES: UR-083 | DR-268
pub async fn save_pin_state(
db: &Arc<RusqliteService>,
user_id: &str,
state: &PinState,
) -> Result<(), String> {
let locked = match state.locked_until {
Some(dt) => QueryParam::String(dt.to_rfc3339()),
None => QueryParam::Null,
};
let query = Query::with_params(
"UPDATE user_pins SET failed_count = ?, locked_until = ? WHERE user_id = ?",
vec![
QueryParam::Int64(i64::from(state.failed_count)),
locked,
QueryParam::String(user_id.to_string()),
],
);
db.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Forget a profile: its PIN, its per-user rows, and its `users` row.
///
/// Deliberately does **not** call Jellyfin's logout endpoint. Removing a profile
/// from this device is a local act; invalidating a token the person may be using
/// on their phone is not what "remove from this TV" means. The caller deletes the
/// stored token separately.
///
/// TRACES: UR-082 | DR-267
pub async fn remove_profile(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
// ON DELETE CASCADE covers user_pins, user_data, user_item_visibility,
// user_libraries, download_grants and the rest; the users row is the root.
let query = Query::with_params(
"DELETE FROM users WHERE id = ?",
vec![QueryParam::String(user_id.to_string())],
);
db.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}