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 | _ => { |
| 72 | let n = input_value("login-name").trim().to_string(); |
| 73 | if n.is_empty() { |
| 74 | set_error.set(i18n::t(i18n::k::LOGIN_ERROR).into()); |
| 75 | return; |
| 76 | } |
| 77 | (Some(n), None) |
| 78 | } |
| 79 | }; |
| 80 | if pass.is_empty() { |
| 81 | set_error.set(i18n::t(i18n::k::LOGIN_ERROR).into()); |
| 82 | return; |
| 83 | } |
| 84 | set_error.set(String::new()); |
| 85 | set_busy.set(true); |
| 86 | spawn_local(async move { |
| 87 | match api::login(name, pass, state_id).await { |
| 88 | Ok(resp) => apply(resp), |
| 89 | Err(e) => { |
| 90 | set_error.set(e.to_string()); |
| 91 | set_busy.set(false); |
| 92 | // The handle was spent on the way in, whether or not the |
| 93 | // password was right. Retrying on this step would send a |
| 94 | // dead one forever, so go back to the start. |
| 95 | if matches!(step.get_untracked(), Step::NeedPassword(_)) { |
| 96 | set_step.set(Step::Start); |
| 97 | } |
| 98 | } |
| 99 | } |
| 100 | }); |
| 101 | }; |
| 102 | |
| 103 | // The name goes along only if one was typed: without it the server issues |
| 104 | // a discoverable challenge, which is what lets a passkey sign in with an |
| 105 | // empty form. |
| 106 | let on_passkey = move |_| { |
| 107 | if busy.get() { |
| 108 | return; |
| 109 | } |
| 110 | let name = input_value("login-name").trim().to_string(); |
| 111 | set_error.set(String::new()); |
| 112 | set_busy.set(true); |
| 113 | spawn_local(async move { |
| 114 | match api::passkey_login((!name.is_empty()).then_some(name), false).await { |
| 115 | Ok(None) => set_busy.set(false), |
| 116 | Ok(Some(resp)) => apply(resp), |
| 117 | Err(e) => { |
| 118 | set_error.set(e.to_string()); |
| 119 | set_busy.set(false); |
| 120 | } |
| 121 | } |
| 122 | }); |
| 123 | }; |
| 124 | |
| 125 | // Conditional mediation: the passkey offers itself in the name field's |
| 126 | // autofill instead of behind a button. The request stays pending until |
| 127 | // the user picks one, so it must not touch `busy` — the form has to keep |
| 128 | // working while it waits. |
| 129 | let supported = crate::passkey::supported(); |
| 130 | if supported { |
| 131 | spawn_local(async move { |
| 132 | if !crate::passkey::conditional_supported().await { |
| 133 | return; |
| 134 | } |
| 135 | match api::passkey_login(None, true).await { |
| 136 | Ok(Some(resp)) if resp.ok => finish_session(set_me, set_phase, set_error), |
| 137 | Ok(Some(resp)) => { |
| 138 | set_busy.set(true); |
| 139 | apply(resp); |
| 140 | } |
| 141 | // Cancelled, or the page is going away. Either way silent. |
| 142 | Ok(None) | Err(_) => {} |
| 143 | } |
| 144 | }); |
| 145 | } |
| 146 | // Leaving the page with a conditional request still parked would block |
| 147 | // the next `navigator.credentials.get`. |
| 148 | on_cleanup(crate::passkey::cancel); |
| 149 | |
| 150 | view! { |
| 151 | <div class="center-screen"> |
| 152 | <div class="card auth-card"> |
| 153 | <Logo class="auth-logo".to_string()/> |
| 154 | <h1>"filebrowser-ng"</h1> |
| 155 | <p class="muted"> |
| 156 | {move || match step.get() { |
| 157 | Step::NeedPasskey => i18n::t(i18n::k::SECOND_FACTOR_PASSKEY).to_string(), |
| 158 | Step::NeedPassword(p) => { |
| 159 | i18n::t_fmt(i18n::k::SECOND_FACTOR_PASSWORD, &p.name) |
| 160 | } |
| 161 | Step::Start => i18n::t(i18n::k::LOGIN_SUBTITLE).to_string(), |
| 162 | }} |
| 163 | </p> |
| 164 | <form on:submit=on_submit> |
| 165 | <Show when=move || matches!(step.get(), Step::Start)> |
| 166 | <label class="field"> |
| 167 | <span>{i18n::tr(i18n::k::NAME)}</span> |
| 168 | <input |
| 169 | id="login-name" |
| 170 | type="text" |
| 171 | autofocus=true |
| 172 | // `webauthn` is what puts passkeys into this |
| 173 | // field's autofill dropdown. |
| 174 | autocomplete="username webauthn" |
| 175 | /> |
| 176 | </label> |
| 177 | </Show> |
| 178 | <Show when=move || !matches!(step.get(), Step::NeedPasskey)> |
| 179 | <label class="field"> |
| 180 | <span>{i18n::tr(i18n::k::PASSWORD)}</span> |
| 181 | <input |
| 182 | id="login-pass" |
| 183 | type="password" |
| 184 | autocomplete="current-password" |
| 185 | /> |
| 186 | </label> |
| 187 | </Show> |
| 188 | <p class="error">{move || error.get()}</p> |
| 189 | <Show when=move || !matches!(step.get(), Step::NeedPasskey)> |
| 190 | <button class="btn-primary" type="submit" disabled=move || busy.get()> |
| 191 | {i18n::tr(i18n::k::SIGN_IN)} |
| 192 | </button> |
| 193 | </Show> |
| 194 | </form> |
| 195 | <Show when=move || supported && matches!(step.get(), Step::Start)> |
| 196 | <p class="login-or">{i18n::tr(i18n::k::OR_LABEL)}</p> |
| 197 | <button class="btn" disabled=move || busy.get() on:click=on_passkey> |
| 198 | {i18n::tr(i18n::k::USE_PASSKEY)} |
| 199 | </button> |
| 200 | <p class="setting-desc">{i18n::tr(i18n::k::USE_PASSKEY_HINT)}</p> |
| 201 | </Show> |
| 202 | </div> |
| 203 | </div> |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | /// Load `/me` and hand the app over to the signed-in shell. |
| 208 | /// |
| 209 | /// The current hash is kept, so a deep link (e.g. `#/users`) survives the |
| 210 | /// sign-in. A non-admin landing on an admin section is redirected to the |
| 211 | /// files view by the shell instead. |
| 212 | fn finish_session( |
| 213 | set_me: WriteSignal<Option<Me>>, |
| 214 | set_phase: WriteSignal<AuthPhase>, |
| 215 | set_error: WriteSignal<String>, |
| 216 | ) { |
| 217 | spawn_local(async move { |
| 218 | match api::me().await { |
| 219 | Ok(m) => { |
| 220 | set_me.set(Some(m)); |
| 221 | set_phase.set(AuthPhase::Authed); |
| 222 | } |
| 223 | Err(e) => set_error.set(e.to_string()), |
| 224 | } |
| 225 | }); |
| 226 | } |
| 227 |