@rashidee/co2 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +25449 -0
- package/drizzle/0000_remarkable_charles_xavier.sql +210 -0
- package/drizzle/0001_wise_wolf_cub.sql +13 -0
- package/drizzle/0002_secret_spyke.sql +16 -0
- package/drizzle/0003_workspace_config_singleton.sql +16 -0
- package/drizzle/0004_terminal_claim_v120.sql +11 -0
- package/drizzle/0005_brainstorm_v124.sql +10 -0
- package/drizzle/0006_project_v140.sql +6 -0
- package/drizzle/0007_prd_wizard_v124.sql +14 -0
- package/drizzle/0008_workflow_artifact_v126.sql +24 -0
- package/drizzle/0009_project_import_v150.sql +7 -0
- package/drizzle/0010_quality_v151.sql +3 -0
- package/drizzle/meta/0000_snapshot.json +1335 -0
- package/drizzle/meta/0001_snapshot.json +1416 -0
- package/drizzle/meta/0002_snapshot.json +1510 -0
- package/drizzle/meta/_journal.json +83 -0
- package/package.json +41 -0
- package/plugin/.claude-plugin/marketplace.json +36 -0
- package/plugin/.claude-plugin/plugin.json +22 -0
- package/plugin/README.md +625 -0
- package/plugin/SKILLS.md +350 -0
- package/plugin/co2.svg +5 -0
- package/plugin/commands/brainstorm-loop.md +129 -0
- package/plugin/skills/conductor-defect/SKILL.md +898 -0
- package/plugin/skills/conductor-feature-develop/SKILL.md +1383 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-domain-template.md +131 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-master-template.md +89 -0
- package/plugin/skills/conductor-feature-develop/references/playwright-setup.md +224 -0
- package/plugin/skills/conductor-feature-prepare/SKILL.md +604 -0
- package/plugin/skills/depgen-k8s/SKILL.md +468 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-laravel.md +197 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-nodejs.md +163 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-spring-boot.md +174 -0
- package/plugin/skills/depgen-k8s/references/k8s-patterns.md +869 -0
- package/plugin/skills/mockgen-shadcn/SKILL.md +1067 -0
- package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +1467 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-index-template.md +216 -0
- package/plugin/skills/mockgen-tailwind/SKILL.md +904 -0
- package/plugin/skills/mockgen-tailwind/references/admin-layout-template.md +720 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-index-template.md +190 -0
- package/plugin/skills/modelgen-nosql/SKILL.md +617 -0
- package/plugin/skills/modelgen-nosql/references/model-extraction-methodology.md +497 -0
- package/plugin/skills/modelgen-nosql/references/nosql-design-guide.md +258 -0
- package/plugin/skills/modelgen-relational/SKILL.md +707 -0
- package/plugin/skills/modelgen-relational/references/model-extraction-methodology.md +499 -0
- package/plugin/skills/specgen-flutter-riverpod/SKILL.md +898 -0
- package/plugin/skills/specgen-flutter-riverpod/references/component-patterns.md +971 -0
- package/plugin/skills/specgen-flutter-riverpod/references/notification-patterns.md +457 -0
- package/plugin/skills/specgen-flutter-riverpod/references/routing-patterns.md +362 -0
- package/plugin/skills/specgen-flutter-riverpod/references/security-patterns.md +709 -0
- package/plugin/skills/specgen-flutter-riverpod/references/spec-template.md +1566 -0
- package/plugin/skills/specgen-flutter-riverpod/references/state-patterns.md +518 -0
- package/plugin/skills/specgen-flutter-riverpod/references/storage-patterns.md +370 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/SKILL.md +984 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/batch-patterns.md +359 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/messaging-patterns.md +468 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/modulith-patterns.md +461 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/reporting-patterns.md +803 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/security-patterns.md +501 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/spec-template.md +1268 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/web-patterns.md +301 -0
- package/plugin/skills/specgen-node-cli-web/SKILL.md +561 -0
- package/plugin/skills/specgen-node-cli-web/references/auth-patterns.md +293 -0
- package/plugin/skills/specgen-node-cli-web/references/cli-packaging-patterns.md +250 -0
- package/plugin/skills/specgen-node-cli-web/references/server-patterns.md +247 -0
- package/plugin/skills/specgen-node-cli-web/references/spec-template.md +401 -0
- package/plugin/skills/specgen-node-cli-web/references/testing-patterns.md +174 -0
- package/plugin/skills/specgen-react-mui/SKILL.md +789 -0
- package/plugin/skills/specgen-react-mui/references/component-patterns.md +1077 -0
- package/plugin/skills/specgen-react-mui/references/reporting-patterns.md +1998 -0
- package/plugin/skills/specgen-react-mui/references/routing-patterns.md +461 -0
- package/plugin/skills/specgen-react-mui/references/security-patterns.md +599 -0
- package/plugin/skills/specgen-react-mui/references/spec-template.md +1030 -0
- package/plugin/skills/specgen-react-mui/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-react-tailwind/SKILL.md +859 -0
- package/plugin/skills/specgen-react-tailwind/references/component-patterns.md +1212 -0
- package/plugin/skills/specgen-react-tailwind/references/reporting-patterns.md +1529 -0
- package/plugin/skills/specgen-react-tailwind/references/routing-patterns.md +669 -0
- package/plugin/skills/specgen-react-tailwind/references/security-patterns.md +656 -0
- package/plugin/skills/specgen-react-tailwind/references/spec-template.md +1237 -0
- package/plugin/skills/specgen-react-tailwind/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-sdk-java/SKILL.md +933 -0
- package/plugin/skills/specgen-sdk-java/references/api-extraction-patterns.md +247 -0
- package/plugin/skills/specgen-sdk-java/references/http-client-patterns.md +495 -0
- package/plugin/skills/specgen-sdk-java/references/packaging-patterns.md +366 -0
- package/plugin/skills/specgen-sdk-java/references/spec-template.md +956 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/SKILL.md +1284 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/jasper-patterns.md +1638 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/modulith-patterns.md +354 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/security-patterns.md +551 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/spec-template.md +2502 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/web-patterns.md +503 -0
- package/plugin/skills/specgen-spring-jpa-restapi/SKILL.md +990 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/modulith-patterns.md +279 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/reporting-patterns.md +1536 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/restapi-patterns.md +722 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/security-patterns.md +351 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/spec-template.md +553 -0
- package/plugin/skills/specgen-ts-cli/SKILL.md +861 -0
- package/plugin/skills/specgen-ts-cli/references/async-patterns.md +1101 -0
- package/plugin/skills/specgen-ts-cli/references/command-patterns.md +383 -0
- package/plugin/skills/specgen-ts-cli/references/config-patterns.md +430 -0
- package/plugin/skills/specgen-ts-cli/references/database-patterns.md +750 -0
- package/plugin/skills/specgen-ts-cli/references/packaging-patterns.md +426 -0
- package/plugin/skills/specgen-ts-cli/references/spec-template.md +1391 -0
- package/plugin/skills/specgen-ts-cli/references/testing-patterns.md +909 -0
- package/plugin/skills/testgen-functional/SKILL.md +1381 -0
- package/plugin/skills/tracegen-matrix/SKILL.md +243 -0
- package/plugin/skills/util-preparek8senv/SKILL.md +422 -0
- package/plugin/skills/util-projectinit/SKILL.md +307 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-loop.md +129 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-protocol.md +144 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-setup-guide.md +153 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-template-appdev.md +87 -0
- package/plugin/skills/util-projectinit/reference/co2-context-generation-guide.md +761 -0
- package/plugin/skills/util-projectsync/SKILL.md +501 -0
- package/plugin/skills/util-updprd/SKILL.md +324 -0
- package/plugin/skills/util-usanalyzer/SKILL.md +307 -0
- package/plugin/skills/util-ustagger/SKILL.md +154 -0
- package/static/assets/abnfDiagram-VRR7QNED-DvPuwyCV.js +1 -0
- package/static/assets/arc-DRKw7sjs.js +1 -0
- package/static/assets/architectureDiagram-ZJ3FMSHR-CaNcxUbC.js +36 -0
- package/static/assets/blockDiagram-677ZJIJ3-Demd4AOK.js +132 -0
- package/static/assets/c4Diagram-LMCZKHZV-PVKUfc2x.js +10 -0
- package/static/assets/channel-C2nVbuwv.js +1 -0
- package/static/assets/chunk-2Q5K7J3B-CExf8ww_.js +1 -0
- package/static/assets/chunk-32BRIVSS-CFPaoONF.js +1 -0
- package/static/assets/chunk-5VM5RSS4-DE2esM67.js +15 -0
- package/static/assets/chunk-EX3LRPZG-SON_YdDG.js +231 -0
- package/static/assets/chunk-JWPE2WC7-Qxah8LNr.js +1 -0
- package/static/assets/chunk-MOJQB5TN-BcWGQV3j.js +88 -0
- package/static/assets/chunk-RYQCIY6F-DkDj-OR5.js +1 -0
- package/static/assets/chunk-V7JOEXUC-BlI1Mrah.js +206 -0
- package/static/assets/chunk-VR4S4FIN-Agf__duj.js +1 -0
- package/static/assets/chunk-XXDRQBXY-B5CfAh85.js +1 -0
- package/static/assets/classDiagram-OUVF2IWQ-DXJIljsR.js +1 -0
- package/static/assets/classDiagram-v2-EOCWNBFH-DXJIljsR.js +1 -0
- package/static/assets/cose-bilkent-JH36ORCC-DO0cQkcy.js +1 -0
- package/static/assets/cynefin-VYW2F7L2-CfOnsnXc.js +178 -0
- package/static/assets/cynefinDiagram-TSTJHNR4-BJ8tULAv.js +62 -0
- package/static/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
- package/static/assets/dagre-VKFMJZFB-DfjwzHqP.js +4 -0
- package/static/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/static/assets/diagram-FQU43EPY-CndSOIIM.js +3 -0
- package/static/assets/diagram-G47NLZAW-Czf5hN0Z.js +24 -0
- package/static/assets/diagram-NH7WQ7WH-COAyojGh.js +24 -0
- package/static/assets/diagram-OA4YK3LP-H8PoiN2m.js +30 -0
- package/static/assets/diagram-WEI45ONY-DzcaVzjp.js +41 -0
- package/static/assets/ebnfDiagram-CCIWWBDH-DPazGYLJ.js +1 -0
- package/static/assets/erDiagram-Q63AITRT-Bo-fubgF.js +85 -0
- package/static/assets/flowDiagram-23GEKE2U-CSvxUDwh.js +156 -0
- package/static/assets/ganttDiagram-NO4QXBWP-DnBveWX6.js +292 -0
- package/static/assets/gitGraphDiagram-IHSO6WYX-B3rVL8c-.js +106 -0
- package/static/assets/graph-C9eacEi8.js +1 -0
- package/static/assets/index-LZSQkLE2.css +41 -0
- package/static/assets/index-y7XDSgMG.js +495 -0
- package/static/assets/infoDiagram-FWYZ7A6U-CvWV9yy2.js +2 -0
- package/static/assets/init-Gi6I4Gst.js +1 -0
- package/static/assets/ishikawaDiagram-FXEZZL3T-DF28a1N6.js +70 -0
- package/static/assets/journeyDiagram-5HDEW3XC-CB6CugYk.js +139 -0
- package/static/assets/kanban-definition-HUTT4EX6-ResSLF6d.js +89 -0
- package/static/assets/katex-C5jXJg4s.js +257 -0
- package/static/assets/layout-DEXfKzaS.js +1 -0
- package/static/assets/linear-CLO--ryk.js +1 -0
- package/static/assets/map-Czzmt4hB.js +1 -0
- package/static/assets/mindmap-definition-LN4V7U3C-BvW3EbxX.js +96 -0
- package/static/assets/ordinal-Cboi1Yqb.js +1 -0
- package/static/assets/pegDiagram-2B236MQR-BHCgqrrM.js +1 -0
- package/static/assets/pieDiagram-ENE6RG2P-DVmCQ5FA.js +39 -0
- package/static/assets/quadrantDiagram-ABIIQ3AL-B3e5KR52.js +7 -0
- package/static/assets/railroadDiagram-RFXS5EU6-Csv5ysLc.js +1 -0
- package/static/assets/requirementDiagram-TGXJPOKE-DOMVzvnB.js +84 -0
- package/static/assets/sankeyDiagram-HTMAVEWB-B1dcTOvy.js +40 -0
- package/static/assets/sequenceDiagram-DBY2YBRQ-BY414yeO.js +162 -0
- package/static/assets/sizeCapture-X5ZJPWSS-CuJfrfBN.js +1 -0
- package/static/assets/stateDiagram-2N3HPSRC-FX4rJkzV.js +1 -0
- package/static/assets/stateDiagram-v2-6OUMAXLB-BVufbO_H.js +1 -0
- package/static/assets/swimlanes-5IMT3BWC-Sfy58mmv.js +2 -0
- package/static/assets/swimlanesDiagram-G3AALYLV-wDCDw08H.js +8 -0
- package/static/assets/timeline-definition-FHXFAJF6-CmV7by8p.js +120 -0
- package/static/assets/vennDiagram-L72KCM5P-FqIyFZFt.js +34 -0
- package/static/assets/wardleyDiagram-EHGQE667-BZ06O45i.js +78 -0
- package/static/assets/xychartDiagram-FW5EYKEG-BOkffR06.js +7 -0
- package/static/index.html +24 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
# Security Patterns — OAuth2 PKCE (Keycloak) and Local JWT Auth
|
|
2
|
+
|
|
3
|
+
This reference describes the complete authentication architecture for the spec. Include
|
|
4
|
+
all of this content in Section 6 of the generated specification.
|
|
5
|
+
|
|
6
|
+
The auth layer is framework-agnostic — it is the same as the MUI variant — but all
|
|
7
|
+
user-facing UI (login form, callback, error states) is styled with Tailwind utilities and
|
|
8
|
+
uses Headless UI/Heroicons where interactivity is needed.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Architecture Overview — Keycloak PKCE (Auth = Keycloak)
|
|
13
|
+
|
|
14
|
+
The application operates as an OAuth2 Public Client using Authorization Code flow with
|
|
15
|
+
PKCE (Proof Key for Code Exchange) and OIDC. Keycloak is the external identity provider
|
|
16
|
+
that handles login UI, user registration, and role management. The application's
|
|
17
|
+
responsibilities are:
|
|
18
|
+
|
|
19
|
+
1. Redirect unauthenticated users to Keycloak login page (PKCE challenge generated client-side)
|
|
20
|
+
2. Handle the OAuth2 callback and exchange the authorization code for tokens
|
|
21
|
+
3. Store tokens securely in memory (Zustand store) — NOT in localStorage
|
|
22
|
+
4. Extract roles from OIDC ID token claims
|
|
23
|
+
5. Attach Bearer token to all API requests via Axios interceptor
|
|
24
|
+
6. Silently refresh the access token before expiry
|
|
25
|
+
|
|
26
|
+
The flow:
|
|
27
|
+
```
|
|
28
|
+
User navigates to protected route
|
|
29
|
+
→ ProtectedRoute detects no auth
|
|
30
|
+
→ oidc-client-ts generates code_verifier + code_challenge (PKCE)
|
|
31
|
+
→ 302 Redirect to Keycloak /auth?response_type=code&code_challenge=...
|
|
32
|
+
→ User logs in at Keycloak
|
|
33
|
+
→ Keycloak redirects to /auth/callback?code=...
|
|
34
|
+
→ AuthCallback page exchanges code for tokens (ID token + access token)
|
|
35
|
+
→ react-oidc-context stores tokens in memory
|
|
36
|
+
→ AuthProvider exposes user + token via useAuth() hook
|
|
37
|
+
→ Axios interceptor reads token from auth context
|
|
38
|
+
→ User redirected to originally-requested page
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Package Dependencies
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"oidc-client-ts": "^3.1.0",
|
|
49
|
+
"react-oidc-context": "^3.2.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Environment Variables
|
|
57
|
+
|
|
58
|
+
```env
|
|
59
|
+
# .env.development
|
|
60
|
+
VITE_KEYCLOAK_AUTHORITY=http://localhost:8180/realms/{{KEYCLOAK_REALM}}
|
|
61
|
+
VITE_KEYCLOAK_CLIENT_ID={{KEYCLOAK_CLIENT_ID}}
|
|
62
|
+
VITE_KEYCLOAK_REDIRECT_URI=http://localhost:3000/auth/callback
|
|
63
|
+
VITE_KEYCLOAK_POST_LOGOUT_REDIRECT_URI=http://localhost:3000
|
|
64
|
+
VITE_KEYCLOAK_SCOPE=openid profile email
|
|
65
|
+
|
|
66
|
+
# .env.production
|
|
67
|
+
VITE_KEYCLOAK_AUTHORITY=${KEYCLOAK_AUTHORITY}
|
|
68
|
+
VITE_KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID}
|
|
69
|
+
VITE_KEYCLOAK_REDIRECT_URI=${APP_URL}/auth/callback
|
|
70
|
+
VITE_KEYCLOAK_POST_LOGOUT_REDIRECT_URI=${APP_URL}
|
|
71
|
+
VITE_KEYCLOAK_SCOPE=openid profile email
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## OIDC Configuration
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
// src/lib/auth/oidcConfig.ts
|
|
80
|
+
import type { AuthProviderProps } from 'react-oidc-context';
|
|
81
|
+
|
|
82
|
+
export const oidcConfig: AuthProviderProps = {
|
|
83
|
+
authority: import.meta.env.VITE_KEYCLOAK_AUTHORITY,
|
|
84
|
+
client_id: import.meta.env.VITE_KEYCLOAK_CLIENT_ID,
|
|
85
|
+
redirect_uri: import.meta.env.VITE_KEYCLOAK_REDIRECT_URI,
|
|
86
|
+
post_logout_redirect_uri: import.meta.env.VITE_KEYCLOAK_POST_LOGOUT_REDIRECT_URI,
|
|
87
|
+
scope: import.meta.env.VITE_KEYCLOAK_SCOPE,
|
|
88
|
+
response_type: 'code',
|
|
89
|
+
// PKCE is enabled by default in oidc-client-ts
|
|
90
|
+
automaticSilentRenew: true,
|
|
91
|
+
loadUserInfo: true,
|
|
92
|
+
onSigninCallback: (_user) => {
|
|
93
|
+
// Remove the auth params from the URL after successful login
|
|
94
|
+
window.history.replaceState({}, document.title, window.location.pathname);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## AuthProvider Setup
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
// src/main.tsx
|
|
105
|
+
import React from 'react';
|
|
106
|
+
import ReactDOM from 'react-dom/client';
|
|
107
|
+
import { AuthProvider } from 'react-oidc-context';
|
|
108
|
+
import { QueryClientProvider } from '@tanstack/react-query';
|
|
109
|
+
import { oidcConfig } from './lib/auth/oidcConfig';
|
|
110
|
+
import { queryClient } from './lib/queryClient';
|
|
111
|
+
import { App } from './App';
|
|
112
|
+
import './index.css';
|
|
113
|
+
|
|
114
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
115
|
+
<React.StrictMode>
|
|
116
|
+
<AuthProvider {...oidcConfig}>
|
|
117
|
+
<QueryClientProvider client={queryClient}>
|
|
118
|
+
<App />
|
|
119
|
+
</QueryClientProvider>
|
|
120
|
+
</AuthProvider>
|
|
121
|
+
</React.StrictMode>,
|
|
122
|
+
);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Role Extraction from OIDC Token
|
|
128
|
+
|
|
129
|
+
Keycloak places roles in both `realm_access.roles` and `resource_access.<client_id>.roles`
|
|
130
|
+
within the ID token claims. Extract them using a utility:
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
// src/lib/auth/keycloakRoles.ts
|
|
134
|
+
import type { User } from 'oidc-client-ts';
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Extract Keycloak roles from the OIDC user's ID token claims.
|
|
138
|
+
* Combines realm roles and client-specific resource roles.
|
|
139
|
+
*/
|
|
140
|
+
export function extractKeycloakRoles(user: User | null | undefined): string[] {
|
|
141
|
+
if (!user?.profile) return [];
|
|
142
|
+
|
|
143
|
+
const profile = user.profile as Record<string, unknown>;
|
|
144
|
+
const roles: string[] = [];
|
|
145
|
+
|
|
146
|
+
const realmAccess = profile['realm_access'] as { roles?: string[] } | undefined;
|
|
147
|
+
if (realmAccess?.roles) roles.push(...realmAccess.roles);
|
|
148
|
+
|
|
149
|
+
const clientId = import.meta.env.VITE_KEYCLOAK_CLIENT_ID as string;
|
|
150
|
+
const resourceAccess = profile['resource_access'] as
|
|
151
|
+
| Record<string, { roles?: string[] }>
|
|
152
|
+
| undefined;
|
|
153
|
+
if (resourceAccess?.[clientId]?.roles) roles.push(...resourceAccess[clientId].roles);
|
|
154
|
+
|
|
155
|
+
return [...new Set(roles.map((r) => r.toUpperCase()))];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function hasRole(user: User | null | undefined, role: string): boolean {
|
|
159
|
+
return extractKeycloakRoles(user).includes(role.toUpperCase());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function hasAnyRole(user: User | null | undefined, roles: string[]): boolean {
|
|
163
|
+
const userRoles = extractKeycloakRoles(user);
|
|
164
|
+
return roles.some((r) => userRoles.includes(r.toUpperCase()));
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## useAuthUser Hook
|
|
171
|
+
|
|
172
|
+
A thin wrapper over `react-oidc-context`'s `useAuth()` that adds role-based utilities:
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
// src/lib/auth/useAuthUser.ts
|
|
176
|
+
import { useAuth } from 'react-oidc-context';
|
|
177
|
+
import { extractKeycloakRoles, hasRole, hasAnyRole } from './keycloakRoles';
|
|
178
|
+
|
|
179
|
+
export interface AuthUser {
|
|
180
|
+
id: string;
|
|
181
|
+
username: string;
|
|
182
|
+
email: string;
|
|
183
|
+
name: string;
|
|
184
|
+
roles: string[];
|
|
185
|
+
accessToken: string | undefined;
|
|
186
|
+
isAuthenticated: boolean;
|
|
187
|
+
isLoading: boolean;
|
|
188
|
+
login: () => Promise<void>;
|
|
189
|
+
logout: () => Promise<void>;
|
|
190
|
+
hasRole: (role: string) => boolean;
|
|
191
|
+
hasAnyRole: (roles: string[]) => boolean;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function useAuthUser(): AuthUser {
|
|
195
|
+
const auth = useAuth();
|
|
196
|
+
const roles = extractKeycloakRoles(auth.user);
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
id: auth.user?.profile.sub ?? '',
|
|
200
|
+
username: (auth.user?.profile.preferred_username as string) ?? '',
|
|
201
|
+
email: auth.user?.profile.email ?? '',
|
|
202
|
+
name: auth.user?.profile.name ?? '',
|
|
203
|
+
roles,
|
|
204
|
+
accessToken: auth.user?.access_token,
|
|
205
|
+
isAuthenticated: auth.isAuthenticated,
|
|
206
|
+
isLoading: auth.isLoading,
|
|
207
|
+
login: () => auth.signinRedirect(),
|
|
208
|
+
logout: () =>
|
|
209
|
+
auth.signoutRedirect({
|
|
210
|
+
post_logout_redirect_uri: import.meta.env.VITE_KEYCLOAK_POST_LOGOUT_REDIRECT_URI,
|
|
211
|
+
}),
|
|
212
|
+
hasRole: (role) => hasRole(auth.user, role),
|
|
213
|
+
hasAnyRole: (r) => hasAnyRole(auth.user, r),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## ProtectedRoute Component
|
|
221
|
+
|
|
222
|
+
```tsx
|
|
223
|
+
// src/shared/components/ProtectedRoute.tsx
|
|
224
|
+
import { useAuth } from 'react-oidc-context';
|
|
225
|
+
import { Navigate, useLocation } from 'react-router-dom';
|
|
226
|
+
import { hasAnyRole } from '../../lib/auth/keycloakRoles';
|
|
227
|
+
import { LoadingOverlay } from './LoadingOverlay';
|
|
228
|
+
|
|
229
|
+
interface ProtectedRouteProps {
|
|
230
|
+
children: React.ReactNode;
|
|
231
|
+
/** If provided, the user must have at least one of these roles */
|
|
232
|
+
requiredRoles?: string[];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function ProtectedRoute({ children, requiredRoles }: ProtectedRouteProps) {
|
|
236
|
+
const auth = useAuth();
|
|
237
|
+
const location = useLocation();
|
|
238
|
+
|
|
239
|
+
if (auth.isLoading) return <LoadingOverlay message="Loading…" />;
|
|
240
|
+
|
|
241
|
+
if (!auth.isAuthenticated) {
|
|
242
|
+
// Trigger Keycloak login redirect
|
|
243
|
+
auth.signinRedirect({ state: { from: location.pathname } });
|
|
244
|
+
return <LoadingOverlay message="Redirecting to sign-in…" />;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (requiredRoles && !hasAnyRole(auth.user, requiredRoles)) {
|
|
248
|
+
return <Navigate to="/unauthorized" replace />;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return <>{children}</>;
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## AuthCallback Page
|
|
258
|
+
|
|
259
|
+
```tsx
|
|
260
|
+
// src/features/auth/pages/AuthCallbackPage.tsx
|
|
261
|
+
import { useEffect } from 'react';
|
|
262
|
+
import { useAuth } from 'react-oidc-context';
|
|
263
|
+
import { useNavigate } from 'react-router-dom';
|
|
264
|
+
import { LoadingOverlay } from '../../../shared/components/LoadingOverlay';
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Handles the OAuth2 callback redirect from Keycloak.
|
|
268
|
+
* react-oidc-context processes the code automatically via onSigninCallback;
|
|
269
|
+
* this page just shows a loading state during the exchange.
|
|
270
|
+
*/
|
|
271
|
+
export function AuthCallbackPage() {
|
|
272
|
+
const auth = useAuth();
|
|
273
|
+
const navigate = useNavigate();
|
|
274
|
+
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
if (auth.isAuthenticated) {
|
|
277
|
+
const from = (auth.user?.state as { from?: string })?.from ?? '/';
|
|
278
|
+
navigate(from, { replace: true });
|
|
279
|
+
}
|
|
280
|
+
}, [auth.isAuthenticated, auth.user, navigate]);
|
|
281
|
+
|
|
282
|
+
if (auth.error) {
|
|
283
|
+
return (
|
|
284
|
+
<div className="mx-auto max-w-md p-8 text-center">
|
|
285
|
+
<h2 className="text-lg font-semibold text-danger">Authentication Error</h2>
|
|
286
|
+
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">{auth.error.message}</p>
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return <LoadingOverlay message="Completing sign-in…" />;
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## Axios Interceptor (Keycloak)
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
// src/lib/api/axiosInstance.ts
|
|
301
|
+
import axios from 'axios';
|
|
302
|
+
import { getAuthUser } from './authBridge';
|
|
303
|
+
|
|
304
|
+
export const axiosInstance = axios.create({
|
|
305
|
+
baseURL: import.meta.env.VITE_API_BASE_URL,
|
|
306
|
+
timeout: 30_000,
|
|
307
|
+
headers: { 'Content-Type': 'application/json' },
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Attach Bearer token to every request
|
|
311
|
+
axiosInstance.interceptors.request.use(
|
|
312
|
+
(config) => {
|
|
313
|
+
const token = getAuthUser()?.access_token;
|
|
314
|
+
if (token) config.headers.Authorization = `Bearer ${token}`;
|
|
315
|
+
return config;
|
|
316
|
+
},
|
|
317
|
+
(error) => Promise.reject(error),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// Global error handling
|
|
321
|
+
axiosInstance.interceptors.response.use(
|
|
322
|
+
(response) => response,
|
|
323
|
+
(error) => {
|
|
324
|
+
if (axios.isAxiosError(error)) {
|
|
325
|
+
const status = error.response?.status;
|
|
326
|
+
if (status === 401) {
|
|
327
|
+
// Token expired — oidc-client-ts silent renewal handles this automatically.
|
|
328
|
+
console.warn('Unauthorized — triggering silent renew');
|
|
329
|
+
} else if (status === 403) {
|
|
330
|
+
console.warn('Forbidden — insufficient permissions');
|
|
331
|
+
} else if (status && status >= 500) {
|
|
332
|
+
console.error('Server error', error.response?.data);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return Promise.reject(error);
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
The `authBridge` module provides a way to access the current OIDC user from outside
|
|
341
|
+
React (needed for the Axios interceptor, which runs outside React's component tree):
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
// src/lib/api/authBridge.ts
|
|
345
|
+
import type { User } from 'oidc-client-ts';
|
|
346
|
+
|
|
347
|
+
let _currentUser: User | null = null;
|
|
348
|
+
|
|
349
|
+
export function setAuthUser(user: User | null): void {
|
|
350
|
+
_currentUser = user;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function getAuthUser(): User | null {
|
|
354
|
+
return _currentUser;
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Register the bridge in the `App` component:
|
|
359
|
+
|
|
360
|
+
```tsx
|
|
361
|
+
// src/App.tsx (relevant excerpt)
|
|
362
|
+
import { useAuth } from 'react-oidc-context';
|
|
363
|
+
import { useEffect } from 'react';
|
|
364
|
+
import { setAuthUser } from './lib/api/authBridge';
|
|
365
|
+
|
|
366
|
+
export function App() {
|
|
367
|
+
const auth = useAuth();
|
|
368
|
+
|
|
369
|
+
// Keep the auth bridge in sync with the current OIDC user
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
setAuthUser(auth.user ?? null);
|
|
372
|
+
}, [auth.user]);
|
|
373
|
+
|
|
374
|
+
return (/* router and providers */);
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
## OIDC ID Token Structure Reference
|
|
381
|
+
|
|
382
|
+
Keycloak places roles in the ID token as follows:
|
|
383
|
+
|
|
384
|
+
```json
|
|
385
|
+
{
|
|
386
|
+
"sub": "f1234-abcd-5678",
|
|
387
|
+
"preferred_username": "john.doe",
|
|
388
|
+
"email": "john.doe@example.com",
|
|
389
|
+
"name": "John Doe",
|
|
390
|
+
"realm_access": { "roles": ["USER", "offline_access"] },
|
|
391
|
+
"resource_access": {
|
|
392
|
+
"{{KEYCLOAK_CLIENT_ID}}": { "roles": ["ADMIN"] }
|
|
393
|
+
},
|
|
394
|
+
"scope": "openid profile email"
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
This structure is what `extractKeycloakRoles()` parses.
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
## Role Constants
|
|
403
|
+
|
|
404
|
+
Define roles as a constants object to avoid string duplication:
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
// src/lib/auth/roles.ts
|
|
408
|
+
export const Roles = {
|
|
409
|
+
ADMIN: 'ADMIN',
|
|
410
|
+
EDITOR: 'EDITOR',
|
|
411
|
+
USER: 'USER',
|
|
412
|
+
} as const;
|
|
413
|
+
|
|
414
|
+
export type Role = (typeof Roles)[keyof typeof Roles];
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
## Architecture Overview — Local JWT Auth (Auth = Local)
|
|
420
|
+
|
|
421
|
+
When the backend manages authentication (no external IdP), the flow is:
|
|
422
|
+
|
|
423
|
+
```
|
|
424
|
+
User submits login form
|
|
425
|
+
→ POST /api/auth/login with { email, password }
|
|
426
|
+
→ Backend returns { accessToken, user }
|
|
427
|
+
→ Zustand auth store saves token in memory
|
|
428
|
+
→ Axios interceptor reads token from Zustand store
|
|
429
|
+
→ On page reload: token is lost, user must re-login (or use refresh token cookie)
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### Zustand Auth Store (Local JWT)
|
|
433
|
+
|
|
434
|
+
```typescript
|
|
435
|
+
// src/store/auth.store.ts
|
|
436
|
+
import { create } from 'zustand';
|
|
437
|
+
import { axiosInstance } from '../lib/api/axiosInstance';
|
|
438
|
+
|
|
439
|
+
interface UserInfo {
|
|
440
|
+
id: string;
|
|
441
|
+
email: string;
|
|
442
|
+
name: string;
|
|
443
|
+
roles: string[];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
interface AuthState {
|
|
447
|
+
user: UserInfo | null;
|
|
448
|
+
accessToken: string | null;
|
|
449
|
+
isAuthenticated: boolean;
|
|
450
|
+
isLoading: boolean;
|
|
451
|
+
login: (email: string, password: string) => Promise<void>;
|
|
452
|
+
logout: () => Promise<void>;
|
|
453
|
+
setToken: (token: string, user: UserInfo) => void;
|
|
454
|
+
clearAuth: () => void;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export const useAuthStore = create<AuthState>((set) => ({
|
|
458
|
+
user: null,
|
|
459
|
+
accessToken: null,
|
|
460
|
+
isAuthenticated: false,
|
|
461
|
+
isLoading: false,
|
|
462
|
+
|
|
463
|
+
login: async (email, password) => {
|
|
464
|
+
set({ isLoading: true });
|
|
465
|
+
try {
|
|
466
|
+
const response = await axiosInstance.post<{ accessToken: string; user: UserInfo }>(
|
|
467
|
+
'/auth/login',
|
|
468
|
+
{ email, password },
|
|
469
|
+
);
|
|
470
|
+
set({
|
|
471
|
+
accessToken: response.data.accessToken,
|
|
472
|
+
user: response.data.user,
|
|
473
|
+
isAuthenticated: true,
|
|
474
|
+
isLoading: false,
|
|
475
|
+
});
|
|
476
|
+
} catch (error) {
|
|
477
|
+
set({ isLoading: false });
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
logout: async () => {
|
|
483
|
+
try {
|
|
484
|
+
await axiosInstance.post('/auth/logout');
|
|
485
|
+
} finally {
|
|
486
|
+
set({ user: null, accessToken: null, isAuthenticated: false });
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
setToken: (token, user) => set({ accessToken: token, user, isAuthenticated: true }),
|
|
491
|
+
clearAuth: () => set({ user: null, accessToken: null, isAuthenticated: false }),
|
|
492
|
+
}));
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
For the Local-JWT variant, `useAuthUser()` reads from this store instead of `react-oidc-context`:
|
|
496
|
+
|
|
497
|
+
```typescript
|
|
498
|
+
// src/lib/auth/useAuthUser.ts (Local JWT variant)
|
|
499
|
+
import { useAuthStore } from '../../store/auth.store';
|
|
500
|
+
|
|
501
|
+
export function useAuthUser() {
|
|
502
|
+
const { user, accessToken, isAuthenticated, isLoading, logout } = useAuthStore();
|
|
503
|
+
return {
|
|
504
|
+
id: user?.id ?? '',
|
|
505
|
+
name: user?.name ?? '',
|
|
506
|
+
email: user?.email ?? '',
|
|
507
|
+
roles: user?.roles ?? [],
|
|
508
|
+
accessToken: accessToken ?? undefined,
|
|
509
|
+
isAuthenticated,
|
|
510
|
+
isLoading,
|
|
511
|
+
logout,
|
|
512
|
+
hasRole: (role: string) => !!user?.roles.includes(role.toUpperCase()),
|
|
513
|
+
hasAnyRole: (roles: string[]) => roles.some((r) => user?.roles.includes(r.toUpperCase())),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### Axios Interceptor (Local JWT)
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
// src/lib/api/axiosInstance.ts
|
|
522
|
+
import axios from 'axios';
|
|
523
|
+
import { useAuthStore } from '../../store/auth.store';
|
|
524
|
+
|
|
525
|
+
export const axiosInstance = axios.create({
|
|
526
|
+
baseURL: import.meta.env.VITE_API_BASE_URL,
|
|
527
|
+
timeout: 30_000,
|
|
528
|
+
withCredentials: true, // Required for httpOnly refresh token cookie
|
|
529
|
+
headers: { 'Content-Type': 'application/json' },
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
axiosInstance.interceptors.request.use((config) => {
|
|
533
|
+
const token = useAuthStore.getState().accessToken;
|
|
534
|
+
if (token) config.headers.Authorization = `Bearer ${token}`;
|
|
535
|
+
return config;
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
axiosInstance.interceptors.response.use(
|
|
539
|
+
(response) => response,
|
|
540
|
+
async (error) => {
|
|
541
|
+
if (!axios.isAxiosError(error)) return Promise.reject(error);
|
|
542
|
+
const status = error.response?.status;
|
|
543
|
+
const originalRequest = error.config as typeof error.config & { _retry?: boolean };
|
|
544
|
+
|
|
545
|
+
if (status === 401 && !originalRequest._retry) {
|
|
546
|
+
originalRequest._retry = true;
|
|
547
|
+
try {
|
|
548
|
+
const res = await axiosInstance.post<{ accessToken: string; user: unknown }>('/auth/refresh');
|
|
549
|
+
useAuthStore
|
|
550
|
+
.getState()
|
|
551
|
+
.setToken(res.data.accessToken, res.data.user as Parameters<typeof useAuthStore.getState().setToken>[1]);
|
|
552
|
+
originalRequest.headers = originalRequest.headers ?? {};
|
|
553
|
+
originalRequest.headers.Authorization = `Bearer ${res.data.accessToken}`;
|
|
554
|
+
return axiosInstance(originalRequest);
|
|
555
|
+
} catch {
|
|
556
|
+
useAuthStore.getState().clearAuth();
|
|
557
|
+
window.location.href = '/login';
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return Promise.reject(error);
|
|
561
|
+
},
|
|
562
|
+
);
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
### ProtectedRoute Component (Local JWT)
|
|
566
|
+
|
|
567
|
+
```tsx
|
|
568
|
+
// src/shared/components/ProtectedRoute.tsx
|
|
569
|
+
import { Navigate, useLocation } from 'react-router-dom';
|
|
570
|
+
import { useAuthStore } from '../../store/auth.store';
|
|
571
|
+
import { LoadingOverlay } from './LoadingOverlay';
|
|
572
|
+
|
|
573
|
+
interface ProtectedRouteProps {
|
|
574
|
+
children: React.ReactNode;
|
|
575
|
+
requiredRoles?: string[];
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function ProtectedRoute({ children, requiredRoles }: ProtectedRouteProps) {
|
|
579
|
+
const { isAuthenticated, isLoading, user } = useAuthStore();
|
|
580
|
+
const location = useLocation();
|
|
581
|
+
|
|
582
|
+
if (isLoading) return <LoadingOverlay />;
|
|
583
|
+
|
|
584
|
+
if (!isAuthenticated) {
|
|
585
|
+
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (requiredRoles && !requiredRoles.some((r) => user?.roles.includes(r.toUpperCase()))) {
|
|
589
|
+
return <Navigate to="/unauthorized" replace />;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return <>{children}</>;
|
|
593
|
+
}
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
### Login Page (Local JWT) — Tailwind + RHF + Zod
|
|
597
|
+
|
|
598
|
+
```tsx
|
|
599
|
+
// src/features/auth/pages/LoginPage.tsx
|
|
600
|
+
import { useForm } from 'react-hook-form';
|
|
601
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
602
|
+
import { z } from 'zod';
|
|
603
|
+
import { useNavigate, useLocation } from 'react-router-dom';
|
|
604
|
+
import { Button } from '../../../shared/components/Button';
|
|
605
|
+
import { Card } from '../../../shared/components/Card';
|
|
606
|
+
import { TextFieldController } from '../../../shared/form/TextFieldController';
|
|
607
|
+
import { useAuthStore } from '../../../store/auth.store';
|
|
608
|
+
import { useNotificationStore } from '../../../store/notification.store';
|
|
609
|
+
|
|
610
|
+
const loginSchema = z.object({
|
|
611
|
+
email: z.string().email('Enter a valid email address'),
|
|
612
|
+
password: z.string().min(1, 'Password is required'),
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
type LoginFormValues = z.infer<typeof loginSchema>;
|
|
616
|
+
|
|
617
|
+
export function LoginPage() {
|
|
618
|
+
const navigate = useNavigate();
|
|
619
|
+
const location = useLocation();
|
|
620
|
+
const login = useAuthStore((s) => s.login);
|
|
621
|
+
const isLoading = useAuthStore((s) => s.isLoading);
|
|
622
|
+
const push = useNotificationStore((s) => s.push);
|
|
623
|
+
|
|
624
|
+
const { control, handleSubmit } = useForm<LoginFormValues>({
|
|
625
|
+
resolver: zodResolver(loginSchema),
|
|
626
|
+
defaultValues: { email: '', password: '' },
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const onSubmit = handleSubmit(async (values) => {
|
|
630
|
+
try {
|
|
631
|
+
await login(values.email, values.password);
|
|
632
|
+
const from = (location.state as { from?: string })?.from ?? '/dashboard';
|
|
633
|
+
navigate(from, { replace: true });
|
|
634
|
+
} catch {
|
|
635
|
+
push('error', 'Invalid email or password.');
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
return (
|
|
640
|
+
<Card className="w-full max-w-md p-8">
|
|
641
|
+
<h1 className="mb-6 text-center text-xl font-semibold text-slate-900 dark:text-slate-100">
|
|
642
|
+
Sign in to {import.meta.env.VITE_APP_TITLE}
|
|
643
|
+
</h1>
|
|
644
|
+
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
|
645
|
+
<TextFieldController name="email" control={control} label="Email" type="email" required />
|
|
646
|
+
<TextFieldController name="password" control={control} label="Password" type="password" required />
|
|
647
|
+
<Button type="submit" className="w-full" loading={isLoading}>
|
|
648
|
+
Sign in
|
|
649
|
+
</Button>
|
|
650
|
+
</form>
|
|
651
|
+
</Card>
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
> `AuthLayout` centers this card: `<div className="flex min-h-screen items-center justify-center bg-canvas p-4"><Outlet /></div>`.
|