security.rs
⎇
Raw
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
10use leptos::prelude::*;
11use wasm_bindgen_futures::spawn_local;
12
13use crate::api::{self, AppPasswordInfo, AuthMode, Me, PasskeyInfo, input_value};
14use crate::components::icon::icon_svg;
15use crate::components::toast::{ToastMsg, show, show_error};
16use crate::i18n;
17use crate::icons::IconName;
18use crate::util::{copy_button, format_date};
19
20#[component]
21pub 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| {
307 // Only `Some(false)` earns a warning: `None` means
308 // the browser did not say.
309 let warn = p.discoverable == Some(false);
310 view! {
311 <CredentialRow
312 icon=IconName::User
313 id=p.id
314 name=p.name
315 created_at=p.created_at
316 last_used_at=p.last_used_at
317 warn=warn
318 on_remove=remove_passkey
319 />
320 }
321 })
322 .collect::<Vec<_>>()
323 .into_any(),
324 }}
325 </div>
326 <div class="security-form">
327 <label class="field">
328 <span>{i18n::tr(i18n::k::PASSKEY_LABEL)}</span>
329 <input id="sec-passkey-name" type="text" autocomplete="off"/>
330 </label>
331 <div class="modal-actions">
332 <button class="btn" disabled=move || busy.get() on:click=add_passkey>
333 {icon_svg(IconName::Add, "ic-btn")}
334 {i18n::tr(i18n::k::ADD_PASSKEY)}
335 </button>
336 </div>
337 </div>
338 </Show>
339
340 <h3 class="setting-label">{i18n::tr(i18n::k::APP_PASSWORDS)}</h3>
341 <p class="setting-desc">{i18n::tr(i18n::k::APP_PASSWORDS_HINT)}</p>
342 <div class="passkey-list">
343 {move || match app_passwords.get() {
344 None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
345 .into_any(),
346 Some(list) if list.is_empty() => {
347 view! { <p class="muted">{i18n::tr(i18n::k::NO_APP_PASSWORDS)}</p> }
348 .into_any()
349 }
350 Some(list) => list
351 .into_iter()
352 .map(|p| view! {
353 <CredentialRow
354 icon=IconName::Share
355 id=p.id
356 name=p.name
357 created_at=p.created_at
358 last_used_at=p.last_used_at
359 warn=false
360 on_remove=remove_app_password
361 />
362 })
363 .collect::<Vec<_>>()
364 .into_any(),
365 }}
366 </div>
367 {move || {
368 let value = secret.get()?;
369 Some(view! {
370 <div class="share-link-row">
371 <input
372 class="share-link-input"
373 readonly=true
374 value=value.clone()
375 on:click={|ev| event_target::<web_sys::HtmlInputElement>(&ev).select()}
376 />
377 {copy_button("btn", None, i18n::tr(i18n::k::COPY), value, i18n::k::COPIED, toast)}
378 </div>
379 <p class="setting-desc">{i18n::tr(i18n::k::APP_PASSWORD_SECRET_ONCE)}</p>
380 })
381 }}
382 <div class="security-form">
383 <label class="field">
384 <span>{i18n::tr(i18n::k::APP_PASSWORD_LABEL)}</span>
385 <input id="sec-app-name" type="text" autocomplete="off"/>
386 </label>
387 <div class="modal-actions">
388 <button class="btn" disabled=move || busy.get() on:click=create_app_password>
389 {icon_svg(IconName::Add, "ic-btn")}
390 {i18n::tr(i18n::k::ADD_APP_PASSWORD)}
391 </button>
392 </div>
393 </div>
394 </div>
395 }
396}
397
398/// One passkey or app password in the list. `warn` marks a passkey that
399/// the browser reported as not discoverable.
400#[component]
401fn CredentialRow(
402 icon: IconName,
403 id: i64,
404 name: String,
405 created_at: String,
406 last_used_at: Option<String>,
407 warn: bool,
408 on_remove: Callback<i64>,
409) -> impl IntoView {
410 let used = match &last_used_at {
411 Some(t) => i18n::t_fmt(i18n::k::PASSKEY_LAST_USED, &format_date(t)),
412 None => i18n::t(i18n::k::PASSKEY_NEVER_USED).to_string(),
413 };
414 view! {
415 <div class="passkey-row">
416 {icon_svg(icon, "ic-row")}
417 <span class="passkey-main">
418 <span class="passkey-name">{name}</span>
419 <span class="setting-desc">
420 {i18n::t_fmt(i18n::k::PASSKEY_ADDED_ON, &format_date(&created_at))}
421 " · " {used}
422 </span>
423 {warn.then(|| view! {
424 <span class="passkey-warning" title=i18n::t(i18n::k::PASSKEY_NEEDS_NAME_HINT)>
425 {i18n::tr(i18n::k::PASSKEY_NEEDS_NAME)}
426 </span>
427 })}
428 </span>
429 <button
430 class="icon-btn"
431 title=i18n::t(i18n::k::DELETE)
432 aria-label=i18n::t(i18n::k::DELETE)
433 on:click=move |_| on_remove.run(id)
434 >
435 {icon_svg(IconName::Close, "ic-btn")}
436 </button>
437 </div>
438 }
439}
440
441fn clear_fields(ids: &[&str]) {
442 ids.iter().for_each(|id| api::clear_input(id));
443}
444