Phase 12: Initial Setup Wizard #22
@@ -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.
|
// 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.
|
// A container restart is required to pick up any changed OIDC config values.
|
||||||
if (key === 'oidc_issuer') {
|
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;
|
process.env.OIDC_ISSUER = row.value;
|
||||||
}
|
}
|
||||||
if (key === 'oidc_client_id') {
|
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;
|
process.env.OIDC_CLIENT_ID = row.value;
|
||||||
}
|
}
|
||||||
if (key === 'app_external_url') {
|
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;
|
process.env.OIDC_AUTH_EXTERNAL_URL = row.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,7 +148,10 @@ setupRouter.post('/validate/db', async (c) => {
|
|||||||
await db.execute(sql`SELECT 1`);
|
await db.execute(sql`SELECT 1`);
|
||||||
return c.json({ ok: true }, 200);
|
return c.json({ ok: true }, 200);
|
||||||
} catch (err) {
|
} 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);
|
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 ?? '';
|
const publicKey = process.env.VAPID_PUBLIC_KEY ?? '';
|
||||||
|
|
||||||
if (!privateKey || !publicKey) {
|
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
|
// 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;
|
const submittedPublicKey = submittedRow?.value;
|
||||||
if (!submittedPublicKey || submittedPublicKey !== publicKey) {
|
if (!submittedPublicKey || submittedPublicKey !== publicKey) {
|
||||||
return c.json({
|
return c.json(
|
||||||
ok: false,
|
{
|
||||||
error:
|
ok: false,
|
||||||
'VAPID public key does not match the configured key pair. Paste the exact VAPID_PUBLIC_KEY printed by `npm run generate-secrets`.',
|
error:
|
||||||
}, 400);
|
'VAPID public key does not match the configured key pair. Paste the exact VAPID_PUBLIC_KEY printed by `npm run generate-secrets`.',
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -248,10 +257,13 @@ setupRouter.post('/validate/vapid', async (c) => {
|
|||||||
);
|
);
|
||||||
return c.json({ ok: true }, 200);
|
return c.json({ ok: true }, 200);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return c.json({
|
return c.json(
|
||||||
ok: false,
|
{
|
||||||
error: err instanceof Error ? err.message : 'VAPID validation failed',
|
ok: false,
|
||||||
}, 400);
|
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];
|
)) as unknown as [{ count: string | number }[], unknown];
|
||||||
const unclaimedCount = Number(countRows[0][0]?.count ?? 0);
|
const unclaimedCount = Number(countRows[0][0]?.count ?? 0);
|
||||||
if (unclaimedCount > 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)
|
// Step 1: Assign color (first unused from palette, or round-robin fallback)
|
||||||
@@ -322,20 +336,22 @@ setupRouter.post('/credential', zValidator('json', credentialSchema, noEchoHook)
|
|||||||
})
|
})
|
||||||
.$returningId();
|
.$returningId();
|
||||||
|
|
||||||
const [row] = await tx
|
const [row] = await tx.select().from(users).where(eq(users.id, inserted.id)).limit(1);
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.id, inserted.id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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
|
// A concurrent request already created the unclaimed admin row
|
||||||
return c.json({ error: 'Setup already in progress' }, 409);
|
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);
|
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)
|
// 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).
|
// The user row MUST exist before this call (Pitfall 5 — FK constraint).
|
||||||
try {
|
try {
|
||||||
await validateEncryptAndStoreCredential(
|
await validateEncryptAndStoreCredential(localUser.id, fastmailEmail, appPassword, 'caldav');
|
||||||
localUser.id,
|
|
||||||
fastmailEmail,
|
|
||||||
appPassword,
|
|
||||||
'caldav',
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Roll back the local user insert on credential failure to avoid orphaned rows
|
// Roll back the local user insert on credential failure to avoid orphaned rows
|
||||||
await db.delete(users).where(eq(users.id, localUser.id));
|
await db.delete(users).where(eq(users.id, localUser.id));
|
||||||
|
|||||||
@@ -203,7 +203,8 @@ async function seedCredential(userId: number): Promise<void> {
|
|||||||
// Env and DB cleanup between tests
|
// 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';
|
const VAPID_PRIVATE_KEY = 'QEkqsrxLqpv_ynpCECWFLYlOxCEGd-_O5u0AXzY_qEY';
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -223,9 +224,7 @@ beforeEach(async () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
// Clean up seeded rows between tests
|
// Clean up seeded rows between tests
|
||||||
await db.delete(memberCredentials);
|
await db.delete(memberCredentials);
|
||||||
await db
|
await db.delete(users).where(eq(users.oidcIss, 'https://auth.test.setup'));
|
||||||
.delete(users)
|
|
||||||
.where(eq(users.oidcIss, 'https://auth.test.setup'));
|
|
||||||
// Delete local (wizard-created) users with null oidcIss
|
// Delete local (wizard-created) users with null oidcIss
|
||||||
// We identify them by displayName prefix for safety
|
// We identify them by displayName prefix for safety
|
||||||
// Use raw delete of unclaimed users (the test-seeded ones)
|
// 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',
|
displayName: 'OIDC User With claimed=false',
|
||||||
color: '#4A90D9',
|
color: '#4A90D9',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
claimed: false, // legacy/hypothetical — oidcIss is NOT NULL
|
claimed: false, // legacy/hypothetical — oidcIss is NOT NULL
|
||||||
});
|
});
|
||||||
|
|
||||||
mockValidateCredentialShouldThrow = false;
|
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;
|
process.env.VAPID_PUBLIC_KEY = VAPID_PUBLIC_KEY;
|
||||||
|
|
||||||
const app = await getApp();
|
const app = await getApp();
|
||||||
const res = await app.fetch(jsonRequest('GET', '/api/setup/status'));
|
// The /status route always returns the status; the guard kicks in on mutation
|
||||||
// status route always returns the status — but guard kicks in on mutation routes
|
// routes. Test a mutation route to confirm 423.
|
||||||
// Test a mutation route to confirm 423
|
|
||||||
const completeRes = await app.fetch(jsonRequest('POST', '/api/setup/complete'));
|
const completeRes = await app.fetch(jsonRequest('POST', '/api/setup/complete'));
|
||||||
expect(completeRes.status).toBe(423);
|
expect(completeRes.status).toBe(423);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -636,7 +636,9 @@ export async function validateSetupDb(): Promise<void> {
|
|||||||
if (res.status === 423) throw new SetupAlreadyLockedError();
|
if (res.status === 423) throw new SetupAlreadyLockedError();
|
||||||
|
|
||||||
if (!res.ok) {
|
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<void> {
|
|||||||
if (res.status === 423) throw new SetupAlreadyLockedError();
|
if (res.status === 423) throw new SetupAlreadyLockedError();
|
||||||
|
|
||||||
if (!res.ok) {
|
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<void> {
|
|||||||
if (res.status === 423) throw new SetupAlreadyLockedError();
|
if (res.status === 423) throw new SetupAlreadyLockedError();
|
||||||
|
|
||||||
if (!res.ok) {
|
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.status === 423) throw new SetupAlreadyLockedError();
|
||||||
|
|
||||||
if (!res.ok) {
|
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.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -28,11 +33,21 @@ function mockFetchResponse(body: unknown, status = 200): Response {
|
|||||||
return {
|
return {
|
||||||
ok: status >= 200 && status < 300,
|
ok: status >= 200 && status < 300,
|
||||||
status,
|
status,
|
||||||
json: async () => body,
|
json: () => Promise.resolve(body),
|
||||||
clone: function () { return this as unknown as Response; },
|
clone: function () {
|
||||||
|
return this as unknown as Response;
|
||||||
|
},
|
||||||
} 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<typeof vi.fn>): Record<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
// Build a canonical camelCase payload using the contract type.
|
// Build a canonical camelCase payload using the contract type.
|
||||||
// If SetupConfigPayload still has snake_case fields, TypeScript will error here
|
// If SetupConfigPayload still has snake_case fields, TypeScript will error here
|
||||||
// (the interface and this test agree on the camelCase contract).
|
// (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));
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||||
await postSetupConfig(VALID_PAYLOAD);
|
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).toHaveProperty('oidcIssuer', 'https://auth.example.com');
|
||||||
expect(body).not.toHaveProperty('oidc_issuer');
|
expect(body).not.toHaveProperty('oidc_issuer');
|
||||||
});
|
});
|
||||||
@@ -70,7 +85,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => {
|
|||||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||||
await postSetupConfig(VALID_PAYLOAD);
|
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).toHaveProperty('oidcClientId', 'familysync');
|
||||||
expect(body).not.toHaveProperty('oidc_client_id');
|
expect(body).not.toHaveProperty('oidc_client_id');
|
||||||
});
|
});
|
||||||
@@ -79,7 +94,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => {
|
|||||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||||
await postSetupConfig(VALID_PAYLOAD);
|
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).toHaveProperty('vapidPublicKey', 'BH_example_public_key');
|
||||||
expect(body).not.toHaveProperty('vapid_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));
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||||
await postSetupConfig(VALID_PAYLOAD);
|
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).toHaveProperty('appExternalUrl', 'https://familysync.example.com');
|
||||||
expect(body).not.toHaveProperty('app_url');
|
expect(body).not.toHaveProperty('app_url');
|
||||||
});
|
});
|
||||||
@@ -97,7 +112,7 @@ describe('postSetupConfig — payload contract (BUG 1 regression)', () => {
|
|||||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||||
await postSetupConfig(VALID_PAYLOAD);
|
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(
|
expect(Object.keys(body).sort()).toEqual(
|
||||||
['appExternalUrl', 'oidcClientId', 'oidcIssuer', 'vapidPublicKey'].sort(),
|
['appExternalUrl', 'oidcClientId', 'oidcIssuer', 'vapidPublicKey'].sort(),
|
||||||
);
|
);
|
||||||
@@ -125,15 +140,17 @@ describe('postSetupConfig — error rendering (BUG 2 regression)', () => {
|
|||||||
error: {
|
error: {
|
||||||
name: 'ZodError',
|
name: 'ZodError',
|
||||||
issues: [
|
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));
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(zodErrorBody, 400));
|
||||||
|
|
||||||
await expect(
|
await expect(postSetupConfig(VALID_PAYLOAD)).rejects.toSatisfy((err: Error) => {
|
||||||
postSetupConfig(VALID_PAYLOAD),
|
|
||||||
).rejects.toSatisfy((err: Error) => {
|
|
||||||
// The error message must NOT be "[object Object]"
|
// The error message must NOT be "[object Object]"
|
||||||
expect(err.message).not.toBe('[object Object]');
|
expect(err.message).not.toBe('[object Object]');
|
||||||
// The error message must be a non-empty string
|
// The error message must be a non-empty string
|
||||||
@@ -149,31 +166,29 @@ describe('postSetupConfig — error rendering (BUG 2 regression)', () => {
|
|||||||
error: {
|
error: {
|
||||||
name: 'ZodError',
|
name: 'ZodError',
|
||||||
issues: [
|
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));
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(zodErrorBody, 400));
|
||||||
|
|
||||||
await expect(
|
await expect(postSetupConfig(VALID_PAYLOAD)).rejects.toThrow('oidcIssuer must be an https URL');
|
||||||
postSetupConfig(VALID_PAYLOAD),
|
|
||||||
).rejects.toThrow('oidcIssuer must be an https URL');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws a fallback status error when error body is empty', async () => {
|
it('throws a fallback status error when error body is empty', async () => {
|
||||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse({}, 400));
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse({}, 400));
|
||||||
|
|
||||||
await expect(
|
await expect(postSetupConfig(VALID_PAYLOAD)).rejects.toThrow('400');
|
||||||
postSetupConfig(VALID_PAYLOAD),
|
|
||||||
).rejects.toThrow('400');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns void on 200 success', async () => {
|
it('returns void on 200 success', async () => {
|
||||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||||
|
|
||||||
await expect(
|
await expect(postSetupConfig(VALID_PAYLOAD)).resolves.toBeUndefined();
|
||||||
postSetupConfig(VALID_PAYLOAD),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,7 +217,7 @@ describe('postSetupCredential — payload contract (CR-01 regression)', () => {
|
|||||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||||
await postSetupCredential(VALID_CREDENTIAL_PAYLOAD);
|
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());
|
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));
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||||
await postSetupCredential(VALID_CREDENTIAL_PAYLOAD);
|
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');
|
expect(body).not.toHaveProperty('providerType');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,7 +233,7 @@ describe('postSetupCredential — payload contract (CR-01 regression)', () => {
|
|||||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||||
await postSetupCredential(VALID_CREDENTIAL_PAYLOAD);
|
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');
|
expect(body).toHaveProperty('fastmailEmail', 'user@fastmail.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,7 +241,7 @@ describe('postSetupCredential — payload contract (CR-01 regression)', () => {
|
|||||||
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
fetchSpy.mockResolvedValueOnce(mockFetchResponse(null, 200));
|
||||||
await postSetupCredential(VALID_CREDENTIAL_PAYLOAD);
|
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');
|
expect(body).toHaveProperty('appPassword', 'secret-app-password');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ function makeQueryClient() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSetupPage(queryClient: QueryClient, props: React.ComponentProps<typeof SetupPage> = {}) {
|
function renderSetupPage(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
props: React.ComponentProps<typeof SetupPage> = {},
|
||||||
|
) {
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -149,7 +152,7 @@ describe('SetupPage — Welcome step', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('has aria-live region for validation status', async () => {
|
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
|
// 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.
|
// 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
|
// 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');
|
const { postSetupConfig } = await import('../api/client.js');
|
||||||
(postSetupConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
(postSetupConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('App URL'), { target: { value: 'https://app.example.com' } });
|
fireEvent.change(screen.getByLabelText('App URL'), {
|
||||||
fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { target: { value: 'https://auth.example.com' } });
|
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('OIDC client ID'), { target: { value: 'familysync' } });
|
||||||
fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } });
|
fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
it('calls validateSetupVapid after DB and OIDC pass in step 2', async () => {
|
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<typeof vi.fn>).mockResolvedValue(undefined);
|
(validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
(validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
(validateSetupVapid as ReturnType<typeof vi.fn>).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 () => {
|
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<typeof vi.fn>).mockResolvedValue(undefined);
|
(validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
(validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
(validateSetupVapid as ReturnType<typeof vi.fn>).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 () => {
|
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<typeof vi.fn>).mockResolvedValue(undefined);
|
(validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
(validateSetupVapid as ReturnType<typeof vi.fn>).mockRejectedValue(
|
(validateSetupVapid as ReturnType<typeof vi.fn>).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();
|
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 () => {
|
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<typeof vi.fn>).mockResolvedValue(undefined);
|
(validateSetupDb as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
(validateSetupOidc as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
(validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
(validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
@@ -360,8 +373,12 @@ describe('SetupPage — Step 2 Instance copy + DB-name field (gaps 1, 3)', () =>
|
|||||||
(validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
(validateSetupVapid as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
|
|
||||||
await advanceToStep2();
|
await advanceToStep2();
|
||||||
fireEvent.change(screen.getByLabelText('App URL'), { target: { value: 'https://app.example.com' } });
|
fireEvent.change(screen.getByLabelText('App URL'), {
|
||||||
fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { target: { value: 'https://auth.example.com' } });
|
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('OIDC client ID'), { target: { value: 'familysync' } });
|
||||||
fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } });
|
fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' }));
|
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' }));
|
fireEvent.click(await screen.findByRole('button', { name: 'Continue' }));
|
||||||
await screen.findByText('Instance Configuration');
|
await screen.findByText('Instance Configuration');
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('App URL'), { target: { value: 'https://app.example.com' } });
|
fireEvent.change(screen.getByLabelText('App URL'), {
|
||||||
fireEvent.change(screen.getByLabelText('OIDC issuer URL'), { target: { value: 'https://auth.example.com' } });
|
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('OIDC client ID'), { target: { value: 'familysync' } });
|
||||||
fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } });
|
fireEvent.change(screen.getByLabelText('VAPID public key'), { target: { value: 'BHtest123' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Save & Validate' }));
|
||||||
|
|||||||
@@ -175,14 +175,8 @@ function StepIndicator({ currentStep }: StepIndicatorProps) {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
background:
|
background: isCompleted || isActive ? 'var(--color-member-0, #4a90d9)' : 'transparent',
|
||||||
isCompleted || isActive
|
border: isCompleted || isActive ? 'none' : '2px solid var(--color-border, #e2e4e9)',
|
||||||
? '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)',
|
color: isCompleted || isActive ? '#ffffff' : 'var(--color-text-muted, #9ca3af)',
|
||||||
fontSize: 'var(--text-label-size, 13px)',
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
@@ -220,11 +214,7 @@ function StepIndicator({ currentStep }: StepIndicatorProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ ...circleStyle, zIndex: 1, position: 'relative' }}>
|
<div style={{ ...circleStyle, zIndex: 1, position: 'relative' }}>
|
||||||
{isCompleted ? (
|
{isCompleted ? <CheckCircle size={16} aria-hidden="true" /> : <span>{stepNum}</span>}
|
||||||
<CheckCircle size={16} aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<span>{stepNum}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
@@ -383,12 +373,10 @@ function Step1Welcome({ onContinue, stepHeadingRef }: Step1Props) {
|
|||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
This wizard will guide you through configuring your self-hosted instance. Before
|
This wizard will guide you through configuring your self-hosted instance. Before continuing,
|
||||||
continuing, run{' '}
|
run <code>npm run generate-secrets</code> from the repo to generate your instance secrets
|
||||||
<code>npm run generate-secrets</code>{' '}
|
and add them to your Docker environment. You'll also need: your OIDC client credentials
|
||||||
from the repo to generate your instance secrets and add them to your Docker environment.
|
(Authelia) and a Fastmail account with an app password. This takes about 5 minutes.
|
||||||
You'll also need: your OIDC client credentials (Authelia) and a Fastmail account
|
|
||||||
with an app password. This takes about 5 minutes.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Before you start note block */}
|
{/* Before you start note block */}
|
||||||
@@ -465,7 +453,9 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S
|
|||||||
});
|
});
|
||||||
const dbName = setupStatus?.dbName ?? '';
|
const dbName = setupStatus?.dbName ?? '';
|
||||||
|
|
||||||
const [validationRows, setValidationRows] = useState<Pick<ValidationRowStatus, 'db' | 'oidc' | 'vapid'>>({
|
const [validationRows, setValidationRows] = useState<
|
||||||
|
Pick<ValidationRowStatus, 'db' | 'oidc' | 'vapid'>
|
||||||
|
>({
|
||||||
db: 'idle',
|
db: 'idle',
|
||||||
oidc: 'idle',
|
oidc: 'idle',
|
||||||
vapid: 'idle',
|
vapid: 'idle',
|
||||||
@@ -518,9 +508,7 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setFieldError(
|
setFieldError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
||||||
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"
|
placeholder="familysync"
|
||||||
style={inputStyle(false)}
|
style={inputStyle(false)}
|
||||||
/>
|
/>
|
||||||
<div style={helperStyle}>
|
<div style={helperStyle}>The client ID registered in Authelia for this application.</div>
|
||||||
The client ID registered in Authelia for this application.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* VAPID Public Key */}
|
{/* VAPID Public Key */}
|
||||||
@@ -673,8 +659,7 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S
|
|||||||
style={inputStyle(false)}
|
style={inputStyle(false)}
|
||||||
/>
|
/>
|
||||||
<div style={helperStyle}>
|
<div style={helperStyle}>
|
||||||
Paste the <code>VAPID_PUBLIC_KEY</code> value from{' '}
|
Paste the <code>VAPID_PUBLIC_KEY</code> value from <code>npm run generate-secrets</code>.
|
||||||
<code>npm run generate-secrets</code>.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -721,19 +706,22 @@ function Step2Config({ onBack, onSuccess, stepHeadingRef, fields, setFields }: S
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* General field error (before validation rows show) */}
|
{/* General field error (before validation rows show) */}
|
||||||
{fieldError && validationRows.db === 'idle' && validationRows.oidc === 'idle' && validationRows.vapid === 'idle' && (
|
{fieldError &&
|
||||||
<div
|
validationRows.db === 'idle' &&
|
||||||
role="status"
|
validationRows.oidc === 'idle' &&
|
||||||
aria-live="polite"
|
validationRows.vapid === 'idle' && (
|
||||||
style={{
|
<div
|
||||||
color: 'var(--color-destructive, #dc2626)',
|
role="status"
|
||||||
fontSize: 'var(--text-body-size, 15px)',
|
aria-live="polite"
|
||||||
marginTop: 'var(--space-3, 12px)',
|
style={{
|
||||||
}}
|
color: 'var(--color-destructive, #dc2626)',
|
||||||
>
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
{fieldError}
|
marginTop: 'var(--space-3, 12px)',
|
||||||
</div>
|
}}
|
||||||
)}
|
>
|
||||||
|
{fieldError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Continue + Back row — appears only when both validations pass */}
|
{/* Continue + Back row — appears only when both validations pass */}
|
||||||
{bothPassed && configSaved && (
|
{bothPassed && configSaved && (
|
||||||
@@ -806,15 +794,12 @@ function Step3Credential({ onBack, onSuccess, onLocked, stepHeadingRef }: Step3P
|
|||||||
onLocked();
|
onLocked();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setErrorText(
|
setErrorText(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
||||||
err instanceof Error ? err.message : 'Something went wrong. Please try again.',
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isPending = completeMutation.isPending || finalMutation.isPending;
|
const isPending = completeMutation.isPending || finalMutation.isPending;
|
||||||
const saveDisabled =
|
const saveDisabled = isPending || email.trim().length === 0 || password.trim().length === 0;
|
||||||
isPending || email.trim().length === 0 || password.trim().length === 0;
|
|
||||||
|
|
||||||
function handleValidate() {
|
function handleValidate() {
|
||||||
if (saveDisabled) return;
|
if (saveDisabled) return;
|
||||||
@@ -853,9 +838,8 @@ function Step3Credential({ onBack, onSuccess, onLocked, stepHeadingRef }: Step3P
|
|||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add the Fastmail app password for the first household member. This credential is
|
Add the Fastmail app password for the first household member. This credential is validated
|
||||||
validated against Fastmail CalDAV before saving. The password is never stored in plain
|
against Fastmail CalDAV before saving. The password is never stored in plain text.
|
||||||
text.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Fastmail email */}
|
{/* Fastmail email */}
|
||||||
@@ -1177,10 +1161,7 @@ export function SetupPage({ alreadyLocked = false }: SetupPageProps) {
|
|||||||
|
|
||||||
{/* Step card (Surface 3) */}
|
{/* Step card (Surface 3) */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<Step1Welcome
|
<Step1Welcome onContinue={() => setStep(2)} stepHeadingRef={stepHeadingRef} />
|
||||||
onContinue={() => setStep(2)}
|
|
||||||
stepHeadingRef={stepHeadingRef}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<Step2Config
|
<Step2Config
|
||||||
|
|||||||
Reference in New Issue
Block a user