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
165 // Follows a sign-in step to its end: a session, or a passkey prompt after the password.
166 let handle = move |result: Result<LoginResult, String>| {
167 spawn_local(async move {
168 let mut result = result;
169 if let Ok(LoginResult {
170 passkey_challenge: Some(ch),
171 ..
172 }) = result
173 {
174 result = answer(ch).await;
175 }
176 match result {
177 Ok(r) if r.ok => account.reload(),
178 Ok(_) => error.set(Some("Sign-in failed.".into())),
179 Err(e) => error.set(Some(e)),
180 }
181 busy.set(false);
182 });
183 };
184
185 let submit = move |ev: leptos::ev::SubmitEvent| {
186 ev.prevent_default();
187 busy.set(true);
188 let body = Login {
189 username: username.get_untracked(),
190 password: password.get_untracked(),
191 };
192 spawn_local(async move {
193 let result = http::post::<LoginResult>("/api/login", &body)
194 .await
195 .map_err(|e| match e.status {
196 401 => "Wrong username or password.".to_string(),
197 429 => "Too many failed attempts. Try again in 15 minutes.".to_string(),
198 _ => e.to_string(),
199 });
200 handle(result);
201 });
202 };
203
204 let with_passkey = move |_| {
205 busy.set(true);
206 spawn_local(async move {
207 let result = match http::post::<Challenge>("/api/passkey/login", &()).await {
208 Ok(ch) => answer(ch).await,
209 Err(e) => Err(e.to_string()),
210 };
211 handle(result);
212 });
213 };
214
215 view! {
216 <form class="login" on:submit=submit>
217 <h1>"opentracker"</h1>
218 <label>"Username" <input autocomplete="username webauthn" required bind:value=username /></label>
219 <label>
220 "Password"
221 <input type="password" autocomplete="current-password" required bind:value=password />
222 </label>
223 <button class="primary" disabled=busy>"Log in"</button>
224 <Show when=passkey::supported>
225 <button type="button" disabled=busy on:click=with_passkey>"Sign in with a passkey"</button>
226 </Show>
227 <p class="error">{move || error.get()}</p>
228 </form>
229 }
230}
231
232#[derive(Clone, Copy, PartialEq)]
233enum Tab {
234 Map,
235 Settings,
236}
237
238#[component]
239fn Shell() -> impl IntoView {
240 let account = expect_context::<Account>();
241 locate::Locator::provide();
242 let tab = RwSignal::new(Tab::Map);
243 let logout = move |_| {
244 spawn_local(async move {
245 let _ = http::post::<()>("/api/logout", &()).await;
246 account.reload();
247 })
248 };
249 view! {
250 <header>
251 <img class="brand" src="/icon.svg" alt="opentracker" width="28" height="28" />
252 <nav>
253 <button class:active=move || tab.get() == Tab::Map on:click=move |_| tab.set(Tab::Map)>
254 "Map"
255 </button>
256 <button
257 class:active=move || tab.get() == Tab::Settings
258 on:click=move |_| tab.set(Tab::Settings)
259 >
260 "Settings"
261 </button>
262 </nav>
263 <ThemeToggle />
264 <span class="user">{move || account.me().map(|m| m.username)}</span>
265 <button on:click=logout>"Log out"</button>
266 </header>
267 <main>
268 {move || match tab.get() {
269 Tab::Map => view! { <map::MapPage /> }.into_any(),
270 Tab::Settings => view! { <settings::Settings /> }.into_any(),
271 }}
272 </main>
273 }
274}
275
276/// Auto follows the system setting. The choice lives in localStorage, where index.html reads it at load.
277#[component]
278pub fn ThemeToggle() -> impl IntoView {
279 let storage = || window().local_storage().ok().flatten();
280 let initial = storage()
281 .and_then(|s| s.get_item("theme").ok().flatten())
282 .unwrap_or_default();
283 let theme = RwSignal::new(initial);
284 let choose = move |value: &'static str| {
285 theme.set(value.to_owned());
286 let root = document().document_element().unwrap();
287 let _ = match value {
288 "" => root.remove_attribute("data-theme"),
289 v => root.set_attribute("data-theme", v),
290 };
291 if let Some(s) = storage() {
292 let _ = match value {
293 "" => s.remove_item("theme"),
294 v => s.set_item("theme", v),
295 };
296 }
297 };
298 // Each click moves to the next theme: auto, light, dark, auto.
299 let next = move || match theme.get().as_str() {
300 "" => "light",
301 "light" => "dark",
302 _ => "",
303 };
304 let label = move || match theme.get().as_str() {
305 "light" => "light",
306 "dark" => "dark",
307 _ => "auto",
308 };
309 view! {
310 <button
311 class="theme"
312 title=move || format!("Theme: {}", label())
313 aria-label=move || format!("Theme: {}. Click to change.", label())
314 on:click=move |_| choose(next())
315 inner_html=move || {
316 icon(match theme.get().as_str() {
317 "light" => SUN,
318 "dark" => MOON,
319 _ => AUTO,
320 })
321 }
322 ></button>
323 }
324}
325
326const 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"/>"#;
327const MOON: &str = r#"<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>"#;
328/// A half-filled circle: the system decides.
329const AUTO: &str =
330 r#"<circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor"/>"#;
331
332fn icon(body: &str) -> String {
333 format!(
334 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>"#
335 )
336}
337
338pub fn now_secs() -> i64 {
339 (js_sys::Date::now() / 1000.0) as i64
340}
341
342pub fn fmt_time(ts: i64) -> String {
343 js_sys::Date::new(&JsValue::from_f64(ts as f64 * 1000.0))
344 .to_locale_string("default", &JsValue::UNDEFINED)
345 .into()
346}
347
348pub fn ago(ts: i64) -> String {
349 match (now_secs() - ts).max(0) {
350 d @ 0..60 => format!("{d} s ago"),
351 d @ 60..3600 => format!("{} min ago", d / 60),
352 d @ 3600..86400 => format!("{} h ago", d / 3600),
353 _ => fmt_time(ts),
354 }
355}
356