From b6490feff47c8167e9ce0728cefc46903c50fd93 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 23:05:15 -0400 Subject: [PATCH] fix(19): satisfy CI fast-checks + secret scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lint (eslint --max-warnings 0): - index.ts: disable no-unsafe-argument on the type-only Context mismatch when delegating to the OIDC handler inside the local-session skip wrapper - localAuth.ts: handleLogout is sync (no await) — drop async (require-await) - devBypass.ts: disable detect-possible-timing-attacks on the public well-known dev-placeholder string compare (not a secret comparison) - remove dead code / unused bindings flagged by no-unused-vars: makeTestApp (localSession.test), makeUnauthContext + BrowserContext import (login.spec), unused memberId (admin.test), unused txSelectCount counter (me.test) - localAuthMiddleware.test / me.test: fix unused + reflow-detached eslint-disable directives Format: prettier --write across the 20 Phase-19 files that were never formatted. Secret scan (gitleaks): allowlist two false positives — the synthetic >=32-char TEST_SECRET in localSession.test.ts, and .planning/ design prose (a generic-api-key regex hit on "credential atomically, 409-equivalent"). Neither is a real secret. Verified locally: format:check, lint, typecheck, md:lint, gitleaks (no leaks), PWA 266/266, API 452/452. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitleaks.toml | 8 + apps/api/scripts/reset-admin.ts | 15 +- apps/api/src/auth/devBypass.ts | 3 + apps/api/src/index.ts | 8 +- apps/api/src/routes/admin.ts | 122 ++++++++-------- apps/api/src/routes/localAuth.ts | 19 ++- apps/api/src/routes/me.ts | 94 ++++++------ apps/api/test/setup.ts | 8 +- .../tests/auth/localAuthMiddleware.test.ts | 9 +- apps/api/tests/auth/localSession.test.ts | 32 +--- apps/api/tests/routes/admin.test.ts | 15 +- apps/api/tests/routes/authMode.test.ts | 6 +- apps/api/tests/routes/localAuth.test.ts | 48 +++--- apps/api/tests/routes/me.test.ts | 137 +++++++++++------- apps/pwa/e2e/login.spec.ts | 37 +---- apps/pwa/src/App.tsx | 10 +- apps/pwa/src/api/client.ts | 5 +- .../src/components/InstructionSheet.test.tsx | 9 +- apps/pwa/src/components/SettingsSheet.tsx | 9 +- apps/pwa/src/routes/AdminPage.tsx | 3 +- apps/pwa/src/routes/LoginPage.tsx | 5 +- apps/pwa/src/styles/tokens.css | 10 +- 22 files changed, 318 insertions(+), 294 deletions(-) diff --git a/.gitleaks.toml b/.gitleaks.toml index 045bce1..b882dc1 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -26,3 +26,11 @@ paths = ['''apps/api/tests/broker/crypto\.test\.ts'''] [[allowlists]] description = "apps/api/tests/routes/setup.test.ts — synthetic VAPID public/private test pair used to set process.env.VAPID_* in the setup-route tests; not a real credential (verified not present in .env)" paths = ['''apps/api/tests/routes/setup\.test\.ts'''] + +[[allowlists]] +description = "apps/api/tests/auth/localSession.test.ts — TEST_SECRET is a synthetic >=32-char JWT signing secret used only to exercise issue/verify cookie round-trips under Vitest; not a real credential (Phase 19)" +paths = ['''apps/api/tests/auth/localSession\.test\.ts'''] + +[[allowlists]] +description = ".planning/ design docs are internal planning prose (PLAN/SUMMARY/SECURITY/etc.) that frequently discuss credentials, tokens, and auth — they trip generic regex rules (e.g. 'credential atomically, 409-equivalent') but never carry production secrets; not shipped in any image" +paths = ['''\.planning/'''] diff --git a/apps/api/scripts/reset-admin.ts b/apps/api/scripts/reset-admin.ts index 3bda451..6081a05 100644 --- a/apps/api/scripts/reset-admin.ts +++ b/apps/api/scripts/reset-admin.ts @@ -45,9 +45,14 @@ const KEY_LEN = 32; function hashPassword(password: string): string { const salt = randomBytes(16); const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }); - return ['scrypt', SCRYPT_N, SCRYPT_R, SCRYPT_P, salt.toString('base64url'), hash.toString('base64url')].join( - '$', - ); + return [ + 'scrypt', + SCRYPT_N, + SCRYPT_R, + SCRYPT_P, + salt.toString('base64url'), + hash.toString('base64url'), + ].join('$'); } // ── CLI arg parsing (no new deps — process.argv only) ─────────────────────────────────── @@ -109,7 +114,9 @@ if (!dryRun && (!password || password.trim() === '')) { } if (dryRun && !password) { // In dry-run mode a placeholder password is acceptable — skip real validation - console.log('[dry-run] Args validated: --username present, --dry-run active (no write will occur)'); + console.log( + '[dry-run] Args validated: --username present, --dry-run active (no write will occur)', + ); } // ── DB connection ───────────────────────────────────────────────────────────────────────── diff --git a/apps/api/src/auth/devBypass.ts b/apps/api/src/auth/devBypass.ts index 2f2671b..3e08989 100644 --- a/apps/api/src/auth/devBypass.ts +++ b/apps/api/src/auth/devBypass.ts @@ -153,6 +153,9 @@ export function devSessionCookieMiddleware(): MiddlewareHandler { // BL-01: warn loudly if the secret is the well-known dev placeholder. A genuine, // signature-valid session token minted under this known value is trivially forgeable // if the same secret ever leaks into a non-bypass environment. + // Not a security comparison — this matches against a PUBLIC well-known placeholder to + // emit a warning, so constant-time equality is irrelevant here. + // eslint-disable-next-line security/detect-possible-timing-attacks if (secret === 'dev-secret-change-me-0000000000000000') { console.warn( '[devSessionCookieMiddleware] LOCAL_SESSION_SECRET is the well-known dev placeholder. ' + diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d1b2891..599cd34 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -128,7 +128,10 @@ app.get('/callback', async (c) => { return c.redirect('/?error=oidc-link-conflict'); } // Unexpected error during link binding — log and continue with normal redirect. - console.error('[callback] linkOidcToUser unexpected error:', err instanceof Error ? err.message : String(err)); + console.error( + '[callback] linkOidcToUser unexpected error:', + err instanceof Error ? err.message : String(err), + ); } } @@ -193,6 +196,9 @@ if (!devBypassActive) { await next(); return; } + // oidcHandler's parameter is typed as the generic Hono Context; our wrapper's c is the + // same runtime Context narrowed to '/api/*' — the structural mismatch is type-only. + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument await oidcHandler(c, next); }); // Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02). diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index aa2c183..3f62d77 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -140,77 +140,73 @@ const createMemberSchema = z.object({ initialPassword: z.string().min(8), }); -adminRouter.post( - '/members', - zValidator('json', createMemberSchema, noEchoHook), - async (c) => { - const { displayName, username, initialPassword } = c.req.valid('json'); - // T-19-06: NEVER log request body, displayName, username, or initialPassword here +adminRouter.post('/members', zValidator('json', createMemberSchema, noEchoHook), async (c) => { + const { displayName, username, initialPassword } = c.req.valid('json'); + // T-19-06: NEVER log request body, displayName, username, or initialPassword here - // Assign the first palette color not already in use (mirrors upsertUser color logic) - const usedRows = await db.select({ color: users.color }).from(users); - const usedColors = new Set(usedRows.map((r) => r.color)); - const color = - COLOR_PALETTE.find((c) => !usedColors.has(c)) ?? - COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length]; + // Assign the first palette color not already in use (mirrors upsertUser color logic) + const usedRows = await db.select({ color: users.color }).from(users); + const usedColors = new Set(usedRows.map((r) => r.color)); + const color = + COLOR_PALETTE.find((c) => !usedColors.has(c)) ?? + COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length]; - // WR-03: hash the initial password BEFORE opening the transaction so the (now async, - // threadpool) scrypt work does not hold the DB transaction open for its duration. - const initialPasswordHash = await hashPassword(initialPassword); + // WR-03: hash the initial password BEFORE opening the transaction so the (now async, + // threadpool) scrypt work does not hold the DB transaction open for its duration. + const initialPasswordHash = await hashPassword(initialPassword); - try { - let newUserId: number; + try { + let newUserId: number; - // T-19-10: atomic transaction — both inserts succeed or both roll back - await db.transaction(async (tx) => { - // Insert the new users row (no oidcIss/oidcSub — local-only member) - const [inserted] = await tx - .insert(users) - .values({ - displayName, - color, - isAdmin: false, - claimed: false, // no OIDC identity bound yet - }) - .$returningId(); - newUserId = inserted.id; + // T-19-10: atomic transaction — both inserts succeed or both roll back + await db.transaction(async (tx) => { + // Insert the new users row (no oidcIss/oidcSub — local-only member) + const [inserted] = await tx + .insert(users) + .values({ + displayName, + color, + isAdmin: false, + claimed: false, // no OIDC identity bound yet + }) + .$returningId(); + newUserId = inserted.id; - // Insert local_credentials row with hashed initial password - // If username is already in use, the UNIQUE constraint fires here and rolls back - await tx.insert(localCredentials).values({ - userId: newUserId, - username, - passwordHash: initialPasswordHash, - }); + // Insert local_credentials row with hashed initial password + // If username is already in use, the UNIQUE constraint fires here and rolls back + await tx.insert(localCredentials).values({ + userId: newUserId, + username, + passwordHash: initialPasswordHash, }); + }); - // Return the new user's id (the PWA uses it to navigate to the member) - return c.json({ id: newUserId! }, 201); - } catch (err) { - // Username uniqueness violation — UNIQUE constraint on local_credentials.username. - // Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY. - const isDup = - (err instanceof Error && err.message.includes('ER_DUP_ENTRY')) || - (err != null && - typeof err === 'object' && - 'code' in err && - (err as { code?: string }).code === 'ER_DUP_ENTRY') || - (err != null && - typeof err === 'object' && - 'cause' in err && - (err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY'); - if (isDup) { - return c.json({ error: 'Username already in use' }, 409); - } - // Unexpected errors — log message only, never the body or password - console.error( - '[admin/POST /members] Unexpected error:', - err instanceof Error ? err.message : String(err), - ); - return c.json({ error: 'Service unavailable' }, 503); + // Return the new user's id (the PWA uses it to navigate to the member) + return c.json({ id: newUserId! }, 201); + } catch (err) { + // Username uniqueness violation — UNIQUE constraint on local_credentials.username. + // Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY. + const isDup = + (err instanceof Error && err.message.includes('ER_DUP_ENTRY')) || + (err != null && + typeof err === 'object' && + 'code' in err && + (err as { code?: string }).code === 'ER_DUP_ENTRY') || + (err != null && + typeof err === 'object' && + 'cause' in err && + (err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY'); + if (isDup) { + return c.json({ error: 'Username already in use' }, 409); } - }, -); + // Unexpected errors — log message only, never the body or password + console.error( + '[admin/POST /members] Unexpected error:', + err instanceof Error ? err.message : String(err), + ); + return c.json({ error: 'Service unavailable' }, 503); + } +}); // --------------------------------------------------------------------------- // POST /api/admin/members/:id/password diff --git a/apps/api/src/routes/localAuth.ts b/apps/api/src/routes/localAuth.ts index 68bd47e..e2bc1d5 100644 --- a/apps/api/src/routes/localAuth.ts +++ b/apps/api/src/routes/localAuth.ts @@ -186,7 +186,10 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook) .limit(1); cred = found; } catch (err) { - console.error('[localAuth/POST /local/login] DB error:', err instanceof Error ? err.message : String(err)); + console.error( + '[localAuth/POST /local/login] DB error:', + err instanceof Error ? err.message : String(err), + ); return c.json({ error: 'Service unavailable' }, 503); } @@ -200,7 +203,12 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook) if (!valid || !cred) { // Increment failure counter (keyed on username) - const cur = loginAttempts.get(key) ?? { count: 0, lockedUntil: 0, lockedOut: false, lockedAt: 0 }; + const cur = loginAttempts.get(key) ?? { + count: 0, + lockedUntil: 0, + lockedOut: false, + lockedAt: 0, + }; cur.count += 1; cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000; cur.lockedOut = cur.count >= LOCKOUT_FAILURES; @@ -215,7 +223,10 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook) try { await issueLocalSessionCookie(c, cred.userId); } catch (err) { - console.error('[localAuth/POST /local/login] Cookie issue error:', err instanceof Error ? err.message : String(err)); + console.error( + '[localAuth/POST /local/login] Cookie issue error:', + err instanceof Error ? err.message : String(err), + ); return c.json({ error: 'Service unavailable' }, 503); } return c.json({ ok: true }, 200); @@ -226,7 +237,7 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook) // GET /local/logout → GET /api/auth/local/logout (browser-redirect alias) // --------------------------------------------------------------------------- -async function handleLogout(c: Context) { +function handleLogout(c: Context) { clearLocalSessionCookie(c); return c.json({ ok: true }, 200); } diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 3cf8961..09928d3 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -105,7 +105,9 @@ meRouter.get('/', async (c) => { // but isAdmin, needsProviderSetup, and hasLocalCredential are still resolved from the DB (T-10-05). const devUser = c.get('user'); if (devUser) { - const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(devUser.id); + const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus( + devUser.id, + ); return c.json({ user: { id: devUser.id, @@ -141,7 +143,9 @@ meRouter.get('/', async (c) => { return c.json({ error: 'Could not resolve user' }, 500); } - const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(user.id); + const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus( + user.id, + ); return c.json({ user: { @@ -232,57 +236,53 @@ const mePasswordSchema = z.object({ newPassword: z.string().min(8), }); -meRouter.post( - '/password', - zValidator('json', mePasswordSchema, meNoEchoHook), - async (c) => { - // T-19-07: ALWAYS resolve userId from session — never from body - const currentUserId = await resolveUserId(c); - if (!currentUserId) { - return c.json({ error: 'Unauthorized' }, 401); - } +meRouter.post('/password', zValidator('json', mePasswordSchema, meNoEchoHook), async (c) => { + // T-19-07: ALWAYS resolve userId from session — never from body + const currentUserId = await resolveUserId(c); + if (!currentUserId) { + return c.json({ error: 'Unauthorized' }, 401); + } - const { currentPassword, newPassword } = c.req.valid('json'); - // T-19-06: NEVER log currentPassword, newPassword, or the request body + const { currentPassword, newPassword } = c.req.valid('json'); + // T-19-06: NEVER log currentPassword, newPassword, or the request body - // Look up the user's local_credentials row (404 if none — no local credential to change) - const [credRow] = await db - .select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId }) - .from(localCredentials) - .where(eq(localCredentials.userId, currentUserId)) - .limit(1); + // Look up the user's local_credentials row (404 if none — no local credential to change) + const [credRow] = await db + .select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId }) + .from(localCredentials) + .where(eq(localCredentials.userId, currentUserId)) + .limit(1); - if (!credRow) { - return c.json({ error: 'No local credential found' }, 404); - } + if (!credRow) { + return c.json({ error: 'No local credential found' }, 404); + } - // T-19-07: verify current password before any update (WR-03: async scrypt) - const isCorrect = await verifyPassword(credRow.passwordHash, currentPassword); - if (!isCorrect) { - // CR-03: return 403 (NOT 401) for a wrong current password. The PWA's global - // MutationCache treats any 401 as "session expired" and arms the re-auth - // interstitial / login redirect — so a 401 here would force-log-out a user who - // merely mistyped their current password. 403 is in-app authorization-failure and - // lets the client surface "current password incorrect" without dropping the session. - return c.json({ error: 'Current password incorrect' }, 403); - } + // T-19-07: verify current password before any update (WR-03: async scrypt) + const isCorrect = await verifyPassword(credRow.passwordHash, currentPassword); + if (!isCorrect) { + // CR-03: return 403 (NOT 401) for a wrong current password. The PWA's global + // MutationCache treats any 401 as "session expired" and arms the re-auth + // interstitial / login redirect — so a 401 here would force-log-out a user who + // merely mistyped their current password. 403 is in-app authorization-failure and + // lets the client surface "current password incorrect" without dropping the session. + return c.json({ error: 'Current password incorrect' }, 403); + } - try { - await db - .update(localCredentials) - .set({ passwordHash: await hashPassword(newPassword) }) - .where(eq(localCredentials.userId, currentUserId)); + try { + await db + .update(localCredentials) + .set({ passwordHash: await hashPassword(newPassword) }) + .where(eq(localCredentials.userId, currentUserId)); - return c.json({ ok: true }, 200); - } catch (err) { - console.error( - '[me/POST /password] Unexpected error:', - err instanceof Error ? err.message : String(err), - ); - return c.json({ error: 'Service unavailable' }, 503); - } - }, -); + return c.json({ ok: true }, 200); + } catch (err) { + console.error( + '[me/POST /password] Unexpected error:', + err instanceof Error ? err.message : String(err), + ); + return c.json({ error: 'Service unavailable' }, 503); + } +}); // --------------------------------------------------------------------------- // POST /api/me/link-oidc — initiate OIDC identity-link flow (AUTH-LOCAL-10, T-19-09) diff --git a/apps/api/test/setup.ts b/apps/api/test/setup.ts index 82ead50..3af377a 100644 --- a/apps/api/test/setup.ts +++ b/apps/api/test/setup.ts @@ -24,7 +24,13 @@ import { afterEach } from 'vitest'; import { db } from '../src/db/client.js'; -import { lists, listItems, listShares, pushSubscriptions, localCredentials } from '../src/db/schema.js'; +import { + lists, + listItems, + listShares, + pushSubscriptions, + localCredentials, +} from '../src/db/schema.js'; /** * Truncate list and push tables in FK-safe order after each test. diff --git a/apps/api/tests/auth/localAuthMiddleware.test.ts b/apps/api/tests/auth/localAuthMiddleware.test.ts index 6bba8ab..8217c94 100644 --- a/apps/api/tests/auth/localAuthMiddleware.test.ts +++ b/apps/api/tests/auth/localAuthMiddleware.test.ts @@ -73,7 +73,6 @@ function makeApp(middleware: ReturnType, presetUser?: unknown) { }); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-call app.use('/api/*', middleware()); let capturedUser: unknown = 'NOT_SET_SENTINEL'; @@ -147,7 +146,13 @@ describe('localAuthMiddleware', () => { const res = await app.request('/api/test'); expect(res.status).toBe(200); expect(capturedUser).toBeDefined(); - const u = capturedUser as { id: number; oidcIss: string; oidcSub: string; displayName: string | null; color: string }; + const u = capturedUser as { + id: number; + oidcIss: string; + oidcSub: string; + displayName: string | null; + color: string; + }; expect(u.id).toBe(7); expect(u.oidcIss).toBe('https://auth.example.com'); expect(u.oidcSub).toBe('sub-abc'); diff --git a/apps/api/tests/auth/localSession.test.ts b/apps/api/tests/auth/localSession.test.ts index 2d86a4d..55c8042 100644 --- a/apps/api/tests/auth/localSession.test.ts +++ b/apps/api/tests/auth/localSession.test.ts @@ -18,33 +18,6 @@ import { Hono } from 'hono'; const TEST_SECRET = 'test-secret-that-is-at-least-32-characters-long-for-jwt'; const TEST_USER_ID = 42; -// ── Helpers ──────────────────────────────────────────────────────────────────── - -/** Create a minimal Hono test app with an issue route and a verify route. */ -function makeTestApp(secret: string | undefined) { - return { - setup: async () => { - // Import inside function to pick up modified env - const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import( - '../../src/auth/localSession.js' - ); - const app = new Hono(); - - app.post('/issue', async (c) => { - await issueLocalSessionCookie(c, TEST_USER_ID); - return c.json({ ok: true }); - }); - - app.get('/verify', async (c) => { - const userId = await verifyLocalSessionCookie(c); - return c.json({ userId }); - }); - - return app; - }, - }; -} - describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => { let originalEnv: NodeJS.ProcessEnv; @@ -60,9 +33,8 @@ describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => { }); it('Test 1: issue then verify round-trips userId', async () => { - const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import( - '../../src/auth/localSession.js' - ); + const { issueLocalSessionCookie, verifyLocalSessionCookie } = + await import('../../src/auth/localSession.js'); const app = new Hono(); app.post('/issue', async (c) => { diff --git a/apps/api/tests/routes/admin.test.ts b/apps/api/tests/routes/admin.test.ts index 0a12737..9c4bfd9 100644 --- a/apps/api/tests/routes/admin.test.ts +++ b/apps/api/tests/routes/admin.test.ts @@ -30,7 +30,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { db } from '../../src/db/client.js'; -import { users, memberCredentials, calendars, appConfig, localCredentials } from '../../src/db/schema.js'; +import { + users, + memberCredentials, + calendars, + appConfig, + localCredentials, +} from '../../src/db/schema.js'; import { verifyPassword } from '../../src/auth/localCredentials.js'; // --------------------------------------------------------------------------- @@ -949,7 +955,8 @@ describe('POST /api/admin/members', () => { it('Test 3: admin can reset any member password without knowing the current one', async () => { const adminId = await seedUser('admin-reset-pw', true); - const memberId = await seedUser('member-reset-target', false); + // Seeded for DB-state parity; this test creates its own member via the admin API below. + await seedUser('member-reset-target', false); currentDevUserId = adminId; const app = await getApp(); @@ -1025,7 +1032,9 @@ describe('POST /api/admin/members', () => { // GET /members should show hasLocalCredential:true for this member const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members')); expect(getRes.status).toBe(200); - const body = (await getRes.json()) as { members: Array<{ id: number; hasLocalCredential: boolean }> }; + const body = (await getRes.json()) as { + members: Array<{ id: number; hasLocalCredential: boolean }>; + }; const memberRow = body.members.find((m) => m.id === newMemberId); expect(memberRow).toBeDefined(); diff --git a/apps/api/tests/routes/authMode.test.ts b/apps/api/tests/routes/authMode.test.ts index 57b58a5..b8d29fb 100644 --- a/apps/api/tests/routes/authMode.test.ts +++ b/apps/api/tests/routes/authMode.test.ts @@ -47,15 +47,13 @@ vi.mock('@hono/oidc-auth', () => ({ })); vi.mock('../../src/auth/devBypass.js', () => ({ - devAuthBypass: - () => async (_c: unknown, next: () => Promise) => next(), + devAuthBypass: () => async (_c: unknown, next: () => Promise) => next(), // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); vi.mock('../../src/auth/localAuthMiddleware.js', () => ({ - localAuthMiddleware: - () => async (_c: unknown, next: () => Promise) => next(), + localAuthMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); // --------------------------------------------------------------------------- diff --git a/apps/api/tests/routes/localAuth.test.ts b/apps/api/tests/routes/localAuth.test.ts index 6f511d3..c97929c 100644 --- a/apps/api/tests/routes/localAuth.test.ts +++ b/apps/api/tests/routes/localAuth.test.ts @@ -34,9 +34,9 @@ vi.mock('../../src/db/client.js', () => ({ select: vi.fn().mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ - limit: vi.fn().mockImplementation(() => - Promise.resolve(mockCredRow ? [mockCredRow] : []) - ), + limit: vi + .fn() + .mockImplementation(() => Promise.resolve(mockCredRow ? [mockCredRow] : [])), }), }), })), @@ -57,14 +57,12 @@ let issuedUserId: number | null = null; let clearSessionCalled = false; vi.mock('../../src/auth/localSession.js', () => ({ - issueLocalSessionCookie: vi.fn().mockImplementation( - (_c: unknown, userId: number) => { - issueSessionCalled = true; - issuedUserId = userId; - // Simulate setting a cookie on the context - return Promise.resolve(); - } - ), + issueLocalSessionCookie: vi.fn().mockImplementation((_c: unknown, userId: number) => { + issueSessionCalled = true; + issuedUserId = userId; + // Simulate setting a cookie on the context + return Promise.resolve(); + }), clearLocalSessionCookie: vi.fn().mockImplementation((_c: unknown) => { clearSessionCalled = true; }), @@ -76,15 +74,13 @@ vi.mock('../../src/auth/localSession.js', () => ({ // --------------------------------------------------------------------------- vi.mock('../../src/auth/devBypass.js', () => ({ - devAuthBypass: - () => async (_c: unknown, next: () => Promise) => next(), + devAuthBypass: () => async (_c: unknown, next: () => Promise) => next(), // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); vi.mock('../../src/auth/localAuthMiddleware.js', () => ({ - localAuthMiddleware: - () => async (_c: unknown, next: () => Promise) => next(), + localAuthMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); vi.mock('@hono/oidc-auth', () => ({ @@ -195,7 +191,9 @@ describe('POST /api/auth/local/login', () => { mockCredRow = undefined; // No credential row found const app = await getApp(); - const res = await app.fetch(makeLoginRequest({ username: 'unknown-user', password: 'anypassword' })); + const res = await app.fetch( + makeLoginRequest({ username: 'unknown-user', password: 'anypassword' }), + ); expect(res.status).toBe(401); const body = (await res.json()) as { error: string }; @@ -212,14 +210,14 @@ describe('POST /api/auth/local/login', () => { // 5 failures to trigger the rate window for (let i = 0; i < 5; i++) { const res = await app.fetch( - makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1') + makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1'), ); expect(res.status).toBe(401); } // 6th attempt from same IP → 429 const res6 = await app.fetch( - makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1') + makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1'), ); expect(res6.status).toBe(429); const body = (await res6.json()) as { error: string }; @@ -233,14 +231,12 @@ describe('POST /api/auth/local/login', () => { // 10 failures from same IP → lockout for (let i = 0; i < 10; i++) { - await app.fetch( - makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2') - ); + await app.fetch(makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')); } // 11th attempt → 423 (locked) const res11 = await app.fetch( - makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2') + makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'), ); expect(res11.status).toBe(423); const body = (await res11.json()) as { error: string }; @@ -252,7 +248,7 @@ describe('POST /api/auth/local/login', () => { loginAttempts.delete('alice'); const resAfterReset = await app.fetch( - makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2') + makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'), ); expect(resAfterReset.status).toBe(401); }); @@ -295,7 +291,7 @@ describe('POST /api/auth/local/login', () => { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '1.2.3.4' }, body: JSON.stringify({ username: 'mysecretusername' }), - }) + }), ); expect(res.status).toBe(400); @@ -319,7 +315,7 @@ describe('POST /api/auth/local/logout', () => { new Request('http://localhost/api/auth/local/logout', { method: 'POST', headers: { 'x-forwarded-for': '1.2.3.4' }, - }) + }), ); expect(res.status).toBe(200); @@ -336,7 +332,7 @@ describe('GET /api/auth/local/logout', () => { new Request('http://localhost/api/auth/local/logout', { method: 'GET', headers: { 'x-forwarded-for': '1.2.3.4' }, - }) + }), ); expect(res.status).toBe(200); diff --git a/apps/api/tests/routes/me.test.ts b/apps/api/tests/routes/me.test.ts index 6f50480..1a3ccbd 100644 --- a/apps/api/tests/routes/me.test.ts +++ b/apps/api/tests/routes/me.test.ts @@ -290,16 +290,20 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () => where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]), }), - innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), + }), }), - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; } // fallback for other selects return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), - innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), + }), }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; @@ -346,7 +350,9 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () => where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]), }), - innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), + }), }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; @@ -354,7 +360,9 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () => return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), - innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), + }), }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; @@ -386,13 +394,18 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () => it('Test 3: user with no local_credentials row → 404', async () => { const { db } = await import('../../src/db/client.js'); - vi.mocked(db.select).mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), - innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), - }), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any)); + vi.mocked(db.select).mockImplementation( + () => + ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), + }), + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); const { app } = await import('../../src/index.js'); const res = await app.request('/api/me/password', { @@ -457,7 +470,9 @@ describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => { return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }), - innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), + }), }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; @@ -483,7 +498,9 @@ describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => { return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }), - innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), + }), }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; @@ -520,14 +537,14 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => { let deletedLocalCreds = false; // Mock: SELECT users WHERE oidc_iss = iss AND oidc_sub = sub → no conflict (empty) - let txSelectCount = 0; const mockTx = { select: vi.fn().mockImplementation(() => { - txSelectCount++; return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // no conflict - innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), + }), }), }; }), @@ -545,18 +562,25 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => { })), }; - vi.mocked(db.select).mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict - innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), - }), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any)); + vi.mocked(db.select).mockImplementation( + () => + ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), + }), + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise) => { - await fn(mockTx); - }); + vi.mocked(db).transaction = vi + .fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation(async (fn: (tx: any) => Promise) => { + await fn(mockTx); + }); await linkOidcToUser(42, iss, sub); @@ -574,15 +598,20 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => { let deletedLocalCreds = false; // Preflight SELECT finds a conflicting user (id=99, different from target=42) - vi.mocked(db.select).mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict! - }), - innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), - }), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any)); + vi.mocked(db.select).mockImplementation( + () => + ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict! + }), + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), + }), + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); // Transaction should NEVER be called on conflict const mockTx = { @@ -593,10 +622,12 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => { }), })), }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise) => { - await fn(mockTx); - }); + vi.mocked(db).transaction = vi + .fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockImplementation(async (fn: (tx: any) => Promise) => { + await fn(mockTx); + }); // Should throw OidcLinkConflictError, not proceed to transaction await expect(linkOidcToUser(42, iss, sub)).rejects.toThrow(OidcLinkConflictError); @@ -611,13 +642,20 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => { const { db } = await import('../../src/db/client.js'); // Mock db — not needed for route shape test but avoids errors - vi.mocked(db.select).mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }), - innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), - }), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any)); + vi.mocked(db.select).mockImplementation( + () => + ({ + from: vi.fn().mockReturnValue({ + where: vi + .fn() + .mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }), + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }), + }), + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); const { app } = await import('../../src/index.js'); const res = await app.request('/api/me/link-oidc', { @@ -630,7 +668,8 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => { const body = (await res.json()) as { authorizationUrl?: string; state?: string }; // The response must have at minimum a signedState field (or authorizationUrl) // — the exact shape depends on implementation; assert it's an object with a useful field - const hasInitiationPayload = 'authorizationUrl' in body || 'state' in body || 'signedState' in body; + const hasInitiationPayload = + 'authorizationUrl' in body || 'state' in body || 'signedState' in body; expect(hasInitiationPayload).toBe(true); }); }); diff --git a/apps/pwa/e2e/login.spec.ts b/apps/pwa/e2e/login.spec.ts index f5836aa..44a65f5 100644 --- a/apps/pwa/e2e/login.spec.ts +++ b/apps/pwa/e2e/login.spec.ts @@ -30,7 +30,7 @@ * pnpm --filter @familysync/pwa test:e2e --grep "login" * pnpm --filter @familysync/pwa exec playwright test --project=desktop login.spec.ts */ -import { test, expect, type BrowserContext } from '@playwright/test'; +import { test, expect } from '@playwright/test'; // Selectors derived from 19-UI-SPEC.md Surfaces 3-7 (locked by plan 04 implementation) const SELECTORS = { @@ -43,32 +43,6 @@ const SELECTORS = { errorMessage: '[role="status"]', }; -/** - * Build an unauthenticated browser context by clearing all cookies and storage. - * The devSessionCookieMiddleware issues a new local-session cookie on each API - * request, so we need to clear the cookie from the BROWSER side. Navigating to - * a page that clears the cookie header is the reliable approach in Playwright. - */ -async function makeUnauthContext( - context: BrowserContext, - baseURL: string, -): Promise { - // Clear all cookies (removes the local-session cookie set by prior API calls) - await context.clearCookies(); - // Also clear localStorage/sessionStorage to avoid any cached auth state - const page = await context.newPage(); - try { - // Navigate somewhere to gain origin access, then clear storage - await page.goto(baseURL, { waitUntil: 'domcontentloaded', timeout: 10_000 }).catch(() => {}); - await page.evaluate(() => { - try { localStorage.clear(); } catch { /* cross-origin or unavailable */ } - try { sessionStorage.clear(); } catch { /* cross-origin or unavailable */ } - }); - } finally { - await page.close(); - } -} - // Only run these specs on the desktop profile. The login form is a standard web // page (not PWA-specific) and Chromium handles cookies most consistently for this test. // iphone/pixel still reach the authed app via the bypass-issued cookie (unchanged behavior). @@ -78,9 +52,7 @@ test.describe('Login form — real auth round-trip (desktop/Chromium only)', () 'Login form tests only run on Chromium (desktop profile) — other profiles use the bypass cookie', ); - test('/login renders all brand + form surfaces (UI-SPEC Surfaces 2-7)', async ({ - page, - }) => { + test('/login renders all brand + form surfaces (UI-SPEC Surfaces 2-7)', async ({ page }) => { // Navigate DIRECTLY to /login rather than asserting an unauthenticated root→/login // redirect: under the always-on DEV_AUTH_BYPASS, /api/me is authed via DEV_USER // injection regardless of the cookie, so visiting / lands on /calendar and a @@ -129,10 +101,7 @@ test.describe('Login form — real auth round-trip (desktop/Chromium only)', () await expect(page).toHaveURL(/\/login/); }); - test('correct devuser/devpass logs in and navigates out of /login', async ({ - page, - context, - }) => { + test('correct devuser/devpass logs in and navigates out of /login', async ({ page, context }) => { await context.clearCookies(); await page.goto('/login', { waitUntil: 'domcontentloaded' }); diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index bfa9ddf..84feadf 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -195,10 +195,7 @@ export default function App() { Phase 19: shown when the user is unauthenticated AND localEnabled === true. The route itself always renders LoginPage (authMode gating is in the `*` route gate below). LoginPage receives authMode so it can show the optional OIDC button when oidcEnabled. */} - } - /> + } /> {/* All other routes are gated on setup completion */} - ) : meQuery.isError && !meQuery.isLoading && !authModeQuery.data?.localEnabled && authModeQuery.data?.oidcEnabled ? ( + ) : meQuery.isError && + !meQuery.isLoading && + !authModeQuery.data?.localEnabled && + authModeQuery.data?.oidcEnabled ? ( // Unauthenticated + OIDC-only mode: top-level redirect to /api/login (today's behavior) // Use a render side-effect via useEffect isn't available here; use a helper element diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index 7bd52a8..426007f 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -103,10 +103,7 @@ export async function fetchAuthMode(): Promise<{ localEnabled: boolean; oidcEnab * * Throws nothing on 200 OK — the local-session cookie is set by the server. */ -export async function fetchLocalLogin(body: { - username: string; - password: string; -}): Promise { +export async function fetchLocalLogin(body: { username: string; password: string }): Promise { const res = await fetch('/api/auth/local/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/apps/pwa/src/components/InstructionSheet.test.tsx b/apps/pwa/src/components/InstructionSheet.test.tsx index b3871bf..1688a34 100644 --- a/apps/pwa/src/components/InstructionSheet.test.tsx +++ b/apps/pwa/src/components/InstructionSheet.test.tsx @@ -28,7 +28,14 @@ vi.mock('../hooks/usePushSubscription.js', () => ({ // Mock client so the test doesn't make real network calls. vi.mock('../api/client.js', () => ({ fetchMe: vi.fn().mockResolvedValue({ - user: { id: 1, displayName: 'Test', color: '#4a90d9', isAdmin: false, needsProviderSetup: false, hasLocalCredential: false }, + user: { + id: 1, + displayName: 'Test', + color: '#4a90d9', + isAdmin: false, + needsProviderSetup: false, + hasLocalCredential: false, + }, }), fetchAuthMode: vi.fn().mockResolvedValue({ localEnabled: true, oidcEnabled: false }), fetchChangePassword: vi.fn().mockResolvedValue(undefined), diff --git a/apps/pwa/src/components/SettingsSheet.tsx b/apps/pwa/src/components/SettingsSheet.tsx index 59f5b1a..a17acc5 100644 --- a/apps/pwa/src/components/SettingsSheet.tsx +++ b/apps/pwa/src/components/SettingsSheet.tsx @@ -500,10 +500,7 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) { {/* Surface 13 — Link OIDC confirmation sheet (hasLocalCredential + oidcEnabled gate) */} {linkOidcOpen && ( - setLinkOidcOpen(false)} - /> + setLinkOidcOpen(false)} /> )} ); @@ -935,7 +932,9 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) { color: 'var(--color-text-secondary, #6b7280)', }} > - {"After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed."} + { + "After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed." + }

{/* Secondary note */} diff --git a/apps/pwa/src/routes/AdminPage.tsx b/apps/pwa/src/routes/AdminPage.tsx index b0f518a..7d4f823 100644 --- a/apps/pwa/src/routes/AdminPage.tsx +++ b/apps/pwa/src/routes/AdminPage.tsx @@ -1241,8 +1241,7 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps }); const isPending = resetMutation.isPending; - const submitDisabled = - isPending || newPassword.length === 0 || confirmPassword.length === 0; + const submitDisabled = isPending || newPassword.length === 0 || confirmPassword.length === 0; if (!isOpen) return null; diff --git a/apps/pwa/src/routes/LoginPage.tsx b/apps/pwa/src/routes/LoginPage.tsx index 921178e..fd580bf 100644 --- a/apps/pwa/src/routes/LoginPage.tsx +++ b/apps/pwa/src/routes/LoginPage.tsx @@ -146,10 +146,7 @@ export function LoginPage({ authMode }: LoginPageProps) { const isLoading = loginMutation.isPending; const bothNonEmpty = username.trim().length > 0 && password.length > 0; const submitDisabled = - isLoading || - !bothNonEmpty || - loginError === 'rate-limit' || - loginError === 'locked'; + isLoading || !bothNonEmpty || loginError === 'rate-limit' || loginError === 'locked'; // Derive whether inputs should show error state const inputHasError = loginError === 'invalid'; diff --git a/apps/pwa/src/styles/tokens.css b/apps/pwa/src/styles/tokens.css index 88fa3bf..ea5d94e 100644 --- a/apps/pwa/src/styles/tokens.css +++ b/apps/pwa/src/styles/tokens.css @@ -94,11 +94,11 @@ * never the BrandSlot component structure (see 19-UI-SPEC.md §Brand Slot). * ───────────────────────────────────────────────────────────────────────── */ - --brand-logo-bg: var(--color-member-0); /* placeholder circle background */ - --brand-logo-text: #ffffff; /* placeholder initials color */ - --brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */ - --brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */ - --brand-app-name: 'FamilySync'; /* drives doc only — not used as CSS content */ + --brand-logo-bg: var(--color-member-0); /* placeholder circle background */ + --brand-logo-text: #ffffff; /* placeholder initials color */ + --brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */ + --brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */ + --brand-app-name: 'FamilySync'; /* drives doc only — not used as CSS content */ /* ───────────────────────────────────────────────────────────────────────── * BREAKPOINTS (reference; use in @media queries)