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