create-astram-app 0.1.3 → 0.2.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/dist/add-frontend.js +107 -0
- package/dist/add-frontend.js.map +1 -0
- package/dist/index.js +122 -25
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/template/frontend/FRONTEND.md +92 -0
- package/template/frontend/_env.example +6 -0
- package/template/frontend/_gitignore +9 -0
- package/template/frontend/eslint.config.js +71 -0
- package/template/frontend/index.html +13 -0
- package/template/frontend/package.json +52 -0
- package/template/frontend/public/astram-logo-full.png +0 -0
- package/template/frontend/public/astram-logo-white.png +0 -0
- package/template/frontend/public/favicon.png +0 -0
- package/template/frontend/src/App.jsx +167 -0
- package/template/frontend/src/auth/AuthContext.jsx +338 -0
- package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
- package/template/frontend/src/components/EntityFormModal.jsx +154 -0
- package/template/frontend/src/components/EntityList.jsx +480 -0
- package/template/frontend/src/components/PageHeader.jsx +54 -0
- package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
- package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
- package/template/frontend/src/components/ui/alert.tsx +83 -0
- package/template/frontend/src/components/ui/avatar.tsx +51 -0
- package/template/frontend/src/components/ui/badge.tsx +56 -0
- package/template/frontend/src/components/ui/button.tsx +67 -0
- package/template/frontend/src/components/ui/card.tsx +92 -0
- package/template/frontend/src/components/ui/checkbox.tsx +30 -0
- package/template/frontend/src/components/ui/date-field.tsx +47 -0
- package/template/frontend/src/components/ui/dialog.tsx +214 -0
- package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
- package/template/frontend/src/components/ui/input-otp.tsx +81 -0
- package/template/frontend/src/components/ui/input.tsx +21 -0
- package/template/frontend/src/components/ui/label.tsx +46 -0
- package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
- package/template/frontend/src/components/ui/select.tsx +199 -0
- package/template/frontend/src/components/ui/separator.tsx +26 -0
- package/template/frontend/src/components/ui/sheet.tsx +139 -0
- package/template/frontend/src/components/ui/skeleton.tsx +13 -0
- package/template/frontend/src/components/ui/sonner.tsx +23 -0
- package/template/frontend/src/components/ui/table.tsx +181 -0
- package/template/frontend/src/components/ui/tooltip.tsx +59 -0
- package/template/frontend/src/config/branding.js +80 -0
- package/template/frontend/src/hooks/useMobile.tsx +21 -0
- package/template/frontend/src/index.css +265 -0
- package/template/frontend/src/lib/utils.js +7 -0
- package/template/frontend/src/main.jsx +27 -0
- package/template/frontend/src/pages/Dashboard.jsx +127 -0
- package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
- package/template/frontend/src/pages/Login.jsx +393 -0
- package/template/frontend/src/pages/ResetPassword.jsx +159 -0
- package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
- package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
- package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
- package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
- package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
- package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
- package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
- package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
- package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
- package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
- package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
- package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
- package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
- package/template/frontend/src/pages/users/UserForm.jsx +167 -0
- package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
- package/template/frontend/src/platform/httpClient.js +125 -0
- package/template/frontend/src/platform/returnPathStore.js +43 -0
- package/template/frontend/src/platform/sessionStore.js +29 -0
- package/template/frontend/src/platform/shieldTransport.js +172 -0
- package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
- package/template/frontend/src/shell/AppShell.jsx +237 -0
- package/template/frontend/src/shell/Sidebar.jsx +257 -0
- package/template/frontend/src/shell/nav.js +90 -0
- package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
- package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
- package/template/frontend/tsconfig.json +22 -0
- package/template/frontend/vite.config.js +30 -0
- package/template/server/_env.example +27 -15
- package/template/server/package.json +7 -4
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App — routing + auth guards.
|
|
3
|
+
*
|
|
4
|
+
* /login, /forgot-password → public auth screens (redirect to /dashboard if
|
|
5
|
+
* already signed in)
|
|
6
|
+
* /dashboard → protected (RequireAuth bounces to /login, saving the
|
|
7
|
+
* return path)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect } from 'react';
|
|
11
|
+
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
|
12
|
+
import { useAuth, useCan } from './auth/AuthContext';
|
|
13
|
+
import { saveReturnPath } from './platform/returnPathStore';
|
|
14
|
+
import Login from './pages/Login';
|
|
15
|
+
import ForgotPassword from './pages/ForgotPassword';
|
|
16
|
+
import ResetPassword from './pages/ResetPassword';
|
|
17
|
+
import Dashboard from './pages/Dashboard';
|
|
18
|
+
import AccountLayout from './pages/account/AccountLayout';
|
|
19
|
+
import ProfilePage from './pages/account/ProfilePage';
|
|
20
|
+
import ChangePasswordPage from './pages/account/ChangePasswordPage';
|
|
21
|
+
import TwoFactorPage from './pages/account/TwoFactorPage';
|
|
22
|
+
import ReenrollPage from './pages/account/ReenrollPage';
|
|
23
|
+
import CompaniesPage from './pages/companies/CompaniesPage';
|
|
24
|
+
import CompanyForm from './pages/companies/CompanyForm';
|
|
25
|
+
import RolesPage from './pages/roles/RolesPage';
|
|
26
|
+
import RoleForm from './pages/roles/RoleForm';
|
|
27
|
+
import UsersPage from './pages/users/UsersPage';
|
|
28
|
+
import UserForm from './pages/users/UserForm';
|
|
29
|
+
import AccessPage from './pages/access/AccessPage';
|
|
30
|
+
import AccessForm from './pages/access/AccessForm';
|
|
31
|
+
import AuditPage from './pages/audit/AuditPage';
|
|
32
|
+
|
|
33
|
+
/** The single forced-re-enroll screen path (the enforcement's frontend twin). */
|
|
34
|
+
const REENROLL_PATH = '/account/reenroll';
|
|
35
|
+
|
|
36
|
+
/** Only-when-signed-out (auth screens bounce authenticated users to the app). */
|
|
37
|
+
function PublicOnly({ children }) {
|
|
38
|
+
const { isAuthenticated } = useAuth();
|
|
39
|
+
return isAuthenticated ? <Navigate to="/dashboard" replace /> : children;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Only-when-signed-in (protected pages bounce to login, remembering where you were).
|
|
44
|
+
*
|
|
45
|
+
* ALSO the frontend twin of the server-side re-enroll gate: when the session is
|
|
46
|
+
* `mustReenroll` (recovery-code login), every protected route is pinned to the forced
|
|
47
|
+
* re-enroll screen — mirroring the server 403'ing every non-allowlisted route. The
|
|
48
|
+
* enforcement is server-side (this is UX, not the security boundary); it just spares
|
|
49
|
+
* the user a bounce-through-403 on each click until a device is confirmed.
|
|
50
|
+
*
|
|
51
|
+
* `gate` (Phase E slice 1, optional): a permission KEY required to render this route.
|
|
52
|
+
* When absent from the caller's auth-facts projection, redirect to `/dashboard` instead
|
|
53
|
+
* of rendering — sparing a direct-navigation bounce-through-403 the same way `mustReenroll`
|
|
54
|
+
* does above. UX ONLY: the server's decide() on the page's underlying @Classified route
|
|
55
|
+
* remains the real enforcement regardless of this check (a stale/tampered projection just
|
|
56
|
+
* means the user reaches the page and then gets 403'd by its data calls).
|
|
57
|
+
*/
|
|
58
|
+
function RequireAuth({ children, gate }) {
|
|
59
|
+
const { isAuthenticated, mustReenroll } = useAuth();
|
|
60
|
+
// Called UNCONDITIONALLY (Rules of Hooks) — `useCan(undefined)` simply resolves to
|
|
61
|
+
// `false` when no gate is set, so the ungated case is handled by short-circuiting on
|
|
62
|
+
// `gate &&` below rather than skipping the hook call itself.
|
|
63
|
+
const canGate = useCan(gate);
|
|
64
|
+
const allowed = !gate || canGate;
|
|
65
|
+
const location = useLocation();
|
|
66
|
+
// Remember where the user was so the next login returns them here. Persisted to
|
|
67
|
+
// localStorage (via saveReturnPath) so it survives a full reload / browser restart —
|
|
68
|
+
// the in-memory `from` router state below only helps within the same page session.
|
|
69
|
+
// Done in an effect (a render-time side-effect would be impure); path+search so a
|
|
70
|
+
// deep-linked list page (e.g. ?page=2) restores exactly.
|
|
71
|
+
const fullPath = location.pathname + location.search;
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!isAuthenticated) saveReturnPath(fullPath);
|
|
74
|
+
}, [isAuthenticated, fullPath]);
|
|
75
|
+
|
|
76
|
+
if (!isAuthenticated) {
|
|
77
|
+
return <Navigate to="/login" replace state={{ from: fullPath }} />;
|
|
78
|
+
}
|
|
79
|
+
if (mustReenroll && location.pathname !== REENROLL_PATH) {
|
|
80
|
+
return <Navigate to={REENROLL_PATH} replace />;
|
|
81
|
+
}
|
|
82
|
+
if (gate && !allowed) {
|
|
83
|
+
return <Navigate to="/dashboard" replace />;
|
|
84
|
+
}
|
|
85
|
+
return children;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default function App() {
|
|
89
|
+
return (
|
|
90
|
+
<Routes>
|
|
91
|
+
<Route path="/login" element={<PublicOnly><Login /></PublicOnly>} />
|
|
92
|
+
<Route path="/forgot-password" element={<PublicOnly><ForgotPassword /></PublicOnly>} />
|
|
93
|
+
<Route path="/reset-password" element={<PublicOnly><ResetPassword /></PublicOnly>} />
|
|
94
|
+
<Route path="/dashboard" element={<RequireAuth><Dashboard /></RequireAuth>} />
|
|
95
|
+
{/* Forced re-enroll — a standalone full-screen flow (NOT an account tab). While
|
|
96
|
+
the session is `mustReenroll`, RequireAuth pins every other protected route
|
|
97
|
+
here; this path is the one RequireAuth lets through so the screen can load. */}
|
|
98
|
+
<Route path="/account/reenroll" element={<RequireAuth><ReenrollPage /></RequireAuth>} />
|
|
99
|
+
{/* Account — a persistent layout with per-tab child routes (bookmarkable, back
|
|
100
|
+
works). /account redirects to the Profile tab. */}
|
|
101
|
+
<Route path="/account" element={<RequireAuth><AccountLayout /></RequireAuth>}>
|
|
102
|
+
<Route index element={<Navigate to="/account/profile" replace />} />
|
|
103
|
+
<Route path="profile" element={<ProfilePage />} />
|
|
104
|
+
<Route path="password" element={<ChangePasswordPage />} />
|
|
105
|
+
<Route path="2fa" element={<TwoFactorPage />} />
|
|
106
|
+
</Route>
|
|
107
|
+
{/* Identity management — each list is a LAYOUT route; add/edit are child routes
|
|
108
|
+
that render as a routed-modal overlay (bookmarkable, back button closes).
|
|
109
|
+
`gate` (Phase E slice 1) mirrors the Sidebar's nav gating — VERIFIED against
|
|
110
|
+
each page's actual @Classified list route (see shell/nav.js's header doc). */}
|
|
111
|
+
<Route
|
|
112
|
+
path="/companies"
|
|
113
|
+
element={
|
|
114
|
+
<RequireAuth gate="identity:company:read">
|
|
115
|
+
<CompaniesPage />
|
|
116
|
+
</RequireAuth>
|
|
117
|
+
}
|
|
118
|
+
>
|
|
119
|
+
<Route path="add" element={<CompanyForm />} />
|
|
120
|
+
<Route path="edit/:uuid" element={<CompanyForm />} />
|
|
121
|
+
</Route>
|
|
122
|
+
<Route
|
|
123
|
+
path="/roles"
|
|
124
|
+
element={
|
|
125
|
+
<RequireAuth gate="identity:role:read">
|
|
126
|
+
<RolesPage />
|
|
127
|
+
</RequireAuth>
|
|
128
|
+
}
|
|
129
|
+
>
|
|
130
|
+
<Route path="add" element={<RoleForm />} />
|
|
131
|
+
<Route path="edit/:uuid" element={<RoleForm />} />
|
|
132
|
+
</Route>
|
|
133
|
+
<Route
|
|
134
|
+
path="/users"
|
|
135
|
+
element={
|
|
136
|
+
<RequireAuth gate="identity:user:read">
|
|
137
|
+
<UsersPage />
|
|
138
|
+
</RequireAuth>
|
|
139
|
+
}
|
|
140
|
+
>
|
|
141
|
+
<Route path="add" element={<UserForm />} />
|
|
142
|
+
<Route path="edit/:uuid" element={<UserForm />} />
|
|
143
|
+
</Route>
|
|
144
|
+
<Route
|
|
145
|
+
path="/access"
|
|
146
|
+
element={
|
|
147
|
+
<RequireAuth gate="identity:role:read">
|
|
148
|
+
<AccessPage />
|
|
149
|
+
</RequireAuth>
|
|
150
|
+
}
|
|
151
|
+
>
|
|
152
|
+
<Route path="add" element={<AccessForm />} />
|
|
153
|
+
<Route path="edit/:uuid" element={<AccessForm />} />
|
|
154
|
+
</Route>
|
|
155
|
+
{/* Audit — read-only, no child add/edit routes (audit events are immutable). */}
|
|
156
|
+
<Route
|
|
157
|
+
path="/audit"
|
|
158
|
+
element={
|
|
159
|
+
<RequireAuth gate="identity:role:read">
|
|
160
|
+
<AuditPage />
|
|
161
|
+
</RequireAuth>
|
|
162
|
+
}
|
|
163
|
+
/>
|
|
164
|
+
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
|
165
|
+
</Routes>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthContext — the web app's session state + wiring of @astram/frontend-core's
|
|
3
|
+
* platform-agnostic auth logic to the WEB platform adapters (fetch + localStorage).
|
|
4
|
+
*
|
|
5
|
+
* The LOGIC (how login/refresh/logout talk to the framework API) lives in
|
|
6
|
+
* @astram/frontend-core — this file only injects the browser's fetch client +
|
|
7
|
+
* localStorage, holds the React state, and exposes `useAuth()` to the views. A future
|
|
8
|
+
* mobile app has its own AuthContext that injects RN adapters into the SAME shared
|
|
9
|
+
* services.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
13
|
+
import { createShieldClient, systemClock } from '@astram/sdk';
|
|
14
|
+
import {
|
|
15
|
+
createAccountService,
|
|
16
|
+
createAuditService,
|
|
17
|
+
createAuthService,
|
|
18
|
+
createIdentityService,
|
|
19
|
+
isAuthenticated,
|
|
20
|
+
principalOf,
|
|
21
|
+
} from '@astram/frontend-core';
|
|
22
|
+
import { createWebHttpClient } from '../platform/httpClient';
|
|
23
|
+
import { createWebSessionStore } from '../platform/sessionStore';
|
|
24
|
+
import { createWebShieldTransport } from '../platform/shieldTransport';
|
|
25
|
+
|
|
26
|
+
const AuthContext = createContext(null);
|
|
27
|
+
|
|
28
|
+
export function AuthProvider({ children }) {
|
|
29
|
+
// Whether the current session is server-side-restricted to re-securing the account
|
|
30
|
+
// (recovery-code login → `mustReenroll`). Held in a ref so the http-client interceptor
|
|
31
|
+
// (built once, below) can flip it without being re-created; mirrored into React state
|
|
32
|
+
// so the router re-renders. Until a fresh device is confirmed, the app forces the
|
|
33
|
+
// re-enroll screen (§4). Set on either (a) a `mfaRecoveryUsed:true` login result, or
|
|
34
|
+
// (b) the app-wide 403 `reenrollment_required` interceptor catching a blocked request.
|
|
35
|
+
const [mustReenroll, setMustReenroll] = useState(false);
|
|
36
|
+
const reenrollRef = useRef(false);
|
|
37
|
+
const markReenroll = () => {
|
|
38
|
+
if (reenrollRef.current) return; // already flagged — avoid redundant re-renders
|
|
39
|
+
reenrollRef.current = true;
|
|
40
|
+
setMustReenroll(true);
|
|
41
|
+
};
|
|
42
|
+
const clearReenroll = () => {
|
|
43
|
+
reenrollRef.current = false;
|
|
44
|
+
setMustReenroll(false);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// The CURRENT access token, read by the shield transport's `getToken` getter. A ref
|
|
48
|
+
// (not a captured value) so the transport — built ONCE below — always reads the LIVE
|
|
49
|
+
// token across login/refresh/logout without needing to be reconstructed (mirrors
|
|
50
|
+
// `reenrollRef` above for the identical reason).
|
|
51
|
+
const tokenRef = useRef(null);
|
|
52
|
+
|
|
53
|
+
// Build the shared services from the injected web platform adapters (once). The
|
|
54
|
+
// LOGIC is @astram/frontend-core; only the http client + storage are web-specific here.
|
|
55
|
+
const services = useRef(null);
|
|
56
|
+
if (!services.current) {
|
|
57
|
+
// The http client gets an app-wide interceptor: a 403 whose body is EXACTLY
|
|
58
|
+
// `{ error: 'reenrollment_required' }` (the enforced-re-enroll gate, §4) flips the
|
|
59
|
+
// app into the forced re-enroll flow. Matched narrowly so a future permission-deny
|
|
60
|
+
// 403 never triggers it.
|
|
61
|
+
const http = createWebHttpClient({ onReenrollRequired: () => markReenroll() });
|
|
62
|
+
const store = createWebSessionStore();
|
|
63
|
+
services.current = {
|
|
64
|
+
auth: createAuthService(http),
|
|
65
|
+
identity: createIdentityService(http),
|
|
66
|
+
audit: createAuditService(http),
|
|
67
|
+
account: createAccountService(http),
|
|
68
|
+
// The @astram/sdk ShieldClient (Phase E slice 1) — a UX-convenience projection of
|
|
69
|
+
// the caller's OWN coarse grants, fetched once per session via `getAuthFacts`.
|
|
70
|
+
// NEVER the security boundary: the server's decide() on every @Classified route
|
|
71
|
+
// is the real enforcement (see shieldTransport.js's header doc). `getToken` reads
|
|
72
|
+
// `tokenRef.current` (not a closed-over value) so the SAME client instance always
|
|
73
|
+
// sees the CURRENT token across a refresh, without being rebuilt.
|
|
74
|
+
shield: createShieldClient({
|
|
75
|
+
transport: createWebShieldTransport({ http, getToken: () => tokenRef.current }),
|
|
76
|
+
clock: systemClock,
|
|
77
|
+
scheduler: { sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) },
|
|
78
|
+
}),
|
|
79
|
+
store,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const { auth, identity, audit, account, shield, store } = services.current;
|
|
83
|
+
|
|
84
|
+
const [state, setState] = useState(() => store.load()); // SessionState | null
|
|
85
|
+
// The signed-in user's DISPLAY profile (name/email/company/roles) from GET /auth/me.
|
|
86
|
+
// Resolved server-side to NAMES (never UUIDs). null until fetched / when unavailable.
|
|
87
|
+
const [profile, setProfile] = useState(null);
|
|
88
|
+
|
|
89
|
+
// The caller's authorization-fact projection (Phase E slice 1) — `null` until fetched
|
|
90
|
+
// at least once for this session. Fail-closed: `useCan` treats `null` (not yet known)
|
|
91
|
+
// exactly like "the fact is absent" — hide/disable until a projection is in hand,
|
|
92
|
+
// never optimistically show.
|
|
93
|
+
const [authFacts, setAuthFacts] = useState(null);
|
|
94
|
+
|
|
95
|
+
// Keep `tokenRef` in lockstep with the persisted session's access token so the shield
|
|
96
|
+
// transport (built once, above) always reads the CURRENT token.
|
|
97
|
+
tokenRef.current = state?.tokens?.accessToken ?? null;
|
|
98
|
+
|
|
99
|
+
const persist = (value) => {
|
|
100
|
+
setState(value);
|
|
101
|
+
store.save(value);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Fetch the display profile for the current access token. Best-effort: on any
|
|
105
|
+
// non-ok (401/404/501/unreachable) we simply clear it and the UI falls back to the
|
|
106
|
+
// interim identity hint — the profile is enrichment, never a gate.
|
|
107
|
+
const loadProfile = async (accessToken) => {
|
|
108
|
+
if (!accessToken) {
|
|
109
|
+
setProfile(null);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const r = await auth.me(accessToken);
|
|
113
|
+
setProfile(r.ok ? r.profile : null);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Re-fetch the display profile for the CURRENT session's token. Exposed via
|
|
117
|
+
// useAuth() so a self-service edit (PATCH /account/profile) can refresh the
|
|
118
|
+
// header/user card in place after a successful save. Best-effort like loadProfile.
|
|
119
|
+
const refreshProfile = async () => {
|
|
120
|
+
await loadProfile(state?.tokens?.accessToken);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Fetch the caller's OWN authorization-fact projection. Called on login/mount, AND
|
|
124
|
+
// (Phase E slice 2 — ADR-0011 frontend twin) repeatedly via the focus + interval
|
|
125
|
+
// effect below, so a mid-session permission-version BUMP is picked up without a
|
|
126
|
+
// manual reload: `shield.getAuthFacts` re-checks staleness (via the transport's
|
|
127
|
+
// `currentVersion()` probe) on EVERY call and only refetches the full projection when
|
|
128
|
+
// the version actually moved — a poll that finds nothing stale costs one cheap
|
|
129
|
+
// version-probe request. `shield.getAuthFacts` is ITSELF fail-closed by construction:
|
|
130
|
+
// no transport / a terminal deny / a transient outage all resolve to a projection with
|
|
131
|
+
// NO allowed facts, or the last-good one held stale (see shieldTransport.js +
|
|
132
|
+
// client.ts's `fetchAndCacheProjection`) — never throws, so this never needs a
|
|
133
|
+
// try/catch to stay safe, and (no-flicker) `authFacts` is only ever set to a
|
|
134
|
+
// RESOLVED projection, never reset to `null` mid-poll. `principalId` absent (no
|
|
135
|
+
// session yet) → skip, leaving `authFacts` at its fail-closed `null` (useCan
|
|
136
|
+
// hides/denies until known).
|
|
137
|
+
const refreshAuthFacts = async (principalId) => {
|
|
138
|
+
if (!principalId) {
|
|
139
|
+
setAuthFacts(null);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const projection = await shield.getAuthFacts(principalId);
|
|
143
|
+
setAuthFacts(projection);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// The shared post-authentication work: persist the session + fetch the display
|
|
147
|
+
// profile + the auth-facts projection. Both a plain login and an MFA-completed login
|
|
148
|
+
// end here, so the session is established identically regardless of which path
|
|
149
|
+
// authenticated the user.
|
|
150
|
+
const establishSession = async (session, tokens, identityHint) => {
|
|
151
|
+
// Keep the identity hint the user signed in with as an interim label; the real
|
|
152
|
+
// display profile is fetched from /auth/me right after (names, never UUIDs).
|
|
153
|
+
persist({ session, tokens, identityHint });
|
|
154
|
+
await Promise.all([
|
|
155
|
+
loadProfile(tokens?.accessToken),
|
|
156
|
+
refreshAuthFacts(session?.principalId ?? null),
|
|
157
|
+
]);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const login = async (identityHint, password) => {
|
|
161
|
+
const r = await auth.login(identityHint, password);
|
|
162
|
+
if (r.ok === true) {
|
|
163
|
+
await establishSession(r.session, r.tokens, identityHint);
|
|
164
|
+
}
|
|
165
|
+
return r; // { ok:true } | { ok:'mfa', challengeToken } | { ok:false, error }
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Complete a pending MFA challenge (the second step after login returned
|
|
169
|
+
// { ok:'mfa', challengeToken }). `factor` is the second-factor kind ('totp' by
|
|
170
|
+
// default, or 'recovery_code' when the user signs in with a saved recovery code).
|
|
171
|
+
// `identityHint` is threaded through from the login step so the same interim label
|
|
172
|
+
// is kept when the session is established. On success the session is established
|
|
173
|
+
// exactly as a plain login would.
|
|
174
|
+
//
|
|
175
|
+
// NOTE (bug fix): the caller used to pass `identityHint` in the `factor` slot, so a
|
|
176
|
+
// TOTP verify sent `factor: <username>` — the recovery-code work needs a real
|
|
177
|
+
// `factor`, so it is now the third positional arg (default 'totp') and identityHint
|
|
178
|
+
// is fourth. When the login was completed with a recovery code the server returns
|
|
179
|
+
// `mfaRecoveryUsed:true`; we surface it AND flip the app into the forced re-enroll
|
|
180
|
+
// flow immediately (the enforcement gate would 403 the very next request anyway).
|
|
181
|
+
const completeMfa = async (challengeToken, code, factor = 'totp', identityHint) => {
|
|
182
|
+
const r = await auth.completeMfa(challengeToken, code, factor);
|
|
183
|
+
if (r.ok === true) {
|
|
184
|
+
await establishSession(r.session, r.tokens, identityHint);
|
|
185
|
+
if (r.mfaRecoveryUsed === true) markReenroll();
|
|
186
|
+
return { ok: true, mfaRecoveryUsed: r.mfaRecoveryUsed === true };
|
|
187
|
+
}
|
|
188
|
+
return r; // { ok:false, error }
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const logout = async () => {
|
|
192
|
+
const sessionId = state?.session?.sessionId;
|
|
193
|
+
persist(null); // clear locally first — the UI must never wait on the server
|
|
194
|
+
setProfile(null);
|
|
195
|
+
setAuthFacts(null); // fail-closed: no session -> no cached facts to (mis)trust
|
|
196
|
+
clearReenroll(); // a fresh sign-in starts unrestricted
|
|
197
|
+
await auth.logout(sessionId);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// On mount (e.g. a page refresh that rehydrated a persisted session), fetch the
|
|
201
|
+
// profile + the auth-facts projection once for the stored session. Runs only when we
|
|
202
|
+
// have a token but haven't fetched yet (profile === null / authFacts === null).
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
const token = state?.tokens?.accessToken;
|
|
205
|
+
if (token && profile === null) void loadProfile(token);
|
|
206
|
+
if (token && authFacts === null) void refreshAuthFacts(principalOf(state));
|
|
207
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
208
|
+
}, []);
|
|
209
|
+
|
|
210
|
+
// Phase E slice 2 (ADR-0011 frontend twin) — POKE `getAuthFacts` periodically + on
|
|
211
|
+
// window focus so the SDK's own version-staleness check (client.ts's
|
|
212
|
+
// `isProjectionFresh`, driven by shieldTransport's `currentVersion()`) actually RUNS.
|
|
213
|
+
// The SDK does NOT poll itself; without a repeated caller a mid-session permission
|
|
214
|
+
// change (or a version bump from any cause) would sit unnoticed until the next
|
|
215
|
+
// full page reload. This does NOT replace the login/mount fetches above — it adds a
|
|
216
|
+
// focus listener + a light interval on top, and fires regardless of `authFacts`
|
|
217
|
+
// being null-or-not (the whole point is to re-check an already-fetched, possibly now
|
|
218
|
+
// STALE projection — the mount effect's `authFacts === null` guard only covers the
|
|
219
|
+
// "never fetched yet" case).
|
|
220
|
+
//
|
|
221
|
+
// No-flicker: `refreshAuthFacts` only calls `setAuthFacts` on a resolved projection —
|
|
222
|
+
// it never resets to `null` first. `shield.getAuthFacts` is itself fail-closed and
|
|
223
|
+
// NEVER throws (see the refreshAuthFacts doc above): a transient version-probe/fetch
|
|
224
|
+
// failure resolves the last-good (now-marked-stale-internally) or empty projection,
|
|
225
|
+
// never a rejected promise — so there is nothing here to catch, and the UI never
|
|
226
|
+
// blanks out mid-poll.
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
const token = state?.tokens?.accessToken;
|
|
229
|
+
if (!token) return undefined; // not authenticated — nothing to keep fresh
|
|
230
|
+
|
|
231
|
+
const principalId = principalOf(state);
|
|
232
|
+
const poke = () => {
|
|
233
|
+
if (principalId) void refreshAuthFacts(principalId);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const onFocus = () => poke();
|
|
237
|
+
window.addEventListener('focus', onFocus);
|
|
238
|
+
|
|
239
|
+
const POLL_MS = 60_000; // cheap: each tick is one version-probe request unless stale
|
|
240
|
+
const intervalId = setInterval(poke, POLL_MS);
|
|
241
|
+
|
|
242
|
+
return () => {
|
|
243
|
+
window.removeEventListener('focus', onFocus);
|
|
244
|
+
clearInterval(intervalId);
|
|
245
|
+
};
|
|
246
|
+
// Re-armed whenever the token/principal changes (login/logout/refresh) so a stale
|
|
247
|
+
// closure never keeps polling for a signed-out or since-replaced session.
|
|
248
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
249
|
+
}, [state?.tokens?.accessToken, state?.session?.principalId]);
|
|
250
|
+
|
|
251
|
+
const value = useMemo(
|
|
252
|
+
() => ({
|
|
253
|
+
session: state?.session ?? null,
|
|
254
|
+
tokens: state?.tokens ?? null,
|
|
255
|
+
// The human-readable label the user signed in with (never the raw UUID).
|
|
256
|
+
identityHint: state?.identityHint ?? null,
|
|
257
|
+
// The signed-in user's display profile (name/email/company/roles) or null.
|
|
258
|
+
profile,
|
|
259
|
+
// Re-fetch the display profile for the current token (used after a self-service
|
|
260
|
+
// profile edit so the header/user card reflects the new name/email at once).
|
|
261
|
+
refreshProfile,
|
|
262
|
+
isAuthenticated: isAuthenticated(state),
|
|
263
|
+
// True while the current session is restricted to re-securing the account
|
|
264
|
+
// (recovery-code login). The router forces the re-enroll screen until cleared.
|
|
265
|
+
mustReenroll,
|
|
266
|
+
// Clear the restriction — called by the re-enroll screen after a device confirm
|
|
267
|
+
// succeeds (the server has already flipped the bound claim; this releases the UI).
|
|
268
|
+
clearReenroll,
|
|
269
|
+
login,
|
|
270
|
+
// Complete a pending MFA challenge (second factor) — same session-establish
|
|
271
|
+
// path as login. Consumed by the Login page's MFA code step.
|
|
272
|
+
completeMfa,
|
|
273
|
+
logout,
|
|
274
|
+
// The shared auth service — exposed so the PUBLIC pre-auth pages
|
|
275
|
+
// (ForgotPassword / ResetPassword) can call requestReset / resetPassword
|
|
276
|
+
// through the SAME injected web http client (no bespoke client per page).
|
|
277
|
+
auth,
|
|
278
|
+
// The shared identity service (for protected calls) — same injected client.
|
|
279
|
+
identity,
|
|
280
|
+
// The shared audit-log service (audit viewer) — same injected web http client.
|
|
281
|
+
audit,
|
|
282
|
+
// The shared self-scoped account service (change-password today; profile/MFA
|
|
283
|
+
// later) — same injected web http client.
|
|
284
|
+
account,
|
|
285
|
+
// The @astram/sdk ShieldClient (Phase E slice 1) — exposed for any page that needs
|
|
286
|
+
// direct access (e.g. a forced re-fetch). Most call sites should prefer `useCan`.
|
|
287
|
+
shield,
|
|
288
|
+
// The caller's raw auth-facts projection, or `null` before the first fetch
|
|
289
|
+
// resolves. Most call sites should prefer `useCan(key)` below; exposed for
|
|
290
|
+
// completeness / debugging.
|
|
291
|
+
authFacts,
|
|
292
|
+
// `can(permissionKey)` — is the given permission key present as an ALLOWED fact in
|
|
293
|
+
// the current projection? FAIL-CLOSED: an absent fact, OR no projection fetched
|
|
294
|
+
// yet (`authFacts === null`), both read as `false` (can't) — never optimistically
|
|
295
|
+
// `true`. This is a UX convenience only: hides/disables nav + page gates; it is
|
|
296
|
+
// NEVER the security boundary — the server's decide() on every @Classified route
|
|
297
|
+
// is the real enforcement (see shieldTransport.js's header doc).
|
|
298
|
+
can: (permissionKey) =>
|
|
299
|
+
Boolean(
|
|
300
|
+
authFacts?.facts?.some((f) => f.action === permissionKey && f.allowed === true),
|
|
301
|
+
),
|
|
302
|
+
}),
|
|
303
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
304
|
+
[state, auth, identity, audit, account, shield, profile, mustReenroll, authFacts],
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Colocating the consumer hook with its Provider is the standard React pattern;
|
|
311
|
+
// the fast-refresh "only export components" rule is advisory here (moving useAuth
|
|
312
|
+
// to its own file would churn 19 import sites for no runtime benefit).
|
|
313
|
+
// eslint-disable-next-line react-refresh/only-export-components
|
|
314
|
+
export function useAuth() {
|
|
315
|
+
const ctx = useContext(AuthContext);
|
|
316
|
+
if (!ctx) throw new Error('useAuth must be used within <AuthProvider>');
|
|
317
|
+
return ctx;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* `useCan(permissionKey)` — Phase E slice 1: is the given permission key an ALLOWED
|
|
322
|
+
* fact in the caller's current auth-facts projection? Reads `can` off `useAuth()`, so
|
|
323
|
+
* it always sees the LATEST projection (re-renders when it changes).
|
|
324
|
+
*
|
|
325
|
+
* FAIL-CLOSED: `false` whenever the key is absent from the projection OR no projection
|
|
326
|
+
* has been fetched yet (loading) — never optimistically `true`. This is a UX
|
|
327
|
+
* convenience ONLY (hide/disable nav + page gates); it is NEVER the security boundary —
|
|
328
|
+
* a tampered/stale answer here just means the user sees an action they can't use, and
|
|
329
|
+
* the server's decide() on the underlying @Classified route still 403s it.
|
|
330
|
+
*
|
|
331
|
+
* @param {string} permissionKey
|
|
332
|
+
* @returns {boolean}
|
|
333
|
+
*/
|
|
334
|
+
// eslint-disable-next-line react-refresh/only-export-components
|
|
335
|
+
export function useCan(permissionKey) {
|
|
336
|
+
const { can } = useAuth();
|
|
337
|
+
return can(permissionKey);
|
|
338
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConfirmDialog — a small, themed confirm gate for SENSITIVE / irreversible actions
|
|
3
|
+
* (disable 2FA, revoke, delete, …). Built on the shadcn AlertDialog primitive
|
|
4
|
+
* (`alert-dialog.tsx`) — the semantic confirm role — so it inherits the app's dialog
|
|
5
|
+
* theming in both light and dark and announces as an alert dialog to assistive tech.
|
|
6
|
+
* The action fires ONLY from the confirm handler — never on the initial click.
|
|
7
|
+
*
|
|
8
|
+
* Controlled: the parent owns `open` + the busy state. While `busy` is true both
|
|
9
|
+
* buttons are disabled so a slow request can't be double-fired or cancelled mid-flight.
|
|
10
|
+
* `children` renders BETWEEN the message and the buttons (e.g. a required code input),
|
|
11
|
+
* so the confirm can gather one last piece of input before committing.
|
|
12
|
+
*/
|
|
13
|
+
import { Loader2 } from 'lucide-react';
|
|
14
|
+
import { cn } from '@/lib/utils';
|
|
15
|
+
import { buttonVariants } from '@/components/ui/button';
|
|
16
|
+
import {
|
|
17
|
+
AlertDialog,
|
|
18
|
+
AlertDialogCancel,
|
|
19
|
+
AlertDialogContent,
|
|
20
|
+
AlertDialogDescription,
|
|
21
|
+
AlertDialogFooter,
|
|
22
|
+
AlertDialogHeader,
|
|
23
|
+
AlertDialogTitle,
|
|
24
|
+
} from '@/components/ui/alert-dialog';
|
|
25
|
+
|
|
26
|
+
export default function ConfirmDialog({
|
|
27
|
+
open,
|
|
28
|
+
title = 'Are you sure?',
|
|
29
|
+
message,
|
|
30
|
+
confirmLabel = 'Confirm',
|
|
31
|
+
cancelLabel = 'Cancel',
|
|
32
|
+
destructive = false,
|
|
33
|
+
busy = false,
|
|
34
|
+
onConfirm,
|
|
35
|
+
onCancel,
|
|
36
|
+
children,
|
|
37
|
+
}) {
|
|
38
|
+
return (
|
|
39
|
+
<AlertDialog
|
|
40
|
+
open={open}
|
|
41
|
+
onOpenChange={(next) => {
|
|
42
|
+
// Only allow closing via the dialog's own affordances when we're not mid-request.
|
|
43
|
+
if (!next && !busy) onCancel?.();
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<AlertDialogContent>
|
|
47
|
+
{/* A form wrapper so pressing Enter in a child input submits (fires onConfirm),
|
|
48
|
+
matching native dialog behavior. The confirm button is type="submit". */}
|
|
49
|
+
<form
|
|
50
|
+
onSubmit={(e) => {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
if (!busy) onConfirm?.();
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<AlertDialogHeader>
|
|
56
|
+
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
57
|
+
{message && <AlertDialogDescription>{message}</AlertDialogDescription>}
|
|
58
|
+
</AlertDialogHeader>
|
|
59
|
+
|
|
60
|
+
{/* Space the child input from the header above and the buttons below
|
|
61
|
+
so the field isn't flush against the footer. */}
|
|
62
|
+
{children && <div className="mt-4 mb-6">{children}</div>}
|
|
63
|
+
|
|
64
|
+
<AlertDialogFooter className="mt-4">
|
|
65
|
+
<AlertDialogCancel
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={onCancel}
|
|
68
|
+
disabled={busy}
|
|
69
|
+
className="w-full sm:w-auto"
|
|
70
|
+
>
|
|
71
|
+
{cancelLabel}
|
|
72
|
+
</AlertDialogCancel>
|
|
73
|
+
{/* Use a plain submit button (not AlertDialogAction) so the confirm fires
|
|
74
|
+
via the form's onSubmit and busy-guard, not an auto-close. Styled with
|
|
75
|
+
buttonVariants so it reads as a real destructive-capable primary. */}
|
|
76
|
+
<button
|
|
77
|
+
type="submit"
|
|
78
|
+
disabled={busy}
|
|
79
|
+
className={cn(
|
|
80
|
+
buttonVariants({ variant: destructive ? 'destructive' : 'default' }),
|
|
81
|
+
'w-full sm:w-auto',
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
{busy && <Loader2 className="size-4 animate-spin" />}
|
|
85
|
+
{confirmLabel}
|
|
86
|
+
</button>
|
|
87
|
+
</AlertDialogFooter>
|
|
88
|
+
</form>
|
|
89
|
+
</AlertDialogContent>
|
|
90
|
+
</AlertDialog>
|
|
91
|
+
);
|
|
92
|
+
}
|