use api::{ Challenge, ChallengeAnswer, ChangePassword, Device, DeviceToken, Link, NewDevice, NewLink, NewShare, NewUser, Passkey, ResetPassword, SetDefaultDevice, SetDisplayName, SetRetention, SetRole, SetTwoFactor, Share, ShareSettings, Shares, User, }; use leptos::prelude::*; use leptos::task::spawn_local; use wasm_bindgen::prelude::*; use crate::i18n::tr; 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| { let label = tr(label); 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(&tr(msg)).unwrap_or(false) } fn expiry(e: Option) -> String { match e { None => tr("never"), 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(), }) } } /// A table with one row per item, an error, or the `empty` hint. fn table( items: LocalResource, http::Error>>, empty: &'static str, columns: [&'static str; 3], row: impl Fn(T) -> V + Copy + Send + Sync + 'static, ) -> impl IntoView where T: Clone + 'static, V: IntoView + 'static, { move || { items.get().map(|r| match r { Err(e) => view! {

{e.to_string()}

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

{tr(empty)}

}.into_any(), Ok(list) => view! { {columns.map(|c| view! { })} {list.into_iter().map(row).collect_view()}
{tr(c)}
} .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 set_default = move |device: Option| { spawn_local(async move { match http::put::<()>("/api/me/default-device", &SetDefaultDevice { device }).await { Ok(()) => { error.set(None); account.reload(); } Err(e) => error.set(Some(e.to_string())), } }); }; let token_url = url.clone(); view! {

{tr("Add a device")}

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

{tr("The token is shown only once. Set up the device with:")}

{tr("Address")} " " {token_url.clone()}

{tr("Token")} " " {token}

} }) }}

{tr("The Android app needs no token. Enter this address in the app and sign in:")} " " {url}

{move || error.get()}

{tr("Devices")}

{table(devices, "No devices yet.", ["Name", "Added", "Last upload"], move |d| { let id = d.id; let is_default = move || account.me().and_then(|m| m.default_device) == Some(id); view! { {d.name.clone()} {d.web.then(|| view! {
{tr("The web app, in any browser")}
})}
{tr("Default: the map shows this device first")}
{fmt_time(d.created_at)} {d.last_seen_at.map_or(tr("never"), ago)} } })}

{tr("The map selects the default device first, also for people you share with. Without a default, it selects the device with the newest position.")}

} } /// 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_else(|| tr("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! {
{tr("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, _) => tr("all devices"), (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) => tr("some devices"), }; let trail = match s.trail_since { None => tr("current position only"), Some(0) => tr("full trail"), Some(t) => tr(&format!("trail since {}", fmt_time(t))), }; let precision = match s.precision_m { 0 => tr("exact"), m @ ..1000 => tr(&format!("about {m} m")), m => tr(&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! {

{tr("Nobody.")}

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

{tr("Share my location")}

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

{tr("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! {

{tr("I share my location with")}

{table(s.outgoing, true)}

{tr("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(&tr("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 { tr("Link created and copied.") } else { tr("Link created.") }))); } 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! {

{tr("Create a guest link")}

{tr("Anyone with the link sees your location, without an account.")}

{status(message)}

{tr("Guest links")}

{table(links, "No links yet.", ["Name", "Shows", "Expires"], move |l| { let link = url(&l.token); let mut shows = devices.with(|mine| describe(&l.settings, Some(mine))); if l.has_password { shows = format!("{shows} · {}", tr("password")); } view! { {if l.name.is_empty() { tr("unnamed") } else { l.name.clone() }} {shows} {expiry(l.expires_at)} " " } })}
} } #[component] fn DisplayName() -> impl IntoView { let account = expect_context::(); let me = account.me().unwrap(); let name = RwSignal::new(me.display_name.unwrap_or_default()); let message = RwSignal::new(None::>); let submit = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); let body = SetDisplayName { name: Some(name.get_untracked()), }; spawn_local(async move { match http::put::<()>("/api/me/display-name", &body).await { Ok(()) => { message.set(Some(Ok(tr("Saved.")))); account.reload(); } Err(e) => message.set(Some(Err(e.to_string()))), } }); }; view! {

{tr("Display name")}

{tr("Others see this name on the map. Leave it empty to show your username.")}

{status(message)}
} } #[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(tr("Enter a number of days.")))); 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(tr("Saved.")))); account.reload(); } Err(e) => message.set(Some(Err(e.to_string()))), } }); }; let server_limit = match max { Some(d) => tr(&format!( "The server keeps points for {d} days. You can choose a shorter time." )), None => tr("The server keeps points forever. You can choose a limit."), }; let placeholder = max.map_or(tr("forever"), |d| d.to_string()); view! {

{tr("Location history")}

{server_limit}

{tr("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(tr("The new passwords do not match.")))); 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(tr( "Password saved. Your other sessions are logged out.", )))); 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(tr("Password removed.")))); 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(tr(&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! {

{tr("Password")}

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

} >

" " {tr("Needs a passkey, and two-factor sign-in turned off.")}

{status(pw_message)}

{tr("Passkeys")}

{table(passkeys, "No passkeys yet.", ["Name", "Added", "Last used"], move |k| view! { {k.name.clone()} {fmt_time(k.created_at)} {k.last_used_at.map_or(tr("never"), ago)} })} "This browser does not support passkeys."

} >
{status(key_message)}

{tr("Sign-in")}

{tr("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(tr(&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(&tr(&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(tr(&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(tr(&if is_admin { format!("{name} is now an admin.") } else { format!("{name} is now 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! {

{tr("Add a user")}

{status(message)}

{tr("Users")}

// The list always contains the admin who views it. {table(users, "", ["Username", "Role", "Created"], move |u| { let me = Some(u.id) == my_id; let u2 = u.clone(); let u3 = u.clone(); view! { {u.username.clone()} {fmt_time(u.created_at)} " " } })}
} }