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