create-astram-app 0.1.2 → 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 +132 -33
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/template/CLAUDE.md +51 -0
- package/template/README.md +43 -44
- package/template/_gitignore +6 -0
- package/template/frontend/FRONTEND.md +92 -0
- package/template/frontend/README.md +16 -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 +45 -0
- package/template/server/eslint.config.js +95 -0
- package/template/server/package.json +49 -0
- package/template/server/scripts/dev.mjs +51 -0
- package/template/server/scripts/sync-astram-guide.mjs +63 -0
- package/template/_env.example +0 -33
- package/template/package.json +0 -37
- /package/template/{src → server/src}/app.module.ts +0 -0
- /package/template/{src → server/src}/features/_gitkeep +0 -0
- /package/template/{src → server/src}/health/demo.controller.ts +0 -0
- /package/template/{src → server/src}/health/health.controller.ts +0 -0
- /package/template/{src → server/src}/main.ts +0 -0
- /package/template/{test → server/test}/enforcement.e2e.spec.ts +0 -0
- /package/template/{tsconfig.build.json → server/tsconfig.build.json} +0 -0
- /package/template/{tsconfig.json → server/tsconfig.json} +0 -0
- /package/template/{vitest.config.ts → server/vitest.config.ts} +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EntityFormModal — the routed-modal overlay for the identity Add/Edit forms. Built on
|
|
3
|
+
* the shadcn Dialog primitive (`dialog.tsx`) so it inherits the app's light/dark
|
|
4
|
+
* theming, focus trap, and Escape-to-close.
|
|
5
|
+
*
|
|
6
|
+
* This is the "routed-modal" pattern: the modal is rendered by an Add/Edit CHILD ROUTE
|
|
7
|
+
* (e.g. /companies/add, /companies/edit/:uuid) under the list's layout, so the URL is
|
|
8
|
+
* bookmarkable and the back button closes it — while looking like a lightweight overlay.
|
|
9
|
+
* The list stays mounted behind it.
|
|
10
|
+
*
|
|
11
|
+
* The `children` are the form fields; the form wrapper submits on Enter (the primary is
|
|
12
|
+
* `type="submit"`), matching native dialog behavior. `busy` disables both buttons so a
|
|
13
|
+
* slow request can't be double-fired or cancelled mid-flight.
|
|
14
|
+
*
|
|
15
|
+
* Props:
|
|
16
|
+
* title — dialog heading
|
|
17
|
+
* description — optional sub-line
|
|
18
|
+
* open — controlled open state (the child route passes `true`)
|
|
19
|
+
* onClose — () => void (Escape / scrim / Cancel → typically navigate(-1))
|
|
20
|
+
* onSubmit — () => void (fired by Enter or the primary button; the parent runs the API)
|
|
21
|
+
* submitLabel — primary button label (default "Save")
|
|
22
|
+
* cancelLabel — ghost cancel label (default "Cancel")
|
|
23
|
+
* busy — request in flight → buttons disabled
|
|
24
|
+
* error — an error string shown above the buttons
|
|
25
|
+
* contentClassName — optional override for the dialog width (default a compact form);
|
|
26
|
+
* wider forms (e.g. the user edit page with its assignments editor)
|
|
27
|
+
* pass a roomier max-width.
|
|
28
|
+
* expandable — show the OCA-style expand/collapse (full-screen) toggle in the header
|
|
29
|
+
* (default true). Full-screen is the natural escape for the roomier
|
|
30
|
+
* assignments modal; small forms keep it but it's harmless.
|
|
31
|
+
* children — the form fields
|
|
32
|
+
*
|
|
33
|
+
* Overflow containment: the DialogContent is a CSS grid, and the form → body chain all
|
|
34
|
+
* carry `min-w-0` so a wide descendant (the assignments posting table) can shrink below
|
|
35
|
+
* its content width — which lets that table's own `overflow-x-auto` wrapper scroll it
|
|
36
|
+
* INSIDE the modal instead of forcing the modal past its `max-w`. The body also scrolls
|
|
37
|
+
* vertically (`overflow-y-auto` + a max-height) so a tall assignments editor never spills.
|
|
38
|
+
*
|
|
39
|
+
* Full-screen: the expand toggle flips a local `expanded` flag; when set, the content
|
|
40
|
+
* overrides its centered/bounded classes to fill the viewport (`inset-0 w-screen h-screen
|
|
41
|
+
* max-w-none rounded-none`, no translate) — the OCA `.dsa-modal--full` treatment.
|
|
42
|
+
*/
|
|
43
|
+
import { useState } from 'react';
|
|
44
|
+
import { Loader2, Maximize2, Minimize2 } from 'lucide-react';
|
|
45
|
+
import { cn } from '@/lib/utils';
|
|
46
|
+
import { Button } from '@/components/ui/button';
|
|
47
|
+
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
48
|
+
import {
|
|
49
|
+
Dialog,
|
|
50
|
+
DialogContent,
|
|
51
|
+
DialogDescription,
|
|
52
|
+
DialogFooter,
|
|
53
|
+
DialogHeader,
|
|
54
|
+
DialogTitle,
|
|
55
|
+
} from '@/components/ui/dialog';
|
|
56
|
+
|
|
57
|
+
export default function EntityFormModal({
|
|
58
|
+
title,
|
|
59
|
+
description,
|
|
60
|
+
open,
|
|
61
|
+
onClose,
|
|
62
|
+
onSubmit,
|
|
63
|
+
submitLabel = 'Save',
|
|
64
|
+
cancelLabel = 'Cancel',
|
|
65
|
+
busy = false,
|
|
66
|
+
error = '',
|
|
67
|
+
contentClassName = 'sm:max-w-md',
|
|
68
|
+
expandable = true,
|
|
69
|
+
children,
|
|
70
|
+
}) {
|
|
71
|
+
const [expanded, setExpanded] = useState(false);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Dialog
|
|
75
|
+
open={open}
|
|
76
|
+
onOpenChange={(next) => {
|
|
77
|
+
// Close only via the dialog's own affordances, and never mid-request.
|
|
78
|
+
if (!next && !busy) onClose?.();
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<DialogContent
|
|
82
|
+
showCloseButton={!busy}
|
|
83
|
+
className={cn(
|
|
84
|
+
// `min-w-0` lets the grid's content track shrink below a wide child's
|
|
85
|
+
// intrinsic width (the grid min-width:auto trap), so the inner posting
|
|
86
|
+
// table's overflow-x-auto is what absorbs extra width — the modal never
|
|
87
|
+
// exceeds its max-width. `overflow-hidden` clips any residual bleed.
|
|
88
|
+
'flex max-h-[calc(100vh-4rem)] min-w-0 flex-col overflow-hidden',
|
|
89
|
+
expanded
|
|
90
|
+
? // OCA `.dsa-modal--full`: fill the viewport, square corners, no offset.
|
|
91
|
+
'inset-0 h-screen max-h-screen w-screen max-w-none translate-x-0 translate-y-0 rounded-none sm:max-w-none'
|
|
92
|
+
: contentClassName,
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
{/* A form wrapper so Enter in any field submits (fires onSubmit); the primary
|
|
96
|
+
button is type="submit". `min-w-0` + flex-col so the body can scroll and the
|
|
97
|
+
wide posting table shrinks rather than stretching the modal. */}
|
|
98
|
+
<form
|
|
99
|
+
onSubmit={(e) => {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
if (!busy) onSubmit?.();
|
|
102
|
+
}}
|
|
103
|
+
className="flex min-w-0 flex-1 flex-col overflow-hidden"
|
|
104
|
+
>
|
|
105
|
+
<DialogHeader>
|
|
106
|
+
<DialogTitle>{title}</DialogTitle>
|
|
107
|
+
{description && <DialogDescription>{description}</DialogDescription>}
|
|
108
|
+
</DialogHeader>
|
|
109
|
+
|
|
110
|
+
{/* Scrollable body: `min-w-0` propagates the shrink to children; `overflow-y-auto`
|
|
111
|
+
keeps a tall assignments editor inside the modal. */}
|
|
112
|
+
<div className="mt-4 mb-6 min-w-0 flex-1 space-y-4 overflow-y-auto">
|
|
113
|
+
{children}
|
|
114
|
+
{error && (
|
|
115
|
+
<Alert variant="destructive">
|
|
116
|
+
<AlertDescription>{error}</AlertDescription>
|
|
117
|
+
</Alert>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<DialogFooter>
|
|
122
|
+
<Button
|
|
123
|
+
type="button"
|
|
124
|
+
variant="ghost"
|
|
125
|
+
onClick={onClose}
|
|
126
|
+
disabled={busy}
|
|
127
|
+
className="w-full sm:w-auto"
|
|
128
|
+
>
|
|
129
|
+
{cancelLabel}
|
|
130
|
+
</Button>
|
|
131
|
+
<Button type="submit" disabled={busy} className="w-full sm:w-auto">
|
|
132
|
+
{busy && <Loader2 className="size-4 animate-spin" />}
|
|
133
|
+
{submitLabel}
|
|
134
|
+
</Button>
|
|
135
|
+
</DialogFooter>
|
|
136
|
+
</form>
|
|
137
|
+
|
|
138
|
+
{/* Expand / collapse (full-screen) toggle — sits to the LEFT of the Dialog's own
|
|
139
|
+
close X (which is `absolute top-4 right-4`). OCA's modal expand affordance. */}
|
|
140
|
+
{expandable && (
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
onClick={() => setExpanded((v) => !v)}
|
|
144
|
+
aria-label={expanded ? 'Exit full screen' : 'Expand to full screen'}
|
|
145
|
+
title={expanded ? 'Exit full screen' : 'Expand to full screen'}
|
|
146
|
+
className="ring-offset-background focus:ring-ring absolute top-4 right-11 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
|
147
|
+
>
|
|
148
|
+
{expanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
|
149
|
+
</button>
|
|
150
|
+
)}
|
|
151
|
+
</DialogContent>
|
|
152
|
+
</Dialog>
|
|
153
|
+
);
|
|
154
|
+
}
|