main.rs
⎇
Raw
1mod guest;
2mod http;
3mod locate;
4mod map;
5mod passkey;
6mod settings;
7
8use api::{Challenge, ChallengeAnswer, Credentials, Login, LoginResult, Me, SetupStatus};
9use leptos::prelude::*;
10use leptos::task::spawn_local;
11use wasm_bindgen::JsValue;
12
13fn main() {
14 console_error_panic_hook::set_once();
15 leptos::mount::mount_to_body(App);
16}
17
18#[derive(Clone, PartialEq)]
19enum Session {
20 Loading,
21 Setup,
22 LoggedOut,
23 LoggedIn(Me),
24}
25
26/// The signed-in user, shared through context. `reload` fetches it again after a change.
27#[derive(Clone, Copy)]
28pub struct Account {
29 session: RwSignal<Session>,
30}
31
32impl Account {
33 pub fn me(&self) -> Option<Me> {
34 match self.session.get() {
35 Session::LoggedIn(me) => Some(me),
36 _ => None,
37 }
38 }
39
40 pub fn reload(self) {
41 spawn_local(async move {
42 let next = match http::get::<Me>("/api/me").await {
43 Ok(me) => Session::LoggedIn(me),
44 Err(_) => match http::get::<SetupStatus>("/api/setup").await {
45 Ok(s) if s.needed => Session::Setup,
46 _ => Session::LoggedOut,
47 },
48 };
49 self.session.set(next);
50 });
51 }
52}
53
54#[derive(Clone, Copy, PartialEq)]
55enum Page {
56 Loading,
57 Setup,
58 Login,
59 Main,
60}
61
62#[component]
63fn App() -> impl IntoView {
64 let hash = window().location().hash().unwrap_or_default();
65 match hash.strip_prefix("#l=") {
66 Some(token) => view! { <guest::GuestPage token=token.to_owned() /> }.into_any(),
67 None => view! { <AccountApp /> }.into_any(),
68 }
69}
70
71#[component]
72fn AccountApp() -> impl IntoView {
73 let account = Account {
74 session: RwSignal::new(Session::Loading),
75 };
76 provide_context(account);
77 account.reload();
78 // Only a change of state swaps the page. A reloaded Me must not reset the open tab.
79 let page = Memo::new(move |_| match account.session.get() {
80 Session::Loading => Page::Loading,
81 Session::Setup => Page::Setup,
82 Session::LoggedOut => Page::Login,
83 Session::LoggedIn(_) => Page::Main,
84 });
85 move || match page.get() {
86 Page::Loading => ().into_any(),
87 Page::Setup => view! { <SetupForm /> }.into_any(),
88 Page::Login => view! { <LoginForm /> }.into_any(),
89 Page::Main => view! { <Shell /> }.into_any(),
90 }
91}
92
93#[component]
94fn SetupForm() -> impl IntoView {
95 let account = expect_context::<Account>();
96 let username = RwSignal::new(String::new());
97 let password = RwSignal::new(String::new());
98 let repeat = RwSignal::new(String::new());
99 let error = RwSignal::new(None::<String>);
100
101 let submit = move |ev: leptos::ev::SubmitEvent| {
102 ev.prevent_default();
103 if password.get_untracked() != repeat.get_untracked() {
104 error.set(Some("The passwords do not match.".into()));
105 return;
106 }
107 let body = Credentials {
108 username: username.get_untracked(),
109 password: password.get_untracked(),
110 };
111 spawn_local(async move {
112 match http::post::<LoginResult>("/api/setup", &body).await {
113 Ok(_) => account.reload(),
114 Err(e) => error.set(Some(e.to_string())),
115 }
116 });
117 };
118
119 view! {
120 <form class="login" on:submit=submit>
121 <h1>"opentracker"</h1>
122 <p>"Welcome. Create the admin account to finish the setup."</p>
123 <label>"Username" <input autocomplete="username" required bind:value=username /></label>
124 <label>
125 "Password"
126 <input type="password" autocomplete="new-password" minlength="8" required bind:value=password />
127 </label>
128 <label>
129 "Repeat password"
130 <input type="password" autocomplete="new-password" minlength="8" required bind:value=repeat />
131 </label>
132 <button class="primary">"Create admin account"</button>
133 <p class="error">{move || error.get()}</p>
134 </form>
135 }
136}
137
138/// Answers a passkey challenge in the browser and sends the answer to the server.
139async fn answer(challenge: Challenge) -> Result<LoginResult, String> {
140 let credential = passkey::get(&challenge.options).await?;
141 let body = ChallengeAnswer {
142 state_id: challenge.state_id,
143 credential,
144 name: String::new(),
145 };
146 http::post::<LoginResult>("/api/passkey/login/finish", &body)
147 .await
148 .map_err(|e| e.to_string())
149}
150
151#[component]
152fn LoginForm() -> impl IntoView {
153 let account = expect_context::<Account>();
154 let username = RwSignal::new(String::new());
155 let password = RwSignal::new(String::new());
156 let error = RwSignal::new(None::<String>);
157 let busy = RwSignal::new(false);
158 // Set when a passkey passed and the account also needs its password.
159 let password_step = RwSignal::new(None::<String>);
160
161 // Follows a sign-in step to its end: a session, a passkey prompt, or a password prompt.
162 let handle = move |result: Result<LoginResult, String>| {
163 spawn_local(async move {
164 let mut result = result;
165 if let Ok(LoginResult {
166 passkey_challenge: Some(ch),
167 ..
168 }) = result
169 {
170 result = answer(ch).await;
171 }
172 match result {
173 Ok(r) if r.ok => account.reload(),
174 Ok(LoginResult {
175 password_required: Some(state_id),
176 ..
177 }) => {
178 password.set(String::new());
179 password_step.set(Some(state_id));
180 error.set(None);
181 }
182 Ok(_) => error.set(Some("Sign-in failed.".into())),
183 Err(e) => error.set(Some(e)),
184 }
185 busy.set(false);
186 });
187 };
188
189 let submit = move |ev: leptos::ev::SubmitEvent| {
190 ev.prevent_default();
191 busy.set(true);
192 let body = Login {
193 username: username.get_untracked(),
194 password: password.get_untracked(),
195 state_id: password_step.get_untracked(),
196 };
197 spawn_local(async move {
198 let result = http::post::<LoginResult>("/api/login", &body)
199 .await
200 .map_err(|e| match e.status {
201 401 => "Wrong username or password.".to_string(),
202 429 => "Too many failed attempts. Try again in 15 minutes.".to_string(),
203 _ => e.to_string(),
204 });
205 handle(result);
206 });
207 };
208
209 let with_passkey = move |_| {
210 busy.set(true);
211 spawn_local(async move {
212 let result = match http::post::<Challenge>("/api/passkey/login", &()).await {
213 Ok(ch) => answer(ch).await,
214 Err(e) => Err(e.to_string()),
215 };
216 handle(result);
217 });
218 };
219
220 view! {
221 <form class="login" on:submit=submit>
222 <h1>"opentracker"</h1>
223 <Show
224 when=move || password_step.get().is_none()
225 fallback=move || view! { <p>"This account also needs its password."</p> }
226 >
227 <label>"Username" <input autocomplete="username webauthn" required bind:value=username /></label>
228 </Show>
229 <label>
230 "Password"
231 <input type="password" autocomplete="current-password" required bind:value=password />
232 </label>
233 <button class="primary" disabled=busy>"Log in"</button>
234 <Show when=move || passkey::supported() && password_step.get().is_none()>
235 <button type="button" disabled=busy on:click=with_passkey>"Sign in with a passkey"</button>
236 </Show>
237 <p class="error">{move || error.get()}</p>
238 </form>
239 }
240}
241
242#[derive(Clone, Copy, PartialEq)]
243enum Tab {
244 Map,
245 Settings,
246}
247
248#[component]
249fn Shell() -> impl IntoView {
250 let account = expect_context::<Account>();
251 locate::Locator::provide();
252 let tab = RwSignal::new(Tab::Map);
253 let logout = move |_| {
254 spawn_local(async move {
255 let _ = http::post::<()>("/api/logout", &()).await;
256 account.reload();
257 })
258 };
259 view! {
260 <header>
261 <img class="brand" src="/icon.svg" alt="opentracker" width="28" height="28" />
262 <nav>
263 <button class:active=move || tab.get() == Tab::Map on:click=move |_| tab.set(Tab::Map)>
264 "Map"
265 </button>
266 <button
267 class:active=move || tab.get() == Tab::Settings
268 on:click=move |_| tab.set(Tab::Settings)
269 >
270 "Settings"
271 </button>
272 </nav>
273 <ThemeToggle />
274 <span class="user">{move || account.me().map(|m| m.username)}</span>
275 <button on:click=logout>"Log out"</button>
276 </header>
277 <main>
278 {move || match tab.get() {
279 Tab::Map => view! { <map::MapPage /> }.into_any(),
280 Tab::Settings => view! { <settings::Settings /> }.into_any(),
281 }}
282 </main>
283 }
284}
285
286/// Auto follows the system setting. The choice lives in localStorage, where index.html reads it at load.
287#[component]
288pub fn ThemeToggle() -> impl IntoView {
289 let storage = || window().local_storage().ok().flatten();
290 let initial = storage()
291 .and_then(|s| s.get_item("theme").ok().flatten())
292 .unwrap_or_default();
293 let theme = RwSignal::new(initial);
294 let choose = move |value: &'static str| {
295 theme.set(value.to_owned());
296 let root = document().document_element().unwrap();
297 let _ = match value {
298 "" => root.remove_attribute("data-theme"),
299 v => root.set_attribute("data-theme", v),
300 };
301 if let Some(s) = storage() {
302 let _ = match value {
303 "" => s.remove_item("theme"),
304 v => s.set_item("theme", v),
305 };
306 }
307 };
308 // Each click moves to the next theme: auto, light, dark, auto.
309 let next = move || match theme.get().as_str() {
310 "" => "light",
311 "light" => "dark",
312 _ => "",
313 };
314 let label = move || match theme.get().as_str() {
315 "light" => "light",
316 "dark" => "dark",
317 _ => "auto",
318 };
319 view! {
320 <button
321 class="theme"
322 title=move || format!("Theme: {}", label())
323 aria-label=move || format!("Theme: {}. Click to change.", label())
324 on:click=move |_| choose(next())
325 inner_html=move || {
326 icon(match theme.get().as_str() {
327 "light" => SUN,
328 "dark" => MOON,
329 _ => AUTO,
330 })
331 }
332 ></button>
333 }
334}
335
336const SUN: &str = r#"<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>"#;
337const MOON: &str = r#"<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>"#;
338/// A half-filled circle: the system decides.
339const AUTO: &str =
340 r#"<circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor"/>"#;
341
342fn icon(body: &str) -> String {
343 format!(
344 r#"<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">{body}</svg>"#
345 )
346}
347
348pub fn now_secs() -> i64 {
349 (js_sys::Date::now() / 1000.0) as i64
350}
351
352pub fn fmt_time(ts: i64) -> String {
353 js_sys::Date::new(&JsValue::from_f64(ts as f64 * 1000.0))
354 .to_locale_string("default", &JsValue::UNDEFINED)
355 .into()
356}
357
358pub fn ago(ts: i64) -> String {
359 match (now_secs() - ts).max(0) {
360 d @ 0..60 => format!("{d} s ago"),
361 d @ 60..3600 => format!("{} min ago", d / 60),
362 d @ 3600..86400 => format!("{} h ago", d / 3600),
363 _ => fmt_time(ts),
364 }
365}
366