security.rs
⎇
Raw
1//! The Security tab of profile settings: the password, the passkeys, which of
2//! the two this account needs to sign in, and the app passwords for WebDAV.
3//!
4//! Every rule that keeps an account reachable lives on the server. This view
5//! only hides controls that cannot work — a browser without WebAuthn, a
6//! `Remove password` button on an account with no passkeys — so the reason
7//! for a refusal arrives as the server's own message rather than two
8//! implementations of the same rule drifting apart.
9
10use leptos::prelude::*;
11use wasm_bindgen_futures::spawn_local;
12
13use crate::api::{self, AppPasswordInfo, AuthMode, Me, PasskeyInfo, input_value};
14use crate::components::icon::Icon;
15use crate::components::toast::{ToastMsg, show, show_error};
16use crate::i18n;
17use crate::icons::IconName;
18use crate::util::{copy_to_clipboard, select_input, select_value};
19
20/// Trim an RFC 3339 timestamp to its date. The exact minute a passkey was
21/// registered is noise in a list.
22fn day(ts: &str) -> String {
23 ts.split('T').next().unwrap_or(ts).to_string()
24}
25
26#[component]
27pub fn SecurityView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) -> impl IntoView {
28 let toast = use_context::<ToastMsg>().expect("toast context");
29 let user = move || me.get().and_then(|m| m.user);
30 let has_password = move || user().is_some_and(|u| u.has_password);
31 let saved_mode = move || user().map(|u| u.auth_mode).unwrap_or_default();
32
33 let (passkeys, set_passkeys) = signal(Option::<Vec<PasskeyInfo>>::None);
34 let (busy, set_busy) = signal(false);
35 let (error, set_error) = signal(Option::<String>::None);
36 // The select is a draft until Save. Changing a sign-in requirement by
37 // brushing a dropdown would be the one change on this page nobody asked
38 // for.
39 let (mode_draft, set_mode_draft) = signal(Option::<AuthMode>::None);
40 let mode = move || mode_draft.get().unwrap_or_else(saved_mode);
41
42 let reload = move || {
43 spawn_local(async move {
44 match api::list_passkeys().await {
45 Ok(list) => set_passkeys.set(Some(list)),
46 Err(e) => set_passkeys.set({
47 show_error(toast, e.to_string());
48 Some(Vec::new())
49 }),
50 }
51 });
52 };
53 Effect::new(move |_| reload());
54
55 // Re-reading `/me` after a credential change keeps the rest of the app
56 // (and this view's own buttons) in step with what the account now needs.
57 let refresh_me = move || {
58 spawn_local(async move {
59 if let Ok(m) = api::me().await {
60 set_me.set(Some(m));
61 }
62 });
63 };
64
65 // --- password and sign-in requirement, in one save --------------------
66 let save = move |_| {
67 if busy.get() {
68 return;
69 }
70 set_error.set(None);
71 let next = input_value("sec-new");
72 let repeat = input_value("sec-repeat");
73 // An empty new password means "leave it alone", so the repeat box
74 // only matters once something was typed.
75 if !next.is_empty() {
76 if next != repeat {
77 return set_error.set(Some(i18n::t(i18n::k::SETUP_PW_MISMATCH).to_string()));
78 }
79 if next.len() < 8 {
80 return set_error.set(Some(i18n::t(i18n::k::PW_SHORT).to_string()));
81 }
82 }
83 let wanted = mode_draft.get().filter(|m| *m != saved_mode());
84 if next.is_empty() && wanted.is_none() {
85 return;
86 }
87 set_busy.set(true);
88 spawn_local(async move {
89 // The password first: switching to "both" needs one to exist, so
90 // setting a first password and requiring both can be one save.
91 let mut failed = false;
92 if !next.is_empty() {
93 match api::change_password(next).await {
94 Ok(_) => clear_fields(&["sec-new", "sec-repeat"]),
95 Err(e) => {
96 set_error.set(Some(e.to_string()));
97 failed = true;
98 }
99 }
100 }
101 if !failed
102 && let Some(m) = wanted
103 && let Err(e) = api::set_auth_mode(m).await
104 {
105 set_error.set(Some(e.to_string()));
106 failed = true;
107 }
108 if !failed {
109 show(toast, i18n::t(i18n::k::SAVED).to_string());
110 }
111 // Either way `/me` is now the truth: a partial save must not
112 // leave the form claiming something else.
113 set_mode_draft.set(None);
114 refresh_me();
115 set_busy.set(false);
116 });
117 };
118
119 let remove_password = move |_| {
120 if busy.get() {
121 return;
122 }
123 set_error.set(None);
124 set_busy.set(true);
125 spawn_local(async move {
126 match api::delete_password().await {
127 Ok(_) => {
128 show(toast, i18n::t(i18n::k::PASSWORD_REMOVED).to_string());
129 clear_fields(&["sec-new", "sec-repeat"]);
130 refresh_me();
131 }
132 Err(e) => set_error.set(Some(e.to_string())),
133 }
134 set_busy.set(false);
135 });
136 };
137
138 // --- passkeys ---------------------------------------------------------
139 let add_passkey = move |_| {
140 if busy.get() {
141 return;
142 }
143 set_error.set(None);
144 let label = input_value("sec-passkey-name");
145 set_busy.set(true);
146 spawn_local(async move {
147 match api::add_passkey(label).await {
148 Ok(_) => {
149 show(toast, i18n::t(i18n::k::PASSKEY_ADDED).to_string());
150 clear_fields(&["sec-passkey-name"]);
151 reload();
152 refresh_me();
153 }
154 Err(e) => set_error.set(Some(e.to_string())),
155 }
156 set_busy.set(false);
157 });
158 };
159
160 let remove_passkey = Callback::new(move |id: i64| {
161 set_error.set(None);
162 spawn_local(async move {
163 match api::delete_passkey(id).await {
164 Ok(_) => {
165 show(toast, i18n::t(i18n::k::PASSKEY_REMOVED).to_string());
166 reload();
167 refresh_me();
168 }
169 Err(e) => set_error.set(Some(e.to_string())),
170 }
171 });
172 });
173
174 // --- app passwords ----------------------------------------------------
175 let (app_passwords, set_app_passwords) = signal(Option::<Vec<AppPasswordInfo>>::None);
176 // The server cannot show a secret twice, so it lives only in this signal.
177 let (secret, set_secret) = signal(Option::<String>::None);
178
179 let reload_app_passwords = move || {
180 spawn_local(async move {
181 match api::list_app_passwords().await {
182 Ok(list) => set_app_passwords.set(Some(list)),
183 Err(e) => set_app_passwords.set({
184 show_error(toast, e.to_string());
185 Some(Vec::new())
186 }),
187 }
188 });
189 };
190 Effect::new(move |_| reload_app_passwords());
191
192 let create_app_password = move |_| {
193 if busy.get() {
194 return;
195 }
196 set_error.set(None);
197 // Clear the previous secret before the request, not after: two
198 // look-alike hex boxes invite copying the stale one.
199 set_secret.set(None);
200 let label = input_value("sec-app-name");
201 set_busy.set(true);
202 spawn_local(async move {
203 match api::create_app_password(label).await {
204 Ok(created) => {
205 clear_fields(&["sec-app-name"]);
206 // No toast: the secret box appearing below says it landed.
207 set_secret.set(Some(created.secret));
208 reload_app_passwords();
209 }
210 Err(e) => set_error.set(Some(e.to_string())),
211 }
212 set_busy.set(false);
213 });
214 };
215
216 let remove_app_password = Callback::new(move |id: i64| {
217 set_error.set(None);
218 spawn_local(async move {
219 match api::delete_app_password(id).await {
220 Ok(_) => {
221 show(toast, i18n::t(i18n::k::APP_PASSWORD_REMOVED).to_string());
222 reload_app_passwords();
223 }
224 Err(e) => set_error.set(Some(e.to_string())),
225 }
226 });
227 });
228
229 let supported = crate::passkey::supported();
230
231 view! {
232 <div class="security-tab">
233 {move || {
234 let Some(e) = error.get() else {
235 return view! {}.into_any();
236 };
237 view! { <p class="dialog-error">{e}</p> }.into_view().into_any()
238 }}
239
240 <h3 class="setting-label">{i18n::tr(i18n::k::PASSWORD)}</h3>
241 <Show when=move || !has_password()>
242 <p class="muted">{i18n::tr(i18n::k::NO_PASSWORD_SET)}</p>
243 </Show>
244 <div class="security-form">
245 <label class="field">
246 <span>{i18n::tr(i18n::k::NEW_PASSWORD)}</span>
247 <input id="sec-new" type="password" autocomplete="new-password"/>
248 <Show when=move || has_password()>
249 <span class="setting-desc">{i18n::tr(i18n::k::NEW_PASSWORD_HINT)}</span>
250 </Show>
251 </label>
252 <label class="field">
253 <span>{i18n::tr(i18n::k::REPEAT_PASSWORD)}</span>
254 <input id="sec-repeat" type="password" autocomplete="new-password"/>
255 </label>
256 </div>
257
258 <h3 class="setting-label">{i18n::tr(i18n::k::SIGNIN_REQUIREMENT)}</h3>
259 <div class="setting-row setting-row-select">
260 <span>
261 <span class="setting-desc">
262 {move || if mode() == AuthMode::Both {
263 i18n::t(i18n::k::SIGNIN_BOTH_DESC)
264 } else {
265 i18n::t(i18n::k::SIGNIN_EITHER_DESC)
266 }}
267 </span>
268 </span>
269 <select
270 prop:value=move || mode().as_str()
271 on:change=move |ev: web_sys::Event| {
272 if let Some(m) = select_value(&ev).and_then(|v| AuthMode::from_wire(&v)) {
273 set_mode_draft.set(Some(m));
274 }
275 }
276 >
277 <option value="either">{i18n::tr(i18n::k::SIGNIN_EITHER)}</option>
278 <option value="both">{i18n::tr(i18n::k::SIGNIN_BOTH)}</option>
279 </select>
280 </div>
281 <div class="modal-actions">
282 // Only offered when it can succeed: the account needs a
283 // password to drop and a passkey to fall back on.
284 <Show when=move || {
285 has_password()
286 && saved_mode() == AuthMode::Either
287 && passkeys.get().is_some_and(|p| !p.is_empty())
288 }>
289 <button
290 class="btn btn-danger"
291 disabled=move || busy.get()
292 title=i18n::t(i18n::k::REMOVE_PASSWORD_DESC)
293 on:click=remove_password
294 >
295 {i18n::tr(i18n::k::REMOVE_PASSWORD)}
296 </button>
297 </Show>
298 <button class="btn btn-primary" disabled=move || busy.get() on:click=save>
299 {move || if busy.get() {
300 i18n::t(i18n::k::SAVING).to_string()
301 } else {
302 i18n::t(i18n::k::SAVE).to_string()
303 }}
304 </button>
305 </div>
306
307 <h3 class="setting-label">{i18n::tr(i18n::k::PASSKEYS)}</h3>
308 <p class="setting-desc">{i18n::tr(i18n::k::PASSKEYS_HINT)}</p>
309 <Show
310 when=move || supported
311 fallback=|| view! { <p class="muted">{i18n::tr(i18n::k::PASSKEY_UNSUPPORTED)}</p> }
312 >
313 <div class="passkey-list">
314 {move || match passkeys.get() {
315 None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
316 .into_view()
317 .into_any(),
318 Some(list) if list.is_empty() => {
319 view! { <p class="muted">{i18n::tr(i18n::k::NO_PASSKEYS)}</p> }
320 .into_view()
321 .into_any()
322 }
323 Some(list) => list
324 .into_iter()
325 .map(|p| view! { <PasskeyRow info=p on_remove=remove_passkey/> })
326 .collect::<Vec<_>>()
327 .into_view()
328 .into_any(),
329 }}
330 </div>
331 <div class="security-form">
332 <label class="field">
333 <span>{i18n::tr(i18n::k::PASSKEY_LABEL)}</span>
334 <input id="sec-passkey-name" type="text" autocomplete="off"/>
335 </label>
336 <div class="modal-actions">
337 <button class="btn" disabled=move || busy.get() on:click=add_passkey>
338 <Icon name=IconName::Add class="ic-btn".to_string()/>
339 {i18n::tr(i18n::k::ADD_PASSKEY)}
340 </button>
341 </div>
342 </div>
343 </Show>
344
345 <h3 class="setting-label">{i18n::tr(i18n::k::APP_PASSWORDS)}</h3>
346 <p class="setting-desc">{i18n::tr(i18n::k::APP_PASSWORDS_HINT)}</p>
347 <div class="passkey-list">
348 {move || match app_passwords.get() {
349 None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
350 .into_view()
351 .into_any(),
352 Some(list) if list.is_empty() => {
353 view! { <p class="muted">{i18n::tr(i18n::k::NO_APP_PASSWORDS)}</p> }
354 .into_view()
355 .into_any()
356 }
357 Some(list) => list
358 .into_iter()
359 .map(|p| {
360 view! { <AppPasswordRow info=p on_remove=remove_app_password/> }
361 })
362 .collect::<Vec<_>>()
363 .into_view()
364 .into_any(),
365 }}
366 </div>
367 {move || {
368 let Some(value) = secret.get() else {
369 return view! {}.into_any();
370 };
371 let copy = value.clone();
372 view! {
373 <div class="share-link-row">
374 <input
375 class="share-link-input"
376 readonly=true
377 value=value
378 on:click=|ev| select_input(&ev)
379 />
380 <button
381 class="btn"
382 on:click=move |_| copy_to_clipboard(
383 &copy,
384 i18n::t(i18n::k::COPIED),
385 toast,
386 )
387 >
388 <Icon name=IconName::Copy class="ic-btn".to_string()/>
389 {i18n::tr(i18n::k::COPY)}
390 </button>
391 </div>
392 <p class="setting-desc">{i18n::tr(i18n::k::APP_PASSWORD_SECRET_ONCE)}</p>
393 }
394 .into_view()
395 .into_any()
396 }}
397 <div class="security-form">
398 <label class="field">
399 <span>{i18n::tr(i18n::k::APP_PASSWORD_LABEL)}</span>
400 <input id="sec-app-name" type="text" autocomplete="off"/>
401 </label>
402 <div class="modal-actions">
403 <button class="btn" disabled=move || busy.get() on:click=create_app_password>
404 <Icon name=IconName::Add class="ic-btn".to_string()/>
405 {i18n::tr(i18n::k::ADD_APP_PASSWORD)}
406 </button>
407 </div>
408 </div>
409 </div>
410 }
411}
412
413/// One app password in the list. No warning row, unlike a passkey: nothing
414/// about one can be half-working.
415#[component]
416fn AppPasswordRow(info: AppPasswordInfo, on_remove: Callback<i64>) -> impl IntoView {
417 let id = info.id;
418 let used = match &info.last_used_at {
419 Some(t) => i18n::t_fmt(i18n::k::PASSKEY_LAST_USED, &day(t)),
420 None => i18n::t(i18n::k::PASSKEY_NEVER_USED).to_string(),
421 };
422 view! {
423 <div class="passkey-row">
424 <Icon name=IconName::Share class="ic-row".to_string()/>
425 <span class="passkey-main">
426 <span class="passkey-name">{info.name.clone()}</span>
427 <span class="setting-desc">
428 {i18n::t_fmt(i18n::k::PASSKEY_ADDED_ON, &day(&info.created_at))}
429 " · " {used}
430 </span>
431 </span>
432 <button
433 class="icon-btn"
434 title=i18n::t(i18n::k::DELETE)
435 aria-label=i18n::t(i18n::k::DELETE)
436 on:click=move |_| on_remove.run(id)
437 >
438 <Icon name=IconName::Close class="ic-btn".to_string()/>
439 </button>
440 </div>
441 }
442}
443
444/// One passkey in the list.
445///
446/// `discoverable == Some(false)` is the only case that earns a warning. The
447/// browser reports this through an optional, unsigned extension, so `None`
448/// means it did not say, and guessing "not discoverable" there would put a
449/// scary label on a passkey that works perfectly.
450#[component]
451fn PasskeyRow(info: PasskeyInfo, on_remove: Callback<i64>) -> impl IntoView {
452 let id = info.id;
453 let used = match &info.last_used_at {
454 Some(t) => i18n::t_fmt(i18n::k::PASSKEY_LAST_USED, &day(t)),
455 None => i18n::t(i18n::k::PASSKEY_NEVER_USED).to_string(),
456 };
457 let needs_name = info.discoverable == Some(false);
458 view! {
459 <div class="passkey-row">
460 <Icon name=IconName::User class="ic-row".to_string()/>
461 <span class="passkey-main">
462 <span class="passkey-name">{info.name.clone()}</span>
463 <span class="setting-desc">
464 {i18n::t_fmt(i18n::k::PASSKEY_ADDED_ON, &day(&info.created_at))}
465 " · " {used}
466 </span>
467 <Show when=move || needs_name>
468 <span class="passkey-warning" title=i18n::t(i18n::k::PASSKEY_NEEDS_NAME_HINT)>
469 {i18n::tr(i18n::k::PASSKEY_NEEDS_NAME)}
470 </span>
471 </Show>
472 </span>
473 <button
474 class="icon-btn"
475 title=i18n::t(i18n::k::DELETE)
476 aria-label=i18n::t(i18n::k::DELETE)
477 on:click=move |_| on_remove.run(id)
478 >
479 <Icon name=IconName::Close class="ic-btn".to_string()/>
480 </button>
481 </div>
482 }
483}
484
485fn clear_fields(ids: &[&str]) {
486 ids.iter().for_each(|id| api::clear_input(id));
487}
488