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 _ => {
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.
212fn 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