create-astram-app 0.2.0 → 0.4.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.
@@ -0,0 +1,337 @@
1
+ /**
2
+ * GrantForm — the "Offer a cross-tenant grant" form, rendered as a routed-modal overlay
3
+ * by `/cross-tenant/offer` under GrantsPage's layout (mirrors the identity add/edit
4
+ * forms' routed-modal shape: `useOutletContext` for `onRefresh`, closes via
5
+ * `navigate(-1)`).
6
+ *
7
+ * Composition (all EXISTING components — no new CSS):
8
+ * - `SearchableSelect` for the grantee TENANT picker (over `identity.listCompanies`)
9
+ * - a grantee-kind toggle (user/role) — plain Button variants
10
+ * - `SearchableSelect` for the grantee user/role picker (over `identity.listUsers`/
11
+ * `identity.listRoles`) — resets when the tenant OR kind changes
12
+ * - a plain `Select` for the object type (from `crossTenant.listOfferableTypes` —
13
+ * tenantScoped types ONLY, decision 1)
14
+ * - a `Checkbox` per allowed-action (the type's action vocabulary) — at least one
15
+ * required; NEVER a wildcard option is offered (the writer hard-rejects '*')
16
+ * - a native `DateField` for the expiry, defaulting to now+90 days, converted to a
17
+ * future epoch-ms on submit (decision 2: mandatory, default 90 days)
18
+ *
19
+ * ⛔ Object_type scope ONLY (decision 4): there is no recordId/scope field anywhere in
20
+ * this form — the server hard-codes `scope:'object_type'` from the authenticated
21
+ * principal's context; nothing here could send a record-scoped offer even if it tried.
22
+ *
23
+ * ⛔ `owningTenantId`/`offeredBy` are never sent from here — only
24
+ * `{ granteeTenantId, granteeKind, granteeUuid, typeKey, allowedActions, expiresAtMs }`.
25
+ */
26
+ import { useCallback, useEffect, useMemo, useState } from 'react';
27
+ import { useNavigate, useOutletContext } from 'react-router-dom';
28
+ import { toast } from 'sonner';
29
+ import { useAuth } from '@/auth/AuthContext';
30
+ import EntityFormModal from '@/components/EntityFormModal';
31
+ import { Button } from '@/components/ui/button';
32
+ import { Label } from '@/components/ui/label';
33
+ import { Checkbox } from '@/components/ui/checkbox';
34
+ import { DateField } from '@/components/ui/date-field';
35
+ import { SearchableSelect } from '@/components/ui/searchable-select';
36
+ import {
37
+ Select,
38
+ SelectContent,
39
+ SelectItem,
40
+ SelectTrigger,
41
+ SelectValue,
42
+ } from '@/components/ui/select';
43
+
44
+ const DEFAULT_EXPIRY_DAYS = 90;
45
+
46
+ /** now + N days, as a yyyy-mm-dd string for the native date input's default value. */
47
+ function defaultExpiryDateString() {
48
+ const d = new Date(Date.now() + DEFAULT_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
49
+ return d.toISOString().slice(0, 10);
50
+ }
51
+
52
+ /** A yyyy-mm-dd date string -> END-OF-DAY epoch-ms (so "today" is still a valid future expiry). */
53
+ function dateStringToEndOfDayMs(dateStr) {
54
+ if (!dateStr) return NaN;
55
+ const d = new Date(`${dateStr}T23:59:59.999`);
56
+ return d.getTime();
57
+ }
58
+
59
+ export default function GrantForm() {
60
+ const { tokens, identity, crossTenant } = useAuth();
61
+ const token = tokens?.accessToken;
62
+ const navigate = useNavigate();
63
+ const { onRefresh } = useOutletContext() ?? {};
64
+
65
+ const [granteeTenantId, setGranteeTenantId] = useState(null);
66
+ const [granteeTenantLabel, setGranteeTenantLabel] = useState(null);
67
+
68
+ const [granteeKind, setGranteeKind] = useState('user');
69
+ const [granteeUuid, setGranteeUuid] = useState(null);
70
+ const [granteeLabel, setGranteeLabel] = useState(null);
71
+
72
+ const [types, setTypes] = useState([]);
73
+ const [typesLoading, setTypesLoading] = useState(true);
74
+ const [typeKey, setTypeKey] = useState('');
75
+ const [selectedActions, setSelectedActions] = useState(new Set());
76
+
77
+ const [expiryDate, setExpiryDate] = useState(defaultExpiryDateString());
78
+
79
+ const [busy, setBusy] = useState(false);
80
+ const [error, setError] = useState('');
81
+
82
+ // Load the offerable (tenantScoped-only) object types once.
83
+ useEffect(() => {
84
+ if (!token) return undefined;
85
+ let active = true;
86
+ (async () => {
87
+ setTypesLoading(true);
88
+ const r = await crossTenant.listOfferableTypes(token);
89
+ if (!active) return;
90
+ if (r.ok) {
91
+ setTypes(r.types || []);
92
+ if (r.types?.length === 1) setTypeKey(r.types[0].typeKey);
93
+ } else {
94
+ setError(r.error || 'Could not load the offerable object types.');
95
+ }
96
+ setTypesLoading(false);
97
+ })();
98
+ return () => {
99
+ active = false;
100
+ };
101
+ }, [crossTenant, token]);
102
+
103
+ const currentType = useMemo(() => types.find((t) => t.typeKey === typeKey), [types, typeKey]);
104
+
105
+ // Reset the chosen actions whenever the object type changes (a prior type's actions
106
+ // don't necessarily apply to the new one).
107
+ const onTypeChange = (nextKey) => {
108
+ setTypeKey(nextKey);
109
+ setSelectedActions(new Set());
110
+ };
111
+
112
+ const toggleAction = (action) => {
113
+ setSelectedActions((prev) => {
114
+ const next = new Set(prev);
115
+ if (next.has(action)) next.delete(action);
116
+ else next.add(action);
117
+ return next;
118
+ });
119
+ };
120
+
121
+ // Reset the grantee user/role whenever the TENANT or KIND changes (a uuid from a
122
+ // different tenant/kind is meaningless).
123
+ const onTenantChange = (value) => {
124
+ setGranteeTenantId(value);
125
+ setGranteeUuid(null);
126
+ setGranteeLabel(null);
127
+ };
128
+ const onKindChange = (kind) => {
129
+ setGranteeKind(kind);
130
+ setGranteeUuid(null);
131
+ setGranteeLabel(null);
132
+ };
133
+
134
+ const fetchTenants = useCallback(
135
+ async ({ search: term, page, limit }) => {
136
+ const r = await identity.listCompanies(token, { page, limit, search: term, sortBy: 'name', sortOrder: 'asc' });
137
+ if (!r.ok) return { options: [], total: 0, page };
138
+ return {
139
+ options: (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.name || '(unnamed)' })),
140
+ total: r.total ?? 0,
141
+ page: r.page ?? page,
142
+ };
143
+ },
144
+ [identity, token],
145
+ );
146
+
147
+ const fetchGrantees = useCallback(
148
+ async ({ search: term, page, limit }) => {
149
+ if (granteeKind === 'role') {
150
+ const r = await identity.listRoles(token, { page, limit, search: term, sortBy: 'name', sortOrder: 'asc' });
151
+ if (!r.ok) return { options: [], total: 0, page };
152
+ return {
153
+ options: (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.name || '(unnamed)' })),
154
+ total: r.total ?? 0,
155
+ page: r.page ?? page,
156
+ };
157
+ }
158
+ const r = await identity.listUsers(token, { page, limit, search: term, sortBy: 'username', sortOrder: 'asc' });
159
+ if (!r.ok) return { options: [], total: 0, page };
160
+ return {
161
+ options: (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.username || '(unnamed)' })),
162
+ total: r.total ?? 0,
163
+ page: r.page ?? page,
164
+ };
165
+ },
166
+ [identity, token, granteeKind],
167
+ );
168
+
169
+ const close = () => navigate(-1);
170
+
171
+ const onSubmit = async () => {
172
+ if (!granteeTenantId) {
173
+ setError('Pick a grantee tenant.');
174
+ return;
175
+ }
176
+ if (!granteeUuid) {
177
+ setError(`Pick a grantee ${granteeKind}.`);
178
+ return;
179
+ }
180
+ if (!typeKey) {
181
+ setError('Pick an object type.');
182
+ return;
183
+ }
184
+ const allowedActions = [...selectedActions];
185
+ if (allowedActions.length === 0) {
186
+ setError('Pick at least one allowed action.');
187
+ return;
188
+ }
189
+ const expiresAtMs = dateStringToEndOfDayMs(expiryDate);
190
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
191
+ setError('Pick a future expiry date.');
192
+ return;
193
+ }
194
+
195
+ setError('');
196
+ setBusy(true);
197
+ const r = await crossTenant.offer(token, {
198
+ granteeTenantId,
199
+ granteeKind,
200
+ granteeUuid,
201
+ typeKey,
202
+ allowedActions,
203
+ expiresAtMs,
204
+ });
205
+ setBusy(false);
206
+ if (!r.ok) {
207
+ setError(r.error || 'Could not offer the grant.');
208
+ return;
209
+ }
210
+ toast.success('Grant offered');
211
+ onRefresh?.();
212
+ close();
213
+ };
214
+
215
+ return (
216
+ <EntityFormModal
217
+ title="Offer a cross-tenant grant"
218
+ description="Let another tenant's user or role reach into one of your object types, for a bounded set of actions until an expiry."
219
+ open
220
+ onClose={close}
221
+ onSubmit={onSubmit}
222
+ submitLabel="Offer grant"
223
+ busy={busy}
224
+ error={error}
225
+ >
226
+ <div className="space-y-1.5">
227
+ <Label htmlFor="grant-tenant" variant="field">
228
+ Grantee tenant
229
+ </Label>
230
+ <SearchableSelect
231
+ value={granteeTenantId}
232
+ selectedLabel={granteeTenantLabel}
233
+ onChange={onTenantChange}
234
+ onLabelChange={setGranteeTenantLabel}
235
+ fetchPage={fetchTenants}
236
+ placeholder="Select a tenant"
237
+ searchPlaceholder="Search tenants"
238
+ />
239
+ </div>
240
+
241
+ <div className="space-y-1.5">
242
+ <Label variant="field">Grant to</Label>
243
+ <div className="flex gap-2">
244
+ <Button
245
+ type="button"
246
+ variant={granteeKind === 'user' ? 'default' : 'outline'}
247
+ size="sm"
248
+ onClick={() => onKindChange('user')}
249
+ >
250
+ User
251
+ </Button>
252
+ <Button
253
+ type="button"
254
+ variant={granteeKind === 'role' ? 'default' : 'outline'}
255
+ size="sm"
256
+ onClick={() => onKindChange('role')}
257
+ >
258
+ Role
259
+ </Button>
260
+ </div>
261
+ </div>
262
+
263
+ <div className="space-y-1.5">
264
+ <Label htmlFor="grant-grantee" variant="field">
265
+ {granteeKind === 'role' ? 'Role' : 'User'}
266
+ </Label>
267
+ <SearchableSelect
268
+ key={`${granteeTenantId ?? ''}:${granteeKind}`}
269
+ value={granteeUuid}
270
+ selectedLabel={granteeLabel}
271
+ onChange={setGranteeUuid}
272
+ onLabelChange={setGranteeLabel}
273
+ fetchPage={fetchGrantees}
274
+ placeholder={granteeKind === 'role' ? 'Select a role' : 'Select a user'}
275
+ searchPlaceholder={granteeKind === 'role' ? 'Search roles' : 'Search users'}
276
+ />
277
+ </div>
278
+
279
+ <div className="space-y-1.5">
280
+ <Label htmlFor="grant-type" variant="field">
281
+ Object type
282
+ </Label>
283
+ <Select value={typeKey} onValueChange={onTypeChange} disabled={typesLoading || types.length === 0}>
284
+ <SelectTrigger id="grant-type">
285
+ <SelectValue placeholder={typesLoading ? 'Loading…' : 'Select an object type'} />
286
+ </SelectTrigger>
287
+ <SelectContent>
288
+ {types.map((t) => (
289
+ <SelectItem key={t.typeKey} value={t.typeKey}>
290
+ {t.label}
291
+ </SelectItem>
292
+ ))}
293
+ </SelectContent>
294
+ </Select>
295
+ {!typesLoading && types.length === 0 && (
296
+ <p className="text-xs text-muted-foreground">No object types are available to offer yet.</p>
297
+ )}
298
+ </div>
299
+
300
+ {currentType && (
301
+ <div className="space-y-1.5">
302
+ <Label variant="field">Allowed actions</Label>
303
+ <div className="flex flex-col gap-2">
304
+ {currentType.actions.map((action) => (
305
+ <label key={action} className="flex items-center gap-2 text-sm">
306
+ <Checkbox
307
+ checked={selectedActions.has(action)}
308
+ onCheckedChange={() => toggleAction(action)}
309
+ />
310
+ <span className="capitalize">{action}</span>
311
+ </label>
312
+ ))}
313
+ </div>
314
+ <p className="text-xs text-muted-foreground">
315
+ Pick at least one. The grantee can never do more than this explicit set.
316
+ </p>
317
+ </div>
318
+ )}
319
+
320
+ <div className="space-y-1.5">
321
+ <Label htmlFor="grant-expiry" variant="field">
322
+ Expires on
323
+ </Label>
324
+ <DateField
325
+ id="grant-expiry"
326
+ value={expiryDate}
327
+ onChange={(e) => setExpiryDate(e.target.value)}
328
+ min={new Date().toISOString().slice(0, 10)}
329
+ />
330
+ <p className="text-xs text-muted-foreground">
331
+ Defaults to {DEFAULT_EXPIRY_DAYS} days out. The grant must have a future expiry — no
332
+ perpetual grants.
333
+ </p>
334
+ </div>
335
+ </EntityFormModal>
336
+ );
337
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * GrantsPage — my tenant's cross-tenant grant book: the OUTGOING grants my tenant OWNS
3
+ * (any status) + the INCOMING grants ACCEPTED into my tenant. Mirrors the shared
4
+ * identity-admin list shape (AppShell + PageHeader + a controlled `EntityList`), plus
5
+ * an `<Outlet context={{ onRefresh }} />` for the routed-modal offer form at
6
+ * `/cross-tenant/offer`.
7
+ *
8
+ * Two lists on one page (both server-scoped to MY tenant, never a client-chosen
9
+ * tenant): "Outgoing" (rows, any status — Revoke available while not already revoked)
10
+ * and "Incoming" (accepted grants reaching INTO my tenant — read-only here; the grantee
11
+ * side manages acceptance from the Inbox page).
12
+ *
13
+ * ⛔ No raw UUIDs: every column renders a resolved NAME/label (grantee tenant/user/role
14
+ * name, type label, offeredBy name); `row.uuid` is a key/API-arg only.
15
+ */
16
+ import { useCallback, useEffect, useRef, useState } from 'react';
17
+ import { Outlet, useNavigate } from 'react-router-dom';
18
+ import { Handshake, Inbox } from 'lucide-react';
19
+ import { toast } from 'sonner';
20
+ import { useAuth } from '@/auth/AuthContext';
21
+ import AppShell from '@/shell/AppShell';
22
+ import PageHeader from '@/components/PageHeader';
23
+ import EntityList, { EntityListControls } from '@/components/EntityList';
24
+ import ConfirmDialog from '@/components/ConfirmDialog';
25
+ import { Badge } from '@/components/ui/badge';
26
+ import { Button } from '@/components/ui/button';
27
+
28
+ /** Compact absolute date — e.g. "13 Jul 2026"; '—' when missing/invalid. */
29
+ function formatDate(ms) {
30
+ if (!ms && ms !== 0) return '—';
31
+ const d = new Date(ms);
32
+ if (Number.isNaN(d.getTime())) return '—';
33
+ return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
34
+ }
35
+
36
+ function statusVariant(status) {
37
+ if (status === 'accepted') return 'success';
38
+ if (status === 'revoked') return 'destructive';
39
+ return 'info'; // 'offered'
40
+ }
41
+
42
+ function ActionChips({ actions }) {
43
+ if (!actions || actions.length === 0) return <span className="text-xs text-muted-foreground">—</span>;
44
+ return (
45
+ <div className="flex flex-wrap gap-1">
46
+ {actions.map((a) => (
47
+ <Badge key={a} variant="secondary" className="font-normal">
48
+ {a}
49
+ </Badge>
50
+ ))}
51
+ </div>
52
+ );
53
+ }
54
+
55
+ export default function GrantsPage() {
56
+ const { tokens, crossTenant } = useAuth();
57
+ const token = tokens?.accessToken;
58
+ const navigate = useNavigate();
59
+
60
+ const [rows, setRows] = useState([]);
61
+ const [incoming, setIncoming] = useState([]);
62
+ const [loading, setLoading] = useState(true);
63
+ const [error, setError] = useState('');
64
+
65
+ const [revokeTarget, setRevokeTarget] = useState(null);
66
+ const [revokeBusy, setRevokeBusy] = useState(false);
67
+
68
+ // Abort a superseded list request (rapid refreshes) — mirrors UsersPage/AuditPage.
69
+ const loadAbortRef = useRef(/** @type {AbortController | null} */ (null));
70
+
71
+ const load = useCallback(async () => {
72
+ if (!token) return;
73
+ loadAbortRef.current?.abort();
74
+ const controller = new AbortController();
75
+ loadAbortRef.current = controller;
76
+
77
+ setLoading(true);
78
+ const r = await crossTenant.listGrants(token, { signal: controller.signal });
79
+
80
+ if (controller.signal.aborted || loadAbortRef.current !== controller) return;
81
+
82
+ if (r.ok) {
83
+ setRows(r.rows || []);
84
+ setIncoming(r.incoming || []);
85
+ setError('');
86
+ } else {
87
+ setRows([]);
88
+ setIncoming([]);
89
+ setError(r.error || 'Could not load your cross-tenant grants.');
90
+ }
91
+ setLoading(false);
92
+ }, [crossTenant, token]);
93
+
94
+ useEffect(() => {
95
+ load();
96
+ return () => loadAbortRef.current?.abort();
97
+ }, [load]);
98
+
99
+ const onConfirmRevoke = async () => {
100
+ if (!revokeTarget) return;
101
+ setRevokeBusy(true);
102
+ const r = await crossTenant.revoke(token, revokeTarget.uuid);
103
+ setRevokeBusy(false);
104
+ if (!r.ok) {
105
+ toast.error(r.error || 'Could not revoke the grant.');
106
+ return;
107
+ }
108
+ setRevokeTarget(null);
109
+ toast.success('Grant revoked');
110
+ await load();
111
+ };
112
+
113
+ const outgoingColumns = [
114
+ {
115
+ key: 'granteeTenantName',
116
+ label: 'Grantee tenant',
117
+ render: (row) => <span className="font-medium text-foreground">{row.granteeTenantName}</span>,
118
+ },
119
+ {
120
+ key: 'granteeName',
121
+ label: 'Grantee',
122
+ render: (row) => (
123
+ <span>
124
+ {row.granteeName}{' '}
125
+ <span className="text-xs text-muted-foreground">({row.granteeKind})</span>
126
+ </span>
127
+ ),
128
+ },
129
+ { key: 'typeLabel', label: 'Object type' },
130
+ { key: 'actions', label: 'Actions', render: (row) => <ActionChips actions={row.allowedActions} /> },
131
+ {
132
+ key: 'status',
133
+ label: 'Status',
134
+ render: (row) => (
135
+ <Badge variant={statusVariant(row.status)} className="font-normal capitalize">
136
+ {row.status}
137
+ </Badge>
138
+ ),
139
+ },
140
+ { key: 'expiresAtMs', label: 'Expires', render: (row) => formatDate(row.expiresAtMs) },
141
+ { key: 'offeredByName', label: 'Offered by' },
142
+ {
143
+ key: 'rowActions',
144
+ label: '',
145
+ render: (row) =>
146
+ row.status !== 'revoked' ? (
147
+ <div className="flex justify-end">
148
+ <Button
149
+ type="button"
150
+ variant="ghost"
151
+ size="sm"
152
+ className="text-destructive hover:bg-destructive/10 hover:text-destructive"
153
+ onClick={() => setRevokeTarget(row)}
154
+ >
155
+ Revoke
156
+ </Button>
157
+ </div>
158
+ ) : null,
159
+ },
160
+ ];
161
+
162
+ const incomingColumns = [
163
+ {
164
+ key: 'owningTenantName',
165
+ label: 'Owning tenant',
166
+ render: (row) => <span className="font-medium text-foreground">{row.owningTenantName}</span>,
167
+ },
168
+ {
169
+ key: 'granteeName',
170
+ label: 'Granted to',
171
+ render: (row) => (
172
+ <span>
173
+ {row.granteeName}{' '}
174
+ <span className="text-xs text-muted-foreground">({row.granteeKind})</span>
175
+ </span>
176
+ ),
177
+ },
178
+ { key: 'typeLabel', label: 'Object type' },
179
+ { key: 'actions', label: 'Actions', render: (row) => <ActionChips actions={row.allowedActions} /> },
180
+ { key: 'expiresAtMs', label: 'Expires', render: (row) => formatDate(row.expiresAtMs) },
181
+ { key: 'offeredByName', label: 'Offered by' },
182
+ ];
183
+
184
+ return (
185
+ <AppShell section="Identity" title="Cross-tenant grants">
186
+ <div className="p-4">
187
+ <PageHeader
188
+ section="Identity"
189
+ title="Cross-tenant grants"
190
+ icon={Handshake}
191
+ actions={
192
+ <>
193
+ <Button type="button" variant="outline" size="sm" onClick={() => navigate('/cross-tenant/inbox')}>
194
+ <Inbox className="size-4" />
195
+ Inbox
196
+ </Button>
197
+ <EntityListControls addLabel="Offer grant" onAdd={() => navigate('/cross-tenant/offer')} />
198
+ </>
199
+ }
200
+ />
201
+
202
+ <div className="mb-2 text-sm font-medium text-foreground">Outgoing — grants you&apos;ve offered</div>
203
+ <EntityList
204
+ columns={outgoingColumns}
205
+ rows={rows}
206
+ loading={loading}
207
+ error={error}
208
+ emptyLabel="You haven't offered any cross-tenant grants yet."
209
+ />
210
+
211
+ <div className="mt-6 mb-2 text-sm font-medium text-foreground">
212
+ Incoming — grants accepted into your tenant
213
+ </div>
214
+ <EntityList
215
+ columns={incomingColumns}
216
+ rows={incoming}
217
+ loading={loading}
218
+ error={error}
219
+ emptyLabel="No cross-tenant grants have been accepted into your tenant."
220
+ />
221
+ </div>
222
+
223
+ <Outlet context={{ onRefresh: load }} />
224
+
225
+ <ConfirmDialog
226
+ open={!!revokeTarget}
227
+ title={revokeTarget ? `Revoke the grant to “${revokeTarget.granteeTenantName}”?` : 'Revoke grant?'}
228
+ message="This immediately revokes the grant. The grantee tenant will lose access. This cannot be undone."
229
+ confirmLabel="Revoke"
230
+ destructive
231
+ busy={revokeBusy}
232
+ onConfirm={onConfirmRevoke}
233
+ onCancel={() => setRevokeTarget(null)}
234
+ />
235
+ </AppShell>
236
+ );
237
+ }