@postxl/generators 1.11.6 → 1.12.0

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 (42) hide show
  1. package/dist/backend-view/model-view-service.generator.js +4 -1
  2. package/dist/backend-view/model-view-service.generator.js.map +1 -1
  3. package/dist/backend-view/template/query.utils.test.ts +34 -0
  4. package/dist/backend-view/template/query.utils.ts +89 -0
  5. package/dist/frontend-admin/admin.generator.d.ts +9 -0
  6. package/dist/frontend-admin/admin.generator.js +19 -0
  7. package/dist/frontend-admin/admin.generator.js.map +1 -1
  8. package/dist/frontend-admin/generators/admin-sidebar.generator.js +1 -1
  9. package/dist/frontend-admin/generators/audit-log-sidebar.generator.js +107 -113
  10. package/dist/frontend-admin/generators/audit-log-sidebar.generator.js.map +1 -1
  11. package/dist/frontend-admin/generators/comment-sidebar.generator.d.ts +9 -0
  12. package/dist/frontend-admin/generators/comment-sidebar.generator.js +246 -0
  13. package/dist/frontend-admin/generators/comment-sidebar.generator.js.map +1 -0
  14. package/dist/frontend-admin/generators/detail-sidebar.generator.d.ts +9 -0
  15. package/dist/frontend-admin/generators/detail-sidebar.generator.js +148 -0
  16. package/dist/frontend-admin/generators/detail-sidebar.generator.js.map +1 -0
  17. package/dist/frontend-admin/generators/model-admin-page.generator.js +40 -6
  18. package/dist/frontend-admin/generators/model-admin-page.generator.js.map +1 -1
  19. package/dist/frontend-core/frontend.generator.js +2 -1
  20. package/dist/frontend-core/frontend.generator.js.map +1 -1
  21. package/dist/frontend-core/template/docs/LOGIN_PROCESS.d2 +372 -0
  22. package/dist/frontend-core/template/docs/LOGIN_PROCESS.md +214 -0
  23. package/dist/frontend-core/template/docs/LOGIN_PROCESS.svg +914 -0
  24. package/dist/frontend-core/template/src/components/admin/table-filter.stories.tsx +265 -0
  25. package/dist/frontend-core/template/src/components/admin/table-filter.tsx +224 -128
  26. package/dist/frontend-core/template/src/components/admin/table-view-panel.tsx +159 -0
  27. package/dist/frontend-core/template/src/context-providers/auth-context-provider.tsx +54 -43
  28. package/dist/frontend-core/template/src/hooks/use-table-view-config.tsx +249 -0
  29. package/dist/frontend-core/template/src/pages/dashboard/dashboard.page.tsx +5 -5
  30. package/dist/frontend-core/template/src/pages/error/auth-error.page.tsx +37 -0
  31. package/dist/frontend-core/template/src/pages/error/default-error.page.tsx +24 -40
  32. package/dist/frontend-core/template/src/pages/error/not-found-error.page.tsx +8 -10
  33. package/dist/frontend-core/template/src/pages/login/login.page.tsx +39 -13
  34. package/dist/frontend-core/template/src/pages/unauthorized/unauthorized.page.tsx +2 -2
  35. package/dist/frontend-core/template/src/routes/_auth-routes.tsx +21 -8
  36. package/dist/frontend-core/template/src/routes/auth-error.tsx +19 -0
  37. package/dist/frontend-core/template/vite.config.ts +5 -0
  38. package/dist/frontend-core/types/hook.d.ts +1 -1
  39. package/dist/frontend-tables/generators/model-table.generator.js +30 -5
  40. package/dist/frontend-tables/generators/model-table.generator.js.map +1 -1
  41. package/dist/types/template/query.types.ts +19 -1
  42. package/package.json +5 -5
@@ -0,0 +1,372 @@
1
+ # SteerEx Login Process Flow
2
+ direction: down
3
+
4
+ title: {
5
+ label: SteerEx Authentication Flow
6
+ near: top-center
7
+ shape: text
8
+ style: {
9
+ font-size: 24
10
+ bold: true
11
+ }
12
+ }
13
+
14
+ # ============================================
15
+ # Entry Point
16
+ # ============================================
17
+ user: {
18
+ label: User
19
+ shape: person
20
+ style.fill: "#e3f2fd"
21
+ }
22
+
23
+ # ============================================
24
+ # Login Phase
25
+ # ============================================
26
+ login_phase: Login Phase {
27
+ style.fill: "#fafafa"
28
+ style.stroke: "#bdbdbd"
29
+ style.border-radius: 8
30
+
31
+ index_route: {
32
+ label: |md
33
+ **/** (Index Route)
34
+ Entry point
35
+ |
36
+ style.fill: "#e3f2fd"
37
+ style.border-radius: 6
38
+ }
39
+
40
+ login_page: {
41
+ label: |md
42
+ **/login**
43
+ LoginPage component
44
+ |
45
+ style.fill: "#fff3e0"
46
+ style.border-radius: 6
47
+ }
48
+
49
+ index_route -> login_page: Redirect
50
+ }
51
+
52
+ # ============================================
53
+ # Keycloak Authentication
54
+ # ============================================
55
+ keycloak_phase: Keycloak Authentication {
56
+ style.fill: "#f3e5f5"
57
+ style.stroke: "#ce93d8"
58
+ style.border-radius: 8
59
+
60
+ kc_init: {
61
+ label: |md
62
+ **Keycloak Init**
63
+ check-sso mode
64
+ loginState = 'inProgress'
65
+ |
66
+ style.fill: "#e1bee7"
67
+ style.border-radius: 6
68
+ }
69
+
70
+ init_result: {
71
+ shape: diamond
72
+ label: |md
73
+ Init
74
+ result?
75
+ |
76
+ style.fill: "#fff9c4"
77
+ }
78
+
79
+ sso_check: {
80
+ shape: diamond
81
+ label: |md
82
+ SSO
83
+ Session?
84
+ |
85
+ style.fill: "#fff9c4"
86
+ }
87
+
88
+ kc_login: {
89
+ label: |md
90
+ **Keycloak Login**
91
+ User enters credentials
92
+ loginState = 'loggedOut'
93
+ |
94
+ style.fill: "#ffccbc"
95
+ style.border-radius: 6
96
+ }
97
+
98
+ silent_token: {
99
+ label: |md
100
+ **Silent Token**
101
+ Via iframe
102
+ loginState = 'loggedIn'
103
+ |
104
+ style.fill: "#c8e6c9"
105
+ style.border-radius: 6
106
+ }
107
+
108
+ kc_init -> init_result
109
+ init_result -> sso_check: Success
110
+ init_result -> errors.auth_error_init: "Failure → errorInit"
111
+ sso_check -> kc_login: No
112
+ sso_check -> silent_token: Yes
113
+ }
114
+
115
+ # ============================================
116
+ # User Data & Authorization
117
+ # ============================================
118
+ auth_phase: User Data & Authorization {
119
+ style.fill: "#e8f5e9"
120
+ style.stroke: "#81c784"
121
+ style.border-radius: 8
122
+
123
+ load_user: {
124
+ label: |md
125
+ **Load User Data**
126
+ trpc.viewer.viewer
127
+ (enabled when loggedIn)
128
+ |
129
+ style.fill: "#c8e6c9"
130
+ style.border-radius: 6
131
+ }
132
+
133
+ load_check: {
134
+ shape: diamond
135
+ label: |md
136
+ Load
137
+ success?
138
+ |
139
+ style.fill: "#fff9c4"
140
+ }
141
+
142
+ role_check: {
143
+ shape: diamond
144
+ label: |md
145
+ Has
146
+ 'unauthorized'
147
+ role?
148
+ |
149
+ style.fill: "#fff9c4"
150
+ }
151
+
152
+ load_user -> load_check
153
+ load_check -> role_check: Success
154
+ load_check -> errors.auth_error_load: "Failure → errorLoadUser"
155
+ }
156
+
157
+ # ============================================
158
+ # Error States
159
+ # ============================================
160
+ errors: Error States {
161
+ style.fill: "#ffebee"
162
+ style.stroke: "#ef9a9a"
163
+ style.border-radius: 8
164
+
165
+ auth_error_init: {
166
+ label: |md
167
+ **/auth-error**
168
+ ?type=errorInit
169
+ |
170
+ style.fill: "#ffcdd2"
171
+ style.border-radius: 6
172
+ }
173
+
174
+ auth_error_load: {
175
+ label: |md
176
+ **/auth-error**
177
+ ?type=errorLoadUser
178
+ |
179
+ style.fill: "#ffcdd2"
180
+ style.border-radius: 6
181
+ }
182
+
183
+ auth_error_token: {
184
+ label: |md
185
+ **/auth-error**
186
+ ?type=errorTokenRefresh
187
+ |
188
+ style.fill: "#ffcdd2"
189
+ style.border-radius: 6
190
+ }
191
+
192
+ unauthorized: {
193
+ label: |md
194
+ **/unauthorized**
195
+ |
196
+ style.fill: "#ffcdd2"
197
+ style.border-radius: 6
198
+ }
199
+
200
+ no_programs: {
201
+ label: |md
202
+ **Error**
203
+ No Programs
204
+ |
205
+ style.fill: "#ffcdd2"
206
+ style.border-radius: 6
207
+ }
208
+ }
209
+
210
+ # ============================================
211
+ # Protected Routes
212
+ # ============================================
213
+ protected_phase: Protected Routes {
214
+ style.fill: "#e3f2fd"
215
+ style.stroke: "#64b5f6"
216
+ style.border-radius: 8
217
+
218
+ auth_guard: {
219
+ label: |md
220
+ **/_auth-routes**
221
+ AuthGuard component
222
+ (renders when loggedIn)
223
+ |
224
+ style.fill: "#bbdefb"
225
+ style.border-radius: 6
226
+ }
227
+
228
+ load_programs: {
229
+ label: |md
230
+ **Load Programs**
231
+ useUserPrograms()
232
+ |
233
+ style.fill: "#b3e5fc"
234
+ style.border-radius: 6
235
+ }
236
+
237
+ programs_check: {
238
+ shape: diamond
239
+ label: |md
240
+ Programs
241
+ available?
242
+ |
243
+ style.fill: "#fff9c4"
244
+ }
245
+
246
+ select_program: {
247
+ label: |md
248
+ **Select Program**
249
+ localStorage or first
250
+ |
251
+ style.fill: "#b3e5fc"
252
+ style.border-radius: 6
253
+ }
254
+
255
+ set_theme: {
256
+ label: |md
257
+ **Set Theme**
258
+ program.themeKey
259
+ |
260
+ style.fill: "#b3e5fc"
261
+ style.border-radius: 6
262
+ }
263
+
264
+ auth_guard -> load_programs
265
+ load_programs -> programs_check
266
+ programs_check -> select_program: Yes
267
+ select_program -> set_theme
268
+ }
269
+
270
+ # ============================================
271
+ # Dashboard Redirects
272
+ # ============================================
273
+ dashboard_phase: Dashboard Redirects {
274
+ style.fill: "#fff8e1"
275
+ style.stroke: "#ffcc80"
276
+ style.border-radius: 8
277
+
278
+ program_index: {
279
+ label: |md
280
+ **/$programId** (index)
281
+ Redirect to deliverable
282
+ |
283
+ style.fill: "#ffe0b2"
284
+ style.border-radius: 6
285
+ }
286
+
287
+ deliverable_index: {
288
+ label: |md
289
+ **/$programId/$deliverableId** (index)
290
+ Default to 'overview' tab
291
+ |
292
+ style.fill: "#ffe0b2"
293
+ style.border-radius: 6
294
+ }
295
+ }
296
+
297
+ # ============================================
298
+ # Final Destination
299
+ # ============================================
300
+ final: {
301
+ label: |md
302
+ **/dashboard/$programId/$deliverableId/overview**
303
+
304
+ Application Ready
305
+ |
306
+ style.fill: "#81c784"
307
+ style.stroke: "#43a047"
308
+ style.stroke-width: 3
309
+ style.border-radius: 8
310
+ style.font-color: "#1b5e20"
311
+ style.bold: true
312
+ }
313
+
314
+ # ============================================
315
+ # Main Flow Connections
316
+ # ============================================
317
+ user -> login_phase.index_route: Opens app
318
+
319
+ login_phase.login_page -> keycloak_phase.kc_init
320
+
321
+ keycloak_phase.kc_login -> auth_phase.load_user: "Token received → loggedIn"
322
+ keycloak_phase.silent_token -> auth_phase.load_user
323
+
324
+ auth_phase.role_check -> errors.unauthorized: Yes
325
+ auth_phase.role_check -> protected_phase.auth_guard: No
326
+
327
+ protected_phase.programs_check -> errors.no_programs: No
328
+ protected_phase.set_theme -> dashboard_phase.program_index
329
+
330
+ dashboard_phase.program_index -> dashboard_phase.deliverable_index: Redirect
331
+ dashboard_phase.deliverable_index -> final: Redirect
332
+
333
+ # Token refresh error (runtime)
334
+ protected_phase.auth_guard -> errors.auth_error_token: "Token refresh fails → errorTokenRefresh" {
335
+ style.stroke-dash: 5
336
+ style.stroke: "#ef9a9a"
337
+ }
338
+
339
+ # ============================================
340
+ # Legend
341
+ # ============================================
342
+ legend: Legend {
343
+ style.fill: "#fafafa"
344
+ style.stroke: "#e0e0e0"
345
+ style.border-radius: 8
346
+ near: bottom-right
347
+
348
+ l1: Login {
349
+ style.fill: "#fff3e0"
350
+ style.border-radius: 4
351
+ }
352
+ l2: Keycloak {
353
+ style.fill: "#e1bee7"
354
+ style.border-radius: 4
355
+ }
356
+ l3: Auth {
357
+ style.fill: "#c8e6c9"
358
+ style.border-radius: 4
359
+ }
360
+ l4: Routes {
361
+ style.fill: "#bbdefb"
362
+ style.border-radius: 4
363
+ }
364
+ l5: Dashboard {
365
+ style.fill: "#ffe0b2"
366
+ style.border-radius: 4
367
+ }
368
+ l6: Error {
369
+ style.fill: "#ffcdd2"
370
+ style.border-radius: 4
371
+ }
372
+ }
@@ -0,0 +1,214 @@
1
+ # Login Process Documentation
2
+
3
+ This document describes the authentication flow in SteerEx, from initial page load to the dashboard view.
4
+
5
+ ## Overview
6
+
7
+ SteerEx uses **Keycloak** as the identity provider for authentication. The frontend uses the `keycloak-js` adapter with a **check-sso** strategy, which checks for an existing session without forcing a login prompt.
8
+
9
+ Authentication state is managed through a single `loginState` value that can be one of:
10
+ - `inProgress` — Keycloak initialization is underway
11
+ - `loggedIn` — User is authenticated
12
+ - `loggedOut` — User is not authenticated (triggers Keycloak login)
13
+ - `errorInit` — Keycloak initialization failed
14
+ - `errorLoadUser` — User data could not be loaded from the backend
15
+ - `errorTokenRefresh` — Token refresh failed (session expired)
16
+ - `errorNoProgram` — User has no programs or program loading failed (used by `AuthErrorPage`, not set on `loginState`)
17
+
18
+ ## Key Components
19
+
20
+ | Component | File | Purpose |
21
+ | ----------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------- |
22
+ | AuthProvider | `src/context-providers/auth-context-provider.tsx` | Manages `loginState`, Keycloak initialization, token refresh |
23
+ | LoginPage | `src/pages/login/login.page.tsx` | Handles login flow, error redirects, and post-auth navigation |
24
+ | AuthGuard | `src/routes/_auth-routes.tsx` | Protects routes, loads user programs, sets theme |
25
+ | AuthErrorPage | `src/pages/error/auth-error.page.tsx` | Displays auth error messages with "Try Again" and "Login Again" |
26
+ | Program Wrapper | `src/routes/_auth-routes/$programId.tsx` | DashboardWrapperPage layout for program routes |
27
+ | Program Index | `src/routes/_auth-routes/$programId/index.tsx` | Redirects to first deliverable of program, or shows error |
28
+ | Deliverable Index | `src/routes/_auth-routes/$programId/$deliverableId/index.lazy.tsx` | Redirects to default tab (workstream) |
29
+ | Auth Error Route | `src/routes/auth-error.tsx` | `/auth-error` route with Zod-validated `type` search param |
30
+ | Index Route | `src/routes/index.tsx` | Entry point, redirects to `/login` |
31
+
32
+ ## Authentication Flow
33
+
34
+ ### Step 1: Initial Page Load
35
+
36
+ When a user visits the application:
37
+
38
+ 1. **Index route (`/`)** immediately redirects to `/login`
39
+ 2. **AuthProvider** initializes Keycloak with `check-sso` mode, setting `loginState` to `inProgress`
40
+ 3. Keycloak checks for existing SSO session via silent iframe (`/silent-check-sso.html`)
41
+ 4. On success: `loginState` becomes `loggedIn` (if authenticated) or `loggedOut` (if not)
42
+ 5. On failure: `loginState` becomes `errorInit`
43
+
44
+ ### Step 2: Login Page Logic
45
+
46
+ The `LoginPage` component is a non-visual component that reacts to `loginState` changes via a `useEffect`:
47
+
48
+ ```
49
+ ┌───────────────────────────────────────────────────────────────────────┐
50
+ │ LoginPage Effect (runs on loginState changes) │
51
+ ├───────────────────────────────────────────────────────────────────────┤
52
+ │ 1. if (loginState === 'inProgress') → wait (show "Signing In...") │
53
+ │ 2. if (loginState === 'loggedOut') → call login() → Keycloak │
54
+ │ 3. if (loginState is an error) → navigate to /auth-error?type=... │
55
+ │ 4. if (loggedIn && !viewerData) → wait for user data to load │
56
+ │ 5. if (viewerData.roles includes 'unauthorized') → /unauthorized │
57
+ │ 6. else → sanitize redirectUrl, then redirect to: │
58
+ │ redirectUrl (if safe) → /$programId → /auth-error?errorNoProgram │
59
+ └───────────────────────────────────────────────────────────────────────┘
60
+ ```
61
+
62
+ **Redirect URL sanitization (step 6):** The `getSafeRedirectUrl()` helper rejects any URL that doesn't start with `/`, starts with `//`, or contains `://`. This prevents open-redirect attacks via crafted `redirectUrl` search params (e.g. `//evil.com`, `https://evil.com`).
63
+
64
+ **File:** [src/pages/login/login.page.tsx](../src/pages/login/login.page.tsx)
65
+
66
+ ### Step 3: User Data Loading (AuthProvider)
67
+
68
+ After Keycloak authentication succeeds (`loginState === 'loggedIn'`):
69
+
70
+ 1. **AuthProvider** fetches user data via `trpc.viewer.viewer` (enabled only when `loginState === 'loggedIn'`)
71
+ 2. Backend returns: `{ user, userRoles, programs }`
72
+ 3. User data is stored in auth context as `viewerData`
73
+ 4. If fetch fails or returns an error: `loginState` is set to `errorLoadUser`
74
+
75
+ **File:** [src/context-providers/auth-context-provider.tsx](../src/context-providers/auth-context-provider.tsx)
76
+
77
+ ### Step 4: Auth Route Guard
78
+
79
+ When accessing protected routes (`/_auth-routes/*`):
80
+
81
+ 1. **Redirect effect**: If `loginState === 'loggedOut'` → navigate to `/login?redirectUrl=...` (preserves current URL for post-login redirect)
82
+ 2. **Authentication gate**: If `loginState !== 'loggedIn'` → render nothing (`null`). This check runs before any data-dependent rendering to prevent flashing error/loading UI while the redirect effect navigates unauthenticated users to `/login`.
83
+ 3. **Program loading**: Loads user programs via `useUserPrograms()`
84
+ 4. **Error handling**: If program fetch fails (`isProgramError`) or user has no programs (`isLoaded && userPrograms.length === 0`) → render `<AuthErrorPage errorType="errorNoProgram" />` directly as a component (not a route redirect, because `isLoaded` becomes `true` two render cycles before `userPrograms` is populated)
85
+ 5. **Program selection**:
86
+ - Check localStorage for saved `programId`
87
+ - If found and valid → use it
88
+ - Otherwise → use first program from user's program list
89
+ 6. **Theme setup**: Set brand theme via `setBrand(program.themeKey)`
90
+ 7. **Render**: `<CommandPalette />` + `<Outlet />` for child routes
91
+
92
+ **File:** [src/routes/\_auth-routes.tsx](../src/routes/_auth-routes.tsx)
93
+
94
+ ### Step 5: Program & Deliverable Redirects
95
+
96
+ After the auth guard renders successfully, index routes cascade to the final view:
97
+
98
+ #### `/$programId` (index)
99
+
100
+ 1. Waits for `viewerData` and programs to load
101
+ 2. If no programs → renders `<AuthErrorPage errorType="errorNoProgram" />` directly
102
+ 3. Gets `rootDeliverableId` from `viewerData.programs[programId]`
103
+ 4. Redirects to `/$programId/$deliverableId/workstream` via `<Navigate>`
104
+
105
+ **File:** [src/routes/\_auth-routes/$programId/index.tsx](../src/routes/_auth-routes/$programId/index.tsx)
106
+
107
+ #### `/$programId/$deliverableId` (index)
108
+
109
+ 1. Gets `programId` and `deliverableId` from route params
110
+ 2. Redirects to `/$programId/$deliverableId/workstream` via `<Navigate>`
111
+
112
+ **File:** [src/routes/\_auth-routes/$programId/$deliverableId/index.lazy.tsx](../src/routes/_auth-routes/$programId/$deliverableId/index.lazy.tsx)
113
+
114
+ ## Auth Error Page
115
+
116
+ The `AuthErrorPage` component handles all authentication and program error states. It is used in two ways:
117
+
118
+ 1. **As a routed page** at `/auth-error?type=...` — the `type` search param is validated with `z.enum(authErrorTypes)`
119
+ 2. **As an inline component** rendered directly by `AuthGuard` and `ProgramRedirect` when programs can't be loaded
120
+
121
+ The page displays an error message based on the error type and provides two actions:
122
+
123
+ | Button | Action |
124
+ | ------------- | ------------------------------------------------------------------------------------------------------------ |
125
+ | **Try Again** | Full page reload via `<a href="/">` — reinitializes all auth state from scratch |
126
+ | **Login Again** | Calls `logout({ redirectUri: window.location.origin })` — destroys the Keycloak SSO session, then redirects to app root which triggers the full login flow, showing the Keycloak login page |
127
+
128
+ The `logout()` function accepts an optional `{ redirectUri }` parameter that is forwarded to `keycloak.logout()`. Passing `window.location.origin` ensures Keycloak redirects back to the app root after session destruction, rather than back to `/auth-error` (which could cause a loop).
129
+
130
+ **Files:**
131
+ - [src/pages/error/auth-error.page.tsx](../src/pages/error/auth-error.page.tsx)
132
+ - [src/routes/auth-error.tsx](../src/routes/auth-error.tsx)
133
+
134
+ ## Complete Flow Diagram
135
+
136
+ ![Login Process Flow](./LOGIN_PROCESS.svg)
137
+
138
+ ## Configuration
139
+
140
+ Authentication is configured via environment variables:
141
+
142
+ | Variable | Description |
143
+ | -------------------------------- | ---------------------------------------------- |
144
+ | `VITE_AUTH` | Enable/disable authentication (`true`/`false`) |
145
+ | `VITE_PUBLIC_KEYCLOAK_URL` | Keycloak server URL |
146
+ | `VITE_PUBLIC_KEYCLOAK_REALM` | Keycloak realm name |
147
+ | `VITE_PUBLIC_KEYCLOAK_CLIENT_ID` | Keycloak client ID |
148
+ | `VITE_PUBLIC_CHECK_LOGIN_IFRAME` | Enable/disable SSO iframe check |
149
+
150
+ ### No-Auth Mode
151
+
152
+ When `VITE_AUTH=false`:
153
+
154
+ - `loginState` is set directly to `loggedIn`
155
+ - Keycloak initialization is skipped
156
+ - Useful for local development: `pnpm run dev:noAuth`
157
+
158
+ ## Token Management
159
+
160
+ - **Token refresh**: Every 5 seconds, checks if token expires within 60 seconds (only when `loginState === 'loggedIn'`)
161
+ - **Token storage**: Bearer token stored in memory via `setAuthToken()`
162
+ - **Token header**: `Authorization: Bearer<token>` added to API requests
163
+
164
+ ## Error Handling
165
+
166
+ Errors set `loginState` to an error type. The `LoginPage` detects error states and navigates to `/auth-error?type=<errorType>`. The `errorNoProgram` type is special — it is not set on `loginState` but is used directly by components that render `<AuthErrorPage>` inline.
167
+
168
+ | Scenario | `loginState` | Behavior |
169
+ | ---------------------------- | ------------------- | --------------------------------------------------------------------- |
170
+ | Keycloak init fails | `errorInit` | LoginPage redirects to `/auth-error?type=errorInit` |
171
+ | User data fetch fails | `errorLoadUser` | LoginPage redirects to `/auth-error?type=errorLoadUser` |
172
+ | User has `unauthorized` role | (stays `loggedIn`) | LoginPage redirects to `/unauthorized` |
173
+ | Token refresh fails | `errorTokenRefresh` | LoginPage redirects to `/auth-error?type=errorTokenRefresh` |
174
+ | User has no programs | (stays `loggedIn`) | AuthGuard/ProgramRedirect renders `<AuthErrorPage>` inline |
175
+ | Program fetch fails | (stays `loggedIn`) | AuthGuard renders `<AuthErrorPage>` inline |
176
+ | No programs (from LoginPage) | (stays `loggedIn`) | LoginPage redirects to `/auth-error?type=errorNoProgram` |
177
+ | Malicious `redirectUrl` | (stays `loggedIn`) | LoginPage discards URL, falls back to program or error page |
178
+
179
+ ## Route Structure
180
+
181
+ ```
182
+ / → redirects to /login
183
+ /login → handles auth flow, error redirects, and post-auth navigation
184
+ /auth-error?type=... → displays auth error messages (errorInit, errorLoadUser, errorTokenRefresh, errorNoProgram)
185
+ /unauthorized → shown for users with 'unauthorized' role
186
+ /_auth-routes/ → protected layout (requires loginState === 'loggedIn')
187
+ │ - AuthGuard: redirects to /login if loggedOut
188
+ │ - renders null until loggedIn
189
+ │ - loads user programs, selects program, sets theme
190
+ │ - renders <AuthErrorPage> inline if no programs
191
+
192
+ ├── $programId/ → DashboardWrapperPage (layout wrapper)
193
+ │ ├── index → redirects to /$programId/$deliverableId/workstream
194
+ │ │ (gets deliverableId from viewerData.programs[programId])
195
+ │ │ or renders <AuthErrorPage> inline if no programs
196
+ │ │
197
+ │ └── $deliverableId/
198
+ │ ├── index → redirects to /$programId/$deliverableId/workstream
199
+ │ │ (defaults to 'workstream' tab)
200
+ │ │
201
+ │ └── $tab → workstream, pnl, one-off-costs, etc.
202
+
203
+ └── admin/ → admin pages
204
+ ```
205
+
206
+ ## Redirect URL Preservation
207
+
208
+ When an unauthenticated user tries to access a protected route:
209
+
210
+ 1. AuthGuard detects `loginState === 'loggedOut'` and navigates to `/login?redirectUrl=...`
211
+ 2. The current URL (path + search params) is stored as the `redirectUrl` search parameter
212
+ 3. After successful authentication, LoginPage sanitizes the `redirectUrl` (must be a relative path starting with `/`, rejecting protocol-relative `//` and absolute URLs) and redirects to the saved URL
213
+ 4. Fallback: If no `redirectUrl` (or if it was rejected as unsafe), redirects to `/$programId` (using programId from localStorage or first available program), which then cascades through index routes to the final view
214
+ 5. If user has no programs at all, redirects to `/auth-error?type=errorNoProgram`