Skip to main content

jellytau_lib/commands/
profiles.rs

1//! Profile commands: who can use this device, and how they get in.
2//!
3//! The rule that shapes this whole module: **switching is not logging out**.
4//! [`auth_logout`](super::auth::auth_logout) calls Jellyfin's logout endpoint,
5//! which invalidates the token server-side — so a switch built on it would make
6//! every switch back cost a password, which is the problem this feature exists
7//! to solve. Nothing here calls it.
8//!
9//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268, DR-269, DR-270, DR-274
10
11use std::sync::Arc;
12
13use log::{info, warn};
14use tauri::{Emitter, State};
15
16use crate::commands::sessions::SessionPollerWrapper;
17use crate::commands::storage::{CredentialStoreWrapper, DatabaseWrapper};
18use crate::profiles::pin::{self, PinDecision, PinState};
19use crate::profiles::switch::{plan, SwitchStep};
20use crate::profiles::{startup_target, store, Profile, StartupTarget, UnlockOutcome};
21use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
22
23/// Settings key for "ask who's watching on start".
24const ASK_ON_START_KEY: &str = "profiles_ask_on_start";
25
26fn service(db: &State<'_, DatabaseWrapper>) -> Result<Arc<RusqliteService>, String> {
27    let database = db.0.lock().map_err(|e| e.to_string())?;
28    Ok(Arc::new(database.service()))
29}
30
31/// The server this device is signed in to, as `(server_id, server_url)`.
32///
33/// Every profile operation is scoped to it — this is where the same-server
34/// constraint is actually enforced, rather than by omitting a URL field from a
35/// form.
36///
37/// The fallback to the `servers` table is not a convenience. When the last-used
38/// profile has a PIN, `auth_initialize` deliberately does **not** restore its
39/// session, so at startup there is no in-memory session to read — and the picker
40/// still has to know which server's profiles to list. Reading it from storage is
41/// what lets the PIN gate a real thing rather than just a screen.
42async fn current_server(
43    db: &Arc<RusqliteService>,
44    auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
45) -> Result<(String, String), String> {
46    if let Some(session) = auth_manager.0.get_session().await {
47        return Ok((session.server_id, session.server_url));
48    }
49
50    db.query_optional(
51        Query::new("SELECT id, url FROM servers ORDER BY last_connected_at DESC LIMIT 1"),
52        |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
53    )
54    .await
55    .map_err(|e| e.to_string())?
56    .ok_or_else(|| "No server connected".to_string())
57}
58
59async fn ask_on_start(db: &Arc<RusqliteService>) -> bool {
60    let query = Query::with_params(
61        "SELECT value FROM app_settings WHERE key = ?",
62        vec![QueryParam::String(ASK_ON_START_KEY.to_string())],
63    );
64    db.query_optional(query, |row| row.get::<_, String>(0))
65        .await
66        .ok()
67        .flatten()
68        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
69        .unwrap_or(false)
70}
71
72/// List the accounts this device knows for the current server.
73///
74/// TRACES: UR-082 | DR-267
75#[tauri::command]
76#[specta::specta]
77pub async fn profiles_list(
78    db: State<'_, DatabaseWrapper>,
79    auth_manager: State<'_, super::auth::AuthManagerWrapper>,
80) -> Result<Vec<Profile>, String> {
81    let svc = service(&db)?;
82    let (server_id, _) = current_server(&svc, &auth_manager).await?;
83    store::list_profiles(&svc, &server_id).await
84}
85
86/// Whether startup should resume an account or ask who is watching.
87///
88/// The decision is backend state, so the frontend asks rather than computes it.
89///
90/// TRACES: UR-082 | DR-274
91#[tauri::command]
92#[specta::specta]
93pub async fn profiles_startup_target(
94    db: State<'_, DatabaseWrapper>,
95    auth_manager: State<'_, super::auth::AuthManagerWrapper>,
96) -> Result<StartupTarget, String> {
97    let svc = service(&db)?;
98    let (server_id, _) = match current_server(&svc, &auth_manager).await {
99        Ok(pair) => pair,
100        // Nothing signed in yet: the picker doubles as first-run login.
101        Err(_) => return Ok(StartupTarget::Picker),
102    };
103    let profiles = store::list_profiles(&svc, &server_id).await?;
104    Ok(startup_target(&profiles, ask_on_start(&svc).await))
105}
106
107/// Read the "ask who's watching on start" setting.
108///
109/// Separate from [`profiles_startup_target`] on purpose: the target can be
110/// `Picker` for reasons that have nothing to do with this setting — a
111/// PIN-protected last profile always asks — so deriving the toggle's position
112/// from it would show the user a switch that does not describe what it controls.
113///
114/// TRACES: UR-082 | DR-274
115#[tauri::command]
116#[specta::specta]
117pub async fn profiles_get_ask_on_start(db: State<'_, DatabaseWrapper>) -> Result<bool, String> {
118    let svc = service(&db)?;
119    Ok(ask_on_start(&svc).await)
120}
121
122/// Turn "ask who's watching on start" on or off.
123///
124/// TRACES: UR-082 | DR-274
125#[tauri::command]
126#[specta::specta]
127pub async fn profiles_set_ask_on_start(
128    db: State<'_, DatabaseWrapper>,
129    enabled: bool,
130) -> Result<(), String> {
131    let svc = service(&db)?;
132    let query = Query::with_params(
133        "INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
134         ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
135        vec![
136            QueryParam::String(ASK_ON_START_KEY.to_string()),
137            QueryParam::String(if enabled { "1" } else { "0" }.to_string()),
138        ],
139    );
140    svc.execute(query).await.map_err(|e| e.to_string())?;
141    Ok(())
142}
143
144/// Enter a profile, with its PIN if it has one.
145///
146/// A profile with no PIN ignores whatever `pin` was passed — the frontend cannot
147/// invent a lock the backend does not have, and cannot skip one it does.
148///
149/// TRACES: UR-082, UR-083 | DR-267, DR-268, DR-270
150#[tauri::command]
151#[specta::specta]
152#[allow(clippy::too_many_arguments)]
153pub async fn profiles_unlock(
154    app: tauri::AppHandle,
155    db: State<'_, DatabaseWrapper>,
156    creds: State<'_, CredentialStoreWrapper>,
157    auth_manager: State<'_, super::auth::AuthManagerWrapper>,
158    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
159    session_poller: State<'_, SessionPollerWrapper>,
160    user_id: String,
161    pin_code: Option<String>,
162) -> Result<UnlockOutcome, String> {
163    let svc = service(&db)?;
164
165    let profile = store::get_profile(&svc, &user_id)
166        .await?
167        .ok_or_else(|| format!("Unknown profile: {}", user_id))?;
168
169    // Same-server constraint, checked at the point of use rather than trusted
170    // from the caller.
171    let (server_id, _) = current_server(&svc, &auth_manager).await?;
172    if profile.server_id != server_id {
173        return Err("Profile belongs to a different server".to_string());
174    }
175
176    if let Some((hash, state)) = store::get_pin(&svc, &user_id).await? {
177        let candidate = pin_code.unwrap_or_default();
178        let matches = pin::verify_pin(&candidate, &hash);
179        let (decision, next_state) = pin::evaluate(&state, chrono::Utc::now(), matches);
180        store::save_pin_state(&svc, &user_id, &next_state).await?;
181
182        match decision {
183            PinDecision::Reject { attempts_remaining } => {
184                return Ok(UnlockOutcome::WrongPin { attempts_remaining })
185            }
186            PinDecision::Locked { until } => {
187                return Ok(UnlockOutcome::LockedOut {
188                    until: until.to_rfc3339(),
189                })
190            }
191            PinDecision::Accept => {}
192        }
193    }
194
195    let outgoing = active_user_id(&svc).await;
196    execute_switch(
197        &app,
198        &svc,
199        &repository_manager,
200        &session_poller,
201        outgoing.as_deref(),
202        &user_id,
203    )
204    .await?;
205    adopt_session(db, creds, &auth_manager).await?;
206
207    Ok(UnlockOutcome::Ok { user_id })
208}
209
210/// Enter a profile with its Jellyfin password, for someone who has forgotten
211/// their PIN.
212///
213/// There is deliberately no reset token and no recovery secret: the account's
214/// own password is already the authority over it, and a second credential
215/// guarding the same thing would only be a weaker one. A successful password
216/// entry also clears the lockout, which is what makes a forgotten PIN a
217/// detour rather than a dead end.
218///
219/// TRACES: UR-084 | DR-269
220#[tauri::command]
221#[specta::specta]
222#[allow(clippy::too_many_arguments)]
223pub async fn profiles_unlock_with_password(
224    app: tauri::AppHandle,
225    db: State<'_, DatabaseWrapper>,
226    creds: State<'_, CredentialStoreWrapper>,
227    auth_manager: State<'_, super::auth::AuthManagerWrapper>,
228    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
229    session_poller: State<'_, SessionPollerWrapper>,
230    user_id: String,
231    password: String,
232    device_id: String,
233) -> Result<UnlockOutcome, String> {
234    let svc = service(&db)?;
235    let profile = store::get_profile(&svc, &user_id)
236        .await?
237        .ok_or_else(|| format!("Unknown profile: {}", user_id))?;
238
239    let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
240    if profile.server_id != server_id {
241        return Err("Profile belongs to a different server".to_string());
242    }
243
244    let result = auth_manager
245        .0
246        .login(&server_url, &profile.username, &password, &device_id)
247        .await?;
248
249    if result.user.id != user_id {
250        return Err("Signed in as a different account".to_string());
251    }
252
253    save_token(&creds, &user_id, &result.access_token)?;
254
255    // The password got in, so the PIN counters have served their purpose.
256    store::save_pin_state(&svc, &user_id, &PinState::fresh()).await?;
257
258    let outgoing = active_user_id(&svc).await;
259    execute_switch(
260        &app,
261        &svc,
262        &repository_manager,
263        &session_poller,
264        outgoing.as_deref(),
265        &user_id,
266    )
267    .await?;
268    adopt_session(db, creds, &auth_manager).await?;
269
270    Ok(UnlockOutcome::Ok { user_id })
271}
272
273/// Add another account from the **current** server to this device.
274///
275/// Takes no server URL. That is the same-server constraint expressed as a
276/// signature rather than as form validation: there is no way to ask this command
277/// for an account somewhere else.
278///
279/// TRACES: UR-082 | DR-267
280#[tauri::command]
281#[specta::specta]
282pub async fn profiles_add(
283    db: State<'_, DatabaseWrapper>,
284    creds: State<'_, CredentialStoreWrapper>,
285    auth_manager: State<'_, super::auth::AuthManagerWrapper>,
286    username: String,
287    password: String,
288    pin_code: Option<String>,
289    device_id: String,
290) -> Result<Profile, String> {
291    if let Some(code) = &pin_code {
292        pin::validate_pin(code)?;
293    }
294
295    let svc = service(&db)?;
296    let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
297
298    let result = auth_manager
299        .0
300        .login(&server_url, &username, &password, &device_id)
301        .await?;
302
303    let insert = Query::with_params(
304        "INSERT INTO users (id, server_id, username, last_login_at)
305         VALUES (?, ?, ?, CURRENT_TIMESTAMP)
306         ON CONFLICT(id) DO UPDATE SET
307            server_id = excluded.server_id,
308            username = excluded.username,
309            last_login_at = CURRENT_TIMESTAMP",
310        vec![
311            QueryParam::String(result.user.id.clone()),
312            QueryParam::String(server_id.clone()),
313            QueryParam::String(result.user.name.clone()),
314        ],
315    );
316    svc.execute(insert).await.map_err(|e| e.to_string())?;
317
318    save_token(&creds, &result.user.id, &result.access_token)?;
319
320    if let Some(code) = pin_code {
321        let hash = pin::hash_pin(&code)?;
322        store::set_pin(&svc, &result.user.id, &hash).await?;
323    }
324
325    info!("[Profiles] Added profile {}", result.user.name);
326
327    store::get_profile(&svc, &result.user.id)
328        .await?
329        .ok_or_else(|| "Profile vanished after being added".to_string())
330}
331
332/// Set, change, or clear a profile's PIN.
333///
334/// Changing an existing PIN requires the current one. Clearing it (`new_pin =
335/// None`) does too — otherwise the lock could be removed by whoever is standing
336/// in front of the unlocked device, which is exactly who it exists to stop.
337///
338/// TRACES: UR-083 | DR-268
339#[tauri::command]
340#[specta::specta]
341pub async fn profiles_set_pin(
342    db: State<'_, DatabaseWrapper>,
343    user_id: String,
344    current_pin: Option<String>,
345    new_pin: Option<String>,
346) -> Result<(), String> {
347    let svc = service(&db)?;
348
349    if let Some((hash, _)) = store::get_pin(&svc, &user_id).await? {
350        let provided = current_pin.unwrap_or_default();
351        if !pin::verify_pin(&provided, &hash) {
352            return Err("Current PIN is incorrect".to_string());
353        }
354    }
355
356    match new_pin {
357        Some(code) => {
358            pin::validate_pin(&code)?;
359            let hash = pin::hash_pin(&code)?;
360            store::set_pin(&svc, &user_id, &hash).await
361        }
362        None => store::clear_pin(&svc, &user_id).await,
363    }
364}
365
366/// Forget a profile on this device.
367///
368/// Does not call Jellyfin's logout endpoint: removing an account from the family
369/// TV should not sign that person out on their phone. The stored token is
370/// deleted locally, which is the part that actually belongs to this device.
371///
372/// TRACES: UR-082 | DR-267
373#[tauri::command]
374#[specta::specta]
375pub async fn profiles_remove(
376    db: State<'_, DatabaseWrapper>,
377    creds: State<'_, CredentialStoreWrapper>,
378    user_id: String,
379) -> Result<(), String> {
380    let svc = service(&db)?;
381
382    if active_user_id(&svc).await.as_deref() == Some(user_id.as_str()) {
383        return Err("Switch to another profile before removing this one".to_string());
384    }
385
386    {
387        let store = creds.0.lock().map_err(|e| e.to_string())?;
388        if let Err(e) = store.delete_token(&user_id) {
389            warn!("[Profiles] Could not delete stored token: {}", e);
390        }
391    }
392
393    store::remove_profile(&svc, &user_id).await
394}
395
396// --- internals ---------------------------------------------------------------
397
398fn save_token(
399    creds: &State<'_, CredentialStoreWrapper>,
400    user_id: &str,
401    token: &str,
402) -> Result<(), String> {
403    let store = creds.0.lock().map_err(|e| e.to_string())?;
404    store
405        .save_token(user_id, token)
406        .map(|_| ())
407        .map_err(|e| e.to_string())
408}
409
410async fn active_user_id(db: &Arc<RusqliteService>) -> Option<String> {
411    db.query_optional(
412        Query::new("SELECT id FROM users WHERE is_active = 1 LIMIT 1"),
413        |row| row.get::<_, String>(0),
414    )
415    .await
416    .ok()
417    .flatten()
418}
419
420/// Run a switch plan.
421///
422/// The ordering comes from [`crate::profiles::switch::plan`] rather than being
423/// written out here, because the ordering is the invariant worth testing and an
424/// end-to-end switch needs two real accounts on a real server to exercise.
425///
426/// One step is deliberately not executed here: `BuildRepository`. Repository
427/// handles are created by the frontend (`repository_create`) because building
428/// one needs the token and URL it already assembles at login, so the
429/// `profile-switched` event is the signal to do it. What stays in Rust is the
430/// part that matters — that the old handle is destroyed *before* the active user
431/// flips, so nothing can write under the wrong id in between.
432///
433/// TRACES: UR-082 | DR-270
434async fn execute_switch(
435    app: &tauri::AppHandle,
436    db: &Arc<RusqliteService>,
437    repository_manager: &State<'_, super::repository::RepositoryManagerWrapper>,
438    session_poller: &State<'_, SessionPollerWrapper>,
439    from: Option<&str>,
440    to: &str,
441) -> Result<(), String> {
442    let online = true;
443    let steps = plan(from, to, online);
444
445    for step in steps {
446        match step {
447            SwitchStep::StopPlayback => {
448                // The queue cannot outlive its owner: a report landing after the
449                // flip would attribute one account's viewing to another.
450                if let Err(e) = app.emit("profile-switch-stop-playback", ()) {
451                    warn!("[Profiles] Could not signal playback stop: {}", e);
452                }
453            }
454            SwitchStep::ParkSyncQueue { user_id } => {
455                // Rows stay queued under their own user id; nothing is dropped.
456                // Parking is simply declining to drain them under a different
457                // token, which the drain already keys on.
458                info!("[Profiles] Parking sync queue for {}", user_id);
459            }
460            SwitchStep::StopSessionPoller => session_poller.0.stop(),
461            SwitchStep::ClearLockscreenMetadata => {
462                if let Err(e) = app.emit("profile-switch-clear-metadata", ()) {
463                    warn!("[Profiles] Could not clear lockscreen metadata: {}", e);
464                }
465            }
466            SwitchStep::DestroyRepository => {
467                let manager = &repository_manager.0;
468                for handle in manager.handles() {
469                    manager.destroy(&handle);
470                }
471            }
472            SwitchStep::SetActiveUser { user_id } => {
473                set_active_user(db, &user_id).await?;
474            }
475            SwitchStep::BuildRepository { .. } => {
476                // Owned by the frontend; see the doc comment above.
477            }
478            SwitchStep::StartSessionPoller => {
479                // The poller restarts with the new session once the frontend has
480                // built its repository, for the same reason.
481            }
482            SwitchStep::RefreshVisibility { user_id } => {
483                info!("[Profiles] Visibility refresh queued for {}", user_id);
484            }
485            SwitchStep::EmitSwitched { user_id } => {
486                app.emit("profile-switched", serde_json::json!({ "userId": user_id }))
487                    .map_err(|e| e.to_string())?;
488            }
489        }
490    }
491
492    Ok(())
493}
494
495/// Make the newly-active profile the session the rest of the backend acts as.
496///
497/// The token lives in the credential store, which the frontend cannot read, so
498/// the swap has to happen here — the frontend then rebuilds its repository
499/// handle from the session it can now read back. This mirrors what
500/// [`auth_initialize`](super::auth::auth_initialize) does on a cold start, and
501/// deliberately reuses the same storage path rather than a second one that could
502/// drift from it.
503///
504/// TRACES: UR-082 | DR-270
505async fn adopt_session(
506    db: State<'_, DatabaseWrapper>,
507    creds: State<'_, CredentialStoreWrapper>,
508    auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
509) -> Result<(), String> {
510    let active = super::storage::storage_get_active_session(db, creds)
511        .await?
512        .ok_or_else(|| "Profile has no stored session".to_string())?;
513
514    let normalized_url = crate::auth::AuthManager::normalize_url(&active.server_url)?;
515
516    auth_manager
517        .0
518        .set_session(Some(crate::auth::Session {
519            user_id: active.user_id,
520            username: active.username,
521            server_id: active.server_id,
522            server_url: normalized_url,
523            server_name: active.server_name,
524            access_token: active.access_token,
525            verified: false,
526            needs_reauth: false,
527        }))
528        .await;
529
530    Ok(())
531}
532
533async fn set_active_user(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
534    db.execute(Query::new("UPDATE users SET is_active = 0"))
535        .await
536        .map_err(|e| e.to_string())?;
537    db.execute(Query::with_params(
538        "UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
539        vec![QueryParam::String(user_id.to_string())],
540    ))
541    .await
542    .map_err(|e| e.to_string())?;
543    Ok(())
544}