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 // 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>"filebrowser-ng"</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.
203fn 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