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