From b5c05b5f20cc8e39b8ac69632c996e46eb4a07ca Mon Sep 17 00:00:00 2001
From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
Date: Thu, 15 Oct 2026 15:49:24 -0400
Subject: [PATCH] refactor(console): restyle the first-run wizard and
credential request
The first-run wizard (token, CA and admin steps) and the "Make a
credential request" page move onto the @cryptos-pki/ui kit: Field and
Input for every form control, Notice for the fingerprint warnings and
errors, FingerprintConfirm for the CA reconfirm step, CopyBlock and
KeyBackupStep for the in-browser key flows, and Stepper for the
wizard's progress strip. Routes, API calls and refusal wording are
unchanged.
These were also the last importers of the local components/ui/button
and components/ui/input, the local copy-block and key-backup-step.
diagnostics-copy (shown on every sign-in gate) picks up the kit's
Button too, which was its only remaining local import. With nothing
left importing them, components/ui/, copy-block.tsx and
key-backup-step.tsx (plus their tests) are removed, closing out #137's
design review.
Each rebuilt page gets an axe check of its initial state alongside the
existing behavioural tests, which stay green unchanged.
Refs CryptOS-PKI/cryptos-web#187
Refs CryptOS-PKI/cryptos-web#157
Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
---
AGENTS.md | 3 +-
apps/console/src/components/copy-block.tsx | 66 ------
.../src/components/first-run/admin-step.tsx | 203 ++++++++++++------
.../src/components/first-run/ca-step.tsx | 62 +++---
.../first-run/first-run-wizard.test.tsx | 24 +++
.../components/first-run/first-run-wizard.tsx | 18 +-
.../src/components/first-run/token-step.tsx | 46 ++--
.../src/components/key-backup-step.test.tsx | 88 --------
.../src/components/key-backup-step.tsx | 170 ---------------
.../components/layout/diagnostics-copy.tsx | 2 +-
apps/console/src/components/ui/button.tsx | 65 ------
apps/console/src/components/ui/input.test.tsx | 29 ---
apps/console/src/components/ui/input.tsx | 39 ----
apps/console/src/lib/form.ts | 5 +-
.../src/pages/credential-request.test.tsx | 6 +
apps/console/src/pages/credential-request.tsx | 170 ++++++++++-----
16 files changed, 340 insertions(+), 656 deletions(-)
delete mode 100644 apps/console/src/components/copy-block.tsx
delete mode 100644 apps/console/src/components/key-backup-step.test.tsx
delete mode 100644 apps/console/src/components/key-backup-step.tsx
delete mode 100644 apps/console/src/components/ui/button.tsx
delete mode 100644 apps/console/src/components/ui/input.test.tsx
delete mode 100644 apps/console/src/components/ui/input.tsx
diff --git a/AGENTS.md b/AGENTS.md
index b556bd1..0c73bae 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -32,7 +32,8 @@ components, named exports from `src/index.ts`) and
The "Make a credential request" page `/request-credential` is outside the gate too, and makes
no network calls at all: it must not import the Fleet Manager client.
- `src/components/layout/` - app shell: header, sidebar nav, wordmark, theme toggle, auth gate.
-- `src/components/ui/` - shadcn/ui primitives (button, card, badge, separator).
+ There is no local `src/components/ui/`: every primitive comes from `@cryptos-pki/ui`, and
+ `src/components/` holds only app-specific parts (wizards, dialogs, the topology graph).
- `src/context/` - `theme.tsx` (dark/light, persisted) and `auth.tsx` (browser-mTLS gate stub).
- `src/lib/` - `mock.ts` (the typed Node model), `mock-fixtures.real.ts`/`mock-fixtures.stub.ts`
(the fixture data every mock store's `seed()` copies from, swapped by `vite.config.ts` at build
diff --git a/apps/console/src/components/copy-block.tsx b/apps/console/src/components/copy-block.tsx
deleted file mode 100644
index 412c316..0000000
--- a/apps/console/src/components/copy-block.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
-Copyright The CryptOS Authors.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import { Button } from "@/components/ui/button";
-import { downloadText } from "@/lib/download";
-
-// CopyBlock shows generated text (a CSR, an OpenSSL section, a command) with a
-// copy button and, when a filename is given, a download button.
-export const CopyBlock = ({
- filename,
- label,
- text,
-}: {
- filename?: string;
- label: string;
- text: string;
-}) => (
-
-
-
- {label}
-
-
- void navigator.clipboard?.writeText(text)}
- size="sm"
- type="button"
- variant="outline"
- >
- Copy
-
- {filename ? (
- downloadText(filename, text)}
- size="sm"
- type="button"
- variant="outline"
- >
- Download
-
- ) : null}
-
-
-
- {text}
-
-
-);
diff --git a/apps/console/src/components/first-run/admin-step.tsx b/apps/console/src/components/first-run/admin-step.tsx
index 76c0101..e11d330 100644
--- a/apps/console/src/components/first-run/admin-step.tsx
+++ b/apps/console/src/components/first-run/admin-step.tsx
@@ -14,31 +14,83 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { Button } from "@cryptos-pki/ui";
-import { useState } from "react";
+import {
+ Button,
+ CopyBlock,
+ Field,
+ Input,
+ inputClasses,
+ KeyBackupStep,
+ Notice,
+} from "@cryptos-pki/ui";
+import { useEffect, useState } from "react";
-import { CopyBlock } from "@/components/copy-block";
-import { KeyBackupStep } from "@/components/key-backup-step";
import { type FirstAdminResult, submitFirstAdminCertificate } from "@/lib/bootstrap";
import {
buildCredentialPkcs12,
+ generateBackupPassphrase,
+ keyBackupFilename,
+ keyBackupPem,
keyMatchesCertificate,
readCertificateDer,
readKeyBackup,
} from "@/lib/crypto/key-backup";
-import { downloadBytes, readFileBytes } from "@/lib/download";
+import { generateLeafKeyAndCSR } from "@/lib/crypto/leaf-key";
+import { downloadBytes, downloadText, readFileBytes } from "@/lib/download";
import { fleetErrorMessage } from "@/lib/fleet/error-copy";
-import { fieldClass, labelClass, looksLikeEmail } from "@/lib/form";
+import { looksLikeEmail } from "@/lib/form";
import { credentialFileBase, csrToPem, levelOpensslCommand } from "@/lib/operators";
+const legendClass = "font-mono text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground";
+
const SessionCaution = () => (
-
+
The first-run session ends after 15 minutes without a call. If it ends while you are at the CA,
start again with the newest token from the log, confirm the CA's fingerprint again, and
continue from your key backup.
-
+
);
+interface MadeAdminKey {
+ backupPem: string;
+ csrDer: Uint8Array;
+ passphrase: string;
+}
+
+// useMadeAdminKey makes a P-384 admin key and CSR in the browser once PathA
+// reaches the backup step (the same pattern CredentialRequestWizard uses for
+// its own in-browser key), then makes the encrypted key backup.
+const useMadeAdminKey = (active: boolean, email: string) => {
+ const [made, setMade] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!active) return;
+ let cancelled = false;
+ void (async () => {
+ try {
+ const { csrDer, privateKey } = await generateLeafKeyAndCSR({
+ extensionRequest: { level: "admin" },
+ sans: [],
+ subjectCn: email,
+ });
+ const passphrase = generateBackupPassphrase();
+ const backupPem = await keyBackupPem(privateKey, passphrase);
+ if (!cancelled) setMade({ backupPem, csrDer, passphrase });
+ } catch (error_: unknown) {
+ if (!cancelled)
+ setError(error_ instanceof Error ? error_.message : "Key generation failed");
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- runs once per activation of the step
+ }, [active]);
+
+ return { error, made };
+};
+
const certFrom = (text: string): Uint8Array =>
readCertificateDer(new TextEncoder().encode(text.trim()));
@@ -69,6 +121,7 @@ const PathA = ({
const cleanEmail = email.trim().toLowerCase();
const detailsReady = fullName.trim() !== "" && looksLikeEmail(email);
+ const { error: keyError, made } = useMadeAdminKey(mode === "make" && !csrDer, cleanEmail);
const record = async () => {
setPending(true);
@@ -116,24 +169,17 @@ const PathA = ({
if (mode === "details") {
return (
-
- Full name
- setFullName(e.target.value)}
- value={fullName}
- />
-
-
- Email (the certificate's CN)
-
+ setFullName(e.target.value)} value={fullName} />
+
+
+ setEmail(e.target.value)}
type="email"
value={email}
/>
-
+
+ {keyError}
+
+ );
+ if (!made)
+ return (
+
+ Making a P-384 key and CSR in this browser…
+
+ );
return (
{
- setBackup(key.backup);
- setCsrDer(key.csrDer);
+ filename={keyBackupFilename("admin", cleanEmail)}
+ onContinue={() => {
+ setBackup(new TextEncoder().encode(made.backupPem));
+ setCsrDer(made.csrDer);
}}
+ onDownload={() =>
+ downloadText(
+ keyBackupFilename("admin", cleanEmail),
+ made.backupPem,
+ "application/x-pem-file",
+ )
+ }
+ passphrase={made.passphrase}
+ warning={
+ <>
+ Save the key backup and its passphrase
+
+ The private key was made in this browser and never goes to the Fleet Manager. Download
+ the encrypted key backup and store the passphrase in a password manager. The
+ passphrase is shown only here, once; without both, the key is lost and the request has
+ to start again.
+
+ >
+ }
/>
);
}
@@ -171,6 +247,7 @@ const PathA = ({
@@ -183,55 +260,51 @@ const PathA = ({
>
) : (
-
- Key backup file
-
+ {
const chosen = e.target.files?.[0];
if (chosen) void readFileBytes(chosen).then(setBackup);
}}
type="file"
/>
-
+
)}
-
- Signed admin certificate (PEM)
+
+
{anchorDer ? null : (
-
- Operator CA certificate for the chain (optional)
+
+
)}
-
- Key backup passphrase
-
+ setPassphrase(e.target.value)}
type="password"
value={passphrase}
/>
-
+
{result?.warnings.map((w) => (
-
+
{w}
-
+
))}
{error ? (
-
+
{error}
-
+
) : null}
v
return (
-
- Email (the certificate's CN)
- setEmail(e.target.value)}
- type="email"
- value={email}
- />
-
+
+ setEmail(e.target.value)} type="email" value={email} />
+
@@ -305,26 +372,20 @@ const PathB = ({ adminExtfile, onDone }: { adminExtfile: string; onDone: () => v
Paste the signed certificate to have the Fleet Manager check it now. Only the certificate
is sent.
-
- Full name
- setFullName(e.target.value)}
- value={fullName}
- />
-
-
- Signed admin certificate (PEM)
+
+ setFullName(e.target.value)} value={fullName} />
+
+
+
{error ? (
-
+
{error}
-
+
) : null}
{result ? (
@@ -365,7 +426,7 @@ export const AdminStep = ({
return (
- How to make the certificate
+ How to make the certificate
(null);
@@ -86,7 +84,6 @@ export const CaStep = ({
if (current !== "none" && !replace) {
const ca = current.operatorCa;
- const matches = normalizeFingerprint(pasted) === ca.sha256;
const reconfirm = async () => {
setPending(true);
setError(null);
@@ -104,42 +101,37 @@ export const CaStep = ({
};
return (
-
- An operator CA is already registered: {ca.subject} .
- Confirm it is yours before you continue.
-
-
- {colonFingerprint(ca.sha256)}
-
-
- On the CA machine, run{" "}
+
+ setReplace(true)} size="sm" variant="outline">
+ Register a different CA
+
+ void reconfirm()} size="sm">
+ Confirm this CA
+
+ >
+ }
+ compare
+ fingerprint={ca.sha256}
+ fingerprintLabel="sha256"
+ onCompare={setMatches}
+ subject={[["subject", ca.subject]]}
+ title="An operator CA is already registered"
+ tone="warning"
+ >
+ Confirm it is yours before you continue. On the CA machine, run{" "}
openssl x509 -in operator-ca.crt -noout -fingerprint -sha256
{" "}
- and paste the output. If it doesn't match, register your own CA: that retires this
- one immediately.
-
-
- Paste the fingerprint from the CA machine
- setPasted(e.target.value)}
- value={pasted}
- />
-
+ and paste the output below. If it doesn't match, register your own CA: that retires
+ this one immediately.
+
{error ? (
-
+
{error}
-
+
) : null}
-
- setReplace(true)} size="sm" variant="outline">
- Register a different CA
-
- void reconfirm()} size="sm">
- Confirm this CA
-
-
);
}
diff --git a/apps/console/src/components/first-run/first-run-wizard.test.tsx b/apps/console/src/components/first-run/first-run-wizard.test.tsx
index 68d8344..c1ca7cb 100644
--- a/apps/console/src/components/first-run/first-run-wizard.test.tsx
+++ b/apps/console/src/components/first-run/first-run-wizard.test.tsx
@@ -29,6 +29,7 @@ import { __resetBootstrap } from "@/lib/bootstrap";
import { base64 } from "@/lib/crypto/leaf-key";
import { colonFingerprint } from "@/lib/fingerprint";
import * as modeMod from "@/lib/fleet/mode";
+import { expectNoA11yViolations } from "@/test/axe";
const { buildCredentialPkcs12, downloadBytes, downloadText } = vi.hoisted(() => ({
buildCredentialPkcs12: vi.fn(),
@@ -157,6 +158,13 @@ afterEach(() => {
});
describe("FirstRunWizard token step", () => {
+ it("has no axe violations", async () => {
+ const { container } = render(
+ ,
+ );
+ await expectNoA11yViolations(container);
+ });
+
it("leads with the fingerprint check and shows where the token is on each OS", () => {
render( );
const danger = screen.getAllByRole("note")[0];
@@ -194,6 +202,22 @@ describe("FirstRunWizard (mock mode)", () => {
vi.spyOn(modeMod, "fleetMode").mockReturnValue("mock");
});
+ it("has no axe violations once the admin key backup step is showing", async () => {
+ const ca = await makeCA();
+ const { container } = render( );
+ await startSession();
+ await registerCA(ca);
+ fill(/full name/i, "Ada Admin");
+ fill(/^email/i, EMAIL);
+ fireEvent.click(screen.getByRole("button", { name: /make my admin key/i }));
+ await screen.findByLabelText(
+ /key backup passphrase/i,
+ { selector: "code" },
+ { timeout: 10_000 },
+ );
+ await expectNoA11yViolations(container);
+ }, 30_000);
+
it("path A: token, CA with fingerprint confirm, key backup before the CSR, upload, PKCS#12, install", async () => {
const generated = captureKeys();
const ca = await makeCA();
diff --git a/apps/console/src/components/first-run/first-run-wizard.tsx b/apps/console/src/components/first-run/first-run-wizard.tsx
index 71bd013..48054d4 100644
--- a/apps/console/src/components/first-run/first-run-wizard.tsx
+++ b/apps/console/src/components/first-run/first-run-wizard.tsx
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { Button, Wordmark } from "@cryptos-pki/ui";
+import { Button, Stepper, Wordmark } from "@cryptos-pki/ui";
import { useState } from "react";
import { AdminStep } from "@/components/first-run/admin-step";
@@ -81,17 +81,11 @@ export const FirstRunWizard = ({
No operator can sign in yet. Register your operator CA and get the first admin
certificate.
-
- {STEPS.map((s) => (
-
- {s.label}
-
- ))}
-
+ s.id === step)}
+ label="First-run progress"
+ steps={STEPS}
+ />
diff --git a/apps/console/src/components/first-run/token-step.tsx b/apps/console/src/components/first-run/token-step.tsx
index e6c6dd2..2b91ad1 100644
--- a/apps/console/src/components/first-run/token-step.tsx
+++ b/apps/console/src/components/first-run/token-step.tsx
@@ -14,12 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { Button } from "@cryptos-pki/ui";
+import { Button, Field, Input, Notice } from "@cryptos-pki/ui";
import { useState } from "react";
import { startBootstrapSession } from "@/lib/bootstrap";
import { fleetErrorMessage } from "@/lib/fleet/error-copy";
-import { fieldClass, labelClass } from "@/lib/form";
+
+const legendClass = "font-mono text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground";
const Command = ({ children }: { children: string }) => (
@@ -60,27 +61,20 @@ export const TokenStep = ({
return (
-
-
Check the fingerprint first
-
- This Fleet Manager serves a self-signed certificate until you give it one. Before you type
- the token, open the certificate details from the browser's address bar and compare
- its SHA-256 fingerprint with the{" "}
- server certificate SHA-256 line in the Fleet
- Manager's log. If they differ, stop: someone may be intercepting the connection, and
- the token would be theirs.
-
-
+
+ This Fleet Manager serves a self-signed certificate until you give it one. Before you type
+ the token, open the certificate details from the browser's address bar and compare its
+ SHA-256 fingerprint with the server certificate SHA-256{" "}
+ line in the Fleet Manager's log. If they differ, stop: someone may be intercepting the
+ connection, and the token would be theirs.
+
{inProgress ? (
-
+
First run was already started. If that wasn't you, continue with the newest token
from the log. That ends the earlier session, and replacing the registered CA retires
anything it trusted.
-
+
) : null}
@@ -98,10 +92,10 @@ export const TokenStep = ({
The Fleet Manager prints a single-use token in its log at start, and a new one each time
one is used or expires. Take the newest.
-
Linux / macOS
+
Linux / macOS
{"docker logs 2>&1 | grep 'bootstrap token'"}
{"journalctl -u fleet-manager | grep 'bootstrap token'"}
-
Windows (PowerShell)
+
Windows (PowerShell)
{"docker logs 2>&1 | Select-String 'bootstrap token'"}
{tokenExpiresAt ? (
@@ -110,21 +104,19 @@ export const TokenStep = ({
) : null}
-
- Bootstrap token
-
+ setToken(e.target.value)}
placeholder="fos_boot_..."
spellCheck={false}
value={token}
/>
-
+
{error ? (
-
+
{error}
-
+
) : null}
void start()} size="sm">
diff --git a/apps/console/src/components/key-backup-step.test.tsx b/apps/console/src/components/key-backup-step.test.tsx
deleted file mode 100644
index a6fca59..0000000
--- a/apps/console/src/components/key-backup-step.test.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
-Copyright The CryptOS Authors.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-import { KeyBackupStep } from "@/components/key-backup-step";
-
-const downloadText = vi.fn();
-vi.mock("@/lib/download", async (importOriginal) => ({
- ...(await importOriginal()),
- downloadText: (...a: unknown[]) => downloadText(...a),
-}));
-
-const passphraseShown = async (): Promise => {
- const el = await screen.findByLabelText(/key backup passphrase/i, {}, { timeout: 10_000 });
- return el.textContent ?? "";
-};
-
-describe("KeyBackupStep", () => {
- beforeEach(() => downloadText.mockReset());
-
- it("won't continue until the key backup is downloaded", async () => {
- const onBackedUp = vi.fn();
- render( );
- const passphrase = await passphraseShown();
- expect(passphrase).toHaveLength(24);
-
- const next = screen.getByRole("button", { name: /continue/i });
- expect(next).toBeDisabled();
- fireEvent.click(screen.getByLabelText(/saved the passphrase/i));
- expect(next).toBeDisabled();
-
- fireEvent.click(screen.getByRole("button", { name: /download key backup/i }));
- expect(downloadText).toHaveBeenCalledWith(
- "fleetos-admin-alice@example.org.key.pem",
- expect.stringMatching(/^-----BEGIN ENCRYPTED PRIVATE KEY-----/),
- "application/x-pem-file",
- );
- expect(next).toBeEnabled();
-
- fireEvent.click(next);
- await waitFor(() => expect(onBackedUp).toHaveBeenCalledTimes(1));
- const [{ backup, csrDer }] = onBackedUp.mock.calls[0] as [
- { backup: Uint8Array; csrDer: Uint8Array },
- ];
- expect(new TextDecoder().decode(backup)).toBe(downloadText.mock.calls[0][1]);
- expect(csrDer[0]).toBe(0x30);
- }, 20_000);
-
- it("shows the passphrase once: a remount makes a new key and never shows the old one", async () => {
- const first = render(
- ,
- );
- const old = await passphraseShown();
- first.unmount();
- expect(screen.queryByText(old)).not.toBeInTheDocument();
-
- render( );
- const fresh = await passphraseShown();
- expect(fresh).not.toBe(old);
- expect(screen.queryByText(old)).not.toBeInTheDocument();
- }, 20_000);
-
- it("keeps the passphrase out of storage", async () => {
- render( );
- const passphrase = await passphraseShown();
- const stored = [
- ...Object.values(localStorage),
- ...Object.values(sessionStorage),
- document.cookie,
- ].join("\n");
- expect(stored).not.toContain(passphrase);
- }, 20_000);
-});
diff --git a/apps/console/src/components/key-backup-step.tsx b/apps/console/src/components/key-backup-step.tsx
deleted file mode 100644
index 2d251b4..0000000
--- a/apps/console/src/components/key-backup-step.tsx
+++ /dev/null
@@ -1,170 +0,0 @@
-/*
-Copyright The CryptOS Authors.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import { useEffect, useState } from "react";
-
-import type { OperatorLevel } from "@/lib/operators";
-
-import { Button } from "@/components/ui/button";
-import { generateBackupPassphrase, keyBackupFilename, keyBackupPem } from "@/lib/crypto/key-backup";
-import { generateLeafKeyAndCSR } from "@/lib/crypto/leaf-key";
-import { downloadText } from "@/lib/download";
-
-export interface BackedUpKey {
- // backup is the encrypted key backup file, exactly as downloaded.
- backup: Uint8Array;
- csrDer: Uint8Array;
-}
-
-interface Made {
- backupPem: string;
- csrDer: Uint8Array;
- passphrase: string;
-}
-
-// KeyBackupStep makes a P-384 key and a CSR (CN=, asking for the level's
-// profile) in the browser, then makes the operator save an encrypted key
-// backup before anything else happens. The passphrase is generated here and
-// shown only while this step is mounted; nothing stores it. The private key
-// itself is dropped once the backup is written: later steps open the backup.
-export const KeyBackupStep = ({
- email,
- level,
- onBackedUp,
-}: {
- email: string;
- level: OperatorLevel;
- onBackedUp: (key: BackedUpKey) => void;
-}) => {
- const [made, setMade] = useState(null);
- const [error, setError] = useState(null);
- const [downloaded, setDownloaded] = useState(false);
- const [saved, setSaved] = useState(false);
-
- useEffect(() => {
- let cancelled = false;
- void (async () => {
- try {
- const { csrDer, privateKey } = await generateLeafKeyAndCSR({
- extensionRequest: { level },
- sans: [],
- subjectCn: email,
- });
- const passphrase = generateBackupPassphrase();
- const backupPem = await keyBackupPem(privateKey, passphrase);
- if (!cancelled) setMade({ backupPem, csrDer, passphrase });
- } catch (error_: unknown) {
- if (!cancelled)
- setError(error_ instanceof Error ? error_.message : "Key generation failed");
- }
- })();
- return () => {
- cancelled = true;
- };
- }, [email, level]);
-
- if (error) {
- return (
-
- {error}
-
- );
- }
-
- if (!made) {
- return (
-
- Making a P-384 key and CSR in this browser…
-
- );
- }
-
- const filename = keyBackupFilename(level, email);
-
- return (
-
-
-
- Save the key backup and its passphrase
-
-
- The private key was made in this browser and never goes to the Fleet Manager. Download the
- encrypted key backup and store the passphrase in a password manager. The passphrase is
- shown only here, once; the same passphrase will protect the PKCS#12. Without both, the key
- is lost and the request has to start again.
-
-
-
-
-
- Passphrase
-
-
-
- {made.passphrase}
-
- void navigator.clipboard?.writeText(made.passphrase)}
- size="sm"
- type="button"
- variant="outline"
- >
- Copy
-
-
-
-
-
- {
- downloadText(filename, made.backupPem, "application/x-pem-file");
- setDownloaded(true);
- }}
- size="sm"
- type="button"
- variant="outline"
- >
- Download key backup
-
- {filename}
-
-
-
- setSaved(e.target.checked)} type="checkbox" />I have
- saved the passphrase
-
-
-
-
- onBackedUp({ backup: new TextEncoder().encode(made.backupPem), csrDer: made.csrDer })
- }
- size="sm"
- type="button"
- >
- Continue
-
-
-
- );
-};
diff --git a/apps/console/src/components/layout/diagnostics-copy.tsx b/apps/console/src/components/layout/diagnostics-copy.tsx
index 602b38c..b15ebff 100644
--- a/apps/console/src/components/layout/diagnostics-copy.tsx
+++ b/apps/console/src/components/layout/diagnostics-copy.tsx
@@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+import { Button } from "@cryptos-pki/ui";
import { useState } from "react";
import { useLocation } from "react-router-dom";
-import { Button } from "@/components/ui/button";
import { useOptionalAuth } from "@/context/auth";
import { buildReport, fetchBuildInfo } from "@/lib/diagnostics";
import { fleetMode } from "@/lib/fleet/mode";
diff --git a/apps/console/src/components/ui/button.tsx b/apps/console/src/components/ui/button.tsx
deleted file mode 100644
index de4e760..0000000
--- a/apps/console/src/components/ui/button.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
-Copyright The CryptOS Authors.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import { Slot } from "@radix-ui/react-slot";
-import { cva, type VariantProps } from "class-variance-authority";
-import * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-const buttonVariants = cva(
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
- {
- defaultVariants: {
- size: "default",
- variant: "default",
- },
- variants: {
- size: {
- default: "h-9 px-4 py-2",
- icon: "h-9 w-9",
- lg: "h-10 rounded-md px-8",
- sm: "h-8 rounded-md px-3 text-xs",
- },
- variant: {
- default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
- destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
- ghost: "hover:bg-accent hover:text-accent-foreground",
- link: "text-primary underline-offset-4 hover:underline",
- outline:
- "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
- secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
- },
- },
- },
-);
-
-export interface ButtonProps
- extends React.ButtonHTMLAttributes, VariantProps {
- asChild?: boolean;
-}
-
-const Button = React.forwardRef(
- ({ asChild = false, className, size, variant, ...props }, ref) => {
- const Comp = asChild ? Slot : "button";
- return (
-
- );
- },
-);
-Button.displayName = "Button";
-
-export { Button, buttonVariants };
diff --git a/apps/console/src/components/ui/input.test.tsx b/apps/console/src/components/ui/input.test.tsx
deleted file mode 100644
index 7d2fa54..0000000
--- a/apps/console/src/components/ui/input.test.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
-Copyright The CryptOS Authors.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
-
-import { Input } from "./input";
-
-describe("Input", () => {
- it("renders and forwards typed value", () => {
- const onChange = vi.fn();
- render( );
- const el = screen.getByPlaceholderText("search");
- fireEvent.change(el, { target: { value: "ldap" } });
- expect(onChange).toHaveBeenCalled();
- });
-});
diff --git a/apps/console/src/components/ui/input.tsx b/apps/console/src/components/ui/input.tsx
deleted file mode 100644
index 2205485..0000000
--- a/apps/console/src/components/ui/input.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
-Copyright The CryptOS Authors.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-const Input = React.forwardRef>(
- // eslint-disable-next-line react/prop-types
- ({ className, type, ...props }, ref) => {
- return (
-
- );
- },
-);
-Input.displayName = "Input";
-
-export { Input };
diff --git a/apps/console/src/lib/form.ts b/apps/console/src/lib/form.ts
index 0536929..ca0f7a7 100644
--- a/apps/console/src/lib/form.ts
+++ b/apps/console/src/lib/form.ts
@@ -14,8 +14,5 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-// Shared field styling and checks for the operator credential forms.
-export const fieldClass = "w-full rounded-md border bg-card px-3 py-2 font-mono text-sm";
-export const labelClass = "font-mono text-[11px] uppercase tracking-wider text-muted-foreground";
-
+// Shared checks for the operator credential forms.
export const looksLikeEmail = (v: string): boolean => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim());
diff --git a/apps/console/src/pages/credential-request.test.tsx b/apps/console/src/pages/credential-request.test.tsx
index 7891bab..12236af 100644
--- a/apps/console/src/pages/credential-request.test.tsx
+++ b/apps/console/src/pages/credential-request.test.tsx
@@ -21,6 +21,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { generateBackupPassphrase, keyBackupPem } from "@/lib/crypto/key-backup";
import { CredentialRequestPage } from "@/pages/credential-request";
+import { expectNoA11yViolations } from "@/test/axe";
const { downloadBytes, downloadText, fleetClient } = vi.hoisted(() => ({
downloadBytes: vi.fn(),
@@ -62,6 +63,11 @@ const expectNoNetwork = () => {
};
describe("CredentialRequestPage", () => {
+ it("has no axe violations", async () => {
+ const { container } = render( );
+ await expectNoA11yViolations(container);
+ });
+
it("makes the key, backup and CSR entirely in the browser, with no network calls", async () => {
render( );
fill(/full name/i, "Dana Example");
diff --git a/apps/console/src/pages/credential-request.tsx b/apps/console/src/pages/credential-request.tsx
index f8c7aec..0976a7b 100644
--- a/apps/console/src/pages/credential-request.tsx
+++ b/apps/console/src/pages/credential-request.tsx
@@ -15,15 +15,29 @@ limitations under the License.
*/
import "reflect-metadata";
-import { Button, Wordmark } from "@cryptos-pki/ui";
+import {
+ Button,
+ CopyBlock,
+ Field,
+ Input,
+ inputClasses,
+ KeyBackupStep,
+ Notice,
+ Wordmark,
+} from "@cryptos-pki/ui";
import { X509Certificate } from "@peculiar/x509";
-import { useState } from "react";
+import { useEffect, useState } from "react";
-import { CopyBlock } from "@/components/copy-block";
-import { KeyBackupStep } from "@/components/key-backup-step";
-import { buildCredentialPkcs12, readCertificateDer } from "@/lib/crypto/key-backup";
-import { downloadBytes, readFileBytes } from "@/lib/download";
-import { fieldClass, labelClass, looksLikeEmail } from "@/lib/form";
+import {
+ buildCredentialPkcs12,
+ generateBackupPassphrase,
+ keyBackupFilename,
+ keyBackupPem,
+ readCertificateDer,
+} from "@/lib/crypto/key-backup";
+import { generateLeafKeyAndCSR } from "@/lib/crypto/leaf-key";
+import { downloadBytes, downloadText, readFileBytes } from "@/lib/download";
+import { looksLikeEmail } from "@/lib/form";
import {
credentialFileBase,
csrToPem,
@@ -47,6 +61,46 @@ const certIdentity = (certDer: Uint8Array): { email: string; level: string } =>
return { email, level };
};
+interface MadeRequestKey {
+ backupPem: string;
+ csrDer: Uint8Array;
+ passphrase: string;
+}
+
+// useMadeRequestKey makes the holder's P-384 key and CSR in the browser once
+// the form asks for one, the same pattern CredentialRequestWizard uses for
+// its own in-browser key, then makes the encrypted key backup.
+const useMadeRequestKey = (active: boolean, email: string, level: OperatorLevel) => {
+ const [made, setMade] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!active) return;
+ let cancelled = false;
+ void (async () => {
+ try {
+ const { csrDer, privateKey } = await generateLeafKeyAndCSR({
+ extensionRequest: { level },
+ sans: [],
+ subjectCn: email,
+ });
+ const passphrase = generateBackupPassphrase();
+ const backupPem = await keyBackupPem(privateKey, passphrase);
+ if (!cancelled) setMade({ backupPem, csrDer, passphrase });
+ } catch (error_: unknown) {
+ if (!cancelled)
+ setError(error_ instanceof Error ? error_.message : "Key generation failed");
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- runs once per activation of the step
+ }, [active]);
+
+ return { error, made };
+};
+
const MakeRequest = () => {
const [fullName, setFullName] = useState("");
const [email, setEmail] = useState("");
@@ -55,6 +109,7 @@ const MakeRequest = () => {
const [csrPem, setCsrPem] = useState(null);
const cleanEmail = email.trim().toLowerCase();
+ const { error: keyError, made } = useMadeRequestKey(making && !csrPem, cleanEmail, level);
if (csrPem) {
return (
@@ -62,6 +117,7 @@ const MakeRequest = () => {
@@ -74,39 +130,61 @@ const MakeRequest = () => {
}
if (making) {
+ if (keyError)
+ return (
+
+ {keyError}
+
+ );
+ if (!made)
+ return (
+
+ Making a P-384 key and CSR in this browser…
+
+ );
return (
setCsrPem(csrToPem(csrDer))}
+ filename={keyBackupFilename(level, cleanEmail)}
+ onContinue={() => setCsrPem(csrToPem(made.csrDer))}
+ onDownload={() =>
+ downloadText(
+ keyBackupFilename(level, cleanEmail),
+ made.backupPem,
+ "application/x-pem-file",
+ )
+ }
+ passphrase={made.passphrase}
+ warning={
+ <>
+ Save the key backup and its passphrase
+
+ The private key was made in this browser and never goes to the Fleet Manager. Download
+ the encrypted key backup and store the passphrase in a password manager. The
+ passphrase is shown only here, once; without both, the key is lost and you must start
+ again.
+
+ >
+ }
/>
);
}
return (