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.
Files changed (101) hide show
  1. package/dist/add-frontend.js +107 -0
  2. package/dist/add-frontend.js.map +1 -0
  3. package/dist/index.js +132 -33
  4. package/dist/index.js.map +1 -1
  5. package/package.json +3 -2
  6. package/template/CLAUDE.md +51 -0
  7. package/template/README.md +43 -44
  8. package/template/_gitignore +6 -0
  9. package/template/frontend/FRONTEND.md +92 -0
  10. package/template/frontend/README.md +16 -0
  11. package/template/frontend/_env.example +6 -0
  12. package/template/frontend/_gitignore +9 -0
  13. package/template/frontend/eslint.config.js +71 -0
  14. package/template/frontend/index.html +13 -0
  15. package/template/frontend/package.json +52 -0
  16. package/template/frontend/public/astram-logo-full.png +0 -0
  17. package/template/frontend/public/astram-logo-white.png +0 -0
  18. package/template/frontend/public/favicon.png +0 -0
  19. package/template/frontend/src/App.jsx +167 -0
  20. package/template/frontend/src/auth/AuthContext.jsx +338 -0
  21. package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
  22. package/template/frontend/src/components/EntityFormModal.jsx +154 -0
  23. package/template/frontend/src/components/EntityList.jsx +480 -0
  24. package/template/frontend/src/components/PageHeader.jsx +54 -0
  25. package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
  26. package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
  27. package/template/frontend/src/components/ui/alert.tsx +83 -0
  28. package/template/frontend/src/components/ui/avatar.tsx +51 -0
  29. package/template/frontend/src/components/ui/badge.tsx +56 -0
  30. package/template/frontend/src/components/ui/button.tsx +67 -0
  31. package/template/frontend/src/components/ui/card.tsx +92 -0
  32. package/template/frontend/src/components/ui/checkbox.tsx +30 -0
  33. package/template/frontend/src/components/ui/date-field.tsx +47 -0
  34. package/template/frontend/src/components/ui/dialog.tsx +214 -0
  35. package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
  36. package/template/frontend/src/components/ui/input-otp.tsx +81 -0
  37. package/template/frontend/src/components/ui/input.tsx +21 -0
  38. package/template/frontend/src/components/ui/label.tsx +46 -0
  39. package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
  40. package/template/frontend/src/components/ui/select.tsx +199 -0
  41. package/template/frontend/src/components/ui/separator.tsx +26 -0
  42. package/template/frontend/src/components/ui/sheet.tsx +139 -0
  43. package/template/frontend/src/components/ui/skeleton.tsx +13 -0
  44. package/template/frontend/src/components/ui/sonner.tsx +23 -0
  45. package/template/frontend/src/components/ui/table.tsx +181 -0
  46. package/template/frontend/src/components/ui/tooltip.tsx +59 -0
  47. package/template/frontend/src/config/branding.js +80 -0
  48. package/template/frontend/src/hooks/useMobile.tsx +21 -0
  49. package/template/frontend/src/index.css +265 -0
  50. package/template/frontend/src/lib/utils.js +7 -0
  51. package/template/frontend/src/main.jsx +27 -0
  52. package/template/frontend/src/pages/Dashboard.jsx +127 -0
  53. package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
  54. package/template/frontend/src/pages/Login.jsx +393 -0
  55. package/template/frontend/src/pages/ResetPassword.jsx +159 -0
  56. package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
  57. package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
  58. package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
  59. package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
  60. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
  61. package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
  62. package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
  63. package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
  64. package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
  65. package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
  66. package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
  67. package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
  68. package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
  69. package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
  70. package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
  71. package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
  72. package/template/frontend/src/pages/users/UserForm.jsx +167 -0
  73. package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
  74. package/template/frontend/src/platform/httpClient.js +125 -0
  75. package/template/frontend/src/platform/returnPathStore.js +43 -0
  76. package/template/frontend/src/platform/sessionStore.js +29 -0
  77. package/template/frontend/src/platform/shieldTransport.js +172 -0
  78. package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
  79. package/template/frontend/src/shell/AppShell.jsx +237 -0
  80. package/template/frontend/src/shell/Sidebar.jsx +257 -0
  81. package/template/frontend/src/shell/nav.js +90 -0
  82. package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
  83. package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
  84. package/template/frontend/tsconfig.json +22 -0
  85. package/template/frontend/vite.config.js +30 -0
  86. package/template/server/_env.example +45 -0
  87. package/template/server/eslint.config.js +95 -0
  88. package/template/server/package.json +49 -0
  89. package/template/server/scripts/dev.mjs +51 -0
  90. package/template/server/scripts/sync-astram-guide.mjs +63 -0
  91. package/template/_env.example +0 -33
  92. package/template/package.json +0 -37
  93. /package/template/{src → server/src}/app.module.ts +0 -0
  94. /package/template/{src → server/src}/features/_gitkeep +0 -0
  95. /package/template/{src → server/src}/health/demo.controller.ts +0 -0
  96. /package/template/{src → server/src}/health/health.controller.ts +0 -0
  97. /package/template/{src → server/src}/main.ts +0 -0
  98. /package/template/{test → server/test}/enforcement.e2e.spec.ts +0 -0
  99. /package/template/{tsconfig.build.json → server/tsconfig.build.json} +0 -0
  100. /package/template/{tsconfig.json → server/tsconfig.json} +0 -0
  101. /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
+ }