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 +1 -1
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +25 -0
- package/template/frontend/src/auth/AuthContext.jsx +21 -2
- package/template/frontend/src/components/MultiSelectChecklist.jsx +166 -0
- package/template/frontend/src/components/ShareDialog.jsx +269 -0
- package/template/frontend/src/index.css +4 -2
- package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +390 -0
- 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 +20 -0
- package/template/server/package.json +4 -4
- package/template/server/src/app.module.ts +21 -9
- package/template/server/test/enforcement.e2e.spec.ts +73 -6
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ServiceAccountsPage — the Service Accounts admin screen (ADR-0033): machine identities (AI agents,
|
|
3
|
+
* MCP servers, scripts) that authenticate with an `ask_…` API key. List the tenant's accounts (with
|
|
4
|
+
* their roles + scope BY NAME), create one with roles + scope, edit its access, and per-account
|
|
5
|
+
* Issue / Rotate / Revoke its keys (+ Enable/Disable). Create/edit live in
|
|
6
|
+
* {@link ServiceAccountAccessDialog} (pickers limited to what you may grant — Phase 4).
|
|
7
|
+
*
|
|
8
|
+
* The KEY lifecycle is the sensitive part: issue/rotate reveal the raw key EXACTLY ONCE via
|
|
9
|
+
* {@link KeyRevealDialog}; rotate/revoke/disable go through {@link ConfirmDialog} (they're
|
|
10
|
+
* destructive). Accounts are shown by LABEL (+ created-by name); the id is a handle only (ADR-0012).
|
|
11
|
+
*
|
|
12
|
+
* The list is served whole by the server (no server-side paging on this surface), so this page filters
|
|
13
|
+
* by search client-side and does not use the EntityList pager.
|
|
14
|
+
*/
|
|
15
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
16
|
+
import { Bot, KeyRound } from 'lucide-react';
|
|
17
|
+
import { toast } from 'sonner';
|
|
18
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
19
|
+
import AppShell from '@/shell/AppShell';
|
|
20
|
+
import PageHeader from '@/components/PageHeader';
|
|
21
|
+
import EntityList, { EntityListControls } from '@/components/EntityList';
|
|
22
|
+
import ConfirmDialog from '@/components/ConfirmDialog';
|
|
23
|
+
import { Badge } from '@/components/ui/badge';
|
|
24
|
+
import ServiceAccountActionsMenu from './ServiceAccountActionsMenu';
|
|
25
|
+
import ServiceAccountAccessDialog from './ServiceAccountAccessDialog';
|
|
26
|
+
import KeyRevealDialog from './KeyRevealDialog';
|
|
27
|
+
import { summarizeNames } from './accessModel';
|
|
28
|
+
|
|
29
|
+
function formatDate(ms) {
|
|
30
|
+
if (!ms) return '—';
|
|
31
|
+
try {
|
|
32
|
+
return new Date(ms).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
|
33
|
+
} catch {
|
|
34
|
+
return '—';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function ServiceAccountsPage() {
|
|
39
|
+
const { tokens, serviceAccounts } = useAuth();
|
|
40
|
+
const token = tokens?.accessToken;
|
|
41
|
+
|
|
42
|
+
const [accounts, setAccounts] = useState([]);
|
|
43
|
+
const [loading, setLoading] = useState(true);
|
|
44
|
+
const [error, setError] = useState('');
|
|
45
|
+
const [search, setSearch] = useState('');
|
|
46
|
+
|
|
47
|
+
// Create / edit-access dialog: `null` closed, `{ account: null }` create, `{ account }` edit.
|
|
48
|
+
const [accessDialog, setAccessDialog] = useState(null);
|
|
49
|
+
|
|
50
|
+
// One-time key reveal + destructive-confirm state.
|
|
51
|
+
const [issuedKey, setIssuedKey] = useState(null);
|
|
52
|
+
const [pending, setPending] = useState(null); // { kind: 'rotate'|'revoke'|'toggle', account }
|
|
53
|
+
const [pendingBusy, setPendingBusy] = useState(false);
|
|
54
|
+
|
|
55
|
+
const load = useCallback(async () => {
|
|
56
|
+
if (!token) return;
|
|
57
|
+
setLoading(true);
|
|
58
|
+
const r = await serviceAccounts.list(token);
|
|
59
|
+
if (r.ok) {
|
|
60
|
+
setAccounts(r.accounts);
|
|
61
|
+
setError('');
|
|
62
|
+
} else {
|
|
63
|
+
setAccounts([]);
|
|
64
|
+
setError(r.error || 'Could not load service accounts.');
|
|
65
|
+
}
|
|
66
|
+
setLoading(false);
|
|
67
|
+
}, [serviceAccounts, token]);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
load();
|
|
71
|
+
}, [load]);
|
|
72
|
+
|
|
73
|
+
const rows = useMemo(() => {
|
|
74
|
+
const q = search.trim().toLowerCase();
|
|
75
|
+
// Match the name OR any role/company name shown on the row.
|
|
76
|
+
const filtered = q
|
|
77
|
+
? accounts.filter((a) =>
|
|
78
|
+
[a.label, ...(a.roleLabels ?? []), ...(a.scopeLabels ?? [])].some((s) =>
|
|
79
|
+
(s || '').toLowerCase().includes(q),
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
: accounts;
|
|
83
|
+
// Adapt to the EntityList `{ id, fields }` row shape; the account rides along for the menu.
|
|
84
|
+
return filtered.map((a) => ({ id: a.id, fields: a, account: a }));
|
|
85
|
+
}, [accounts, search]);
|
|
86
|
+
|
|
87
|
+
const onSaved = async () => {
|
|
88
|
+
setAccessDialog(null);
|
|
89
|
+
await load();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const onIssueKey = async (account) => {
|
|
93
|
+
const r = await serviceAccounts.issueKey(token, account.id);
|
|
94
|
+
if (!r.ok) {
|
|
95
|
+
toast.error(r.error || 'Could not issue a key.');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
setIssuedKey(r.key); // reveal ONCE
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const confirmPending = async () => {
|
|
102
|
+
if (!pending) return;
|
|
103
|
+
setPendingBusy(true);
|
|
104
|
+
const { kind, account } = pending;
|
|
105
|
+
let r;
|
|
106
|
+
if (kind === 'rotate') r = await serviceAccounts.rotateKey(token, account.id);
|
|
107
|
+
else if (kind === 'revoke') r = await serviceAccounts.revokeKeys(token, account.id);
|
|
108
|
+
else r = await serviceAccounts.update(token, account.id, { status: account.status === 'disabled' ? 'active' : 'disabled' });
|
|
109
|
+
setPendingBusy(false);
|
|
110
|
+
setPending(null);
|
|
111
|
+
if (!r.ok) {
|
|
112
|
+
toast.error(r.error || 'The action could not be completed.');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (kind === 'rotate') {
|
|
116
|
+
setIssuedKey(r.key); // reveal the NEW key ONCE
|
|
117
|
+
toast.success('Key rotated — the previous key no longer works.');
|
|
118
|
+
} else if (kind === 'revoke') {
|
|
119
|
+
toast.success('All keys revoked.');
|
|
120
|
+
} else {
|
|
121
|
+
toast.success(account.status === 'disabled' ? 'Account enabled.' : 'Account disabled.');
|
|
122
|
+
}
|
|
123
|
+
await load();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const COLUMNS = [
|
|
127
|
+
{
|
|
128
|
+
key: 'label',
|
|
129
|
+
label: 'Name',
|
|
130
|
+
render: (row) => (
|
|
131
|
+
<div className="flex items-center gap-2">
|
|
132
|
+
<span className="inline-flex size-8 items-center justify-center rounded-full bg-secondary text-muted-foreground">
|
|
133
|
+
<Bot className="size-4" />
|
|
134
|
+
</span>
|
|
135
|
+
<div className="min-w-0 font-medium">
|
|
136
|
+
<div className="truncate">{row.fields.label}</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
key: 'roles',
|
|
143
|
+
label: 'Roles',
|
|
144
|
+
render: (row) => {
|
|
145
|
+
const { shown, more } = summarizeNames(row.fields.roleLabels);
|
|
146
|
+
if (shown.length === 0) {
|
|
147
|
+
return (
|
|
148
|
+
<Badge variant="outline" className="text-muted-foreground" title="Denied everything until it has a role">
|
|
149
|
+
No roles
|
|
150
|
+
</Badge>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return (
|
|
154
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
155
|
+
{shown.map((name, i) => (
|
|
156
|
+
<Badge key={`${name}-${i}`} variant="secondary">
|
|
157
|
+
{name}
|
|
158
|
+
</Badge>
|
|
159
|
+
))}
|
|
160
|
+
{more > 0 && (
|
|
161
|
+
<Badge variant="outline" title={row.fields.roleLabels.slice(3).join(', ')}>
|
|
162
|
+
+{more}
|
|
163
|
+
</Badge>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
key: 'scope',
|
|
171
|
+
label: 'Scope',
|
|
172
|
+
render: (row) => {
|
|
173
|
+
const names = row.fields.scopeLabels ?? [];
|
|
174
|
+
const { shown, more } = summarizeNames(names);
|
|
175
|
+
if (shown.length === 0) return <span className="text-muted-foreground">—</span>;
|
|
176
|
+
return (
|
|
177
|
+
<span className="text-sm text-muted-foreground" title={names.join(', ')}>
|
|
178
|
+
{shown.join(', ')}
|
|
179
|
+
{more > 0 ? ` +${more}` : ''}
|
|
180
|
+
</span>
|
|
181
|
+
);
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
key: 'status',
|
|
186
|
+
label: 'Status',
|
|
187
|
+
render: (row) =>
|
|
188
|
+
row.fields.status === 'disabled' ? (
|
|
189
|
+
<Badge variant="outline" className="text-muted-foreground">Disabled</Badge>
|
|
190
|
+
) : (
|
|
191
|
+
<Badge>Active</Badge>
|
|
192
|
+
),
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
key: 'createdBy',
|
|
196
|
+
label: 'Created by',
|
|
197
|
+
render: (row) => row.fields.createdByName || '—',
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
key: 'created',
|
|
201
|
+
label: 'Created',
|
|
202
|
+
render: (row) => formatDate(row.fields.createdAtMs),
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
key: 'actions',
|
|
206
|
+
label: '',
|
|
207
|
+
render: (row) => (
|
|
208
|
+
<div className="flex justify-end">
|
|
209
|
+
<ServiceAccountActionsMenu
|
|
210
|
+
account={row.account}
|
|
211
|
+
onEditAccess={(a) => setAccessDialog({ account: a })}
|
|
212
|
+
onIssueKey={onIssueKey}
|
|
213
|
+
onRotateKey={(a) => setPending({ kind: 'rotate', account: a })}
|
|
214
|
+
onToggleStatus={(a) => setPending({ kind: 'toggle', account: a })}
|
|
215
|
+
onRevokeKeys={(a) => setPending({ kind: 'revoke', account: a })}
|
|
216
|
+
/>
|
|
217
|
+
</div>
|
|
218
|
+
),
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
const confirmCopy = (() => {
|
|
223
|
+
if (!pending) return { title: '', message: '', confirmLabel: 'Confirm', destructive: false };
|
|
224
|
+
const name = pending.account.label;
|
|
225
|
+
if (pending.kind === 'rotate') {
|
|
226
|
+
return {
|
|
227
|
+
title: `Rotate the key for “${name}”?`,
|
|
228
|
+
message:
|
|
229
|
+
'A new key is generated and shown once. The current key stops working immediately — update any client using it.',
|
|
230
|
+
confirmLabel: 'Rotate key',
|
|
231
|
+
destructive: true,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (pending.kind === 'revoke') {
|
|
235
|
+
return {
|
|
236
|
+
title: `Revoke all keys for “${name}”?`,
|
|
237
|
+
message:
|
|
238
|
+
'Every key for this account stops working immediately. Any agent using it will lose access until you issue a new key.',
|
|
239
|
+
confirmLabel: 'Revoke keys',
|
|
240
|
+
destructive: true,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const disabling = pending.account.status !== 'disabled';
|
|
244
|
+
return {
|
|
245
|
+
title: disabling ? `Disable “${name}”?` : `Enable “${name}”?`,
|
|
246
|
+
message: disabling
|
|
247
|
+
? 'A disabled account cannot authenticate. Its keys are kept and start working again when you enable it.'
|
|
248
|
+
: 'The account can authenticate again with its existing keys.',
|
|
249
|
+
confirmLabel: disabling ? 'Disable' : 'Enable',
|
|
250
|
+
destructive: disabling,
|
|
251
|
+
};
|
|
252
|
+
})();
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<AppShell section="Identity" title="Service accounts">
|
|
256
|
+
<div className="p-4">
|
|
257
|
+
<PageHeader
|
|
258
|
+
section="Identity"
|
|
259
|
+
title="Service accounts"
|
|
260
|
+
icon={KeyRound}
|
|
261
|
+
actions={
|
|
262
|
+
<EntityListControls
|
|
263
|
+
search={search}
|
|
264
|
+
onSearchChange={setSearch}
|
|
265
|
+
searchPlaceholder="Search service accounts…"
|
|
266
|
+
addLabel="New service account"
|
|
267
|
+
onAdd={() => setAccessDialog({ account: null })}
|
|
268
|
+
/>
|
|
269
|
+
}
|
|
270
|
+
/>
|
|
271
|
+
|
|
272
|
+
<EntityList
|
|
273
|
+
columns={COLUMNS}
|
|
274
|
+
rows={rows}
|
|
275
|
+
search={search}
|
|
276
|
+
loading={loading}
|
|
277
|
+
error={error}
|
|
278
|
+
emptyLabel="No service accounts yet. Create one to give an agent or script scoped API access."
|
|
279
|
+
/>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
{/* Create + edit access (roles + scope, limited to what you may grant) */}
|
|
283
|
+
{accessDialog && (
|
|
284
|
+
<ServiceAccountAccessDialog
|
|
285
|
+
key={accessDialog.account?.id ?? 'new'}
|
|
286
|
+
open
|
|
287
|
+
account={accessDialog.account}
|
|
288
|
+
onClose={() => setAccessDialog(null)}
|
|
289
|
+
onSaved={onSaved}
|
|
290
|
+
/>
|
|
291
|
+
)}
|
|
292
|
+
|
|
293
|
+
{/* One-time key reveal (issue + rotate) */}
|
|
294
|
+
<KeyRevealDialog issued={issuedKey} onClose={() => setIssuedKey(null)} />
|
|
295
|
+
|
|
296
|
+
{/* Destructive confirms: rotate / revoke / disable */}
|
|
297
|
+
<ConfirmDialog
|
|
298
|
+
open={!!pending}
|
|
299
|
+
title={confirmCopy.title}
|
|
300
|
+
message={confirmCopy.message}
|
|
301
|
+
confirmLabel={confirmCopy.confirmLabel}
|
|
302
|
+
destructive={confirmCopy.destructive}
|
|
303
|
+
busy={pendingBusy}
|
|
304
|
+
onConfirm={confirmPending}
|
|
305
|
+
onCancel={() => setPending(null)}
|
|
306
|
+
/>
|
|
307
|
+
</AppShell>
|
|
308
|
+
);
|
|
309
|
+
}
|