create-astram-app 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +211 -38
  2. package/dist/add-frontend.js.map +1 -1
  3. package/dist/index.js +14 -9
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/template/README.md +51 -20
  7. package/template/frontend/README.md +21 -11
  8. package/template/frontend/index.html +16 -0
  9. package/template/frontend/package.json +1 -1
  10. package/template/frontend/src/App.jsx +95 -20
  11. package/template/frontend/src/auth/AuthContext.jsx +145 -21
  12. package/template/frontend/src/auth/routeGate.js +31 -0
  13. package/template/frontend/src/auth/routeGate.test.mjs +59 -0
  14. package/template/frontend/src/auth/sessionFlow.js +27 -0
  15. package/template/frontend/src/auth/sessionFlow.test.mjs +63 -0
  16. package/template/frontend/src/components/EntityList.jsx +1 -7
  17. package/template/frontend/src/components/MultiSelectChecklist.jsx +166 -0
  18. package/template/frontend/src/components/PageHeader.jsx +5 -4
  19. package/template/frontend/src/components/ShareDialog.jsx +3 -5
  20. package/template/frontend/src/components/access/CollapsibleGroup.jsx +4 -1
  21. package/template/frontend/src/components/ui/alert-dialog.tsx +27 -50
  22. package/template/frontend/src/components/ui/alert.tsx +17 -23
  23. package/template/frontend/src/components/ui/avatar.tsx +8 -20
  24. package/template/frontend/src/components/ui/badge.tsx +17 -26
  25. package/template/frontend/src/components/ui/button.tsx +22 -24
  26. package/template/frontend/src/components/ui/card.tsx +19 -36
  27. package/template/frontend/src/components/ui/checkbox.tsx +7 -10
  28. package/template/frontend/src/components/ui/date-field.tsx +26 -27
  29. package/template/frontend/src/components/ui/dialog.tsx +24 -40
  30. package/template/frontend/src/components/ui/dropdown-menu.tsx +25 -54
  31. package/template/frontend/src/components/ui/input-otp.tsx +1 -4
  32. package/template/frontend/src/components/ui/label.tsx +10 -12
  33. package/template/frontend/src/components/ui/searchable-select.tsx +1 -3
  34. package/template/frontend/src/components/ui/select.tsx +31 -43
  35. package/template/frontend/src/components/ui/separator.tsx +6 -6
  36. package/template/frontend/src/components/ui/sheet.tsx +29 -38
  37. package/template/frontend/src/components/ui/skeleton.tsx +3 -3
  38. package/template/frontend/src/components/ui/sonner.tsx +7 -7
  39. package/template/frontend/src/components/ui/table.tsx +56 -70
  40. package/template/frontend/src/components/ui/tooltip.tsx +7 -11
  41. package/template/frontend/src/config/branding.js +1 -4
  42. package/template/frontend/src/hooks/useMobile.tsx +4 -6
  43. package/template/frontend/src/index.css +9 -4
  44. package/template/frontend/src/pages/Dashboard.jsx +13 -7
  45. package/template/frontend/src/pages/ForgotPassword.jsx +9 -2
  46. package/template/frontend/src/pages/Login.jsx +85 -70
  47. package/template/frontend/src/pages/ResetPassword.jsx +9 -2
  48. package/template/frontend/src/pages/access/AccessForm.jsx +49 -15
  49. package/template/frontend/src/pages/access/AccessPage.jsx +10 -6
  50. package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +9 -2
  51. package/template/frontend/src/pages/account/AccountLayout.jsx +11 -6
  52. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +2 -2
  53. package/template/frontend/src/pages/account/ReenrollPage.jsx +3 -5
  54. package/template/frontend/src/pages/account/TwoFactorPage.jsx +3 -8
  55. package/template/frontend/src/pages/companies/CompaniesPage.jsx +7 -9
  56. package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +41 -8
  57. package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +30 -7
  58. package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +12 -3
  59. package/template/frontend/src/pages/roles/RolesPage.jsx +5 -9
  60. package/template/frontend/src/pages/service-accounts/KeyRevealDialog.jsx +104 -0
  61. package/template/frontend/src/pages/service-accounts/ServiceAccountAccessDialog.jsx +231 -0
  62. package/template/frontend/src/pages/service-accounts/ServiceAccountActionsMenu.jsx +56 -0
  63. package/template/frontend/src/pages/service-accounts/ServiceAccountsPage.jsx +368 -0
  64. package/template/frontend/src/pages/service-accounts/accessModel.js +158 -0
  65. package/template/frontend/src/pages/service-accounts/accessModel.test.mjs +189 -0
  66. package/template/frontend/src/pages/users/MembershipCard.jsx +3 -1
  67. package/template/frontend/src/pages/users/PostingRow.jsx +8 -14
  68. package/template/frontend/src/pages/users/UsersPage.jsx +7 -9
  69. package/template/frontend/src/platform/httpClient.js +5 -0
  70. package/template/frontend/src/platform/sessionStore.js +55 -13
  71. package/template/frontend/src/platform/sessionStore.test.mjs +89 -0
  72. package/template/frontend/src/platform/shieldTransport.js +6 -0
  73. package/template/frontend/src/platform/shieldTransport.test.mjs +84 -5
  74. package/template/frontend/src/shell/AppShell.jsx +10 -12
  75. package/template/frontend/src/shell/Sidebar.jsx +15 -3
  76. package/template/frontend/src/shell/nav.js +13 -3
  77. package/template/frontend/vite.config.js +19 -1
  78. package/template/server/_env.example +22 -1
  79. package/template/server/eslint.config.mjs +194 -0
  80. package/template/server/package.json +11 -11
  81. package/template/server/scripts/dev.mjs +17 -6
  82. package/template/server/scripts/sync-astram-guide.mjs +45 -19
  83. package/template/server/src/app.module.ts +11 -5
  84. package/template/server/src/health/demo.controller.ts +16 -12
  85. package/template/server/src/main.ts +23 -1
  86. package/template/server/test/enforcement.e2e.spec.ts +73 -6
  87. package/template/server/eslint.config.js +0 -95
@@ -27,7 +27,10 @@ const ACTIONS = ['read', 'create', 'update', 'delete'];
27
27
 
28
28
  // Object → group mapping. An unmapped typeKey falls into "Other" (fail-safe).
29
29
  const GROUPS = [
30
- { label: 'Organization', types: ['company', 'company_branch', 'company_department', 'department', 'branch'] },
30
+ {
31
+ label: 'Organization',
32
+ types: ['company', 'company_branch', 'company_department', 'department', 'branch'],
33
+ },
31
34
  { label: 'People & Access', types: ['user', 'role', 'company_membership', 'membership_posting'] },
32
35
  ];
33
36
 
@@ -66,7 +69,10 @@ export default function AccessForm() {
66
69
  }, [catalog]);
67
70
  const groups = useMemo(() => {
68
71
  const mapped = new Set(GROUPS.flatMap((g) => g.types));
69
- const out = GROUPS.map((g) => ({ label: g.label, types: g.types.filter((t) => typeKeys.includes(t)) }));
72
+ const out = GROUPS.map((g) => ({
73
+ label: g.label,
74
+ types: g.types.filter((t) => typeKeys.includes(t)),
75
+ }));
70
76
  const other = typeKeys.filter((t) => !mapped.has(t));
71
77
  if (other.length) out.push({ label: 'Other', types: other });
72
78
  return out.filter((g) => g.types.length > 0);
@@ -90,9 +96,18 @@ export default function AccessForm() {
90
96
  // Paginated + searchable role fetcher for the SearchableSelect.
91
97
  const fetchRoles = useCallback(
92
98
  async ({ search, page, limit }) => {
93
- const r = await identity.listRoles(token, { page, limit, search, sortBy: 'name', sortOrder: 'asc' });
99
+ const r = await identity.listRoles(token, {
100
+ page,
101
+ limit,
102
+ search,
103
+ sortBy: 'name',
104
+ sortOrder: 'asc',
105
+ });
94
106
  if (!r.ok) return { options: [], total: 0, page };
95
- const options = (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.name || '(unnamed)' }));
107
+ const options = (r.rows || []).map((row) => ({
108
+ value: row.id,
109
+ label: row.fields?.name || '(unnamed)',
110
+ }));
96
111
  return { options, total: r.total ?? options.length, page: r.page ?? page };
97
112
  },
98
113
  [identity, token],
@@ -107,7 +122,9 @@ export default function AccessForm() {
107
122
  const [catRes, bundlesRes, grantsRes, roleRes] = await Promise.all([
108
123
  identity.listPermissionCatalog(token),
109
124
  identity.listAccessBundles(token),
110
- sourceUuid ? identity.listRolePermissions(token, sourceUuid) : Promise.resolve({ ok: true, grants: [] }),
125
+ sourceUuid
126
+ ? identity.listRolePermissions(token, sourceUuid)
127
+ : Promise.resolve({ ok: true, grants: [] }),
111
128
  // In EDIT mode the role is fixed — resolve its NAME so the (disabled) role select
112
129
  // shows it on the trigger (a uuid is never displayed).
113
130
  mode === 'edit' && sourceUuid
@@ -162,7 +179,9 @@ export default function AccessForm() {
162
179
  };
163
180
 
164
181
  const toggleGroupAll = (group) => {
165
- const groupKeys = group.types.flatMap((t) => ACTIONS.map((a) => cellKey(t, a))).filter(keyExists);
182
+ const groupKeys = group.types
183
+ .flatMap((t) => ACTIONS.map((a) => cellKey(t, a)))
184
+ .filter(keyExists);
166
185
  const allHeld = groupKeys.every((k) => keyHeld(k, working));
167
186
  setWorking((prev) => {
168
187
  const next = new Set(prev);
@@ -245,7 +264,12 @@ export default function AccessForm() {
245
264
  const matchesSearch = (t) =>
246
265
  !objectSearch.trim() || t.toLowerCase().includes(objectSearch.trim().toLowerCase());
247
266
 
248
- const title = mode === 'edit' ? 'Edit permissions' : mode === 'duplicate' ? 'Duplicate access' : 'Assign access';
267
+ const title =
268
+ mode === 'edit'
269
+ ? 'Edit permissions'
270
+ : mode === 'duplicate'
271
+ ? 'Duplicate access'
272
+ : 'Assign access';
249
273
  const description =
250
274
  mode === 'edit'
251
275
  ? 'Adjust the permissions granted to this role.'
@@ -268,8 +292,12 @@ export default function AccessForm() {
268
292
  <div className="grid gap-4 md:grid-cols-[240px_1fr]">
269
293
  {/* Left rail — presets */}
270
294
  <div className="flex flex-col gap-2 md:border-r md:border-border md:pr-4">
271
- <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Presets</div>
272
- {bundles.length === 0 && <p className="text-xs text-muted-foreground">No presets available.</p>}
295
+ <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
296
+ Presets
297
+ </div>
298
+ {bundles.length === 0 && (
299
+ <p className="text-xs text-muted-foreground">No presets available.</p>
300
+ )}
273
301
  {bundles.map((b) => {
274
302
  const isApplied = isBundleApplied(b);
275
303
  return (
@@ -284,7 +312,11 @@ export default function AccessForm() {
284
312
  {/* Checkbox-like: presets stack (multiple can be applied); this reflects +
285
313
  toggles whether ALL of the preset's keys are held. Non-interactive
286
314
  itself (the whole card is the button) — pointer-events-none. */}
287
- <Checkbox checked={isApplied} className="pointer-events-none mt-0.5" tabIndex={-1} />
315
+ <Checkbox
316
+ checked={isApplied}
317
+ className="pointer-events-none mt-0.5"
318
+ tabIndex={-1}
319
+ />
288
320
  <div className="min-w-0">
289
321
  <span className="text-sm font-medium text-foreground">{b.label}</span>
290
322
  {b.description && (
@@ -325,7 +357,9 @@ export default function AccessForm() {
325
357
  {groups.map((group) => {
326
358
  const visibleTypes = group.types.filter(matchesSearch);
327
359
  if (visibleTypes.length === 0) return null;
328
- const groupCells = group.types.flatMap((t) => ACTIONS.map((a) => cellKey(t, a))).filter(keyExists);
360
+ const groupCells = group.types
361
+ .flatMap((t) => ACTIONS.map((a) => cellKey(t, a)))
362
+ .filter(keyExists);
329
363
  const heldCount = groupCells.filter((k) => keyHeld(k, working)).length;
330
364
  return (
331
365
  <CollapsibleGroup
@@ -334,7 +368,9 @@ export default function AccessForm() {
334
368
  count={heldCount}
335
369
  total={groupCells.length}
336
370
  open={openGroups[group.label] ?? true}
337
- onToggle={() => setOpenGroups((p) => ({ ...p, [group.label]: !(p[group.label] ?? true) }))}
371
+ onToggle={() =>
372
+ setOpenGroups((p) => ({ ...p, [group.label]: !(p[group.label] ?? true) }))
373
+ }
338
374
  right={
339
375
  <span
340
376
  role="button"
@@ -405,9 +441,7 @@ export default function AccessForm() {
405
441
  {permCount} permission{permCount === 1 ? '' : 's'}
406
442
  </span>{' '}
407
443
  across {objectCount} object{objectCount === 1 ? '' : 's'}
408
- {appliedPresetLabels.length > 0 && (
409
- <span> · {appliedPresetLabels.join(', ')}</span>
410
- )}
444
+ {appliedPresetLabels.length > 0 && <span> · {appliedPresetLabels.join(', ')}</span>}
411
445
  </div>
412
446
  </div>
413
447
  </div>
@@ -32,11 +32,13 @@ function initialOf(name) {
32
32
  function shortDate(iso) {
33
33
  if (!iso) return null;
34
34
  const d = new Date(iso);
35
- return Number.isNaN(d.getTime()) ? null : d.toLocaleDateString(undefined, {
36
- day: 'numeric',
37
- month: 'short',
38
- year: 'numeric',
39
- });
35
+ return Number.isNaN(d.getTime())
36
+ ? null
37
+ : d.toLocaleDateString(undefined, {
38
+ day: 'numeric',
39
+ month: 'short',
40
+ year: 'numeric',
41
+ });
40
42
  }
41
43
 
42
44
  /** Seed the datatable query from the URL so a reload / shared link restores the view. */
@@ -269,7 +271,9 @@ export default function AccessPage() {
269
271
 
270
272
  <ConfirmDialog
271
273
  open={!!revokeTarget}
272
- title={revokeTarget ? `Revoke all access from “${revokeTarget.roleName}”?` : 'Revoke all access?'}
274
+ title={
275
+ revokeTarget ? `Revoke all access from “${revokeTarget.roleName}”?` : 'Revoke all access?'
276
+ }
273
277
  message="This removes every permission granted to the role. It will no longer appear in this list. This cannot be undone."
274
278
  confirmLabel="Revoke all"
275
279
  destructive
@@ -184,13 +184,20 @@ export default function RecordPoliciesPage() {
184
184
  key: 'effect',
185
185
  label: 'Effect',
186
186
  render: (row) => (
187
- <Badge variant={row.policy.effect === 'deny' ? 'destructive' : 'success'} className="capitalize font-normal">
187
+ <Badge
188
+ variant={row.policy.effect === 'deny' ? 'destructive' : 'success'}
189
+ className="capitalize font-normal"
190
+ >
188
191
  {row.policy.effect}
189
192
  </Badge>
190
193
  ),
191
194
  },
192
195
  { key: 'action', label: 'Action', render: (row) => row.policy.action },
193
- { key: 'rule', label: 'Rule', render: (row) => <span className="text-sm">{describe(row.policy)}</span> },
196
+ {
197
+ key: 'rule',
198
+ label: 'Rule',
199
+ render: (row) => <span className="text-sm">{describe(row.policy)}</span>,
200
+ },
194
201
  {
195
202
  key: 'rowActions',
196
203
  label: '',
@@ -24,9 +24,17 @@ import { cn } from '@/lib/utils';
24
24
  function initialsOf(name) {
25
25
  if (!name) return 'U';
26
26
  const clean = name.includes('@') ? name.split('@')[0] : name;
27
- const parts = clean.trim().split(/[\s._-]+/).filter(Boolean);
27
+ const parts = clean
28
+ .trim()
29
+ .split(/[\s._-]+/)
30
+ .filter(Boolean);
28
31
  const letters = parts.length >= 2 ? parts[0][0] + parts[1][0] : clean.slice(0, 2);
29
- return letters.replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || 'U';
32
+ return (
33
+ letters
34
+ .replace(/[^a-zA-Z0-9]/g, '')
35
+ .slice(0, 2)
36
+ .toUpperCase() || 'U'
37
+ );
30
38
  }
31
39
 
32
40
  const TABS = [
@@ -73,10 +81,7 @@ function IdentityCard({ banner = false }) {
73
81
  )}
74
82
  >
75
83
  <Avatar
76
- className={cn(
77
- 'shrink-0 rounded-xl ring-1 ring-primary/20',
78
- banner ? 'size-11' : 'size-16',
79
- )}
84
+ className={cn('shrink-0 rounded-xl ring-1 ring-primary/20', banner ? 'size-11' : 'size-16')}
80
85
  >
81
86
  <AvatarFallback
82
87
  className={cn(
@@ -126,8 +126,8 @@ export default function ChangePasswordPage() {
126
126
  Change password
127
127
  </CardTitle>
128
128
  <CardDescription>
129
- Set a new password. For your security, changing it signs out your other sessions —
130
- this one stays active.
129
+ Set a new password. For your security, changing it signs out your other sessions — this
130
+ one stays active.
131
131
  </CardDescription>
132
132
  </CardHeader>
133
133
  <CardContent>
@@ -55,9 +55,7 @@ export default function ReenrollPage() {
55
55
  )}
56
56
  <div className="min-w-0">
57
57
  <p className="text-sm font-semibold text-foreground">
58
- {secured
59
- ? 'Your account is secured again'
60
- : 'Finish securing your account'}
58
+ {secured ? 'Your account is secured again' : 'Finish securing your account'}
61
59
  </p>
62
60
  <p className="mt-0.5 text-sm text-muted-foreground">
63
61
  {secured
@@ -75,8 +73,8 @@ export default function ReenrollPage() {
75
73
 
76
74
  {!secured && (
77
75
  <p className="text-center text-xs text-muted-foreground">
78
- You can rename or remove existing devices from your account settings once your account is
79
- secured.
76
+ You can rename or remove existing devices from your account settings once your account
77
+ is secured.
80
78
  </p>
81
79
  )}
82
80
 
@@ -237,20 +237,15 @@ function DeviceRow({ device, onRename, onRemove, disabled, reenrollMode = false
237
237
  <p className="truncate font-medium text-foreground">{device.name}</p>
238
238
  )}
239
239
  <p className="mt-0.5 text-xs text-muted-foreground">
240
- Added {formatAdded(device.createdAtMs)} · Last used {formatLastUsed(device.lastUsedAtMs)}
240
+ Added {formatAdded(device.createdAtMs)} · Last used{' '}
241
+ {formatLastUsed(device.lastUsedAtMs)}
241
242
  </p>
242
243
  </div>
243
244
  </div>
244
245
 
245
246
  {!editing && !reenrollMode && (
246
247
  <div className="flex items-center gap-1 self-end sm:self-auto">
247
- <Button
248
- type="button"
249
- variant="ghost"
250
- size="sm"
251
- onClick={startEdit}
252
- disabled={disabled}
253
- >
248
+ <Button type="button" variant="ghost" size="sm" onClick={startEdit} disabled={disabled}>
254
249
  <Pencil className="size-4" />
255
250
  Rename
256
251
  </Button>
@@ -143,19 +143,13 @@ export default function CompaniesPage() {
143
143
  }, [load]);
144
144
 
145
145
  // Query-change handlers: search/limit reset to page 1; sort toggles asc↔desc.
146
- const onSearchChange = useCallback(
147
- (search) => setQuery((q) => ({ ...q, search, page: 1 })),
148
- [],
149
- );
146
+ const onSearchChange = useCallback((search) => setQuery((q) => ({ ...q, search, page: 1 })), []);
150
147
  const onSortChange = useCallback(
151
148
  (sortBy, sortOrder) => setQuery((q) => ({ ...q, sortBy, sortOrder })),
152
149
  [],
153
150
  );
154
151
  const onPageChange = useCallback((page) => setQuery((q) => ({ ...q, page })), []);
155
- const onLimitChange = useCallback(
156
- (limit) => setQuery((q) => ({ ...q, limit, page: 1 })),
157
- [],
158
- );
152
+ const onLimitChange = useCallback((limit) => setQuery((q) => ({ ...q, limit, page: 1 })), []);
159
153
 
160
154
  const onConfirmDelete = async () => {
161
155
  if (!deleteTarget) return;
@@ -219,7 +213,11 @@ export default function CompaniesPage() {
219
213
 
220
214
  <ConfirmDialog
221
215
  open={!!deleteTarget}
222
- title={deleteTarget ? `Delete “${deleteTarget.fields?.name || 'this company'}”?` : 'Delete company?'}
216
+ title={
217
+ deleteTarget
218
+ ? `Delete “${deleteTarget.fields?.name || 'this company'}”?`
219
+ : 'Delete company?'
220
+ }
223
221
  message="This permanently removes the company and its memberships. This cannot be undone."
224
222
  confirmLabel="Delete"
225
223
  destructive
@@ -133,10 +133,19 @@ export default function GrantForm() {
133
133
 
134
134
  const fetchTenants = useCallback(
135
135
  async ({ search: term, page, limit }) => {
136
- const r = await identity.listCompanies(token, { page, limit, search: term, sortBy: 'name', sortOrder: 'asc' });
136
+ const r = await identity.listCompanies(token, {
137
+ page,
138
+ limit,
139
+ search: term,
140
+ sortBy: 'name',
141
+ sortOrder: 'asc',
142
+ });
137
143
  if (!r.ok) return { options: [], total: 0, page };
138
144
  return {
139
- options: (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.name || '(unnamed)' })),
145
+ options: (r.rows || []).map((row) => ({
146
+ value: row.id,
147
+ label: row.fields?.name || '(unnamed)',
148
+ })),
140
149
  total: r.total ?? 0,
141
150
  page: r.page ?? page,
142
151
  };
@@ -147,18 +156,36 @@ export default function GrantForm() {
147
156
  const fetchGrantees = useCallback(
148
157
  async ({ search: term, page, limit }) => {
149
158
  if (granteeKind === 'role') {
150
- const r = await identity.listRoles(token, { page, limit, search: term, sortBy: 'name', sortOrder: 'asc' });
159
+ const r = await identity.listRoles(token, {
160
+ page,
161
+ limit,
162
+ search: term,
163
+ sortBy: 'name',
164
+ sortOrder: 'asc',
165
+ });
151
166
  if (!r.ok) return { options: [], total: 0, page };
152
167
  return {
153
- options: (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.name || '(unnamed)' })),
168
+ options: (r.rows || []).map((row) => ({
169
+ value: row.id,
170
+ label: row.fields?.name || '(unnamed)',
171
+ })),
154
172
  total: r.total ?? 0,
155
173
  page: r.page ?? page,
156
174
  };
157
175
  }
158
- const r = await identity.listUsers(token, { page, limit, search: term, sortBy: 'username', sortOrder: 'asc' });
176
+ const r = await identity.listUsers(token, {
177
+ page,
178
+ limit,
179
+ search: term,
180
+ sortBy: 'username',
181
+ sortOrder: 'asc',
182
+ });
159
183
  if (!r.ok) return { options: [], total: 0, page };
160
184
  return {
161
- options: (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.username || '(unnamed)' })),
185
+ options: (r.rows || []).map((row) => ({
186
+ value: row.id,
187
+ label: row.fields?.username || '(unnamed)',
188
+ })),
162
189
  total: r.total ?? 0,
163
190
  page: r.page ?? page,
164
191
  };
@@ -280,7 +307,11 @@ export default function GrantForm() {
280
307
  <Label htmlFor="grant-type" variant="field">
281
308
  Object type
282
309
  </Label>
283
- <Select value={typeKey} onValueChange={onTypeChange} disabled={typesLoading || types.length === 0}>
310
+ <Select
311
+ value={typeKey}
312
+ onValueChange={onTypeChange}
313
+ disabled={typesLoading || types.length === 0}
314
+ >
284
315
  <SelectTrigger id="grant-type">
285
316
  <SelectValue placeholder={typesLoading ? 'Loading…' : 'Select an object type'} />
286
317
  </SelectTrigger>
@@ -293,7 +324,9 @@ export default function GrantForm() {
293
324
  </SelectContent>
294
325
  </Select>
295
326
  {!typesLoading && types.length === 0 && (
296
- <p className="text-xs text-muted-foreground">No object types are available to offer yet.</p>
327
+ <p className="text-xs text-muted-foreground">
328
+ No object types are available to offer yet.
329
+ </p>
297
330
  )}
298
331
  </div>
299
332
 
@@ -40,7 +40,8 @@ function statusVariant(status) {
40
40
  }
41
41
 
42
42
  function ActionChips({ actions }) {
43
- if (!actions || actions.length === 0) return <span className="text-xs text-muted-foreground">—</span>;
43
+ if (!actions || actions.length === 0)
44
+ return <span className="text-xs text-muted-foreground">—</span>;
44
45
  return (
45
46
  <div className="flex flex-wrap gap-1">
46
47
  {actions.map((a) => (
@@ -127,7 +128,11 @@ export default function GrantsPage() {
127
128
  ),
128
129
  },
129
130
  { key: 'typeLabel', label: 'Object type' },
130
- { key: 'actions', label: 'Actions', render: (row) => <ActionChips actions={row.allowedActions} /> },
131
+ {
132
+ key: 'actions',
133
+ label: 'Actions',
134
+ render: (row) => <ActionChips actions={row.allowedActions} />,
135
+ },
131
136
  {
132
137
  key: 'status',
133
138
  label: 'Status',
@@ -176,7 +181,11 @@ export default function GrantsPage() {
176
181
  ),
177
182
  },
178
183
  { key: 'typeLabel', label: 'Object type' },
179
- { key: 'actions', label: 'Actions', render: (row) => <ActionChips actions={row.allowedActions} /> },
184
+ {
185
+ key: 'actions',
186
+ label: 'Actions',
187
+ render: (row) => <ActionChips actions={row.allowedActions} />,
188
+ },
180
189
  { key: 'expiresAtMs', label: 'Expires', render: (row) => formatDate(row.expiresAtMs) },
181
190
  { key: 'offeredByName', label: 'Offered by' },
182
191
  ];
@@ -190,16 +199,26 @@ export default function GrantsPage() {
190
199
  icon={Handshake}
191
200
  actions={
192
201
  <>
193
- <Button type="button" variant="outline" size="sm" onClick={() => navigate('/cross-tenant/inbox')}>
202
+ <Button
203
+ type="button"
204
+ variant="outline"
205
+ size="sm"
206
+ onClick={() => navigate('/cross-tenant/inbox')}
207
+ >
194
208
  <Inbox className="size-4" />
195
209
  Inbox
196
210
  </Button>
197
- <EntityListControls addLabel="Offer grant" onAdd={() => navigate('/cross-tenant/offer')} />
211
+ <EntityListControls
212
+ addLabel="Offer grant"
213
+ onAdd={() => navigate('/cross-tenant/offer')}
214
+ />
198
215
  </>
199
216
  }
200
217
  />
201
218
 
202
- <div className="mb-2 text-sm font-medium text-foreground">Outgoing — grants you&apos;ve offered</div>
219
+ <div className="mb-2 text-sm font-medium text-foreground">
220
+ Outgoing — grants you&apos;ve offered
221
+ </div>
203
222
  <EntityList
204
223
  columns={outgoingColumns}
205
224
  rows={rows}
@@ -224,7 +243,11 @@ export default function GrantsPage() {
224
243
 
225
244
  <ConfirmDialog
226
245
  open={!!revokeTarget}
227
- title={revokeTarget ? `Revoke the grant to “${revokeTarget.granteeTenantName}”?` : 'Revoke grant?'}
246
+ title={
247
+ revokeTarget
248
+ ? `Revoke the grant to “${revokeTarget.granteeTenantName}”?`
249
+ : 'Revoke grant?'
250
+ }
228
251
  message="This immediately revokes the grant. The grantee tenant will lose access. This cannot be undone."
229
252
  confirmLabel="Revoke"
230
253
  destructive
@@ -33,7 +33,8 @@ function formatDate(ms) {
33
33
  }
34
34
 
35
35
  function ActionChips({ actions }) {
36
- if (!actions || actions.length === 0) return <span className="text-xs text-muted-foreground">—</span>;
36
+ if (!actions || actions.length === 0)
37
+ return <span className="text-xs text-muted-foreground">—</span>;
37
38
  return (
38
39
  <div className="flex flex-wrap gap-1">
39
40
  {actions.map((a) => (
@@ -119,7 +120,11 @@ export default function InboxPage() {
119
120
  render: (row) => <span className="font-medium text-foreground">{row.owningTenantName}</span>,
120
121
  },
121
122
  { key: 'typeLabel', label: 'Object type' },
122
- { key: 'actions', label: 'Actions', render: (row) => <ActionChips actions={row.allowedActions} /> },
123
+ {
124
+ key: 'actions',
125
+ label: 'Actions',
126
+ render: (row) => <ActionChips actions={row.allowedActions} />,
127
+ },
123
128
  { key: 'expiresAtMs', label: 'Expires', render: (row) => formatDate(row.expiresAtMs) },
124
129
  { key: 'offeredByName', label: 'Offered by' },
125
130
  {
@@ -166,7 +171,11 @@ export default function InboxPage() {
166
171
 
167
172
  <ConfirmDialog
168
173
  open={!!declineTarget}
169
- title={declineTarget ? `Decline the offer from “${declineTarget.owningTenantName}”?` : 'Decline this offer?'}
174
+ title={
175
+ declineTarget
176
+ ? `Decline the offer from “${declineTarget.owningTenantName}”?`
177
+ : 'Decline this offer?'
178
+ }
170
179
  message="This revokes the offer. The offering tenant would need to offer again for you to reconsider. This cannot be undone."
171
180
  confirmLabel="Decline"
172
181
  destructive
@@ -119,19 +119,13 @@ export default function RolesPage() {
119
119
  load();
120
120
  }, [load]);
121
121
 
122
- const onSearchChange = useCallback(
123
- (search) => setQuery((q) => ({ ...q, search, page: 1 })),
124
- [],
125
- );
122
+ const onSearchChange = useCallback((search) => setQuery((q) => ({ ...q, search, page: 1 })), []);
126
123
  const onSortChange = useCallback(
127
124
  (sortBy, sortOrder) => setQuery((q) => ({ ...q, sortBy, sortOrder })),
128
125
  [],
129
126
  );
130
127
  const onPageChange = useCallback((page) => setQuery((q) => ({ ...q, page })), []);
131
- const onLimitChange = useCallback(
132
- (limit) => setQuery((q) => ({ ...q, limit, page: 1 })),
133
- [],
134
- );
128
+ const onLimitChange = useCallback((limit) => setQuery((q) => ({ ...q, limit, page: 1 })), []);
135
129
 
136
130
  const onConfirmDelete = async () => {
137
131
  if (!deleteTarget) return;
@@ -194,7 +188,9 @@ export default function RolesPage() {
194
188
 
195
189
  <ConfirmDialog
196
190
  open={!!deleteTarget}
197
- title={deleteTarget ? `Delete “${deleteTarget.fields?.name || 'this role'}”?` : 'Delete role?'}
191
+ title={
192
+ deleteTarget ? `Delete “${deleteTarget.fields?.name || 'this role'}”?` : 'Delete role?'
193
+ }
198
194
  message="This permanently removes the role. Users currently assigned it will lose the grant. This cannot be undone."
199
195
  confirmLabel="Delete"
200
196
  destructive
@@ -0,0 +1,104 @@
1
+ /**
2
+ * KeyRevealDialog — the ONE-TIME reveal of a freshly issued/rotated service-account API key
3
+ * (ADR-0033). The raw `ask_…` key exists on the client only right after issue/rotate and is NEVER
4
+ * retrievable again, so this modal makes the operator copy it before dismissing.
5
+ *
6
+ * Controlled: the parent passes the `IssuedKey` (or null to close). The key is shown in a read-only
7
+ * field with a Copy button; a prominent warning states it won't be shown again. Closing is deliberate
8
+ * ("I've copied it") so it isn't dismissed by accident.
9
+ *
10
+ * ⛔ The key is a SECRET — it is only ever rendered here, never logged, never persisted client-side.
11
+ */
12
+ import { useState } from 'react';
13
+ import { toast } from 'sonner';
14
+ import { KeyRound, Copy, Check, TriangleAlert } from 'lucide-react';
15
+ import {
16
+ Dialog,
17
+ DialogContent,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ DialogDescription,
21
+ DialogFooter,
22
+ } from '@/components/ui/dialog';
23
+ import { Button } from '@/components/ui/button';
24
+ import { Input } from '@/components/ui/input';
25
+
26
+ function formatExpiry(ms) {
27
+ if (!ms) return 'Never expires';
28
+ try {
29
+ return `Expires ${new Date(ms).toLocaleString()}`;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * @param {{ issued: import('@astram/frontend-core').IssuedKey | null, onClose: () => void }} props
37
+ * `issued` — the one-time key payload, or null (closed). `onClose` — dismiss (after the operator
38
+ * has copied the key).
39
+ */
40
+ export default function KeyRevealDialog({ issued, onClose }) {
41
+ const [copied, setCopied] = useState(false);
42
+
43
+ const copy = async () => {
44
+ if (!issued) return;
45
+ try {
46
+ await navigator.clipboard.writeText(issued.apiKey);
47
+ setCopied(true);
48
+ toast.success('API key copied to clipboard');
49
+ setTimeout(() => setCopied(false), 2000);
50
+ } catch {
51
+ toast.error('Could not copy — select the key and copy it manually.');
52
+ }
53
+ };
54
+
55
+ const close = () => {
56
+ setCopied(false);
57
+ onClose?.();
58
+ };
59
+
60
+ return (
61
+ <Dialog open={!!issued} onOpenChange={(next) => !next && close()}>
62
+ <DialogContent className="sm:max-w-lg">
63
+ <DialogHeader>
64
+ <DialogTitle className="flex items-center gap-2">
65
+ <KeyRound className="h-5 w-5" />
66
+ API key for “{issued?.accountLabel}”
67
+ </DialogTitle>
68
+ <DialogDescription>
69
+ Copy this key now and store it somewhere safe. For your security, it will{' '}
70
+ <strong>never be shown again</strong> — if you lose it, rotate the key to get a new one.
71
+ </DialogDescription>
72
+ </DialogHeader>
73
+
74
+ <div className="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-800/60 dark:bg-amber-950/40 dark:text-amber-200">
75
+ <TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
76
+ <span>
77
+ Treat this like a password. Anyone with this key can act as this service account (within
78
+ its scope). {formatExpiry(issued?.expiresAtMs)} · Version {issued?.version}.
79
+ </span>
80
+ </div>
81
+
82
+ <div className="flex items-center gap-2">
83
+ <Input
84
+ readOnly
85
+ value={issued?.apiKey ?? ''}
86
+ onFocus={(e) => e.target.select()}
87
+ className="font-mono text-xs"
88
+ aria-label="API key"
89
+ />
90
+ <Button type="button" variant="secondary" onClick={copy} className="shrink-0">
91
+ {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
92
+ <span className="ml-1">{copied ? 'Copied' : 'Copy'}</span>
93
+ </Button>
94
+ </div>
95
+
96
+ <DialogFooter>
97
+ <Button type="button" onClick={close}>
98
+ I’ve copied it
99
+ </Button>
100
+ </DialogFooter>
101
+ </DialogContent>
102
+ </Dialog>
103
+ );
104
+ }