From 19c45eb0695bffb973432cb7c998a3fce6665b97 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 17:21:20 -0400 Subject: [PATCH] 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) { )} + {/* Surface 12 — Change password row (hasLocalCredential gate) */} + {hasLocalCredential && ( + <> +
+
+ Account +
+ + + {/* Surface 13 — Link OIDC identity row (hasLocalCredential + oidcEnabled gate) */} + {oidcEnabled && ( + + )} + + )} + {/* Permission-denied hint — only when OS permission === 'denied' */} {permission === 'denied' && (
{instructionsOpen && setInstructionsOpen(false)} />} + + {/* Surface 12 — Change-password sheet (hasLocalCredential gate) */} + {changePasswordOpen && ( + setChangePasswordOpen(false)} + /> + )} + + {/* Surface 13 — Link OIDC confirmation sheet (hasLocalCredential + oidcEnabled gate) */} + {linkOidcOpen && ( + setLinkOidcOpen(false)} + /> + )} + + ); +} + +// ── ChangePasswordSheet (Surface 12) ───────────────────────────────────────── + +/** + * Surface 12 — Self-service password change sheet. + * Opens from the "Change password" row in SettingsSheet. + * Pattern: CredentialSheet (role=dialog, aria-modal, Escape closes, focus heading on open). + * Fields: Current password / New password / Confirm — correct autoComplete values. + * Security: T-19-18 — password fields are controlled state only; never written to storage. + */ + +interface ChangePasswordSheetProps { + isOpen: boolean; + onClose: () => void; +} + +function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) { + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(null); + const headingRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') handleClose(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (isOpen && headingRef.current) { + headingRef.current.focus(); + } + }, [isOpen]); + + function handleClose() { + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setError(null); + onClose(); + } + + const changeMutation = useMutation({ + mutationFn: async () => { + if (newPassword !== confirmPassword) throw new Error('mismatch'); + await fetchChangePassword({ currentPassword, newPassword }); + }, + onSuccess: () => { + handleClose(); + }, + onError: (err) => { + const msg = err instanceof Error ? err.message : 'server'; + if (msg === 'mismatch') { + setError('Passwords do not match.'); + } else if (msg === 'wrong-current') { + setError('Current password is incorrect.'); + } else { + setError('Something went wrong. Please try again.'); + } + }, + }); + + const isPending = changeMutation.isPending; + const submitDisabled = + isPending || + currentPassword.length === 0 || + newPassword.length === 0 || + confirmPassword.length === 0; + + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} + {/* Credential sheet — admin-rotate or admin-add */} @@ -633,6 +925,21 @@ export function AdminPage() { triggerRef={triggerRef} /> )} + + {/* Surface 11B — Reset password sheet */} + {resetTargetMember && ( + { + setResetSheetOpen(false); + // Return focus to trigger + if (resetTriggerRef.current) { + resetTriggerRef.current.focus(); + } + }} + member={resetTargetMember} + /> + )}
); } @@ -643,10 +950,12 @@ interface MemberRowProps { member: AdminMember; colorIndex: number; onAction: (buttonRef: React.RefObject) => void; + onResetPassword?: (buttonRef: React.RefObject) => void; } -function MemberRow({ member, colorIndex, onAction }: MemberRowProps) { +function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowProps) { const buttonRef = useRef(null); + const resetBtnRef = useRef(null); return (
- {/* Action button */} - + {/* Action button row */} +
+ {/* Credential rotate/add button */} + + + {/* Surface 11B — Reset password button (only for members with a local credential) */} + {member.hasLocalCredential && onResetPassword && ( + + )} +
); } @@ -842,6 +1177,285 @@ function CalendarRadioRow({ calendar, isSelected, onSelect }: CalendarRadioRowPr ); } +// ── ResetPasswordSheet ────────────────────────────────────────────────────── + +/** + * Surface 11B — Admin password reset sheet. + * Opens as a bottom sheet (mobile) / centered modal (desktop). + * Pattern: CredentialSheet (role=dialog, aria-modal, Escape closes, focus returns to trigger). + * No current-password field — admin reset does not require knowing the old password. + */ + +interface ResetPasswordSheetProps { + isOpen: boolean; + onClose: () => void; + member: AdminMember; +} + +function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps) { + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(null); + const headingRef = useRef(null); + + // Escape closes the sheet + useEffect(() => { + if (!isOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [isOpen, onClose]); + + // Focus heading on open + useEffect(() => { + if (isOpen && headingRef.current) { + headingRef.current.focus(); + } + }, [isOpen]); + + function handleClose() { + setNewPassword(''); + setConfirmPassword(''); + setError(null); + onClose(); + } + + const resetMutation = useMutation({ + mutationFn: async () => { + if (newPassword !== confirmPassword) throw new Error('mismatch'); + await fetchAdminResetPassword(member.id, newPassword); + }, + onSuccess: () => { + handleClose(); + }, + onError: (err) => { + const msg = err instanceof Error ? err.message : 'server'; + if (msg === 'mismatch') { + setError('Passwords do not match.'); + } else { + setError('Something went wrong. Please try again.'); + } + }, + }); + + const isPending = resetMutation.isPending; + const submitDisabled = + isPending || newPassword.length === 0 || confirmPassword.length === 0; + + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} +