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,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RecordPoliciesPage — the GUIDED RULE-BUILDER (§9.1 / list-scoping engine Phase 5).
|
|
3
|
+
*
|
|
4
|
+
* Author record-access policies through a STRUCTURED, no-code form — never a raw expression:
|
|
5
|
+
* pick object type → load its schema → pick action + effect → add conditions
|
|
6
|
+
* (field dropdown → operator dropdown VALID for that field's type → value input typed per
|
|
7
|
+
* operator) → Save. The current policies for the selected type are listed with delete-confirm.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ The field/operator dropdowns come from the SERVER schema
|
|
10
|
+
* (`GET /identity/record-policies/schema`), so the builder can only compose combinations the
|
|
11
|
+
* server accepts — one source of truth, no client-side operator table. The server re-validates
|
|
12
|
+
* (`validateGuidedPolicy`) and enforces the delegated-admin ceiling on save.
|
|
13
|
+
* ⛔ The target TENANT is never sent — the server authors in the caller's own tenant.
|
|
14
|
+
* ⛔ No raw UUID is shown — `policyId` is a delete key only.
|
|
15
|
+
*/
|
|
16
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
17
|
+
import { ShieldCheck, Plus, Trash2, Info } 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
|
+
import { Input } from '@/components/ui/input';
|
|
27
|
+
import { Label } from '@/components/ui/label';
|
|
28
|
+
import {
|
|
29
|
+
Select,
|
|
30
|
+
SelectContent,
|
|
31
|
+
SelectItem,
|
|
32
|
+
SelectTrigger,
|
|
33
|
+
SelectValue,
|
|
34
|
+
} from '@/components/ui/select';
|
|
35
|
+
|
|
36
|
+
/** The object types this app lets an admin author policies for. Extend as the app grows. */
|
|
37
|
+
const AUTHORABLE_TYPES = ['note'];
|
|
38
|
+
|
|
39
|
+
/** Human labels for the closed operator set. */
|
|
40
|
+
const OPERATOR_LABELS = {
|
|
41
|
+
eq: 'equals',
|
|
42
|
+
ne: 'does not equal',
|
|
43
|
+
in: 'is any of',
|
|
44
|
+
not_in: 'is none of',
|
|
45
|
+
gt: 'is greater than',
|
|
46
|
+
lt: 'is less than',
|
|
47
|
+
gte: 'is at least',
|
|
48
|
+
lte: 'is at most',
|
|
49
|
+
contains: 'contains',
|
|
50
|
+
not_contains: 'does not contain',
|
|
51
|
+
is_null: 'is empty',
|
|
52
|
+
is_not_null: 'is not empty',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** A fresh, empty condition row. */
|
|
56
|
+
const emptyCondition = () => ({ field: '', operator: '', value: '' });
|
|
57
|
+
|
|
58
|
+
export default function RecordPoliciesPage() {
|
|
59
|
+
const { tokens, recordPolicy } = useAuth();
|
|
60
|
+
const token = tokens?.accessToken;
|
|
61
|
+
|
|
62
|
+
const [typeKey, setTypeKey] = useState(AUTHORABLE_TYPES[0] ?? '');
|
|
63
|
+
const [schema, setSchema] = useState(null);
|
|
64
|
+
const [policies, setPolicies] = useState([]);
|
|
65
|
+
const [loading, setLoading] = useState(true);
|
|
66
|
+
const [error, setError] = useState('');
|
|
67
|
+
|
|
68
|
+
// The in-progress policy being built.
|
|
69
|
+
const [action, setAction] = useState('');
|
|
70
|
+
const [effect, setEffect] = useState('allow');
|
|
71
|
+
const [conditions, setConditions] = useState([emptyCondition()]);
|
|
72
|
+
const [saving, setSaving] = useState(false);
|
|
73
|
+
|
|
74
|
+
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
75
|
+
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
76
|
+
|
|
77
|
+
const fieldsByKey = useMemo(() => {
|
|
78
|
+
const m = new Map();
|
|
79
|
+
for (const f of schema?.fields ?? []) m.set(f.key, f);
|
|
80
|
+
return m;
|
|
81
|
+
}, [schema]);
|
|
82
|
+
|
|
83
|
+
const load = useCallback(async () => {
|
|
84
|
+
if (!token || !typeKey) return;
|
|
85
|
+
setLoading(true);
|
|
86
|
+
const [s, p] = await Promise.all([
|
|
87
|
+
recordPolicy.schema(token, typeKey),
|
|
88
|
+
recordPolicy.list(token, typeKey),
|
|
89
|
+
]);
|
|
90
|
+
if (s.ok) {
|
|
91
|
+
setSchema(s.schema);
|
|
92
|
+
// Default the action to the type's first action.
|
|
93
|
+
setAction((prev) => prev || s.schema.actions?.[0] || '');
|
|
94
|
+
setError('');
|
|
95
|
+
} else {
|
|
96
|
+
setSchema(null);
|
|
97
|
+
setError(s.error || 'Could not load the policy schema for this type.');
|
|
98
|
+
}
|
|
99
|
+
setPolicies(p.ok ? p.policies : []);
|
|
100
|
+
setLoading(false);
|
|
101
|
+
}, [recordPolicy, token, typeKey]);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
load();
|
|
105
|
+
}, [load]);
|
|
106
|
+
|
|
107
|
+
// Reset the builder when the type changes (fields/actions differ per type).
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
setAction('');
|
|
110
|
+
setConditions([emptyCondition()]);
|
|
111
|
+
}, [typeKey]);
|
|
112
|
+
|
|
113
|
+
const setCondition = (i, patch) =>
|
|
114
|
+
setConditions((cs) => cs.map((c, idx) => (idx === i ? { ...c, ...patch } : c)));
|
|
115
|
+
const addCondition = () => setConditions((cs) => [...cs, emptyCondition()]);
|
|
116
|
+
const removeCondition = (i) =>
|
|
117
|
+
setConditions((cs) => (cs.length <= 1 ? cs : cs.filter((_, idx) => idx !== i)));
|
|
118
|
+
|
|
119
|
+
const onSave = async () => {
|
|
120
|
+
if (!action) {
|
|
121
|
+
toast.error('Pick an action.');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Build the structured conditions, dropping incomplete rows; a nullary op needs no value.
|
|
125
|
+
const built = [];
|
|
126
|
+
for (const c of conditions) {
|
|
127
|
+
if (!c.field || !c.operator) continue;
|
|
128
|
+
const fieldSchema = fieldsByKey.get(c.field);
|
|
129
|
+
const isNullary = fieldSchema?.nullaryOperators?.includes(c.operator);
|
|
130
|
+
const isArray = fieldSchema?.arrayOperators?.includes(c.operator);
|
|
131
|
+
if (isNullary) {
|
|
132
|
+
built.push({ field: c.field, operator: c.operator });
|
|
133
|
+
} else if (isArray) {
|
|
134
|
+
const values = String(c.value ?? '')
|
|
135
|
+
.split(',')
|
|
136
|
+
.map((v) => v.trim())
|
|
137
|
+
.filter(Boolean);
|
|
138
|
+
built.push({ field: c.field, operator: c.operator, value: values });
|
|
139
|
+
} else {
|
|
140
|
+
built.push({ field: c.field, operator: c.operator, value: c.value });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
setSaving(true);
|
|
145
|
+
const r = await recordPolicy.author(token, { typeKey, action, effect, conditions: built });
|
|
146
|
+
setSaving(false);
|
|
147
|
+
if (!r.ok) {
|
|
148
|
+
toast.error(r.error || 'Could not author the policy.');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
toast.success('Policy authored');
|
|
152
|
+
setConditions([emptyCondition()]);
|
|
153
|
+
await load();
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const onConfirmDelete = async () => {
|
|
157
|
+
if (!deleteTarget) return;
|
|
158
|
+
setDeleteBusy(true);
|
|
159
|
+
const r = await recordPolicy.remove(token, deleteTarget.policyId);
|
|
160
|
+
setDeleteBusy(false);
|
|
161
|
+
if (!r.ok) {
|
|
162
|
+
toast.error(r.error || 'Could not delete the policy.');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
setDeleteTarget(null);
|
|
166
|
+
toast.success('Policy deleted');
|
|
167
|
+
await load();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Render one policy as a human sentence: "allow read where branch equals north AND …".
|
|
171
|
+
const describe = (policy) => {
|
|
172
|
+
const parts = (policy.conditions ?? []).map((c) => {
|
|
173
|
+
const label = OPERATOR_LABELS[c.operator] ?? c.operator;
|
|
174
|
+
const val = Array.isArray(c.value) ? c.value.join(', ') : c.value;
|
|
175
|
+
const isNullary = c.operator === 'is_null' || c.operator === 'is_not_null';
|
|
176
|
+
return `${c.field} ${label}${isNullary ? '' : ` ${val}`}`;
|
|
177
|
+
});
|
|
178
|
+
const where = parts.length ? ` where ${parts.join(' AND ')}` : ' (all records)';
|
|
179
|
+
return `${policy.effect} ${policy.action}${where}`;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const policyColumns = [
|
|
183
|
+
{
|
|
184
|
+
key: 'effect',
|
|
185
|
+
label: 'Effect',
|
|
186
|
+
render: (row) => (
|
|
187
|
+
<Badge variant={row.policy.effect === 'deny' ? 'destructive' : 'success'} className="capitalize font-normal">
|
|
188
|
+
{row.policy.effect}
|
|
189
|
+
</Badge>
|
|
190
|
+
),
|
|
191
|
+
},
|
|
192
|
+
{ key: 'action', label: 'Action', render: (row) => row.policy.action },
|
|
193
|
+
{ key: 'rule', label: 'Rule', render: (row) => <span className="text-sm">{describe(row.policy)}</span> },
|
|
194
|
+
{
|
|
195
|
+
key: 'rowActions',
|
|
196
|
+
label: '',
|
|
197
|
+
render: (row) => (
|
|
198
|
+
<div className="flex justify-end">
|
|
199
|
+
<Button
|
|
200
|
+
type="button"
|
|
201
|
+
variant="ghost"
|
|
202
|
+
size="sm"
|
|
203
|
+
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
204
|
+
onClick={() => setDeleteTarget(row)}
|
|
205
|
+
>
|
|
206
|
+
<Trash2 className="size-4" />
|
|
207
|
+
Delete
|
|
208
|
+
</Button>
|
|
209
|
+
</div>
|
|
210
|
+
),
|
|
211
|
+
},
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<AppShell section="Identity" title="Record policies">
|
|
216
|
+
<div className="p-4">
|
|
217
|
+
<PageHeader section="Identity" title="Record policies" icon={ShieldCheck} />
|
|
218
|
+
|
|
219
|
+
{/* Type picker */}
|
|
220
|
+
<div className="mb-6 flex flex-wrap items-end gap-3">
|
|
221
|
+
<div className="space-y-1">
|
|
222
|
+
<Label htmlFor="policy-type">Object type</Label>
|
|
223
|
+
<Select value={typeKey} onValueChange={setTypeKey}>
|
|
224
|
+
<SelectTrigger id="policy-type" className="w-48">
|
|
225
|
+
<SelectValue placeholder="Pick a type" />
|
|
226
|
+
</SelectTrigger>
|
|
227
|
+
<SelectContent>
|
|
228
|
+
{AUTHORABLE_TYPES.map((t) => (
|
|
229
|
+
<SelectItem key={t} value={t}>
|
|
230
|
+
{t}
|
|
231
|
+
</SelectItem>
|
|
232
|
+
))}
|
|
233
|
+
</SelectContent>
|
|
234
|
+
</Select>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
{/* The guided builder */}
|
|
239
|
+
{schema && (
|
|
240
|
+
<div className="mb-8 rounded-lg border border-border bg-card p-4">
|
|
241
|
+
<div className="mb-4 text-sm font-medium text-foreground">Build a rule</div>
|
|
242
|
+
|
|
243
|
+
<div className="mb-4 flex flex-wrap items-end gap-3">
|
|
244
|
+
<div className="space-y-1">
|
|
245
|
+
<Label htmlFor="policy-effect">Effect</Label>
|
|
246
|
+
<Select value={effect} onValueChange={setEffect}>
|
|
247
|
+
<SelectTrigger id="policy-effect" className="w-36">
|
|
248
|
+
<SelectValue />
|
|
249
|
+
</SelectTrigger>
|
|
250
|
+
<SelectContent>
|
|
251
|
+
<SelectItem value="allow">Allow</SelectItem>
|
|
252
|
+
<SelectItem value="deny">Deny</SelectItem>
|
|
253
|
+
</SelectContent>
|
|
254
|
+
</Select>
|
|
255
|
+
</div>
|
|
256
|
+
<div className="space-y-1">
|
|
257
|
+
<Label htmlFor="policy-action">Action</Label>
|
|
258
|
+
<Select value={action} onValueChange={setAction}>
|
|
259
|
+
<SelectTrigger id="policy-action" className="w-40">
|
|
260
|
+
<SelectValue placeholder="Pick an action" />
|
|
261
|
+
</SelectTrigger>
|
|
262
|
+
<SelectContent>
|
|
263
|
+
{(schema.actions ?? []).map((a) => (
|
|
264
|
+
<SelectItem key={a} value={a}>
|
|
265
|
+
{a}
|
|
266
|
+
</SelectItem>
|
|
267
|
+
))}
|
|
268
|
+
</SelectContent>
|
|
269
|
+
</Select>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
274
|
+
Where (all conditions must match)
|
|
275
|
+
</div>
|
|
276
|
+
<div className="space-y-2">
|
|
277
|
+
{conditions.map((c, i) => {
|
|
278
|
+
const fieldSchema = fieldsByKey.get(c.field);
|
|
279
|
+
const validOps = fieldSchema?.validOperators ?? [];
|
|
280
|
+
const isNullary = fieldSchema?.nullaryOperators?.includes(c.operator);
|
|
281
|
+
const isArray = fieldSchema?.arrayOperators?.includes(c.operator);
|
|
282
|
+
const isContains = c.operator === 'contains' || c.operator === 'not_contains';
|
|
283
|
+
return (
|
|
284
|
+
<div key={i} className="flex flex-wrap items-center gap-2">
|
|
285
|
+
{/* Field */}
|
|
286
|
+
<Select
|
|
287
|
+
value={c.field}
|
|
288
|
+
onValueChange={(field) => setCondition(i, { field, operator: '', value: '' })}
|
|
289
|
+
>
|
|
290
|
+
<SelectTrigger className="w-40">
|
|
291
|
+
<SelectValue placeholder="Field" />
|
|
292
|
+
</SelectTrigger>
|
|
293
|
+
<SelectContent>
|
|
294
|
+
{(schema.fields ?? []).map((f) => (
|
|
295
|
+
<SelectItem key={f.key} value={f.key}>
|
|
296
|
+
{f.label}
|
|
297
|
+
</SelectItem>
|
|
298
|
+
))}
|
|
299
|
+
</SelectContent>
|
|
300
|
+
</Select>
|
|
301
|
+
|
|
302
|
+
{/* Operator — only those VALID for the chosen field's type */}
|
|
303
|
+
<Select
|
|
304
|
+
value={c.operator}
|
|
305
|
+
onValueChange={(operator) => setCondition(i, { operator, value: '' })}
|
|
306
|
+
disabled={!c.field}
|
|
307
|
+
>
|
|
308
|
+
<SelectTrigger className="w-44">
|
|
309
|
+
<SelectValue placeholder="Operator" />
|
|
310
|
+
</SelectTrigger>
|
|
311
|
+
<SelectContent>
|
|
312
|
+
{validOps.map((op) => (
|
|
313
|
+
<SelectItem key={op} value={op}>
|
|
314
|
+
{OPERATOR_LABELS[op] ?? op}
|
|
315
|
+
</SelectItem>
|
|
316
|
+
))}
|
|
317
|
+
</SelectContent>
|
|
318
|
+
</Select>
|
|
319
|
+
|
|
320
|
+
{/* Value — hidden for nullary ops; a comma list hint for array ops */}
|
|
321
|
+
{!isNullary && (
|
|
322
|
+
<Input
|
|
323
|
+
className="w-56"
|
|
324
|
+
placeholder={isArray ? 'value1, value2, …' : 'value'}
|
|
325
|
+
value={c.value ?? ''}
|
|
326
|
+
onChange={(e) => setCondition(i, { value: e.target.value })}
|
|
327
|
+
disabled={!c.operator}
|
|
328
|
+
/>
|
|
329
|
+
)}
|
|
330
|
+
|
|
331
|
+
<Button
|
|
332
|
+
type="button"
|
|
333
|
+
variant="ghost"
|
|
334
|
+
size="icon"
|
|
335
|
+
onClick={() => removeCondition(i)}
|
|
336
|
+
disabled={conditions.length <= 1}
|
|
337
|
+
aria-label="Remove condition"
|
|
338
|
+
>
|
|
339
|
+
<Trash2 className="size-4" />
|
|
340
|
+
</Button>
|
|
341
|
+
|
|
342
|
+
{isContains && (
|
|
343
|
+
<span className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
|
|
344
|
+
<Info className="size-3.5" />
|
|
345
|
+
“contains” is not index-friendly on large tables.
|
|
346
|
+
</span>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
);
|
|
350
|
+
})}
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
<div className="mt-3 flex items-center gap-2">
|
|
354
|
+
<Button type="button" variant="outline" size="sm" onClick={addCondition}>
|
|
355
|
+
<Plus className="size-4" />
|
|
356
|
+
Add condition
|
|
357
|
+
</Button>
|
|
358
|
+
<Button type="button" size="sm" onClick={onSave} disabled={saving}>
|
|
359
|
+
{saving ? 'Saving…' : 'Save policy'}
|
|
360
|
+
</Button>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
|
|
365
|
+
{/* Current policies */}
|
|
366
|
+
<div className="mb-2 text-sm font-medium text-foreground">
|
|
367
|
+
Current policies for “{typeKey}”
|
|
368
|
+
</div>
|
|
369
|
+
<EntityList
|
|
370
|
+
columns={policyColumns}
|
|
371
|
+
rows={policies}
|
|
372
|
+
loading={loading}
|
|
373
|
+
error={error}
|
|
374
|
+
emptyLabel="No record policies yet — build one above."
|
|
375
|
+
/>
|
|
376
|
+
</div>
|
|
377
|
+
|
|
378
|
+
<ConfirmDialog
|
|
379
|
+
open={!!deleteTarget}
|
|
380
|
+
title="Delete this record policy?"
|
|
381
|
+
message="This removes the rule. Access to matching records reverts to the remaining policies. This cannot be undone."
|
|
382
|
+
confirmLabel="Delete"
|
|
383
|
+
destructive
|
|
384
|
+
busy={deleteBusy}
|
|
385
|
+
onConfirm={onConfirmDelete}
|
|
386
|
+
onCancel={() => setDeleteTarget(null)}
|
|
387
|
+
/>
|
|
388
|
+
</AppShell>
|
|
389
|
+
);
|
|
390
|
+
}
|