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

nordisk/holger-ui

src/pages/login.rs

//! Account: who holger says this session is. The passkey session (from the
//! login page, `assets/login.html`) and a pasted bearer land in the same
//! HttpOnly cookie of the console server (A34-38) and ride every call the same
//! way — the rest of the console cannot tell them apart, and holger decides on
//! each call.

use leptos::prelude::*;
use leptos_router::hooks::use_navigate;

use crate::grpc;

/// Paste-a-token login. The token is whatever the server's configured auth
/// methods accept — a static API token or an OIDC/OAuth2 access token issued
/// by the operator's IdP; the console never sees a password and never mints
/// anything itself. Handed to the console server, which keeps it in its HttpOnly
/// cookie (removed by Log out) and sends it on as `authorization: Bearer`.
#[component]
pub fn Login(logged_in: RwSignal<bool>) -> impl IntoView {
    crate::webio::set_title("Account");
    // The server's view of the current session — rendered above the forms so
    // the page doubles as Account.
    let whoami = LocalResource::new(move || {
        let _ = logged_in.get();
        async move { grpc::who_am_i().await.ok() }
    });
    let token = RwSignal::new(String::new());
    let hint: RwSignal<Option<String>> = RwSignal::new(None);
    let navigate = use_navigate();

    let save = move || {
        let t = token.get_untracked();
        let t = t.trim();
        if t.is_empty() {
            hint.set(Some("paste a token first".into()));
            return;
        }
        let (t, navigate) = (t.to_owned(), navigate.clone());
        leptos::task::spawn_local(async move {
            match crate::session::hold_token(&t).await {
                Ok(()) => {
                    logged_in.set(true);
                    navigate("/", Default::default());
                }
                Err(why) => hint.set(Some(why)),
            }
        });
    };
    let save_click = save.clone();

    // Who is logged in, by the server's session (`crate::session`) — the same
    // answer the topbar shows, so /account never offers a login to somebody the
    // topbar calls logged in.
    let status = LocalResource::new(move || {
        let _ = logged_in.get();
        async move { crate::session::fetch_status().await }
    });
    let shown = move || {
        let s = status.get().flatten();
        crate::session::shown(s.as_ref(), s.as_ref().is_some_and(|s| s.token_held))
    };

    view! {
        <section class="page">
            <crate::pages::mine::MineTabs/>
            <h2>"Account"</h2>
            {move || match shown() {
                crate::session::Shown::Session { user, role } => view! {
                    <dl class="facts">
                        <dt>"Session"</dt><dd>"logged in with a passkey"</dd>
                        <dt>"User"</dt><dd>{user}</dd>
                        <dt>"Role here"</dt><dd>{role}</dd>
                    </dl>
                }.into_any(),
                crate::session::Shown::LoggedOut { login } => view! {
                    <p>"Not logged in. "<a class="btn primary" rel="external" href=login>"Log in with your passkey"</a></p>
                }.into_any(),
                _ => view! { <span></span> }.into_any(),
            }}
            // holger's own view of the credential THIS CONSOLE presents — one
            // subject for every browser session, so it is labelled as such.
            {move || whoami.get().flatten().map(|id| view! {
                <h3>"This session"</h3>
                {if id.authenticated {
                    view! { <p>{crate::util::session_words(&id.method, &id.global_role)}</p> }.into_any()
                } else {
                    view! { <p>{format!("You are not signed in. Your role here: {}.", id.global_role)}</p> }.into_any()
                }}
                <p>{crate::util::access_words(&id.access_words, id.read_gate)}</p>
            })}
            // The console's own token and SSO forms are a road ONLY on a holger
            // without passkeys; on a passkey console they were the "several ways
            // in" the owner reported, and they are not offered.
            {move || {
                let s = shown();
                (s.token_forms() && s.offers_login()).then(|| view! {
                    <h3>"Log in with a token"</h3>
                    <p class="count measurenote">
                        "Paste a bearer credential accepted by this server — a static API token, or an \
                         access token from your identity provider. It is held by this console in a \
                         cookie no script can read, sent as authorization: Bearer on every call, and \
                         dropped on Log out. Your role (reader / writer / admin) is the server's mapping, not a \
                         console setting."
                    </p>
                    <div class="toolbar">
                        <label class="vh" for="token-input">"Bearer token"</label>
                        <input
                            id="token-input"
                            type="password"
                            placeholder="bearer token…"
                            prop:value=move || token.get()
                            on:input=move |ev| token.set(event_target_value(&ev))
                            on:keydown={
                                let save = save.clone();
                                move |ev| { if ev.key() == "Enter" { save(); } }
                            }
                        />
                        <button on:click={
                            let save = save_click.clone();
                            move |_| save()
                        }>"Log in"</button>
                    </div>
                    {move || hint.get().map(|m| view! { <p class="err" role="alert">{m}</p> })}

                })
            }}
        </section>
    }
}