create-astram-app 0.5.0 → 0.7.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 (83) hide show
  1. package/README.md +256 -38
  2. package/dist/add-frontend.js.map +1 -1
  3. package/dist/index.js +14 -9
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/template/README.md +51 -20
  7. package/template/frontend/README.md +21 -11
  8. package/template/frontend/index.html +16 -0
  9. package/template/frontend/package.json +1 -1
  10. package/template/frontend/src/App.jsx +82 -20
  11. package/template/frontend/src/auth/AuthContext.jsx +141 -21
  12. package/template/frontend/src/auth/routeGate.js +31 -0
  13. package/template/frontend/src/auth/routeGate.test.mjs +59 -0
  14. package/template/frontend/src/auth/sessionFlow.js +27 -0
  15. package/template/frontend/src/auth/sessionFlow.test.mjs +63 -0
  16. package/template/frontend/src/components/EntityList.jsx +1 -7
  17. package/template/frontend/src/components/PageHeader.jsx +5 -4
  18. package/template/frontend/src/components/ShareDialog.jsx +3 -5
  19. package/template/frontend/src/components/access/CollapsibleGroup.jsx +4 -1
  20. package/template/frontend/src/components/ui/alert-dialog.tsx +27 -50
  21. package/template/frontend/src/components/ui/alert.tsx +17 -23
  22. package/template/frontend/src/components/ui/avatar.tsx +8 -20
  23. package/template/frontend/src/components/ui/badge.tsx +17 -26
  24. package/template/frontend/src/components/ui/button.tsx +22 -24
  25. package/template/frontend/src/components/ui/card.tsx +19 -36
  26. package/template/frontend/src/components/ui/checkbox.tsx +7 -10
  27. package/template/frontend/src/components/ui/date-field.tsx +26 -27
  28. package/template/frontend/src/components/ui/dialog.tsx +24 -40
  29. package/template/frontend/src/components/ui/dropdown-menu.tsx +25 -54
  30. package/template/frontend/src/components/ui/input-otp.tsx +1 -4
  31. package/template/frontend/src/components/ui/label.tsx +10 -12
  32. package/template/frontend/src/components/ui/searchable-select.tsx +1 -3
  33. package/template/frontend/src/components/ui/select.tsx +31 -43
  34. package/template/frontend/src/components/ui/separator.tsx +6 -6
  35. package/template/frontend/src/components/ui/sheet.tsx +29 -38
  36. package/template/frontend/src/components/ui/skeleton.tsx +3 -3
  37. package/template/frontend/src/components/ui/sonner.tsx +7 -7
  38. package/template/frontend/src/components/ui/table.tsx +56 -70
  39. package/template/frontend/src/components/ui/tooltip.tsx +7 -11
  40. package/template/frontend/src/config/branding.js +1 -4
  41. package/template/frontend/src/hooks/useMobile.tsx +4 -6
  42. package/template/frontend/src/index.css +5 -2
  43. package/template/frontend/src/pages/Dashboard.jsx +13 -7
  44. package/template/frontend/src/pages/ForgotPassword.jsx +9 -2
  45. package/template/frontend/src/pages/Login.jsx +85 -70
  46. package/template/frontend/src/pages/ResetPassword.jsx +9 -2
  47. package/template/frontend/src/pages/access/AccessForm.jsx +49 -15
  48. package/template/frontend/src/pages/access/AccessPage.jsx +10 -6
  49. package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +9 -2
  50. package/template/frontend/src/pages/account/AccountLayout.jsx +11 -6
  51. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +2 -2
  52. package/template/frontend/src/pages/account/ReenrollPage.jsx +3 -5
  53. package/template/frontend/src/pages/account/TwoFactorPage.jsx +3 -8
  54. package/template/frontend/src/pages/companies/CompaniesPage.jsx +7 -9
  55. package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +41 -8
  56. package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +30 -7
  57. package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +12 -3
  58. package/template/frontend/src/pages/roles/RolesPage.jsx +5 -9
  59. package/template/frontend/src/pages/service-accounts/ServiceAccountsPage.jsx +77 -18
  60. package/template/frontend/src/pages/service-accounts/accessModel.js +22 -0
  61. package/template/frontend/src/pages/service-accounts/accessModel.test.mjs +28 -0
  62. package/template/frontend/src/pages/users/MembershipCard.jsx +3 -1
  63. package/template/frontend/src/pages/users/PostingRow.jsx +8 -14
  64. package/template/frontend/src/pages/users/UsersPage.jsx +7 -9
  65. package/template/frontend/src/platform/httpClient.js +18 -0
  66. package/template/frontend/src/platform/httpClient.test.mjs +60 -0
  67. package/template/frontend/src/platform/sessionStore.js +55 -13
  68. package/template/frontend/src/platform/sessionStore.test.mjs +89 -0
  69. package/template/frontend/src/platform/shieldTransport.js +6 -0
  70. package/template/frontend/src/platform/shieldTransport.test.mjs +84 -5
  71. package/template/frontend/src/shell/AppShell.jsx +10 -12
  72. package/template/frontend/src/shell/Sidebar.jsx +15 -3
  73. package/template/frontend/src/shell/nav.js +1 -3
  74. package/template/frontend/vite.config.js +19 -1
  75. package/template/server/_env.example +22 -1
  76. package/template/server/eslint.config.mjs +314 -0
  77. package/template/server/package.json +11 -11
  78. package/template/server/scripts/dev.mjs +17 -6
  79. package/template/server/scripts/sync-astram-guide.mjs +45 -19
  80. package/template/server/src/app.module.ts +16 -7
  81. package/template/server/src/health/demo.controller.ts +16 -12
  82. package/template/server/src/main.ts +23 -1
  83. package/template/server/eslint.config.js +0 -95
@@ -2,6 +2,22 @@
2
2
  <html lang="en" suppressHydrationWarning>
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
+ <!--
6
+ Content-Security-Policy (D11). The session tokens live in localStorage, so no script
7
+ may run unless it is served from this origin: no inline scripts, no eval, no third-party
8
+ script hosts. Allowed beyond 'self':
9
+ style-src 'unsafe-inline' the UI kit (sonner, Radix scroll lock) injects <style> tags
10
+ fonts.googleapis/gstatic the Inter/JetBrains Mono import in src/index.css
11
+ img-src data: blob: the MFA QR code is a data: URL
12
+ `npm run dev` relaxes script-src/connect-src for Vite's React-refresh preamble and HMR
13
+ websocket (see `devCsp` in vite.config.js); the built app gets this policy unchanged.
14
+ Add an API origin to connect-src if the frontend calls one other than same-origin /api.
15
+ frame-ancestors cannot be set in a <meta>; send it (or X-Frame-Options) as a header.
16
+ -->
17
+ <meta
18
+ http-equiv="Content-Security-Policy"
19
+ content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'"
20
+ />
5
21
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
22
  <link rel="icon" type="image/png" href="/favicon.png" />
7
23
  <title>__APP_NAME__</title>
@@ -12,7 +12,7 @@
12
12
  "test": "node --test src/**/*.test.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@astram/frontend-core": "^0.4.0",
15
+ "@astram/frontend-core": "^0.5.1",
16
16
  "@astram/sdk": "^0.2.0",
17
17
  "@radix-ui/react-alert-dialog": "^1.1.19",
18
18
  "@radix-ui/react-avatar": "^1.1.10",
@@ -10,7 +10,9 @@
10
10
  import { useEffect } from 'react';
11
11
  import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
12
12
  import { useAuth, useCan } from './auth/AuthContext';
13
+ import { Loader2 } from 'lucide-react';
13
14
  import { saveReturnPath } from './platform/returnPathStore';
15
+ import { decideRoute, REENROLL_PATH } from './auth/routeGate';
14
16
  import Login from './pages/Login';
15
17
  import ForgotPassword from './pages/ForgotPassword';
16
18
  import ResetPassword from './pages/ResetPassword';
@@ -35,9 +37,6 @@ import GrantsPage from './pages/cross-tenant/GrantsPage';
35
37
  import GrantForm from './pages/cross-tenant/GrantForm';
36
38
  import InboxPage from './pages/cross-tenant/InboxPage';
37
39
 
38
- /** The single forced-re-enroll screen path (the enforcement's frontend twin). */
39
- const REENROLL_PATH = '/account/reenroll';
40
-
41
40
  /** Only-when-signed-out (auth screens bounce authenticated users to the app). */
42
41
  function PublicOnly({ children }) {
43
42
  const { isAuthenticated } = useAuth();
@@ -61,12 +60,11 @@ function PublicOnly({ children }) {
61
60
  * means the user reaches the page and then gets 403'd by its data calls).
62
61
  */
63
62
  function RequireAuth({ children, gate }) {
64
- const { isAuthenticated, mustReenroll } = useAuth();
63
+ const { isAuthenticated, mustReenroll, authFactsReady } = useAuth();
65
64
  // Called UNCONDITIONALLY (Rules of Hooks) — `useCan(undefined)` simply resolves to
66
65
  // `false` when no gate is set, so the ungated case is handled by short-circuiting on
67
66
  // `gate &&` below rather than skipping the hook call itself.
68
67
  const canGate = useCan(gate);
69
- const allowed = !gate || canGate;
70
68
  const location = useLocation();
71
69
  // Remember where the user was so the next login returns them here. Persisted to
72
70
  // localStorage (via saveReturnPath) so it survives a full reload / browser restart —
@@ -78,32 +76,96 @@ function RequireAuth({ children, gate }) {
78
76
  if (!isAuthenticated) saveReturnPath(fullPath);
79
77
  }, [isAuthenticated, fullPath]);
80
78
 
81
- if (!isAuthenticated) {
82
- return <Navigate to="/login" replace state={{ from: fullPath }} />;
83
- }
84
- if (mustReenroll && location.pathname !== REENROLL_PATH) {
85
- return <Navigate to={REENROLL_PATH} replace />;
79
+ // A gated route waits for the auth facts (a reload or deep link renders before they load),
80
+ // then decides; a failed load resolves to an empty projection and is denied (routeGate.js).
81
+ switch (
82
+ decideRoute({
83
+ isAuthenticated,
84
+ mustReenroll,
85
+ pathname: location.pathname,
86
+ gate,
87
+ factsReady: authFactsReady,
88
+ canGate,
89
+ })
90
+ ) {
91
+ case 'login':
92
+ return <Navigate to="/login" replace state={{ from: fullPath }} />;
93
+ case 'reenroll':
94
+ return <Navigate to={REENROLL_PATH} replace />;
95
+ case 'loading':
96
+ return (
97
+ <div
98
+ role="status"
99
+ aria-live="polite"
100
+ className="flex min-h-svh items-center justify-center"
101
+ >
102
+ <Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden="true" />
103
+ <span className="sr-only">Loading…</span>
104
+ </div>
105
+ );
106
+ case 'deny':
107
+ return <Navigate to="/dashboard" replace />;
108
+ default:
109
+ return children;
86
110
  }
87
- if (gate && !allowed) {
88
- return <Navigate to="/dashboard" replace />;
89
- }
90
- return children;
91
111
  }
92
112
 
93
113
  export default function App() {
94
114
  return (
95
115
  <Routes>
96
- <Route path="/login" element={<PublicOnly><Login /></PublicOnly>} />
97
- <Route path="/forgot-password" element={<PublicOnly><ForgotPassword /></PublicOnly>} />
98
- <Route path="/reset-password" element={<PublicOnly><ResetPassword /></PublicOnly>} />
99
- <Route path="/dashboard" element={<RequireAuth><Dashboard /></RequireAuth>} />
116
+ <Route
117
+ path="/login"
118
+ element={
119
+ <PublicOnly>
120
+ <Login />
121
+ </PublicOnly>
122
+ }
123
+ />
124
+ <Route
125
+ path="/forgot-password"
126
+ element={
127
+ <PublicOnly>
128
+ <ForgotPassword />
129
+ </PublicOnly>
130
+ }
131
+ />
132
+ <Route
133
+ path="/reset-password"
134
+ element={
135
+ <PublicOnly>
136
+ <ResetPassword />
137
+ </PublicOnly>
138
+ }
139
+ />
140
+ <Route
141
+ path="/dashboard"
142
+ element={
143
+ <RequireAuth>
144
+ <Dashboard />
145
+ </RequireAuth>
146
+ }
147
+ />
100
148
  {/* Forced re-enroll — a standalone full-screen flow (NOT an account tab). While
101
149
  the session is `mustReenroll`, RequireAuth pins every other protected route
102
150
  here; this path is the one RequireAuth lets through so the screen can load. */}
103
- <Route path="/account/reenroll" element={<RequireAuth><ReenrollPage /></RequireAuth>} />
151
+ <Route
152
+ path="/account/reenroll"
153
+ element={
154
+ <RequireAuth>
155
+ <ReenrollPage />
156
+ </RequireAuth>
157
+ }
158
+ />
104
159
  {/* Account — a persistent layout with per-tab child routes (bookmarkable, back
105
160
  works). /account redirects to the Profile tab. */}
106
- <Route path="/account" element={<RequireAuth><AccountLayout /></RequireAuth>}>
161
+ <Route
162
+ path="/account"
163
+ element={
164
+ <RequireAuth>
165
+ <AccountLayout />
166
+ </RequireAuth>
167
+ }
168
+ >
107
169
  <Route index element={<Navigate to="/account/profile" replace />} />
108
170
  <Route path="profile" element={<ProfilePage />} />
109
171
  <Route path="password" element={<ChangePasswordPage />} />
@@ -20,12 +20,15 @@ import {
20
20
  createRecordPolicyService,
21
21
  createRecordShareService,
22
22
  createServiceAccountService,
23
+ createRefreshingHttp,
23
24
  isAuthenticated,
24
25
  principalOf,
25
26
  } from '@astram/frontend-core';
26
27
  import { createWebHttpClient } from '../platform/httpClient';
27
28
  import { createWebSessionStore } from '../platform/sessionStore';
28
29
  import { createWebShieldTransport } from '../platform/shieldTransport';
30
+ import { saveReturnPath } from '../platform/returnPathStore';
31
+ import { establishSession as runEstablishSession } from './sessionFlow';
29
32
 
30
33
  const AuthContext = createContext(null);
31
34
 
@@ -53,17 +56,38 @@ export function AuthProvider({ children }) {
53
56
  // token across login/refresh/logout without needing to be reconstructed (mirrors
54
57
  // `reenrollRef` above for the identical reason).
55
58
  const tokenRef = useRef(null);
59
+ const sessionIdRef = useRef(null);
60
+
61
+ // Session-lifecycle callbacks for the refreshing http client (built once, below). Late-bound
62
+ // through a ref because they need `persist`/`setProfile`/… which are declared further down.
63
+ const sessionEventsRef = useRef({ refreshed: () => {}, expired: () => {} });
56
64
 
57
65
  // Build the shared services from the injected web platform adapters (once). The
58
66
  // LOGIC is @astram/frontend-core; only the http client + storage are web-specific here.
59
67
  const services = useRef(null);
60
68
  if (!services.current) {
61
- // The http client gets an app-wide interceptor: a 403 whose body is EXACTLY
69
+ // The raw http client gets an app-wide interceptor: a 403 whose body is EXACTLY
62
70
  // `{ error: 'reenrollment_required' }` (the enforced-re-enroll gate, §4) flips the
63
71
  // app into the forced re-enroll flow. Matched narrowly so a future permission-deny
64
72
  // 403 never triggers it.
65
- const http = createWebHttpClient({ onReenrollRequired: () => markReenroll() });
73
+ const rawHttp = createWebHttpClient({ onReenrollRequired: () => markReenroll() });
66
74
  const store = createWebSessionStore();
75
+ // Token refresh (#12): every service talks through `http`, which turns a 401 on a
76
+ // request that carried a bearer token into ONE shared refresh (single-flight, and a
77
+ // cross-tab `navigator.locks` section that re-reads localStorage first so two tabs
78
+ // never spend the same refresh token), then retries the request once. A rejected
79
+ // refresh clears the session (→ login); a 503 keeps it (the server did not spend the
80
+ // token; the next request retries). The refresh call itself uses the RAW client, and
81
+ // forwards the wrapper's abort signal so a hung refresh times out (an outage, not a
82
+ // logout) instead of holding the cross-tab lock.
83
+ const refresher = createAuthService(rawHttp);
84
+ const http = createRefreshingHttp({
85
+ http: rawHttp,
86
+ store,
87
+ refresh: (refreshToken, opts) => refresher.refresh(refreshToken, opts),
88
+ onRefreshed: (next) => sessionEventsRef.current.refreshed(next),
89
+ onSessionExpired: () => sessionEventsRef.current.expired(),
90
+ });
67
91
  services.current = {
68
92
  auth: createAuthService(http),
69
93
  identity: createIdentityService(http),
@@ -83,17 +107,32 @@ export function AuthProvider({ children }) {
83
107
  // NEVER the security boundary: the server's decide() on every @Classified route
84
108
  // is the real enforcement (see shieldTransport.js's header doc). `getToken` reads
85
109
  // `tokenRef.current` (not a closed-over value) so the SAME client instance always
86
- // sees the CURRENT token across a refresh, without being rebuilt.
110
+ // sees the CURRENT token across a refresh, without being rebuilt. It uses the
111
+ // refreshing `http` too, so an expired token is refreshed before a 401 reaches it.
87
112
  shield: createShieldClient({
88
113
  transport: createWebShieldTransport({ http, getToken: () => tokenRef.current }),
89
114
  clock: systemClock,
90
115
  scheduler: { sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) },
91
116
  }),
92
117
  store,
118
+ // The auth service over the RAW client: logout goes through it so a 401 there can never
119
+ // trigger a refresh / session-expired path (which would save a stale return path).
120
+ rawAuth: refresher,
93
121
  };
94
122
  }
95
- const { auth, identity, audit, crossTenant, recordPolicy, recordShare, serviceAccounts, account, shield, store } =
96
- services.current;
123
+ const {
124
+ auth,
125
+ identity,
126
+ audit,
127
+ crossTenant,
128
+ recordPolicy,
129
+ recordShare,
130
+ serviceAccounts,
131
+ account,
132
+ shield,
133
+ store,
134
+ rawAuth,
135
+ } = services.current;
97
136
 
98
137
  const [state, setState] = useState(() => store.load()); // SessionState | null
99
138
  // The signed-in user's DISPLAY profile (name/email/company/roles) from GET /auth/me.
@@ -109,12 +148,41 @@ export function AuthProvider({ children }) {
109
148
  // Keep `tokenRef` in lockstep with the persisted session's access token so the shield
110
149
  // transport (built once, above) always reads the CURRENT token.
111
150
  tokenRef.current = state?.tokens?.accessToken ?? null;
151
+ // The current session id, read by the cross-tab listener below to tell a token rotation
152
+ // (same session) from a different sign-in.
153
+ useEffect(() => {
154
+ sessionIdRef.current = state?.session?.sessionId ?? null;
155
+ });
112
156
 
113
157
  const persist = (value) => {
114
158
  setState(value);
115
159
  store.save(value);
116
160
  };
117
161
 
162
+ // Everything derived from a session that must not outlive it.
163
+ const resetSessionViews = () => {
164
+ setProfile(null);
165
+ setAuthFacts(null); // fail-closed: no session -> no cached facts to (mis)trust
166
+ clearReenroll();
167
+ };
168
+
169
+ // Wire the refreshing http client's lifecycle callbacks (the client is built once above;
170
+ // these run only after a network round-trip, so binding them in an effect is early enough).
171
+ useEffect(() => {
172
+ sessionEventsRef.current = {
173
+ // Tokens rotated (here or in another tab): the wrapper already saved them.
174
+ refreshed: (next) => setState(next),
175
+ // Refresh rejected, or the session was signed out in another tab: drop it and
176
+ // remember where the user was. RequireAuth then redirects to /login, which returns
177
+ // here after the next sign-in.
178
+ expired: () => {
179
+ saveReturnPath(window.location.pathname + window.location.search);
180
+ persist(null);
181
+ resetSessionViews();
182
+ },
183
+ };
184
+ });
185
+
118
186
  // Fetch the display profile for the current access token. Best-effort: on any
119
187
  // non-ok (401/404/501/unreachable) we simply clear it and the UI falls back to the
120
188
  // interim identity hint — the profile is enrichment, never a gate.
@@ -153,23 +221,36 @@ export function AuthProvider({ children }) {
153
221
  setAuthFacts(null);
154
222
  return;
155
223
  }
156
- const projection = await shield.getAuthFacts(principalId);
157
- setAuthFacts(projection);
224
+ let projection;
225
+ try {
226
+ projection = await shield.getAuthFacts(principalId);
227
+ } catch {
228
+ projection = null; // getAuthFacts is documented never to throw; belt and braces
229
+ }
230
+ // Fail closed: an unusable result becomes an EMPTY projection (resolved, nothing allowed),
231
+ // so a gated route stops waiting and denies instead of hanging or allowing.
232
+ setAuthFacts(projection ?? { principalId: '', facts: [], projectedAt: Date.now() });
158
233
  };
159
234
 
160
235
  // The shared post-authentication work: persist the session + fetch the display
161
236
  // profile + the auth-facts projection. Both a plain login and an MFA-completed login
162
237
  // end here, so the session is established identically regardless of which path
163
238
  // authenticated the user.
164
- const establishSession = async (session, tokens, identityHint) => {
165
- // Keep the identity hint the user signed in with as an interim label; the real
166
- // display profile is fetched from /auth/me right after (names, never UUIDs).
167
- persist({ session, tokens, identityHint });
168
- await Promise.all([
169
- loadProfile(tokens?.accessToken),
170
- refreshAuthFacts(session?.principalId ?? null),
171
- ]);
172
- };
239
+ // Keeps the identity hint the user signed in with as an interim label; the real display
240
+ // profile is fetched from /auth/me right after (names, never UUIDs). The token ref is
241
+ // published before the fetches (see sessionFlow.js) so the first auth-facts call is authed.
242
+ const establishSession = (session, tokens, identityHint) =>
243
+ runEstablishSession(
244
+ { session, tokens, identityHint },
245
+ {
246
+ persist,
247
+ setCurrentToken: (token) => {
248
+ tokenRef.current = token;
249
+ },
250
+ loadProfile,
251
+ refreshAuthFacts,
252
+ },
253
+ );
173
254
 
174
255
  const login = async (identityHint, password) => {
175
256
  const r = await auth.login(identityHint, password);
@@ -208,7 +289,7 @@ export function AuthProvider({ children }) {
208
289
  setProfile(null);
209
290
  setAuthFacts(null); // fail-closed: no session -> no cached facts to (mis)trust
210
291
  clearReenroll(); // a fresh sign-in starts unrestricted
211
- await auth.logout(sessionId);
292
+ await rawAuth.logout(sessionId); // raw client: no refresh/expiry path on a logout
212
293
  };
213
294
 
214
295
  // On mount (e.g. a page refresh that rehydrated a persisted session), fetch the
@@ -221,6 +302,28 @@ export function AuthProvider({ children }) {
221
302
  // eslint-disable-next-line react-hooks/exhaustive-deps
222
303
  }, []);
223
304
 
305
+ // Cross-tab: another tab rotated the tokens, signed out, or signed in as someone else.
306
+ // localStorage fires `storage` only in the OTHER tabs; follow it so this tab never keeps
307
+ // using a token that is gone, and never shows the previous user's profile/facts.
308
+ useEffect(
309
+ () =>
310
+ store.subscribe((next) => {
311
+ const prevSessionId = sessionIdRef.current;
312
+ setState(next);
313
+ if (!next) {
314
+ resetSessionViews();
315
+ return;
316
+ }
317
+ if (next.session?.sessionId !== prevSessionId) {
318
+ resetSessionViews();
319
+ void loadProfile(next.tokens?.accessToken);
320
+ void refreshAuthFacts(principalOf(next));
321
+ }
322
+ }),
323
+ // eslint-disable-next-line react-hooks/exhaustive-deps
324
+ [],
325
+ );
326
+
224
327
  // Phase E slice 2 (ADR-0011 frontend twin) — POKE `getAuthFacts` periodically + on
225
328
  // window focus so the SDK's own version-staleness check (client.ts's
226
329
  // `isProjectionFresh`, driven by shieldTransport's `currentVersion()`) actually RUNS.
@@ -313,6 +416,11 @@ export function AuthProvider({ children }) {
313
416
  // resolves. Most call sites should prefer `useCan(key)` below; exposed for
314
417
  // completeness / debugging.
315
418
  authFacts,
419
+ // True once the projection has resolved for this session (a failed load resolves to an
420
+ // empty projection). A session without a principal can never load facts: treat it as
421
+ // resolved-with-nothing so gated routes deny instead of waiting forever. RequireAuth
422
+ // waits on this before deciding a gated route (see routeGate.js).
423
+ authFactsReady: authFacts !== null || (isAuthenticated(state) && !principalOf(state)),
316
424
  // `can(permissionKey)` — is the given permission key present as an ALLOWED fact in
317
425
  // the current projection? FAIL-CLOSED: an absent fact, OR no projection fetched
318
426
  // yet (`authFacts === null`), both read as `false` (can't) — never optimistically
@@ -320,12 +428,24 @@ export function AuthProvider({ children }) {
320
428
  // NEVER the security boundary — the server's decide() on every @Classified route
321
429
  // is the real enforcement (see shieldTransport.js's header doc).
322
430
  can: (permissionKey) =>
323
- Boolean(
324
- authFacts?.facts?.some((f) => f.action === permissionKey && f.allowed === true),
325
- ),
431
+ Boolean(authFacts?.facts?.some((f) => f.action === permissionKey && f.allowed === true)),
326
432
  }),
327
433
  // eslint-disable-next-line react-hooks/exhaustive-deps
328
- [state, auth, identity, audit, crossTenant, recordPolicy, recordShare, serviceAccounts, account, shield, profile, mustReenroll, authFacts],
434
+ [
435
+ state,
436
+ auth,
437
+ identity,
438
+ audit,
439
+ crossTenant,
440
+ recordPolicy,
441
+ recordShare,
442
+ serviceAccounts,
443
+ account,
444
+ shield,
445
+ profile,
446
+ mustReenroll,
447
+ authFacts,
448
+ ],
329
449
  );
330
450
 
331
451
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The protected-route decision RequireAuth renders, kept free of React so `node --test` can
3
+ * check it. UX only: the server's decide() on every @Classified route is the enforcement.
4
+ */
5
+
6
+ /** The single forced-re-enroll screen path (the enforcement's frontend twin). */
7
+ export const REENROLL_PATH = '/account/reenroll';
8
+
9
+ /**
10
+ * `factsReady` is true once the auth-facts projection has resolved for this session. A failed
11
+ * load resolves to an EMPTY projection (the SDK is fail-closed), so it is "ready" with no
12
+ * facts and a gated route is denied. `canGate` is never consulted before that.
13
+ *
14
+ * @param {{ isAuthenticated: boolean, mustReenroll: boolean, pathname: string,
15
+ * gate?: string, factsReady: boolean, canGate: boolean }} input
16
+ * @returns {'login' | 'reenroll' | 'loading' | 'deny' | 'render'}
17
+ */
18
+ export function decideRoute({
19
+ isAuthenticated,
20
+ mustReenroll,
21
+ pathname,
22
+ gate,
23
+ factsReady,
24
+ canGate,
25
+ }) {
26
+ if (!isAuthenticated) return 'login';
27
+ if (mustReenroll && pathname !== REENROLL_PATH) return 'reenroll';
28
+ if (gate && !factsReady) return 'loading';
29
+ if (gate && !canGate) return 'deny';
30
+ return 'render';
31
+ }
@@ -0,0 +1,59 @@
1
+ // routeGate.test.mjs — E2E finding: a full reload or deep link to a gated page bounced to
2
+ // /dashboard because RequireAuth decided while the auth facts were still loading. It must
3
+ // wait for them, then decide, and still fail closed.
4
+ //
5
+ // node --test src/auth/routeGate.test.mjs
6
+ import { test, describe } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { decideRoute, REENROLL_PATH } from './routeGate.js';
9
+
10
+ const base = { isAuthenticated: true, mustReenroll: false, pathname: '/users', canGate: false };
11
+
12
+ describe('decideRoute', () => {
13
+ test('gated page, facts not loaded yet (reload / deep link) → loading, not a redirect', () => {
14
+ assert.equal(
15
+ decideRoute({ ...base, gate: 'identity:user:read', factsReady: false }),
16
+ 'loading',
17
+ );
18
+ });
19
+
20
+ test('gated page, facts loaded and the key allowed → render', () => {
21
+ assert.equal(
22
+ decideRoute({ ...base, gate: 'identity:user:read', factsReady: true, canGate: true }),
23
+ 'render',
24
+ );
25
+ });
26
+
27
+ test('fail closed: facts loaded without the key (incl. a failed load = empty projection) → deny', () => {
28
+ assert.equal(
29
+ decideRoute({ ...base, gate: 'identity:user:read', factsReady: true, canGate: false }),
30
+ 'deny',
31
+ );
32
+ });
33
+
34
+ test('fail closed: canGate is never trusted before the facts are ready', () => {
35
+ assert.equal(
36
+ decideRoute({ ...base, gate: 'identity:user:read', factsReady: false, canGate: true }),
37
+ 'loading',
38
+ );
39
+ });
40
+
41
+ test('ungated page renders without waiting for facts', () => {
42
+ assert.equal(decideRoute({ ...base, factsReady: false }), 'render');
43
+ });
44
+
45
+ test('signed out → login, before any facts or gate check', () => {
46
+ assert.equal(
47
+ decideRoute({ ...base, isAuthenticated: false, gate: 'x:y:z', factsReady: false }),
48
+ 'login',
49
+ );
50
+ });
51
+
52
+ test('mustReenroll pins every other route to the re-enroll screen', () => {
53
+ assert.equal(decideRoute({ ...base, mustReenroll: true, factsReady: true }), 'reenroll');
54
+ assert.equal(
55
+ decideRoute({ ...base, mustReenroll: true, pathname: REENROLL_PATH, factsReady: true }),
56
+ 'render',
57
+ );
58
+ });
59
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Session-flow steps AuthContext runs, kept free of React so `node --test` can check them.
3
+ */
4
+
5
+ /**
6
+ * Establish a session after login / MFA: persist it, then fetch the display profile and the
7
+ * auth-facts projection.
8
+ *
9
+ * @param {{ session: object, tokens: { accessToken?: string }, identityHint?: string }} input
10
+ * @param {{
11
+ * persist: (state: object) => void,
12
+ * setCurrentToken: (token: string | null) => void,
13
+ * loadProfile: (accessToken: string | undefined) => Promise<void>,
14
+ * refreshAuthFacts: (principalId: string | null) => Promise<void>,
15
+ * }} deps
16
+ */
17
+ export async function establishSession({ session, tokens, identityHint }, deps) {
18
+ deps.persist({ session, tokens, identityHint });
19
+ // `persist` is a React setState: the token ref the shield transport reads is only synced on
20
+ // the NEXT render. Publish the new token now, or the first auth-facts fetch goes out with no
21
+ // bearer (401 → empty projection → admin nav hidden until the next poll).
22
+ deps.setCurrentToken(tokens?.accessToken ?? null);
23
+ await Promise.all([
24
+ deps.loadProfile(tokens?.accessToken),
25
+ deps.refreshAuthFacts(session?.principalId ?? null),
26
+ ]);
27
+ }
@@ -0,0 +1,63 @@
1
+ // sessionFlow.test.mjs — E2E finding: after a fresh login the first GET /identity/auth-facts
2
+ // went out with NO bearer (the shield transport reads the token from a ref that React had
3
+ // not updated yet), so the admin nav stayed hidden until the next poll.
4
+ //
5
+ // node --test src/auth/sessionFlow.test.mjs
6
+ import { test, describe } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { establishSession } from './sessionFlow.js';
9
+ import { createWebShieldTransport } from '../platform/shieldTransport.js';
10
+
11
+ const fake = (label) => `fake-${label}`;
12
+ const ACCESS = fake('access');
13
+
14
+ describe('establishSession', () => {
15
+ test('the auth-facts fetch carries the NEW access token (no 401 on first login)', async () => {
16
+ // AuthContext wiring: the transport reads `tokenRef.current`, which React only
17
+ // updates on the next render — after establishSession has already fetched.
18
+ const tokenRef = { current: null };
19
+ const seen = [];
20
+ const http = {
21
+ get: async (path, opts) => {
22
+ seen.push({ path, bearer: opts?.bearerToken ?? null });
23
+ return opts?.bearerToken === ACCESS
24
+ ? { ok: true, status: 200, data: { principalId: 'p1', facts: [], projectedAt: 1 } }
25
+ : { ok: false, status: 401, data: null };
26
+ },
27
+ };
28
+ const transport = createWebShieldTransport({ http, getToken: () => tokenRef.current });
29
+ let projection = null;
30
+
31
+ await establishSession(
32
+ { session: { principalId: 'p1' }, tokens: { accessToken: ACCESS }, identityHint: 'a' },
33
+ {
34
+ persist: () => {}, // setState: the ref is NOT updated until a later render
35
+ setCurrentToken: (t) => {
36
+ tokenRef.current = t;
37
+ },
38
+ loadProfile: async () => {},
39
+ refreshAuthFacts: async () => {
40
+ projection = await transport.fetchProjection('p1');
41
+ },
42
+ },
43
+ );
44
+
45
+ assert.deepEqual(seen, [{ path: '/identity/auth-facts', bearer: ACCESS }]);
46
+ assert.deepEqual(projection, { principalId: 'p1', facts: [], projectedAt: 1 });
47
+ });
48
+
49
+ test('persists before fetching, and passes the access token to the profile load', async () => {
50
+ const order = [];
51
+ await establishSession(
52
+ { session: { principalId: 'p1' }, tokens: { accessToken: ACCESS } },
53
+ {
54
+ persist: () => order.push('persist'),
55
+ setCurrentToken: () => order.push('token'),
56
+ loadProfile: async (t) => order.push(`profile:${t === ACCESS}`),
57
+ refreshAuthFacts: async (p) => order.push(`facts:${p}`),
58
+ },
59
+ );
60
+ assert.deepEqual(order.slice(0, 2).sort(), ['persist', 'token']);
61
+ assert.deepEqual(order.slice(2).sort(), ['facts:p1', 'profile:true']);
62
+ });
63
+ });
@@ -66,13 +66,7 @@ import {
66
66
  } from 'lucide-react';
67
67
  import { Button } from '@/components/ui/button';
68
68
  import { Input } from '@/components/ui/input';
69
- import {
70
- Card,
71
- CardContent,
72
- CardDescription,
73
- CardHeader,
74
- CardTitle,
75
- } from '@/components/ui/card';
69
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
76
70
  import { Alert, AlertDescription } from '@/components/ui/alert';
77
71
  import {
78
72
  Select,
@@ -26,7 +26,10 @@ export default function PageHeader({ title, section, icon: Icon = Shield, action
26
26
  {title}
27
27
  </h1>
28
28
  {/* Breadcrumb (relocated from the app header): Section › Page — no leading icon. */}
29
- <nav aria-label="Breadcrumb" className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
29
+ <nav
30
+ aria-label="Breadcrumb"
31
+ className="mt-1 flex items-center gap-2 text-sm text-muted-foreground"
32
+ >
30
33
  {section ? (
31
34
  <>
32
35
  <span>{section}</span>
@@ -46,9 +49,7 @@ export default function PageHeader({ title, section, icon: Icon = Shield, action
46
49
  return (
47
50
  <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
48
51
  {titleAndCrumb}
49
- {actions && (
50
- <div className="flex shrink-0 items-center gap-2 max-sm:w-full">{actions}</div>
51
- )}
52
+ {actions && <div className="flex shrink-0 items-center gap-2 max-sm:w-full">{actions}</div>}
52
53
  </div>
53
54
  );
54
55
  }