security.rs
| 1 | //! The Security tab of profile settings: the password, the passkeys, and |
| 2 | //! which of the two this account needs to sign in. |
| 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, 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::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 | let supported = crate::passkey::supported(); |
| 175 | |
| 176 | view! { |
| 177 | <div class="security-tab"> |
| 178 | {move || { |
| 179 | let Some(e) = error.get() else { |
| 180 | return view! {}.into_any(); |
| 181 | }; |
| 182 | view! { <p class="dialog-error">{e}</p> }.into_view().into_any() |
| 183 | }} |
| 184 | |
| 185 | <h3 class="setting-label">{i18n::tr(i18n::k::PASSWORD)}</h3> |
| 186 | <Show when=move || !has_password()> |
| 187 | <p class="muted">{i18n::tr(i18n::k::NO_PASSWORD_SET)}</p> |
| 188 | </Show> |
| 189 | <div class="security-form"> |
| 190 | <label class="field"> |
| 191 | <span>{i18n::tr(i18n::k::NEW_PASSWORD)}</span> |
| 192 | <input id="sec-new" type="password" autocomplete="new-password"/> |
| 193 | <Show when=move || has_password()> |
| 194 | <span class="setting-desc">{i18n::tr(i18n::k::NEW_PASSWORD_HINT)}</span> |
| 195 | </Show> |
| 196 | </label> |
| 197 | <label class="field"> |
| 198 | <span>{i18n::tr(i18n::k::REPEAT_PASSWORD)}</span> |
| 199 | <input id="sec-repeat" type="password" autocomplete="new-password"/> |
| 200 | </label> |
| 201 | </div> |
| 202 | |
| 203 | <h3 class="setting-label">{i18n::tr(i18n::k::SIGNIN_REQUIREMENT)}</h3> |
| 204 | <div class="setting-row setting-row-select"> |
| 205 | <span> |
| 206 | <span class="setting-desc"> |
| 207 | {move || if mode() == AuthMode::Both { |
| 208 | i18n::t(i18n::k::SIGNIN_BOTH_DESC) |
| 209 | } else { |
| 210 | i18n::t(i18n::k::SIGNIN_EITHER_DESC) |
| 211 | }} |
| 212 | </span> |
| 213 | </span> |
| 214 | <select |
| 215 | prop:value=move || mode().as_str() |
| 216 | on:change=move |ev: web_sys::Event| { |
| 217 | if let Some(m) = select_value(&ev).and_then(|v| AuthMode::from_wire(&v)) { |
| 218 | set_mode_draft.set(Some(m)); |
| 219 | } |
| 220 | } |
| 221 | > |
| 222 | <option value="either">{i18n::tr(i18n::k::SIGNIN_EITHER)}</option> |
| 223 | <option value="both">{i18n::tr(i18n::k::SIGNIN_BOTH)}</option> |
| 224 | </select> |
| 225 | </div> |
| 226 | <div class="modal-actions"> |
| 227 | // Only offered when it can succeed: the account needs a |
| 228 | // password to drop and a passkey to fall back on. |
| 229 | <Show when=move || { |
| 230 | has_password() |
| 231 | && saved_mode() == AuthMode::Either |
| 232 | && passkeys.get().is_some_and(|p| !p.is_empty()) |
| 233 | }> |
| 234 | <button |
| 235 | class="btn btn-danger" |
| 236 | disabled=move || busy.get() |
| 237 | title=i18n::t(i18n::k::REMOVE_PASSWORD_DESC) |
| 238 | on:click=remove_password |
| 239 | > |
| 240 | {i18n::tr(i18n::k::REMOVE_PASSWORD)} |
| 241 | </button> |
| 242 | </Show> |
| 243 | <button class="btn btn-primary" disabled=move || busy.get() on:click=save> |
| 244 | {move || if busy.get() { |
| 245 | i18n::t(i18n::k::SAVING).to_string() |
| 246 | } else { |
| 247 | i18n::t(i18n::k::SAVE).to_string() |
| 248 | }} |
| 249 | </button> |
| 250 | </div> |
| 251 | |
| 252 | <h3 class="setting-label">{i18n::tr(i18n::k::PASSKEYS)}</h3> |
| 253 | <p class="setting-desc">{i18n::tr(i18n::k::PASSKEYS_HINT)}</p> |
| 254 | <Show |
| 255 | when=move || supported |
| 256 | fallback=|| view! { <p class="muted">{i18n::tr(i18n::k::PASSKEY_UNSUPPORTED)}</p> } |
| 257 | > |
| 258 | <div class="passkey-list"> |
| 259 | {move || match passkeys.get() { |
| 260 | None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> } |
| 261 | .into_view() |
| 262 | .into_any(), |
| 263 | Some(list) if list.is_empty() => { |
| 264 | view! { <p class="muted">{i18n::tr(i18n::k::NO_PASSKEYS)}</p> } |
| 265 | .into_view() |
| 266 | .into_any() |
| 267 | } |
| 268 | Some(list) => list |
| 269 | .into_iter() |
| 270 | .map(|p| view! { <PasskeyRow info=p on_remove=remove_passkey/> }) |
| 271 | .collect::<Vec<_>>() |
| 272 | .into_view() |
| 273 | .into_any(), |
| 274 | }} |
| 275 | </div> |
| 276 | <div class="security-form"> |
| 277 | <label class="field"> |
| 278 | <span>{i18n::tr(i18n::k::PASSKEY_LABEL)}</span> |
| 279 | <input id="sec-passkey-name" type="text" autocomplete="off"/> |
| 280 | </label> |
| 281 | <div class="modal-actions"> |
| 282 | <button class="btn" disabled=move || busy.get() on:click=add_passkey> |
| 283 | <Icon name=IconName::Add class="ic-btn".to_string()/> |
| 284 | {i18n::tr(i18n::k::ADD_PASSKEY)} |
| 285 | </button> |
| 286 | </div> |
| 287 | </div> |
| 288 | </Show> |
| 289 | </div> |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | /// One passkey in the list. |
| 294 | /// |
| 295 | /// `discoverable == Some(false)` is the only case that earns a warning. The |
| 296 | /// browser reports this through an optional, unsigned extension, so `None` |
| 297 | /// means it did not say, and guessing "not discoverable" there would put a |
| 298 | /// scary label on a passkey that works perfectly. |
| 299 | #[component] |
| 300 | fn PasskeyRow(info: PasskeyInfo, on_remove: Callback<i64>) -> impl IntoView { |
| 301 | let id = info.id; |
| 302 | let used = match &info.last_used_at { |
| 303 | Some(t) => i18n::t_fmt(i18n::k::PASSKEY_LAST_USED, &day(t)), |
| 304 | None => i18n::t(i18n::k::PASSKEY_NEVER_USED).to_string(), |
| 305 | }; |
| 306 | let needs_name = info.discoverable == Some(false); |
| 307 | view! { |
| 308 | <div class="passkey-row"> |
| 309 | <Icon name=IconName::User class="ic-row".to_string()/> |
| 310 | <span class="passkey-main"> |
| 311 | <span class="passkey-name">{info.name.clone()}</span> |
| 312 | <span class="setting-desc"> |
| 313 | {i18n::t_fmt(i18n::k::PASSKEY_ADDED_ON, &day(&info.created_at))} |
| 314 | " · " {used} |
| 315 | </span> |
| 316 | <Show when=move || needs_name> |
| 317 | <span class="passkey-warning" title=i18n::t(i18n::k::PASSKEY_NEEDS_NAME_HINT)> |
| 318 | {i18n::tr(i18n::k::PASSKEY_NEEDS_NAME)} |
| 319 | </span> |
| 320 | </Show> |
| 321 | </span> |
| 322 | <button |
| 323 | class="icon-btn" |
| 324 | title=i18n::t(i18n::k::DELETE) |
| 325 | aria-label=i18n::t(i18n::k::DELETE) |
| 326 | on:click=move |_| on_remove.run(id) |
| 327 | > |
| 328 | <Icon name=IconName::Close class="ic-btn".to_string()/> |
| 329 | </button> |
| 330 | </div> |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | fn clear_fields(ids: &[&str]) { |
| 335 | ids.iter().for_each(|id| api::clear_input(id)); |
| 336 | } |
| 337 |