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