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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-astram-app",
3
- "version": "0.2.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",
@@ -12,7 +12,7 @@
12
12
  "test": "node --test src/**/*.test.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@astram/frontend-core": "^0.1.0",
15
+ "@astram/frontend-core": "^0.3.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,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 } = services.current;
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
+ }