settings.rs
⎇
Raw
1use api::{
2 Challenge, ChallengeAnswer, ChangePassword, Device, DeviceToken, Link, NewDevice, NewLink,
3 NewShare, NewUser, Passkey, ResetPassword, SetDefaultDevice, SetDisplayName, SetRetention,
4 SetRole, SetTwoFactor, Share, ShareSettings, Shares, User,
5};
6use leptos::prelude::*;
7use leptos::task::spawn_local;
8use wasm_bindgen::prelude::*;
9
10use crate::i18n::tr;
11use crate::{Account, ago, fmt_time, http, map, now_secs, passkey};
12
13#[derive(Clone, Copy, PartialEq)]
14enum Tab {
15 Sharing,
16 Links,
17 Devices,
18 History,
19 Security,
20 Users,
21}
22
23#[component]
24pub fn Settings() -> impl IntoView {
25 let account = expect_context::<Account>();
26 let tab = RwSignal::new(Tab::Sharing);
27 let is_admin = move || account.me().is_some_and(|m| m.is_admin);
28 let tab_button = move |t: Tab, label: &'static str| {
29 let label = tr(label);
30 view! { <button class:active=move || tab.get() == t on:click=move |_| tab.set(t)>{label}</button> }
31 };
32 view! {
33 <div class="settings">
34 <nav class="tabs">
35 {tab_button(Tab::Sharing, "Sharing")}
36 {tab_button(Tab::Links, "Guest links")}
37 {tab_button(Tab::Devices, "Devices")}
38 {tab_button(Tab::History, "History")}
39 {tab_button(Tab::Security, "Security")}
40 <Show when=is_admin>{tab_button(Tab::Users, "Users")}</Show>
41 </nav>
42 {move || match tab.get() {
43 Tab::Sharing => view! { <DisplayName /> <SharesSection /> }.into_any(),
44 Tab::Links => view! { <Links /> }.into_any(),
45 Tab::Devices => view! { <Devices /> }.into_any(),
46 Tab::History => view! { <History /> }.into_any(),
47 Tab::Security => view! { <Security /> }.into_any(),
48 Tab::Users => view! { <Users /> }.into_any(),
49 }}
50 </div>
51 }
52}
53
54fn confirm(msg: &str) -> bool {
55 window().confirm_with_message(&tr(msg)).unwrap_or(false)
56}
57
58fn expiry(e: Option<i64>) -> String {
59 match e {
60 None => tr("never"),
61 Some(t) => fmt_time(t),
62 }
63}
64
65/// The base URL devices connect to.
66fn server_url(account: Account) -> String {
67 account
68 .me()
69 .and_then(|m| m.public_url)
70 .unwrap_or_else(|| window().location().origin().unwrap_or_default())
71}
72
73/// Shows an Ok message or an Err message.
74fn status(message: RwSignal<Option<Result<String, String>>>) -> impl IntoView {
75 move || {
76 message.get().map(|m| match m {
77 Ok(text) => view! { <p>{text}</p> }.into_any(),
78 Err(text) => view! { <p class="error">{text}</p> }.into_any(),
79 })
80 }
81}
82
83/// A table with one row per item, an error, or the `empty` hint.
84fn table<T, V>(
85 items: LocalResource<Result<Vec<T>, http::Error>>,
86 empty: &'static str,
87 columns: [&'static str; 3],
88 row: impl Fn(T) -> V + Copy + Send + Sync + 'static,
89) -> impl IntoView
90where
91 T: Clone + 'static,
92 V: IntoView + 'static,
93{
94 move || {
95 items.get().map(|r| match r {
96 Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_any(),
97 Ok(list) if list.is_empty() => view! { <p class="hint">{tr(empty)}</p> }.into_any(),
98 Ok(list) => view! {
99 <table>
100 <tr>{columns.map(|c| view! { <th>{tr(c)}</th> })}<th></th></tr>
101 {list.into_iter().map(row).collect_view()}
102 </table>
103 }
104 .into_any(),
105 })
106 }
107}
108
109#[component]
110fn Devices() -> impl IntoView {
111 let account = expect_context::<Account>();
112 let version = RwSignal::new(0);
113 let error = RwSignal::new(None::<String>);
114 let name = RwSignal::new(String::new());
115 let new_token = RwSignal::new(None::<String>);
116 let devices = LocalResource::new(move || {
117 version.track();
118 http::get::<Vec<Device>>("/api/devices")
119 });
120 let url = server_url(account);
121
122 let add = move |ev: leptos::ev::SubmitEvent| {
123 ev.prevent_default();
124 let body = NewDevice {
125 name: name.get_untracked(),
126 };
127 spawn_local(async move {
128 match http::post::<DeviceToken>("/api/devices", &body).await {
129 Ok(t) => {
130 new_token.set(Some(t.token));
131 name.set(String::new());
132 error.set(None);
133 }
134 Err(e) => error.set(Some(e.to_string())),
135 }
136 version.update(|v| *v += 1);
137 });
138 };
139
140 let remove = move |d: &Device| {
141 let question = if d.web {
142 "Delete the location history of the web app? It starts again when a browser sends a position.".to_string()
143 } else {
144 format!(
145 "Remove device \"{}\" and delete its location history? It can no longer upload positions.",
146 d.name
147 )
148 };
149 if !confirm(&question) {
150 return;
151 }
152 let id = d.id;
153 spawn_local(async move {
154 match http::delete(&format!("/api/devices/{id}")).await {
155 Ok(()) => error.set(None),
156 Err(e) => error.set(Some(e.to_string())),
157 }
158 version.update(|v| *v += 1);
159 });
160 };
161
162 let set_default = move |device: Option<i64>| {
163 spawn_local(async move {
164 match http::put::<()>("/api/me/default-device", &SetDefaultDevice { device }).await {
165 Ok(()) => {
166 error.set(None);
167 account.reload();
168 }
169 Err(e) => error.set(Some(e.to_string())),
170 }
171 });
172 };
173
174 let token_url = url.clone();
175 view! {
176 <section>
177 <h2>{tr("Add a device")}</h2>
178 <form on:submit=add>
179 <label>{tr("Name")} <input placeholder=tr("phone") required bind:value=name /></label>
180 <button class="primary">{tr("Create token")}</button>
181 </form>
182 {move || {
183 new_token
184 .get()
185 .map(|token| {
186 view! {
187 <p>{tr("The token is shown only once. Set up the device with:")}</p>
188 <p>{tr("Address")} " " <code class="copy">{token_url.clone()}</code></p>
189 <p>{tr("Token")} " " <code class="copy">{token}</code></p>
190 }
191 })
192 }}
193 <p class="hint">{tr("The Android app needs no token. Enter this address in the app and sign in:")} " " <code>{url}</code></p>
194 <p class="error">{move || error.get()}</p>
195 </section>
196 <section>
197 <h2>{tr("Devices")}</h2>
198 {table(devices, "No devices yet.", ["Name", "Added", "Last upload"], move |d| {
199 let id = d.id;
200 let is_default = move || account.me().and_then(|m| m.default_device) == Some(id);
201 view! {
202 <tr>
203 <td>
204 {d.name.clone()}
205 {d.web.then(|| view! { <div class="hint">{tr("The web app, in any browser")}</div> })}
206 <Show when=is_default>
207 <div class="hint">{tr("Default: the map shows this device first")}</div>
208 </Show>
209 </td>
210 <td>{fmt_time(d.created_at)}</td>
211 <td>{d.last_seen_at.map_or(tr("never"), ago)}</td>
212 <td class="actions">
213 <button on:click=move |_| set_default((!is_default()).then_some(id))>
214 {move || tr(if is_default() { "Clear default" } else { "Make default" })}
215 </button>
216 <button on:click=move |_| remove(&d)>{tr("Remove")}</button>
217 </td>
218 </tr>
219 }
220 })}
221 <p class="hint">{tr("The map selects the default device first, also for people you share with. Without a default, it selects the device with the newest position.")}</p>
222 </section>
223 }
224}
225
226/// The form fields that shares and guest links have in common.
227#[derive(Clone, Copy)]
228struct ShareOptions {
229 duration: RwSignal<String>,
230 precision: RwSignal<String>,
231 all_devices: RwSignal<bool>,
232 chosen: RwSignal<Vec<i64>>,
233 history: RwSignal<String>,
234 since: RwSignal<String>,
235}
236
237impl ShareOptions {
238 fn new() -> Self {
239 let today = format!("{}T00:00", map::input_values(&js_sys::Date::new_0()).0);
240 ShareOptions {
241 duration: RwSignal::new("0".into()),
242 precision: RwSignal::new("0".into()),
243 all_devices: RwSignal::new(true),
244 chosen: RwSignal::new(Vec::new()),
245 history: RwSignal::new("none".into()),
246 since: RwSignal::new(today),
247 }
248 }
249
250 fn expires_at(&self) -> Option<i64> {
251 let secs: i64 = self.duration.get_untracked().parse().unwrap_or(0);
252 (secs > 0).then(|| now_secs() + secs)
253 }
254
255 fn settings(&self) -> Result<ShareSettings, String> {
256 let trail_since = match self.history.get_untracked().as_str() {
257 "none" => None,
258 "now" => Some(now_secs()),
259 "since" => Some(
260 map::local_time(&self.since.get_untracked())
261 .ok_or_else(|| tr("Pick when the trail starts."))?,
262 ),
263 _ => Some(0),
264 };
265 Ok(ShareSettings {
266 devices: (!self.all_devices.get_untracked()).then(|| self.chosen.get_untracked()),
267 trail_since,
268 precision_m: self.precision.get_untracked().parse().unwrap_or(0),
269 })
270 }
271}
272
273#[component]
274fn ShareOptionsFields(
275 opts: ShareOptions,
276 #[prop(into)] devices: Signal<Vec<Device>>,
277) -> impl IntoView {
278 let ShareOptions {
279 duration,
280 precision,
281 all_devices,
282 chosen,
283 history,
284 since,
285 } = opts;
286 view! {
287 <label>
288 {tr("For")}
289 <select bind:value=duration>
290 <option value="0">{tr("until I stop it")}</option>
291 <option value="3600">{tr("1 hour")}</option>
292 <option value="28800">{tr("8 hours")}</option>
293 <option value="86400">{tr("1 day")}</option>
294 <option value="604800">{tr("1 week")}</option>
295 <option value="2592000">{tr("1 month")}</option>
296 </select>
297 </label>
298 <label>
299 {tr("Precision")}
300 <select bind:value=precision>
301 <option value="0">{tr("exact")}</option>
302 <option value="100">{tr("about 100 m")}</option>
303 <option value="1000">{tr("about 1 km")}</option>
304 <option value="10000">{tr("about 10 km")}</option>
305 </select>
306 </label>
307 <label>
308 {tr("Trail")}
309 <select bind:value=history>
310 <option value="none">{tr("none, current position only")}</option>
311 <option value="now">{tr("from now on")}</option>
312 <option value="since">{tr("since a time")}</option>
313 <option value="all">{tr("full history")}</option>
314 </select>
315 </label>
316 <Show when=move || history.get() == "since">
317 <label>{tr("Since")} <input type="datetime-local" bind:value=since /></label>
318 </Show>
319 <fieldset>
320 <legend>{tr("Devices")}</legend>
321 <label class="radio">
322 <input type="radio" name="share-devices" prop:checked=all_devices on:change=move |_| all_devices.set(true) />
323 {tr("All devices, also ones I add later")}
324 </label>
325 <label class="radio">
326 <input type="radio" name="share-devices" prop:checked=move || !all_devices.get() on:change=move |_| all_devices.set(false) />
327 {tr("Only these devices:")}
328 </label>
329 <div class="device-choice">
330 {move || {
331 devices
332 .get()
333 .into_iter()
334 .map(|d| {
335 let id = d.id;
336 view! {
337 <label class="radio">
338 <input
339 type="checkbox"
340 disabled=all_devices
341 prop:checked=move || chosen.with(|c| c.contains(&id))
342 on:change:target=move |ev| {
343 let on = ev.target().checked();
344 chosen.update(|c| {
345 c.retain(|x| *x != id);
346 if on {
347 c.push(id);
348 }
349 });
350 }
351 />
352 {d.name}
353 </label>
354 }
355 })
356 .collect_view()
357 }}
358 </div>
359 </fieldset>
360 }
361}
362
363/// A short summary of what a share shows. Device names are known only for the caller's own devices.
364fn describe(s: &ShareSettings, mine: Option<&[Device]>) -> String {
365 let devices = match (&s.devices, mine) {
366 (None, _) => tr("all devices"),
367 (Some(ids), Some(mine)) => ids
368 .iter()
369 .filter_map(|id| mine.iter().find(|d| d.id == *id).map(|d| d.name.clone()))
370 .collect::<Vec<_>>()
371 .join(", "),
372 (Some(_), None) => tr("some devices"),
373 };
374 let trail = match s.trail_since {
375 None => tr("current position only"),
376 Some(0) => tr("full trail"),
377 Some(t) => tr(&format!("trail since {}", fmt_time(t))),
378 };
379 let precision = match s.precision_m {
380 0 => tr("exact"),
381 m @ ..1000 => tr(&format!("about {m} m")),
382 m => tr(&format!("about {} km", m / 1000)),
383 };
384 format!("{devices} · {trail} · {precision}")
385}
386
387fn my_devices() -> Signal<Vec<Device>> {
388 let devices = LocalResource::new(|| http::get::<Vec<Device>>("/api/devices"));
389 Signal::derive(move || devices.get().and_then(Result::ok).unwrap_or_default())
390}
391
392#[component]
393fn SharesSection() -> impl IntoView {
394 let version = RwSignal::new(0);
395 let error = RwSignal::new(None::<String>);
396 let viewer = RwSignal::new(String::new());
397 let opts = ShareOptions::new();
398 let usernames = LocalResource::new(|| http::get::<Vec<String>>("/api/usernames"));
399 let devices = my_devices();
400 let shares = LocalResource::new(move || {
401 version.track();
402 http::get::<Shares>("/api/shares")
403 });
404
405 let submit = move |ev: leptos::ev::SubmitEvent| {
406 ev.prevent_default();
407 let settings = match opts.settings() {
408 Ok(s) => s,
409 Err(e) => return error.set(Some(e)),
410 };
411 let body = NewShare {
412 viewer: viewer.get_untracked(),
413 expires_at: opts.expires_at(),
414 settings,
415 };
416 spawn_local(async move {
417 match http::post::<Share>("/api/shares", &body).await {
418 Ok(_) => {
419 viewer.set(String::new());
420 error.set(None);
421 }
422 Err(e) => error.set(Some(e.to_string())),
423 }
424 version.update(|v| *v += 1);
425 });
426 };
427
428 let remove = move |s: &Share, question: String| {
429 if !confirm(&question) {
430 return;
431 }
432 let id = s.id;
433 spawn_local(async move {
434 if let Err(e) = http::delete(&format!("/api/shares/{id}")).await {
435 error.set(Some(e.to_string()));
436 }
437 version.update(|v| *v += 1);
438 });
439 };
440
441 let table = move |list: Vec<Share>, outgoing: bool| {
442 if list.is_empty() {
443 return view! { <p class="hint">{tr("Nobody.")}</p> }.into_any();
444 }
445 let mine = devices.get();
446 view! {
447 <table>
448 <tr><th>{tr("User")}</th><th>{tr("Shows")}</th><th>{tr("Expires")}</th><th></th></tr>
449 {list
450 .into_iter()
451 .map(|s| {
452 let (label, question) = if outgoing {
453 (tr("Stop"), format!("Stop sharing your location with {}?", s.username))
454 } else {
455 (tr("Remove"), format!("Stop seeing the location of {}?", s.username))
456 };
457 view! {
458 <tr>
459 <td>{s.username.clone()}</td>
460 <td>{describe(&s.settings, outgoing.then_some(&mine[..]))}</td>
461 <td>{expiry(s.expires_at)}</td>
462 <td class="actions"><button on:click=move |_| remove(&s, question.clone())>{label}</button></td>
463 </tr>
464 }
465 })
466 .collect_view()}
467 </table>
468 }
469 .into_any()
470 };
471
472 view! {
473 <section>
474 <h2>{tr("Share my location")}</h2>
475 <form on:submit=submit>
476 <label>
477 {tr("With")}
478 <input placeholder=tr("username") list="usernames" autocomplete="off" required bind:value=viewer />
479 </label>
480 <datalist id="usernames">
481 {move || {
482 usernames
483 .get()
484 .and_then(Result::ok)
485 .unwrap_or_default()
486 .into_iter()
487 .map(|name| view! { <option value=name></option> })
488 .collect_view()
489 }}
490 </datalist>
491 <ShareOptionsFields opts devices />
492 <button class="primary">{tr("Share")}</button>
493 </form>
494 <p class="hint">{tr("Sharing again with the same person replaces the settings.")}</p>
495 <p class="error">{move || error.get()}</p>
496 </section>
497 {move || {
498 shares
499 .get()
500 .map(|r| match r {
501 Err(e) => view! { <section><p class="error">{e.to_string()}</p></section> }.into_any(),
502 Ok(s) => {
503 view! {
504 <section>
505 <h2>{tr("I share my location with")}</h2>
506 {table(s.outgoing, true)}
507 </section>
508 <section>
509 <h2>{tr("Shared with me")}</h2>
510 {table(s.incoming, false)}
511 </section>
512 }
513 .into_any()
514 }
515 })
516 }}
517 }
518}
519
520#[wasm_bindgen(inline_js = r#"
521export function copyText(text) {
522 if (!navigator.clipboard) return Promise.resolve(false);
523 return navigator.clipboard.writeText(text).then(() => true, () => false);
524}
525"#)]
526extern "C" {
527 #[wasm_bindgen(js_name = copyText)]
528 async fn copy_text(text: &str) -> JsValue;
529}
530
531/// The clipboard needs HTTPS or localhost. Elsewhere the link appears in a prompt to copy by hand.
532async fn copy(text: String) -> bool {
533 let copied = copy_text(&text).await.as_bool() == Some(true);
534 if !copied {
535 let _ = window().prompt_with_message_and_default(&tr("Copy this link:"), &text);
536 }
537 copied
538}
539
540#[component]
541fn Links() -> impl IntoView {
542 let account = expect_context::<Account>();
543 let version = RwSignal::new(0);
544 let message = RwSignal::new(None::<Result<String, String>>);
545 let name = RwSignal::new(String::new());
546 let password = RwSignal::new(String::new());
547 let opts = ShareOptions::new();
548 let devices = my_devices();
549 let links = LocalResource::new(move || {
550 version.track();
551 http::get::<Vec<Link>>("/api/links")
552 });
553 let base = StoredValue::new(server_url(account));
554 let url = move |token: &str| format!("{}/#l={token}", base.get_value());
555
556 let create = move |ev: leptos::ev::SubmitEvent| {
557 ev.prevent_default();
558 let settings = match opts.settings() {
559 Ok(s) => s,
560 Err(e) => return message.set(Some(Err(e))),
561 };
562 let pw = password.get_untracked();
563 let body = NewLink {
564 name: name.get_untracked(),
565 expires_at: opts.expires_at(),
566 settings,
567 password: (!pw.is_empty()).then_some(pw),
568 };
569 spawn_local(async move {
570 match http::post::<Link>("/api/links", &body).await {
571 Ok(link) => {
572 name.set(String::new());
573 password.set(String::new());
574 let copied = copy(url(&link.token)).await;
575 message.set(Some(Ok(if copied {
576 tr("Link created and copied.")
577 } else {
578 tr("Link created.")
579 })));
580 }
581 Err(e) => message.set(Some(Err(e.to_string()))),
582 }
583 version.update(|v| *v += 1);
584 });
585 };
586
587 let remove = move |l: &Link| {
588 if !confirm("Delete this link? Everyone who has it loses access.") {
589 return;
590 }
591 let id = l.id;
592 spawn_local(async move {
593 if let Err(e) = http::delete(&format!("/api/links/{id}")).await {
594 message.set(Some(Err(e.to_string())));
595 }
596 version.update(|v| *v += 1);
597 });
598 };
599
600 view! {
601 <section>
602 <h2>{tr("Create a guest link")}</h2>
603 <p class="hint">{tr("Anyone with the link sees your location, without an account.")}</p>
604 <form on:submit=create>
605 <label>{tr("Name")} <input placeholder=tr("for Anna") maxlength="100" bind:value=name /></label>
606 <ShareOptionsFields opts devices />
607 <label>
608 {tr("Password (optional)")}
609 <input type="password" autocomplete="new-password" minlength="8" bind:value=password />
610 </label>
611 <button class="primary">{tr("Create link")}</button>
612 </form>
613 {status(message)}
614 </section>
615 <section>
616 <h2>{tr("Guest links")}</h2>
617 {table(links, "No links yet.", ["Name", "Shows", "Expires"], move |l| {
618 let link = url(&l.token);
619 let mut shows = devices.with(|mine| describe(&l.settings, Some(mine)));
620 if l.has_password {
621 shows = format!("{shows} · {}", tr("password"));
622 }
623 view! {
624 <tr>
625 <td>{if l.name.is_empty() { tr("unnamed") } else { l.name.clone() }}</td>
626 <td>{shows}</td>
627 <td>{expiry(l.expires_at)}</td>
628 <td class="actions">
629 <button on:click=move |_| {
630 let link = link.clone();
631 spawn_local(async move {
632 if copy(link).await {
633 message.set(Some(Ok(tr("Link copied."))));
634 }
635 });
636 }>{tr("Copy")}</button>
637 " "
638 <button on:click=move |_| remove(&l)>{tr("Delete")}</button>
639 </td>
640 </tr>
641 }
642 })}
643 </section>
644 }
645}
646
647#[component]
648fn DisplayName() -> impl IntoView {
649 let account = expect_context::<Account>();
650 let me = account.me().unwrap();
651 let name = RwSignal::new(me.display_name.unwrap_or_default());
652 let message = RwSignal::new(None::<Result<String, String>>);
653
654 let submit = move |ev: leptos::ev::SubmitEvent| {
655 ev.prevent_default();
656 let body = SetDisplayName {
657 name: Some(name.get_untracked()),
658 };
659 spawn_local(async move {
660 match http::put::<()>("/api/me/display-name", &body).await {
661 Ok(()) => {
662 message.set(Some(Ok(tr("Saved."))));
663 account.reload();
664 }
665 Err(e) => message.set(Some(Err(e.to_string()))),
666 }
667 });
668 };
669
670 view! {
671 <section>
672 <h2>{tr("Display name")}</h2>
673 <form on:submit=submit>
674 <label>{tr("Name")} <input maxlength="64" placeholder=me.username bind:value=name /></label>
675 <button class="primary">{tr("Save")}</button>
676 </form>
677 <p class="hint">{tr("Others see this name on the map. Leave it empty to show your username.")}</p>
678 {status(message)}
679 </section>
680 }
681}
682
683#[component]
684fn History() -> impl IntoView {
685 let account = expect_context::<Account>();
686 let me = account.me().unwrap();
687 let max = me.max_retention_days;
688 let days = RwSignal::new(me.retention_days.map(|d| d.to_string()).unwrap_or_default());
689 let message = RwSignal::new(None::<Result<String, String>>);
690
691 let submit = move |ev: leptos::ev::SubmitEvent| {
692 ev.prevent_default();
693 let text = days.get_untracked();
694 let value = match text.trim() {
695 "" => None,
696 t => match t.parse::<i64>() {
697 Ok(d) => Some(d),
698 Err(_) => {
699 message.set(Some(Err(tr("Enter a number of days."))));
700 return;
701 }
702 },
703 };
704 let shorter = match (value, account.me().and_then(|m| m.retention_days).or(max)) {
705 (Some(new), Some(old)) => new < old,
706 (Some(_), None) => true,
707 _ => false,
708 };
709 if shorter && !confirm("Points older than this are deleted now. Continue?") {
710 return;
711 }
712 spawn_local(async move {
713 match http::put::<()>("/api/me/retention", &SetRetention { days: value }).await {
714 Ok(()) => {
715 message.set(Some(Ok(tr("Saved."))));
716 account.reload();
717 }
718 Err(e) => message.set(Some(Err(e.to_string()))),
719 }
720 });
721 };
722
723 let server_limit = match max {
724 Some(d) => tr(&format!(
725 "The server keeps points for {d} days. You can choose a shorter time."
726 )),
727 None => tr("The server keeps points forever. You can choose a limit."),
728 };
729 let placeholder = max.map_or(tr("forever"), |d| d.to_string());
730 view! {
731 <section>
732 <h2>{tr("Location history")}</h2>
733 <p class="hint">{server_limit}</p>
734 <form on:submit=submit>
735 <label>
736 {tr("Keep my points for (days)")}
737 <input type="number" min="1" max=max placeholder=placeholder bind:value=days />
738 </label>
739 <button class="primary">{tr("Save")}</button>
740 </form>
741 <p class="hint">{tr("Leave the field empty to use the server setting.")}</p>
742 {status(message)}
743 </section>
744 }
745}
746
747#[component]
748fn Security() -> impl IntoView {
749 let account = expect_context::<Account>();
750 let version = RwSignal::new(0);
751 let passkeys = LocalResource::new(move || {
752 version.track();
753 http::get::<Vec<Passkey>>("/api/passkeys")
754 });
755 // Credential changes alter what the account allows, so Me is fetched again after each one.
756 let changed = move || {
757 version.update(|v| *v += 1);
758 account.reload();
759 };
760 let has_password = move || account.me().is_some_and(|m| m.has_password);
761 let two_factor = move || account.me().is_some_and(|m| m.two_factor);
762 let passkey_count = move || passkeys.get().and_then(|r| r.ok()).map_or(0, |l| l.len());
763
764 let old = RwSignal::new(String::new());
765 let new = RwSignal::new(String::new());
766 let repeat = RwSignal::new(String::new());
767 let pw_message = RwSignal::new(None::<Result<String, String>>);
768 let change_password = move |ev: leptos::ev::SubmitEvent| {
769 ev.prevent_default();
770 if new.get_untracked() != repeat.get_untracked() {
771 pw_message.set(Some(Err(tr("The new passwords do not match."))));
772 return;
773 }
774 let body = ChangePassword {
775 old: has_password().then(|| old.get_untracked()),
776 new: new.get_untracked(),
777 };
778 spawn_local(async move {
779 match http::post::<()>("/api/me/password", &body).await {
780 Ok(()) => {
781 for s in [old, new, repeat] {
782 s.set(String::new());
783 }
784 pw_message.set(Some(Ok(tr(
785 "Password saved. Your other sessions are logged out.",
786 ))));
787 changed();
788 }
789 Err(e) => pw_message.set(Some(Err(e.to_string()))),
790 }
791 });
792 };
793 let remove_password = move |_| {
794 if !confirm("Remove your password? You then sign in with a passkey only.") {
795 return;
796 }
797 spawn_local(async move {
798 match http::delete("/api/me/password").await {
799 Ok(()) => {
800 pw_message.set(Some(Ok(tr("Password removed."))));
801 changed();
802 }
803 Err(e) => pw_message.set(Some(Err(e.to_string()))),
804 }
805 });
806 };
807
808 let key_name = RwSignal::new(String::new());
809 let key_message = RwSignal::new(None::<Result<String, String>>);
810 let add_passkey = move |ev: leptos::ev::SubmitEvent| {
811 ev.prevent_default();
812 spawn_local(async move {
813 let result = async {
814 let ch = http::post::<Challenge>("/api/passkeys/register", &())
815 .await
816 .map_err(|e| e.to_string())?;
817 let credential = passkey::create(&ch.options).await?;
818 let body = ChallengeAnswer {
819 state_id: ch.state_id,
820 credential,
821 name: key_name.get_untracked(),
822 };
823 http::post::<Passkey>("/api/passkeys/register/finish", &body)
824 .await
825 .map_err(|e| e.to_string())
826 }
827 .await;
828 match result {
829 Ok(k) => {
830 key_name.set(String::new());
831 key_message.set(Some(Ok(tr(&format!("Added \"{}\".", k.name)))));
832 changed();
833 }
834 Err(e) => key_message.set(Some(Err(e))),
835 }
836 });
837 };
838 let remove_passkey = move |k: &Passkey| {
839 if !confirm(&format!("Remove the passkey \"{}\"?", k.name)) {
840 return;
841 }
842 let id = k.id;
843 spawn_local(async move {
844 match http::delete(&format!("/api/passkeys/{id}")).await {
845 Ok(()) => key_message.set(None),
846 Err(e) => key_message.set(Some(Err(e.to_string()))),
847 }
848 changed();
849 });
850 };
851
852 let mode_message = RwSignal::new(None::<Result<String, String>>);
853 let set_mode = move |enabled: bool| {
854 spawn_local(async move {
855 match http::put::<()>("/api/me/two-factor", &SetTwoFactor { enabled }).await {
856 Ok(()) => mode_message.set(None),
857 Err(e) => mode_message.set(Some(Err(e.to_string()))),
858 }
859 changed();
860 });
861 };
862
863 view! {
864 <section>
865 <h2>{tr("Password")}</h2>
866 <form on:submit=change_password>
867 <Show when=has_password>
868 <label>
869 {tr("Current")}
870 <input type="password" autocomplete="current-password" required bind:value=old />
871 </label>
872 </Show>
873 <label>
874 {tr("New")}
875 <input type="password" autocomplete="new-password" minlength="8" required bind:value=new />
876 </label>
877 <label>
878 {tr("Repeat new")}
879 <input type="password" autocomplete="new-password" minlength="8" required bind:value=repeat />
880 </label>
881 <button class="primary">{move || tr(if has_password() { "Change" } else { "Set password" })}</button>
882 </form>
883 <Show
884 when=has_password
885 fallback=|| view! { <p class="hint">"You have no password. You sign in with a passkey."</p> }
886 >
887 <p>
888 <button
889 disabled=move || passkey_count() == 0 || two_factor()
890 on:click=remove_password
891 >
892 {tr("Remove password")}
893 </button>
894 " "
895 <span class="hint">{tr("Needs a passkey, and two-factor sign-in turned off.")}</span>
896 </p>
897 </Show>
898 {status(pw_message)}
899 </section>
900 <section>
901 <h2>{tr("Passkeys")}</h2>
902 {table(passkeys, "No passkeys yet.", ["Name", "Added", "Last used"], move |k| view! {
903 <tr>
904 <td>{k.name.clone()}</td>
905 <td>{fmt_time(k.created_at)}</td>
906 <td>{k.last_used_at.map_or(tr("never"), ago)}</td>
907 <td class="actions"><button on:click=move |_| remove_passkey(&k)>{tr("Remove")}</button></td>
908 </tr>
909 })}
910 <Show
911 when=passkey::supported
912 fallback=|| view! { <p class="hint">"This browser does not support passkeys."</p> }
913 >
914 <form on:submit=add_passkey>
915 <label>{tr("Name")} <input placeholder=tr("laptop, phone, key") bind:value=key_name /></label>
916 <button class="primary">{tr("Add passkey")}</button>
917 </form>
918 </Show>
919 {status(key_message)}
920 </section>
921 <section>
922 <h2>{tr("Sign-in")}</h2>
923 <label class="radio">
924 <input type="radio" name="mode" prop:checked=move || !two_factor() on:change=move |_| set_mode(false) />
925 {tr("Password or passkey")}
926 </label>
927 <label class="radio">
928 <input
929 type="radio"
930 name="mode"
931 prop:checked=two_factor
932 disabled=move || !has_password() || passkey_count() == 0
933 on:change=move |_| set_mode(true)
934 />
935 {tr("Password and passkey (two-factor)")}
936 </label>
937 <p class="hint">{tr("Two-factor sign-in needs a password and at least one passkey.")}</p>
938 {status(mode_message)}
939 </section>
940 }
941}
942
943#[component]
944fn Users() -> impl IntoView {
945 let account = expect_context::<Account>();
946 let my_id = account.me().map(|m| m.id);
947 let version = RwSignal::new(0);
948 let message = RwSignal::new(None::<Result<String, String>>);
949 let users = LocalResource::new(move || {
950 version.track();
951 http::get::<Vec<User>>("/api/users")
952 });
953 let username = RwSignal::new(String::new());
954 let password = RwSignal::new(String::new());
955 let is_admin = RwSignal::new(false);
956
957 let create = move |ev: leptos::ev::SubmitEvent| {
958 ev.prevent_default();
959 let body = NewUser {
960 username: username.get_untracked(),
961 password: password.get_untracked(),
962 is_admin: is_admin.get_untracked(),
963 };
964 spawn_local(async move {
965 match http::post::<User>("/api/users", &body).await {
966 Ok(u) => {
967 username.set(String::new());
968 password.set(String::new());
969 is_admin.set(false);
970 message.set(Some(Ok(tr(&format!("Created {}.", u.username)))));
971 }
972 Err(e) => message.set(Some(Err(e.to_string()))),
973 }
974 version.update(|v| *v += 1);
975 });
976 };
977
978 let reset = move |u: &User| {
979 let prompt = format!(
980 "New password for {}. This also removes their passkeys, disconnects their devices, turns off two-factor sign-in and logs them out.",
981 u.username
982 );
983 let Ok(Some(password)) = window().prompt_with_message(&tr(&prompt)) else {
984 return;
985 };
986 let (id, name) = (u.id, u.username.clone());
987 spawn_local(async move {
988 match http::post::<()>(
989 &format!("/api/users/{id}/password"),
990 &ResetPassword { password },
991 )
992 .await
993 {
994 Ok(()) => message.set(Some(Ok(tr(&format!("Password reset for {name}."))))),
995 Err(e) => message.set(Some(Err(e.to_string()))),
996 }
997 });
998 };
999
1000 let set_role = move |u: &User, is_admin: bool| {
1001 let (id, name) = (u.id, u.username.clone());
1002 spawn_local(async move {
1003 match http::put::<()>(&format!("/api/users/{id}/role"), &SetRole { is_admin }).await {
1004 Ok(()) => message.set(Some(Ok(tr(&if is_admin {
1005 format!("{name} is now an admin.")
1006 } else {
1007 format!("{name} is now a user.")
1008 })))),
1009 Err(e) => message.set(Some(Err(e.to_string()))),
1010 }
1011 version.update(|v| *v += 1);
1012 });
1013 };
1014
1015 let remove = move |u: &User| {
1016 if !confirm(&format!("Delete {} and all their data?", u.username)) {
1017 return;
1018 }
1019 let id = u.id;
1020 spawn_local(async move {
1021 if let Err(e) = http::delete(&format!("/api/users/{id}")).await {
1022 message.set(Some(Err(e.to_string())));
1023 }
1024 version.update(|v| *v += 1);
1025 });
1026 };
1027
1028 view! {
1029 <section>
1030 <h2>{tr("Add a user")}</h2>
1031 <form on:submit=create>
1032 <label>{tr("Username")} <input autocomplete="off" required bind:value=username /></label>
1033 <label>
1034 {tr("Password")}
1035 <input type="password" autocomplete="new-password" minlength="8" required bind:value=password />
1036 </label>
1037 <label class="radio"><input type="checkbox" bind:checked=is_admin />{tr("Admin")}</label>
1038 <button class="primary">{tr("Add")}</button>
1039 </form>
1040 {status(message)}
1041 </section>
1042 <section>
1043 <h2>{tr("Users")}</h2>
1044 // The list always contains the admin who views it.
1045 {table(users, "", ["Username", "Role", "Created"], move |u| {
1046 let me = Some(u.id) == my_id;
1047 let u2 = u.clone();
1048 let u3 = u.clone();
1049 view! {
1050 <tr>
1051 <td>{u.username.clone()}</td>
1052 <td>
1053 <select
1054 aria-label=tr("Role")
1055 disabled=me
1056 on:change:target=move |ev| set_role(&u3, ev.target().value() == "admin")
1057 >
1058 <option value="user" selected=!u.is_admin>{tr("user")}</option>
1059 <option value="admin" selected=u.is_admin>{tr("admin")}</option>
1060 </select>
1061 </td>
1062 <td>{fmt_time(u.created_at)}</td>
1063 <td class="actions">
1064 <button on:click=move |_| reset(&u2)>{tr("Reset password")}</button>
1065 " "
1066 <button disabled=me on:click=move |_| remove(&u)>{tr("Delete")}</button>
1067 </td>
1068 </tr>
1069 }
1070 })}
1071 </section>
1072 }
1073}
1074