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