use api::{ Challenge, ChallengeAnswer, ChangePassword, Device, DeviceToken, Link, NewDevice, NewLink, NewShare, NewUser, Passkey, ResetPassword, SetRetention, SetRole, SetTwoFactor, Share, ShareSettings, Shares, User, }; use leptos::prelude::*; use leptos::task::spawn_local; use wasm_bindgen::prelude::*; use crate::{Account, ago, fmt_time, http, map, now_secs, passkey}; #[derive(Clone, Copy, PartialEq)] enum Tab { Sharing, Links, Devices, History, Security, Users, } #[component] pub fn Settings() -> impl IntoView { let account = expect_context::(); let tab = RwSignal::new(Tab::Sharing); let is_admin = move || account.me().is_some_and(|m| m.is_admin); let tab_button = move |t: Tab, label: &'static str| { view! { } }; view! {
{move || match tab.get() { Tab::Sharing => view! { }.into_any(), Tab::Links => view! { }.into_any(), Tab::Devices => view! { }.into_any(), Tab::History => view! { }.into_any(), Tab::Security => view! { }.into_any(), Tab::Users => view! { }.into_any(), }}
} } fn confirm(msg: &str) -> bool { window().confirm_with_message(msg).unwrap_or(false) } fn expiry(e: Option) -> String { match e { None => "never".into(), Some(t) => fmt_time(t), } } /// The base URL devices connect to. fn server_url(account: Account) -> String { account .me() .and_then(|m| m.public_url) .unwrap_or_else(|| window().location().origin().unwrap_or_default()) } /// Shows an Ok message or an Err message. fn status(message: RwSignal>>) -> impl IntoView { move || { message.get().map(|m| match m { Ok(text) => view! {

{text}

}.into_any(), Err(text) => view! {

{text}

}.into_any(), }) } } #[component] fn Devices() -> impl IntoView { let account = expect_context::(); let version = RwSignal::new(0); let error = RwSignal::new(None::); let name = RwSignal::new(String::new()); let new_token = RwSignal::new(None::); let devices = LocalResource::new(move || { version.track(); http::get::>("/api/devices") }); let url = server_url(account); let add = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); let body = NewDevice { name: name.get_untracked(), }; spawn_local(async move { match http::post::("/api/devices", &body).await { Ok(t) => { new_token.set(Some(t.token)); name.set(String::new()); error.set(None); } Err(e) => error.set(Some(e.to_string())), } version.update(|v| *v += 1); }); }; let remove = move |d: &Device| { let question = if d.web { "Delete the location history of the web app? It starts again when a browser sends a position.".to_string() } else { format!( "Remove device \"{}\" and delete its location history? It can no longer upload positions.", d.name ) }; if !confirm(&question) { return; } let id = d.id; spawn_local(async move { match http::delete(&format!("/api/devices/{id}")).await { Ok(()) => error.set(None), Err(e) => error.set(Some(e.to_string())), } version.update(|v| *v += 1); }); }; let token_url = url.clone(); view! {

"Add a device"

{move || { new_token .get() .map(|token| { view! {

"The token is shown only once. Set up the device with:"

{format!("ot use-token {token_url} {token}")}

} }) }}

"The Android app needs no token. Enter " {url} " in the app and sign in."

{move || error.get()}

"Devices"

{move || { devices .get() .map(|r| match r { Err(e) => view! {

{e.to_string()}

}.into_any(), Ok(list) if list.is_empty() => view! {

"No devices yet."

}.into_any(), Ok(list) => { view! { {list .into_iter() .map(|d| { view! { } }) .collect_view()}
"Name""Added""Last upload"
{d.name.clone()} {d.web.then(|| view! {
"The web app, in any browser"
})}
{fmt_time(d.created_at)} {d.last_seen_at.map_or("never".into(), ago)}
} .into_any() } }) }}
} } /// The form fields that shares and guest links have in common. #[derive(Clone, Copy)] struct ShareOptions { duration: RwSignal, precision: RwSignal, all_devices: RwSignal, chosen: RwSignal>, history: RwSignal, since: RwSignal, } impl ShareOptions { fn new() -> Self { let today = format!("{}T00:00", map::input_values(&js_sys::Date::new_0()).0); ShareOptions { duration: RwSignal::new("0".into()), precision: RwSignal::new("0".into()), all_devices: RwSignal::new(true), chosen: RwSignal::new(Vec::new()), history: RwSignal::new("none".into()), since: RwSignal::new(today), } } fn expires_at(&self) -> Option { let secs: i64 = self.duration.get_untracked().parse().unwrap_or(0); (secs > 0).then(|| now_secs() + secs) } fn settings(&self) -> Result { let trail_since = match self.history.get_untracked().as_str() { "none" => None, "now" => Some(now_secs()), "since" => Some( map::local_time(&self.since.get_untracked()) .ok_or("Pick when the trail starts.")?, ), _ => Some(0), }; Ok(ShareSettings { devices: (!self.all_devices.get_untracked()).then(|| self.chosen.get_untracked()), trail_since, precision_m: self.precision.get_untracked().parse().unwrap_or(0), }) } } #[component] fn ShareOptionsFields( opts: ShareOptions, #[prop(into)] devices: Signal>, ) -> impl IntoView { let ShareOptions { duration, precision, all_devices, chosen, history, since, } = opts; view! {
"Devices"
{move || { devices .get() .into_iter() .map(|d| { let id = d.id; view! { } }) .collect_view() }}
} } /// A short summary of what a share shows. Device names are known only for the caller's own devices. fn describe(s: &ShareSettings, mine: Option<&[Device]>) -> String { let devices = match (&s.devices, mine) { (None, _) => "all devices".to_string(), (Some(ids), Some(mine)) => ids .iter() .filter_map(|id| mine.iter().find(|d| d.id == *id).map(|d| d.name.clone())) .collect::>() .join(", "), (Some(_), None) => "some devices".to_string(), }; let trail = match s.trail_since { None => "current position only".to_string(), Some(0) => "full trail".to_string(), Some(t) => format!("trail since {}", fmt_time(t)), }; let precision = match s.precision_m { 0 => "exact".to_string(), m @ ..1000 => format!("about {m} m"), m => format!("about {} km", m / 1000), }; format!("{devices} · {trail} · {precision}") } fn my_devices() -> Signal> { let devices = LocalResource::new(|| http::get::>("/api/devices")); Signal::derive(move || devices.get().and_then(Result::ok).unwrap_or_default()) } #[component] fn SharesSection() -> impl IntoView { let version = RwSignal::new(0); let error = RwSignal::new(None::); let viewer = RwSignal::new(String::new()); let opts = ShareOptions::new(); let usernames = LocalResource::new(|| http::get::>("/api/usernames")); let devices = my_devices(); let shares = LocalResource::new(move || { version.track(); http::get::("/api/shares") }); let submit = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); let settings = match opts.settings() { Ok(s) => s, Err(e) => return error.set(Some(e)), }; let body = NewShare { viewer: viewer.get_untracked(), expires_at: opts.expires_at(), settings, }; spawn_local(async move { match http::post::("/api/shares", &body).await { Ok(_) => { viewer.set(String::new()); error.set(None); } Err(e) => error.set(Some(e.to_string())), } version.update(|v| *v += 1); }); }; let remove = move |s: &Share, question: String| { if !confirm(&question) { return; } let id = s.id; spawn_local(async move { if let Err(e) = http::delete(&format!("/api/shares/{id}")).await { error.set(Some(e.to_string())); } version.update(|v| *v += 1); }); }; let table = move |list: Vec, outgoing: bool| { if list.is_empty() { return view! {

"Nobody."

}.into_any(); } let mine = devices.get(); view! { {list .into_iter() .map(|s| { let (label, question) = if outgoing { ("Stop", format!("Stop sharing your location with {}?", s.username)) } else { ("Remove", format!("Stop seeing the location of {}?", s.username)) }; view! { } }) .collect_view()}
"User""Shows""Expires"
{s.username.clone()} {describe(&s.settings, outgoing.then_some(&mine[..]))} {expiry(s.expires_at)}
} .into_any() }; view! {

"Share my location"

{move || { usernames .get() .and_then(Result::ok) .unwrap_or_default() .into_iter() .map(|name| view! { }) .collect_view() }}

"Sharing again with the same person replaces the settings."

{move || error.get()}

{move || { shares .get() .map(|r| match r { Err(e) => view! {

{e.to_string()}

}.into_any(), Ok(s) => { view! {

"I share my location with"

{table(s.outgoing, true)}

"Shared with me"

{table(s.incoming, false)}
} .into_any() } }) }} } } #[wasm_bindgen(inline_js = r#" export function copyText(text) { if (!navigator.clipboard) return Promise.resolve(false); return navigator.clipboard.writeText(text).then(() => true, () => false); } "#)] extern "C" { #[wasm_bindgen(js_name = copyText)] async fn copy_text(text: &str) -> JsValue; } /// The clipboard needs HTTPS or localhost. Elsewhere the link appears in a prompt to copy by hand. async fn copy(text: String) -> bool { let copied = copy_text(&text).await.as_bool() == Some(true); if !copied { let _ = window().prompt_with_message_and_default("Copy this link:", &text); } copied } #[component] fn Links() -> impl IntoView { let account = expect_context::(); let version = RwSignal::new(0); let message = RwSignal::new(None::>); let name = RwSignal::new(String::new()); let password = RwSignal::new(String::new()); let opts = ShareOptions::new(); let devices = my_devices(); let links = LocalResource::new(move || { version.track(); http::get::>("/api/links") }); let base = StoredValue::new(server_url(account)); let url = move |token: &str| format!("{}/#l={token}", base.get_value()); let create = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); let settings = match opts.settings() { Ok(s) => s, Err(e) => return message.set(Some(Err(e))), }; let pw = password.get_untracked(); let body = NewLink { name: name.get_untracked(), expires_at: opts.expires_at(), settings, password: (!pw.is_empty()).then_some(pw), }; spawn_local(async move { match http::post::("/api/links", &body).await { Ok(link) => { name.set(String::new()); password.set(String::new()); let copied = copy(url(&link.token)).await; message.set(Some(Ok(if copied { "Link created and copied.".into() } else { "Link created.".into() }))); } Err(e) => message.set(Some(Err(e.to_string()))), } version.update(|v| *v += 1); }); }; let remove = move |l: &Link| { if !confirm("Delete this link? Everyone who has it loses access.") { return; } let id = l.id; spawn_local(async move { if let Err(e) = http::delete(&format!("/api/links/{id}")).await { message.set(Some(Err(e.to_string()))); } version.update(|v| *v += 1); }); }; view! {

"Create a guest link"

"Anyone with the link sees your location, without an account."

{status(message)}

"Guest links"

{move || { links .get() .map(|r| match r { Err(e) => view! {

{e.to_string()}

}.into_any(), Ok(list) if list.is_empty() => view! {

"No links yet."

}.into_any(), Ok(list) => { let mine = devices.get(); view! { {list .into_iter() .map(|l| { let link = url(&l.token); let mut shows = describe(&l.settings, Some(&mine)); if l.has_password { shows.push_str(" · password"); } view! { } }) .collect_view()}
"Name""Shows""Expires"
{if l.name.is_empty() { "unnamed".to_string() } else { l.name.clone() }} {shows} {expiry(l.expires_at)} " "
} .into_any() } }) }}
} } #[component] fn History() -> impl IntoView { let account = expect_context::(); let me = account.me().unwrap(); let max = me.max_retention_days; let days = RwSignal::new(me.retention_days.map(|d| d.to_string()).unwrap_or_default()); let message = RwSignal::new(None::>); let submit = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); let text = days.get_untracked(); let value = match text.trim() { "" => None, t => match t.parse::() { Ok(d) => Some(d), Err(_) => { message.set(Some(Err("Enter a number of days.".into()))); return; } }, }; let shorter = match (value, account.me().and_then(|m| m.retention_days).or(max)) { (Some(new), Some(old)) => new < old, (Some(_), None) => true, _ => false, }; if shorter && !confirm("Points older than this are deleted now. Continue?") { return; } spawn_local(async move { match http::put::<()>("/api/me/retention", &SetRetention { days: value }).await { Ok(()) => { message.set(Some(Ok("Saved.".into()))); account.reload(); } Err(e) => message.set(Some(Err(e.to_string()))), } }); }; let server_limit = match max { Some(d) => format!("The server keeps points for {d} days. You can choose a shorter time."), None => "The server keeps points forever. You can choose a limit.".into(), }; let placeholder = max.map_or("forever".into(), |d| d.to_string()); view! {

"Location history"

{server_limit}

"Leave the field empty to use the server setting."

{status(message)}
} } #[component] fn Security() -> impl IntoView { let account = expect_context::(); let version = RwSignal::new(0); let passkeys = LocalResource::new(move || { version.track(); http::get::>("/api/passkeys") }); // Credential changes alter what the account allows, so Me is fetched again after each one. let changed = move || { version.update(|v| *v += 1); account.reload(); }; let has_password = move || account.me().is_some_and(|m| m.has_password); let two_factor = move || account.me().is_some_and(|m| m.two_factor); let passkey_count = move || passkeys.get().and_then(|r| r.ok()).map_or(0, |l| l.len()); let old = RwSignal::new(String::new()); let new = RwSignal::new(String::new()); let repeat = RwSignal::new(String::new()); let pw_message = RwSignal::new(None::>); let change_password = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); if new.get_untracked() != repeat.get_untracked() { pw_message.set(Some(Err("The new passwords do not match.".into()))); return; } let body = ChangePassword { old: has_password().then(|| old.get_untracked()), new: new.get_untracked(), }; spawn_local(async move { match http::post::<()>("/api/me/password", &body).await { Ok(()) => { for s in [old, new, repeat] { s.set(String::new()); } pw_message.set(Some(Ok( "Password saved. Your other sessions are logged out.".into(), ))); changed(); } Err(e) => pw_message.set(Some(Err(e.to_string()))), } }); }; let remove_password = move |_| { if !confirm("Remove your password? You then sign in with a passkey only.") { return; } spawn_local(async move { match http::delete("/api/me/password").await { Ok(()) => { pw_message.set(Some(Ok("Password removed.".into()))); changed(); } Err(e) => pw_message.set(Some(Err(e.to_string()))), } }); }; let key_name = RwSignal::new(String::new()); let key_message = RwSignal::new(None::>); let add_passkey = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); spawn_local(async move { let result = async { let ch = http::post::("/api/passkeys/register", &()) .await .map_err(|e| e.to_string())?; let credential = passkey::create(&ch.options).await?; let body = ChallengeAnswer { state_id: ch.state_id, credential, name: key_name.get_untracked(), }; http::post::("/api/passkeys/register/finish", &body) .await .map_err(|e| e.to_string()) } .await; match result { Ok(k) => { key_name.set(String::new()); key_message.set(Some(Ok(format!("Added \"{}\".", k.name)))); changed(); } Err(e) => key_message.set(Some(Err(e))), } }); }; let remove_passkey = move |k: &Passkey| { if !confirm(&format!("Remove the passkey \"{}\"?", k.name)) { return; } let id = k.id; spawn_local(async move { match http::delete(&format!("/api/passkeys/{id}")).await { Ok(()) => key_message.set(None), Err(e) => key_message.set(Some(Err(e.to_string()))), } changed(); }); }; let mode_message = RwSignal::new(None::>); let set_mode = move |enabled: bool| { spawn_local(async move { match http::put::<()>("/api/me/two-factor", &SetTwoFactor { enabled }).await { Ok(()) => mode_message.set(None), Err(e) => mode_message.set(Some(Err(e.to_string()))), } changed(); }); }; view! {

"Password"

"You have no password. You sign in with a passkey."

} >

" " "Needs a passkey, and two-factor sign-in turned off."

{status(pw_message)}

"Passkeys"

{move || { passkeys .get() .map(|r| match r { Err(e) => view! {

{e.to_string()}

}.into_any(), Ok(list) if list.is_empty() => view! {

"No passkeys yet."

}.into_any(), Ok(list) => { view! { {list .into_iter() .map(|k| { view! { } }) .collect_view()}
"Name""Added""Last used"
{k.name.clone()} {fmt_time(k.created_at)} {k.last_used_at.map_or("never".into(), ago)}
} .into_any() } }) }} "This browser does not support passkeys."

} >
{status(key_message)}

"Sign-in"

"Two-factor sign-in needs a password and at least one passkey."

{status(mode_message)}
} } #[component] fn Users() -> impl IntoView { let account = expect_context::(); let my_id = account.me().map(|m| m.id); let version = RwSignal::new(0); let message = RwSignal::new(None::>); let users = LocalResource::new(move || { version.track(); http::get::>("/api/users") }); let username = RwSignal::new(String::new()); let password = RwSignal::new(String::new()); let is_admin = RwSignal::new(false); let create = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); let body = NewUser { username: username.get_untracked(), password: password.get_untracked(), is_admin: is_admin.get_untracked(), }; spawn_local(async move { match http::post::("/api/users", &body).await { Ok(u) => { username.set(String::new()); password.set(String::new()); is_admin.set(false); message.set(Some(Ok(format!("Created {}.", u.username)))); } Err(e) => message.set(Some(Err(e.to_string()))), } version.update(|v| *v += 1); }); }; let reset = move |u: &User| { let prompt = format!( "New password for {}. This also removes their passkeys, disconnects their devices, turns off two-factor sign-in and logs them out.", u.username ); let Ok(Some(password)) = window().prompt_with_message(&prompt) else { return; }; let (id, name) = (u.id, u.username.clone()); spawn_local(async move { match http::post::<()>( &format!("/api/users/{id}/password"), &ResetPassword { password }, ) .await { Ok(()) => message.set(Some(Ok(format!("Password reset for {name}.")))), Err(e) => message.set(Some(Err(e.to_string()))), } }); }; let set_role = move |u: &User, is_admin: bool| { let (id, name) = (u.id, u.username.clone()); spawn_local(async move { match http::put::<()>(&format!("/api/users/{id}/role"), &SetRole { is_admin }).await { Ok(()) => message.set(Some(Ok(format!( "{name} is now {}.", if is_admin { "an admin" } else { "a user" } )))), Err(e) => message.set(Some(Err(e.to_string()))), } version.update(|v| *v += 1); }); }; let remove = move |u: &User| { if !confirm(&format!("Delete {} and all their data?", u.username)) { return; } let id = u.id; spawn_local(async move { if let Err(e) = http::delete(&format!("/api/users/{id}")).await { message.set(Some(Err(e.to_string()))); } version.update(|v| *v += 1); }); }; view! {

"Add a user"

{status(message)}

"Users"

{move || { users .get() .map(|r| match r { Err(e) => view! {

{e.to_string()}

}.into_any(), Ok(list) => { view! { {list .into_iter() .map(|u| { let me = Some(u.id) == my_id; let u2 = u.clone(); let u3 = u.clone(); view! { } }) .collect_view()}
"Username""Role""Created"
{u.username.clone()} {fmt_time(u.created_at)} " "
} .into_any() } }) }}
} }