"use client"; import { useState } from "react"; import HelpTooltip from "../components/HelpTooltip"; function b64uToBuf(b64u: string): ArrayBuffer { const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/"); const bin = atob(b64); const buf = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); return buf.buffer; } function bufToB64u(buf: ArrayBuffer): string { const bytes = new Uint8Array(buf); let bin = ""; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } type Status = "idle" | "starting" | "waiting_yubikey" | "saving" | "done" | "error"; export default function EnrollPage() { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [status, setStatus] = useState("idle"); const [message, setMessage] = useState(""); async function enroll(e: React.FormEvent) { e.preventDefault(); setStatus("starting"); setMessage(""); try { // Step 1: get registration challenge const startRes = await fetch("/api/auth/register/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); if (!startRes.ok) { setMessage("Invalid credentials."); setStatus("error"); return; } const { session_id, challenge } = await startRes.json(); // Step 2: create credential with YubiKey setStatus("waiting_yubikey"); const opts = challenge.publicKey; opts.challenge = b64uToBuf(opts.challenge); opts.user.id = b64uToBuf(opts.user.id); // Force security key UI — residentKey/UV discouraged so Android // Chrome doesn't suppress the NFC option in favour of biometrics opts.authenticatorSelection = { authenticatorAttachment: "cross-platform", residentKey: "discouraged", requireResidentKey: false, userVerification: "discouraged", }; // Explicitly request security-key flow so Chrome 116+ doesn't show // the passkey dialog (which errors when an existing credential is present) (opts as Record).hints = ["security-key"]; if (opts.excludeCredentials) { opts.excludeCredentials = opts.excludeCredentials.map( (c: { id: string; type: string; transports?: string[] }) => ({ ...c, id: b64uToBuf(c.id), }), ); } let cred: PublicKeyCredential; try { cred = (await navigator.credentials.create({ publicKey: opts, })) as PublicKeyCredential; } catch (err: unknown) { setMessage( "YubiKey error: " + (err instanceof Error ? err.message : "cancelled"), ); setStatus("error"); return; } // Step 3: save credential setStatus("saving"); const attestation = cred.response as AuthenticatorAttestationResponse; const finishRes = await fetch("/api/auth/register/finish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id, credential: { id: cred.id, rawId: bufToB64u(cred.rawId), type: cred.type, response: { attestationObject: bufToB64u(attestation.attestationObject), clientDataJSON: bufToB64u(attestation.clientDataJSON), transports: ["usb", "nfc", "ble", "hybrid"], }, extensions: {}, }, }), }); if (!finishRes.ok) { setMessage("Registration failed. Check server logs."); setStatus("error"); return; } setStatus("done"); setMessage("YubiKey enrolled! You can now log in."); } catch { setMessage("Something went wrong. Try again."); setStatus("error"); } } const canSubmit = status === "idle" || status === "error"; const busy = !canSubmit; return (
{/* Header */}

dell-xps-nixos-serv

Enroll YubiKey

One-time security key setup

{status === "done" ? (

{message}

) : (
{/* Username */}
setUsername(e.target.value)} required disabled={busy} autoComplete="username" className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm text-gray-900 bg-gray-50 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-50 transition-colors" />
{/* Password */}
setPassword(e.target.value)} required disabled={busy} autoComplete="current-password" className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm text-gray-900 bg-gray-50 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-50 transition-colors" />
{/* YubiKey cue */} {status === "waiting_yubikey" && (

Touch your YubiKey…

)} {/* Error */} {status === "error" && message && (

{message}

)} {/* Submit */}
)}
); }