From d40a794695a2e36925a3460b0a39a60a734eb86d Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:37:18 -0400 Subject: [PATCH] refactor(web): auth context + storage module replace Redux (SHOC shape) (#224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): adopt SHOC design system and shell layout (ADR 0003) Port shoc-frontend-new dev's design system with its CSS-variable single-token-source mechanism: - src/styles/theme.css: SHOC token file ported verbatim (Montserrat/ DM Sans/JetBrains Mono, primary #1c75bc, navy #262262, full radius/ shadow/sidebar/header token layers); fonts self-hosted via @fontsource - src/lib/theme/{css-vars,mui-theme}.ts: getCssVar -> createTheme adapter mirroring SHOC's mui-theme.ts (palette, typography, shadows tuple, component overrides; MUI v9 slot renames expressed as class selectors); theme.ts is now a re-export - Shell: SHOC composition (sidebar column + sticky gradient topbar + scrolling main); sidebar 244px/76px collapse with brand header row, grouped nav, SHOC active treatment (white card + 3px accent bar); topbar 100-degree gradient, surface hamburger, gradient avatar pill - Brand: SeahavenMark + BrandLockup ported (Tailwind re-expressed as sx; wordmark subtitle localized to PROPOSAL SYSTEM) - Login: SHOC auth-card treatment (centered 384px card on #f9fafb) - Old "Sea Haven Ops" Inter/#2563EB theme and Nunito remnants removed; remaining hardcoded hexes replaced with tokens; lucide-react for shell/nav icons per SHOC convention Verify: tsc clean, 26/26 vitest, vite build OK; Playwright screenshots pixel-sampled against the extracted SHOC spec (all hard values exact, no blocking deviations). * feat(contracts): adopt shared api-contracts in web, add zod schemas and ProblemDetails codes Closes WEB-M5 (web hand-duplicated wire types, standing drift risk): - shared/api-contracts: rewritten as the authoritative superset of the .NET DTOs (ProposalListItem/ProposalDetail with poNumber and submittedByName, line item requests, customers, pricing library, dashboard, audit, sites, auth, presigned upload, ApiProblem); stale Proposal/UpdateLineItemsRequest shapes removed - shared/api-contracts/src/schemas.ts: zod runtime schemas coupled to every wire type via `satisfies z.ZodType` (schema/type drift is now a compile error); separate entrypoint so type-only consumers (mobile) never pull zod - web: imports @proposal-system/api-contracts (file: dep + tsconfig paths + vite preserveSymlinks); all 7 lib/api modules re-export shared types so page imports stay stable; enum unions tightened (PricingLibraryPage form state now ServiceCategory-typed) - fix(web): customer create/update sent a singular `address` field the API silently dropped (contract is addresses: string[], CustomerDtos.cs) - addresses now round-trip, extra addresses preserved on edit - api: ProblemDetails responses carry a machine-readable top-level `code` (SHOC error-code vocabulary): ValidationFailed, InvalidStateTransition, NotFound, Unauthorized, InternalError; new BusinessRuleException(code, message) maps to 422 with its code; GlobalExceptionHandlerTests cover the full mapping (wire contract) Cross-checked .NET DTOs vs TS types vs zod schemas with the orchestrator scanner (Gemini): core domains consistent; internal-only DTOs (FileDtos vendor/lambda surface, SimilarProposalDtos, UserDtos admin surface) intentionally uncovered. Verify: dotnet 166/166, web tsc + vitest 26/26 + build, mobile tsc, shared tsc all green. * feat(web): domain-layer conventions doc + react-hook-form deps (Phase 4 prep) * feat(web): scaffold domain module layer (proposals, lineItems, customers, pricingLibrary, admin, sites) Additive-only: pages still use lib/api/* and constants/queryKeys.ts until the page-migration agents run. Each domain ships api.ts (HTTP moved from lib/api), types.ts (contract re-exports + view types), schemas.ts (contract schema re-exports + form schemas with toRequest mappers), and use-cases.ts (TanStack Query v5 hooks + hierarchical query keys, mirroring current page invalidations and toast-on-error behavior). Adds an explicit vite/vitest alias for the @proposal-system/api-contracts/schemas subpath (package has no exports map) plus a schema/mapper smoke test suite. * refactor(web): proposal pages on domain layer, proposal form on react-hook-form * refactor(web): admin workspace on domain layer * refactor(web): customer management on domain layer + react-hook-form * refactor(web): pricing library on domain layer + react-hook-form * refactor(web): dashboards on domain layer * test(web): domain use-case hook coverage * refactor(web): finish domain-layer integration — migrate straggler components, delete legacy api modules - SimilarProposalsPanel -> useSimilarProposals (domain/admin); inline SimilarProposal type replaced by domain/admin/types (identical shape); query key joins the admin hierarchical key space - LineItemEditor type imports -> domain/lineItems/types - Delete now-orphaned lib/api/{proposals,lineItems,customers, pricingLibrary,admin,sites}.ts, constants/queryKeys.ts, hooks/usePaginatedList.ts (lib/api/client.ts + auth.ts stay per domain README rule 5) Verify: tsc clean, vitest 52/52, build OK, Playwright smoke of the authed shell renders on domain hooks. * fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup) Correctness: - State-transition mutations now invalidate every cached view via invalidateProposalViews (detail + line items + lists + stats + admin dashboard) — approving no longer leaves a stale queue for the 5-minute staleTime - Presigned S3 PUT moved to proposals/api.ts with res.ok check — a rejected upload is no longer confirmed as uploaded - toCustomerRequest always sends contactEmail ('' clears); API create path normalizes empty->null to match the update path — customer emails can now be cleared from the UI - Shared Number-based numeric form fields (domain/shared/formFields): '12abc' no longer silently coerces to 12 in the pricing library - Customer create/update invalidate customersKeys.all so cached search autocompletes see new customers - AdminWorkspace clears dirty right after a successful implicit save, before approve — no false unsaved-changes prompt when approve fails - ProposalFormPage submit gate and missing-fields caption derive from ONE checks list (missing customer is now listed) - Empty states gated on !err in ProposalListPage/AdminDashboard — no contradictory error + 'no proposals' UI - VendorDataPanel migrated to useVendorProposals (kills the divergent ['vendorProposals', id] cache key and the inline apiClient query) - useCustomerList/usePricingLibraryList get keepPreviousData — no TablePagination out-of-range flash on page change Cleanup: - Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem, useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema, toUpdateLineItemEntry); tests moved to the live save path (useSaveProposalWorkspace) - Shared useDebouncedValue hook replaces 4 drifted inline debounce copies (one leaked its timer on unmount, two hardcoded 300ms); DEBOUNCE_AUTOCOMPLETE=300 named - Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated onError handlers (CLAUDE.md traceability) - shared/api-contracts gains an exports map; /schemas resolver alias deduplicated from 3 copies to the tsconfig paths mapping Verify: tsc clean, vitest 51/51 (tests updated to pin the new invalidation/mapper behavior + new '12abc' rejection test), vite build OK, dotnet 166/166. * refactor(web): fold Redux auth/ui slices into SHOC-shape auth context + storage module Phase 4 tail of the SHOC-alignment plan. Matches SHOC's auth shape (lib/auth storage module + providers/ context split) while keeping the deliberate divergences: - sessionStorage, not localStorage (WEB-C1 stands; SHOC's localStorage is on the SHOULD-NOT-ALIGN list) - token acquisition stays in the auth pages (Cognito code exchange / dev-login) — the provider only owns session state - 401 interceptor clears storage directly (WEB-M2 behavior preserved; full-page redirect resets provider state) Sidebar open state moves to plain layout state in App passed down as props (SHOC (protected)/_layout.tsx pattern), keeping localStorage persistence. Drops @reduxjs/toolkit and react-redux. Tests: authSlice tests replaced by authStorage + AuthProvider suites (QA-C5 coverage preserved); client interceptor tests updated for the storage-based 401 path. 59 vitest green, tsc clean, vite build OK. Verified end-to-end headless: login redirect, seeded-session shell, sidebar toggle persistence, logout, expired/malformed token handling, RoleGuard bounce; recipe persisted as web/.claude/skills/verify. * fix(web): harden auth session teardown per /sh-security-review findings - AUTH-L1 (confirmed medium): logout() now clears the react-query cache — the singleton cache survived SPA logout, serving the previous principal's cached GETs to the next login in the same tab for up to staleTime with no server round-trip. - AUTH-L3 (confirmed low): isTokenValid decodes base64url before atob — valid Cognito JWTs containing '-'/'_' in the payload segment were misclassified as expired (login lockout/loop; inherited from the old authSlice). - AUTH-L2 (unverified, hardened anyway): 401 interceptor broadcasts AUTH_SESSION_CLEARED_EVENT so AuthProvider drops in-memory state synchronously, restoring the old Redux atomic-clear semantics. - INJ-1 (unverified, hardened anyway): Authorization header only set when the stored token is a string. Each fix pinned by a test; 63 vitest green, tsc clean. * docs: web stack row reflects auth-context refactor (Redux removed, MUI v9) --- README.md | 2 +- web/.claude/skills/verify/SKILL.md | 42 ++++++ web/package-lock.json | 98 +------------ web/package.json | 2 - web/src/App.tsx | 16 ++- web/src/app/__tests__/authSlice.test.ts | 114 ---------------- web/src/app/slices/authSlice.ts | 83 ----------- web/src/app/slices/uiSlice.ts | 29 ---- web/src/app/store.ts | 19 --- web/src/components/ProtectedRoute.tsx | 4 +- web/src/components/Sidebar.tsx | 21 +-- web/src/components/Topbar.tsx | 7 +- .../__tests__/ProtectedRoute.test.tsx | 10 +- web/src/hooks/useAuth.ts | 27 ++-- web/src/lib/api/__tests__/client.test.ts | 47 +++---- web/src/lib/api/client.ts | 11 +- .../lib/auth/__tests__/authStorage.test.ts | 87 ++++++++++++ web/src/lib/auth/authStorage.ts | 40 ++++++ web/src/main.tsx | 7 +- web/src/pages/auth/AuthCallback.tsx | 17 ++- web/src/pages/auth/LoginPage.tsx | 9 +- web/src/providers/AuthProvider.tsx | 68 +++++++++ .../providers/__tests__/AuthProvider.test.tsx | 129 ++++++++++++++++++ web/src/providers/authContext.ts | 27 ++++ 24 files changed, 480 insertions(+), 436 deletions(-) create mode 100644 web/.claude/skills/verify/SKILL.md delete mode 100644 web/src/app/__tests__/authSlice.test.ts delete mode 100644 web/src/app/slices/authSlice.ts delete mode 100644 web/src/app/slices/uiSlice.ts delete mode 100644 web/src/app/store.ts create mode 100644 web/src/lib/auth/__tests__/authStorage.test.ts create mode 100644 web/src/lib/auth/authStorage.ts create mode 100644 web/src/providers/AuthProvider.tsx create mode 100644 web/src/providers/__tests__/AuthProvider.test.tsx create mode 100644 web/src/providers/authContext.ts diff --git a/README.md b/README.md index 2c4dde6..8acc442 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ proposal-system/ | Component | Technologies | |---|---| | API | .NET 8, ASP.NET Core, EF Core + Npgsql, FluentValidation, Cognito JWT, Amazon.Lambda.AspNetCoreServer | -| Web | React 19, TypeScript, MUI v9, Vite, Redux Toolkit, TanStack Query, axios | +| Web | React 19, TypeScript, MUI v9, Vite, TanStack Query, react-hook-form + zod, axios | | Mobile | React Native CLI 0.86, React 19, React Native Paper, React Navigation, react-native-app-auth (PKCE), amazon-cognito-identity-js (SRP), Keychain, offline draft queue | | Lambdas | Python 3.12, arm64, pdfplumber, reportlab, httpx, boto3 | | Infrastructure | CDK TypeScript (aws-cdk-lib pinned exact, kept current by Dependabot) | diff --git a/web/.claude/skills/verify/SKILL.md b/web/.claude/skills/verify/SKILL.md new file mode 100644 index 0000000..589ce63 --- /dev/null +++ b/web/.claude/skills/verify/SKILL.md @@ -0,0 +1,42 @@ +--- +name: verify +description: Drive the proposal-system web SPA headlessly to verify changes at the browser surface (no API required for auth/shell flows). +--- + +# Verify web changes (headless, no API) + +Build/launch: + +```bash +cd web && npm run dev # vite on http://localhost:5173 +``` + +Drive with Playwright resolved from the SHOC checkout (this repo doesn't ship it), +using the cached headless chromium: + +```js +const { chromium } = require('/Users/adammoussa/Documents/repositories/seahaven/shoc-frontend-new/node_modules/playwright'); +const browser = await chromium.launch({ + executablePath: '/Users/adammoussa/Library/Caches/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-mac-arm64/chrome-headless-shell', +}); +``` + +Fake a session without the API by seeding sessionStorage with a future-exp JWT +(AuthUser shape: `{ id, email, displayName, role, token }`, roles: SysAdmin/Admin/Dispatcher): + +```js +await ctx.addInitScript((v) => sessionStorage.setItem('proposal_system_token', v), userJson); +``` + +Gotchas: +- `addInitScript` re-runs on every document load — a full-page reload re-seeds the + token, so "logged-out then reload" probes need a fresh un-seeded context. +- Dev-login buttons on /login call the real API (`/auth/dev-login`) — they fail + without the .NET API running; seed storage instead. +- Sidebar drawer width: expanded 244px, collapsed 76px — read + `.MuiDrawer-paper` bounding rect to assert toggles. + +Flows worth driving: unauthenticated redirect to /login, seeded session renders +shell (user chip + role-gated nav groups), sidebar toggle + localStorage +persistence, logout (toast, storage cleared), expired/malformed token treated as +unauthenticated, Dispatcher bounced from /admin by RoleGuard. diff --git a/web/package-lock.json b/web/package-lock.json index 686754c..14003a6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,14 +17,12 @@ "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", "@proposal-system/api-contracts": "file:../shared/api-contracts", - "@reduxjs/toolkit": "^2.11.2", "@tanstack/react-query": "^5.101.2", "axios": "^1.18.1", "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-hook-form": "^7.81.0", - "react-redux": "^9.2.0", "react-router-dom": "^7.18.1", "react-toastify": "^11.0.5", "zod": "^4.4.3" @@ -950,32 +948,6 @@ "resolved": "../shared/api-contracts", "link": true }, - "node_modules/@reduxjs/toolkit": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", - "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", @@ -1244,6 +1216,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, "license": "MIT" }, "node_modules/@standard-schema/utils": { @@ -1452,12 +1425,6 @@ "@types/react": "*" } }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -2586,16 +2553,6 @@ "node": ">= 6" } }, - "node_modules/immer": { - "version": "11.1.11", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz", - "integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3345,29 +3302,6 @@ "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "license": "MIT" }, - "node_modules/react-redux": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", - "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, "node_modules/react-router": { "version": "7.18.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", @@ -3449,21 +3383,6 @@ "node": ">=8" } }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3474,12 +3393,6 @@ "node": ">=0.10.0" } }, - "node_modules/reselect": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", - "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3790,15 +3703,6 @@ "node": ">=20.18.1" } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/vite": { "version": "8.1.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", diff --git a/web/package.json b/web/package.json index f7371f8..0797d84 100644 --- a/web/package.json +++ b/web/package.json @@ -21,14 +21,12 @@ "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", "@proposal-system/api-contracts": "file:../shared/api-contracts", - "@reduxjs/toolkit": "^2.11.2", "@tanstack/react-query": "^5.101.2", "axios": "^1.18.1", "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-hook-form": "^7.81.0", - "react-redux": "^9.2.0", "react-router-dom": "^7.18.1", "react-toastify": "^11.0.5", "zod": "^4.4.3" diff --git a/web/src/App.tsx b/web/src/App.tsx index f247d00..30d0119 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,7 @@ +import { useState } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { Box, Button, Card, CardContent, Typography } from '@mui/material'; +import { STORAGE_KEY_SIDEBAR } from './constants'; import PeopleIcon from '@mui/icons-material/People'; import ProtectedRoute, { RoleGuard } from './components/ProtectedRoute'; import Topbar from './components/Topbar'; @@ -16,6 +18,16 @@ import CustomerManagementPage from './pages/admin/customers/CustomerManagementPa import PricingLibraryPage from './pages/admin/pricing-library/PricingLibraryPage'; export default function App() { + // SHOC shell pattern (mirrors (protected)/_layout.tsx): sidebar open state is + // plain layout state passed down as props, persisted across visits. + const [sidebarOpen, setSidebarOpenState] = useState( + () => window.localStorage.getItem(STORAGE_KEY_SIDEBAR) !== 'false', + ); + const setSidebarOpen = (open: boolean) => { + setSidebarOpenState(open); + window.localStorage.setItem(STORAGE_KEY_SIDEBAR, String(open)); + }; + return ( } /> @@ -27,9 +39,9 @@ export default function App() { {/* SHOC shell composition (ADR 0003): sidebar column + [topbar / scrolling main] */} - + - + setSidebarOpen(!sidebarOpen)} /> = {}; -vi.stubGlobal('localStorage', { - getItem: (key: string) => mockStorage[key] ?? null, - setItem: (key: string, value: string) => { mockStorage[key] = value; }, - removeItem: (key: string) => { delete mockStorage[key]; }, - clear: () => { Object.keys(mockStorage).forEach(k => delete mockStorage[k]); }, -}); - -function createValidToken(): string { - // Create a JWT-like token with exp far in the future - const header = btoa(JSON.stringify({ alg: 'HS256' })); - const payload = btoa(JSON.stringify({ - sub: 'test-user', - exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now - })); - return `${header}.${payload}.fake-signature`; -} - -function createExpiredToken(): string { - const header = btoa(JSON.stringify({ alg: 'HS256' })); - const payload = btoa(JSON.stringify({ - sub: 'test-user', - exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago - })); - return `${header}.${payload}.fake-signature`; -} - -describe('authSlice', () => { - beforeEach(() => { - Object.keys(mockStorage).forEach(k => delete mockStorage[k]); - }); - - it('QA-C5: setUser stores user and marks authenticated with valid token', () => { - const user: AuthUser = { - id: 'user-1', - email: 'admin@test.com', - displayName: 'Admin', - role: 'Admin', - token: createValidToken(), - }; - - const initialState = { user: null, isAuthenticated: false, loading: false, error: null }; - const state = authReducer(initialState, setUser(user)); - - expect(state.user).toEqual(user); - expect(state.isAuthenticated).toBe(true); - expect(state.loading).toBe(false); - expect(state.error).toBeNull(); - }); - - it('QA-C5: setUser with expired token sets isAuthenticated to false', () => { - const user: AuthUser = { - id: 'user-1', - email: 'admin@test.com', - displayName: 'Admin', - role: 'Admin', - token: createExpiredToken(), - }; - - const initialState = { user: null, isAuthenticated: false, loading: false, error: null }; - const state = authReducer(initialState, setUser(user)); - - expect(state.user).toEqual(user); - expect(state.isAuthenticated).toBe(false); - }); - - it('QA-C5: logout clears user and isAuthenticated', () => { - const user: AuthUser = { - id: 'user-1', - email: 'admin@test.com', - displayName: 'Admin', - role: 'Admin', - token: createValidToken(), - }; - - const loggedInState = { user, isAuthenticated: true, loading: false, error: null }; - const state = authReducer(loggedInState, logout()); - - expect(state.user).toBeNull(); - expect(state.isAuthenticated).toBe(false); - expect(state.error).toBeNull(); - }); - - it('QA-C5: selectUser returns user from state', () => { - const user: AuthUser = { - id: 'user-1', - email: 'admin@test.com', - displayName: 'Admin', - role: 'Admin', - token: createValidToken(), - }; - - const state = { auth: { user, isAuthenticated: true, loading: false, error: null } }; - expect(selectUser(state)).toEqual(user); - }); - - it('QA-C5: selectIsAuthenticated returns false when no user', () => { - const state = { auth: { user: null, isAuthenticated: false, loading: false, error: null } }; - expect(selectIsAuthenticated(state)).toBe(false); - }); -}); diff --git a/web/src/app/slices/authSlice.ts b/web/src/app/slices/authSlice.ts deleted file mode 100644 index 2fd6fdf..0000000 --- a/web/src/app/slices/authSlice.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { STORAGE_KEY_TOKEN } from '../../constants'; -import type { AuthUser } from '../../lib/api/auth'; - -interface AuthState { - user: AuthUser | null; - isAuthenticated: boolean; - loading: boolean; - error: string | null; -} - -// Fix: WEB-C1 — use sessionStorage instead of localStorage to limit JWT exposure. -// Tokens are cleared when the browser tab closes, reducing XSS token-theft window. -// Follow-up: move to httpOnly cookies once the API supports Set-Cookie auth flow. -const getUserFromStorage = (): AuthUser | null => { - try { - const result = window.sessionStorage.getItem(STORAGE_KEY_TOKEN); - return result ? JSON.parse(result) : null; - } catch { - return null; - } -}; - -const isTokenValid = (token: string): boolean => { - if (!token) return false; - try { - const part = token.split('.')[1]; - if (!part) return false; - const payload = JSON.parse(atob(part)); - return new Date(payload.exp * 1000) > new Date(); - } catch { - return false; - } -}; - -const storedUser = getUserFromStorage(); - -const initialState: AuthState = { - user: storedUser, - isAuthenticated: storedUser?.token ? isTokenValid(storedUser.token) : false, - loading: false, - error: null, -}; - -const authSlice = createSlice({ - name: 'auth', - initialState, - reducers: { - setUser: (state, action: PayloadAction) => { - state.user = action.payload; - state.isAuthenticated = action.payload.token ? isTokenValid(action.payload.token) : false; - state.loading = false; - state.error = null; - window.sessionStorage.setItem(STORAGE_KEY_TOKEN, JSON.stringify(action.payload)); // WEB-C1 - }, - logout: (state) => { - state.user = null; - state.isAuthenticated = false; - state.error = null; - window.sessionStorage.removeItem(STORAGE_KEY_TOKEN); // WEB-C1 - }, - setLoading: (state, action: PayloadAction) => { - state.loading = action.payload; - }, - setError: (state, action: PayloadAction) => { - state.error = action.payload; - state.loading = false; - }, - clearError: (state) => { - state.error = null; - }, - }, -}); - -export const { setUser, logout, setLoading, setError, clearError } = authSlice.actions; - -export const selectUser = (state: { auth: AuthState }) => state.auth.user; -export const selectIsAuthenticated = (state: { auth: AuthState }) => state.auth.isAuthenticated; -export const selectAuthLoading = (state: { auth: AuthState }) => state.auth.loading; -export const selectAuthError = (state: { auth: AuthState }) => state.auth.error; -export const selectToken = (state: { auth: AuthState }) => state.auth.user?.token; - -export default authSlice.reducer; diff --git a/web/src/app/slices/uiSlice.ts b/web/src/app/slices/uiSlice.ts deleted file mode 100644 index 0585f4d..0000000 --- a/web/src/app/slices/uiSlice.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { STORAGE_KEY_SIDEBAR } from '../../constants'; - -interface UiState { - sidebarOpen: boolean; -} - -const initialState: UiState = { - sidebarOpen: window.localStorage.getItem(STORAGE_KEY_SIDEBAR) !== 'false', -}; - -const uiSlice = createSlice({ - name: 'ui', - initialState, - reducers: { - toggleSidebar: (state) => { - state.sidebarOpen = !state.sidebarOpen; - window.localStorage.setItem(STORAGE_KEY_SIDEBAR, String(state.sidebarOpen)); - }, - setSidebarOpen: (state, action: PayloadAction) => { - state.sidebarOpen = action.payload; - window.localStorage.setItem(STORAGE_KEY_SIDEBAR, String(action.payload)); - }, - }, -}); - -export const { toggleSidebar, setSidebarOpen } = uiSlice.actions; -export const selectSidebarOpen = (state: { ui: UiState }) => state.ui.sidebarOpen; -export default uiSlice.reducer; diff --git a/web/src/app/store.ts b/web/src/app/store.ts deleted file mode 100644 index 3a480cd..0000000 --- a/web/src/app/store.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { configureStore } from '@reduxjs/toolkit'; -import authReducer from './slices/authSlice'; -import uiReducer from './slices/uiSlice'; - -export const store = configureStore({ - reducer: { - auth: authReducer, - ui: uiReducer, - }, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware({ - serializableCheck: { - ignoredActions: ['auth/setUser'], - }, - }), -}); - -export type RootState = ReturnType; -export type AppDispatch = typeof store.dispatch; diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx index 940f732..16656bf 100644 --- a/web/src/components/ProtectedRoute.tsx +++ b/web/src/components/ProtectedRoute.tsx @@ -3,9 +3,9 @@ import { Box, CircularProgress } from '@mui/material'; import { useAuth } from '../hooks/useAuth'; export default function ProtectedRoute({ children }: { children: React.ReactNode }) { - const { isAuthenticated, loading } = useAuth(); + const { isAuthenticated, isLoading } = useAuth(); - if (loading) { + if (isLoading) { return ( diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 191c619..00808f8 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -2,7 +2,6 @@ // Expanded 244px / collapsed 76px; brand header row; grouped nav with SHOC // active treatment (white card bg + shadow + 3px left accent bar). import { useLocation, useNavigate } from 'react-router-dom'; -import { useSelector, useDispatch } from 'react-redux'; import { Drawer, Box, Tooltip, useMediaQuery, useTheme } from '@mui/material'; import { LayoutDashboard, @@ -15,9 +14,7 @@ import { Users, type LucideIcon, } from 'lucide-react'; -import { selectSidebarOpen, setSidebarOpen } from '../app/slices/uiSlice'; -import { selectUser } from '../app/slices/authSlice'; -import type { RootState } from '../app/store'; +import { useAuth } from '../hooks/useAuth'; import { DRAWER_WIDTH } from '../constants'; import BrandLockup, { BrandMark } from './brand/BrandLockup'; @@ -135,13 +132,17 @@ function NavItem({ return row; } -export default function Sidebar() { +export default function Sidebar({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { const navigate = useNavigate(); const location = useLocation(); - const dispatch = useDispatch(); const theme = useTheme(); - const open = useSelector((state: RootState) => selectSidebarOpen(state)); - const user = useSelector((state: RootState) => selectUser(state)); + const { user } = useAuth(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); // <900px @@ -156,7 +157,7 @@ export default function Sidebar() { const handleNavClick = (path: string) => { navigate(path); if (isMobile) { - dispatch(setSidebarOpen(false)); + onOpenChange(false); } }; @@ -189,7 +190,7 @@ export default function Sidebar() { dispatch(setSidebarOpen(false))} + onClose={() => onOpenChange(false)} sx={{ width: isMobile ? 0 : width, flexShrink: 0, diff --git a/web/src/components/Topbar.tsx b/web/src/components/Topbar.tsx index 5ba17f6..3db385b 100644 --- a/web/src/components/Topbar.tsx +++ b/web/src/components/Topbar.tsx @@ -1,12 +1,9 @@ // App shell top bar — SHOC design system port (ADR 0003, mirrors app-topbar.tsx). -import { useDispatch } from 'react-redux'; import { AppBar, Toolbar, IconButton, Box, Avatar, Typography } from '@mui/material'; import { Menu as MenuIcon, LogOut } from 'lucide-react'; -import { toggleSidebar } from '../app/slices/uiSlice'; import { useAuth } from '../hooks/useAuth'; -export default function Topbar() { - const dispatch = useDispatch(); +export default function Topbar({ onToggleSidebar }: { onToggleSidebar: () => void }) { const { user, logout } = useAuth(); const initials = user?.displayName @@ -29,7 +26,7 @@ export default function Topbar() { > dispatch(toggleSidebar())} + onClick={onToggleSidebar} aria-label="Toggle sidebar menu" sx={{ width: 38, diff --git a/web/src/components/__tests__/ProtectedRoute.test.tsx b/web/src/components/__tests__/ProtectedRoute.test.tsx index 16a6924..ef9ab4d 100644 --- a/web/src/components/__tests__/ProtectedRoute.test.tsx +++ b/web/src/components/__tests__/ProtectedRoute.test.tsx @@ -72,7 +72,7 @@ describe('RoleGuard', () => { mockUseAuth.mockReturnValue({ isAuthenticated: true, user: { id: '1', email: 'admin@test.com', displayName: 'Admin', role: 'Admin', token: 'tok' }, - loading: false, + isLoading: false, }); renderWithRouter( @@ -88,7 +88,7 @@ describe('RoleGuard', () => { mockUseAuth.mockReturnValue({ isAuthenticated: true, user: { id: '2', email: 'dispatch@test.com', displayName: 'Dispatcher', role: 'Dispatcher', token: 'tok' }, - loading: false, + isLoading: false, }); renderWithRouter( @@ -105,7 +105,7 @@ describe('RoleGuard', () => { mockUseAuth.mockReturnValue({ isAuthenticated: false, user: null, - loading: false, + isLoading: false, }); renderWithRouter( @@ -122,7 +122,7 @@ describe('RoleGuard', () => { mockUseAuth.mockReturnValue({ isAuthenticated: true, user: { id: '3', email: 'sysadmin@test.com', displayName: 'SysAdmin', role: 'SysAdmin', token: 'tok' }, - loading: false, + isLoading: false, }); renderWithRouter( @@ -138,7 +138,7 @@ describe('RoleGuard', () => { mockUseAuth.mockReturnValue({ isAuthenticated: true, user: { id: '4', email: 'admin@test.com', displayName: 'Admin', role: 'Admin', token: 'tok' }, - loading: false, + isLoading: false, }); renderWithRouter( diff --git a/web/src/hooks/useAuth.ts b/web/src/hooks/useAuth.ts index 81e4148..aecd0e4 100644 --- a/web/src/hooks/useAuth.ts +++ b/web/src/hooks/useAuth.ts @@ -1,26 +1,16 @@ -import { useSelector, useDispatch } from 'react-redux'; import { useNavigate } from 'react-router-dom'; -import { - selectUser, - selectIsAuthenticated, - selectAuthLoading, - selectAuthError, - logout as logoutAction, -} from '../app/slices/authSlice'; import { toast } from 'react-toastify'; -import type { RootState } from '../app/store'; +import { useAuthContext } from '../providers/authContext'; +// Thin wrapper over the auth context (SHOC shape) that layers on the +// router/toast side effects components expect from logout. export function useAuth() { - const dispatch = useDispatch(); + const { user, isAuthenticated, isLoading, error, login, logout: clearSession, setError, setLoading } = + useAuthContext(); const navigate = useNavigate(); - const user = useSelector((state: RootState) => selectUser(state)); - const isAuthenticated = useSelector((state: RootState) => selectIsAuthenticated(state)); - const loading = useSelector((state: RootState) => selectAuthLoading(state)); - const error = useSelector((state: RootState) => selectAuthError(state)); - const logout = () => { - dispatch(logoutAction()); + clearSession(); toast.info('You have been logged out'); navigate('/login'); }; @@ -28,8 +18,11 @@ export function useAuth() { return { user, isAuthenticated, - loading, + isLoading, error, + login, logout, + setError, + setLoading, }; } diff --git a/web/src/lib/api/__tests__/client.test.ts b/web/src/lib/api/__tests__/client.test.ts index aba0721..b2daf07 100644 --- a/web/src/lib/api/__tests__/client.test.ts +++ b/web/src/lib/api/__tests__/client.test.ts @@ -4,7 +4,7 @@ * Tests that: * - Request interceptor attaches Bearer token from sessionStorage * - Request interceptor handles missing/invalid token data gracefully - * - Response interceptor dispatches Redux logout and redirects on 401 + * - Response interceptor clears the stored session and redirects on 401 * - Response interceptor returns friendly error message on 403 * - Response interceptor returns friendly error message on 404 * - Response interceptor extracts server error detail from response body @@ -14,34 +14,14 @@ import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; import type { InternalAxiosRequestConfig } from 'axios'; // vi.hoisted returns values accessible in both the hoisted mock scope and test scope. -const { interceptors, mockDispatch } = vi.hoisted(() => { +const { interceptors } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const state: Record = {}; - const dispatch = vi.fn(); return { interceptors: state, - mockDispatch: dispatch, }; }); -// Mock the Redux store -vi.mock('../../../app/store', () => ({ - store: { - dispatch: (...args: unknown[]) => mockDispatch(...args), - getState: () => ({ auth: { user: null, isAuthenticated: false, loading: false, error: null } }), - subscribe: vi.fn(), - replaceReducer: vi.fn(), - [Symbol.observable]: vi.fn(), - }, -})); - -// Mock the authSlice logout action -vi.mock('../../../app/slices/authSlice', () => ({ - logout: () => ({ type: 'auth/logout' }), - setUser: (user: unknown) => ({ type: 'auth/setUser', payload: user }), - default: (state: unknown) => state, -})); - // Mock axios vi.mock('axios', () => { const mockInstance = { @@ -140,6 +120,18 @@ describe('API client interceptors', () => { expect(result.headers.Authorization).toBeUndefined(); }); + it('does not attach header when the token field is not a string', () => { + sessionStorageData['proposal_system_token'] = JSON.stringify({ token: { a: 1 } }); + + const config = { + headers: {} as Record, + } as unknown as InternalAxiosRequestConfig; + + const result = interceptors.requestFulfilled(config); + + expect(result.headers.Authorization).toBeUndefined(); + }); + it('WEB-C1: does not attach header when token field is missing in parsed data', () => { sessionStorageData['proposal_system_token'] = JSON.stringify({ email: 'user@test.com' }); @@ -161,7 +153,8 @@ describe('API client interceptors', () => { }); describe('response interceptor', () => { - it('WEB-M2: 401 response dispatches Redux logout and redirects to /login', async () => { + it('WEB-M2: 401 response clears the stored session and redirects to /login', async () => { + sessionStorageData['proposal_system_token'] = JSON.stringify({ token: 'stale-jwt' }); const error = { response: { status: 401, @@ -173,11 +166,12 @@ describe('API client interceptors', () => { const promise = interceptors.responseRejected(error); await expect(promise).rejects.toThrow('Session expired. Please log in again.'); - expect(mockDispatch).toHaveBeenCalledWith({ type: 'auth/logout' }); + expect(sessionStorageData['proposal_system_token']).toBeUndefined(); expect(mockLocation.href).toBe('/login'); }); - it('WEB-M2: 403 response returns permission error without dispatching logout', async () => { + it('WEB-M2: 403 response returns permission error without clearing the session', async () => { + sessionStorageData['proposal_system_token'] = JSON.stringify({ token: 'valid-jwt' }); const error = { response: { status: 403, @@ -189,7 +183,8 @@ describe('API client interceptors', () => { const promise = interceptors.responseRejected(error); await expect(promise).rejects.toThrow('You do not have permission to perform this action.'); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(sessionStorageData['proposal_system_token']).toBeDefined(); + expect(mockLocation.href).toBe(''); }); it('404 response returns resource-not-found error', async () => { diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 6bdb6fd..3e5ffcd 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -1,7 +1,6 @@ import axios from 'axios'; import { API_URL, STORAGE_KEY_TOKEN } from '../../constants'; -import { store } from '../../app/store'; -import { logout } from '../../app/slices/authSlice'; +import { AUTH_SESSION_CLEARED_EVENT, clearAuth } from '../auth/authStorage'; const apiClient = axios.create({ baseURL: API_URL, @@ -17,7 +16,7 @@ apiClient.interceptors.request.use( if (tokenData) { try { const parsed = JSON.parse(tokenData); - if (parsed?.token) { + if (typeof parsed?.token === 'string') { config.headers.Authorization = `Bearer ${parsed.token}`; } } catch { @@ -36,8 +35,10 @@ apiClient.interceptors.response.use( const { status, data } = error.response; if (status === 401) { - // Fix: WEB-M2 — dispatch Redux logout to clear auth state in addition to storage - store.dispatch(logout()); + // Fix: WEB-M2 — terminate the session atomically: clear storage, tell the + // AuthProvider to drop in-memory state, then hard-redirect as belt-and-braces. + clearAuth(); + window.dispatchEvent(new Event(AUTH_SESSION_CLEARED_EVENT)); window.location.href = '/login'; return Promise.reject(new Error('Session expired. Please log in again.')); } diff --git a/web/src/lib/auth/__tests__/authStorage.test.ts b/web/src/lib/auth/__tests__/authStorage.test.ts new file mode 100644 index 0000000..cdaa2ca --- /dev/null +++ b/web/src/lib/auth/__tests__/authStorage.test.ts @@ -0,0 +1,87 @@ +/** + * QA-C5: Auth storage module tests (replaces the Redux authSlice tests). + * + * Tests the sessionStorage-backed session persistence (WEB-C1): + * - setAuthUser/getAuthUser round-trip + * - getAuthUser tolerates malformed stored JSON + * - clearAuth removes the stored session + * - isTokenValid accepts unexpired JWTs and rejects expired/malformed ones + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import type { AuthUser } from '@proposal-system/api-contracts'; +import { getAuthUser, setAuthUser, clearAuth, isTokenValid } from '../authStorage'; +import { STORAGE_KEY_TOKEN } from '../../../constants'; + +function createToken(expOffsetSeconds: number): string { + const header = btoa(JSON.stringify({ alg: 'HS256' })); + const payload = btoa(JSON.stringify({ + sub: 'test-user', + exp: Math.floor(Date.now() / 1000) + expOffsetSeconds, + })); + return `${header}.${payload}.fake-signature`; +} + +const user: AuthUser = { + id: 'user-1', + email: 'admin@test.com', + displayName: 'Admin', + role: 'Admin', + token: createToken(3600), +}; + +describe('authStorage', () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + it('QA-C5: setAuthUser persists to sessionStorage and getAuthUser reads it back', () => { + setAuthUser(user); + + expect(window.sessionStorage.getItem(STORAGE_KEY_TOKEN)).not.toBeNull(); + expect(getAuthUser()).toEqual(user); + }); + + it('QA-C5: getAuthUser returns null when nothing is stored', () => { + expect(getAuthUser()).toBeNull(); + }); + + it('QA-C5: getAuthUser returns null for malformed stored JSON', () => { + window.sessionStorage.setItem(STORAGE_KEY_TOKEN, 'not-valid-json{{{'); + + expect(getAuthUser()).toBeNull(); + }); + + it('QA-C5: clearAuth removes the stored session', () => { + setAuthUser(user); + clearAuth(); + + expect(window.sessionStorage.getItem(STORAGE_KEY_TOKEN)).toBeNull(); + expect(getAuthUser()).toBeNull(); + }); + + it('QA-C5: isTokenValid accepts a token expiring in the future', () => { + expect(isTokenValid(createToken(3600))).toBe(true); + }); + + it('QA-C5: isTokenValid rejects an expired token', () => { + expect(isTokenValid(createToken(-3600))).toBe(false); + }); + + it('isTokenValid accepts a base64url payload (JWT segments are base64url, not base64)', () => { + // '>>>' encodes to 'Pj4-' in base64url — the '-' made the old atob() call throw. + const payload = btoa(JSON.stringify({ sub: '>>>>>>', exp: Math.floor(Date.now() / 1000) + 3600 })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + expect(payload).toMatch(/[-_]/); + + expect(isTokenValid(`${btoa(JSON.stringify({ alg: 'HS256' }))}.${payload}.sig`)).toBe(true); + }); + + it('QA-C5: isTokenValid rejects missing or malformed tokens', () => { + expect(isTokenValid(null)).toBe(false); + expect(isTokenValid(undefined)).toBe(false); + expect(isTokenValid('')).toBe(false); + expect(isTokenValid('not-a-jwt')).toBe(false); + }); +}); diff --git a/web/src/lib/auth/authStorage.ts b/web/src/lib/auth/authStorage.ts new file mode 100644 index 0000000..ca0e7ce --- /dev/null +++ b/web/src/lib/auth/authStorage.ts @@ -0,0 +1,40 @@ +// Plain auth-storage module — SHOC shape (mirrors lib/auth/auth-storage.ts). +// Fix: WEB-C1 — use sessionStorage instead of localStorage to limit JWT exposure. +// Tokens are cleared when the browser tab closes, reducing XSS token-theft window. +// Follow-up: move to httpOnly cookies once the API supports Set-Cookie auth flow. +import type { AuthUser } from '@proposal-system/api-contracts'; +import { STORAGE_KEY_TOKEN } from '../../constants'; + +export function getAuthUser(): AuthUser | null { + try { + const raw = window.sessionStorage.getItem(STORAGE_KEY_TOKEN); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +export function setAuthUser(user: AuthUser): void { + window.sessionStorage.setItem(STORAGE_KEY_TOKEN, JSON.stringify(user)); // WEB-C1 +} + +export function clearAuth(): void { + window.sessionStorage.removeItem(STORAGE_KEY_TOKEN); // WEB-C1 +} + +// Dispatched by non-React code (the 401 interceptor) so AuthProvider can drop +// in-memory session state synchronously, keeping storage and context atomic. +export const AUTH_SESSION_CLEARED_EVENT = 'auth:session-cleared'; + +export function isTokenValid(token: string | null | undefined): boolean { + if (!token) return false; + try { + const part = token.split('.')[1]; + if (!part) return false; + // JWT segments are base64url; atob only accepts standard base64. + const payload = JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/'))); + return new Date(payload.exp * 1000) > new Date(); + } catch { + return false; + } +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 0d0ee59..d27f8f8 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,21 +1,20 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; -import { Provider } from 'react-redux'; import { QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider, CssBaseline } from '@mui/material'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import './styles/globals.css'; import './index.css'; -import { store } from './app/store'; +import AuthProvider from './providers/AuthProvider'; import { queryClient } from './lib/queryClient'; import { theme } from './theme'; import App from './App'; ReactDOM.createRoot(document.getElementById('root')!).render( - + @@ -26,6 +25,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + ); diff --git a/web/src/pages/auth/AuthCallback.tsx b/web/src/pages/auth/AuthCallback.tsx index 8556613..563ec57 100644 --- a/web/src/pages/auth/AuthCallback.tsx +++ b/web/src/pages/auth/AuthCallback.tsx @@ -1,13 +1,12 @@ import { useEffect, useRef } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { useDispatch } from 'react-redux'; import { Box, CircularProgress, Typography } from '@mui/material'; -import { setUser, setError, setLoading } from '../../app/slices/authSlice'; +import { useAuth } from '../../hooks/useAuth'; import { authApi } from '../../lib/api/auth'; export default function AuthCallback() { const navigate = useNavigate(); - const dispatch = useDispatch(); + const { login, setError, setLoading } = useAuth(); const [searchParams] = useSearchParams(); const processed = useRef(false); @@ -19,29 +18,29 @@ export default function AuthCallback() { const error = searchParams.get('error'); if (error) { - dispatch(setError(error)); + setError(error); navigate('/login', { replace: true }); return; } if (!code) { - dispatch(setError('No authorization code received')); + setError('No authorization code received'); navigate('/login', { replace: true }); return; } - dispatch(setLoading(true)); + setLoading(true); authApi .exchangeCode(code, `${window.location.origin}/callback`) .then((user) => { - dispatch(setUser(user)); + login(user); navigate('/', { replace: true }); }) .catch((err) => { - dispatch(setError(err.message || 'Authentication failed')); + setError(err.message || 'Authentication failed'); navigate('/login', { replace: true }); }); - }, [searchParams, dispatch, navigate]); + }, [searchParams, login, setError, setLoading, navigate]); return ( (() => getAuthUser()); + const [isLoading, setIsLoading] = useState(false); + const [error, setErrorState] = useState(null); + + const login = useCallback((nextUser: AuthUser) => { + setAuthUser(nextUser); + setUser(nextUser); + setErrorState(null); + setIsLoading(false); + }, []); + + const logout = useCallback(() => { + clearAuth(); + // Drop all principal-scoped state: cached queries must not survive into the + // next login in the same tab, or query keys shared across users would serve + // the previous principal's data without hitting the server. + queryClient.clear(); + setUser(null); + setErrorState(null); + }, []); + + // The 401 interceptor clears storage outside React; mirror it here so context + // state never outlives the stored session while the redirect commits. + useEffect(() => { + const onSessionCleared = () => setUser(null); + window.addEventListener(AUTH_SESSION_CLEARED_EVENT, onSessionCleared); + return () => window.removeEventListener(AUTH_SESSION_CLEARED_EVENT, onSessionCleared); + }, []); + + const setError = useCallback((message: string | null) => { + setErrorState(message); + setIsLoading(false); + }, []); + + const value = useMemo( + () => ({ + user, + isAuthenticated: isTokenValid(user?.token), + isLoading, + error, + login, + logout, + setError, + setLoading: setIsLoading, + }), + [user, isLoading, error, login, logout, setError], + ); + + return {children}; +} diff --git a/web/src/providers/__tests__/AuthProvider.test.tsx b/web/src/providers/__tests__/AuthProvider.test.tsx new file mode 100644 index 0000000..7271802 --- /dev/null +++ b/web/src/providers/__tests__/AuthProvider.test.tsx @@ -0,0 +1,129 @@ +/** + * QA-C5: AuthProvider tests (replaces the Redux authSlice tests). + * + * Tests the context state management: + * - login stores the user and marks authenticated + * - login with an expired token leaves isAuthenticated false + * - logout clears the user, the error, and the stored session + * - state initializes from an existing stored session + * - setError records the message and ends loading + */ +import { act, renderHook } from '@testing-library/react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import type { AuthUser } from '@proposal-system/api-contracts'; +import AuthProvider from '../AuthProvider'; +import { useAuthContext } from '../authContext'; +import { AUTH_SESSION_CLEARED_EVENT, setAuthUser } from '../../lib/auth/authStorage'; +import { queryClient } from '../../lib/queryClient'; +import { STORAGE_KEY_TOKEN } from '../../constants'; + +function createToken(expOffsetSeconds: number): string { + const header = btoa(JSON.stringify({ alg: 'HS256' })); + const payload = btoa(JSON.stringify({ + sub: 'test-user', + exp: Math.floor(Date.now() / 1000) + expOffsetSeconds, + })); + return `${header}.${payload}.fake-signature`; +} + +function createUser(expOffsetSeconds = 3600): AuthUser { + return { + id: 'user-1', + email: 'admin@test.com', + displayName: 'Admin', + role: 'Admin', + token: createToken(expOffsetSeconds), + }; +} + +function renderAuth() { + return renderHook(() => useAuthContext(), { wrapper: AuthProvider }); +} + +describe('AuthProvider', () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + it('QA-C5: login stores the user and marks authenticated with a valid token', () => { + const user = createUser(); + const { result } = renderAuth(); + + act(() => result.current.login(user)); + + expect(result.current.user).toEqual(user); + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.error).toBeNull(); + expect(window.sessionStorage.getItem(STORAGE_KEY_TOKEN)).not.toBeNull(); + }); + + it('QA-C5: login with an expired token leaves isAuthenticated false', () => { + const user = createUser(-3600); + const { result } = renderAuth(); + + act(() => result.current.login(user)); + + expect(result.current.user).toEqual(user); + expect(result.current.isAuthenticated).toBe(false); + }); + + it('QA-C5: logout clears the user, the error, and the stored session', () => { + const { result } = renderAuth(); + + act(() => result.current.login(createUser())); + act(() => result.current.logout()); + + expect(result.current.user).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.error).toBeNull(); + expect(window.sessionStorage.getItem(STORAGE_KEY_TOKEN)).toBeNull(); + }); + + it('logout clears the react-query cache so the next principal cannot read cached data', () => { + queryClient.setQueryData(['proposals', 'list'], [{ id: 'p1' }]); + const { result } = renderAuth(); + + act(() => result.current.login(createUser())); + act(() => result.current.logout()); + + expect(queryClient.getQueryData(['proposals', 'list'])).toBeUndefined(); + }); + + it('drops in-memory session state when the 401 interceptor broadcasts session-cleared', () => { + const { result } = renderAuth(); + + act(() => result.current.login(createUser())); + act(() => { + window.dispatchEvent(new Event(AUTH_SESSION_CLEARED_EVENT)); + }); + + expect(result.current.user).toBeNull(); + expect(result.current.isAuthenticated).toBe(false); + }); + + it('QA-C5: initializes from an existing stored session', () => { + const user = createUser(); + setAuthUser(user); + + const { result } = renderAuth(); + + expect(result.current.user).toEqual(user); + expect(result.current.isAuthenticated).toBe(true); + }); + + it('QA-C5: setError records the message and ends loading', () => { + const { result } = renderAuth(); + + act(() => result.current.setLoading(true)); + act(() => result.current.setError('Authentication failed')); + + expect(result.current.error).toBe('Authentication failed'); + expect(result.current.isLoading).toBe(false); + }); + + it('QA-C5: useAuthContext throws outside AuthProvider', () => { + expect(() => renderHook(() => useAuthContext())).toThrow( + 'useAuthContext must be used within AuthProvider', + ); + }); +}); diff --git a/web/src/providers/authContext.ts b/web/src/providers/authContext.ts new file mode 100644 index 0000000..60d2a6c --- /dev/null +++ b/web/src/providers/authContext.ts @@ -0,0 +1,27 @@ +// Auth context — SHOC shape (mirrors providers/auth-context.ts). +// Context/provider split keeps this file component-free for fast refresh. +import { createContext, useContext } from 'react'; +import type { AuthUser } from '@proposal-system/api-contracts'; + +export interface AuthContextValue { + user: AuthUser | null; + isAuthenticated: boolean; + isLoading: boolean; + error: string | null; + /** Store a resolved session (Cognito code exchange or dev-login) and mark it active. */ + login: (user: AuthUser) => void; + /** Clear the stored session. Navigation/toast live in useAuth. */ + logout: () => void; + setError: (message: string | null) => void; + setLoading: (loading: boolean) => void; +} + +export const AuthContext = createContext(null); + +export function useAuthContext(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuthContext must be used within AuthProvider'); + } + return context; +}