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