create-astram-app 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-astram-app",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "description": "Scaffolder for a new Astram app — emits a running, framework-enforced consumer project (empty src/features/ + deny-by-default enforcement) that installs the immutable @astram/core, @astram/guard, and @astram/sdk framework.",
6
6
  "type": "module",
@@ -12,7 +12,7 @@
12
12
  "test": "node --test src/**/*.test.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@astram/frontend-core": "^0.1.0",
15
+ "@astram/frontend-core": "^0.2.0",
16
16
  "@astram/sdk": "^0.2.0",
17
17
  "@radix-ui/react-alert-dialog": "^1.1.19",
18
18
  "@radix-ui/react-avatar": "^1.1.10",
@@ -29,6 +29,9 @@ import UserForm from './pages/users/UserForm';
29
29
  import AccessPage from './pages/access/AccessPage';
30
30
  import AccessForm from './pages/access/AccessForm';
31
31
  import AuditPage from './pages/audit/AuditPage';
32
+ import GrantsPage from './pages/cross-tenant/GrantsPage';
33
+ import GrantForm from './pages/cross-tenant/GrantForm';
34
+ import InboxPage from './pages/cross-tenant/InboxPage';
32
35
 
33
36
  /** The single forced-re-enroll screen path (the enforcement's frontend twin). */
34
37
  const REENROLL_PATH = '/account/reenroll';
@@ -152,6 +155,27 @@ export default function App() {
152
155
  <Route path="add" element={<AccessForm />} />
153
156
  <Route path="edit/:uuid" element={<AccessForm />} />
154
157
  </Route>
158
+ {/* Cross-tenant grants — the grant book (mine + incoming) is a LAYOUT route; the
159
+ Offer form (`offer`) renders as a routed-modal overlay under it. The pending
160
+ Inbox is a sibling route. All gated `identity:role:read` (mirrors nav.js). */}
161
+ <Route
162
+ path="/cross-tenant"
163
+ element={
164
+ <RequireAuth gate="identity:role:read">
165
+ <GrantsPage />
166
+ </RequireAuth>
167
+ }
168
+ >
169
+ <Route path="offer" element={<GrantForm />} />
170
+ </Route>
171
+ <Route
172
+ path="/cross-tenant/inbox"
173
+ element={
174
+ <RequireAuth gate="identity:role:read">
175
+ <InboxPage />
176
+ </RequireAuth>
177
+ }
178
+ />
155
179
  {/* Audit — read-only, no child add/edit routes (audit events are immutable). */}
156
180
  <Route
157
181
  path="/audit"
@@ -15,6 +15,7 @@ import {
15
15
  createAccountService,
16
16
  createAuditService,
17
17
  createAuthService,
18
+ createCrossTenantService,
18
19
  createIdentityService,
19
20
  isAuthenticated,
20
21
  principalOf,
@@ -64,6 +65,7 @@ export function AuthProvider({ children }) {
64
65
  auth: createAuthService(http),
65
66
  identity: createIdentityService(http),
66
67
  audit: createAuditService(http),
68
+ crossTenant: createCrossTenantService(http),
67
69
  account: createAccountService(http),
68
70
  // The @astram/sdk ShieldClient (Phase E slice 1) — a UX-convenience projection of
69
71
  // the caller's OWN coarse grants, fetched once per session via `getAuthFacts`.
@@ -79,7 +81,7 @@ export function AuthProvider({ children }) {
79
81
  store,
80
82
  };
81
83
  }
82
- const { auth, identity, audit, account, shield, store } = services.current;
84
+ const { auth, identity, audit, crossTenant, account, shield, store } = services.current;
83
85
 
84
86
  const [state, setState] = useState(() => store.load()); // SessionState | null
85
87
  // The signed-in user's DISPLAY profile (name/email/company/roles) from GET /auth/me.
@@ -279,6 +281,9 @@ export function AuthProvider({ children }) {
279
281
  identity,
280
282
  // The shared audit-log service (audit viewer) — same injected web http client.
281
283
  audit,
284
+ // The shared cross-tenant grant service (offer/accept/revoke + inbox/mine +
285
+ // offerable-types) — same injected web http client.
286
+ crossTenant,
282
287
  // The shared self-scoped account service (change-password today; profile/MFA
283
288
  // later) — same injected web http client.
284
289
  account,
@@ -301,7 +306,7 @@ export function AuthProvider({ children }) {
301
306
  ),
302
307
  }),
303
308
  // eslint-disable-next-line react-hooks/exhaustive-deps
304
- [state, auth, identity, audit, account, shield, profile, mustReenroll, authFacts],
309
+ [state, auth, identity, audit, crossTenant, account, shield, profile, mustReenroll, authFacts],
305
310
  );
306
311
 
307
312
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
@@ -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
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * InboxPage — my tenant's pending cross-tenant grant OFFERS: grants another tenant's
3
+ * admin has offered TO my tenant, not yet accepted/revoked, unexpired. Each row has
4
+ * Accept (`crossTenant.accept`) and Decline (`crossTenant.revoke` — the grantee side
5
+ * revoking an `offered` grant IS the decline action, per the locked design; no separate
6
+ * endpoint/status exists).
7
+ *
8
+ * Mirrors GrantsPage's list shape (custom-render columns + a themed ConfirmDialog for
9
+ * the destructive action — Decline is framed as a confirm, matching the "destructive
10
+ * operations MUST confirm first" rule, since declining an offer is a one-way action from
11
+ * the grantee's perspective).
12
+ *
13
+ * ⛔ No raw UUIDs: offering company name, type label, action chips, dates only;
14
+ * `row.uuid` is a key/API-arg only.
15
+ */
16
+ import { useCallback, useEffect, useRef, useState } from 'react';
17
+ import { Inbox } from 'lucide-react';
18
+ import { toast } from 'sonner';
19
+ import { useAuth } from '@/auth/AuthContext';
20
+ import AppShell from '@/shell/AppShell';
21
+ import PageHeader from '@/components/PageHeader';
22
+ import EntityList from '@/components/EntityList';
23
+ import ConfirmDialog from '@/components/ConfirmDialog';
24
+ import { Badge } from '@/components/ui/badge';
25
+ import { Button } from '@/components/ui/button';
26
+
27
+ /** Compact absolute date — e.g. "13 Jul 2026"; '—' when missing/invalid. */
28
+ function formatDate(ms) {
29
+ if (!ms && ms !== 0) return '—';
30
+ const d = new Date(ms);
31
+ if (Number.isNaN(d.getTime())) return '—';
32
+ return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
33
+ }
34
+
35
+ function ActionChips({ actions }) {
36
+ if (!actions || actions.length === 0) return <span className="text-xs text-muted-foreground">—</span>;
37
+ return (
38
+ <div className="flex flex-wrap gap-1">
39
+ {actions.map((a) => (
40
+ <Badge key={a} variant="secondary" className="font-normal">
41
+ {a}
42
+ </Badge>
43
+ ))}
44
+ </div>
45
+ );
46
+ }
47
+
48
+ export default function InboxPage() {
49
+ const { tokens, crossTenant } = useAuth();
50
+ const token = tokens?.accessToken;
51
+
52
+ const [rows, setRows] = useState([]);
53
+ const [loading, setLoading] = useState(true);
54
+ const [error, setError] = useState('');
55
+
56
+ const [busyUuid, setBusyUuid] = useState(null);
57
+ const [declineTarget, setDeclineTarget] = useState(null);
58
+ const [declineBusy, setDeclineBusy] = useState(false);
59
+
60
+ // Abort a superseded list request — mirrors UsersPage/AuditPage.
61
+ const loadAbortRef = useRef(/** @type {AbortController | null} */ (null));
62
+
63
+ const load = useCallback(async () => {
64
+ if (!token) return;
65
+ loadAbortRef.current?.abort();
66
+ const controller = new AbortController();
67
+ loadAbortRef.current = controller;
68
+
69
+ setLoading(true);
70
+ const r = await crossTenant.listInbox(token, { signal: controller.signal });
71
+
72
+ if (controller.signal.aborted || loadAbortRef.current !== controller) return;
73
+
74
+ if (r.ok) {
75
+ setRows(r.rows || []);
76
+ setError('');
77
+ } else {
78
+ setRows([]);
79
+ setError(r.error || 'Could not load your inbox.');
80
+ }
81
+ setLoading(false);
82
+ }, [crossTenant, token]);
83
+
84
+ useEffect(() => {
85
+ load();
86
+ return () => loadAbortRef.current?.abort();
87
+ }, [load]);
88
+
89
+ const onAccept = async (row) => {
90
+ setBusyUuid(row.uuid);
91
+ const r = await crossTenant.accept(token, row.uuid);
92
+ setBusyUuid(null);
93
+ if (!r.ok) {
94
+ toast.error(r.error || 'Could not accept the offer.');
95
+ return;
96
+ }
97
+ toast.success(`Accepted the offer from ${row.owningTenantName}`);
98
+ await load();
99
+ };
100
+
101
+ const onConfirmDecline = async () => {
102
+ if (!declineTarget) return;
103
+ setDeclineBusy(true);
104
+ const r = await crossTenant.revoke(token, declineTarget.uuid);
105
+ setDeclineBusy(false);
106
+ if (!r.ok) {
107
+ toast.error(r.error || 'Could not decline the offer.');
108
+ return;
109
+ }
110
+ setDeclineTarget(null);
111
+ toast.success('Offer declined');
112
+ await load();
113
+ };
114
+
115
+ const columns = [
116
+ {
117
+ key: 'owningTenantName',
118
+ label: 'Offering company',
119
+ render: (row) => <span className="font-medium text-foreground">{row.owningTenantName}</span>,
120
+ },
121
+ { key: 'typeLabel', label: 'Object type' },
122
+ { key: 'actions', label: 'Actions', render: (row) => <ActionChips actions={row.allowedActions} /> },
123
+ { key: 'expiresAtMs', label: 'Expires', render: (row) => formatDate(row.expiresAtMs) },
124
+ { key: 'offeredByName', label: 'Offered by' },
125
+ {
126
+ key: 'rowActions',
127
+ label: '',
128
+ render: (row) => (
129
+ <div className="flex justify-end gap-1">
130
+ <Button
131
+ type="button"
132
+ size="sm"
133
+ disabled={busyUuid === row.uuid}
134
+ onClick={() => onAccept(row)}
135
+ >
136
+ Accept
137
+ </Button>
138
+ <Button
139
+ type="button"
140
+ variant="ghost"
141
+ size="sm"
142
+ className="text-destructive hover:bg-destructive/10 hover:text-destructive"
143
+ disabled={busyUuid === row.uuid}
144
+ onClick={() => setDeclineTarget(row)}
145
+ >
146
+ Decline
147
+ </Button>
148
+ </div>
149
+ ),
150
+ },
151
+ ];
152
+
153
+ return (
154
+ <AppShell section="Identity" title="Grant inbox">
155
+ <div className="p-4">
156
+ <PageHeader section="Identity" title="Grant inbox" icon={Inbox} />
157
+
158
+ <EntityList
159
+ columns={columns}
160
+ rows={rows}
161
+ loading={loading}
162
+ error={error}
163
+ emptyLabel="No pending cross-tenant grant offers."
164
+ />
165
+ </div>
166
+
167
+ <ConfirmDialog
168
+ open={!!declineTarget}
169
+ title={declineTarget ? `Decline the offer from “${declineTarget.owningTenantName}”?` : 'Decline this offer?'}
170
+ message="This revokes the offer. The offering tenant would need to offer again for you to reconsider. This cannot be undone."
171
+ confirmLabel="Decline"
172
+ destructive
173
+ busy={declineBusy}
174
+ onConfirm={onConfirmDecline}
175
+ onCancel={() => setDeclineTarget(null)}
176
+ />
177
+ </AppShell>
178
+ );
179
+ }
@@ -16,11 +16,16 @@
16
16
  * via `GET access/role-grants` + `GET permission-catalog`, BOTH gated
17
17
  * `role:read` — "managing/reading grants IS role administration")
18
18
  * - Audit → `identity:role:read` (audit.controller.ts `GET audit`)
19
+ * - Cross-tenant → `identity:role:read` (cross-tenant-grant.controller.ts `GET
20
+ * cross-tenant-grants` / `GET .../inbox` / `GET .../offerable-types`,
21
+ * all gated `role:read` — reading/managing cross-tenant grants IS role
22
+ * administration; the OFFER write is separately `cross_tenant_grant:share`)
19
23
  * Dashboard / Account are left UNGATED — self/owned surfaces every authenticated
20
24
  * caller may reach (no admin-only list route backs them).
21
25
  */
22
26
  import {
23
27
  Building2,
28
+ Handshake,
24
29
  History,
25
30
  KeyRound,
26
31
  LayoutDashboard,
@@ -69,6 +74,14 @@ export const NAV_SECTIONS = [
69
74
  matchPrefix: true,
70
75
  gate: 'identity:role:read',
71
76
  },
77
+ {
78
+ icon: Handshake,
79
+ label: 'Cross-tenant',
80
+ path: '/cross-tenant',
81
+ badge: null,
82
+ matchPrefix: true,
83
+ gate: 'identity:role:read',
84
+ },
72
85
  {
73
86
  icon: History,
74
87
  label: 'Audit',
@@ -20,11 +20,11 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@astram/core": "^1.0.0",
23
- "@astram/engine": "^0.1.2",
24
- "@astram/guard": "^0.4.1",
23
+ "@astram/engine": "^0.1.3",
24
+ "@astram/guard": "^0.5.0",
25
25
  "@astram/guide": "^0.1.0",
26
- "@astram/identity": "^0.1.2",
27
- "@astram/migrate": "^0.1.1",
26
+ "@astram/identity": "^0.2.0",
27
+ "@astram/migrate": "^0.2.0",
28
28
  "@astram/sdk": "^0.2.0",
29
29
  "@nestjs/common": "^11.0.0",
30
30
  "@nestjs/core": "^11.0.0",
@@ -35,10 +35,36 @@ import { DemoController } from './health/demo.controller';
35
35
  * You then get framework-owned `POST /auth/{login,mfa,refresh,logout}` for free —
36
36
  * no controllers to write. (Leaving `auth` off keeps the zero-config enforcement
37
37
  * boot; turning it on requires the config above, by design — fail-closed.)
38
+ *
39
+ * ── OPTIONAL: the identity ADMIN CONSOLE (companies/users/roles CRUD) ──────────
40
+ * The framework also ships the identity management surface — company/user/role CRUD
41
+ * (memberships, access, audit in later versions) — as controllers you mount by
42
+ * importing ONE module. `@astram/guard` never imports `@astram/identity` (the
43
+ * dependency graph is identity → guard), so the consumer wires it (ADR-0026):
44
+ * 1. Turn auth on (above), pass the identity permission keys (so the frontend admin
45
+ * nav lights up), AND pass `recordAuthorization` (so the decision guard actually
46
+ * GRANTS — without it, decide() denies every classified route):
47
+ * import { identityAuthActionKeys, identityControlPlaneKeys, IdentityFeatureModule,
48
+ * createIdentityAuthzPool, buildIdentityRecordAuthorization } from '@astram/identity';
49
+ * const authzPool = createIdentityAuthzPool();
50
+ * imports: [
51
+ * ShieldModule.forRoot({ auth: true,
52
+ * identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys,
53
+ * recordAuthorization: buildIdentityRecordAuthorization(authzPool) }),
54
+ * IdentityFeatureModule.forRoot(), // mounts /identity/{companies,users,roles}
55
+ * ],
56
+ * `IdentityFeatureModule.forRoot()` self-wires the engine from your `APP_DB_*` env
57
+ * (no separate engine setup). Every route is `@Classified` — enforced by the same
58
+ * deny-by-default guard; `recordAuthorization` makes that guard render REAL decisions
59
+ * from your roles' grants. Omit the module and login still works; the admin pages 404.
38
60
  */
39
61
  @Module({
40
- // Enforcement-only by default (boots with no config). For real login, use:
41
- // imports: [ShieldModule.forRoot({ auth: true })],
62
+ // Enforcement-only by default (boots with no config). For real login + the identity
63
+ // admin console, use (see the OPTIONAL blocks above for the full form):
64
+ // imports: [
65
+ // ShieldModule.forRoot({ auth: true, identityActionKeys, identityControlPlaneKeys }),
66
+ // IdentityFeatureModule.forRoot(),
67
+ // ],
42
68
  imports: [ShieldModule.forRoot()],
43
69
  controllers: [HealthController, DemoController],
44
70
  })
@@ -2,15 +2,23 @@ import { Controller, Get } from '@nestjs/common';
2
2
  import { Public } from '@astram/guard';
3
3
 
4
4
  /**
5
- * Demonstration routes proving the deny-by-default enforcement seam (ADR-0002).
5
+ * Demonstration route proving the enforcement seam (ADR-0002).
6
6
  *
7
- * - `GET /demo/public` → `@Public()` → REACHABLE (200).
8
- * - `GET /demo/unclassified` → no metadata → DENIED by the global guard (403).
7
+ * - `GET /demo/public` → `@Public()` → REACHABLE (200): an explicit opt-out of the
8
+ * deny-by-default global guard.
9
9
  *
10
- * The second handler carries NO classification decorator on purpose: it shows
11
- * that forgetting to declare a route locks the door (deny-by-default) rather than
12
- * silently exposing it. This controller is illustrative — delete it once you've
13
- * seen enforcement work; a real feature would classify every route.
10
+ * Deny-by-default has TWO layers, and they differ in WHEN they bite:
11
+ * 1. **Boot-time (fail-closed, strongest):** the identity boot check (`verifyGatable`)
12
+ * scans every route and ABORTS BOOT if any non-`@Public` route lacks `@Classified`.
13
+ * A forgotten declaration doesn't just get denied at request time — the app won't
14
+ * start. That is why the illustrative "unclassified route" below is a COMMENT, not a
15
+ * live handler: a live one would prevent this app from booting. Uncomment it only to
16
+ * SEE the boot abort, then re-comment (or classify it).
17
+ * 2. **Request-time (per-request):** even absent the boot check, the global guard denies
18
+ * an unclassified route with 403.
19
+ *
20
+ * This controller is illustrative — delete it once you've seen enforcement work; a real
21
+ * feature classifies every route with `@Public()` or `@Classified(...)`.
14
22
  */
15
23
  @Controller('demo')
16
24
  export class DemoController {
@@ -20,9 +28,12 @@ export class DemoController {
20
28
  return { message: 'public route reachable — explicit @Public() opt-out' };
21
29
  }
22
30
 
23
- // NOTE: intentionally NO @Public() / @Classified() — must be DENIED (403).
24
- @Get('unclassified')
25
- unclassifiedRoute(): { message: string } {
26
- return { message: 'this body must never be reached — the guard denies first' };
27
- }
31
+ // Intentionally NO @Public() / @Classified(). Kept as a COMMENT because the boot-time
32
+ // `verifyGatable` (fail-closed) aborts boot on any unclassified route — a live handler
33
+ // here would stop the app from starting. Uncomment to witness the boot abort, then
34
+ // re-comment or classify it.
35
+ // @Get('unclassified')
36
+ // unclassifiedRoute(): { message: string } {
37
+ // return { message: 'this body must never be reached — the guard denies first' };
38
+ // }
28
39
  }