security.rs
| 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 | |
| 10 | use leptos::prelude::*; |
| 11 | use wasm_bindgen_futures::spawn_local; |
| 12 | |
| 13 | use crate::api::{self, AppPasswordInfo, AuthMode, Me, PasskeyInfo, input_value}; |
| 14 | use crate::components::icon::Icon; |
| 15 | use crate::components::toast::{ToastMsg, show, show_error}; |
| 16 | use crate::i18n; |
| 17 | use crate::icons::IconName; |
| 18 | use 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. |
| 22 | fn day(ts: &str) -> String { |
| 23 | ts.split('T').next().unwrap_or(ts).to_string() |
| 24 | } |
| 25 | |
| 26 | #[component] |
| 27 | pub 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 | ©, |
| 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] |
| 416 | fn 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] |
| 451 | fn 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 | |
| 485 | fn clear_fields(ids: &[&str]) { |
| 486 | ids.iter().for_each(|id| api::clear_input(id)); |
| 487 | } |
| 488 |