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.
- package/package.json +1 -1
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +36 -0
- package/template/frontend/src/auth/AuthContext.jsx +20 -2
- package/template/frontend/src/components/ShareDialog.jsx +269 -0
- package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +390 -0
- package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +337 -0
- package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +237 -0
- package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +179 -0
- package/template/frontend/src/shell/nav.js +21 -0
- package/template/server/package.json +5 -5
- package/template/server/src/app.module.ts +40 -2
- package/template/server/src/health/demo.controller.ts +23 -12
|
@@ -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.
|
|
23
|
-
"@astram/engine": "^0.
|
|
24
|
-
"@astram/guard": "^0.
|
|
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
|
|
27
|
-
"@astram/migrate": "^0.
|
|
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
|
|
41
|
-
//
|
|
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
|
|
5
|
+
* Demonstration route proving the enforcement seam (ADR-0002).
|
|
6
6
|
*
|
|
7
|
-
* - `GET /demo/public`
|
|
8
|
-
*
|
|
7
|
+
* - `GET /demo/public` → `@Public()` → REACHABLE (200): an explicit opt-out of the
|
|
8
|
+
* deny-by-default global guard.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
}
|