+ {/* Surface 2 — Brand Slot (above the login card, in the flow) */} + + + {/* Surface 3 — Login Card */} +
+

+ Sign in +

+ + {/* Surface 4 — Username field */} +
+ + setUsername(e.target.value)} + onKeyDown={handleUsernameKeyDown} + aria-describedby={loginError ? 'login-error' : undefined} + style={inputStyle(inputHasError)} + /> +
+ + {/* Surface 5 — Password field with show/hide toggle */} +
+ +
+ setPassword(e.target.value)} + onKeyDown={handlePasswordKeyDown} + onBlur={() => setShowPassword(false)} + aria-describedby={loginError ? 'login-error' : undefined} + style={{ ...inputStyle(inputHasError), paddingRight: '44px' }} + /> + {/* Show/hide toggle button — 44px tap target (UI-SPEC Surface 5) */} + +
+
+ + {/* Surface 6 — Error / lockout banner */} + {loginError && ( +
+ {loginError === 'invalid' && ( +
+
+ )} + + {loginError === 'rate-limit' && ( +
+
+ )} + + {loginError === 'locked' && ( +
+
+ )} + + {loginError === 'server' && ( +
+
+ )} +
+ )} + + {/* Surface 7 — Primary submit button */} + + + {/* Surface 10 — Forgot password helper (informational only, not interactive) */} +

+ Forgot your password? Ask your admin. +

+
+ + {/* Surfaces 8 & 9 — Method divider + OIDC button (only when oidcEnabled) */} + {oidcEnabled && ( + <> + {/* Surface 8 — Method divider */} + + ); +} -- 2.54.0 From 1f94dc5eb7c274e4504e45e0955a269fdd1e93bc Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 17:21:05 -0400 Subject: [PATCH 37/72] feat(19-05): global-setup local_credentials seed + login.spec.ts + CI harness env - global-setup.ts: TRUNCATE local_credentials + seed devuser/devpass (PHC scrypt inline) - Create login.spec.ts: real-login-form e2e (gate redirect, wrong-password error, correct login) - ci.yml: add LOCAL_SESSION_SECRET dev value + local_credentials seed step in harness job - Fix all test mocks: add devSessionCookieMiddleware no-op to vi.mock(devBypass.js) blocks in admin/setup/push/lists/localAuth/authMode/requireAdmin tests (Rule 1 - Bug: missing export) - Full API suite: 446/446 tests pass; pnpm typecheck: exit 0 --- .gitea/workflows/ci.yml | 49 ++++++++ apps/api/tests/lib/requireAdmin.test.ts | 2 + apps/api/tests/routes/admin.test.ts | 2 + apps/api/tests/routes/authMode.test.ts | 2 + apps/api/tests/routes/lists.test.ts | 2 + apps/api/tests/routes/localAuth.test.ts | 2 + apps/api/tests/routes/push.test.ts | 4 + apps/api/tests/routes/setup.test.ts | 2 + apps/pwa/e2e/global-setup.ts | 23 ++++ apps/pwa/e2e/login.spec.ts | 145 ++++++++++++++++++++++++ 10 files changed, 233 insertions(+) create mode 100644 apps/pwa/e2e/login.spec.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e8548d4..c99841a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -285,6 +285,49 @@ jobs: # CI=true makes Playwright start Vite :5173 itself (reuseExistingServer=false), use # retries:2/workers:1, and apply reporter:'github' — which --reporter=list,html overrides # because Gitea does not render github annotations (Pitfall 5 / D-06). Both projects run. + # Phase 19 (AUTH-LOCAL-16, D-14/D-15): seed local_credentials for dev user (id=1). + # devSessionCookieMiddleware issues a local-session cookie on each /api/* request + # when DEV_AUTH_BYPASS=true and LOCAL_SESSION_SECRET is set, so the PWA login gate + # skips /login and existing specs still reach the authed app unchanged. + # global-setup.ts also seeds this row via hashPasswordInline — this step is a + # belt-and-suspenders seed for the initial CI DB state before Playwright runs. + # The dev password 'devpass' is NOT a secret — it only exists in the ephemeral CI DB. + - name: Seed local_credentials for dev user (id=1) + env: + DB_HOST: mariadb + DB_PORT: 3306 + DB_USER: familysync + DB_PASSWORD: testpass + DB_NAME: familysync + run: | + node --input-type=commonjs - <<'EOF' + const mysql = require('mysql2/promise'); + const crypto = require('crypto'); + // Inline PHC scrypt hash (matches apps/api/src/auth/localCredentials.ts) + function hashPassword(password) { + const salt = crypto.randomBytes(16); + const hash = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }); + return ['scrypt', 16384, 8, 1, salt.toString('base64url'), hash.toString('base64url')].join('$'); + } + (async () => { + const conn = await mysql.createConnection({ + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT ?? 3306), + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + }); + const passwordHash = hashPassword('devpass'); + await conn.execute( + "INSERT INTO local_credentials (user_id, username, password_hash) VALUES (1, 'devuser', ?) ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)", + [passwordHash], + ); + console.log('seeded local_credentials for dev user id=1'); + await conn.end(); + })(); + EOF + working-directory: apps/pwa + - name: Run harness (start API + Playwright iphone + pixel + desktop) env: CI: 'true' @@ -298,6 +341,12 @@ jobs: NODE_OPTIONS: '--dns-result-order=ipv4first' DEV_AUTH_BYPASS: 'true' NODE_ENV: development + # Phase 19 (AUTH-LOCAL-16, D-14/D-15): LOCAL_SESSION_SECRET required for + # devSessionCookieMiddleware to issue real local-session cookies under bypass. + # This is a fixed dev-only value — NEVER a production secret. + # Must be >=32 chars (assertLocalSessionSecretSet boot guard skips in bypass mode, + # but the cookie signing requires a non-empty secret to function). + LOCAL_SESSION_SECRET: 'dev-secret-change-me-0000000000000000' DB_HOST: mariadb DB_PORT: 3306 DB_USER: familysync diff --git a/apps/api/tests/lib/requireAdmin.test.ts b/apps/api/tests/lib/requireAdmin.test.ts index 16cea75..a27cae5 100644 --- a/apps/api/tests/lib/requireAdmin.test.ts +++ b/apps/api/tests/lib/requireAdmin.test.ts @@ -32,6 +32,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({ color: '#4A90D9', }, 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(), COLOR_PALETTE: ['#4A90D9'], })); diff --git a/apps/api/tests/routes/admin.test.ts b/apps/api/tests/routes/admin.test.ts index a71076f..c525fe4 100644 --- a/apps/api/tests/routes/admin.test.ts +++ b/apps/api/tests/routes/admin.test.ts @@ -93,6 +93,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({ c.set('user', { id: currentDevUserId }); await next(); }, + // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests (cookie not needed) + devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); vi.mock('@hono/oidc-auth', () => ({ diff --git a/apps/api/tests/routes/authMode.test.ts b/apps/api/tests/routes/authMode.test.ts index 19fe7fe..57b58a5 100644 --- a/apps/api/tests/routes/authMode.test.ts +++ b/apps/api/tests/routes/authMode.test.ts @@ -49,6 +49,8 @@ vi.mock('@hono/oidc-auth', () => ({ vi.mock('../../src/auth/devBypass.js', () => ({ 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', () => ({ diff --git a/apps/api/tests/routes/lists.test.ts b/apps/api/tests/routes/lists.test.ts index bc04e5f..eff06e9 100644 --- a/apps/api/tests/routes/lists.test.ts +++ b/apps/api/tests/routes/lists.test.ts @@ -35,6 +35,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({ c.set('user', { id: currentDevUserId }); await next(); }, + // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests + devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); // Also mock the oidcAuthMiddleware so the OIDC guard is a no-op in tests. diff --git a/apps/api/tests/routes/localAuth.test.ts b/apps/api/tests/routes/localAuth.test.ts index 9e2a33a..ee4cbb3 100644 --- a/apps/api/tests/routes/localAuth.test.ts +++ b/apps/api/tests/routes/localAuth.test.ts @@ -77,6 +77,8 @@ vi.mock('../../src/auth/localSession.js', () => ({ vi.mock('../../src/auth/devBypass.js', () => ({ 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', () => ({ diff --git a/apps/api/tests/routes/push.test.ts b/apps/api/tests/routes/push.test.ts index e7c2349..8235a81 100644 --- a/apps/api/tests/routes/push.test.ts +++ b/apps/api/tests/routes/push.test.ts @@ -30,6 +30,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({ c.set('user', { id: currentDevUserId }); await next(); }, + // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests + devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); vi.mock('@hono/oidc-auth', () => ({ @@ -111,6 +113,8 @@ describe('POST /api/push/subscription', () => { // and OIDC getAuth returns null — so resolveUserId returns null → 401. vi.doMock('../../src/auth/devBypass.js', () => ({ 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.doMock('../../src/auth/middleware.js', () => ({ getAuth: () => null, diff --git a/apps/api/tests/routes/setup.test.ts b/apps/api/tests/routes/setup.test.ts index 74900c9..2242352 100644 --- a/apps/api/tests/routes/setup.test.ts +++ b/apps/api/tests/routes/setup.test.ts @@ -101,6 +101,8 @@ vi.mock('../../src/auth/devBypass.js', () => ({ // No user injection for setup routes — pre-auth surface await next(); }, + // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests + devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise) => next(), })); // --------------------------------------------------------------------------- diff --git a/apps/pwa/e2e/global-setup.ts b/apps/pwa/e2e/global-setup.ts index 649a97a..035c0cb 100644 --- a/apps/pwa/e2e/global-setup.ts +++ b/apps/pwa/e2e/global-setup.ts @@ -21,6 +21,16 @@ */ import mysql from 'mysql2/promise'; +import { scryptSync, randomBytes } from 'node:crypto'; + +// ── Inline scrypt PHC hashPassword ─────────────────────────────────────────── +// global-setup is plain Node.js (no @playwright/test, cannot import compiled TS). +// Copy of apps/api/src/auth/localCredentials.ts hashPassword (Pitfall 11). +function hashPasswordInline(password: string): string { + const salt = randomBytes(16); + const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }); + return ['scrypt', 16384, 8, 1, salt.toString('base64url'), hash.toString('base64url')].join('$'); +} export default async function globalSetup(): Promise { // ── Step 0: Fail-closed environment guard (CR-01 — data-loss prevention) ───── @@ -107,6 +117,7 @@ export default async function globalSetup(): Promise { await conn.execute('TRUNCATE TABLE list_shares'); await conn.execute('TRUNCATE TABLE lists'); await conn.execute('TRUNCATE TABLE calendar_events'); + await conn.execute('TRUNCATE TABLE local_credentials'); await conn.execute('SET FOREIGN_KEY_CHECKS=1'); // Phase 18: clear any stored household timezone so the timezone spec always @@ -146,6 +157,18 @@ export default async function globalSetup(): Promise { ON DUPLICATE KEY UPDATE fastmail_email='dev@e2e.local'`, ); + // Phase 19 — Option C (AUTH-LOCAL-16): seed local_credentials for the dev user (id=1). + // devSessionCookieMiddleware issues a local-session cookie so the PWA login gate skips + // /login and the existing harness specs still reach the authed app unchanged. + // A dedicated login.spec.ts clears the cookie to test the real login form. + // hashPasswordInline is inlined (Pitfall 11 — plain Node.js, cannot import compiled TS). + await conn.execute( + `INSERT INTO local_credentials (user_id, username, password_hash) + VALUES (1, 'devuser', ?) + ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)`, + [hashPasswordInline('devpass')], + ); + // CI guard (Pitfall 4): ensure calendar row id=10 exists before inserting events. // INSERT IGNORE is a no-op if the row already exists (dev DB), creates it if not (CI fresh DB). await conn.execute( diff --git a/apps/pwa/e2e/login.spec.ts b/apps/pwa/e2e/login.spec.ts new file mode 100644 index 0000000..e5c9d5d --- /dev/null +++ b/apps/pwa/e2e/login.spec.ts @@ -0,0 +1,145 @@ +/** + * login.spec.ts — Phase 19 AUTH-LOCAL-12/15/16 + * + * Real-login-form e2e tests covering the PWA login gate + form interaction. + * + * Strategy (Option C): + * The global-setup seeds 'devuser'/'devpass' into local_credentials and the API's + * devSessionCookieMiddleware issues a local-session cookie on every /api/* request + * under DEV_AUTH_BYPASS=true. The OTHER specs (layout, calendar, lists) rely on that + * cookie being present and do NOT clear it — they still reach the authed app unchanged. + * + * This spec runs in a SEPARATE browser context that clears the local-session cookie + * (via storageState:'' and explicit cookie-clear) so the real login gate fires. After + * verifying the form, it logs in as devuser/devpass to confirm the full round-trip. + * + * Specs covered: + * 1. Navigating to the app while unauthenticated → redirected to /login, brand + form visible + * 2. Wrong password → single "Incorrect username or password." error message + * 3. Correct devuser/devpass → navigates into the app (out of /login) + * + * Only runs on the desktop/chromium project (Chromium handles local-session cookies + * consistently; WebKit PWA restrictions are irrelevant here since the login form is + * a normal web page, not a Home Screen PWA). Other profiles inherit the bypass cookie. + * + * Run: + * 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'; + +// Selectors derived from 19-UI-SPEC.md Surfaces 3-7 (locked by plan 04 implementation) +const SELECTORS = { + usernameInput: '#login-username', + // password input has id="login-password" (UI-SPEC Surface 5) + passwordInput: '#login-password', + // Primary submit: role=button with name "Sign in" (UI-SPEC Surface 7) + submitBtn: 'button[type="submit"]', + // Error message is in a role="status" element (UI-SPEC Surface 6) + 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). +test.describe('Login form — real auth round-trip (desktop/Chromium only)', () => { + test.skip( + ({ browserName }) => browserName !== 'chromium', + 'Login form tests only run on Chromium (desktop profile) — other profiles use the bypass cookie', + ); + + test('unauthenticated navigation → /login gate: brand slot and form visible', async ({ + page, + context, + baseURL, + }) => { + // Start from a clean state — no local-session cookie + await context.clearCookies(); + + // Navigate to the app root; the PWA login gate should redirect to /login + await page.goto(baseURL ?? 'http://localhost:5173', { waitUntil: 'networkidle' }); + + // Assert we are on the /login route + await expect(page).toHaveURL(/\/login/); + + // Brand slot: "FamilySync" text should be visible (UI-SPEC Surface 2) + await expect(page.getByText('FamilySync', { exact: true })).toBeVisible(); + + // Login card heading "Sign in" (UI-SPEC Surface 3) + await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible(); + + // Username field (UI-SPEC Surface 4) + await expect(page.locator(SELECTORS.usernameInput)).toBeVisible(); + + // Password field (UI-SPEC Surface 5) + await expect(page.locator(SELECTORS.passwordInput)).toBeVisible(); + + // Submit button (UI-SPEC Surface 7) + await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); + }); + + test('wrong password shows single "Incorrect username or password." error', async ({ + page, + context, + }) => { + await context.clearCookies(); + + await page.goto('/login', { waitUntil: 'domcontentloaded' }); + + // Fill in wrong credentials + await page.locator(SELECTORS.usernameInput).fill('devuser'); + await page.locator(SELECTORS.passwordInput).fill('wrongpassword'); + await page.getByRole('button', { name: 'Sign in' }).click(); + + // Error message appears (UI-SPEC Surface 6 — "Incorrect username or password.") + const errorEl = page.locator(SELECTORS.errorMessage); + await expect(errorEl).toBeVisible({ timeout: 5_000 }); + await expect(errorEl).toContainText('Incorrect username or password.'); + + // Still on /login + await expect(page).toHaveURL(/\/login/); + }); + + test('correct devuser/devpass logs in and navigates out of /login', async ({ + page, + context, + }) => { + await context.clearCookies(); + + await page.goto('/login', { waitUntil: 'domcontentloaded' }); + + // Fill in the seeded dev credentials (global-setup seeds devuser/devpass) + await page.locator(SELECTORS.usernameInput).fill('devuser'); + await page.locator(SELECTORS.passwordInput).fill('devpass'); + await page.getByRole('button', { name: 'Sign in' }).click(); + + // After login, the page navigates away from /login (to / or /calendar) + await expect(page).not.toHaveURL(/\/login/, { timeout: 10_000 }); + }); +}); -- 2.54.0 From 19c45eb0695bffb973432cb7c998a3fce6665b97 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 17:21:20 -0400 Subject: [PATCH 38/72] feat(19-04): AdminPage LOCAL ACCOUNTS + SettingsSheet change-password / link-OIDC - Add hasLocalCredential to AdminMember type (mirrors API extension from plan 19-02) - Add createMember mutation + Surface 11A inline add-member form in AdminPage - Add Surface 11B Reset-password button in MemberRow (hasLocalCredential gate) - Add ResetPasswordSheet component (bottom-sheet, role=dialog, focus-managed, Escape closes) - Add Surface 12 Change-password row in SettingsSheet (hasLocalCredential gate) - Add Surface 13 Link-OIDC identity row in SettingsSheet (hasLocalCredential + oidcEnabled gate) - Add ChangePasswordSheet component (current/new/confirm fields, change-password mutation) - Add LinkOidcSheet component (confirmation dialog; uses generic OIDC copy per D-06, no provider branding) - Fix: update InstructionSheet.test.tsx to wrap with QueryClientProvider (Rule 1 - now uses useQuery) - Fix: remove stale eslint-disable in App.test.tsx (lint --max-warnings 0 would fail) - All 263 tests pass; typecheck clean; lint clean --- apps/pwa/src/App.test.tsx | 2 +- apps/pwa/src/api/client.ts | 1 + .../src/components/InstructionSheet.test.tsx | 26 +- apps/pwa/src/components/SettingsSheet.tsx | 621 ++++++++++++++++ apps/pwa/src/routes/AdminPage.tsx | 664 +++++++++++++++++- 5 files changed, 1286 insertions(+), 28 deletions(-) diff --git a/apps/pwa/src/App.test.tsx b/apps/pwa/src/App.test.tsx index 56238ca..e375f0d 100644 --- a/apps/pwa/src/App.test.tsx +++ b/apps/pwa/src/App.test.tsx @@ -125,7 +125,7 @@ function renderApp(queryClient: QueryClient) { const mockFetchSetupStatus = fetchSetupStatus as Mock; const mockFetchMe = fetchMe as Mock; -const _mockFetchAuthMode = fetchAuthMode as Mock; // eslint-disable-line @typescript-eslint/no-unused-vars +const _mockFetchAuthMode = fetchAuthMode as Mock; // ── Tests ───────────────────────────────────────────────────────────────────── diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index 329f422..2ead9ba 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -543,6 +543,7 @@ export interface AdminMember { displayName: string | null; color: string; hasCredential: boolean; + hasLocalCredential: boolean; // true when a local_credentials row exists for this member (Phase 19) } export interface AdminMembersResponse { diff --git a/apps/pwa/src/components/InstructionSheet.test.tsx b/apps/pwa/src/components/InstructionSheet.test.tsx index a1c74c0..b3871bf 100644 --- a/apps/pwa/src/components/InstructionSheet.test.tsx +++ b/apps/pwa/src/components/InstructionSheet.test.tsx @@ -7,8 +7,10 @@ * - onClose (the sheet-close prop) is NOT called when the dialog opens */ +import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; // ── Module mocks ────────────────────────────────────────────────────────────── @@ -22,6 +24,17 @@ vi.mock('../hooks/usePushSubscription.js', () => ({ readNotificationsEnabled: vi.fn(() => false), })); +// Phase 19: SettingsSheet now calls fetchMe and fetchAuthMode inside useQuery. +// 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 }, + }), + fetchAuthMode: vi.fn().mockResolvedValue({ localEnabled: true, oidcEnabled: false }), + fetchChangePassword: vi.fn().mockResolvedValue(undefined), + fetchLinkOidc: vi.fn().mockResolvedValue({ redirectUrl: '/oidc' }), +})); + // ── Minimal Notification stub (jsdom lacks it) ──────────────────────────────── beforeEach(() => { @@ -44,12 +57,21 @@ beforeEach(() => { import { SettingsSheet } from './SettingsSheet.js'; +// ── Test helper ─────────────────────────────────────────────────────────────── + +function renderWithQueryClient(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render({ui}); +} + // ── Tests ───────────────────────────────────────────────────────────────────── describe('SettingsSheet — "How to enable" wiring (UAT-05-T4)', () => { it('clicking "How to enable" opens the InstructionSheet dialog and does NOT call onClose', () => { const onCloseSpy = vi.fn(); - render(); + renderWithQueryClient(); // No instruction dialog yet expect(screen.queryByRole('dialog', { name: /re-enable notifications/i })).toBeNull(); @@ -71,7 +93,7 @@ describe('SettingsSheet — "How to enable" wiring (UAT-05-T4)', () => { it('InstructionSheet "Done" button closes the instruction dialog without calling sheet onClose', () => { const onCloseSpy = vi.fn(); - render(); + renderWithQueryClient(); // Open the instruction sheet fireEvent.click(screen.getByText('How to enable')); diff --git a/apps/pwa/src/components/SettingsSheet.tsx b/apps/pwa/src/components/SettingsSheet.tsx index 28bbf18..287af72 100644 --- a/apps/pwa/src/components/SettingsSheet.tsx +++ b/apps/pwa/src/components/SettingsSheet.tsx @@ -23,8 +23,10 @@ import { useEffect, useRef, useState } from 'react'; import { X, Bell, AlertCircle, Loader2 } from 'lucide-react'; +import { useQuery, useMutation } from '@tanstack/react-query'; import { usePushSubscription } from '../hooks/usePushSubscription.js'; import { InstructionSheet } from './InstructionSheet.js'; +import { fetchMe, fetchAuthMode, fetchChangePassword, fetchLinkOidc } from '../api/client.js'; // CR-04: fetch VAPID key (from sessionStorage cache if available) for the // tap-gated subscribe() path. Same logic as PushPermissionPrompt. @@ -53,6 +55,28 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) { const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription(); const [isTogglingOn, setIsTogglingOn] = useState(false); const [instructionsOpen, setInstructionsOpen] = useState(false); + + // Phase 19: read meData (same query key as App.tsx — TanStack deduplicates the request) + const meQuery = useQuery({ + queryKey: ['me'], + queryFn: fetchMe, + retry: false, + staleTime: 0, + }); + const authModeQuery = useQuery({ + queryKey: ['authMode'], + queryFn: fetchAuthMode, + retry: false, + staleTime: 60_000, + }); + + const hasLocalCredential = meQuery.data?.user.hasLocalCredential ?? false; + const oidcEnabled = authModeQuery.data?.oidcEnabled ?? false; + + // Change-password sheet state (Surface 12) + const [changePasswordOpen, setChangePasswordOpen] = useState(false); + // Link-OIDC confirmation sheet state (Surface 13) + const [linkOidcOpen, setLinkOidcOpen] = useState(false); // CR-04: pre-fetch the VAPID key into state so the toggle tap handler can call // subscribe(registration, vapidKey) without any network await before pushManager.subscribe(). const [vapidKey, setVapidKey] = useState(null); @@ -341,6 +365,77 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) { )}