crates/monetize-ui-web/src/pages/login.rs
//! Passkey login, and passkey enrolment: the first operator with the break-glass
//! token, every later key by the operator's own session; and, for a signed-in
//! operator, the operator invites ([`crate::pages::invite::OperatorInvites`]).
use leptos::prelude::*;
use leptos_router::hooks::use_query_map;
use serde_json::Value;
use crate::api::Fail;
use crate::app::session;
use crate::ui::{Badge, FailBox, PageHeader, Panel, Tone};
/// This console's host, as the browser knows it (`monetize.ignalina.dk`).
fn host() -> String {
web_sys::window()
.and_then(|w| w.location().hostname().ok())
.unwrap_or_default()
}
#[component]
pub fn Login() -> impl IntoView {
let status = session();
let busy = RwSignal::new(false);
let failed = RwSignal::new(None::<Fail>);
let notice = RwSignal::new(None::<String>);
let label = RwSignal::new(String::new());
let token = RwSignal::new(String::new());
// "Add another key", pressed: the only way the form shows for a principal that holds a key.
let chose_add = RwSignal::new(false);
let login = move |_| {
busy.set(true);
failed.set(None);
leptos::task::spawn_local(async move {
let r: Result<(), Fail> = async {
let options = crate::api::get::<Value>(crate::fmt::PASSKEY_LOGIN_START).await?;
// The browser's own refusal: the server was never asked, so no HTTP status.
let cred = crate::passkey::get(&options)
.await
.map_err(|e| Fail::no_passkey(&host(), &e))?;
crate::api::post::<_, Value>(
"/auth/passkey/login/finish",
&crate::fmt::passkey_login_body(&options, &cred),
)
.await
.map_err(|f| f.login_refused(&host()))?;
Ok(())
}
.await;
match r {
Ok(()) => {
if let Some(w) = web_sys::window() {
let _ = w.location().set_href("/");
}
}
Err(f) => failed.set(Some(f)),
}
busy.set(false);
});
};
let register = move |_| {
busy.set(true);
failed.set(None);
notice.set(None);
let bearer = token.get_untracked().trim().to_owned();
let bearer = (!bearer.is_empty()).then_some(bearer);
let l = label.get_untracked().trim().to_owned();
leptos::task::spawn_local(async move {
let r: Result<(), Fail> = async {
let options = crate::api::get_as::<Value>(
&crate::fmt::passkey_register_start(&l),
bearer.as_deref(),
)
.await?;
// The browser's own refusal: the server was never asked, so no HTTP status.
let cred = crate::passkey::create(&options)
.await
.map_err(|e| Fail::passkey_not_created(&host(), &e))?;
crate::api::post_as::<_, Value>(
"/auth/passkey/register/finish",
&crate::fmt::passkey_register_body(&options, &cred, &l),
bearer.as_deref(),
)
.await?;
Ok(())
}
.await;
match r {
Ok(()) => {
// The enrolment signed this browser in: the page reads who, again.
if let Some(w) = web_sys::window() {
let _ = w.location().set_href("/login");
}
}
// A key that is already enrolled is done, not failed.
Err(f) if f.already_done() => {
chose_add.set(false);
notice.set(Some(f.message));
}
Err(f) => failed.set(Some(f)),
}
busy.set(false);
});
};
// ★ Reached by a refusal, not by the menu: `resolved` sends a 401 here
// with `why=session`, and that is the ONLY time the panel gets the red
// bulb and its line. `fmt::login_prompt` decides; this only reads the
// URL. From the menu the panel is calm — a warning about nothing teaches
// an operator to ignore the warning.
let query = use_query_map();
let prompt = move || crate::fmt::login_prompt(query.read().get("why").as_deref());
view! {
<div class="login stack">
<PageHeader title="Passkeys" subtitle="How you sign in."/>
{move || match status.get() {
None => view! { <Badge tone=Tone::Plain label="checking …"/> }.into_any(),
Some(s) if s.open => view! { <Panel title="Unlocked console"><div class="panel-body hint">"No sign-in is set up: this console is open on this machine only."</div></Panel> }.into_any(),
Some(s) if !s.passkey_enabled => view! { <Panel title="Passkeys are off"><div class="panel-body hint">"Only the emergency token protects this console."</div></Panel> }.into_any(),
Some(s) => {
let needs_token = s.bootstrap && !s.authenticated;
let (authenticated, admin, bootstrap, has_passkey) = (s.authenticated, s.is_operator(), s.bootstrap, s.has_passkey);
let offer = move || crate::fmt::enrol_offer(authenticated, admin, bootstrap, has_passkey, chose_add.get());
view! {
<Panel title="Sign in">
{move || prompt().map(|line| view! { <div class="panel-body login-prompt" role="alert"><span class="bulb bad" aria-hidden="true"></span>{line}</div> })}
<div class="panel-body stack">
{if s.authenticated { view! { <p class="hint">{format!("Logged in as {}.", s.user)}</p> }.into_any() } else { view! { <p class="hint">"Touch your security key or use the platform authenticator."</p> }.into_any() }}
<div><button class="btn primary" disabled=move || busy.get() on:click=login>"Sign in with a passkey"</button></div>
</div>
</Panel>
{move || match offer() {
crate::fmt::EnrolOffer::Nothing => ().into_any(),
crate::fmt::EnrolOffer::AddAnother => view! {
<div><button class="btn quiet" id="enrol-add-another" on:click=move |_| chose_add.set(true)>"Add another key"</button></div>
}.into_any(),
_ => view! {
<Panel title=if needs_token { "Enrol the first operator" } else if has_passkey { "Add another key" } else { "Enrol a passkey" }>
<div class="panel-body stack">
{needs_token.then(|| view! {
<p class="hint">"No passkey yet. Use the emergency token once to add the first one."</p>
<div class="field"><label for="tok">"Admin token"</label><input id="tok" type="password" prop:value=move || token.get() on:input=move |ev| token.set(event_target_value(&ev))/></div>
})}
<div class="row" style="align-items:flex-end">
<div class="field"><label for="label">"Device label (optional)"</label><input id="label" type="text" placeholder="yubikey" prop:value=move || label.get() on:input=move |ev| label.set(event_target_value(&ev))/></div>
<button class="btn" disabled=move || busy.get() on:click=register>{if has_passkey { "Add this key" } else { "Enrol this key" }}</button>
{has_passkey.then(|| view! { <button class="btn quiet" on:click=move |_| chose_add.set(false)>"Cancel"</button> })}
</div>
</div>
</Panel>
}.into_any(),
}}
{(s.authenticated && !s.open).then(|| view! { <crate::pages::invite::OperatorInvites/> })}
}.into_any()
}
}}
{move || notice.get().map(|n| view! { <div class="notice">{n}</div> })}
{move || failed.get().map(|f| view! { <FailBox fail=f/> })}
</div>
}
}