create-astram-app 0.4.0 → 0.6.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.
- package/README.md +211 -38
- package/dist/add-frontend.js.map +1 -1
- package/dist/index.js +14 -9
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/template/README.md +51 -20
- package/template/frontend/README.md +21 -11
- package/template/frontend/index.html +16 -0
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +95 -20
- package/template/frontend/src/auth/AuthContext.jsx +145 -21
- package/template/frontend/src/auth/routeGate.js +31 -0
- package/template/frontend/src/auth/routeGate.test.mjs +59 -0
- package/template/frontend/src/auth/sessionFlow.js +27 -0
- package/template/frontend/src/auth/sessionFlow.test.mjs +63 -0
- package/template/frontend/src/components/EntityList.jsx +1 -7
- package/template/frontend/src/components/MultiSelectChecklist.jsx +166 -0
- package/template/frontend/src/components/PageHeader.jsx +5 -4
- package/template/frontend/src/components/ShareDialog.jsx +3 -5
- package/template/frontend/src/components/access/CollapsibleGroup.jsx +4 -1
- package/template/frontend/src/components/ui/alert-dialog.tsx +27 -50
- package/template/frontend/src/components/ui/alert.tsx +17 -23
- package/template/frontend/src/components/ui/avatar.tsx +8 -20
- package/template/frontend/src/components/ui/badge.tsx +17 -26
- package/template/frontend/src/components/ui/button.tsx +22 -24
- package/template/frontend/src/components/ui/card.tsx +19 -36
- package/template/frontend/src/components/ui/checkbox.tsx +7 -10
- package/template/frontend/src/components/ui/date-field.tsx +26 -27
- package/template/frontend/src/components/ui/dialog.tsx +24 -40
- package/template/frontend/src/components/ui/dropdown-menu.tsx +25 -54
- package/template/frontend/src/components/ui/input-otp.tsx +1 -4
- package/template/frontend/src/components/ui/label.tsx +10 -12
- package/template/frontend/src/components/ui/searchable-select.tsx +1 -3
- package/template/frontend/src/components/ui/select.tsx +31 -43
- package/template/frontend/src/components/ui/separator.tsx +6 -6
- package/template/frontend/src/components/ui/sheet.tsx +29 -38
- package/template/frontend/src/components/ui/skeleton.tsx +3 -3
- package/template/frontend/src/components/ui/sonner.tsx +7 -7
- package/template/frontend/src/components/ui/table.tsx +56 -70
- package/template/frontend/src/components/ui/tooltip.tsx +7 -11
- package/template/frontend/src/config/branding.js +1 -4
- package/template/frontend/src/hooks/useMobile.tsx +4 -6
- package/template/frontend/src/index.css +9 -4
- package/template/frontend/src/pages/Dashboard.jsx +13 -7
- package/template/frontend/src/pages/ForgotPassword.jsx +9 -2
- package/template/frontend/src/pages/Login.jsx +85 -70
- package/template/frontend/src/pages/ResetPassword.jsx +9 -2
- package/template/frontend/src/pages/access/AccessForm.jsx +49 -15
- package/template/frontend/src/pages/access/AccessPage.jsx +10 -6
- package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +9 -2
- package/template/frontend/src/pages/account/AccountLayout.jsx +11 -6
- package/template/frontend/src/pages/account/ChangePasswordPage.jsx +2 -2
- package/template/frontend/src/pages/account/ReenrollPage.jsx +3 -5
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +3 -8
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +7 -9
- package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +41 -8
- package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +30 -7
- package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +12 -3
- package/template/frontend/src/pages/roles/RolesPage.jsx +5 -9
- package/template/frontend/src/pages/service-accounts/KeyRevealDialog.jsx +104 -0
- package/template/frontend/src/pages/service-accounts/ServiceAccountAccessDialog.jsx +231 -0
- package/template/frontend/src/pages/service-accounts/ServiceAccountActionsMenu.jsx +56 -0
- package/template/frontend/src/pages/service-accounts/ServiceAccountsPage.jsx +368 -0
- package/template/frontend/src/pages/service-accounts/accessModel.js +158 -0
- package/template/frontend/src/pages/service-accounts/accessModel.test.mjs +189 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +3 -1
- package/template/frontend/src/pages/users/PostingRow.jsx +8 -14
- package/template/frontend/src/pages/users/UsersPage.jsx +7 -9
- package/template/frontend/src/platform/httpClient.js +5 -0
- package/template/frontend/src/platform/sessionStore.js +55 -13
- package/template/frontend/src/platform/sessionStore.test.mjs +89 -0
- package/template/frontend/src/platform/shieldTransport.js +6 -0
- package/template/frontend/src/platform/shieldTransport.test.mjs +84 -5
- package/template/frontend/src/shell/AppShell.jsx +10 -12
- package/template/frontend/src/shell/Sidebar.jsx +15 -3
- package/template/frontend/src/shell/nav.js +13 -3
- package/template/frontend/vite.config.js +19 -1
- package/template/server/_env.example +22 -1
- package/template/server/eslint.config.mjs +194 -0
- package/template/server/package.json +11 -11
- package/template/server/scripts/dev.mjs +17 -6
- package/template/server/scripts/sync-astram-guide.mjs +45 -19
- package/template/server/src/app.module.ts +11 -5
- package/template/server/src/health/demo.controller.ts +16 -12
- package/template/server/src/main.ts +23 -1
- package/template/server/test/enforcement.e2e.spec.ts +73 -6
- package/template/server/eslint.config.js +0 -95
|
@@ -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';
|
|
@@ -30,13 +32,11 @@ import AccessPage from './pages/access/AccessPage';
|
|
|
30
32
|
import AccessForm from './pages/access/AccessForm';
|
|
31
33
|
import RecordPoliciesPage from './pages/access/RecordPoliciesPage';
|
|
32
34
|
import AuditPage from './pages/audit/AuditPage';
|
|
35
|
+
import ServiceAccountsPage from './pages/service-accounts/ServiceAccountsPage';
|
|
33
36
|
import GrantsPage from './pages/cross-tenant/GrantsPage';
|
|
34
37
|
import GrantForm from './pages/cross-tenant/GrantForm';
|
|
35
38
|
import InboxPage from './pages/cross-tenant/InboxPage';
|
|
36
39
|
|
|
37
|
-
/** The single forced-re-enroll screen path (the enforcement's frontend twin). */
|
|
38
|
-
const REENROLL_PATH = '/account/reenroll';
|
|
39
|
-
|
|
40
40
|
/** Only-when-signed-out (auth screens bounce authenticated users to the app). */
|
|
41
41
|
function PublicOnly({ children }) {
|
|
42
42
|
const { isAuthenticated } = useAuth();
|
|
@@ -60,12 +60,11 @@ function PublicOnly({ children }) {
|
|
|
60
60
|
* means the user reaches the page and then gets 403'd by its data calls).
|
|
61
61
|
*/
|
|
62
62
|
function RequireAuth({ children, gate }) {
|
|
63
|
-
const { isAuthenticated, mustReenroll } = useAuth();
|
|
63
|
+
const { isAuthenticated, mustReenroll, authFactsReady } = useAuth();
|
|
64
64
|
// Called UNCONDITIONALLY (Rules of Hooks) — `useCan(undefined)` simply resolves to
|
|
65
65
|
// `false` when no gate is set, so the ungated case is handled by short-circuiting on
|
|
66
66
|
// `gate &&` below rather than skipping the hook call itself.
|
|
67
67
|
const canGate = useCan(gate);
|
|
68
|
-
const allowed = !gate || canGate;
|
|
69
68
|
const location = useLocation();
|
|
70
69
|
// Remember where the user was so the next login returns them here. Persisted to
|
|
71
70
|
// localStorage (via saveReturnPath) so it survives a full reload / browser restart —
|
|
@@ -77,32 +76,96 @@ function RequireAuth({ children, gate }) {
|
|
|
77
76
|
if (!isAuthenticated) saveReturnPath(fullPath);
|
|
78
77
|
}, [isAuthenticated, fullPath]);
|
|
79
78
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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;
|
|
88
110
|
}
|
|
89
|
-
return children;
|
|
90
111
|
}
|
|
91
112
|
|
|
92
113
|
export default function App() {
|
|
93
114
|
return (
|
|
94
115
|
<Routes>
|
|
95
|
-
<Route
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
/>
|
|
99
148
|
{/* Forced re-enroll — a standalone full-screen flow (NOT an account tab). While
|
|
100
149
|
the session is `mustReenroll`, RequireAuth pins every other protected route
|
|
101
150
|
here; this path is the one RequireAuth lets through so the screen can load. */}
|
|
102
|
-
<Route
|
|
151
|
+
<Route
|
|
152
|
+
path="/account/reenroll"
|
|
153
|
+
element={
|
|
154
|
+
<RequireAuth>
|
|
155
|
+
<ReenrollPage />
|
|
156
|
+
</RequireAuth>
|
|
157
|
+
}
|
|
158
|
+
/>
|
|
103
159
|
{/* Account — a persistent layout with per-tab child routes (bookmarkable, back
|
|
104
160
|
works). /account redirects to the Profile tab. */}
|
|
105
|
-
<Route
|
|
161
|
+
<Route
|
|
162
|
+
path="/account"
|
|
163
|
+
element={
|
|
164
|
+
<RequireAuth>
|
|
165
|
+
<AccountLayout />
|
|
166
|
+
</RequireAuth>
|
|
167
|
+
}
|
|
168
|
+
>
|
|
106
169
|
<Route index element={<Navigate to="/account/profile" replace />} />
|
|
107
170
|
<Route path="profile" element={<ProfilePage />} />
|
|
108
171
|
<Route path="password" element={<ChangePasswordPage />} />
|
|
@@ -167,6 +230,18 @@ export default function App() {
|
|
|
167
230
|
</RequireAuth>
|
|
168
231
|
}
|
|
169
232
|
/>
|
|
233
|
+
{/* Service accounts (ADR-0033) — machine identities + their API keys. Gated
|
|
234
|
+
identity:service_account:read (service-account.controller.ts `GET service-accounts`);
|
|
235
|
+
create/edit is service_account:create|update, keys are service_account:manage_keys,
|
|
236
|
+
all enforced server-side per route. */}
|
|
237
|
+
<Route
|
|
238
|
+
path="/service-accounts"
|
|
239
|
+
element={
|
|
240
|
+
<RequireAuth gate="identity:service_account:read">
|
|
241
|
+
<ServiceAccountsPage />
|
|
242
|
+
</RequireAuth>
|
|
243
|
+
}
|
|
244
|
+
/>
|
|
170
245
|
{/* Cross-tenant grants — the grant book (mine + incoming) is a LAYOUT route; the
|
|
171
246
|
Offer form (`offer`) renders as a routed-modal overlay under it. The pending
|
|
172
247
|
Inbox is a sibling route. All gated `identity:role:read` (mirrors nav.js). */}
|
|
@@ -19,12 +19,16 @@ import {
|
|
|
19
19
|
createIdentityService,
|
|
20
20
|
createRecordPolicyService,
|
|
21
21
|
createRecordShareService,
|
|
22
|
+
createServiceAccountService,
|
|
23
|
+
createRefreshingHttp,
|
|
22
24
|
isAuthenticated,
|
|
23
25
|
principalOf,
|
|
24
26
|
} from '@astram/frontend-core';
|
|
25
27
|
import { createWebHttpClient } from '../platform/httpClient';
|
|
26
28
|
import { createWebSessionStore } from '../platform/sessionStore';
|
|
27
29
|
import { createWebShieldTransport } from '../platform/shieldTransport';
|
|
30
|
+
import { saveReturnPath } from '../platform/returnPathStore';
|
|
31
|
+
import { establishSession as runEstablishSession } from './sessionFlow';
|
|
28
32
|
|
|
29
33
|
const AuthContext = createContext(null);
|
|
30
34
|
|
|
@@ -52,17 +56,36 @@ export function AuthProvider({ children }) {
|
|
|
52
56
|
// token across login/refresh/logout without needing to be reconstructed (mirrors
|
|
53
57
|
// `reenrollRef` above for the identical reason).
|
|
54
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: () => {} });
|
|
55
64
|
|
|
56
65
|
// Build the shared services from the injected web platform adapters (once). The
|
|
57
66
|
// LOGIC is @astram/frontend-core; only the http client + storage are web-specific here.
|
|
58
67
|
const services = useRef(null);
|
|
59
68
|
if (!services.current) {
|
|
60
|
-
// 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
|
|
61
70
|
// `{ error: 'reenrollment_required' }` (the enforced-re-enroll gate, §4) flips the
|
|
62
71
|
// app into the forced re-enroll flow. Matched narrowly so a future permission-deny
|
|
63
72
|
// 403 never triggers it.
|
|
64
|
-
const
|
|
73
|
+
const rawHttp = createWebHttpClient({ onReenrollRequired: () => markReenroll() });
|
|
65
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.
|
|
81
|
+
const refresher = createAuthService(rawHttp);
|
|
82
|
+
const http = createRefreshingHttp({
|
|
83
|
+
http: rawHttp,
|
|
84
|
+
store,
|
|
85
|
+
refresh: (refreshToken) => refresher.refresh(refreshToken),
|
|
86
|
+
onRefreshed: (next) => sessionEventsRef.current.refreshed(next),
|
|
87
|
+
onSessionExpired: () => sessionEventsRef.current.expired(),
|
|
88
|
+
});
|
|
66
89
|
services.current = {
|
|
67
90
|
auth: createAuthService(http),
|
|
68
91
|
identity: createIdentityService(http),
|
|
@@ -73,23 +96,41 @@ export function AuthProvider({ children }) {
|
|
|
73
96
|
recordPolicy: createRecordPolicyService(http),
|
|
74
97
|
// The owner-share surface (§9.2) — share/unshare/list a record's shares (owner-only).
|
|
75
98
|
recordShare: createRecordShareService(http),
|
|
99
|
+
// Service accounts (ADR-0033) — list/create/edit machine accounts + issue/rotate/revoke
|
|
100
|
+
// their `ask_…` API keys (the raw key is revealed once on issue/rotate).
|
|
101
|
+
serviceAccounts: createServiceAccountService(http),
|
|
76
102
|
account: createAccountService(http),
|
|
77
103
|
// The @astram/sdk ShieldClient (Phase E slice 1) — a UX-convenience projection of
|
|
78
104
|
// the caller's OWN coarse grants, fetched once per session via `getAuthFacts`.
|
|
79
105
|
// NEVER the security boundary: the server's decide() on every @Classified route
|
|
80
106
|
// is the real enforcement (see shieldTransport.js's header doc). `getToken` reads
|
|
81
107
|
// `tokenRef.current` (not a closed-over value) so the SAME client instance always
|
|
82
|
-
// sees the CURRENT token across a refresh, without being rebuilt.
|
|
108
|
+
// sees the CURRENT token across a refresh, without being rebuilt. It uses the
|
|
109
|
+
// refreshing `http` too, so an expired token is refreshed before a 401 reaches it.
|
|
83
110
|
shield: createShieldClient({
|
|
84
111
|
transport: createWebShieldTransport({ http, getToken: () => tokenRef.current }),
|
|
85
112
|
clock: systemClock,
|
|
86
113
|
scheduler: { sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) },
|
|
87
114
|
}),
|
|
88
115
|
store,
|
|
116
|
+
// The auth service over the RAW client: logout goes through it so a 401 there can never
|
|
117
|
+
// trigger a refresh / session-expired path (which would save a stale return path).
|
|
118
|
+
rawAuth: refresher,
|
|
89
119
|
};
|
|
90
120
|
}
|
|
91
|
-
const {
|
|
92
|
-
|
|
121
|
+
const {
|
|
122
|
+
auth,
|
|
123
|
+
identity,
|
|
124
|
+
audit,
|
|
125
|
+
crossTenant,
|
|
126
|
+
recordPolicy,
|
|
127
|
+
recordShare,
|
|
128
|
+
serviceAccounts,
|
|
129
|
+
account,
|
|
130
|
+
shield,
|
|
131
|
+
store,
|
|
132
|
+
rawAuth,
|
|
133
|
+
} = services.current;
|
|
93
134
|
|
|
94
135
|
const [state, setState] = useState(() => store.load()); // SessionState | null
|
|
95
136
|
// The signed-in user's DISPLAY profile (name/email/company/roles) from GET /auth/me.
|
|
@@ -105,12 +146,41 @@ export function AuthProvider({ children }) {
|
|
|
105
146
|
// Keep `tokenRef` in lockstep with the persisted session's access token so the shield
|
|
106
147
|
// transport (built once, above) always reads the CURRENT token.
|
|
107
148
|
tokenRef.current = state?.tokens?.accessToken ?? null;
|
|
149
|
+
// The current session id, read by the cross-tab listener below to tell a token rotation
|
|
150
|
+
// (same session) from a different sign-in.
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
sessionIdRef.current = state?.session?.sessionId ?? null;
|
|
153
|
+
});
|
|
108
154
|
|
|
109
155
|
const persist = (value) => {
|
|
110
156
|
setState(value);
|
|
111
157
|
store.save(value);
|
|
112
158
|
};
|
|
113
159
|
|
|
160
|
+
// Everything derived from a session that must not outlive it.
|
|
161
|
+
const resetSessionViews = () => {
|
|
162
|
+
setProfile(null);
|
|
163
|
+
setAuthFacts(null); // fail-closed: no session -> no cached facts to (mis)trust
|
|
164
|
+
clearReenroll();
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Wire the refreshing http client's lifecycle callbacks (the client is built once above;
|
|
168
|
+
// these run only after a network round-trip, so binding them in an effect is early enough).
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
sessionEventsRef.current = {
|
|
171
|
+
// Tokens rotated (here or in another tab): the wrapper already saved them.
|
|
172
|
+
refreshed: (next) => setState(next),
|
|
173
|
+
// Refresh rejected, or the session was signed out in another tab: drop it and
|
|
174
|
+
// remember where the user was. RequireAuth then redirects to /login, which returns
|
|
175
|
+
// here after the next sign-in.
|
|
176
|
+
expired: () => {
|
|
177
|
+
saveReturnPath(window.location.pathname + window.location.search);
|
|
178
|
+
persist(null);
|
|
179
|
+
resetSessionViews();
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
|
|
114
184
|
// Fetch the display profile for the current access token. Best-effort: on any
|
|
115
185
|
// non-ok (401/404/501/unreachable) we simply clear it and the UI falls back to the
|
|
116
186
|
// interim identity hint — the profile is enrichment, never a gate.
|
|
@@ -149,23 +219,36 @@ export function AuthProvider({ children }) {
|
|
|
149
219
|
setAuthFacts(null);
|
|
150
220
|
return;
|
|
151
221
|
}
|
|
152
|
-
|
|
153
|
-
|
|
222
|
+
let projection;
|
|
223
|
+
try {
|
|
224
|
+
projection = await shield.getAuthFacts(principalId);
|
|
225
|
+
} catch {
|
|
226
|
+
projection = null; // getAuthFacts is documented never to throw; belt and braces
|
|
227
|
+
}
|
|
228
|
+
// Fail closed: an unusable result becomes an EMPTY projection (resolved, nothing allowed),
|
|
229
|
+
// so a gated route stops waiting and denies instead of hanging or allowing.
|
|
230
|
+
setAuthFacts(projection ?? { principalId: '', facts: [], projectedAt: Date.now() });
|
|
154
231
|
};
|
|
155
232
|
|
|
156
233
|
// The shared post-authentication work: persist the session + fetch the display
|
|
157
234
|
// profile + the auth-facts projection. Both a plain login and an MFA-completed login
|
|
158
235
|
// end here, so the session is established identically regardless of which path
|
|
159
236
|
// authenticated the user.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
237
|
+
// Keeps the identity hint the user signed in with as an interim label; the real display
|
|
238
|
+
// profile is fetched from /auth/me right after (names, never UUIDs). The token ref is
|
|
239
|
+
// published before the fetches (see sessionFlow.js) so the first auth-facts call is authed.
|
|
240
|
+
const establishSession = (session, tokens, identityHint) =>
|
|
241
|
+
runEstablishSession(
|
|
242
|
+
{ session, tokens, identityHint },
|
|
243
|
+
{
|
|
244
|
+
persist,
|
|
245
|
+
setCurrentToken: (token) => {
|
|
246
|
+
tokenRef.current = token;
|
|
247
|
+
},
|
|
248
|
+
loadProfile,
|
|
249
|
+
refreshAuthFacts,
|
|
250
|
+
},
|
|
251
|
+
);
|
|
169
252
|
|
|
170
253
|
const login = async (identityHint, password) => {
|
|
171
254
|
const r = await auth.login(identityHint, password);
|
|
@@ -204,7 +287,7 @@ export function AuthProvider({ children }) {
|
|
|
204
287
|
setProfile(null);
|
|
205
288
|
setAuthFacts(null); // fail-closed: no session -> no cached facts to (mis)trust
|
|
206
289
|
clearReenroll(); // a fresh sign-in starts unrestricted
|
|
207
|
-
await
|
|
290
|
+
await rawAuth.logout(sessionId); // raw client: no refresh/expiry path on a logout
|
|
208
291
|
};
|
|
209
292
|
|
|
210
293
|
// On mount (e.g. a page refresh that rehydrated a persisted session), fetch the
|
|
@@ -217,6 +300,28 @@ export function AuthProvider({ children }) {
|
|
|
217
300
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
218
301
|
}, []);
|
|
219
302
|
|
|
303
|
+
// Cross-tab: another tab rotated the tokens, signed out, or signed in as someone else.
|
|
304
|
+
// localStorage fires `storage` only in the OTHER tabs; follow it so this tab never keeps
|
|
305
|
+
// using a token that is gone, and never shows the previous user's profile/facts.
|
|
306
|
+
useEffect(
|
|
307
|
+
() =>
|
|
308
|
+
store.subscribe((next) => {
|
|
309
|
+
const prevSessionId = sessionIdRef.current;
|
|
310
|
+
setState(next);
|
|
311
|
+
if (!next) {
|
|
312
|
+
resetSessionViews();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (next.session?.sessionId !== prevSessionId) {
|
|
316
|
+
resetSessionViews();
|
|
317
|
+
void loadProfile(next.tokens?.accessToken);
|
|
318
|
+
void refreshAuthFacts(principalOf(next));
|
|
319
|
+
}
|
|
320
|
+
}),
|
|
321
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
322
|
+
[],
|
|
323
|
+
);
|
|
324
|
+
|
|
220
325
|
// Phase E slice 2 (ADR-0011 frontend twin) — POKE `getAuthFacts` periodically + on
|
|
221
326
|
// window focus so the SDK's own version-staleness check (client.ts's
|
|
222
327
|
// `isProjectionFresh`, driven by shieldTransport's `currentVersion()`) actually RUNS.
|
|
@@ -297,6 +402,8 @@ export function AuthProvider({ children }) {
|
|
|
297
402
|
recordPolicy,
|
|
298
403
|
// The shared owner-share service (share/unshare/list a record's shares) — same client.
|
|
299
404
|
recordShare,
|
|
405
|
+
// The shared service-accounts service (list/create/edit + issue/rotate/revoke keys).
|
|
406
|
+
serviceAccounts,
|
|
300
407
|
// The shared self-scoped account service (change-password today; profile/MFA
|
|
301
408
|
// later) — same injected web http client.
|
|
302
409
|
account,
|
|
@@ -307,6 +414,11 @@ export function AuthProvider({ children }) {
|
|
|
307
414
|
// resolves. Most call sites should prefer `useCan(key)` below; exposed for
|
|
308
415
|
// completeness / debugging.
|
|
309
416
|
authFacts,
|
|
417
|
+
// True once the projection has resolved for this session (a failed load resolves to an
|
|
418
|
+
// empty projection). A session without a principal can never load facts: treat it as
|
|
419
|
+
// resolved-with-nothing so gated routes deny instead of waiting forever. RequireAuth
|
|
420
|
+
// waits on this before deciding a gated route (see routeGate.js).
|
|
421
|
+
authFactsReady: authFacts !== null || (isAuthenticated(state) && !principalOf(state)),
|
|
310
422
|
// `can(permissionKey)` — is the given permission key present as an ALLOWED fact in
|
|
311
423
|
// the current projection? FAIL-CLOSED: an absent fact, OR no projection fetched
|
|
312
424
|
// yet (`authFacts === null`), both read as `false` (can't) — never optimistically
|
|
@@ -314,12 +426,24 @@ export function AuthProvider({ children }) {
|
|
|
314
426
|
// NEVER the security boundary — the server's decide() on every @Classified route
|
|
315
427
|
// is the real enforcement (see shieldTransport.js's header doc).
|
|
316
428
|
can: (permissionKey) =>
|
|
317
|
-
Boolean(
|
|
318
|
-
authFacts?.facts?.some((f) => f.action === permissionKey && f.allowed === true),
|
|
319
|
-
),
|
|
429
|
+
Boolean(authFacts?.facts?.some((f) => f.action === permissionKey && f.allowed === true)),
|
|
320
430
|
}),
|
|
321
431
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
322
|
-
[
|
|
432
|
+
[
|
|
433
|
+
state,
|
|
434
|
+
auth,
|
|
435
|
+
identity,
|
|
436
|
+
audit,
|
|
437
|
+
crossTenant,
|
|
438
|
+
recordPolicy,
|
|
439
|
+
recordShare,
|
|
440
|
+
serviceAccounts,
|
|
441
|
+
account,
|
|
442
|
+
shield,
|
|
443
|
+
profile,
|
|
444
|
+
mustReenroll,
|
|
445
|
+
authFacts,
|
|
446
|
+
],
|
|
323
447
|
);
|
|
324
448
|
|
|
325
449
|
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,
|