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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-astram-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -28,7 +28,11 @@ import UsersPage from './pages/users/UsersPage';
|
|
|
28
28
|
import UserForm from './pages/users/UserForm';
|
|
29
29
|
import AccessPage from './pages/access/AccessPage';
|
|
30
30
|
import AccessForm from './pages/access/AccessForm';
|
|
31
|
+
import RecordPoliciesPage from './pages/access/RecordPoliciesPage';
|
|
31
32
|
import AuditPage from './pages/audit/AuditPage';
|
|
33
|
+
import GrantsPage from './pages/cross-tenant/GrantsPage';
|
|
34
|
+
import GrantForm from './pages/cross-tenant/GrantForm';
|
|
35
|
+
import InboxPage from './pages/cross-tenant/InboxPage';
|
|
32
36
|
|
|
33
37
|
/** The single forced-re-enroll screen path (the enforcement's frontend twin). */
|
|
34
38
|
const REENROLL_PATH = '/account/reenroll';
|
|
@@ -152,6 +156,38 @@ export default function App() {
|
|
|
152
156
|
<Route path="add" element={<AccessForm />} />
|
|
153
157
|
<Route path="edit/:uuid" element={<AccessForm />} />
|
|
154
158
|
</Route>
|
|
159
|
+
{/* Guided rule-builder — author/list/delete record-access policies (§9.1). Gated
|
|
160
|
+
identity:role:read (policy authoring IS access administration; the write gate is
|
|
161
|
+
role:update, enforced server-side on POST/DELETE). */}
|
|
162
|
+
<Route
|
|
163
|
+
path="/record-policies"
|
|
164
|
+
element={
|
|
165
|
+
<RequireAuth gate="identity:role:read">
|
|
166
|
+
<RecordPoliciesPage />
|
|
167
|
+
</RequireAuth>
|
|
168
|
+
}
|
|
169
|
+
/>
|
|
170
|
+
{/* Cross-tenant grants — the grant book (mine + incoming) is a LAYOUT route; the
|
|
171
|
+
Offer form (`offer`) renders as a routed-modal overlay under it. The pending
|
|
172
|
+
Inbox is a sibling route. All gated `identity:role:read` (mirrors nav.js). */}
|
|
173
|
+
<Route
|
|
174
|
+
path="/cross-tenant"
|
|
175
|
+
element={
|
|
176
|
+
<RequireAuth gate="identity:role:read">
|
|
177
|
+
<GrantsPage />
|
|
178
|
+
</RequireAuth>
|
|
179
|
+
}
|
|
180
|
+
>
|
|
181
|
+
<Route path="offer" element={<GrantForm />} />
|
|
182
|
+
</Route>
|
|
183
|
+
<Route
|
|
184
|
+
path="/cross-tenant/inbox"
|
|
185
|
+
element={
|
|
186
|
+
<RequireAuth gate="identity:role:read">
|
|
187
|
+
<InboxPage />
|
|
188
|
+
</RequireAuth>
|
|
189
|
+
}
|
|
190
|
+
/>
|
|
155
191
|
{/* Audit — read-only, no child add/edit routes (audit events are immutable). */}
|
|
156
192
|
<Route
|
|
157
193
|
path="/audit"
|
|
@@ -15,7 +15,10 @@ import {
|
|
|
15
15
|
createAccountService,
|
|
16
16
|
createAuditService,
|
|
17
17
|
createAuthService,
|
|
18
|
+
createCrossTenantService,
|
|
18
19
|
createIdentityService,
|
|
20
|
+
createRecordPolicyService,
|
|
21
|
+
createRecordShareService,
|
|
19
22
|
isAuthenticated,
|
|
20
23
|
principalOf,
|
|
21
24
|
} from '@astram/frontend-core';
|
|
@@ -64,6 +67,12 @@ export function AuthProvider({ children }) {
|
|
|
64
67
|
auth: createAuthService(http),
|
|
65
68
|
identity: createIdentityService(http),
|
|
66
69
|
audit: createAuditService(http),
|
|
70
|
+
crossTenant: createCrossTenantService(http),
|
|
71
|
+
// The guided rule-builder surface (§9.1) — author/list/delete record-access policies +
|
|
72
|
+
// the field/operator schema that drives the builder's dropdowns.
|
|
73
|
+
recordPolicy: createRecordPolicyService(http),
|
|
74
|
+
// The owner-share surface (§9.2) — share/unshare/list a record's shares (owner-only).
|
|
75
|
+
recordShare: createRecordShareService(http),
|
|
67
76
|
account: createAccountService(http),
|
|
68
77
|
// The @astram/sdk ShieldClient (Phase E slice 1) — a UX-convenience projection of
|
|
69
78
|
// the caller's OWN coarse grants, fetched once per session via `getAuthFacts`.
|
|
@@ -79,7 +88,8 @@ export function AuthProvider({ children }) {
|
|
|
79
88
|
store,
|
|
80
89
|
};
|
|
81
90
|
}
|
|
82
|
-
const { auth, identity, audit, account, shield, store } =
|
|
91
|
+
const { auth, identity, audit, crossTenant, recordPolicy, recordShare, account, shield, store } =
|
|
92
|
+
services.current;
|
|
83
93
|
|
|
84
94
|
const [state, setState] = useState(() => store.load()); // SessionState | null
|
|
85
95
|
// The signed-in user's DISPLAY profile (name/email/company/roles) from GET /auth/me.
|
|
@@ -279,6 +289,14 @@ export function AuthProvider({ children }) {
|
|
|
279
289
|
identity,
|
|
280
290
|
// The shared audit-log service (audit viewer) — same injected web http client.
|
|
281
291
|
audit,
|
|
292
|
+
// The shared cross-tenant grant service (offer/accept/revoke + inbox/mine +
|
|
293
|
+
// offerable-types) — same injected web http client.
|
|
294
|
+
crossTenant,
|
|
295
|
+
// The shared guided rule-builder service (author/list/delete record policies + the
|
|
296
|
+
// field/operator schema) — same injected web http client.
|
|
297
|
+
recordPolicy,
|
|
298
|
+
// The shared owner-share service (share/unshare/list a record's shares) — same client.
|
|
299
|
+
recordShare,
|
|
282
300
|
// The shared self-scoped account service (change-password today; profile/MFA
|
|
283
301
|
// later) — same injected web http client.
|
|
284
302
|
account,
|
|
@@ -301,7 +319,7 @@ export function AuthProvider({ children }) {
|
|
|
301
319
|
),
|
|
302
320
|
}),
|
|
303
321
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
304
|
-
[state, auth, identity, audit, account, shield, profile, mustReenroll, authFacts],
|
|
322
|
+
[state, auth, identity, audit, crossTenant, recordPolicy, recordShare, account, shield, profile, mustReenroll, authFacts],
|
|
305
323
|
);
|
|
306
324
|
|
|
307
325
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShareDialog — the reusable OWNER-SHARE widget (§9.2 / list-scoping engine Phase 6).
|
|
3
|
+
*
|
|
4
|
+
* A record's owner opens this from a record's detail/list to manage WHO can see it: it lists the
|
|
5
|
+
* record's current shares (grantee NAMES, never raw uuids), lets the owner add a share (grantee
|
|
6
|
+
* kind → user/role searchable picker → action → Add), and revoke one with a confirm. The server
|
|
7
|
+
* enforces owner-only (a non-owner gets 403); the tenant + sharer are resolved server-side.
|
|
8
|
+
*
|
|
9
|
+
* Props:
|
|
10
|
+
* open, onOpenChange — dialog control.
|
|
11
|
+
* typeKey, recordId — the record being shared (the owner-bound is server-enforced).
|
|
12
|
+
* recordLabel — a human label for the title (never the uuid).
|
|
13
|
+
* actions — the action vocabulary to offer (defaults to ['read']).
|
|
14
|
+
*/
|
|
15
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
16
|
+
import { Share2, Trash2 } from 'lucide-react';
|
|
17
|
+
import { toast } from 'sonner';
|
|
18
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
19
|
+
import {
|
|
20
|
+
Dialog,
|
|
21
|
+
DialogContent,
|
|
22
|
+
DialogHeader,
|
|
23
|
+
DialogTitle,
|
|
24
|
+
DialogDescription,
|
|
25
|
+
} from '@/components/ui/dialog';
|
|
26
|
+
import { Button } from '@/components/ui/button';
|
|
27
|
+
import { Label } from '@/components/ui/label';
|
|
28
|
+
import { Badge } from '@/components/ui/badge';
|
|
29
|
+
import {
|
|
30
|
+
Select,
|
|
31
|
+
SelectContent,
|
|
32
|
+
SelectItem,
|
|
33
|
+
SelectTrigger,
|
|
34
|
+
SelectValue,
|
|
35
|
+
} from '@/components/ui/select';
|
|
36
|
+
import { SearchableSelect } from '@/components/ui/searchable-select';
|
|
37
|
+
import ConfirmDialog from '@/components/ConfirmDialog';
|
|
38
|
+
|
|
39
|
+
export default function ShareDialog({
|
|
40
|
+
open,
|
|
41
|
+
onOpenChange,
|
|
42
|
+
typeKey,
|
|
43
|
+
recordId,
|
|
44
|
+
recordLabel,
|
|
45
|
+
actions = ['read'],
|
|
46
|
+
}) {
|
|
47
|
+
const { tokens, recordShare, identity } = useAuth();
|
|
48
|
+
const token = tokens?.accessToken;
|
|
49
|
+
|
|
50
|
+
const [shares, setShares] = useState([]);
|
|
51
|
+
const [loading, setLoading] = useState(false);
|
|
52
|
+
const [error, setError] = useState('');
|
|
53
|
+
|
|
54
|
+
// The add-share form.
|
|
55
|
+
const [granteeKind, setGranteeKind] = useState('user');
|
|
56
|
+
const [grantee, setGrantee] = useState(null); // { value, label }
|
|
57
|
+
const [action, setAction] = useState(actions[0] ?? 'read');
|
|
58
|
+
const [adding, setAdding] = useState(false);
|
|
59
|
+
|
|
60
|
+
const [revokeTarget, setRevokeTarget] = useState(null);
|
|
61
|
+
const [revokeBusy, setRevokeBusy] = useState(false);
|
|
62
|
+
|
|
63
|
+
const load = useCallback(async () => {
|
|
64
|
+
if (!token || !open) return;
|
|
65
|
+
setLoading(true);
|
|
66
|
+
const r = await recordShare.listShares(token, typeKey, recordId);
|
|
67
|
+
if (r.ok) {
|
|
68
|
+
setShares(r.shares);
|
|
69
|
+
setError('');
|
|
70
|
+
} else {
|
|
71
|
+
setShares([]);
|
|
72
|
+
setError(r.error || 'Could not load the shares.');
|
|
73
|
+
}
|
|
74
|
+
setLoading(false);
|
|
75
|
+
}, [recordShare, token, typeKey, recordId, open]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
load();
|
|
79
|
+
}, [load]);
|
|
80
|
+
|
|
81
|
+
// Reset the grantee picker when the kind flips (user vs role are different directories).
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
setGrantee(null);
|
|
84
|
+
}, [granteeKind]);
|
|
85
|
+
|
|
86
|
+
// Fetch grantee options (users or roles) for the searchable picker — value=uuid, label=name.
|
|
87
|
+
const fetchGrantees = useCallback(
|
|
88
|
+
async ({ search, page }) => {
|
|
89
|
+
const opts = { page, limit: 10, search };
|
|
90
|
+
const r =
|
|
91
|
+
granteeKind === 'role'
|
|
92
|
+
? await identity.listRoles(token, opts)
|
|
93
|
+
: await identity.listUsers(token, opts);
|
|
94
|
+
if (!r.ok) return { options: [], total: 0, page: page ?? 1 };
|
|
95
|
+
const options = (r.rows || []).map((row) => ({
|
|
96
|
+
value: row.id ?? row.uuid,
|
|
97
|
+
label:
|
|
98
|
+
granteeKind === 'role'
|
|
99
|
+
? row.fields?.name ?? '(role)'
|
|
100
|
+
: row.fields?.username ?? row.fields?.email ?? '(user)',
|
|
101
|
+
}));
|
|
102
|
+
return { options, total: r.total ?? options.length, page: page ?? 1 };
|
|
103
|
+
},
|
|
104
|
+
[identity, token, granteeKind],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const onAdd = async () => {
|
|
108
|
+
if (!grantee) {
|
|
109
|
+
toast.error('Pick a grantee.');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
setAdding(true);
|
|
113
|
+
const r = await recordShare.share(token, {
|
|
114
|
+
typeKey,
|
|
115
|
+
recordId,
|
|
116
|
+
granteeKind,
|
|
117
|
+
granteeUuid: grantee.value,
|
|
118
|
+
action,
|
|
119
|
+
});
|
|
120
|
+
setAdding(false);
|
|
121
|
+
if (!r.ok) {
|
|
122
|
+
toast.error(r.error || 'Could not share the record.');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
toast.success('Shared');
|
|
126
|
+
setGrantee(null);
|
|
127
|
+
await load();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const onConfirmRevoke = async () => {
|
|
131
|
+
if (!revokeTarget) return;
|
|
132
|
+
setRevokeBusy(true);
|
|
133
|
+
const r = await recordShare.unshare(token, {
|
|
134
|
+
typeKey,
|
|
135
|
+
recordId,
|
|
136
|
+
granteeKind: revokeTarget.granteeKind,
|
|
137
|
+
granteeUuid: revokeTarget.granteeUuid,
|
|
138
|
+
action: revokeTarget.action,
|
|
139
|
+
});
|
|
140
|
+
setRevokeBusy(false);
|
|
141
|
+
if (!r.ok) {
|
|
142
|
+
toast.error(r.error || 'Could not revoke the share.');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
setRevokeTarget(null);
|
|
146
|
+
toast.success('Share revoked');
|
|
147
|
+
await load();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
152
|
+
<DialogContent className="max-w-lg">
|
|
153
|
+
<DialogHeader>
|
|
154
|
+
<DialogTitle className="flex items-center gap-2">
|
|
155
|
+
<Share2 className="size-4" />
|
|
156
|
+
Share {recordLabel ? `“${recordLabel}”` : 'record'}
|
|
157
|
+
</DialogTitle>
|
|
158
|
+
<DialogDescription>
|
|
159
|
+
Grant a user or role access to this record. Only you (the owner) can manage its shares.
|
|
160
|
+
</DialogDescription>
|
|
161
|
+
</DialogHeader>
|
|
162
|
+
|
|
163
|
+
{/* Current shares */}
|
|
164
|
+
<div className="space-y-2">
|
|
165
|
+
<Label>Shared with</Label>
|
|
166
|
+
{loading ? (
|
|
167
|
+
<div className="text-sm text-muted-foreground">Loading…</div>
|
|
168
|
+
) : error ? (
|
|
169
|
+
<div className="text-sm text-destructive">{error}</div>
|
|
170
|
+
) : shares.length === 0 ? (
|
|
171
|
+
<div className="text-sm text-muted-foreground">Not shared with anyone yet.</div>
|
|
172
|
+
) : (
|
|
173
|
+
<ul className="divide-y divide-border rounded-md border border-border">
|
|
174
|
+
{shares.map((s) => (
|
|
175
|
+
<li
|
|
176
|
+
key={`${s.granteeKind}:${s.granteeUuid}:${s.action}`}
|
|
177
|
+
className="flex items-center justify-between px-3 py-2"
|
|
178
|
+
>
|
|
179
|
+
<span className="flex items-center gap-2 text-sm">
|
|
180
|
+
<span className="font-medium text-foreground">{s.granteeName}</span>
|
|
181
|
+
<span className="text-xs text-muted-foreground">({s.granteeKind})</span>
|
|
182
|
+
<Badge variant="secondary" className="font-normal">
|
|
183
|
+
{s.action}
|
|
184
|
+
</Badge>
|
|
185
|
+
</span>
|
|
186
|
+
<Button
|
|
187
|
+
type="button"
|
|
188
|
+
variant="ghost"
|
|
189
|
+
size="sm"
|
|
190
|
+
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
191
|
+
onClick={() => setRevokeTarget(s)}
|
|
192
|
+
>
|
|
193
|
+
<Trash2 className="size-4" />
|
|
194
|
+
Revoke
|
|
195
|
+
</Button>
|
|
196
|
+
</li>
|
|
197
|
+
))}
|
|
198
|
+
</ul>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{/* Add a share */}
|
|
203
|
+
<div className="mt-2 space-y-3 rounded-md border border-border p-3">
|
|
204
|
+
<Label>Add access</Label>
|
|
205
|
+
<div className="flex flex-wrap items-end gap-2">
|
|
206
|
+
<div className="space-y-1">
|
|
207
|
+
<Label className="text-xs text-muted-foreground">Grantee kind</Label>
|
|
208
|
+
<Select value={granteeKind} onValueChange={setGranteeKind}>
|
|
209
|
+
<SelectTrigger className="w-28">
|
|
210
|
+
<SelectValue />
|
|
211
|
+
</SelectTrigger>
|
|
212
|
+
<SelectContent>
|
|
213
|
+
<SelectItem value="user">User</SelectItem>
|
|
214
|
+
<SelectItem value="role">Role</SelectItem>
|
|
215
|
+
</SelectContent>
|
|
216
|
+
</Select>
|
|
217
|
+
</div>
|
|
218
|
+
<div className="min-w-48 flex-1 space-y-1">
|
|
219
|
+
<Label className="text-xs text-muted-foreground">
|
|
220
|
+
{granteeKind === 'role' ? 'Role' : 'User'}
|
|
221
|
+
</Label>
|
|
222
|
+
<SearchableSelect
|
|
223
|
+
value={grantee?.value ?? null}
|
|
224
|
+
selectedLabel={grantee?.label ?? null}
|
|
225
|
+
onChange={(value) =>
|
|
226
|
+
setGrantee(value ? { value, label: grantee?.label ?? '' } : null)
|
|
227
|
+
}
|
|
228
|
+
onLabelChange={(label) =>
|
|
229
|
+
setGrantee((g) => (g ? { ...g, label: label ?? '' } : g))
|
|
230
|
+
}
|
|
231
|
+
fetchPage={fetchGrantees}
|
|
232
|
+
placeholder={`Pick a ${granteeKind}`}
|
|
233
|
+
/>
|
|
234
|
+
</div>
|
|
235
|
+
<div className="space-y-1">
|
|
236
|
+
<Label className="text-xs text-muted-foreground">Action</Label>
|
|
237
|
+
<Select value={action} onValueChange={setAction}>
|
|
238
|
+
<SelectTrigger className="w-28">
|
|
239
|
+
<SelectValue />
|
|
240
|
+
</SelectTrigger>
|
|
241
|
+
<SelectContent>
|
|
242
|
+
{actions.map((a) => (
|
|
243
|
+
<SelectItem key={a} value={a}>
|
|
244
|
+
{a}
|
|
245
|
+
</SelectItem>
|
|
246
|
+
))}
|
|
247
|
+
</SelectContent>
|
|
248
|
+
</Select>
|
|
249
|
+
</div>
|
|
250
|
+
<Button type="button" size="sm" onClick={onAdd} disabled={adding || !grantee}>
|
|
251
|
+
{adding ? 'Adding…' : 'Add'}
|
|
252
|
+
</Button>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
</DialogContent>
|
|
256
|
+
|
|
257
|
+
<ConfirmDialog
|
|
258
|
+
open={!!revokeTarget}
|
|
259
|
+
title={revokeTarget ? `Revoke ${revokeTarget.granteeName}'s access?` : 'Revoke access?'}
|
|
260
|
+
message="This removes their access to this record. This cannot be undone."
|
|
261
|
+
confirmLabel="Revoke"
|
|
262
|
+
destructive
|
|
263
|
+
busy={revokeBusy}
|
|
264
|
+
onConfirm={onConfirmRevoke}
|
|
265
|
+
onCancel={() => setRevokeTarget(null)}
|
|
266
|
+
/>
|
|
267
|
+
</Dialog>
|
|
268
|
+
);
|
|
269
|
+
}
|