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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-astram-app",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
@@ -12,7 +12,7 @@
12
12
  "test": "node --test src/**/*.test.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@astram/frontend-core": "^0.2.0",
15
+ "@astram/frontend-core": "^0.4.0",
16
16
  "@astram/sdk": "^0.2.0",
17
17
  "@radix-ui/react-alert-dialog": "^1.1.19",
18
18
  "@radix-ui/react-avatar": "^1.1.10",
@@ -28,7 +28,9 @@ 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 ServiceAccountsPage from './pages/service-accounts/ServiceAccountsPage';
32
34
  import GrantsPage from './pages/cross-tenant/GrantsPage';
33
35
  import GrantForm from './pages/cross-tenant/GrantForm';
34
36
  import InboxPage from './pages/cross-tenant/InboxPage';
@@ -155,6 +157,29 @@ export default function App() {
155
157
  <Route path="add" element={<AccessForm />} />
156
158
  <Route path="edit/:uuid" element={<AccessForm />} />
157
159
  </Route>
160
+ {/* Guided rule-builder — author/list/delete record-access policies (§9.1). Gated
161
+ identity:role:read (policy authoring IS access administration; the write gate is
162
+ role:update, enforced server-side on POST/DELETE). */}
163
+ <Route
164
+ path="/record-policies"
165
+ element={
166
+ <RequireAuth gate="identity:role:read">
167
+ <RecordPoliciesPage />
168
+ </RequireAuth>
169
+ }
170
+ />
171
+ {/* Service accounts (ADR-0033) — machine identities + their API keys. Gated
172
+ identity:service_account:read (service-account.controller.ts `GET service-accounts`);
173
+ create/edit is service_account:create|update, keys are service_account:manage_keys,
174
+ all enforced server-side per route. */}
175
+ <Route
176
+ path="/service-accounts"
177
+ element={
178
+ <RequireAuth gate="identity:service_account:read">
179
+ <ServiceAccountsPage />
180
+ </RequireAuth>
181
+ }
182
+ />
158
183
  {/* Cross-tenant grants — the grant book (mine + incoming) is a LAYOUT route; the
159
184
  Offer form (`offer`) renders as a routed-modal overlay under it. The pending
160
185
  Inbox is a sibling route. All gated `identity:role:read` (mirrors nav.js). */}
@@ -17,6 +17,9 @@ import {
17
17
  createAuthService,
18
18
  createCrossTenantService,
19
19
  createIdentityService,
20
+ createRecordPolicyService,
21
+ createRecordShareService,
22
+ createServiceAccountService,
20
23
  isAuthenticated,
21
24
  principalOf,
22
25
  } from '@astram/frontend-core';
@@ -66,6 +69,14 @@ export function AuthProvider({ children }) {
66
69
  identity: createIdentityService(http),
67
70
  audit: createAuditService(http),
68
71
  crossTenant: createCrossTenantService(http),
72
+ // The guided rule-builder surface (§9.1) — author/list/delete record-access policies +
73
+ // the field/operator schema that drives the builder's dropdowns.
74
+ recordPolicy: createRecordPolicyService(http),
75
+ // The owner-share surface (§9.2) — share/unshare/list a record's shares (owner-only).
76
+ recordShare: createRecordShareService(http),
77
+ // Service accounts (ADR-0033) — list/create/edit machine accounts + issue/rotate/revoke
78
+ // their `ask_…` API keys (the raw key is revealed once on issue/rotate).
79
+ serviceAccounts: createServiceAccountService(http),
69
80
  account: createAccountService(http),
70
81
  // The @astram/sdk ShieldClient (Phase E slice 1) — a UX-convenience projection of
71
82
  // the caller's OWN coarse grants, fetched once per session via `getAuthFacts`.
@@ -81,7 +92,8 @@ export function AuthProvider({ children }) {
81
92
  store,
82
93
  };
83
94
  }
84
- const { auth, identity, audit, crossTenant, account, shield, store } = services.current;
95
+ const { auth, identity, audit, crossTenant, recordPolicy, recordShare, serviceAccounts, account, shield, store } =
96
+ services.current;
85
97
 
86
98
  const [state, setState] = useState(() => store.load()); // SessionState | null
87
99
  // The signed-in user's DISPLAY profile (name/email/company/roles) from GET /auth/me.
@@ -284,6 +296,13 @@ export function AuthProvider({ children }) {
284
296
  // The shared cross-tenant grant service (offer/accept/revoke + inbox/mine +
285
297
  // offerable-types) — same injected web http client.
286
298
  crossTenant,
299
+ // The shared guided rule-builder service (author/list/delete record policies + the
300
+ // field/operator schema) — same injected web http client.
301
+ recordPolicy,
302
+ // The shared owner-share service (share/unshare/list a record's shares) — same client.
303
+ recordShare,
304
+ // The shared service-accounts service (list/create/edit + issue/rotate/revoke keys).
305
+ serviceAccounts,
287
306
  // The shared self-scoped account service (change-password today; profile/MFA
288
307
  // later) — same injected web http client.
289
308
  account,
@@ -306,7 +325,7 @@ export function AuthProvider({ children }) {
306
325
  ),
307
326
  }),
308
327
  // eslint-disable-next-line react-hooks/exhaustive-deps
309
- [state, auth, identity, audit, crossTenant, account, shield, profile, mustReenroll, authFacts],
328
+ [state, auth, identity, audit, crossTenant, recordPolicy, recordShare, serviceAccounts, account, shield, profile, mustReenroll, authFacts],
310
329
  );
311
330
 
312
331
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * MultiSelectChecklist — a searchable checkbox list for picking several items (roles, scope nodes).
3
+ * Shows a selected count and "Select all / None" (applied to the VISIBLE, unlocked options).
4
+ * Tree-shaped lists pass `depth` per option and are indented by it (a flat tree, no tree widget).
5
+ *
6
+ * Options: `{ id, name, depth?, locked? }`. `id` is only the value — never rendered; `name` is shown
7
+ * (ADR-0012). A `locked` option renders checked + disabled with `lockedHint` (e.g. "assigned by
8
+ * someone with more access"): it is not part of `value` and never toggled.
9
+ *
10
+ * Props:
11
+ * id — base id for the search input + list (a11y)
12
+ * label — field label
13
+ * options — the option list (above)
14
+ * value — array of selected (unlocked) ids
15
+ * onChange — (nextIds) => void
16
+ * searchPlaceholder, emptyLabel, lockedHint, hint, error, disabled, loading
17
+ */
18
+ import { useMemo, useState } from 'react';
19
+ import { Lock } from 'lucide-react';
20
+ import { cn } from '@/lib/utils';
21
+ import { Checkbox } from '@/components/ui/checkbox';
22
+ import { Input } from '@/components/ui/input';
23
+ import { Label } from '@/components/ui/label';
24
+ import { Badge } from '@/components/ui/badge';
25
+ import { Button } from '@/components/ui/button';
26
+
27
+ // Indent steps as literal classes (Tailwind scans literal strings; no inline spacing styles).
28
+ const INDENT = ['pl-0', 'pl-5', 'pl-10', 'pl-14', 'pl-20'];
29
+ const indentClass = (depth) => INDENT[Math.min(Math.max(depth || 0, 0), INDENT.length - 1)];
30
+
31
+ export default function MultiSelectChecklist({
32
+ id,
33
+ label,
34
+ options,
35
+ value,
36
+ onChange,
37
+ searchPlaceholder = 'Search…',
38
+ emptyLabel = 'Nothing to choose from.',
39
+ lockedHint = 'locked',
40
+ hint,
41
+ error,
42
+ disabled = false,
43
+ loading = false,
44
+ }) {
45
+ const [query, setQuery] = useState('');
46
+ const selected = useMemo(() => new Set(value), [value]);
47
+ const q = query.trim().toLowerCase();
48
+ // While searching, show matches flat (indent only makes sense for the whole tree).
49
+ const visible = q ? options.filter((o) => o.name.toLowerCase().includes(q)) : options;
50
+ const visibleUnlocked = visible.filter((o) => !o.locked);
51
+ const lockedTotal = options.filter((o) => o.locked).length;
52
+ const count = selected.size + lockedTotal;
53
+
54
+ const toggle = (optId) => {
55
+ const next = new Set(selected);
56
+ if (next.has(optId)) next.delete(optId);
57
+ else next.add(optId);
58
+ onChange(options.filter((o) => !o.locked && next.has(o.id)).map((o) => o.id));
59
+ };
60
+ const selectAll = () => {
61
+ const next = new Set(selected);
62
+ for (const o of visibleUnlocked) next.add(o.id);
63
+ onChange(options.filter((o) => !o.locked && next.has(o.id)).map((o) => o.id));
64
+ };
65
+ const selectNone = () => {
66
+ const drop = new Set(visibleUnlocked.map((o) => o.id));
67
+ onChange(value.filter((v) => !drop.has(v)));
68
+ };
69
+
70
+ const errorId = error ? `${id}-error` : undefined;
71
+
72
+ return (
73
+ <div className="space-y-1.5">
74
+ <div className="flex items-center justify-between gap-2">
75
+ <Label htmlFor={`${id}-search`} variant="field">
76
+ {label}
77
+ </Label>
78
+ <Badge variant="secondary" aria-live="polite">
79
+ {count} selected
80
+ </Badge>
81
+ </div>
82
+
83
+ <div className="flex items-center gap-2">
84
+ <Input
85
+ id={`${id}-search`}
86
+ value={query}
87
+ onChange={(e) => setQuery(e.target.value)}
88
+ placeholder={searchPlaceholder}
89
+ disabled={disabled || loading}
90
+ className="h-8"
91
+ />
92
+ <Button
93
+ type="button"
94
+ variant="ghost"
95
+ size="sm"
96
+ onClick={selectAll}
97
+ disabled={disabled || loading || visibleUnlocked.length === 0}
98
+ >
99
+ Select all
100
+ </Button>
101
+ <Button
102
+ type="button"
103
+ variant="ghost"
104
+ size="sm"
105
+ onClick={selectNone}
106
+ disabled={disabled || loading || visibleUnlocked.length === 0}
107
+ >
108
+ None
109
+ </Button>
110
+ </div>
111
+
112
+ <div
113
+ id={id}
114
+ role="group"
115
+ aria-label={label}
116
+ aria-describedby={errorId}
117
+ className={cn(
118
+ 'max-h-48 overflow-y-auto rounded-md border border-border p-2',
119
+ error && 'border-destructive',
120
+ )}
121
+ >
122
+ {loading ? (
123
+ <p className="px-1 py-2 text-xs text-muted-foreground">Loading…</p>
124
+ ) : visible.length === 0 ? (
125
+ <p className="px-1 py-2 text-xs text-muted-foreground">
126
+ {q ? 'No matches.' : emptyLabel}
127
+ </p>
128
+ ) : (
129
+ <ul className="space-y-1">
130
+ {visible.map((o) => (
131
+ <li key={o.id} className={cn(!q && indentClass(o.depth))}>
132
+ <label
133
+ className={cn(
134
+ 'flex items-center gap-2 rounded px-1 py-1 text-sm',
135
+ o.locked ? 'text-muted-foreground' : 'cursor-pointer hover:bg-accent',
136
+ )}
137
+ >
138
+ <Checkbox
139
+ checked={o.locked ? true : selected.has(o.id)}
140
+ disabled={disabled || o.locked}
141
+ onCheckedChange={() => !o.locked && toggle(o.id)}
142
+ />
143
+ <span className="truncate">{o.name}</span>
144
+ {o.locked && (
145
+ <span className="ml-auto inline-flex shrink-0 items-center gap-1 text-xs">
146
+ <Lock className="size-3" aria-hidden="true" />
147
+ {lockedHint}
148
+ </span>
149
+ )}
150
+ </label>
151
+ </li>
152
+ ))}
153
+ </ul>
154
+ )}
155
+ </div>
156
+
157
+ {error ? (
158
+ <p id={errorId} className="text-xs text-destructive">
159
+ {error}
160
+ </p>
161
+ ) : (
162
+ hint && <p className="text-xs text-muted-foreground">{hint}</p>
163
+ )}
164
+ </div>
165
+ );
166
+ }
@@ -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
+ }
@@ -1,7 +1,9 @@
1
+ /* Inter (UI) + JetBrains Mono (code/data) — the professional SaaS standard.
2
+ Must stay FIRST: `@import 'tailwindcss'` inlines rules, and a url @import after any
3
+ rule is invalid CSS (the build warns and browsers ignore it). */
4
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
1
5
  @import 'tailwindcss';
2
6
  @import 'tw-animate-css';
3
- /* Inter (UI) + JetBrains Mono (code/data) — the professional SaaS standard. */
4
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
5
7
 
6
8
  /*
7
9
  astram3 design tokens — shadcn/ui + Tailwind v4 (CSS-var driven).