vtc-service 0.7.0

Service for Verifiable Trust Communities
// Passkey login page.
//
// Operator clicks "Sign in with passkey" → POST to
// `/v1/auth/passkey-login/start` → run `navigator.credentials.get`
// → POST to `/finish` → daemon sets the `vtc_admin_session` cookie
// (HttpOnly) and the `csrf` cookie (JS-readable). The shell then
// reloads its sign-in probe and renders the authenticated UI.

import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Fingerprint } from "lucide-react";

import { postJson } from "@/lib/api";
import {
  decodePublicKeyOptions,
  serializeAssertion,
  type JsonPublicKeyOptions,
} from "@/lib/webauthn";

const TRUST_TASK_START =
  "https://trusttasks.org/openvtc/vtc/auth/passkey-login/start/1.0";
const TRUST_TASK_FINISH =
  "https://trusttasks.org/openvtc/vtc/auth/passkey-login/finish/1.0";

type Phase =
  | { kind: "idle" }
  | { kind: "running" }
  | { kind: "error"; message: string; hint?: string };

export function Login() {
  const [phase, setPhase] = useState<Phase>({ kind: "idle" });
  const queryClient = useQueryClient();

  const signIn = async () => {
    setPhase({ kind: "running" });
    try {
      // ── /passkey-login/start ──
      const start = await postJson<{
        authId: string;
        options: { publicKey: JsonPublicKeyOptions };
      }>("/v1/auth/passkey-login/start", undefined, {
        trustTask: TRUST_TASK_START,
      });

      const publicKey = decodePublicKeyOptions(
        start.options.publicKey,
      ) as PublicKeyCredentialRequestOptions;

      // ── navigator.credentials.get ──
      const credential = (await navigator.credentials.get({
        publicKey,
      })) as PublicKeyCredential | null;
      if (!credential) {
        setPhase({
          kind: "error",
          message: "Passkey ceremony returned no credential.",
          hint: "Retry, or use a different authenticator.",
        });
        return;
      }

      // ── /passkey-login/finish ──
      await postJson<unknown>(
        "/v1/auth/passkey-login/finish",
        {
          auth_id: start.authId,
          credential: serializeAssertion(credential),
        },
        { trustTask: TRUST_TASK_FINISH },
      );

      // Success — the daemon set the cookies. Invalidate the
      // whoami probe (the same key App.tsx uses) so the shell
      // re-renders into the authenticated tree without a manual
      // reload. The previous "session-probe" key didn't match any
      // live query, so the shell stayed stuck on this page.
      await queryClient.invalidateQueries({ queryKey: ["whoami"] });
    } catch (err) {
      const e = err as { status?: number; message?: string };
      let hint: string | undefined;
      if (e.status === 401) {
        hint =
          "Your passkey isn't recognised, or the ACL revoked your admin role. " +
          "Ask another admin to issue a fresh `vtc admin invite --did <your-did>`.";
      } else if (e.status === 404) {
        hint = "No passkeys are registered yet — claim the install URL first.";
      } else if (
        err instanceof DOMException &&
        err.name === "NotAllowedError"
      ) {
        hint = "Passkey prompt cancelled or denied by the browser.";
      }
      setPhase({
        kind: "error",
        message: e.message ?? String(err),
        hint,
      });
    }
  };

  return (
    <section className="page login-page">
      <div className="login-card">
        <h2>VTC Admin</h2>
        <p className="lead">
          Sign in with the passkey you registered at install.
        </p>
        <button
          type="button"
          className="primary"
          onClick={signIn}
          disabled={phase.kind === "running"}
        >
          <Fingerprint size={16} aria-hidden="true" />
          {phase.kind === "running"
            ? "Waiting for passkey…"
            : "Sign in with passkey"}
        </button>
        {phase.kind === "error" && (
          <section className="card error">
            <h3>Sign-in failed</h3>
            <p>{phase.message}</p>
            {phase.hint && <p className="lead">{phase.hint}</p>}
          </section>
        )}
        <footer>
          <p>
            No passkey yet? Open the install URL the daemon operator
            shared, or ask them to mint a fresh one via{" "}
            <code>vtc admin invite</code>.
          </p>
        </footer>
      </div>
    </section>
  );
}