@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.
Files changed (190) hide show
  1. package/dist/index.js +25449 -0
  2. package/drizzle/0000_remarkable_charles_xavier.sql +210 -0
  3. package/drizzle/0001_wise_wolf_cub.sql +13 -0
  4. package/drizzle/0002_secret_spyke.sql +16 -0
  5. package/drizzle/0003_workspace_config_singleton.sql +16 -0
  6. package/drizzle/0004_terminal_claim_v120.sql +11 -0
  7. package/drizzle/0005_brainstorm_v124.sql +10 -0
  8. package/drizzle/0006_project_v140.sql +6 -0
  9. package/drizzle/0007_prd_wizard_v124.sql +14 -0
  10. package/drizzle/0008_workflow_artifact_v126.sql +24 -0
  11. package/drizzle/0009_project_import_v150.sql +7 -0
  12. package/drizzle/0010_quality_v151.sql +3 -0
  13. package/drizzle/meta/0000_snapshot.json +1335 -0
  14. package/drizzle/meta/0001_snapshot.json +1416 -0
  15. package/drizzle/meta/0002_snapshot.json +1510 -0
  16. package/drizzle/meta/_journal.json +83 -0
  17. package/package.json +41 -0
  18. package/plugin/.claude-plugin/marketplace.json +36 -0
  19. package/plugin/.claude-plugin/plugin.json +22 -0
  20. package/plugin/README.md +625 -0
  21. package/plugin/SKILLS.md +350 -0
  22. package/plugin/co2.svg +5 -0
  23. package/plugin/commands/brainstorm-loop.md +129 -0
  24. package/plugin/skills/conductor-defect/SKILL.md +898 -0
  25. package/plugin/skills/conductor-feature-develop/SKILL.md +1383 -0
  26. package/plugin/skills/conductor-feature-develop/references/implementation-domain-template.md +131 -0
  27. package/plugin/skills/conductor-feature-develop/references/implementation-master-template.md +89 -0
  28. package/plugin/skills/conductor-feature-develop/references/playwright-setup.md +224 -0
  29. package/plugin/skills/conductor-feature-prepare/SKILL.md +604 -0
  30. package/plugin/skills/depgen-k8s/SKILL.md +468 -0
  31. package/plugin/skills/depgen-k8s/references/dockerfile-laravel.md +197 -0
  32. package/plugin/skills/depgen-k8s/references/dockerfile-nodejs.md +163 -0
  33. package/plugin/skills/depgen-k8s/references/dockerfile-spring-boot.md +174 -0
  34. package/plugin/skills/depgen-k8s/references/k8s-patterns.md +869 -0
  35. package/plugin/skills/mockgen-shadcn/SKILL.md +1067 -0
  36. package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +1467 -0
  37. package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +498 -0
  38. package/plugin/skills/mockgen-shadcn/references/mockup-index-template.md +216 -0
  39. package/plugin/skills/mockgen-tailwind/SKILL.md +904 -0
  40. package/plugin/skills/mockgen-tailwind/references/admin-layout-template.md +720 -0
  41. package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +498 -0
  42. package/plugin/skills/mockgen-tailwind/references/mockup-index-template.md +190 -0
  43. package/plugin/skills/modelgen-nosql/SKILL.md +617 -0
  44. package/plugin/skills/modelgen-nosql/references/model-extraction-methodology.md +497 -0
  45. package/plugin/skills/modelgen-nosql/references/nosql-design-guide.md +258 -0
  46. package/plugin/skills/modelgen-relational/SKILL.md +707 -0
  47. package/plugin/skills/modelgen-relational/references/model-extraction-methodology.md +499 -0
  48. package/plugin/skills/specgen-flutter-riverpod/SKILL.md +898 -0
  49. package/plugin/skills/specgen-flutter-riverpod/references/component-patterns.md +971 -0
  50. package/plugin/skills/specgen-flutter-riverpod/references/notification-patterns.md +457 -0
  51. package/plugin/skills/specgen-flutter-riverpod/references/routing-patterns.md +362 -0
  52. package/plugin/skills/specgen-flutter-riverpod/references/security-patterns.md +709 -0
  53. package/plugin/skills/specgen-flutter-riverpod/references/spec-template.md +1566 -0
  54. package/plugin/skills/specgen-flutter-riverpod/references/state-patterns.md +518 -0
  55. package/plugin/skills/specgen-flutter-riverpod/references/storage-patterns.md +370 -0
  56. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/SKILL.md +984 -0
  57. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/batch-patterns.md +359 -0
  58. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/messaging-patterns.md +468 -0
  59. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/modulith-patterns.md +461 -0
  60. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/reporting-patterns.md +803 -0
  61. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/security-patterns.md +501 -0
  62. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/spec-template.md +1268 -0
  63. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/web-patterns.md +301 -0
  64. package/plugin/skills/specgen-node-cli-web/SKILL.md +561 -0
  65. package/plugin/skills/specgen-node-cli-web/references/auth-patterns.md +293 -0
  66. package/plugin/skills/specgen-node-cli-web/references/cli-packaging-patterns.md +250 -0
  67. package/plugin/skills/specgen-node-cli-web/references/server-patterns.md +247 -0
  68. package/plugin/skills/specgen-node-cli-web/references/spec-template.md +401 -0
  69. package/plugin/skills/specgen-node-cli-web/references/testing-patterns.md +174 -0
  70. package/plugin/skills/specgen-react-mui/SKILL.md +789 -0
  71. package/plugin/skills/specgen-react-mui/references/component-patterns.md +1077 -0
  72. package/plugin/skills/specgen-react-mui/references/reporting-patterns.md +1998 -0
  73. package/plugin/skills/specgen-react-mui/references/routing-patterns.md +461 -0
  74. package/plugin/skills/specgen-react-mui/references/security-patterns.md +599 -0
  75. package/plugin/skills/specgen-react-mui/references/spec-template.md +1030 -0
  76. package/plugin/skills/specgen-react-mui/references/state-patterns.md +327 -0
  77. package/plugin/skills/specgen-react-tailwind/SKILL.md +859 -0
  78. package/plugin/skills/specgen-react-tailwind/references/component-patterns.md +1212 -0
  79. package/plugin/skills/specgen-react-tailwind/references/reporting-patterns.md +1529 -0
  80. package/plugin/skills/specgen-react-tailwind/references/routing-patterns.md +669 -0
  81. package/plugin/skills/specgen-react-tailwind/references/security-patterns.md +656 -0
  82. package/plugin/skills/specgen-react-tailwind/references/spec-template.md +1237 -0
  83. package/plugin/skills/specgen-react-tailwind/references/state-patterns.md +327 -0
  84. package/plugin/skills/specgen-sdk-java/SKILL.md +933 -0
  85. package/plugin/skills/specgen-sdk-java/references/api-extraction-patterns.md +247 -0
  86. package/plugin/skills/specgen-sdk-java/references/http-client-patterns.md +495 -0
  87. package/plugin/skills/specgen-sdk-java/references/packaging-patterns.md +366 -0
  88. package/plugin/skills/specgen-sdk-java/references/spec-template.md +956 -0
  89. package/plugin/skills/specgen-spring-jpa-jtehtmx/SKILL.md +1284 -0
  90. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/batch-patterns.md +1224 -0
  91. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/jasper-patterns.md +1638 -0
  92. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/messaging-patterns.md +512 -0
  93. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/modulith-patterns.md +354 -0
  94. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/security-patterns.md +551 -0
  95. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/spec-template.md +2502 -0
  96. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/web-patterns.md +503 -0
  97. package/plugin/skills/specgen-spring-jpa-restapi/SKILL.md +990 -0
  98. package/plugin/skills/specgen-spring-jpa-restapi/references/batch-patterns.md +1224 -0
  99. package/plugin/skills/specgen-spring-jpa-restapi/references/messaging-patterns.md +512 -0
  100. package/plugin/skills/specgen-spring-jpa-restapi/references/modulith-patterns.md +279 -0
  101. package/plugin/skills/specgen-spring-jpa-restapi/references/reporting-patterns.md +1536 -0
  102. package/plugin/skills/specgen-spring-jpa-restapi/references/restapi-patterns.md +722 -0
  103. package/plugin/skills/specgen-spring-jpa-restapi/references/security-patterns.md +351 -0
  104. package/plugin/skills/specgen-spring-jpa-restapi/references/spec-template.md +553 -0
  105. package/plugin/skills/specgen-ts-cli/SKILL.md +861 -0
  106. package/plugin/skills/specgen-ts-cli/references/async-patterns.md +1101 -0
  107. package/plugin/skills/specgen-ts-cli/references/command-patterns.md +383 -0
  108. package/plugin/skills/specgen-ts-cli/references/config-patterns.md +430 -0
  109. package/plugin/skills/specgen-ts-cli/references/database-patterns.md +750 -0
  110. package/plugin/skills/specgen-ts-cli/references/packaging-patterns.md +426 -0
  111. package/plugin/skills/specgen-ts-cli/references/spec-template.md +1391 -0
  112. package/plugin/skills/specgen-ts-cli/references/testing-patterns.md +909 -0
  113. package/plugin/skills/testgen-functional/SKILL.md +1381 -0
  114. package/plugin/skills/tracegen-matrix/SKILL.md +243 -0
  115. package/plugin/skills/util-preparek8senv/SKILL.md +422 -0
  116. package/plugin/skills/util-projectinit/SKILL.md +307 -0
  117. package/plugin/skills/util-projectinit/reference/brainstorm-loop.md +129 -0
  118. package/plugin/skills/util-projectinit/reference/brainstorm-protocol.md +144 -0
  119. package/plugin/skills/util-projectinit/reference/brainstorm-setup-guide.md +153 -0
  120. package/plugin/skills/util-projectinit/reference/brainstorm-template-appdev.md +87 -0
  121. package/plugin/skills/util-projectinit/reference/co2-context-generation-guide.md +761 -0
  122. package/plugin/skills/util-projectsync/SKILL.md +501 -0
  123. package/plugin/skills/util-updprd/SKILL.md +324 -0
  124. package/plugin/skills/util-usanalyzer/SKILL.md +307 -0
  125. package/plugin/skills/util-ustagger/SKILL.md +154 -0
  126. package/static/assets/abnfDiagram-VRR7QNED-DvPuwyCV.js +1 -0
  127. package/static/assets/arc-DRKw7sjs.js +1 -0
  128. package/static/assets/architectureDiagram-ZJ3FMSHR-CaNcxUbC.js +36 -0
  129. package/static/assets/blockDiagram-677ZJIJ3-Demd4AOK.js +132 -0
  130. package/static/assets/c4Diagram-LMCZKHZV-PVKUfc2x.js +10 -0
  131. package/static/assets/channel-C2nVbuwv.js +1 -0
  132. package/static/assets/chunk-2Q5K7J3B-CExf8ww_.js +1 -0
  133. package/static/assets/chunk-32BRIVSS-CFPaoONF.js +1 -0
  134. package/static/assets/chunk-5VM5RSS4-DE2esM67.js +15 -0
  135. package/static/assets/chunk-EX3LRPZG-SON_YdDG.js +231 -0
  136. package/static/assets/chunk-JWPE2WC7-Qxah8LNr.js +1 -0
  137. package/static/assets/chunk-MOJQB5TN-BcWGQV3j.js +88 -0
  138. package/static/assets/chunk-RYQCIY6F-DkDj-OR5.js +1 -0
  139. package/static/assets/chunk-V7JOEXUC-BlI1Mrah.js +206 -0
  140. package/static/assets/chunk-VR4S4FIN-Agf__duj.js +1 -0
  141. package/static/assets/chunk-XXDRQBXY-B5CfAh85.js +1 -0
  142. package/static/assets/classDiagram-OUVF2IWQ-DXJIljsR.js +1 -0
  143. package/static/assets/classDiagram-v2-EOCWNBFH-DXJIljsR.js +1 -0
  144. package/static/assets/cose-bilkent-JH36ORCC-DO0cQkcy.js +1 -0
  145. package/static/assets/cynefin-VYW2F7L2-CfOnsnXc.js +178 -0
  146. package/static/assets/cynefinDiagram-TSTJHNR4-BJ8tULAv.js +62 -0
  147. package/static/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
  148. package/static/assets/dagre-VKFMJZFB-DfjwzHqP.js +4 -0
  149. package/static/assets/defaultLocale-DX6XiGOO.js +1 -0
  150. package/static/assets/diagram-FQU43EPY-CndSOIIM.js +3 -0
  151. package/static/assets/diagram-G47NLZAW-Czf5hN0Z.js +24 -0
  152. package/static/assets/diagram-NH7WQ7WH-COAyojGh.js +24 -0
  153. package/static/assets/diagram-OA4YK3LP-H8PoiN2m.js +30 -0
  154. package/static/assets/diagram-WEI45ONY-DzcaVzjp.js +41 -0
  155. package/static/assets/ebnfDiagram-CCIWWBDH-DPazGYLJ.js +1 -0
  156. package/static/assets/erDiagram-Q63AITRT-Bo-fubgF.js +85 -0
  157. package/static/assets/flowDiagram-23GEKE2U-CSvxUDwh.js +156 -0
  158. package/static/assets/ganttDiagram-NO4QXBWP-DnBveWX6.js +292 -0
  159. package/static/assets/gitGraphDiagram-IHSO6WYX-B3rVL8c-.js +106 -0
  160. package/static/assets/graph-C9eacEi8.js +1 -0
  161. package/static/assets/index-LZSQkLE2.css +41 -0
  162. package/static/assets/index-y7XDSgMG.js +495 -0
  163. package/static/assets/infoDiagram-FWYZ7A6U-CvWV9yy2.js +2 -0
  164. package/static/assets/init-Gi6I4Gst.js +1 -0
  165. package/static/assets/ishikawaDiagram-FXEZZL3T-DF28a1N6.js +70 -0
  166. package/static/assets/journeyDiagram-5HDEW3XC-CB6CugYk.js +139 -0
  167. package/static/assets/kanban-definition-HUTT4EX6-ResSLF6d.js +89 -0
  168. package/static/assets/katex-C5jXJg4s.js +257 -0
  169. package/static/assets/layout-DEXfKzaS.js +1 -0
  170. package/static/assets/linear-CLO--ryk.js +1 -0
  171. package/static/assets/map-Czzmt4hB.js +1 -0
  172. package/static/assets/mindmap-definition-LN4V7U3C-BvW3EbxX.js +96 -0
  173. package/static/assets/ordinal-Cboi1Yqb.js +1 -0
  174. package/static/assets/pegDiagram-2B236MQR-BHCgqrrM.js +1 -0
  175. package/static/assets/pieDiagram-ENE6RG2P-DVmCQ5FA.js +39 -0
  176. package/static/assets/quadrantDiagram-ABIIQ3AL-B3e5KR52.js +7 -0
  177. package/static/assets/railroadDiagram-RFXS5EU6-Csv5ysLc.js +1 -0
  178. package/static/assets/requirementDiagram-TGXJPOKE-DOMVzvnB.js +84 -0
  179. package/static/assets/sankeyDiagram-HTMAVEWB-B1dcTOvy.js +40 -0
  180. package/static/assets/sequenceDiagram-DBY2YBRQ-BY414yeO.js +162 -0
  181. package/static/assets/sizeCapture-X5ZJPWSS-CuJfrfBN.js +1 -0
  182. package/static/assets/stateDiagram-2N3HPSRC-FX4rJkzV.js +1 -0
  183. package/static/assets/stateDiagram-v2-6OUMAXLB-BVufbO_H.js +1 -0
  184. package/static/assets/swimlanes-5IMT3BWC-Sfy58mmv.js +2 -0
  185. package/static/assets/swimlanesDiagram-G3AALYLV-wDCDw08H.js +8 -0
  186. package/static/assets/timeline-definition-FHXFAJF6-CmV7by8p.js +120 -0
  187. package/static/assets/vennDiagram-L72KCM5P-FqIyFZFt.js +34 -0
  188. package/static/assets/wardleyDiagram-EHGQE667-BZ06O45i.js +78 -0
  189. package/static/assets/xychartDiagram-FW5EYKEG-BOkffR06.js +7 -0
  190. 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>`.