login.rs
| 1 | use crate::i18n; |
| 2 | use leptos::prelude::*; |
| 3 | use wasm_bindgen_futures::spawn_local; |
| 4 | |
| 5 | use crate::api::{self, LoginResp, Me, PasswordStep, input_value}; |
| 6 | use crate::app::AuthPhase; |
| 7 | use crate::components::logo::Logo; |
| 8 | |
| 9 | /// Where a sign-in currently stands. |
| 10 | /// |
| 11 | /// An account that needs a password *and* a passkey can start with either |
| 12 | /// one, so both halves are a step the form can be parked on. Which half is |
| 13 | /// missing is the server's answer, never a guess here. |
| 14 | #[derive(Clone)] |
| 15 | enum Step { |
| 16 | /// Name and password, plus the passkey button. |
| 17 | Start, |
| 18 | /// The password passed; the passkey is still to come. Nothing to fill in, |
| 19 | /// the browser's own dialog is already open. |
| 20 | NeedPasskey, |
| 21 | /// A passkey passed; this account's password is still to come. |
| 22 | NeedPassword(PasswordStep), |
| 23 | } |
| 24 | |
| 25 | #[component] |
| 26 | pub fn LoginView( |
| 27 | set_me: WriteSignal<Option<Me>>, |
| 28 | set_phase: WriteSignal<AuthPhase>, |
| 29 | ) -> impl IntoView { |
| 30 | let (error, set_error) = signal(String::new()); |
| 31 | let (busy, set_busy) = signal(false); |
| 32 | let (step, set_step) = signal(Step::Start); |
| 33 | |
| 34 | // Both legs can produce any of the three answers, so one place applies |
| 35 | // them. |
| 36 | let apply = move |resp: LoginResp| { |
| 37 | if let Some(challenge) = resp.passkey_challenge { |
| 38 | set_step.set(Step::NeedPasskey); |
| 39 | spawn_local(async move { |
| 40 | match api::passkey_finish(challenge, false).await { |
| 41 | // The browser request was cancelled to make room for |
| 42 | // another one. Nothing happened, so say nothing. |
| 43 | Ok(None) => {} |
| 44 | Ok(Some(next)) if next.ok => finish_session(set_me, set_phase, set_error), |
| 45 | Ok(Some(_)) => set_error.set(i18n::t(i18n::k::PASSKEY_NOT_USED).into()), |
| 46 | Err(e) => set_error.set(e.to_string()), |
| 47 | } |
| 48 | set_busy.set(false); |
| 49 | set_step.set(Step::Start); |
| 50 | }); |
| 51 | return; |
| 52 | } |
| 53 | if let Some(pending) = resp.password_required { |
| 54 | set_step.set(Step::NeedPassword(pending)); |
| 55 | set_busy.set(false); |
| 56 | return; |
| 57 | } |
| 58 | if resp.ok { |
| 59 | finish_session(set_me, set_phase, set_error); |
| 60 | } |
| 61 | set_busy.set(false); |
| 62 | }; |
| 63 | |
| 64 | let on_submit = move |ev: web_sys::SubmitEvent| { |
| 65 | ev.prevent_default(); |
| 66 | let pass = input_value("login-pass"); |
| 67 | // The second password step already knows the account, so the name |
| 68 | // field is not on screen and not needed. |
| 69 | let (name, state_id) = match step.get() { |
| 70 | Step::NeedPassword(p) => (None, Some(p.state_id)), |
| 71 | _ => (Some(input_value("login-name").trim().to_string()), None), |
| 72 | }; |
| 73 | set_error.set(String::new()); |
| 74 | set_busy.set(true); |
| 75 | spawn_local(async move { |
| 76 | match api::login(name, pass, state_id).await { |
| 77 | Ok(resp) => apply(resp), |
| 78 | Err(e) => { |
| 79 | set_error.set(e.to_string()); |
| 80 | set_busy.set(false); |
| 81 | // The handle was spent on the way in, whether or not the |
| 82 | // password was right. Retrying on this step would send a |
| 83 | // dead one forever, so go back to the start. |
| 84 | if matches!(step.get_untracked(), Step::NeedPassword(_)) { |
| 85 | set_step.set(Step::Start); |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | }); |
| 90 | }; |
| 91 | |
| 92 | // The name goes along only if one was typed: without it the server issues |
| 93 | // a discoverable challenge, which is what lets a passkey sign in with an |
| 94 | // empty form. |
| 95 | let on_passkey = move |_| { |
| 96 | if busy.get() { |
| 97 | return; |
| 98 | } |
| 99 | let name = input_value("login-name").trim().to_string(); |
| 100 | set_error.set(String::new()); |
| 101 | set_busy.set(true); |
| 102 | spawn_local(async move { |
| 103 | match api::passkey_login((!name.is_empty()).then_some(name), false).await { |
| 104 | Ok(None) => set_busy.set(false), |
| 105 | Ok(Some(resp)) => apply(resp), |
| 106 | Err(e) => { |
| 107 | set_error.set(e.to_string()); |
| 108 | set_busy.set(false); |
| 109 | } |
| 110 | } |
| 111 | }); |
| 112 | }; |
| 113 | |
| 114 | // Conditional mediation: the passkey offers itself in the name field's |
| 115 | // autofill instead of behind a button. The request stays pending until |
| 116 | // the user picks one, so it must not touch `busy` — the form has to keep |
| 117 | // working while it waits. |
| 118 | let supported = crate::passkey::supported(); |
| 119 | if supported { |
| 120 | spawn_local(async move { |
| 121 | if !crate::passkey::conditional_supported().await { |
| 122 | return; |
| 123 | } |
| 124 | match api::passkey_login(None, true).await { |
| 125 | Ok(Some(resp)) if resp.ok => finish_session(set_me, set_phase, set_error), |
| 126 | Ok(Some(resp)) => { |
| 127 | set_busy.set(true); |
| 128 | apply(resp); |
| 129 | } |
| 130 | // Cancelled, or the page is going away. Either way silent. |
| 131 | Ok(None) | Err(_) => {} |
| 132 | } |
| 133 | }); |
| 134 | } |
| 135 | // Leaving the page with a conditional request still parked would block |
| 136 | // the next `navigator.credentials.get`. |
| 137 | on_cleanup(crate::passkey::cancel); |
| 138 | |
| 139 | view! { |
| 140 | <div class="center-screen"> |
| 141 | <div class="card auth-card"> |
| 142 | <Logo class="auth-logo".to_string()/> |
| 143 | <h1>"dovenest"</h1> |
| 144 | <p class="muted"> |
| 145 | {move || match step.get() { |
| 146 | Step::NeedPasskey => i18n::t(i18n::k::SECOND_FACTOR_PASSKEY).to_string(), |
| 147 | Step::NeedPassword(p) => { |
| 148 | i18n::t_fmt(i18n::k::SECOND_FACTOR_PASSWORD, &p.name) |
| 149 | } |
| 150 | Step::Start => i18n::t(i18n::k::LOGIN_SUBTITLE).to_string(), |
| 151 | }} |
| 152 | </p> |
| 153 | <form on:submit=on_submit> |
| 154 | <Show when=move || matches!(step.get(), Step::Start)> |
| 155 | <label class="field"> |
| 156 | <span>{i18n::tr(i18n::k::NAME)}</span> |
| 157 | <input |
| 158 | id="login-name" |
| 159 | type="text" |
| 160 | required=true |
| 161 | autofocus=true |
| 162 | // `webauthn` is what puts passkeys into this |
| 163 | // field's autofill dropdown. |
| 164 | autocomplete="username webauthn" |
| 165 | /> |
| 166 | </label> |
| 167 | </Show> |
| 168 | <Show when=move || !matches!(step.get(), Step::NeedPasskey)> |
| 169 | <label class="field"> |
| 170 | <span>{i18n::tr(i18n::k::PASSWORD)}</span> |
| 171 | <input |
| 172 | id="login-pass" |
| 173 | type="password" |
| 174 | required=true |
| 175 | autocomplete="current-password" |
| 176 | /> |
| 177 | </label> |
| 178 | </Show> |
| 179 | <p class="error">{move || error.get()}</p> |
| 180 | <Show when=move || !matches!(step.get(), Step::NeedPasskey)> |
| 181 | <button class="btn-primary" type="submit" disabled=move || busy.get()> |
| 182 | {i18n::tr(i18n::k::SIGN_IN)} |
| 183 | </button> |
| 184 | </Show> |
| 185 | </form> |
| 186 | <Show when=move || supported && matches!(step.get(), Step::Start)> |
| 187 | <p class="login-or">{i18n::tr(i18n::k::OR_LABEL)}</p> |
| 188 | <button class="btn" disabled=move || busy.get() on:click=on_passkey> |
| 189 | {i18n::tr(i18n::k::USE_PASSKEY)} |
| 190 | </button> |
| 191 | <p class="setting-desc">{i18n::tr(i18n::k::USE_PASSKEY_HINT)}</p> |
| 192 | </Show> |
| 193 | </div> |
| 194 | </div> |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | /// Load `/me` and hand the app over to the signed-in shell. |
| 199 | /// |
| 200 | /// The current hash is kept, so a deep link (e.g. `#/users`) survives the |
| 201 | /// sign-in. A non-admin landing on an admin section is redirected to the |
| 202 | /// files view by the shell instead. |
| 203 | fn finish_session( |
| 204 | set_me: WriteSignal<Option<Me>>, |
| 205 | set_phase: WriteSignal<AuthPhase>, |
| 206 | set_error: WriteSignal<String>, |
| 207 | ) { |
| 208 | spawn_local(async move { |
| 209 | match api::me().await { |
| 210 | Ok(m) => { |
| 211 | set_me.set(Some(m)); |
| 212 | set_phase.set(AuthPhase::Authed); |
| 213 | } |
| 214 | Err(e) => set_error.set(e.to_string()), |
| 215 | } |
| 216 | }); |
| 217 | } |
| 218 |