//! 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, 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, 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) -> 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, 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 { 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 { 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, ) -> Result { 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 { 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, device_id: String, ) -> Result { 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, new_pin: Option, ) -> 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) -> Option { 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, 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, 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(()) }