guest.rs
| 1 | //! The page behind a guest link: `/#l=<token>`. |
| 2 | |
| 3 | use api::{GuestAuth, GuestKey, GuestUnlock, GuestView}; |
| 4 | use leptos::prelude::*; |
| 5 | use leptos::task::spawn_local; |
| 6 | |
| 7 | use crate::{ThemeToggle, http, map}; |
| 8 | |
| 9 | #[derive(Clone, PartialEq)] |
| 10 | enum State { |
| 11 | Loading, |
| 12 | Locked, |
| 13 | Open(String), |
| 14 | Gone, |
| 15 | Failed(String), |
| 16 | } |
| 17 | |
| 18 | fn storage() -> Option<web_sys::Storage> { |
| 19 | window().session_storage().ok().flatten() |
| 20 | } |
| 21 | |
| 22 | #[component] |
| 23 | pub fn GuestPage(token: String) -> impl IntoView { |
| 24 | let storage_key = format!("link {token}"); |
| 25 | let key = RwSignal::new(storage().and_then(|s| s.get_item(&storage_key).ok().flatten())); |
| 26 | let token = StoredValue::new(token); |
| 27 | let auth = move || GuestAuth { |
| 28 | token: token.get_value(), |
| 29 | key: key.get_untracked(), |
| 30 | }; |
| 31 | let state = RwSignal::new(State::Loading); |
| 32 | let check = move || { |
| 33 | spawn_local(async move { |
| 34 | state.set(match http::post::<GuestView>("/api/guest", &auth()).await { |
| 35 | Ok(v) => State::Open(v.person.username), |
| 36 | Err(e) if e.status == 401 => State::Locked, |
| 37 | Err(e) if e.status == 404 => State::Gone, |
| 38 | Err(e) => State::Failed(e.to_string()), |
| 39 | }) |
| 40 | }) |
| 41 | }; |
| 42 | check(); |
| 43 | |
| 44 | let password = RwSignal::new(String::new()); |
| 45 | let error = RwSignal::new(None::<String>); |
| 46 | let unlock = move |ev: leptos::ev::SubmitEvent| { |
| 47 | ev.prevent_default(); |
| 48 | let body = GuestUnlock { |
| 49 | token: token.get_value(), |
| 50 | password: password.get_untracked(), |
| 51 | }; |
| 52 | let storage_key = storage_key.clone(); |
| 53 | spawn_local(async move { |
| 54 | match http::post::<GuestKey>("/api/guest/unlock", &body).await { |
| 55 | Ok(k) => { |
| 56 | if let Some(s) = storage() { |
| 57 | let _ = s.set_item(&storage_key, &k.key); |
| 58 | } |
| 59 | key.set(Some(k.key)); |
| 60 | error.set(None); |
| 61 | check(); |
| 62 | } |
| 63 | Err(e) => error.set(Some(match e.status { |
| 64 | 401 => "Wrong password.".into(), |
| 65 | 429 => "Too many failed attempts. Try again in 15 minutes.".into(), |
| 66 | _ => e.to_string(), |
| 67 | })), |
| 68 | } |
| 69 | }); |
| 70 | }; |
| 71 | |
| 72 | let title = move || match state.get() { |
| 73 | State::Open(name) => format!("Location of {name}"), |
| 74 | _ => "Shared location".into(), |
| 75 | }; |
| 76 | view! { |
| 77 | <header> |
| 78 | <img class="brand" src="/icon.svg" alt="opentracker" width="28" height="28" /> |
| 79 | <span class="guest-title">{title}</span> |
| 80 | <ThemeToggle /> |
| 81 | </header> |
| 82 | {move || match state.get() { |
| 83 | State::Loading => ().into_any(), |
| 84 | State::Locked => { |
| 85 | view! { |
| 86 | <form class="login" on:submit=unlock.clone()> |
| 87 | <p>"This shared location is protected with a password."</p> |
| 88 | <label> |
| 89 | "Password" |
| 90 | <input type="password" autocomplete="current-password" required bind:value=password /> |
| 91 | </label> |
| 92 | <button class="primary">"Show location"</button> |
| 93 | <p class="error">{move || error.get()}</p> |
| 94 | </form> |
| 95 | } |
| 96 | .into_any() |
| 97 | } |
| 98 | State::Open(_) => view! { <main><map::MapPage source=map::Source::Guest(auth()) /></main> }.into_any(), |
| 99 | State::Gone => { |
| 100 | view! { <p class="login">"This link is no longer valid. It may have expired or been deleted."</p> } |
| 101 | .into_any() |
| 102 | } |
| 103 | State::Failed(msg) => view! { <p class="login error">{msg}</p> }.into_any(), |
| 104 | }} |
| 105 | } |
| 106 | } |
| 107 |