//! The Security tab of profile settings: the password, the passkeys, which of //! the two this account needs to sign in, and the app passwords for WebDAV. //! //! Every rule that keeps an account reachable lives on the server. This view //! only hides controls that cannot work — a browser without WebAuthn, a //! `Remove password` button on an account with no passkeys — so the reason //! for a refusal arrives as the server's own message rather than two //! implementations of the same rule drifting apart. use leptos::prelude::*; use wasm_bindgen_futures::spawn_local; use crate::api::{self, AppPasswordInfo, AuthMode, Me, PasskeyInfo, input_value}; use crate::components::icon::icon_svg; use crate::components::toast::{ToastMsg, show, show_error}; use crate::i18n; use crate::icons::IconName; use crate::util::{copy_button, format_date}; #[component] pub fn SecurityView(me: ReadSignal>, set_me: WriteSignal>) -> impl IntoView { let toast = use_context::().expect("toast context"); let user = move || me.get().and_then(|m| m.user); let has_password = move || user().is_some_and(|u| u.has_password); let saved_mode = move || user().map(|u| u.auth_mode).unwrap_or_default(); let (passkeys, set_passkeys) = signal(Option::>::None); let (busy, set_busy) = signal(false); let (error, set_error) = signal(Option::::None); // The select is a draft until Save. Changing a sign-in requirement by // brushing a dropdown would be the one change on this page nobody asked // for. let (mode_draft, set_mode_draft) = signal(Option::::None); let mode = move || mode_draft.get().unwrap_or_else(saved_mode); let reload = move || { spawn_local(async move { match api::list_passkeys().await { Ok(list) => set_passkeys.set(Some(list)), Err(e) => set_passkeys.set({ show_error(toast, e.to_string()); Some(Vec::new()) }), } }); }; Effect::new(move |_| reload()); // Re-reading `/me` after a credential change keeps the rest of the app // (and this view's own buttons) in step with what the account now needs. let refresh_me = move || crate::views::shell::refresh_me(set_me, toast); // --- password and sign-in requirement, in one save -------------------- let save = move |_| { if busy.get() { return; } set_error.set(None); let next = input_value("sec-new"); let repeat = input_value("sec-repeat"); // An empty new password means "leave it alone", so the repeat box // only matters once something was typed. if !next.is_empty() { if next != repeat { return set_error.set(Some(i18n::t(i18n::k::SETUP_PW_MISMATCH).to_string())); } if next.len() < 8 { return set_error.set(Some(i18n::t(i18n::k::PW_SHORT).to_string())); } } let wanted = mode_draft.get().filter(|m| *m != saved_mode()); if next.is_empty() && wanted.is_none() { return; } set_busy.set(true); spawn_local(async move { // The password first: switching to "both" needs one to exist, so // setting a first password and requiring both can be one save. let mut failed = false; if !next.is_empty() { match api::change_password(next).await { Ok(_) => clear_fields(&["sec-new", "sec-repeat"]), Err(e) => { set_error.set(Some(e.to_string())); failed = true; } } } if !failed && let Some(m) = wanted && let Err(e) = api::set_auth_mode(m).await { set_error.set(Some(e.to_string())); failed = true; } if !failed { show(toast, i18n::t(i18n::k::SAVED).to_string()); } // Either way `/me` is now the truth: a partial save must not // leave the form claiming something else. set_mode_draft.set(None); refresh_me(); set_busy.set(false); }); }; let remove_password = move |_| { if busy.get() { return; } set_error.set(None); set_busy.set(true); spawn_local(async move { match api::delete_password().await { Ok(_) => { show(toast, i18n::t(i18n::k::PASSWORD_REMOVED).to_string()); clear_fields(&["sec-new", "sec-repeat"]); refresh_me(); } Err(e) => set_error.set(Some(e.to_string())), } set_busy.set(false); }); }; // --- passkeys --------------------------------------------------------- let add_passkey = move |_| { if busy.get() { return; } set_error.set(None); let label = input_value("sec-passkey-name"); set_busy.set(true); spawn_local(async move { match api::add_passkey(label).await { Ok(_) => { show(toast, i18n::t(i18n::k::PASSKEY_ADDED).to_string()); clear_fields(&["sec-passkey-name"]); reload(); refresh_me(); } Err(e) => set_error.set(Some(e.to_string())), } set_busy.set(false); }); }; let remove_passkey = Callback::new(move |id: i64| { set_error.set(None); spawn_local(async move { match api::delete_passkey(id).await { Ok(_) => { show(toast, i18n::t(i18n::k::PASSKEY_REMOVED).to_string()); reload(); refresh_me(); } Err(e) => set_error.set(Some(e.to_string())), } }); }); // --- app passwords ---------------------------------------------------- let (app_passwords, set_app_passwords) = signal(Option::>::None); // The server cannot show a secret twice, so it lives only in this signal. let (secret, set_secret) = signal(Option::::None); let reload_app_passwords = move || { spawn_local(async move { match api::list_app_passwords().await { Ok(list) => set_app_passwords.set(Some(list)), Err(e) => set_app_passwords.set({ show_error(toast, e.to_string()); Some(Vec::new()) }), } }); }; Effect::new(move |_| reload_app_passwords()); let create_app_password = move |_| { if busy.get() { return; } set_error.set(None); // Clear the previous secret before the request, not after: two // look-alike hex boxes invite copying the stale one. set_secret.set(None); let label = input_value("sec-app-name"); set_busy.set(true); spawn_local(async move { match api::create_app_password(label).await { Ok(created) => { clear_fields(&["sec-app-name"]); // No toast: the secret box appearing below says it landed. set_secret.set(Some(created.secret)); reload_app_passwords(); } Err(e) => set_error.set(Some(e.to_string())), } set_busy.set(false); }); }; let remove_app_password = Callback::new(move |id: i64| { set_error.set(None); spawn_local(async move { match api::delete_app_password(id).await { Ok(_) => { show(toast, i18n::t(i18n::k::APP_PASSWORD_REMOVED).to_string()); reload_app_passwords(); } Err(e) => set_error.set(Some(e.to_string())), } }); }); let supported = crate::passkey::supported(); view! {
{move || error.get().map(|e| view! {

{e}

})}

{i18n::tr(i18n::k::PASSWORD)}

{i18n::tr(i18n::k::NO_PASSWORD_SET)}

{i18n::tr(i18n::k::SIGNIN_REQUIREMENT)}

{move || if mode() == AuthMode::Both { i18n::t(i18n::k::SIGNIN_BOTH_DESC) } else { i18n::t(i18n::k::SIGNIN_EITHER_DESC) }}

{i18n::tr(i18n::k::PASSKEYS)}

{i18n::tr(i18n::k::PASSKEYS_HINT)}

{i18n::tr(i18n::k::PASSKEY_UNSUPPORTED)}

} >
{move || match passkeys.get() { None => view! {

{i18n::tr(i18n::k::LOADING)}

} .into_any(), Some(list) if list.is_empty() => { view! {

{i18n::tr(i18n::k::NO_PASSKEYS)}

} .into_any() } Some(list) => list .into_iter() .map(|p| view! { }) .collect::>() .into_any(), }}

{i18n::tr(i18n::k::APP_PASSWORDS)}

{i18n::tr(i18n::k::APP_PASSWORDS_HINT)}

{move || match app_passwords.get() { None => view! {

{i18n::tr(i18n::k::LOADING)}

} .into_any(), Some(list) if list.is_empty() => { view! {

{i18n::tr(i18n::k::NO_APP_PASSWORDS)}

} .into_any() } Some(list) => list .into_iter() .map(|p| view! { }) .collect::>() .into_any(), }}
{move || { let value = secret.get()?; Some(view! {

{i18n::tr(i18n::k::APP_PASSWORD_SECRET_ONCE)}

}) }}
} } /// One passkey or app password in the list. #[component] fn CredentialRow( icon: IconName, id: i64, name: String, created_at: String, last_used_at: Option, on_remove: Callback, ) -> impl IntoView { let used = match &last_used_at { Some(t) => i18n::t_fmt(i18n::k::PASSKEY_LAST_USED, &format_date(t)), None => i18n::t(i18n::k::PASSKEY_NEVER_USED).to_string(), }; view! {
{icon_svg(icon, "ic-row")} {name} {i18n::t_fmt(i18n::k::PASSKEY_ADDED_ON, &format_date(&created_at))} " · " {used}
} } fn clear_fields(ids: &[&str]) { ids.iter().for_each(|id| api::clear_input(id)); }