From a193bc8236314a0c4a1927e418f47780c6aa4164 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 16 Jun 2026 16:53:14 -0400 Subject: [PATCH] =?UTF-8?q?fix(12):=20satisfy=20CI=20fast-checks=20?= =?UTF-8?q?=E2=80=94=20lint=20unused=20vars,=20typed=20contract-test=20bod?= =?UTF-8?q?y,=20prettier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused 'res'/'container' assignments (no-unused-vars) - setupClient.contract.test.ts: typed parseSentBody helper + non-async json mock (no-unsafe-*/require-await) - Prettier format 7 setup files Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/auth/middleware.ts | 12 ++- apps/api/src/routes/setup.ts | 61 +++++++------ apps/api/tests/routes/setup.test.ts | 14 ++- apps/pwa/src/api/client.ts | 16 +++- apps/pwa/src/api/setupClient.contract.test.ts | 67 ++++++++------ apps/pwa/src/routes/SetupPage.test.tsx | 47 +++++++--- apps/pwa/src/routes/SetupPage.tsx | 87 ++++++++----------- 7 files changed, 172 insertions(+), 132 deletions(-) diff --git a/apps/api/src/auth/middleware.ts b/apps/api/src/auth/middleware.ts index 494cf0d..1737635 100644 --- a/apps/api/src/auth/middleware.ts +++ b/apps/api/src/auth/middleware.ts @@ -89,15 +89,21 @@ export async function oidcConfigFallbackMiddleware(c: Context, next: Next): Prom // container is already running, the in-process value is stale until restart. // A container restart is required to pick up any changed OIDC config values. if (key === 'oidc_issuer') { - console.info('[oidcFallback] Writing OIDC_ISSUER from app_config — container restart required to update this value'); + console.info( + '[oidcFallback] Writing OIDC_ISSUER from app_config — container restart required to update this value', + ); process.env.OIDC_ISSUER = row.value; } if (key === 'oidc_client_id') { - console.info('[oidcFallback] Writing OIDC_CLIENT_ID from app_config — container restart required to update this value'); + console.info( + '[oidcFallback] Writing OIDC_CLIENT_ID from app_config — container restart required to update this value', + ); process.env.OIDC_CLIENT_ID = row.value; } if (key === 'app_external_url') { - console.info('[oidcFallback] Writing OIDC_AUTH_EXTERNAL_URL from app_config — container restart required to update this value'); + console.info( + '[oidcFallback] Writing OIDC_AUTH_EXTERNAL_URL from app_config — container restart required to update this value', + ); process.env.OIDC_AUTH_EXTERNAL_URL = row.value; } } diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts index a347e85..8526818 100644 --- a/apps/api/src/routes/setup.ts +++ b/apps/api/src/routes/setup.ts @@ -148,7 +148,10 @@ setupRouter.post('/validate/db', async (c) => { await db.execute(sql`SELECT 1`); return c.json({ ok: true }, 200); } catch (err) { - console.error('[setup/validate/db] DB round-trip failed:', err instanceof Error ? err.message : String(err)); + console.error( + '[setup/validate/db] DB round-trip failed:', + err instanceof Error ? err.message : String(err), + ); return c.json({ ok: false, error: 'DB unavailable' }, 503); } }); @@ -217,7 +220,10 @@ setupRouter.post('/validate/vapid', async (c) => { const publicKey = process.env.VAPID_PUBLIC_KEY ?? ''; if (!privateKey || !publicKey) { - return c.json({ ok: false, error: 'VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY env vars must be set' }, 400); + return c.json( + { ok: false, error: 'VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY env vars must be set' }, + 400, + ); } // Gap 2: assert the operator-submitted public key matches the env public key BEFORE @@ -230,11 +236,14 @@ setupRouter.post('/validate/vapid', async (c) => { const submittedPublicKey = submittedRow?.value; if (!submittedPublicKey || submittedPublicKey !== publicKey) { - return c.json({ - ok: false, - error: - 'VAPID public key does not match the configured key pair. Paste the exact VAPID_PUBLIC_KEY printed by `npm run generate-secrets`.', - }, 400); + return c.json( + { + ok: false, + error: + 'VAPID public key does not match the configured key pair. Paste the exact VAPID_PUBLIC_KEY printed by `npm run generate-secrets`.', + }, + 400, + ); } try { @@ -248,10 +257,13 @@ setupRouter.post('/validate/vapid', async (c) => { ); return c.json({ ok: true }, 200); } catch (err) { - return c.json({ - ok: false, - error: err instanceof Error ? err.message : 'VAPID validation failed', - }, 400); + return c.json( + { + ok: false, + error: err instanceof Error ? err.message : 'VAPID validation failed', + }, + 400, + ); } }); @@ -298,7 +310,9 @@ setupRouter.post('/credential', zValidator('json', credentialSchema, noEchoHook) )) as unknown as [{ count: string | number }[], unknown]; const unclaimedCount = Number(countRows[0][0]?.count ?? 0); if (unclaimedCount > 0) { - throw Object.assign(new Error('An unclaimed user already exists'), { code: 'DUPLICATE_UNCLAIMED' }); + throw Object.assign(new Error('An unclaimed user already exists'), { + code: 'DUPLICATE_UNCLAIMED', + }); } // Step 1: Assign color (first unused from palette, or round-robin fallback) @@ -322,20 +336,22 @@ setupRouter.post('/credential', zValidator('json', credentialSchema, noEchoHook) }) .$returningId(); - const [row] = await tx - .select() - .from(users) - .where(eq(users.id, inserted.id)) - .limit(1); + const [row] = await tx.select().from(users).where(eq(users.id, inserted.id)).limit(1); return row; }); } catch (err) { - if (err instanceof Error && (err as NodeJS.ErrnoException & { code?: string }).code === 'DUPLICATE_UNCLAIMED') { + if ( + err instanceof Error && + (err as NodeJS.ErrnoException & { code?: string }).code === 'DUPLICATE_UNCLAIMED' + ) { // A concurrent request already created the unclaimed admin row return c.json({ error: 'Setup already in progress' }, 409); } - console.error('[setup/POST /credential] Transaction error:', err instanceof Error ? err.message : String(err)); + console.error( + '[setup/POST /credential] Transaction error:', + err instanceof Error ? err.message : String(err), + ); return c.json({ error: 'Service unavailable' }, 503); } @@ -353,12 +369,7 @@ setupRouter.post('/credential', zValidator('json', credentialSchema, noEchoHook) // Step 3: Validate + encrypt + store the credential via the shared helper (D-09) // The user row MUST exist before this call (Pitfall 5 — FK constraint). try { - await validateEncryptAndStoreCredential( - localUser.id, - fastmailEmail, - appPassword, - 'caldav', - ); + await validateEncryptAndStoreCredential(localUser.id, fastmailEmail, appPassword, 'caldav'); } catch (err) { // Roll back the local user insert on credential failure to avoid orphaned rows await db.delete(users).where(eq(users.id, localUser.id)); diff --git a/apps/api/tests/routes/setup.test.ts b/apps/api/tests/routes/setup.test.ts index e8ea60f..74900c9 100644 --- a/apps/api/tests/routes/setup.test.ts +++ b/apps/api/tests/routes/setup.test.ts @@ -203,7 +203,8 @@ async function seedCredential(userId: number): Promise { // Env and DB cleanup between tests // --------------------------------------------------------------------------- -const VAPID_PUBLIC_KEY = 'BKO9RLPqxNQ7GOLHsQ5kFXqMkfJBfElkd5h9ECQ0gRu7R5SJP6Ct5GkRuPxqY1u0UVY84_z7JGJsLhO-wChk_sE'; +const VAPID_PUBLIC_KEY = + 'BKO9RLPqxNQ7GOLHsQ5kFXqMkfJBfElkd5h9ECQ0gRu7R5SJP6Ct5GkRuPxqY1u0UVY84_z7JGJsLhO-wChk_sE'; const VAPID_PRIVATE_KEY = 'QEkqsrxLqpv_ynpCECWFLYlOxCEGd-_O5u0AXzY_qEY'; beforeEach(async () => { @@ -223,9 +224,7 @@ beforeEach(async () => { afterEach(async () => { // Clean up seeded rows between tests await db.delete(memberCredentials); - await db - .delete(users) - .where(eq(users.oidcIss, 'https://auth.test.setup')); + await db.delete(users).where(eq(users.oidcIss, 'https://auth.test.setup')); // Delete local (wizard-created) users with null oidcIss // We identify them by displayName prefix for safety // Use raw delete of unclaimed users (the test-seeded ones) @@ -554,7 +553,7 @@ describe('POST /api/setup/credential', () => { displayName: 'OIDC User With claimed=false', color: '#4A90D9', isAdmin: false, - claimed: false, // legacy/hypothetical — oidcIss is NOT NULL + claimed: false, // legacy/hypothetical — oidcIss is NOT NULL }); mockValidateCredentialShouldThrow = false; @@ -632,9 +631,8 @@ describe('/api/setup/* — 423 when effectively configured (D-10 effective-confi process.env.VAPID_PUBLIC_KEY = VAPID_PUBLIC_KEY; const app = await getApp(); - const res = await app.fetch(jsonRequest('GET', '/api/setup/status')); - // status route always returns the status — but guard kicks in on mutation routes - // Test a mutation route to confirm 423 + // The /status route always returns the status; the guard kicks in on mutation + // routes. Test a mutation route to confirm 423. const completeRes = await app.fetch(jsonRequest('POST', '/api/setup/complete')); expect(completeRes.status).toBe(423); }); diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index 6b02998..6bfd1fd 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -636,7 +636,9 @@ export async function validateSetupDb(): Promise { 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.'); + throw new Error( + 'Cannot reach the database. Check DB_HOST, DB_PORT, DB_USER, DB_PASSWORD in your Docker environment and try again.', + ); } } @@ -650,7 +652,9 @@ export async function validateSetupOidc(): Promise { 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.'); + throw new Error( + 'OIDC discovery failed. Check the issuer URL and that Authelia is reachable from the server.', + ); } } @@ -663,7 +667,9 @@ export async function validateSetupVapid(): Promise { 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`.'); + throw new Error( + 'VAPID validation failed. Check that your VAPID keys were generated with `npm run generate-secrets`.', + ); } } @@ -686,7 +692,9 @@ export async function postSetupCredential(payload: SetupCredentialPayload): Prom 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."); + throw new Error( + "Invalid password — CalDAV validation failed. Check the scope is 'Calendars & Contacts (CalDAV)' and try again.", + ); } } diff --git a/apps/pwa/src/api/setupClient.contract.test.ts b/apps/pwa/src/api/setupClient.contract.test.ts index e00942e..78294b5 100644 --- a/apps/pwa/src/api/setupClient.contract.test.ts +++ b/apps/pwa/src/api/setupClient.contract.test.ts @@ -20,7 +20,12 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { postSetupConfig, postSetupCredential, type SetupConfigPayload, type SetupCredentialPayload } from './client.js'; +import { + postSetupConfig, + postSetupCredential, + type SetupConfigPayload, + type SetupCredentialPayload, +} from './client.js'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -28,11 +33,21 @@ function mockFetchResponse(body: unknown, status = 200): Response { return { ok: status >= 200 && status < 300, status, - json: async () => body, - clone: function () { return this as unknown as Response; }, + json: () => Promise.resolve(body), + clone: function () { + return this as unknown as Response; + }, } as unknown as Response; } +// Extract the JSON body sent on the first fetch call as a typed record, so the +// strict typed-lint rules (no-unsafe-*) are satisfied without scattering casts. +function parseSentBody(spy: ReturnType): Record { + const calls = spy.mock.calls as unknown as Array<[unknown, RequestInit]>; + const init = calls[0][1]; + return JSON.parse(init.body as string) as Record; +} + // Build a canonical camelCase payload using the contract type. // If SetupConfigPayload still has snake_case fields, TypeScript will error here // (the interface and this test agree on the camelCase contract). @@ -61,7 +76,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); await postSetupConfig(VALID_PAYLOAD); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); + const body = parseSentBody(fetchSpy); expect(body).toHaveProperty('oidcIssuer', 'https://auth.example.com'); expect(body).not.toHaveProperty('oidc_issuer'); }); @@ -70,7 +85,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); await postSetupConfig(VALID_PAYLOAD); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); + const body = parseSentBody(fetchSpy); expect(body).toHaveProperty('oidcClientId', 'familysync'); expect(body).not.toHaveProperty('oidc_client_id'); }); @@ -79,7 +94,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); await postSetupConfig(VALID_PAYLOAD); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); + const body = parseSentBody(fetchSpy); expect(body).toHaveProperty('vapidPublicKey', 'BH_example_public_key'); expect(body).not.toHaveProperty('vapid_public_key'); }); @@ -88,7 +103,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); await postSetupConfig(VALID_PAYLOAD); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); + const body = parseSentBody(fetchSpy); expect(body).toHaveProperty('appExternalUrl', 'https://familysync.example.com'); expect(body).not.toHaveProperty('app_url'); }); @@ -97,7 +112,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); await postSetupConfig(VALID_PAYLOAD); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); + const body = parseSentBody(fetchSpy); expect(Object.keys(body).sort()).toEqual( ['appExternalUrl', 'oidcClientId', 'oidcIssuer', 'vapidPublicKey'].sort(), ); @@ -125,15 +140,17 @@ describe('postSetupConfig — error rendering (BUG 2 regression)', () => { error: { name: 'ZodError', issues: [ - { code: 'invalid_string', message: 'oidcIssuer must be an https URL', path: ['oidcIssuer'] }, + { + code: 'invalid_string', + message: 'oidcIssuer must be an https URL', + path: ['oidcIssuer'], + }, ], }, }; fetchSpy.mockResolvedValueOnce(mockFetchResponse(zodErrorBody, 400)); - await expect( - postSetupConfig(VALID_PAYLOAD), - ).rejects.toSatisfy((err: Error) => { + await expect(postSetupConfig(VALID_PAYLOAD)).rejects.toSatisfy((err: Error) => { // The error message must NOT be "[object Object]" expect(err.message).not.toBe('[object Object]'); // The error message must be a non-empty string @@ -149,31 +166,29 @@ describe('postSetupConfig — error rendering (BUG 2 regression)', () => { error: { name: 'ZodError', issues: [ - { code: 'invalid_string', message: 'oidcIssuer must be an https URL', path: ['oidcIssuer'] }, + { + code: 'invalid_string', + message: 'oidcIssuer must be an https URL', + path: ['oidcIssuer'], + }, ], }, }; fetchSpy.mockResolvedValueOnce(mockFetchResponse(zodErrorBody, 400)); - await expect( - postSetupConfig(VALID_PAYLOAD), - ).rejects.toThrow('oidcIssuer must be an https URL'); + await expect(postSetupConfig(VALID_PAYLOAD)).rejects.toThrow('oidcIssuer must be an https URL'); }); it('throws a fallback status error when error body is empty', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse({}, 400)); - await expect( - postSetupConfig(VALID_PAYLOAD), - ).rejects.toThrow('400'); + await expect(postSetupConfig(VALID_PAYLOAD)).rejects.toThrow('400'); }); it('returns void on 200 success', async () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); - await expect( - postSetupConfig(VALID_PAYLOAD), - ).resolves.toBeUndefined(); + await expect(postSetupConfig(VALID_PAYLOAD)).resolves.toBeUndefined(); }); }); @@ -202,7 +217,7 @@ describe('postSetupCredential — payload contract (CR-01 regression)', () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); await postSetupCredential(VALID_CREDENTIAL_PAYLOAD); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); + const body = parseSentBody(fetchSpy); expect(Object.keys(body).sort()).toEqual(['appPassword', 'fastmailEmail'].sort()); }); @@ -210,7 +225,7 @@ describe('postSetupCredential — payload contract (CR-01 regression)', () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); await postSetupCredential(VALID_CREDENTIAL_PAYLOAD); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); + const body = parseSentBody(fetchSpy); expect(body).not.toHaveProperty('providerType'); }); @@ -218,7 +233,7 @@ describe('postSetupCredential — payload contract (CR-01 regression)', () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); await postSetupCredential(VALID_CREDENTIAL_PAYLOAD); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); + const body = parseSentBody(fetchSpy); expect(body).toHaveProperty('fastmailEmail', 'user@fastmail.com'); }); @@ -226,7 +241,7 @@ describe('postSetupCredential — payload contract (CR-01 regression)', () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200)); await postSetupCredential(VALID_CREDENTIAL_PAYLOAD); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string); + const body = parseSentBody(fetchSpy); expect(body).toHaveProperty('appPassword', 'secret-app-password'); }); }); diff --git a/apps/pwa/src/routes/SetupPage.test.tsx b/apps/pwa/src/routes/SetupPage.test.tsx index 9634506..ccff8ba 100644 --- a/apps/pwa/src/routes/SetupPage.test.tsx +++ b/apps/pwa/src/routes/SetupPage.test.tsx @@ -51,7 +51,10 @@ function makeQueryClient() { }); } -function renderSetupPage(queryClient: QueryClient, props: React.ComponentProps = {}) { +function renderSetupPage( + queryClient: QueryClient, + props: React.ComponentProps = {}, +) { return render( @@ -149,7 +152,7 @@ describe('SetupPage — Welcome step', () => { }); it('has aria-live region for validation status', async () => { - const { container } = renderSetupPage(queryClient); + renderSetupPage(queryClient); // The step 2 config form has aria-live; step 1 welcome step does not yet show validation // but the structure has it via the general error block. We advance to step 2 to test. // For now we verify that once rendered, the component tree has the correct aria-live @@ -239,15 +242,20 @@ describe('SetupPage — Step 2 VAPID validation (CR-01 gap)', () => { const { postSetupConfig } = await import('../api/client.js'); (postSetupConfig as ReturnType).mockResolvedValue(undefined); - fireEvent.change(screen.getByLabelText('App URL'), { target: { value: 'https://app.example.com' } }); - fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { target: { value: 'https://auth.example.com' } }); + fireEvent.change(screen.getByLabelText('App URL'), { + target: { value: 'https://app.example.com' }, + }); + fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { + target: { value: 'https://auth.example.com' }, + }); fireEvent.change(screen.getByLabelText('OIDC client ID'), { target: { value: 'familysync' } }); fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } }); fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' })); } it('calls validateSetupVapid after DB and OIDC pass in step 2', async () => { - const { validateSetupDb, validateSetupOidc, validateSetupVapid } = await import('../api/client.js'); + const { validateSetupDb, validateSetupOidc, validateSetupVapid } = + await import('../api/client.js'); (validateSetupDb as ReturnType).mockResolvedValue(undefined); (validateSetupOidc as ReturnType).mockResolvedValue(undefined); (validateSetupVapid as ReturnType).mockResolvedValue(undefined); @@ -261,7 +269,8 @@ describe('SetupPage — Step 2 VAPID validation (CR-01 gap)', () => { }); it('renders a VAPID validation row after step 2 validation completes', async () => { - const { validateSetupDb, validateSetupOidc, validateSetupVapid } = await import('../api/client.js'); + const { validateSetupDb, validateSetupOidc, validateSetupVapid } = + await import('../api/client.js'); (validateSetupDb as ReturnType).mockResolvedValue(undefined); (validateSetupOidc as ReturnType).mockResolvedValue(undefined); (validateSetupVapid as ReturnType).mockResolvedValue(undefined); @@ -276,11 +285,14 @@ describe('SetupPage — Step 2 VAPID validation (CR-01 gap)', () => { }); it('does NOT show Continue when VAPID validation fails', async () => { - const { validateSetupDb, validateSetupOidc, validateSetupVapid } = await import('../api/client.js'); + const { validateSetupDb, validateSetupOidc, validateSetupVapid } = + await import('../api/client.js'); (validateSetupDb as ReturnType).mockResolvedValue(undefined); (validateSetupOidc as ReturnType).mockResolvedValue(undefined); (validateSetupVapid as ReturnType).mockRejectedValue( - new Error('VAPID validation failed. Check that your VAPID keys were generated with `npm run generate-secrets`.') + new Error( + 'VAPID validation failed. Check that your VAPID keys were generated with `npm run generate-secrets`.', + ), ); await advanceToStep2(); @@ -296,7 +308,8 @@ describe('SetupPage — Step 2 VAPID validation (CR-01 gap)', () => { }); it('shows Continue only when db, oidc, AND vapid all pass', async () => { - const { validateSetupDb, validateSetupOidc, validateSetupVapid } = await import('../api/client.js'); + const { validateSetupDb, validateSetupOidc, validateSetupVapid } = + await import('../api/client.js'); (validateSetupDb as ReturnType).mockResolvedValue(undefined); (validateSetupOidc as ReturnType).mockResolvedValue(undefined); (validateSetupVapid as ReturnType).mockResolvedValue(undefined); @@ -360,8 +373,12 @@ describe('SetupPage — Step 2 Instance copy + DB-name field (gaps 1, 3)', () => (validateSetupVapid as ReturnType).mockResolvedValue(undefined); await advanceToStep2(); - fireEvent.change(screen.getByLabelText('App URL'), { target: { value: 'https://app.example.com' } }); - fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { target: { value: 'https://auth.example.com' } }); + fireEvent.change(screen.getByLabelText('App URL'), { + target: { value: 'https://app.example.com' }, + }); + fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { + target: { value: 'https://auth.example.com' }, + }); fireEvent.change(screen.getByLabelText('OIDC client ID'), { target: { value: 'familysync' } }); fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } }); fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' })); @@ -403,8 +420,12 @@ describe('SetupPage — Back navigation preserves Instance fields (gap 4)', () = fireEvent.click(await screen.findByRole('button', { name: 'Continue' })); await screen.findByText('Instance Configuration'); - fireEvent.change(screen.getByLabelText('App URL'), { target: { value: 'https://app.example.com' } }); - fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { target: { value: 'https://auth.example.com' } }); + fireEvent.change(screen.getByLabelText('App URL'), { + target: { value: 'https://app.example.com' }, + }); + fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { + target: { value: 'https://auth.example.com' }, + }); fireEvent.change(screen.getByLabelText('OIDC client ID'), { target: { value: 'familysync' } }); fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } }); fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' })); diff --git a/apps/pwa/src/routes/SetupPage.tsx b/apps/pwa/src/routes/SetupPage.tsx index b5652f3..2c200d8 100644 --- a/apps/pwa/src/routes/SetupPage.tsx +++ b/apps/pwa/src/routes/SetupPage.tsx @@ -175,14 +175,8 @@ function StepIndicator({ currentStep }: StepIndicatorProps) { display: 'flex', alignItems: 'center', justifyContent: 'center', - background: - isCompleted || isActive - ? 'var(--color-member-0, #4a90d9)' - : 'transparent', - border: - isCompleted || isActive - ? 'none' - : '2px solid var(--color-border, #e2e4e9)', + background: isCompleted || isActive ? 'var(--color-member-0, #4a90d9)' : 'transparent', + border: isCompleted || isActive ? 'none' : '2px solid var(--color-border, #e2e4e9)', color: isCompleted || isActive ? '#ffffff' : 'var(--color-text-muted, #9ca3af)', fontSize: 'var(--text-label-size, 13px)', fontWeight: 600, @@ -220,11 +214,7 @@ function StepIndicator({ currentStep }: StepIndicatorProps) { )}
- {isCompleted ? ( -
- This wizard will guide you through configuring your self-hosted instance. Before - continuing, run{' '} - npm run generate-secrets{' '} - from the repo to generate your instance secrets and add them to your Docker environment. - You'll also need: your OIDC client credentials (Authelia) and a Fastmail account - with an app password. This takes about 5 minutes. + This wizard will guide you through configuring your self-hosted instance. Before continuing, + run npm run generate-secrets from the repo to generate your instance secrets + and add them to your Docker environment. You'll also need: your OIDC client credentials + (Authelia) and a Fastmail account with an app password. This takes about 5 minutes.

{/* Before you start note block */} @@ -465,7 +453,9 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S }); const dbName = setupStatus?.dbName ?? ''; - const [validationRows, setValidationRows] = useState>({ + const [validationRows, setValidationRows] = useState< + Pick + >({ db: 'idle', oidc: 'idle', vapid: 'idle', @@ -518,9 +508,7 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S } }, onError: (err) => { - setFieldError( - err instanceof Error ? err.message : 'Something went wrong. Please try again.', - ); + setFieldError(err instanceof Error ? err.message : 'Something went wrong. Please try again.'); }, }); @@ -654,9 +642,7 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S placeholder="familysync" style={inputStyle(false)} /> -
- The client ID registered in Authelia for this application. -
+
The client ID registered in Authelia for this application.
{/* VAPID Public Key */} @@ -673,8 +659,7 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S style={inputStyle(false)} />
- Paste the VAPID_PUBLIC_KEY value from{' '} - npm run generate-secrets. + Paste the VAPID_PUBLIC_KEY value from npm run generate-secrets.
@@ -721,19 +706,22 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S /> {/* General field error (before validation rows show) */} - {fieldError && validationRows.db === 'idle' && validationRows.oidc === 'idle' && validationRows.vapid === 'idle' && ( -
- {fieldError} -
- )} + {fieldError && + validationRows.db === 'idle' && + validationRows.oidc === 'idle' && + validationRows.vapid === 'idle' && ( +
+ {fieldError} +
+ )} {/* Continue + Back row — appears only when both validations pass */} {bothPassed && configSaved && ( @@ -806,15 +794,12 @@ function Step3Credential({ onBack, onSuccess, onLocked, stepHeadingRef }: Step3P onLocked(); return; } - setErrorText( - err instanceof Error ? err.message : 'Something went wrong. Please try again.', - ); + setErrorText(err instanceof Error ? err.message : 'Something went wrong. Please try again.'); }, }); const isPending = completeMutation.isPending || finalMutation.isPending; - const saveDisabled = - isPending || email.trim().length === 0 || password.trim().length === 0; + const saveDisabled = isPending || email.trim().length === 0 || password.trim().length === 0; function handleValidate() { if (saveDisabled) return; @@ -853,9 +838,8 @@ function Step3Credential({ onBack, onSuccess, onLocked, stepHeadingRef }: Step3P lineHeight: 1.5, }} > - Add the Fastmail app password for the first household member. This credential is - validated against Fastmail CalDAV before saving. The password is never stored in plain - text. + Add the Fastmail app password for the first household member. This credential is validated + against Fastmail CalDAV before saving. The password is never stored in plain text.

{/* Fastmail email */} @@ -1177,10 +1161,7 @@ export function SetupPage({ alreadyLocked = false }: SetupPageProps) { {/* Step card (Surface 3) */} {step === 1 && ( - setStep(2)} - stepHeadingRef={stepHeadingRef} - /> + setStep(2)} stepHeadingRef={stepHeadingRef} /> )} {step === 2 && (