use crate::i18n; use leptos::prelude::*; use wasm_bindgen_futures::spawn_local; use crate::api::{self, LoginResp, Me, PasswordStep, input_value}; use crate::app::AuthPhase; use crate::components::logo::Logo; /// Where a sign-in currently stands. /// /// An account that needs a password *and* a passkey can start with either /// one, so both halves are a step the form can be parked on. Which half is /// missing is the server's answer, never a guess here. #[derive(Clone)] enum Step { /// Name and password, plus the passkey button. Start, /// The password passed; the passkey is still to come. Nothing to fill in, /// the browser's own dialog is already open. NeedPasskey, /// A passkey passed; this account's password is still to come. NeedPassword(PasswordStep), } #[component] pub fn LoginView( set_me: WriteSignal>, set_phase: WriteSignal, ) -> impl IntoView { let (error, set_error) = signal(String::new()); let (busy, set_busy) = signal(false); let (step, set_step) = signal(Step::Start); // Both legs can produce any of the three answers, so one place applies // them. let apply = move |resp: LoginResp| { if let Some(challenge) = resp.passkey_challenge { set_step.set(Step::NeedPasskey); spawn_local(async move { match api::passkey_finish(challenge, false).await { // The browser request was cancelled to make room for // another one. Nothing happened, so say nothing. Ok(None) => {} Ok(Some(next)) if next.ok => finish_session(set_me, set_phase, set_error), Ok(Some(_)) => set_error.set(i18n::t(i18n::k::PASSKEY_NOT_USED).into()), Err(e) => set_error.set(e.to_string()), } set_busy.set(false); set_step.set(Step::Start); }); return; } if let Some(pending) = resp.password_required { set_step.set(Step::NeedPassword(pending)); set_busy.set(false); return; } if resp.ok { finish_session(set_me, set_phase, set_error); } set_busy.set(false); }; let on_submit = move |ev: web_sys::SubmitEvent| { ev.prevent_default(); let pass = input_value("login-pass"); // The second password step already knows the account, so the name // field is not on screen and not needed. let (name, state_id) = match step.get() { Step::NeedPassword(p) => (None, Some(p.state_id)), _ => { let n = input_value("login-name").trim().to_string(); if n.is_empty() { set_error.set(i18n::t(i18n::k::LOGIN_ERROR).into()); return; } (Some(n), None) } }; if pass.is_empty() { set_error.set(i18n::t(i18n::k::LOGIN_ERROR).into()); return; } set_error.set(String::new()); set_busy.set(true); spawn_local(async move { match api::login(name, pass, state_id).await { Ok(resp) => apply(resp), Err(e) => { set_error.set(e.to_string()); set_busy.set(false); // The handle was spent on the way in, whether or not the // password was right. Retrying on this step would send a // dead one forever, so go back to the start. if matches!(step.get_untracked(), Step::NeedPassword(_)) { set_step.set(Step::Start); } } } }); }; // The name goes along only if one was typed: without it the server issues // a discoverable challenge, which is what lets a passkey sign in with an // empty form. let on_passkey = move |_| { if busy.get() { return; } let name = input_value("login-name").trim().to_string(); set_error.set(String::new()); set_busy.set(true); spawn_local(async move { match api::passkey_login((!name.is_empty()).then_some(name), false).await { Ok(None) => set_busy.set(false), Ok(Some(resp)) => apply(resp), Err(e) => { set_error.set(e.to_string()); set_busy.set(false); } } }); }; // Conditional mediation: the passkey offers itself in the name field's // autofill instead of behind a button. The request stays pending until // the user picks one, so it must not touch `busy` — the form has to keep // working while it waits. let supported = crate::passkey::supported(); if supported { spawn_local(async move { if !crate::passkey::conditional_supported().await { return; } match api::passkey_login(None, true).await { Ok(Some(resp)) if resp.ok => finish_session(set_me, set_phase, set_error), Ok(Some(resp)) => { set_busy.set(true); apply(resp); } // Cancelled, or the page is going away. Either way silent. Ok(None) | Err(_) => {} } }); } // Leaving the page with a conditional request still parked would block // the next `navigator.credentials.get`. on_cleanup(crate::passkey::cancel); view! {
} } /// Load `/me` and hand the app over to the signed-in shell. /// /// The current hash is kept, so a deep link (e.g. `#/users`) survives the /// sign-in. A non-admin landing on an admin section is redirected to the /// files view by the shell instead. fn finish_session( set_me: WriteSignal>, set_phase: WriteSignal, set_error: WriteSignal, ) { spawn_local(async move { match api::me().await { Ok(m) => { set_me.set(Some(m)); set_phase.set(AuthPhase::Authed); } Err(e) => set_error.set(e.to_string()), } }); }