login.rs
⎇
Raw
1use crate::i18n;
2use leptos::prelude::*;
3use wasm_bindgen_futures::spawn_local;
4
5use crate::api::{self, LoginResp, Me, PasswordStep, input_value};
6use crate::app::AuthPhase;
7use 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)]
15enum 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]
26pub 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 let on_passkey = move |_| {
93 if busy.get() {
94 return;
95 }
96 set_error.set(String::new());
97 set_busy.set(true);
98 spawn_local(async move {
99 match api::passkey_login(false).await {
100 Ok(None) => set_busy.set(false),
101 Ok(Some(resp)) => apply(resp),
102 Err(e) => {
103 set_error.set(e.to_string());
104 set_busy.set(false);
105 }
106 }
107 });
108 };
109
110 // Conditional mediation: the passkey offers itself in the name field's
111 // autofill instead of behind a button. The request stays pending until
112 // the user picks one, so it must not touch `busy` — the form has to keep
113 // working while it waits.
114 let supported = crate::passkey::supported();
115 if supported {
116 spawn_local(async move {
117 if !crate::passkey::conditional_supported().await {
118 return;
119 }
120 match api::passkey_login(true).await {
121 Ok(Some(resp)) if resp.ok => finish_session(set_me, set_phase, set_error),
122 Ok(Some(resp)) => {
123 set_busy.set(true);
124 apply(resp);
125 }
126 // Cancelled, or the page is going away. Either way silent.
127 Ok(None) | Err(_) => {}
128 }
129 });
130 }
131 // Leaving the page with a conditional request still parked would block
132 // the next `navigator.credentials.get`.
133 on_cleanup(crate::passkey::cancel);
134
135 view! {
136 <div class="center-screen">
137 <div class="card auth-card">
138 <Logo class="auth-logo".to_string()/>
139 <h1>"dovenest"</h1>
140 <p class="muted">
141 {move || match step.get() {
142 Step::NeedPasskey => i18n::t(i18n::k::SECOND_FACTOR_PASSKEY).to_string(),
143 Step::NeedPassword(p) => {
144 i18n::t_fmt(i18n::k::SECOND_FACTOR_PASSWORD, &p.name)
145 }
146 Step::Start => i18n::t(i18n::k::LOGIN_SUBTITLE).to_string(),
147 }}
148 </p>
149 <form on:submit=on_submit>
150 <Show when=move || matches!(step.get(), Step::Start)>
151 <label class="field">
152 <span>{i18n::tr(i18n::k::NAME)}</span>
153 <input
154 id="login-name"
155 type="text"
156 required=true
157 autofocus=true
158 // `webauthn` is what puts passkeys into this
159 // field's autofill dropdown.
160 autocomplete="username webauthn"
161 />
162 </label>
163 </Show>
164 <Show when=move || !matches!(step.get(), Step::NeedPasskey)>
165 <label class="field">
166 <span>{i18n::tr(i18n::k::PASSWORD)}</span>
167 <input
168 id="login-pass"
169 type="password"
170 required=true
171 autocomplete="current-password"
172 />
173 </label>
174 </Show>
175 <p class="error">{move || error.get()}</p>
176 <Show when=move || !matches!(step.get(), Step::NeedPasskey)>
177 <button class="btn-primary" type="submit" disabled=move || busy.get()>
178 {i18n::tr(i18n::k::SIGN_IN)}
179 </button>
180 </Show>
181 </form>
182 <Show when=move || supported && matches!(step.get(), Step::Start)>
183 <p class="login-or">{i18n::tr(i18n::k::OR_LABEL)}</p>
184 <button class="btn" disabled=move || busy.get() on:click=on_passkey>
185 {i18n::tr(i18n::k::USE_PASSKEY)}
186 </button>
187 </Show>
188 </div>
189 </div>
190 }
191}
192
193/// Load `/me` and hand the app over to the signed-in shell.
194///
195/// The current hash is kept, so a deep link (e.g. `#/users`) survives the
196/// sign-in. A non-admin landing on an admin section is redirected to the
197/// files view by the shell instead.
198fn finish_session(
199 set_me: WriteSignal<Option<Me>>,
200 set_phase: WriteSignal<AuthPhase>,
201 set_error: WriteSignal<String>,
202) {
203 spawn_local(async move {
204 match api::me().await {
205 Ok(m) => {
206 set_me.set(Some(m));
207 set_phase.set(AuthPhase::Authed);
208 }
209 Err(e) => set_error.set(e.to_string()),
210 }
211 });
212}
213