create-astram-app 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +211 -38
- package/dist/add-frontend.js.map +1 -1
- package/dist/index.js +14 -9
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/template/README.md +51 -20
- package/template/frontend/README.md +21 -11
- package/template/frontend/index.html +16 -0
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +95 -20
- package/template/frontend/src/auth/AuthContext.jsx +145 -21
- package/template/frontend/src/auth/routeGate.js +31 -0
- package/template/frontend/src/auth/routeGate.test.mjs +59 -0
- package/template/frontend/src/auth/sessionFlow.js +27 -0
- package/template/frontend/src/auth/sessionFlow.test.mjs +63 -0
- package/template/frontend/src/components/EntityList.jsx +1 -7
- package/template/frontend/src/components/MultiSelectChecklist.jsx +166 -0
- package/template/frontend/src/components/PageHeader.jsx +5 -4
- package/template/frontend/src/components/ShareDialog.jsx +3 -5
- package/template/frontend/src/components/access/CollapsibleGroup.jsx +4 -1
- package/template/frontend/src/components/ui/alert-dialog.tsx +27 -50
- package/template/frontend/src/components/ui/alert.tsx +17 -23
- package/template/frontend/src/components/ui/avatar.tsx +8 -20
- package/template/frontend/src/components/ui/badge.tsx +17 -26
- package/template/frontend/src/components/ui/button.tsx +22 -24
- package/template/frontend/src/components/ui/card.tsx +19 -36
- package/template/frontend/src/components/ui/checkbox.tsx +7 -10
- package/template/frontend/src/components/ui/date-field.tsx +26 -27
- package/template/frontend/src/components/ui/dialog.tsx +24 -40
- package/template/frontend/src/components/ui/dropdown-menu.tsx +25 -54
- package/template/frontend/src/components/ui/input-otp.tsx +1 -4
- package/template/frontend/src/components/ui/label.tsx +10 -12
- package/template/frontend/src/components/ui/searchable-select.tsx +1 -3
- package/template/frontend/src/components/ui/select.tsx +31 -43
- package/template/frontend/src/components/ui/separator.tsx +6 -6
- package/template/frontend/src/components/ui/sheet.tsx +29 -38
- package/template/frontend/src/components/ui/skeleton.tsx +3 -3
- package/template/frontend/src/components/ui/sonner.tsx +7 -7
- package/template/frontend/src/components/ui/table.tsx +56 -70
- package/template/frontend/src/components/ui/tooltip.tsx +7 -11
- package/template/frontend/src/config/branding.js +1 -4
- package/template/frontend/src/hooks/useMobile.tsx +4 -6
- package/template/frontend/src/index.css +9 -4
- package/template/frontend/src/pages/Dashboard.jsx +13 -7
- package/template/frontend/src/pages/ForgotPassword.jsx +9 -2
- package/template/frontend/src/pages/Login.jsx +85 -70
- package/template/frontend/src/pages/ResetPassword.jsx +9 -2
- package/template/frontend/src/pages/access/AccessForm.jsx +49 -15
- package/template/frontend/src/pages/access/AccessPage.jsx +10 -6
- package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +9 -2
- package/template/frontend/src/pages/account/AccountLayout.jsx +11 -6
- package/template/frontend/src/pages/account/ChangePasswordPage.jsx +2 -2
- package/template/frontend/src/pages/account/ReenrollPage.jsx +3 -5
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +3 -8
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +7 -9
- package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +41 -8
- package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +30 -7
- package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +12 -3
- package/template/frontend/src/pages/roles/RolesPage.jsx +5 -9
- package/template/frontend/src/pages/service-accounts/KeyRevealDialog.jsx +104 -0
- package/template/frontend/src/pages/service-accounts/ServiceAccountAccessDialog.jsx +231 -0
- package/template/frontend/src/pages/service-accounts/ServiceAccountActionsMenu.jsx +56 -0
- package/template/frontend/src/pages/service-accounts/ServiceAccountsPage.jsx +368 -0
- package/template/frontend/src/pages/service-accounts/accessModel.js +158 -0
- package/template/frontend/src/pages/service-accounts/accessModel.test.mjs +189 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +3 -1
- package/template/frontend/src/pages/users/PostingRow.jsx +8 -14
- package/template/frontend/src/pages/users/UsersPage.jsx +7 -9
- package/template/frontend/src/platform/httpClient.js +5 -0
- package/template/frontend/src/platform/sessionStore.js +55 -13
- package/template/frontend/src/platform/sessionStore.test.mjs +89 -0
- package/template/frontend/src/platform/shieldTransport.js +6 -0
- package/template/frontend/src/platform/shieldTransport.test.mjs +84 -5
- package/template/frontend/src/shell/AppShell.jsx +10 -12
- package/template/frontend/src/shell/Sidebar.jsx +15 -3
- package/template/frontend/src/shell/nav.js +13 -3
- package/template/frontend/vite.config.js +19 -1
- package/template/server/_env.example +22 -1
- package/template/server/eslint.config.mjs +194 -0
- package/template/server/package.json +11 -11
- package/template/server/scripts/dev.mjs +17 -6
- package/template/server/scripts/sync-astram-guide.mjs +45 -19
- package/template/server/src/app.module.ts +11 -5
- package/template/server/src/health/demo.controller.ts +16 -12
- package/template/server/src/main.ts +23 -1
- package/template/server/test/enforcement.e2e.spec.ts +73 -6
- package/template/server/eslint.config.js +0 -95
|
@@ -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
|
-
{
|
|
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) => ({
|
|
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, {
|
|
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) => ({
|
|
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
|
|
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
|
|
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 =
|
|
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">
|
|
272
|
-
|
|
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
|
|
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
|
|
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={() =>
|
|
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())
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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={
|
|
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
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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={
|
|
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, {
|
|
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) => ({
|
|
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, {
|
|
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) => ({
|
|
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, {
|
|
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) => ({
|
|
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
|
|
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">
|
|
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)
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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">
|
|
219
|
+
<div className="mb-2 text-sm font-medium text-foreground">
|
|
220
|
+
Outgoing — grants you'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={
|
|
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)
|
|
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
|
-
{
|
|
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={
|
|
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={
|
|
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
|
+
}
|