fix(12-04): wire validateSetupVapid into setup wizard (close CR-01 / SETUP-02 gap)

- Import validateSetupVapid from api/client.ts in SetupPage.tsx
- Add vapid: ValidationRowState to validationRows state (alongside db/oidc)
- Extend configMutation.onSuccess chain: DB → OIDC → VAPID (sequential)
- Add ValidationRow for VAPID with pending/success/failure text
- Gate setBothPassed(true) on all three rows passing (db AND oidc AND vapid)
- Update anyPending and handleSaveAndValidate reset to include vapid state
- All 249 PWA tests pass; TypeScript clean

Closes CR-01; satisfies SETUP-02 "VAPID private key decodes to 32 bytes"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lucas Berger
2026-06-15 15:13:40 -04:00
co-authored by Claude Sonnet 4.6
parent 7d0205df05
commit 0d53249b02
+38 -11
View File
@@ -8,8 +8,9 @@
* Step 3: Calendar Credential (Fastmail email + app password; CalDAV validation)
* Terminal: "Setup complete" (Surface 7) — replaces wizard card after step 3 completes
*
* Step 2 validates DB connectivity (POST /api/setup/validate/db) and OIDC discovery
* (POST /api/setup/validate/oidc) AFTER writing config (POST /api/setup/config).
* Step 2 validates DB connectivity (POST /api/setup/validate/db), OIDC discovery
* (POST /api/setup/validate/oidc), and VAPID key pair (POST /api/setup/validate/vapid)
* AFTER writing config (POST /api/setup/config). All three must pass to proceed.
* Step 3 validates CalDAV PROPFIND (POST /api/setup/credential), then calls
* POST /api/setup/complete to flip setup_complete.
*
@@ -26,6 +27,7 @@ import {
postSetupConfig,
validateSetupDb,
validateSetupOidc,
validateSetupVapid,
postSetupCredential,
postSetupComplete,
SetupAlreadyLockedError,
@@ -43,6 +45,7 @@ type ValidationRowState = 'idle' | 'pending' | 'success' | 'failure';
interface ValidationRowStatus {
db: ValidationRowState;
oidc: ValidationRowState;
vapid: ValidationRowState;
caldav: ValidationRowState;
}
@@ -439,9 +442,10 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef }: Step2Props) {
const [vapidPublicKey, setVapidPublicKey] = useState('');
const [fieldError, setFieldError] = useState<string | null>(null);
const [validationRows, setValidationRows] = useState<Pick<ValidationRowStatus, 'db' | 'oidc'>>({
const [validationRows, setValidationRows] = useState<Pick<ValidationRowStatus, 'db' | 'oidc' | 'vapid'>>({
db: 'idle',
oidc: 'idle',
vapid: 'idle',
});
// Track overall state: null = not yet run, 'running', 'done' (both pass), 'failed'
@@ -453,15 +457,26 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef }: Step2Props) {
onSuccess: async () => {
setConfigSaved(true);
setFieldError(null);
// Now run DB validation
setValidationRows({ db: 'pending', oidc: 'idle' });
// Sequential validation: DB → OIDC → VAPID
setValidationRows({ db: 'pending', oidc: 'idle', vapid: 'idle' });
try {
await validateSetupDb();
setValidationRows({ db: 'success', oidc: 'pending' });
setValidationRows({ db: 'success', oidc: 'pending', vapid: 'idle' });
try {
await validateSetupOidc();
setValidationRows({ db: 'success', oidc: 'success' });
setValidationRows({ db: 'success', oidc: 'success', vapid: 'pending' });
try {
await validateSetupVapid();
setValidationRows({ db: 'success', oidc: 'success', vapid: 'success' });
setBothPassed(true);
} catch (vapidErr) {
setValidationRows((prev) => ({ ...prev, vapid: 'failure' }));
setFieldError(
vapidErr instanceof Error
? vapidErr.message
: 'VAPID validation failed. Check that your VAPID keys were generated with `npm run generate-secrets`.',
);
}
} catch (oidcErr) {
setValidationRows((prev) => ({ ...prev, oidc: 'failure' }));
setFieldError(
@@ -471,7 +486,7 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef }: Step2Props) {
);
}
} catch (dbErr) {
setValidationRows({ db: 'failure', oidc: 'idle' });
setValidationRows({ db: 'failure', oidc: 'idle', vapid: 'idle' });
setFieldError(
dbErr instanceof Error
? dbErr.message
@@ -487,14 +502,17 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef }: Step2Props) {
});
const isSaveValidating = configMutation.isPending;
const isValidating = validationRows.db === 'pending' || validationRows.oidc === 'pending';
const isValidating =
validationRows.db === 'pending' ||
validationRows.oidc === 'pending' ||
validationRows.vapid === 'pending';
const anyPending = isSaveValidating || isValidating;
function handleSaveAndValidate() {
setFieldError(null);
setBothPassed(false);
setConfigSaved(false);
setValidationRows({ db: 'idle', oidc: 'idle' });
setValidationRows({ db: 'idle', oidc: 'idle', vapid: 'idle' });
if (!appUrl.trim() || !oidcIssuer.trim() || !oidcClientId.trim() || !vapidPublicKey.trim()) {
setFieldError('All fields are required.');
@@ -644,9 +662,18 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef }: Step2Props) {
'OIDC discovery failed. Check the issuer URL and that Authelia is reachable from the server.'
}
/>
<ValidationRow
state={validationRows.vapid}
pendingText="Validating VAPID key pair…"
successText="VAPID keys verified."
failureText={
fieldError ??
'VAPID validation failed. Check that your VAPID keys were generated with `npm run generate-secrets`.'
}
/>
{/* General field error (before validation rows show) */}
{fieldError && validationRows.db === 'idle' && validationRows.oidc === 'idle' && (
{fieldError && validationRows.db === 'idle' && validationRows.oidc === 'idle' && validationRows.vapid === 'idle' && (
<div
role="status"
aria-live="polite"