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; |
| 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 || 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 | ©, |
| 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] |
| 424 | fn 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 | |
| 464 | fn clear_fields(ids: &[&str]) { |
| 465 | ids.iter().for_each(|id| api::clear_input(id)); |
| 466 | } |
| 467 |