create-astram-app 0.4.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 +1 -1
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +13 -0
- package/template/frontend/src/auth/AuthContext.jsx +8 -2
- package/template/frontend/src/components/MultiSelectChecklist.jsx +166 -0
- package/template/frontend/src/index.css +4 -2
- package/template/frontend/src/pages/service-accounts/KeyRevealDialog.jsx +104 -0
- package/template/frontend/src/pages/service-accounts/ServiceAccountAccessDialog.jsx +231 -0
- package/template/frontend/src/pages/service-accounts/ServiceAccountActionsMenu.jsx +56 -0
- package/template/frontend/src/pages/service-accounts/ServiceAccountsPage.jsx +309 -0
- package/template/frontend/src/pages/service-accounts/accessModel.js +136 -0
- package/template/frontend/src/pages/service-accounts/accessModel.test.mjs +161 -0
- package/template/frontend/src/shell/nav.js +12 -0
- package/template/server/package.json +4 -4
- package/template/server/test/enforcement.e2e.spec.ts +73 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-astram-app",
|
|
3
|
-
"version": "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",
|
|
@@ -30,6 +30,7 @@ import AccessPage from './pages/access/AccessPage';
|
|
|
30
30
|
import AccessForm from './pages/access/AccessForm';
|
|
31
31
|
import RecordPoliciesPage from './pages/access/RecordPoliciesPage';
|
|
32
32
|
import AuditPage from './pages/audit/AuditPage';
|
|
33
|
+
import ServiceAccountsPage from './pages/service-accounts/ServiceAccountsPage';
|
|
33
34
|
import GrantsPage from './pages/cross-tenant/GrantsPage';
|
|
34
35
|
import GrantForm from './pages/cross-tenant/GrantForm';
|
|
35
36
|
import InboxPage from './pages/cross-tenant/InboxPage';
|
|
@@ -167,6 +168,18 @@ export default function App() {
|
|
|
167
168
|
</RequireAuth>
|
|
168
169
|
}
|
|
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
|
+
/>
|
|
170
183
|
{/* Cross-tenant grants — the grant book (mine + incoming) is a LAYOUT route; the
|
|
171
184
|
Offer form (`offer`) renders as a routed-modal overlay under it. The pending
|
|
172
185
|
Inbox is a sibling route. All gated `identity:role:read` (mirrors nav.js). */}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
createIdentityService,
|
|
20
20
|
createRecordPolicyService,
|
|
21
21
|
createRecordShareService,
|
|
22
|
+
createServiceAccountService,
|
|
22
23
|
isAuthenticated,
|
|
23
24
|
principalOf,
|
|
24
25
|
} from '@astram/frontend-core';
|
|
@@ -73,6 +74,9 @@ export function AuthProvider({ children }) {
|
|
|
73
74
|
recordPolicy: createRecordPolicyService(http),
|
|
74
75
|
// The owner-share surface (§9.2) — share/unshare/list a record's shares (owner-only).
|
|
75
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),
|
|
76
80
|
account: createAccountService(http),
|
|
77
81
|
// The @astram/sdk ShieldClient (Phase E slice 1) — a UX-convenience projection of
|
|
78
82
|
// the caller's OWN coarse grants, fetched once per session via `getAuthFacts`.
|
|
@@ -88,7 +92,7 @@ export function AuthProvider({ children }) {
|
|
|
88
92
|
store,
|
|
89
93
|
};
|
|
90
94
|
}
|
|
91
|
-
const { auth, identity, audit, crossTenant, recordPolicy, recordShare, account, shield, store } =
|
|
95
|
+
const { auth, identity, audit, crossTenant, recordPolicy, recordShare, serviceAccounts, account, shield, store } =
|
|
92
96
|
services.current;
|
|
93
97
|
|
|
94
98
|
const [state, setState] = useState(() => store.load()); // SessionState | null
|
|
@@ -297,6 +301,8 @@ export function AuthProvider({ children }) {
|
|
|
297
301
|
recordPolicy,
|
|
298
302
|
// The shared owner-share service (share/unshare/list a record's shares) — same client.
|
|
299
303
|
recordShare,
|
|
304
|
+
// The shared service-accounts service (list/create/edit + issue/rotate/revoke keys).
|
|
305
|
+
serviceAccounts,
|
|
300
306
|
// The shared self-scoped account service (change-password today; profile/MFA
|
|
301
307
|
// later) — same injected web http client.
|
|
302
308
|
account,
|
|
@@ -319,7 +325,7 @@ export function AuthProvider({ children }) {
|
|
|
319
325
|
),
|
|
320
326
|
}),
|
|
321
327
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
322
|
-
[state, auth, identity, audit, crossTenant, recordPolicy, recordShare, account, shield, profile, mustReenroll, authFacts],
|
|
328
|
+
[state, auth, identity, audit, crossTenant, recordPolicy, recordShare, serviceAccounts, account, shield, profile, mustReenroll, authFacts],
|
|
323
329
|
);
|
|
324
330
|
|
|
325
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
|
+
}
|
|
@@ -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).
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ServiceAccountAccessDialog — create a service account, or edit an existing one's name + access
|
|
3
|
+
* (roles + scope). Phase 4 of the Service Accounts plan.
|
|
4
|
+
*
|
|
5
|
+
* The pickers offer ONLY what the caller may grant: `serviceAccounts.listAssignable` is computed
|
|
6
|
+
* server-side from the same ADR-0029 ceiling the save enforces. On edit, the agent's roles/nodes the
|
|
7
|
+
* caller cannot grant are shown checked + locked ("assigned by someone with more access") and are
|
|
8
|
+
* never sent — the server keeps them. Create defaults the scope to everything the caller reaches;
|
|
9
|
+
* both create and edit require at least one scope node. Edit sends only the fields that changed.
|
|
10
|
+
*
|
|
11
|
+
* Errors: a 403 (over the ceiling) or 409 (edited concurrently) shows the server text inline above the
|
|
12
|
+
* buttons; a 400 lands on the field it is about. Everything is shown by name, never by id.
|
|
13
|
+
*
|
|
14
|
+
* Props: open, account (null ⇒ create), onClose(), onSaved(account). Mount it only while open with a
|
|
15
|
+
* `key` per target (the page does), so each open starts from fresh state.
|
|
16
|
+
*/
|
|
17
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
18
|
+
import { toast } from 'sonner';
|
|
19
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
20
|
+
import EntityFormModal from '@/components/EntityFormModal';
|
|
21
|
+
import MultiSelectChecklist from '@/components/MultiSelectChecklist';
|
|
22
|
+
import { Input } from '@/components/ui/input';
|
|
23
|
+
import { Label } from '@/components/ui/label';
|
|
24
|
+
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
25
|
+
import {
|
|
26
|
+
LOCKED_HINT,
|
|
27
|
+
NO_ROLES_WARNING,
|
|
28
|
+
buildAccessModel,
|
|
29
|
+
validateAccess,
|
|
30
|
+
sameIds,
|
|
31
|
+
hasNoRoles,
|
|
32
|
+
createBody,
|
|
33
|
+
editPatch,
|
|
34
|
+
fieldOfServerError,
|
|
35
|
+
} from './accessModel';
|
|
36
|
+
|
|
37
|
+
export default function ServiceAccountAccessDialog({ open, account, onClose, onSaved }) {
|
|
38
|
+
const { tokens, serviceAccounts } = useAuth();
|
|
39
|
+
const token = tokens?.accessToken;
|
|
40
|
+
const editing = !!account;
|
|
41
|
+
|
|
42
|
+
// The page mounts this dialog fresh (keyed) per open, so state initialises from props here —
|
|
43
|
+
// no reset-in-effect.
|
|
44
|
+
const [assignable, setAssignable] = useState(null); // { roles, scopeNodes } | null
|
|
45
|
+
const [loadingAssignable, setLoadingAssignable] = useState(true);
|
|
46
|
+
const [assignableError, setAssignableError] = useState('');
|
|
47
|
+
|
|
48
|
+
const [label, setLabel] = useState(account?.label ?? '');
|
|
49
|
+
const [roles, setRoles] = useState([]);
|
|
50
|
+
const [scope, setScope] = useState([]);
|
|
51
|
+
const [fieldErrors, setFieldErrors] = useState({});
|
|
52
|
+
const [formError, setFormError] = useState('');
|
|
53
|
+
const [busy, setBusy] = useState(false);
|
|
54
|
+
|
|
55
|
+
// Load what the caller may grant (ignore a stale response).
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!open || !token) return undefined;
|
|
58
|
+
let cancelled = false;
|
|
59
|
+
serviceAccounts.listAssignable(token).then((r) => {
|
|
60
|
+
if (cancelled) return;
|
|
61
|
+
setLoadingAssignable(false);
|
|
62
|
+
if (!r.ok) {
|
|
63
|
+
setAssignableError(r.error || 'Could not load the roles and companies you can assign.');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const loaded = { roles: r.roles, scopeNodes: r.scopeNodes };
|
|
67
|
+
const m = buildAccessModel(loaded, account);
|
|
68
|
+
setAssignable(loaded);
|
|
69
|
+
setRoles(m.initialRoles);
|
|
70
|
+
setScope(m.initialScope);
|
|
71
|
+
});
|
|
72
|
+
return () => {
|
|
73
|
+
cancelled = true;
|
|
74
|
+
};
|
|
75
|
+
}, [open, token, account, serviceAccounts]);
|
|
76
|
+
|
|
77
|
+
const model = useMemo(
|
|
78
|
+
() => (assignable ? buildAccessModel(assignable, account) : null),
|
|
79
|
+
[assignable, account],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const accessReady = !!model;
|
|
83
|
+
|
|
84
|
+
const onSubmit = async () => {
|
|
85
|
+
// Without the assignable set we cannot build a safe access request: create is blocked; edit
|
|
86
|
+
// can still rename (access fields are then left untouched).
|
|
87
|
+
if (!accessReady && !editing) {
|
|
88
|
+
setFormError(assignableError || 'Still loading what you can assign.');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Scope is required whenever it will be SENT: always on create; on edit only if it changed
|
|
92
|
+
// (an untouched scope is not sent, so an agent whose nodes are all locked can still be renamed).
|
|
93
|
+
const requireScope = accessReady && (!editing || !sameIds(scope, model.initialScope));
|
|
94
|
+
const errors = validateAccess({ label, scope, requireScope });
|
|
95
|
+
setFieldErrors(errors);
|
|
96
|
+
if (Object.keys(errors).length > 0) return;
|
|
97
|
+
setFormError('');
|
|
98
|
+
|
|
99
|
+
let r;
|
|
100
|
+
if (!editing) {
|
|
101
|
+
setBusy(true);
|
|
102
|
+
r = await serviceAccounts.create(token, createBody({ label, roles, scope }));
|
|
103
|
+
} else {
|
|
104
|
+
const patch = accessReady
|
|
105
|
+
? editPatch({
|
|
106
|
+
account,
|
|
107
|
+
label,
|
|
108
|
+
roles,
|
|
109
|
+
scope,
|
|
110
|
+
initialRoles: model.initialRoles,
|
|
111
|
+
initialScope: model.initialScope,
|
|
112
|
+
})
|
|
113
|
+
: label.trim() !== account.label
|
|
114
|
+
? { label: label.trim() }
|
|
115
|
+
: {};
|
|
116
|
+
if (Object.keys(patch).length === 0) {
|
|
117
|
+
toast.message('No changes to save.');
|
|
118
|
+
onClose?.();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
setBusy(true);
|
|
122
|
+
r = await serviceAccounts.update(token, account.id, patch);
|
|
123
|
+
}
|
|
124
|
+
setBusy(false);
|
|
125
|
+
|
|
126
|
+
if (!r.ok) {
|
|
127
|
+
if (r.status === 400) {
|
|
128
|
+
const field = fieldOfServerError(r.error);
|
|
129
|
+
if (field) {
|
|
130
|
+
setFieldErrors({ [field]: r.error });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
setFormError(r.error || 'The service account could not be saved.');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
toast.success(editing ? `Saved “${r.account.label}”` : `Created “${r.account.label}”`);
|
|
138
|
+
onSaved?.(r.account);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const noRoles = accessReady && hasNoRoles(roles, model.roleOptions);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<EntityFormModal
|
|
145
|
+
open={open}
|
|
146
|
+
onClose={onClose}
|
|
147
|
+
onSubmit={onSubmit}
|
|
148
|
+
busy={busy}
|
|
149
|
+
error={formError}
|
|
150
|
+
title={editing ? `Edit “${account.label}”` : 'New service account'}
|
|
151
|
+
description={
|
|
152
|
+
editing
|
|
153
|
+
? 'Rename the agent or change what it can reach. Access you cannot grant stays as it is.'
|
|
154
|
+
: 'A machine identity for an agent, MCP server, or script, created in your own tenant. Issue its API key next.'
|
|
155
|
+
}
|
|
156
|
+
submitLabel={editing ? 'Save' : 'Create'}
|
|
157
|
+
contentClassName="sm:max-w-lg"
|
|
158
|
+
>
|
|
159
|
+
<div className="space-y-1.5">
|
|
160
|
+
<Label htmlFor="sa-label" variant="field">
|
|
161
|
+
Name
|
|
162
|
+
</Label>
|
|
163
|
+
<Input
|
|
164
|
+
id="sa-label"
|
|
165
|
+
value={label}
|
|
166
|
+
onChange={(e) => setLabel(e.target.value)}
|
|
167
|
+
placeholder="e.g. Nightly ingest bot"
|
|
168
|
+
autoFocus
|
|
169
|
+
aria-invalid={!!fieldErrors.label}
|
|
170
|
+
aria-describedby={fieldErrors.label ? 'sa-label-error' : undefined}
|
|
171
|
+
/>
|
|
172
|
+
{fieldErrors.label && (
|
|
173
|
+
<p id="sa-label-error" className="text-xs text-destructive">
|
|
174
|
+
{fieldErrors.label}
|
|
175
|
+
</p>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{assignableError && (
|
|
180
|
+
<Alert variant="destructive">
|
|
181
|
+
<AlertDescription>
|
|
182
|
+
{assignableError}
|
|
183
|
+
{editing ? ' You can still rename this agent; its access is left unchanged.' : ''}
|
|
184
|
+
</AlertDescription>
|
|
185
|
+
</Alert>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{!assignableError && (
|
|
189
|
+
<>
|
|
190
|
+
<MultiSelectChecklist
|
|
191
|
+
id="sa-roles"
|
|
192
|
+
label="Roles"
|
|
193
|
+
options={model?.roleOptions ?? []}
|
|
194
|
+
value={roles}
|
|
195
|
+
onChange={setRoles}
|
|
196
|
+
loading={loadingAssignable}
|
|
197
|
+
disabled={busy}
|
|
198
|
+
searchPlaceholder="Search roles…"
|
|
199
|
+
emptyLabel="You hold no roles you can give an agent."
|
|
200
|
+
lockedHint={LOCKED_HINT}
|
|
201
|
+
hint="Only roles you hold yourself are offered."
|
|
202
|
+
/>
|
|
203
|
+
{noRoles && (
|
|
204
|
+
<Alert variant="warning">
|
|
205
|
+
<AlertDescription>{NO_ROLES_WARNING}</AlertDescription>
|
|
206
|
+
</Alert>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
<MultiSelectChecklist
|
|
210
|
+
id="sa-scope"
|
|
211
|
+
label="Scope"
|
|
212
|
+
options={model?.scopeOptions ?? []}
|
|
213
|
+
value={scope}
|
|
214
|
+
onChange={setScope}
|
|
215
|
+
loading={loadingAssignable}
|
|
216
|
+
disabled={busy}
|
|
217
|
+
searchPlaceholder="Search companies…"
|
|
218
|
+
emptyLabel="No companies in your reach."
|
|
219
|
+
lockedHint={LOCKED_HINT}
|
|
220
|
+
hint={
|
|
221
|
+
editing
|
|
222
|
+
? 'The companies this agent can act in (at least one).'
|
|
223
|
+
: 'Defaults to everything you can reach. Keep at least one.'
|
|
224
|
+
}
|
|
225
|
+
error={fieldErrors.scope}
|
|
226
|
+
/>
|
|
227
|
+
</>
|
|
228
|
+
)}
|
|
229
|
+
</EntityFormModal>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ServiceAccountActionsMenu — the ⋯ row-action menu on the Service Accounts listing.
|
|
3
|
+
* Edit access · (sep) · Issue new key · Rotate key · (sep) · Enable/Disable · Revoke all keys
|
|
4
|
+
* (destructive). The account is named by LABEL; its id is a handle passed to the callbacks, never shown.
|
|
5
|
+
*/
|
|
6
|
+
import { MoreHorizontal, KeyRound, RefreshCw, Power, Trash2, ShieldCheck } from 'lucide-react';
|
|
7
|
+
import {
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
DropdownMenuContent,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuSeparator,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
} from '@/components/ui/dropdown-menu';
|
|
14
|
+
|
|
15
|
+
export default function ServiceAccountActionsMenu({
|
|
16
|
+
account,
|
|
17
|
+
onEditAccess,
|
|
18
|
+
onIssueKey,
|
|
19
|
+
onRotateKey,
|
|
20
|
+
onToggleStatus,
|
|
21
|
+
onRevokeKeys,
|
|
22
|
+
}) {
|
|
23
|
+
const disabled = account.status === 'disabled';
|
|
24
|
+
return (
|
|
25
|
+
<DropdownMenu>
|
|
26
|
+
<DropdownMenuTrigger asChild>
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
aria-label={`Actions for ${account.label}`}
|
|
30
|
+
className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
31
|
+
>
|
|
32
|
+
<MoreHorizontal className="size-4" />
|
|
33
|
+
</button>
|
|
34
|
+
</DropdownMenuTrigger>
|
|
35
|
+
<DropdownMenuContent align="end" className="w-52">
|
|
36
|
+
<DropdownMenuItem onClick={() => onEditAccess(account)}>
|
|
37
|
+
<ShieldCheck className="size-4" /> Edit access
|
|
38
|
+
</DropdownMenuItem>
|
|
39
|
+
<DropdownMenuSeparator />
|
|
40
|
+
<DropdownMenuItem onClick={() => onIssueKey(account)}>
|
|
41
|
+
<KeyRound className="size-4" /> Issue new key
|
|
42
|
+
</DropdownMenuItem>
|
|
43
|
+
<DropdownMenuItem onClick={() => onRotateKey(account)}>
|
|
44
|
+
<RefreshCw className="size-4" /> Rotate key
|
|
45
|
+
</DropdownMenuItem>
|
|
46
|
+
<DropdownMenuSeparator />
|
|
47
|
+
<DropdownMenuItem onClick={() => onToggleStatus(account)}>
|
|
48
|
+
<Power className="size-4" /> {disabled ? 'Enable account' : 'Disable account'}
|
|
49
|
+
</DropdownMenuItem>
|
|
50
|
+
<DropdownMenuItem variant="destructive" onClick={() => onRevokeKeys(account)}>
|
|
51
|
+
<Trash2 className="size-4" /> Revoke all keys
|
|
52
|
+
</DropdownMenuItem>
|
|
53
|
+
</DropdownMenuContent>
|
|
54
|
+
</DropdownMenu>
|
|
55
|
+
);
|
|
56
|
+
}
|