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::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! { {label} }
};
view! {
{tab_button(Tab::Sharing, "Sharing")}
{tab_button(Tab::Links, "Guest links")}
{tab_button(Tab::Devices, "Devices")}
{tab_button(Tab::History, "History")}
{tab_button(Tab::Security, "Security")}
{tab_button(Tab::Users, "Users")}
{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! { {tr(c)} })}
{list.into_iter().map(row).collect_view()}
}
.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! {
{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| view! {
{d.name.clone()}
{d.web.then(|| view! { {tr("The web app, in any browser")}
})}
{fmt_time(d.created_at)}
{d.last_seen_at.map_or(tr("never"), ago)}
{tr("Remove")}
})}
}
}
/// 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("For")}
{tr("until I stop it")}
{tr("1 hour")}
{tr("8 hours")}
{tr("1 day")}
{tr("1 week")}
{tr("1 month")}
{tr("Precision")}
{tr("exact")}
{tr("about 100 m")}
{tr("about 1 km")}
{tr("about 10 km")}
{tr("Trail")}
{tr("none, current position only")}
{tr("from now on")}
{tr("since a time")}
{tr("full history")}
{tr("Since")}
{tr("Devices")}
{tr("All devices, also ones I add later")}
{tr("Only these devices:")}
{move || {
devices
.get()
.into_iter()
.map(|d| {
let id = d.id;
view! {
{d.name}
}
})
.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! {
{tr("User")} {tr("Shows")} {tr("Expires")}
{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! {
{s.username.clone()}
{describe(&s.settings, outgoing.then_some(&mine[..]))}
{expiry(s.expires_at)}
{label}
}
})
.collect_view()}
}
.into_any()
};
view! {
{tr("Share my location")}
{tr("Sharing again with the same person replaces the settings.")}
{move || error.get()}
{move || {
shares
.get()
.map(|r| match r {
Err(e) => view! { }.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)}
{tr("Copy")}
" "
{tr("Delete")}
}
})}
}
}
#[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("Remove password")}
" "
{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)}
{tr("Remove")}
})}
"This browser does not support passkeys." }
>
{status(key_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()}
{tr("user")}
{tr("admin")}
{fmt_time(u.created_at)}
{tr("Reset password")}
" "
{tr("Delete")}
}
})}
}
}