mod app; mod guest; mod http; mod locate; mod map; mod passkey; mod settings; use api::{Challenge, ChallengeAnswer, Credentials, Login, LoginResult, Me, SetupStatus}; use leptos::prelude::*; use leptos::task::spawn_local; use wasm_bindgen::JsValue; fn main() { console_error_panic_hook::set_once(); leptos::mount::mount_to_body(App); } #[derive(Clone, PartialEq)] enum Session { Loading, Setup, LoggedOut, LoggedIn(Me), } /// The signed-in user, shared through context. `reload` fetches it again after a change. #[derive(Clone, Copy)] pub struct Account { session: RwSignal, } impl Account { pub fn me(&self) -> Option { match self.session.get() { Session::LoggedIn(me) => Some(me), _ => None, } } pub fn reload(self) { spawn_local(async move { let next = match http::get::("/api/me").await { Ok(me) => Session::LoggedIn(me), Err(_) => match http::get::("/api/setup").await { Ok(s) if s.needed => Session::Setup, _ => Session::LoggedOut, }, }; self.session.set(next); }); } } #[derive(Clone, Copy, PartialEq)] enum Page { Loading, Setup, Login, Main, } #[component] fn App() -> impl IntoView { let hash = window().location().hash().unwrap_or_default(); if let Some(token) = hash.strip_prefix("#l=") { return view! { }.into_any(); } if hash == "#device" { return view! { }.into_any(); } view! { }.into_any() } /// `pair` shows the app pairing page instead of the main UI after sign-in. #[component] fn AccountApp(pair: bool) -> impl IntoView { let account = Account { session: RwSignal::new(Session::Loading), }; provide_context(account); account.reload(); // Only a change of state swaps the page. A reloaded Me must not reset the open tab. let page = Memo::new(move |_| match account.session.get() { Session::Loading => Page::Loading, Session::Setup => Page::Setup, Session::LoggedOut => Page::Login, Session::LoggedIn(_) => Page::Main, }); move || match page.get() { Page::Loading => ().into_any(), Page::Setup => view! { }.into_any(), Page::Login => view! { }.into_any(), Page::Main if pair => view! { }.into_any(), Page::Main => view! { }.into_any(), } } #[component] fn SetupForm() -> impl IntoView { let account = expect_context::(); let username = RwSignal::new(String::new()); let password = RwSignal::new(String::new()); let repeat = RwSignal::new(String::new()); let error = RwSignal::new(None::); let submit = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); if password.get_untracked() != repeat.get_untracked() { error.set(Some("The passwords do not match.".into())); return; } let body = Credentials { username: username.get_untracked(), password: password.get_untracked(), }; spawn_local(async move { match http::post::("/api/setup", &body).await { Ok(_) => account.reload(), Err(e) => error.set(Some(e.to_string())), } }); }; view! { } } /// Answers a passkey challenge in the browser and sends the answer to the server. async fn answer(challenge: Challenge) -> Result { let credential = passkey::get(&challenge.options).await?; let body = ChallengeAnswer { state_id: challenge.state_id, credential, name: String::new(), }; http::post::("/api/passkey/login/finish", &body) .await .map_err(|e| e.to_string()) } #[component] fn LoginForm() -> impl IntoView { let account = expect_context::(); let username = RwSignal::new(String::new()); let password = RwSignal::new(String::new()); let error = RwSignal::new(None::); let busy = RwSignal::new(false); // Follows a sign-in step to its end: a session, or a passkey prompt after the password. let handle = move |result: Result| { spawn_local(async move { let mut result = result; if let Ok(LoginResult { passkey_challenge: Some(ch), .. }) = result { result = answer(ch).await; } match result { Ok(r) if r.ok => account.reload(), Ok(_) => error.set(Some("Sign-in failed.".into())), Err(e) => error.set(Some(e)), } busy.set(false); }); }; let submit = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); busy.set(true); let body = Login { username: username.get_untracked(), password: password.get_untracked(), }; spawn_local(async move { let result = http::post::("/api/login", &body) .await .map_err(|e| match e.status { 401 => "Wrong username or password.".to_string(), 429 => "Too many failed attempts. Try again in 15 minutes.".to_string(), _ => e.to_string(), }); handle(result); }); }; let with_passkey = move |_| { busy.set(true); spawn_local(async move { let result = match http::post::("/api/passkey/login", &()).await { Ok(ch) => answer(ch).await, Err(e) => Err(e.to_string()), }; handle(result); }); }; view! { } } #[derive(Clone, Copy, PartialEq)] enum Tab { Map, Settings, } #[component] fn Shell() -> impl IntoView { let account = expect_context::(); locate::Locator::provide(); let tab = RwSignal::new(Tab::Map); let logout = move |_| { spawn_local(async move { let _ = http::post::<()>("/api/logout", &()).await; account.reload(); }) }; view! {
opentracker {move || account.me().map(|m| m.username)}
{move || match tab.get() { Tab::Map => view! { }.into_any(), Tab::Settings => view! { }.into_any(), }}
} } /// Auto follows the system setting. The choice lives in localStorage, where index.html reads it at load. #[component] pub fn ThemeToggle() -> impl IntoView { let storage = || window().local_storage().ok().flatten(); let initial = storage() .and_then(|s| s.get_item("theme").ok().flatten()) .unwrap_or_default(); let theme = RwSignal::new(initial); let choose = move |value: &'static str| { theme.set(value.to_owned()); let root = document().document_element().unwrap(); let _ = match value { "" => root.remove_attribute("data-theme"), v => root.set_attribute("data-theme", v), }; if let Some(s) = storage() { let _ = match value { "" => s.remove_item("theme"), v => s.set_item("theme", v), }; } }; // Each click moves to the next theme: auto, light, dark, auto. let next = move || match theme.get().as_str() { "" => "light", "light" => "dark", _ => "", }; let label = move || match theme.get().as_str() { "light" => "light", "dark" => "dark", _ => "auto", }; view! { } } const SUN: &str = r#""#; const MOON: &str = r#""#; /// A half-filled circle: the system decides. const AUTO: &str = r#""#; fn icon(body: &str) -> String { format!( r#""# ) } pub fn now_secs() -> i64 { (js_sys::Date::now() / 1000.0) as i64 } pub fn fmt_time(ts: i64) -> String { js_sys::Date::new(&JsValue::from_f64(ts as f64 * 1000.0)) .to_locale_string("default", &JsValue::UNDEFINED) .into() } pub fn ago(ts: i64) -> String { match (now_secs() - ts).max(0) { d @ 0..60 => format!("{d} s ago"), d @ 60..3600 => format!("{} min ago", d / 60), d @ 3600..86400 => format!("{} h ago", d / 3600), _ => fmt_time(ts), } }