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