Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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>
    }
}