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,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,22 @@ export const NAV_SECTIONS = [
69
74
  matchPrefix: true,
70
75
  gate: 'identity:role:read',
71
76
  },
77
+ {
78
+ icon: ShieldCheck,
79
+ label: 'Record policies',
80
+ path: '/record-policies',
81
+ badge: null,
82
+ matchPrefix: true,
83
+ gate: 'identity:role:read',
84
+ },
85
+ {
86
+ icon: Handshake,
87
+ label: 'Cross-tenant',
88
+ path: '/cross-tenant',
89
+ badge: null,
90
+ matchPrefix: true,
91
+ gate: 'identity:role:read',
92
+ },
72
93
  {
73
94
  icon: History,
74
95
  label: 'Audit',
@@ -19,12 +19,12 @@
19
19
  "postinstall": "node scripts/sync-astram-guide.mjs"
20
20
  },
21
21
  "dependencies": {
22
- "@astram/core": "^1.0.0",
23
- "@astram/engine": "^0.1.2",
24
- "@astram/guard": "^0.4.1",
22
+ "@astram/core": "^1.2.0",
23
+ "@astram/engine": "^0.2.0",
24
+ "@astram/guard": "^0.6.0",
25
25
  "@astram/guide": "^0.1.0",
26
- "@astram/identity": "^0.1.2",
27
- "@astram/migrate": "^0.1.1",
26
+ "@astram/identity": "^0.3.1",
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,48 @@ 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 + authz) ───
40
+ * The framework also ships the identity management surface — company/user/role CRUD,
41
+ * memberships, access/grants, audit, cross-tenant grants, the GUIDED RULE-BUILDER
42
+ * (record-access policies), and OWNER SHARES — as controllers you mount by importing
43
+ * ONE module. `@astram/guard` never imports `@astram/identity` (the dependency graph is
44
+ * identity → guard), so the consumer wires it (ADR-0026):
45
+ * 1. Turn auth on (above), pass the identity permission keys (so the frontend admin
46
+ * nav lights up), AND pass `recordAuthorization` (so the decision guard actually
47
+ * GRANTS — without it, decide() denies every classified route):
48
+ * import { identityAuthActionKeys, identityControlPlaneKeys, IdentityFeatureModule,
49
+ * createIdentityAuthzPool, buildIdentityRecordAuthorization } from '@astram/identity';
50
+ * const authzPool = createIdentityAuthzPool();
51
+ * imports: [
52
+ * ShieldModule.forRoot({ auth: true,
53
+ * identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys,
54
+ * recordAuthorization: buildIdentityRecordAuthorization(authzPool) }),
55
+ * IdentityFeatureModule.forRoot(), // mounts /identity/{companies,users,roles,…}
56
+ * ],
57
+ * `IdentityFeatureModule.forRoot()` self-wires the engine from your `APP_DB_*` env
58
+ * (no separate engine setup). Every route is `@Classified` — enforced by the same
59
+ * deny-by-default guard; `recordAuthorization` makes that guard render REAL decisions
60
+ * from your roles' grants. Omit the module and login still works; the admin pages 404.
61
+ *
62
+ * ⛔ IF YOU REGISTER YOUR OWN OWNED OBJECT TYPES (e.g. a `Note`): pass them BOTH to
63
+ * `buildIdentityRecordAuthorization(authzPool, [Note])` (so their action keys project
64
+ * into gate-5 grants) AND to `IdentityFeatureModule.forRoot({ extraObjects: [Note] })`
65
+ * (so the GUIDED RULE-BUILDER schema + the OWNER-SHARE resolver know them — else a
66
+ * record of your type is NOT policy-authorable and NOT shareable: "type '…' is not
67
+ * registered — cannot share"). The frontend ships the matching pages/widget
68
+ * (RecordPoliciesPage, ShareDialog) already wired to `useAuth().recordPolicy` /
69
+ * `.recordShare`.
38
70
  */
39
71
  @Module({
40
- // Enforcement-only by default (boots with no config). For real login, use:
41
- // imports: [ShieldModule.forRoot({ auth: true })],
72
+ // Enforcement-only by default (boots with no config). For real login + the identity
73
+ // admin console (incl. the guided rule-builder + owner shares), use (see the OPTIONAL
74
+ // blocks above for the full form + the extraObjects note for your OWN owned types):
75
+ // imports: [
76
+ // ShieldModule.forRoot({ auth: true, identityActionKeys, identityControlPlaneKeys,
77
+ // recordAuthorization: buildIdentityRecordAuthorization(authzPool, [/* YourType */]) }),
78
+ // IdentityFeatureModule.forRoot({ /* extraObjects: [YourType] */ }),
79
+ // ],
42
80
  imports: [ShieldModule.forRoot()],
43
81
  controllers: [HealthController, DemoController],
44
82
  })
@@ -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
  }