mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 05:23:14 +00:00
refactor(web): auth context + storage module replace Redux (SHOC shape) (#224)
* 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<T>` (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)
This commit is contained in:
parent
efcb13838e
commit
d40a794695
24 changed files with 480 additions and 436 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
42
web/.claude/skills/verify/SKILL.md
Normal file
42
web/.claude/skills/verify/SKILL.md
Normal file
|
|
@ -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.
|
||||
98
web/package-lock.json
generated
98
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
|
@ -27,9 +39,9 @@ export default function App() {
|
|||
<ProtectedRoute>
|
||||
{/* SHOC shell composition (ADR 0003): sidebar column + [topbar / scrolling main] */}
|
||||
<Box sx={{ display: 'flex', height: '100vh', width: '100%', overflow: 'hidden', backgroundColor: 'var(--background)' }}>
|
||||
<Sidebar />
|
||||
<Sidebar open={sidebarOpen} onOpenChange={setSidebarOpen} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, height: '100vh', overflow: 'hidden' }}>
|
||||
<Topbar />
|
||||
<Topbar onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} />
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
/**
|
||||
* QA-C5: Auth slice tests.
|
||||
*
|
||||
* Tests the Redux auth state management:
|
||||
* - setUser stores user and marks authenticated
|
||||
* - logout clears user and marks unauthenticated
|
||||
* - Expired token sets isAuthenticated to false
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import authReducer, { setUser, logout, selectUser, selectIsAuthenticated } from '../slices/authSlice';
|
||||
import type { AuthUser } from '../../lib/api/auth';
|
||||
|
||||
// Mock localStorage
|
||||
const mockStorage: Record<string, string> = {};
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<AuthUser>) => {
|
||||
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<boolean>) => {
|
||||
state.loading = action.payload;
|
||||
},
|
||||
setError: (state, action: PayloadAction<string>) => {
|
||||
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;
|
||||
|
|
@ -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<boolean>) => {
|
||||
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;
|
||||
|
|
@ -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<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
|
@ -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 (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<CircularProgress />
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Drawer
|
||||
variant={isMobile ? 'temporary' : 'permanent'}
|
||||
open={isMobile ? open : true}
|
||||
onClose={() => dispatch(setSidebarOpen(false))}
|
||||
onClose={() => onOpenChange(false)}
|
||||
sx={{
|
||||
width: isMobile ? 0 : width,
|
||||
flexShrink: 0,
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
>
|
||||
<Toolbar disableGutters sx={{ height: 'var(--spacing-topbar)', minHeight: 'var(--spacing-topbar)', gap: '18px', pl: '16px', pr: '22px' }}>
|
||||
<IconButton
|
||||
onClick={() => dispatch(toggleSidebar())}
|
||||
onClick={onToggleSidebar}
|
||||
aria-label="Toggle sidebar menu"
|
||||
sx={{
|
||||
width: 38,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, any> = {};
|
||||
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<string, string>,
|
||||
} 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 () => {
|
||||
|
|
|
|||
|
|
@ -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.'));
|
||||
}
|
||||
|
|
|
|||
87
web/src/lib/auth/__tests__/authStorage.test.ts
Normal file
87
web/src/lib/auth/__tests__/authStorage.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
40
web/src/lib/auth/authStorage.ts
Normal file
40
web/src/lib/auth/authStorage.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
|
|
@ -26,6 +25,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
// centered 384px card on --color-bg-page, brand lockup, Montserrat heading.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Alert, Box, Button, Typography, Divider, Stack } from '@mui/material';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||
|
|
@ -10,7 +9,6 @@ import EngineeringIcon from '@mui/icons-material/Engineering';
|
|||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { authApi } from '../../lib/api/auth';
|
||||
import { setUser, setError } from '../../app/slices/authSlice';
|
||||
import BrandLockup from '../../components/brand/BrandLockup';
|
||||
|
||||
const COGNITO_DOMAIN = import.meta.env.VITE_COGNITO_DOMAIN || '';
|
||||
|
|
@ -35,8 +33,7 @@ const devButtonSx = {
|
|||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { isAuthenticated, error } = useAuth();
|
||||
const { isAuthenticated, error, login, setError } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isDevMode = !CLIENT_ID;
|
||||
|
|
@ -58,10 +55,10 @@ export default function LoginPage() {
|
|||
try {
|
||||
const dev = devUsers[role] ?? devUsers['SysAdmin']!;
|
||||
const user = await authApi.devLogin(dev.email, dev.name, role);
|
||||
dispatch(setUser(user));
|
||||
login(user);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
dispatch(setError(err instanceof Error ? err.message : 'Dev login failed'));
|
||||
setError(err instanceof Error ? err.message : 'Dev login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
|
|||
68
web/src/providers/AuthProvider.tsx
Normal file
68
web/src/providers/AuthProvider.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// Auth provider — SHOC shape (mirrors providers/auth-provider.tsx).
|
||||
// Session persistence is delegated to lib/auth/authStorage (sessionStorage, WEB-C1);
|
||||
// this provider only owns the in-memory state. Token acquisition (Cognito code
|
||||
// exchange, dev-login) stays in the auth pages — they hand the resolved AuthUser
|
||||
// to login().
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { AuthUser } from '@proposal-system/api-contracts';
|
||||
import {
|
||||
AUTH_SESSION_CLEARED_EVENT,
|
||||
clearAuth,
|
||||
getAuthUser,
|
||||
isTokenValid,
|
||||
setAuthUser,
|
||||
} from '../lib/auth/authStorage';
|
||||
import { queryClient } from '../lib/queryClient';
|
||||
import { AuthContext, type AuthContextValue } from './authContext';
|
||||
|
||||
export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(() => getAuthUser());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setErrorState] = useState<string | null>(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<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
isAuthenticated: isTokenValid(user?.token),
|
||||
isLoading,
|
||||
error,
|
||||
login,
|
||||
logout,
|
||||
setError,
|
||||
setLoading: setIsLoading,
|
||||
}),
|
||||
[user, isLoading, error, login, logout, setError],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
129
web/src/providers/__tests__/AuthProvider.test.tsx
Normal file
129
web/src/providers/__tests__/AuthProvider.test.tsx
Normal file
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
27
web/src/providers/authContext.ts
Normal file
27
web/src/providers/authContext.ts
Normal file
|
|
@ -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<AuthContextValue | null>(null);
|
||||
|
||||
export function useAuthContext(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuthContext must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue