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