jellytau_lib/profiles/mod.rs
1//! Multi-user profiles on one device.
2//!
3//! A "profile" is an account on the *currently connected* Jellyfin server that
4//! this device has signed into at least once. The rows have existed since the
5//! first schema (`users`), and so have `storage_get_users` /
6//! `storage_set_active_user`; what was missing was never the storage but the
7//! decision of who may switch to what — which is domain logic, and stays here.
8//!
9//! Two things this module is careful about:
10//!
11//! - **Switching is not logging out.** `auth_logout` calls Jellyfin's logout
12//! endpoint, which invalidates the token server-side. That is precisely the
13//! behaviour a switch must not have, or every switch back would need a
14//! password. Nothing here calls it.
15//! - **"Child account" is not modelled.** A child's profile is simply one with
16//! no PIN. The frontend receives an opaque [`UnlockMethod`] and renders it; it
17//! never infers a role, and no role taxonomy is invented on either side.
18//!
19//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268
20
21pub mod pin;
22pub mod store;
23pub mod switch;
24
25use serde::{Deserialize, Serialize};
26
27/// How a profile is entered.
28///
29/// Deliberately not "adult"/"child": the app has no way to know a person's age
30/// and no business encoding one. It knows whether a code is set.
31///
32/// TRACES: UR-083 | DR-276
33#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub enum UnlockMethod {
36 /// One tap. No code set.
37 None,
38 /// A numeric code gates the switch.
39 Pin,
40}
41
42/// A switchable account on this device.
43///
44/// TRACES: UR-082 | DR-267
45#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct Profile {
48 pub user_id: String,
49 pub username: String,
50 pub server_id: String,
51 /// Jellyfin's primary-image tag, for the tile. `None` renders initials.
52 pub avatar_tag: Option<String>,
53 pub unlock_method: UnlockMethod,
54 pub last_used_at: Option<String>,
55 pub is_active: bool,
56}
57
58/// The result of an unlock attempt.
59///
60/// Note the explicit field renames. tauri-specta emits tagged-union *fields*
61/// with their Rust names rather than camelCasing them, so a field that would
62/// differ between the two conventions is renamed here by hand — the same trap
63/// that produced `new_url` on the frontend once already.
64///
65/// TRACES: UR-083, UR-084 | DR-268, DR-269
66#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(tag = "type", rename_all = "camelCase")]
68pub enum UnlockOutcome {
69 /// Switched. The profile is now active.
70 Ok {
71 #[serde(rename = "userId")]
72 user_id: String,
73 },
74 /// Wrong code, attempts left.
75 WrongPin {
76 #[serde(rename = "attemptsRemaining")]
77 attempts_remaining: u32,
78 },
79 /// Too many wrong codes; refused until this RFC3339 instant.
80 LockedOut { until: String },
81 /// No code is recoverable from here — sign in with the account password.
82 NeedsPassword,
83}
84
85/// What the app should do when it starts.
86///
87/// The decision is backend state (profile count, PIN presence, a stored
88/// setting), so the frontend asks rather than computes. A single account with no
89/// PIN always resumes, which is what keeps this feature invisible until it is
90/// wanted.
91///
92/// TRACES: UR-082 | DR-274
93#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(tag = "type", rename_all = "camelCase")]
95pub enum StartupTarget {
96 /// Resume this profile without asking.
97 Resume {
98 #[serde(rename = "userId")]
99 user_id: String,
100 },
101 /// Show the picker.
102 Picker,
103}
104
105/// Decide the startup target from the profiles present and the user's setting.
106///
107/// Pure, because the rule is worth testing and the inputs are trivial to state:
108///
109/// - No profiles at all → picker (which renders as the first-run login).
110/// - The last-used profile has a PIN → picker, regardless of the setting. A code
111/// that could be skipped by relaunching is not a code.
112/// - More than one profile and "ask who's watching" is on → picker.
113/// - Otherwise → resume, exactly as the app behaved before profiles existed.
114///
115/// TRACES: UR-082 | DR-274
116pub fn startup_target(profiles: &[Profile], ask_on_start: bool) -> StartupTarget {
117 let last_used = profiles
118 .iter()
119 .max_by(|a, b| a.last_used_at.cmp(&b.last_used_at));
120
121 match last_used {
122 None => StartupTarget::Picker,
123 Some(p) if p.unlock_method == UnlockMethod::Pin => StartupTarget::Picker,
124 Some(_) if ask_on_start && profiles.len() > 1 => StartupTarget::Picker,
125 Some(p) => StartupTarget::Resume {
126 user_id: p.user_id.clone(),
127 },
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 fn profile(id: &str, unlock: UnlockMethod, last_used: Option<&str>) -> Profile {
136 Profile {
137 user_id: id.to_string(),
138 username: id.to_string(),
139 server_id: "server-1".to_string(),
140 avatar_tag: None,
141 unlock_method: unlock,
142 last_used_at: last_used.map(|s| s.to_string()),
143 is_active: false,
144 }
145 }
146
147 /// UT: the pre-profiles install — one account, no PIN — never sees a picker.
148 #[test]
149 fn single_pinless_profile_resumes() {
150 let profiles = vec![profile(
151 "u1",
152 UnlockMethod::None,
153 Some("2026-01-01T00:00:00Z"),
154 )];
155 assert_eq!(
156 startup_target(&profiles, true),
157 StartupTarget::Resume {
158 user_id: "u1".to_string()
159 },
160 "a lone profile resumes even with the setting on"
161 );
162 }
163
164 /// UT: a PIN is not skippable by relaunching the app.
165 #[test]
166 fn pinned_last_profile_always_asks() {
167 let profiles = vec![profile(
168 "u1",
169 UnlockMethod::Pin,
170 Some("2026-01-01T00:00:00Z"),
171 )];
172 assert_eq!(startup_target(&profiles, false), StartupTarget::Picker);
173 }
174
175 /// UT: several pinless profiles resume the last one unless asked to ask.
176 #[test]
177 fn multiple_profiles_follow_the_setting() {
178 let profiles = vec![
179 profile("u1", UnlockMethod::None, Some("2026-01-01T00:00:00Z")),
180 profile("u2", UnlockMethod::None, Some("2026-02-01T00:00:00Z")),
181 ];
182 assert_eq!(
183 startup_target(&profiles, false),
184 StartupTarget::Resume {
185 user_id: "u2".to_string()
186 },
187 "resumes the most recently used"
188 );
189 assert_eq!(startup_target(&profiles, true), StartupTarget::Picker);
190 }
191
192 /// UT: nothing signed in yet.
193 #[test]
194 fn no_profiles_shows_the_picker() {
195 assert_eq!(startup_target(&[], false), StartupTarget::Picker);
196 }
197}