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