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;
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 || error.get().map(|e| view! { <p class="dialog-error">{e}</p> })}
234
235 <h3 class="setting-label">{i18n::tr(i18n::k::PASSWORD)}</h3>
236 <Show when=move || !has_password()>
237 <p class="muted">{i18n::tr(i18n::k::NO_PASSWORD_SET)}</p>
238 </Show>
239 <div class="security-form">
240 <label class="field">
241 <span>{i18n::tr(i18n::k::NEW_PASSWORD)}</span>
242 <input id="sec-new" type="password" autocomplete="new-password"/>
243 <Show when=move || has_password()>
244 <span class="setting-desc">{i18n::tr(i18n::k::NEW_PASSWORD_HINT)}</span>
245 </Show>
246 </label>
247 <label class="field">
248 <span>{i18n::tr(i18n::k::REPEAT_PASSWORD)}</span>
249 <input id="sec-repeat" type="password" autocomplete="new-password"/>
250 </label>
251 </div>
252
253 <h3 class="setting-label">{i18n::tr(i18n::k::SIGNIN_REQUIREMENT)}</h3>
254 <div class="setting-row setting-row-select">
255 <span>
256 <span class="setting-desc">
257 {move || if mode() == AuthMode::Both {
258 i18n::t(i18n::k::SIGNIN_BOTH_DESC)
259 } else {
260 i18n::t(i18n::k::SIGNIN_EITHER_DESC)
261 }}
262 </span>
263 </span>
264 <select
265 prop:value=move || mode().as_str()
266 on:change=move |ev: web_sys::Event| {
267 if let Some(m) = AuthMode::from_wire(&event_target_value(&ev)) {
268 set_mode_draft.set(Some(m));
269 }
270 }
271 >
272 <option value="either">{i18n::tr(i18n::k::SIGNIN_EITHER)}</option>
273 <option value="both">{i18n::tr(i18n::k::SIGNIN_BOTH)}</option>
274 </select>
275 </div>
276 <div class="modal-actions">
277 // Only offered when it can succeed: the account needs a
278 // password to drop and a passkey to fall back on.
279 <Show when=move || {
280 has_password()
281 && saved_mode() == AuthMode::Either
282 && passkeys.get().is_some_and(|p| !p.is_empty())
283 }>
284 <button
285 class="btn btn-danger"
286 disabled=move || busy.get()
287 title=i18n::t(i18n::k::REMOVE_PASSWORD_DESC)
288 on:click=remove_password
289 >
290 {i18n::tr(i18n::k::REMOVE_PASSWORD)}
291 </button>
292 </Show>
293 <button class="btn btn-primary" disabled=move || busy.get() on:click=save>
294 {move || if busy.get() {
295 i18n::t(i18n::k::SAVING).to_string()
296 } else {
297 i18n::t(i18n::k::SAVE).to_string()
298 }}
299 </button>
300 </div>
301
302 <h3 class="setting-label">{i18n::tr(i18n::k::PASSKEYS)}</h3>
303 <p class="setting-desc">{i18n::tr(i18n::k::PASSKEYS_HINT)}</p>
304 <Show
305 when=move || supported
306 fallback=|| view! { <p class="muted">{i18n::tr(i18n::k::PASSKEY_UNSUPPORTED)}</p> }
307 >
308 <div class="passkey-list">
309 {move || match passkeys.get() {
310 None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
311 .into_any(),
312 Some(list) if list.is_empty() => {
313 view! { <p class="muted">{i18n::tr(i18n::k::NO_PASSKEYS)}</p> }
314 .into_any()
315 }
316 Some(list) => list
317 .into_iter()
318 .map(|p| {
319 // Only `Some(false)` earns a warning: `None` means
320 // the browser did not say.
321 let warn = p.discoverable == Some(false);
322 view! {
323 <CredentialRow
324 icon=IconName::User
325 id=p.id
326 name=p.name
327 created_at=p.created_at
328 last_used_at=p.last_used_at
329 warn=warn
330 on_remove=remove_passkey
331 />
332 }
333 })
334 .collect::<Vec<_>>()
335 .into_any(),
336 }}
337 </div>
338 <div class="security-form">
339 <label class="field">
340 <span>{i18n::tr(i18n::k::PASSKEY_LABEL)}</span>
341 <input id="sec-passkey-name" type="text" autocomplete="off"/>
342 </label>
343 <div class="modal-actions">
344 <button class="btn" disabled=move || busy.get() on:click=add_passkey>
345 <Icon name=IconName::Add class="ic-btn".to_string()/>
346 {i18n::tr(i18n::k::ADD_PASSKEY)}
347 </button>
348 </div>
349 </div>
350 </Show>
351
352 <h3 class="setting-label">{i18n::tr(i18n::k::APP_PASSWORDS)}</h3>
353 <p class="setting-desc">{i18n::tr(i18n::k::APP_PASSWORDS_HINT)}</p>
354 <div class="passkey-list">
355 {move || match app_passwords.get() {
356 None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
357 .into_any(),
358 Some(list) if list.is_empty() => {
359 view! { <p class="muted">{i18n::tr(i18n::k::NO_APP_PASSWORDS)}</p> }
360 .into_any()
361 }
362 Some(list) => list
363 .into_iter()
364 .map(|p| view! {
365 <CredentialRow
366 icon=IconName::Share
367 id=p.id
368 name=p.name
369 created_at=p.created_at
370 last_used_at=p.last_used_at
371 warn=false
372 on_remove=remove_app_password
373 />
374 })
375 .collect::<Vec<_>>()
376 .into_any(),
377 }}
378 </div>
379 {move || {
380 let value = secret.get()?;
381 let copy = value.clone();
382 Some(view! {
383 <div class="share-link-row">
384 <input
385 class="share-link-input"
386 readonly=true
387 value=value
388 on:click={|ev| event_target::<web_sys::HtmlInputElement>(&ev).select()}
389 />
390 <button
391 class="btn"
392 on:click=move |_| copy_to_clipboard(
393 &copy,
394 i18n::t(i18n::k::COPIED),
395 toast,
396 )
397 >
398 <Icon name=IconName::Copy class="ic-btn".to_string()/>
399 {i18n::tr(i18n::k::COPY)}
400 </button>
401 </div>
402 <p class="setting-desc">{i18n::tr(i18n::k::APP_PASSWORD_SECRET_ONCE)}</p>
403 })
404 }}
405 <div class="security-form">
406 <label class="field">
407 <span>{i18n::tr(i18n::k::APP_PASSWORD_LABEL)}</span>
408 <input id="sec-app-name" type="text" autocomplete="off"/>
409 </label>
410 <div class="modal-actions">
411 <button class="btn" disabled=move || busy.get() on:click=create_app_password>
412 <Icon name=IconName::Add class="ic-btn".to_string()/>
413 {i18n::tr(i18n::k::ADD_APP_PASSWORD)}
414 </button>
415 </div>
416 </div>
417 </div>
418 }
419}
420
421/// One passkey or app password in the list. `warn` marks a passkey that
422/// the browser reported as not discoverable.
423#[component]
424fn CredentialRow(
425 icon: IconName,
426 id: i64,
427 name: String,
428 created_at: String,
429 last_used_at: Option<String>,
430 warn: bool,
431 on_remove: Callback<i64>,
432) -> impl IntoView {
433 let used = match &last_used_at {
434 Some(t) => i18n::t_fmt(i18n::k::PASSKEY_LAST_USED, &day(t)),
435 None => i18n::t(i18n::k::PASSKEY_NEVER_USED).to_string(),
436 };
437 view! {
438 <div class="passkey-row">
439 <Icon name=icon class="ic-row".to_string()/>
440 <span class="passkey-main">
441 <span class="passkey-name">{name}</span>
442 <span class="setting-desc">
443 {i18n::t_fmt(i18n::k::PASSKEY_ADDED_ON, &day(&created_at))}
444 " · " {used}
445 </span>
446 {warn.then(|| view! {
447 <span class="passkey-warning" title=i18n::t(i18n::k::PASSKEY_NEEDS_NAME_HINT)>
448 {i18n::tr(i18n::k::PASSKEY_NEEDS_NAME)}
449 </span>
450 })}
451 </span>
452 <button
453 class="icon-btn"
454 title=i18n::t(i18n::k::DELETE)
455 aria-label=i18n::t(i18n::k::DELETE)
456 on:click=move |_| on_remove.run(id)
457 >
458 <Icon name=IconName::Close class="ic-btn".to_string()/>
459 </button>
460 </div>
461 }
462}
463
464fn clear_fields(ids: &[&str]) {
465 ids.iter().for_each(|id| api::clear_input(id));
466}
467