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:
@@ -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 {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user