feat(12-04): setup API client functions + SetupPage wizard component

- Add 7 setup functions to client.ts: fetchSetupStatus, postSetupConfig,
  validateSetupDb, validateSetupOidc, validateSetupVapid, postSetupCredential,
  postSetupComplete; plus SetupAlreadyLockedError for 423 handling
- Add SetupPage.tsx: standalone 4-step wizard (Welcome → Instance Configuration
  → Calendar Credential → Terminal/Locked) with Surface 2 step indicator,
  Surface 5 validation rows, Surface 6 action row, Surface 7 terminal screen,
  Surface 8 already-locked screen; role=main, aria-live, no nav shell
- No dangerouslySetInnerHTML; no AppNav/BottomTabBar imports
- All 230 pwa tests pass; typecheck clean; build green
This commit is contained in:
Lucas Berger
2026-06-15 14:27:25 -04:00
parent eb84e6e8e2
commit 62d80f6c46
3 changed files with 1297 additions and 38 deletions
+161
View File
@@ -527,3 +527,164 @@ export async function saveMyCredential(payload: SaveMyCredentialPayload): Promis
handleAuthResponse(res, 'POST /api/me/credential');
}
// ── /api/setup/* (Phase 12 — initial setup wizard) ───────────────────────────
/**
* Response from GET /api/setup/status.
* setupComplete: false → the wizard has not been completed; redirect to /setup.
* setupComplete: true → normal app boot proceeds.
*/
export interface SetupStatusResponse {
setupComplete: boolean;
}
/**
* Payload for POST /api/setup/config.
* Collects non-secret runtime config written to the app_config table (D-02).
* No secrets — VAPID private key and encryption key stay in Docker env.
*/
export interface SetupConfigPayload {
app_url: string;
oidc_issuer: string;
oidc_client_id: string;
vapid_public_key: string;
}
/**
* Payload for POST /api/setup/credential.
* The Fastmail app password is sent once and NEVER stored client-side (T-12-15).
*/
export interface SetupCredentialPayload {
fastmailEmail: string;
appPassword: string;
}
/**
* GET /api/setup/status — unauthenticated; fetched before the OIDC guard.
* staleTime: 0 — always fresh (the gate must not be stale; mirrors D-10 spirit).
*/
export async function fetchSetupStatus(): Promise<SetupStatusResponse> {
const res = await fetch('/api/setup/status', {
// No credentials: 'include' needed — this is a pre-auth endpoint.
// No redirect: 'manual' — setup endpoints never redirect to Authelia.
});
if (!res.ok) {
throw new Error(`GET /api/setup/status failed: ${res.status}`);
}
return res.json() as Promise<SetupStatusResponse>;
}
/**
* POST /api/setup/config — writes non-secret config values to app_config.
* Must be called before the validation step so the OIDC issuer is persisted
* for the server-side discovery check.
*/
export async function postSetupConfig(payload: SetupConfigPayload): Promise<void> {
const res = await fetch('/api/setup/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.status === 423) {
throw new SetupAlreadyLockedError();
}
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `POST /api/setup/config failed: ${res.status}`);
}
}
/**
* POST /api/setup/validate/db — confirms DB connectivity.
* Returns void on success; throws on failure with a typed message.
*/
export async function validateSetupDb(): Promise<void> {
const res = await fetch('/api/setup/validate/db', { method: 'POST' });
if (res.status === 423) throw new SetupAlreadyLockedError();
if (!res.ok) {
throw new Error('Cannot reach the database. Check DB_HOST, DB_PORT, DB_USER, DB_PASSWORD in your Docker environment and try again.');
}
}
/**
* POST /api/setup/validate/oidc — fetches the OIDC discovery document.
* Requires that POST /api/setup/config has already been called with a valid oidc_issuer.
*/
export async function validateSetupOidc(): Promise<void> {
const res = await fetch('/api/setup/validate/oidc', { method: 'POST' });
if (res.status === 423) throw new SetupAlreadyLockedError();
if (!res.ok) {
throw new Error('OIDC discovery failed. Check the issuer URL and that Authelia is reachable from the server.');
}
}
/**
* POST /api/setup/validate/vapid — structural check of the VAPID key pair.
*/
export async function validateSetupVapid(): Promise<void> {
const res = await fetch('/api/setup/validate/vapid', { method: 'POST' });
if (res.status === 423) throw new SetupAlreadyLockedError();
if (!res.ok) {
throw new Error('VAPID validation failed. Check that your VAPID keys were generated with `npm run generate-secrets`.');
}
}
/**
* POST /api/setup/credential — validates the Fastmail app password against
* CalDAV PROPFIND and inserts the local wizard user + credential row.
* The password is never stored client-side (T-12-15).
*/
export async function postSetupCredential(payload: SetupCredentialPayload): Promise<void> {
const res = await fetch('/api/setup/credential', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerType: 'caldav',
fastmailEmail: payload.fastmailEmail,
appPassword: payload.appPassword,
}),
});
if (res.status === 423) throw new SetupAlreadyLockedError();
if (!res.ok) {
throw new Error("Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again.");
}
}
/**
* POST /api/setup/complete — flips setup_complete in app_config.
* Returns 200 on first call; throws SetupAlreadyLockedError on 423.
*/
export async function postSetupComplete(): Promise<void> {
const res = await fetch('/api/setup/complete', { method: 'POST' });
if (res.status === 423) throw new SetupAlreadyLockedError();
if (!res.ok) {
throw new Error(`POST /api/setup/complete failed: ${res.status}`);
}
}
/**
* Thrown when any setup endpoint returns 423 (setup already locked).
* SetupPage catches this and renders Surface 8 (Already Locked screen).
*/
export class SetupAlreadyLockedError extends Error {
readonly name = 'SetupAlreadyLockedError';
constructor() {
super('Setup is already complete and locked.');
Object.setPrototypeOf(this, SetupAlreadyLockedError.prototype);
}
}