create-astram-app 0.3.0 → 0.5.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,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
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * KeyRevealDialog — the ONE-TIME reveal of a freshly issued/rotated service-account API key
3
+ * (ADR-0033). The raw `ask_…` key exists on the client only right after issue/rotate and is NEVER
4
+ * retrievable again, so this modal makes the operator copy it before dismissing.
5
+ *
6
+ * Controlled: the parent passes the `IssuedKey` (or null to close). The key is shown in a read-only
7
+ * field with a Copy button; a prominent warning states it won't be shown again. Closing is deliberate
8
+ * ("I've copied it") so it isn't dismissed by accident.
9
+ *
10
+ * ⛔ The key is a SECRET — it is only ever rendered here, never logged, never persisted client-side.
11
+ */
12
+ import { useState } from 'react';
13
+ import { toast } from 'sonner';
14
+ import { KeyRound, Copy, Check, TriangleAlert } from 'lucide-react';
15
+ import {
16
+ Dialog,
17
+ DialogContent,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ DialogDescription,
21
+ DialogFooter,
22
+ } from '@/components/ui/dialog';
23
+ import { Button } from '@/components/ui/button';
24
+ import { Input } from '@/components/ui/input';
25
+
26
+ function formatExpiry(ms) {
27
+ if (!ms) return 'Never expires';
28
+ try {
29
+ return `Expires ${new Date(ms).toLocaleString()}`;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * @param {{ issued: import('@astram/frontend-core').IssuedKey | null, onClose: () => void }} props
37
+ * `issued` — the one-time key payload, or null (closed). `onClose` — dismiss (after the operator
38
+ * has copied the key).
39
+ */
40
+ export default function KeyRevealDialog({ issued, onClose }) {
41
+ const [copied, setCopied] = useState(false);
42
+
43
+ const copy = async () => {
44
+ if (!issued) return;
45
+ try {
46
+ await navigator.clipboard.writeText(issued.apiKey);
47
+ setCopied(true);
48
+ toast.success('API key copied to clipboard');
49
+ setTimeout(() => setCopied(false), 2000);
50
+ } catch {
51
+ toast.error('Could not copy — select the key and copy it manually.');
52
+ }
53
+ };
54
+
55
+ const close = () => {
56
+ setCopied(false);
57
+ onClose?.();
58
+ };
59
+
60
+ return (
61
+ <Dialog open={!!issued} onOpenChange={(next) => !next && close()}>
62
+ <DialogContent className="sm:max-w-lg">
63
+ <DialogHeader>
64
+ <DialogTitle className="flex items-center gap-2">
65
+ <KeyRound className="h-5 w-5" />
66
+ API key for “{issued?.accountLabel}”
67
+ </DialogTitle>
68
+ <DialogDescription>
69
+ Copy this key now and store it somewhere safe. For your security, it will{' '}
70
+ <strong>never be shown again</strong> — if you lose it, rotate the key to get a new one.
71
+ </DialogDescription>
72
+ </DialogHeader>
73
+
74
+ <div className="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-800/60 dark:bg-amber-950/40 dark:text-amber-200">
75
+ <TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
76
+ <span>
77
+ Treat this like a password. Anyone with this key can act as this service account (within
78
+ its scope). {formatExpiry(issued?.expiresAtMs)} · Version {issued?.version}.
79
+ </span>
80
+ </div>
81
+
82
+ <div className="flex items-center gap-2">
83
+ <Input
84
+ readOnly
85
+ value={issued?.apiKey ?? ''}
86
+ onFocus={(e) => e.target.select()}
87
+ className="font-mono text-xs"
88
+ aria-label="API key"
89
+ />
90
+ <Button type="button" variant="secondary" onClick={copy} className="shrink-0">
91
+ {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
92
+ <span className="ml-1">{copied ? 'Copied' : 'Copy'}</span>
93
+ </Button>
94
+ </div>
95
+
96
+ <DialogFooter>
97
+ <Button type="button" onClick={close}>
98
+ I’ve copied it
99
+ </Button>
100
+ </DialogFooter>
101
+ </DialogContent>
102
+ </Dialog>
103
+ );
104
+ }