@better-forge/ui 0.1.0 → 0.1.1
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/LICENSE +21 -21
- package/README.md +13 -13
- package/icons.json +170 -170
- package/package.json +71 -71
- package/src/api/client.ts +139 -139
- package/src/components/ConfirmIdentityModal.tsx +82 -82
- package/src/components/ConfirmModal.tsx +83 -83
- package/src/components/CopyButton.tsx +71 -71
- package/src/components/CredentialSection.tsx +270 -270
- package/src/components/ErrorBoundary.tsx +47 -47
- package/src/components/ModalShell.tsx +160 -160
- package/src/components/ResetTwoFactorModal.tsx +74 -74
- package/src/components/SidePanel.tsx +96 -96
- package/src/components/SingleSignOnModal.tsx +161 -161
- package/src/components/SsoConfirmation.tsx +96 -96
- package/src/components/VaultTypePicker.tsx +131 -131
- package/src/components/WhatsNew.tsx +84 -84
- package/src/components/formParts.tsx +59 -59
- package/src/components/ui/Alert.tsx +45 -45
- package/src/components/ui/DataTable.tsx +225 -225
- package/src/components/ui/Field.tsx +63 -63
- package/src/components/ui/PageHeader.tsx +36 -36
- package/src/components/ui/Pagination.tsx +37 -37
- package/src/components/ui/QuickStartPanel.tsx +57 -57
- package/src/components/ui/StateViews.tsx +135 -135
- package/src/components/ui/Switch.tsx +65 -65
- package/src/components/ui/Toast.tsx +104 -104
- package/src/components/ui/index.ts +9 -9
- package/src/config.ts +46 -46
- package/src/hooks/useDarkMode.ts +38 -38
- package/src/hooks/useDebouncedValue.ts +22 -22
- package/src/index.ts +42 -42
- package/src/lib/changelog.ts +93 -93
- package/src/lib/dateFormat.ts +90 -90
- package/src/navigation.ts +6 -6
- package/src/pages/ApiTokens.tsx +361 -361
- package/src/pages/AuditLog.tsx +388 -388
- package/src/pages/Backups.tsx +170 -170
- package/src/pages/ChangePassword.tsx +84 -84
- package/src/pages/Login.tsx +275 -275
- package/src/pages/Settings.tsx +264 -264
- package/src/pages/Setup.tsx +438 -438
- package/src/pages/SsoConfirm.tsx +26 -26
- package/src/pages/SsoTestResult.tsx +104 -104
- package/src/pages/Users.tsx +426 -426
- package/src/pages/Vault.tsx +1036 -1036
- package/src/shell/AdminShell.tsx +256 -256
- package/src/shell/guards.tsx +88 -88
- package/styles/base.css +521 -521
- package/styles/icons.css +4 -4
- package/tailwind-preset.js +79 -79
package/src/pages/ApiTokens.tsx
CHANGED
|
@@ -1,361 +1,361 @@
|
|
|
1
|
-
import { useId, useState, type ReactNode } from 'react'
|
|
2
|
-
import { VAULT_USE_SCOPE, type TokenPreset, type TokenResource } from '@better-forge/contracts'
|
|
3
|
-
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
4
|
-
import { api } from '../api/client'
|
|
5
|
-
import { uiConfig } from '../config'
|
|
6
|
-
import { ConfirmIdentityModal } from '../components/ConfirmIdentityModal'
|
|
7
|
-
import { ConfirmModal } from '../components/ConfirmModal'
|
|
8
|
-
import { ModalFooter, ModalHeader, ModalShell } from '../components/ModalShell'
|
|
9
|
-
import { CopyButton } from '../components/CopyButton'
|
|
10
|
-
import { Alert, EmptyStateLink, EmptyTableRow, ErrorState, LoadingState, PageContainer, PageHeader, useToast, DataTable, DataTh, useTableSort } from '../components/ui'
|
|
11
|
-
import { formatDate } from '../lib/dateFormat'
|
|
12
|
-
|
|
13
|
-
interface ApiToken {
|
|
14
|
-
id: number
|
|
15
|
-
name: string
|
|
16
|
-
prefix: string
|
|
17
|
-
scopes: string[]
|
|
18
|
-
createdAt: number
|
|
19
|
-
expiresAt: number | null
|
|
20
|
-
lastUsedAt: number | null
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Ninety days unless a person decides otherwise: a forgotten token should not work forever. */
|
|
24
|
-
const DEFAULT_EXPIRY = '90'
|
|
25
|
-
const EXPIRIES = [
|
|
26
|
-
{ value: '30', label: '30 days' },
|
|
27
|
-
{ value: '90', label: '90 days' },
|
|
28
|
-
{ value: '365', label: '1 year' },
|
|
29
|
-
{ value: 'never', label: 'Never expires' },
|
|
30
|
-
]
|
|
31
|
-
|
|
32
|
-
const read = (key: string) => `${key}:read`
|
|
33
|
-
const write = (key: string) => `${key}:write`
|
|
34
|
-
|
|
35
|
-
/** What a token may do, for the list: each part with its highest level, the vault permission last. */
|
|
36
|
-
function permissionLabels(scopes: string[], resources: readonly TokenResource[]): string[] {
|
|
37
|
-
const labels = resources.flatMap((resource) => {
|
|
38
|
-
if (scopes.includes(write(resource.key))) return [`${resource.label} · write`]
|
|
39
|
-
if (scopes.includes(read(resource.key))) return [`${resource.label} · read`]
|
|
40
|
-
return []
|
|
41
|
-
})
|
|
42
|
-
return scopes.includes(VAULT_USE_SCOPE) ? [...labels, 'Vault secrets'] : labels
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function errorMessage(err: unknown, fallback: string) {
|
|
46
|
-
return err instanceof Error && err.message ? err.message : fallback
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function expiry(token: ApiToken) {
|
|
50
|
-
if (token.expiresAt === null) return 'Never'
|
|
51
|
-
return token.expiresAt <= Date.now() ? `Expired ${formatDate(token.expiresAt)}` : formatDate(token.expiresAt)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** The parts of the API a token can be given, and the ready-made sets of permissions, are the application's. */
|
|
55
|
-
export default function ApiTokensPage({ resources, presets, actions }: { resources: readonly TokenResource[]; presets: readonly TokenPreset[]; /** Buttons shown next to New token, for what a product adds to tokens. */ actions?: ReactNode }) {
|
|
56
|
-
const qc = useQueryClient()
|
|
57
|
-
const toast = useToast()
|
|
58
|
-
const nameInputId = useId()
|
|
59
|
-
const [showCreate, setShowCreate] = useState(false)
|
|
60
|
-
const [name, setName] = useState('')
|
|
61
|
-
const [scopes, setScopes] = useState<string[]>([])
|
|
62
|
-
const [expiresInDays, setExpiresInDays] = useState(DEFAULT_EXPIRY)
|
|
63
|
-
const [created, setCreated] = useState<{ name: string; token: string } | null>(null)
|
|
64
|
-
const [revokeTarget, setRevokeTarget] = useState<ApiToken | null>(null)
|
|
65
|
-
const [confirming, setConfirming] = useState(false)
|
|
66
|
-
|
|
67
|
-
const { data: tokens = [], isLoading, isError, refetch } = useQuery<ApiToken[]>({
|
|
68
|
-
queryKey: ['api-tokens'],
|
|
69
|
-
queryFn: () => api.get('/admin/api-tokens'),
|
|
70
|
-
})
|
|
71
|
-
const { rows: sortedTokens, sort } = useTableSort(tokens, {
|
|
72
|
-
name: (t) => t.name,
|
|
73
|
-
token: (t) => t.prefix,
|
|
74
|
-
expires: (t) => t.expiresAt ?? Number.MAX_SAFE_INTEGER,
|
|
75
|
-
'last used': (t) => t.lastUsedAt,
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
async function createToken(currentPassword: string | undefined) {
|
|
79
|
-
const data = await api.post<ApiToken & { token: string }>('/admin/api-tokens', {
|
|
80
|
-
name,
|
|
81
|
-
scopes,
|
|
82
|
-
expiresInDays: expiresInDays === 'never' ? null : Number(expiresInDays),
|
|
83
|
-
currentPassword,
|
|
84
|
-
})
|
|
85
|
-
qc.invalidateQueries({ queryKey: ['api-tokens'] })
|
|
86
|
-
setCreated({ name: data.name, token: data.token })
|
|
87
|
-
setShowCreate(false)
|
|
88
|
-
setName('')
|
|
89
|
-
setScopes([])
|
|
90
|
-
setExpiresInDays(DEFAULT_EXPIRY)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const revokeMutation = useMutation({
|
|
94
|
-
mutationFn: (id: number) => api.delete(`/admin/api-tokens/${id}`),
|
|
95
|
-
onSuccess: () => {
|
|
96
|
-
toast.success(revokeTarget ? `Revoked ${revokeTarget.name}.` : 'Token revoked.')
|
|
97
|
-
setRevokeTarget(null)
|
|
98
|
-
qc.invalidateQueries({ queryKey: ['api-tokens'] })
|
|
99
|
-
},
|
|
100
|
-
onError: (err) => {
|
|
101
|
-
setRevokeTarget(null)
|
|
102
|
-
toast.error(errorMessage(err, 'Failed to revoke token'))
|
|
103
|
-
},
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
function openCreate() {
|
|
107
|
-
setShowCreate(true)
|
|
108
|
-
setCreated(null)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Writing a part includes reading it, so ticking Write ticks Read and unticking Read unticks Write. */
|
|
112
|
-
function toggle(scope: string, on: boolean) {
|
|
113
|
-
setScopes((current) => {
|
|
114
|
-
const next = new Set(current)
|
|
115
|
-
const [key, level] = scope.split(':') as [string, string]
|
|
116
|
-
if (on) {
|
|
117
|
-
next.add(scope)
|
|
118
|
-
if (level === 'write') next.add(read(key))
|
|
119
|
-
} else {
|
|
120
|
-
next.delete(scope)
|
|
121
|
-
if (level === 'read') next.delete(write(key))
|
|
122
|
-
}
|
|
123
|
-
return [...next]
|
|
124
|
-
})
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const canCreate = name.trim().length > 0 && scopes.length > 0
|
|
128
|
-
|
|
129
|
-
return (
|
|
130
|
-
<PageContainer>
|
|
131
|
-
<PageHeader
|
|
132
|
-
title="API tokens"
|
|
133
|
-
subtitle={isLoading || isError ? undefined : `${tokens.length} token${tokens.length !== 1 ? 's' : ''}`}
|
|
134
|
-
actions={
|
|
135
|
-
<>
|
|
136
|
-
{actions}
|
|
137
|
-
<button type="button" onClick={openCreate} className="btn btn-primary">
|
|
138
|
-
<span className="material-symbols-outlined" aria-hidden="true">add</span>
|
|
139
|
-
New token
|
|
140
|
-
</button>
|
|
141
|
-
</>
|
|
142
|
-
}
|
|
143
|
-
/>
|
|
144
|
-
|
|
145
|
-
<p className="text-sm max-w-2xl" style={{ color: 'var(--m3-secondary)' }}>
|
|
146
|
-
Tokens let scripts and CI call the admin API with <code className="font-mono">Authorization: Bearer <token></code>.
|
|
147
|
-
A token has only the permissions you tick. It cannot manage {uiConfig().texts.tokenCannotManage}. It belongs to no user, so revoke the tokens of an administrator who leaves.
|
|
148
|
-
</p>
|
|
149
|
-
|
|
150
|
-
{showCreate && (
|
|
151
|
-
<ModalShell align="top" onClose={() => setShowCreate(false)} label="New token">
|
|
152
|
-
<div className="rounded-2xl w-full max-w-2xl my-8" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
|
|
153
|
-
<ModalHeader icon="key" title="New token" onClose={() => setShowCreate(false)} />
|
|
154
|
-
<form onSubmit={(e) => { e.preventDefault(); if (canCreate) setConfirming(true) }}>
|
|
155
|
-
<div className="p-4 sm:p-6">
|
|
156
|
-
<label htmlFor={nameInputId} className="block text-xs mb-1.5" style={{ color: 'var(--m3-secondary)' }}>Name</label>
|
|
157
|
-
<div className="flex flex-wrap gap-3">
|
|
158
|
-
<input
|
|
159
|
-
id={nameInputId}
|
|
160
|
-
value={name}
|
|
161
|
-
onChange={(e) => setName(e.target.value)}
|
|
162
|
-
maxLength={80}
|
|
163
|
-
placeholder="GitHub Actions"
|
|
164
|
-
className="input-sig flex-1 min-w-[200px]"
|
|
165
|
-
/>
|
|
166
|
-
<select aria-label="Expires" value={expiresInDays} onChange={(e) => setExpiresInDays(e.target.value)} className="input-sig w-auto">
|
|
167
|
-
{EXPIRIES.map((x) => <option key={x.value} value={x.value}>{x.label}</option>)}
|
|
168
|
-
</select>
|
|
169
|
-
</div>
|
|
170
|
-
|
|
171
|
-
<fieldset className="mt-4">
|
|
172
|
-
<legend className="text-xs mb-1.5" style={{ color: 'var(--m3-secondary)' }}>Permissions</legend>
|
|
173
|
-
<div className="flex flex-wrap gap-2 mb-3">
|
|
174
|
-
{presets.map((preset) => (
|
|
175
|
-
<button
|
|
176
|
-
key={preset.label}
|
|
177
|
-
type="button"
|
|
178
|
-
title={preset.description}
|
|
179
|
-
onClick={() => setScopes([...preset.scopes])}
|
|
180
|
-
className="btn btn-secondary btn-sm"
|
|
181
|
-
>
|
|
182
|
-
{preset.label}
|
|
183
|
-
</button>
|
|
184
|
-
))}
|
|
185
|
-
<button type="button" onClick={() => setScopes([])} className="btn btn-ghost btn-sm">Clear</button>
|
|
186
|
-
</div>
|
|
187
|
-
<div className="rounded-xl overflow-x-auto" style={{ border: '1px solid var(--m3-outline-variant)' }}>
|
|
188
|
-
<table className="w-full min-w-[480px] text-sm">
|
|
189
|
-
<thead>
|
|
190
|
-
<tr style={{ background: 'var(--m3-surface-container)' }}>
|
|
191
|
-
<th className="px-4 py-2 text-left font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>Part</th>
|
|
192
|
-
<th className="px-4 py-2 w-20 font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>Read</th>
|
|
193
|
-
<th className="px-4 py-2 w-20 font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>Write</th>
|
|
194
|
-
</tr>
|
|
195
|
-
</thead>
|
|
196
|
-
<tbody>
|
|
197
|
-
{resources.map((resource, index) => (
|
|
198
|
-
<tr key={resource.key} style={{ borderTop: index > 0 ? '1px solid var(--m3-outline-variant)' : 'none' }}>
|
|
199
|
-
<td className="px-4 py-2">
|
|
200
|
-
<span style={{ color: 'var(--m3-on-surface)' }}>{resource.label}</span>
|
|
201
|
-
<span className="block text-xs" style={{ color: 'var(--m3-secondary)' }}>{resource.description}</span>
|
|
202
|
-
</td>
|
|
203
|
-
{(['read', 'write'] as const).map((level) => (
|
|
204
|
-
<td key={level} className="px-4 py-2 text-center">
|
|
205
|
-
{resource.levels.includes(level) && (
|
|
206
|
-
<input
|
|
207
|
-
type="checkbox"
|
|
208
|
-
aria-label={`${resource.label} ${level}`}
|
|
209
|
-
checked={scopes.includes(`${resource.key}:${level}`)}
|
|
210
|
-
onChange={(e) => toggle(`${resource.key}:${level}`, e.target.checked)}
|
|
211
|
-
/>
|
|
212
|
-
)}
|
|
213
|
-
</td>
|
|
214
|
-
))}
|
|
215
|
-
</tr>
|
|
216
|
-
))}
|
|
217
|
-
</tbody>
|
|
218
|
-
</table>
|
|
219
|
-
</div>
|
|
220
|
-
<label className="flex items-start gap-3 mt-3 text-sm" style={{ color: 'var(--m3-on-surface)' }}>
|
|
221
|
-
<input
|
|
222
|
-
type="checkbox"
|
|
223
|
-
className="mt-1"
|
|
224
|
-
checked={scopes.includes(VAULT_USE_SCOPE)}
|
|
225
|
-
onChange={(e) => toggle(VAULT_USE_SCOPE, e.target.checked)}
|
|
226
|
-
/>
|
|
227
|
-
<span>
|
|
228
|
-
Use vault secrets
|
|
229
|
-
<span className="block text-xs" style={{ color: 'var(--m3-secondary)' }}>
|
|
230
|
-
Lets the token attach a vault secret to {uiConfig().texts.tokenVaultTargets}, or change one that already uses a vault. Without it the token cannot read or use credentials from a vault.
|
|
231
|
-
</span>
|
|
232
|
-
</span>
|
|
233
|
-
</label>
|
|
234
|
-
</fieldset>
|
|
235
|
-
|
|
236
|
-
</div>
|
|
237
|
-
|
|
238
|
-
<ModalFooter>
|
|
239
|
-
<button type="button" onClick={() => setShowCreate(false)} className="btn btn-secondary">Cancel</button>
|
|
240
|
-
<button type="submit" disabled={!canCreate} className="btn btn-primary">
|
|
241
|
-
Create
|
|
242
|
-
</button>
|
|
243
|
-
</ModalFooter>
|
|
244
|
-
</form>
|
|
245
|
-
</div>
|
|
246
|
-
</ModalShell>
|
|
247
|
-
)}
|
|
248
|
-
|
|
249
|
-
{confirming && (
|
|
250
|
-
<ConfirmIdentityModal
|
|
251
|
-
title="Create token"
|
|
252
|
-
message="A token can act on the admin API without you being signed in. Confirm it's you to create it."
|
|
253
|
-
confirmLabel="Create token"
|
|
254
|
-
pendingLabel="Creating…"
|
|
255
|
-
action={createToken}
|
|
256
|
-
onClose={() => setConfirming(false)}
|
|
257
|
-
/>
|
|
258
|
-
)}
|
|
259
|
-
|
|
260
|
-
{created && (
|
|
261
|
-
<Alert tone="success" title={<>Token <strong>{created.name}</strong> created</>} onDismiss={() => setCreated(null)}>
|
|
262
|
-
<p className="text-xs mb-3" style={{ color: 'var(--m3-secondary)' }}>
|
|
263
|
-
Copy it now. It will not be shown again; if you lose it, revoke it and create a new one.
|
|
264
|
-
</p>
|
|
265
|
-
<div className="flex flex-wrap items-center gap-3">
|
|
266
|
-
<code
|
|
267
|
-
className="font-mono text-sm px-4 py-2 rounded-xl select-all break-all"
|
|
268
|
-
style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-on-surface)' }}
|
|
269
|
-
>
|
|
270
|
-
{created.token}
|
|
271
|
-
</code>
|
|
272
|
-
<CopyButton value={created.token} />
|
|
273
|
-
</div>
|
|
274
|
-
</Alert>
|
|
275
|
-
)}
|
|
276
|
-
|
|
277
|
-
{isLoading ? (
|
|
278
|
-
<LoadingState label="Loading tokens…" />
|
|
279
|
-
) : isError ? (
|
|
280
|
-
<ErrorState message="Could not load tokens." onRetry={() => void refetch()} />
|
|
281
|
-
) : (
|
|
282
|
-
<div className="rounded-2xl overflow-x-auto" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
|
|
283
|
-
<DataTable sort={sort} className="w-full min-w-[760px] text-sm">
|
|
284
|
-
<thead>
|
|
285
|
-
<tr style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
|
|
286
|
-
{['Name', 'Token', 'Permissions', 'Expires', 'Last used', ''].map((h) => (
|
|
287
|
-
<DataTh
|
|
288
|
-
key={h}
|
|
289
|
-
resizable={h !== ''}
|
|
290
|
-
{...(h !== '' && h !== 'Permissions' && { sortKey: h.toLowerCase() })}
|
|
291
|
-
className={`px-4 py-3 font-mono text-xs uppercase tracking-wider ${h === '' ? 'text-right' : 'text-left'}`}
|
|
292
|
-
style={{ color: 'var(--m3-secondary)', background: 'var(--m3-surface-container)' }}
|
|
293
|
-
>
|
|
294
|
-
{h === '' ? <span className="sr-only">Actions</span> : h}
|
|
295
|
-
</DataTh>
|
|
296
|
-
))}
|
|
297
|
-
</tr>
|
|
298
|
-
</thead>
|
|
299
|
-
<tbody>
|
|
300
|
-
{sortedTokens.map((token, i) => (
|
|
301
|
-
<tr key={token.id} style={{ borderTop: i > 0 ? '1px solid var(--m3-outline-variant)' : 'none' }}>
|
|
302
|
-
<td className="px-4 py-3 font-medium" style={{ color: 'var(--m3-on-surface)' }}>{token.name}</td>
|
|
303
|
-
<td className="px-4 py-3 font-mono text-xs" style={{ color: 'var(--m3-secondary)' }}>{token.prefix}…</td>
|
|
304
|
-
<td className="px-4 py-3">
|
|
305
|
-
<ul className="flex flex-wrap gap-1.5" aria-label={`Permissions of ${token.name}`}>
|
|
306
|
-
{permissionLabels(token.scopes, resources).map((label) => (
|
|
307
|
-
<li key={label} className="text-xs font-semibold px-2 py-0.5 rounded-lg" style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-on-surface)' }}>
|
|
308
|
-
{label}
|
|
309
|
-
</li>
|
|
310
|
-
))}
|
|
311
|
-
</ul>
|
|
312
|
-
</td>
|
|
313
|
-
<td className="px-4 py-3 font-mono text-xs" style={{ color: 'var(--m3-secondary)' }}>{expiry(token)}</td>
|
|
314
|
-
<td className="px-4 py-3 font-mono text-xs" style={{ color: 'var(--m3-secondary)' }}>
|
|
315
|
-
{token.lastUsedAt === null ? 'Never' : formatDate(token.lastUsedAt)}
|
|
316
|
-
</td>
|
|
317
|
-
<td className="px-4 py-3">
|
|
318
|
-
<div className="flex items-center justify-end">
|
|
319
|
-
<button
|
|
320
|
-
type="button"
|
|
321
|
-
onClick={() => setRevokeTarget(token)}
|
|
322
|
-
title="Revoke token"
|
|
323
|
-
aria-label={`Revoke ${token.name}`}
|
|
324
|
-
className="w-8 h-8 flex items-center justify-center rounded-lg transition-colors text-xs focus-ring"
|
|
325
|
-
style={{ color: 'var(--m3-secondary)' }}
|
|
326
|
-
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--m3-error-container)'; e.currentTarget.style.color = 'var(--m3-on-error-container)' }}
|
|
327
|
-
onMouseLeave={(e) => { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--m3-secondary)' }}
|
|
328
|
-
>
|
|
329
|
-
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>delete</span>
|
|
330
|
-
</button>
|
|
331
|
-
</div>
|
|
332
|
-
</td>
|
|
333
|
-
</tr>
|
|
334
|
-
))}
|
|
335
|
-
{tokens.length === 0 && (
|
|
336
|
-
<EmptyTableRow
|
|
337
|
-
colSpan={7}
|
|
338
|
-
icon="key"
|
|
339
|
-
title="No API tokens yet."
|
|
340
|
-
description={<><EmptyStateLink onClick={openCreate}>Create a token</EmptyStateLink> to call the admin API from scripts and CI.</>}
|
|
341
|
-
/>
|
|
342
|
-
)}
|
|
343
|
-
</tbody>
|
|
344
|
-
</DataTable>
|
|
345
|
-
</div>
|
|
346
|
-
)}
|
|
347
|
-
|
|
348
|
-
{revokeTarget && (
|
|
349
|
-
<ConfirmModal
|
|
350
|
-
title="Revoke token"
|
|
351
|
-
message={`Revoke "${revokeTarget.name}"? Anything still using it stops working immediately.`}
|
|
352
|
-
confirmLabel="Revoke"
|
|
353
|
-
pending={revokeMutation.isPending}
|
|
354
|
-
pendingLabel="Revoking…"
|
|
355
|
-
onConfirm={() => revokeMutation.mutate(revokeTarget.id)}
|
|
356
|
-
onCancel={() => setRevokeTarget(null)}
|
|
357
|
-
/>
|
|
358
|
-
)}
|
|
359
|
-
</PageContainer>
|
|
360
|
-
)
|
|
361
|
-
}
|
|
1
|
+
import { useId, useState, type ReactNode } from 'react'
|
|
2
|
+
import { VAULT_USE_SCOPE, type TokenPreset, type TokenResource } from '@better-forge/contracts'
|
|
3
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
4
|
+
import { api } from '../api/client'
|
|
5
|
+
import { uiConfig } from '../config'
|
|
6
|
+
import { ConfirmIdentityModal } from '../components/ConfirmIdentityModal'
|
|
7
|
+
import { ConfirmModal } from '../components/ConfirmModal'
|
|
8
|
+
import { ModalFooter, ModalHeader, ModalShell } from '../components/ModalShell'
|
|
9
|
+
import { CopyButton } from '../components/CopyButton'
|
|
10
|
+
import { Alert, EmptyStateLink, EmptyTableRow, ErrorState, LoadingState, PageContainer, PageHeader, useToast, DataTable, DataTh, useTableSort } from '../components/ui'
|
|
11
|
+
import { formatDate } from '../lib/dateFormat'
|
|
12
|
+
|
|
13
|
+
interface ApiToken {
|
|
14
|
+
id: number
|
|
15
|
+
name: string
|
|
16
|
+
prefix: string
|
|
17
|
+
scopes: string[]
|
|
18
|
+
createdAt: number
|
|
19
|
+
expiresAt: number | null
|
|
20
|
+
lastUsedAt: number | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Ninety days unless a person decides otherwise: a forgotten token should not work forever. */
|
|
24
|
+
const DEFAULT_EXPIRY = '90'
|
|
25
|
+
const EXPIRIES = [
|
|
26
|
+
{ value: '30', label: '30 days' },
|
|
27
|
+
{ value: '90', label: '90 days' },
|
|
28
|
+
{ value: '365', label: '1 year' },
|
|
29
|
+
{ value: 'never', label: 'Never expires' },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const read = (key: string) => `${key}:read`
|
|
33
|
+
const write = (key: string) => `${key}:write`
|
|
34
|
+
|
|
35
|
+
/** What a token may do, for the list: each part with its highest level, the vault permission last. */
|
|
36
|
+
function permissionLabels(scopes: string[], resources: readonly TokenResource[]): string[] {
|
|
37
|
+
const labels = resources.flatMap((resource) => {
|
|
38
|
+
if (scopes.includes(write(resource.key))) return [`${resource.label} · write`]
|
|
39
|
+
if (scopes.includes(read(resource.key))) return [`${resource.label} · read`]
|
|
40
|
+
return []
|
|
41
|
+
})
|
|
42
|
+
return scopes.includes(VAULT_USE_SCOPE) ? [...labels, 'Vault secrets'] : labels
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function errorMessage(err: unknown, fallback: string) {
|
|
46
|
+
return err instanceof Error && err.message ? err.message : fallback
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function expiry(token: ApiToken) {
|
|
50
|
+
if (token.expiresAt === null) return 'Never'
|
|
51
|
+
return token.expiresAt <= Date.now() ? `Expired ${formatDate(token.expiresAt)}` : formatDate(token.expiresAt)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The parts of the API a token can be given, and the ready-made sets of permissions, are the application's. */
|
|
55
|
+
export default function ApiTokensPage({ resources, presets, actions }: { resources: readonly TokenResource[]; presets: readonly TokenPreset[]; /** Buttons shown next to New token, for what a product adds to tokens. */ actions?: ReactNode }) {
|
|
56
|
+
const qc = useQueryClient()
|
|
57
|
+
const toast = useToast()
|
|
58
|
+
const nameInputId = useId()
|
|
59
|
+
const [showCreate, setShowCreate] = useState(false)
|
|
60
|
+
const [name, setName] = useState('')
|
|
61
|
+
const [scopes, setScopes] = useState<string[]>([])
|
|
62
|
+
const [expiresInDays, setExpiresInDays] = useState(DEFAULT_EXPIRY)
|
|
63
|
+
const [created, setCreated] = useState<{ name: string; token: string } | null>(null)
|
|
64
|
+
const [revokeTarget, setRevokeTarget] = useState<ApiToken | null>(null)
|
|
65
|
+
const [confirming, setConfirming] = useState(false)
|
|
66
|
+
|
|
67
|
+
const { data: tokens = [], isLoading, isError, refetch } = useQuery<ApiToken[]>({
|
|
68
|
+
queryKey: ['api-tokens'],
|
|
69
|
+
queryFn: () => api.get('/admin/api-tokens'),
|
|
70
|
+
})
|
|
71
|
+
const { rows: sortedTokens, sort } = useTableSort(tokens, {
|
|
72
|
+
name: (t) => t.name,
|
|
73
|
+
token: (t) => t.prefix,
|
|
74
|
+
expires: (t) => t.expiresAt ?? Number.MAX_SAFE_INTEGER,
|
|
75
|
+
'last used': (t) => t.lastUsedAt,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
async function createToken(currentPassword: string | undefined) {
|
|
79
|
+
const data = await api.post<ApiToken & { token: string }>('/admin/api-tokens', {
|
|
80
|
+
name,
|
|
81
|
+
scopes,
|
|
82
|
+
expiresInDays: expiresInDays === 'never' ? null : Number(expiresInDays),
|
|
83
|
+
currentPassword,
|
|
84
|
+
})
|
|
85
|
+
qc.invalidateQueries({ queryKey: ['api-tokens'] })
|
|
86
|
+
setCreated({ name: data.name, token: data.token })
|
|
87
|
+
setShowCreate(false)
|
|
88
|
+
setName('')
|
|
89
|
+
setScopes([])
|
|
90
|
+
setExpiresInDays(DEFAULT_EXPIRY)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const revokeMutation = useMutation({
|
|
94
|
+
mutationFn: (id: number) => api.delete(`/admin/api-tokens/${id}`),
|
|
95
|
+
onSuccess: () => {
|
|
96
|
+
toast.success(revokeTarget ? `Revoked ${revokeTarget.name}.` : 'Token revoked.')
|
|
97
|
+
setRevokeTarget(null)
|
|
98
|
+
qc.invalidateQueries({ queryKey: ['api-tokens'] })
|
|
99
|
+
},
|
|
100
|
+
onError: (err) => {
|
|
101
|
+
setRevokeTarget(null)
|
|
102
|
+
toast.error(errorMessage(err, 'Failed to revoke token'))
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
function openCreate() {
|
|
107
|
+
setShowCreate(true)
|
|
108
|
+
setCreated(null)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Writing a part includes reading it, so ticking Write ticks Read and unticking Read unticks Write. */
|
|
112
|
+
function toggle(scope: string, on: boolean) {
|
|
113
|
+
setScopes((current) => {
|
|
114
|
+
const next = new Set(current)
|
|
115
|
+
const [key, level] = scope.split(':') as [string, string]
|
|
116
|
+
if (on) {
|
|
117
|
+
next.add(scope)
|
|
118
|
+
if (level === 'write') next.add(read(key))
|
|
119
|
+
} else {
|
|
120
|
+
next.delete(scope)
|
|
121
|
+
if (level === 'read') next.delete(write(key))
|
|
122
|
+
}
|
|
123
|
+
return [...next]
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const canCreate = name.trim().length > 0 && scopes.length > 0
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<PageContainer>
|
|
131
|
+
<PageHeader
|
|
132
|
+
title="API tokens"
|
|
133
|
+
subtitle={isLoading || isError ? undefined : `${tokens.length} token${tokens.length !== 1 ? 's' : ''}`}
|
|
134
|
+
actions={
|
|
135
|
+
<>
|
|
136
|
+
{actions}
|
|
137
|
+
<button type="button" onClick={openCreate} className="btn btn-primary">
|
|
138
|
+
<span className="material-symbols-outlined" aria-hidden="true">add</span>
|
|
139
|
+
New token
|
|
140
|
+
</button>
|
|
141
|
+
</>
|
|
142
|
+
}
|
|
143
|
+
/>
|
|
144
|
+
|
|
145
|
+
<p className="text-sm max-w-2xl" style={{ color: 'var(--m3-secondary)' }}>
|
|
146
|
+
Tokens let scripts and CI call the admin API with <code className="font-mono">Authorization: Bearer <token></code>.
|
|
147
|
+
A token has only the permissions you tick. It cannot manage {uiConfig().texts.tokenCannotManage}. It belongs to no user, so revoke the tokens of an administrator who leaves.
|
|
148
|
+
</p>
|
|
149
|
+
|
|
150
|
+
{showCreate && (
|
|
151
|
+
<ModalShell align="top" onClose={() => setShowCreate(false)} label="New token">
|
|
152
|
+
<div className="rounded-2xl w-full max-w-2xl my-8" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
|
|
153
|
+
<ModalHeader icon="key" title="New token" onClose={() => setShowCreate(false)} />
|
|
154
|
+
<form onSubmit={(e) => { e.preventDefault(); if (canCreate) setConfirming(true) }}>
|
|
155
|
+
<div className="p-4 sm:p-6">
|
|
156
|
+
<label htmlFor={nameInputId} className="block text-xs mb-1.5" style={{ color: 'var(--m3-secondary)' }}>Name</label>
|
|
157
|
+
<div className="flex flex-wrap gap-3">
|
|
158
|
+
<input
|
|
159
|
+
id={nameInputId}
|
|
160
|
+
value={name}
|
|
161
|
+
onChange={(e) => setName(e.target.value)}
|
|
162
|
+
maxLength={80}
|
|
163
|
+
placeholder="GitHub Actions"
|
|
164
|
+
className="input-sig flex-1 min-w-[200px]"
|
|
165
|
+
/>
|
|
166
|
+
<select aria-label="Expires" value={expiresInDays} onChange={(e) => setExpiresInDays(e.target.value)} className="input-sig w-auto">
|
|
167
|
+
{EXPIRIES.map((x) => <option key={x.value} value={x.value}>{x.label}</option>)}
|
|
168
|
+
</select>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<fieldset className="mt-4">
|
|
172
|
+
<legend className="text-xs mb-1.5" style={{ color: 'var(--m3-secondary)' }}>Permissions</legend>
|
|
173
|
+
<div className="flex flex-wrap gap-2 mb-3">
|
|
174
|
+
{presets.map((preset) => (
|
|
175
|
+
<button
|
|
176
|
+
key={preset.label}
|
|
177
|
+
type="button"
|
|
178
|
+
title={preset.description}
|
|
179
|
+
onClick={() => setScopes([...preset.scopes])}
|
|
180
|
+
className="btn btn-secondary btn-sm"
|
|
181
|
+
>
|
|
182
|
+
{preset.label}
|
|
183
|
+
</button>
|
|
184
|
+
))}
|
|
185
|
+
<button type="button" onClick={() => setScopes([])} className="btn btn-ghost btn-sm">Clear</button>
|
|
186
|
+
</div>
|
|
187
|
+
<div className="rounded-xl overflow-x-auto" style={{ border: '1px solid var(--m3-outline-variant)' }}>
|
|
188
|
+
<table className="w-full min-w-[480px] text-sm">
|
|
189
|
+
<thead>
|
|
190
|
+
<tr style={{ background: 'var(--m3-surface-container)' }}>
|
|
191
|
+
<th className="px-4 py-2 text-left font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>Part</th>
|
|
192
|
+
<th className="px-4 py-2 w-20 font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>Read</th>
|
|
193
|
+
<th className="px-4 py-2 w-20 font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>Write</th>
|
|
194
|
+
</tr>
|
|
195
|
+
</thead>
|
|
196
|
+
<tbody>
|
|
197
|
+
{resources.map((resource, index) => (
|
|
198
|
+
<tr key={resource.key} style={{ borderTop: index > 0 ? '1px solid var(--m3-outline-variant)' : 'none' }}>
|
|
199
|
+
<td className="px-4 py-2">
|
|
200
|
+
<span style={{ color: 'var(--m3-on-surface)' }}>{resource.label}</span>
|
|
201
|
+
<span className="block text-xs" style={{ color: 'var(--m3-secondary)' }}>{resource.description}</span>
|
|
202
|
+
</td>
|
|
203
|
+
{(['read', 'write'] as const).map((level) => (
|
|
204
|
+
<td key={level} className="px-4 py-2 text-center">
|
|
205
|
+
{resource.levels.includes(level) && (
|
|
206
|
+
<input
|
|
207
|
+
type="checkbox"
|
|
208
|
+
aria-label={`${resource.label} ${level}`}
|
|
209
|
+
checked={scopes.includes(`${resource.key}:${level}`)}
|
|
210
|
+
onChange={(e) => toggle(`${resource.key}:${level}`, e.target.checked)}
|
|
211
|
+
/>
|
|
212
|
+
)}
|
|
213
|
+
</td>
|
|
214
|
+
))}
|
|
215
|
+
</tr>
|
|
216
|
+
))}
|
|
217
|
+
</tbody>
|
|
218
|
+
</table>
|
|
219
|
+
</div>
|
|
220
|
+
<label className="flex items-start gap-3 mt-3 text-sm" style={{ color: 'var(--m3-on-surface)' }}>
|
|
221
|
+
<input
|
|
222
|
+
type="checkbox"
|
|
223
|
+
className="mt-1"
|
|
224
|
+
checked={scopes.includes(VAULT_USE_SCOPE)}
|
|
225
|
+
onChange={(e) => toggle(VAULT_USE_SCOPE, e.target.checked)}
|
|
226
|
+
/>
|
|
227
|
+
<span>
|
|
228
|
+
Use vault secrets
|
|
229
|
+
<span className="block text-xs" style={{ color: 'var(--m3-secondary)' }}>
|
|
230
|
+
Lets the token attach a vault secret to {uiConfig().texts.tokenVaultTargets}, or change one that already uses a vault. Without it the token cannot read or use credentials from a vault.
|
|
231
|
+
</span>
|
|
232
|
+
</span>
|
|
233
|
+
</label>
|
|
234
|
+
</fieldset>
|
|
235
|
+
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<ModalFooter>
|
|
239
|
+
<button type="button" onClick={() => setShowCreate(false)} className="btn btn-secondary">Cancel</button>
|
|
240
|
+
<button type="submit" disabled={!canCreate} className="btn btn-primary">
|
|
241
|
+
Create
|
|
242
|
+
</button>
|
|
243
|
+
</ModalFooter>
|
|
244
|
+
</form>
|
|
245
|
+
</div>
|
|
246
|
+
</ModalShell>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
{confirming && (
|
|
250
|
+
<ConfirmIdentityModal
|
|
251
|
+
title="Create token"
|
|
252
|
+
message="A token can act on the admin API without you being signed in. Confirm it's you to create it."
|
|
253
|
+
confirmLabel="Create token"
|
|
254
|
+
pendingLabel="Creating…"
|
|
255
|
+
action={createToken}
|
|
256
|
+
onClose={() => setConfirming(false)}
|
|
257
|
+
/>
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
{created && (
|
|
261
|
+
<Alert tone="success" title={<>Token <strong>{created.name}</strong> created</>} onDismiss={() => setCreated(null)}>
|
|
262
|
+
<p className="text-xs mb-3" style={{ color: 'var(--m3-secondary)' }}>
|
|
263
|
+
Copy it now. It will not be shown again; if you lose it, revoke it and create a new one.
|
|
264
|
+
</p>
|
|
265
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
266
|
+
<code
|
|
267
|
+
className="font-mono text-sm px-4 py-2 rounded-xl select-all break-all"
|
|
268
|
+
style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-on-surface)' }}
|
|
269
|
+
>
|
|
270
|
+
{created.token}
|
|
271
|
+
</code>
|
|
272
|
+
<CopyButton value={created.token} />
|
|
273
|
+
</div>
|
|
274
|
+
</Alert>
|
|
275
|
+
)}
|
|
276
|
+
|
|
277
|
+
{isLoading ? (
|
|
278
|
+
<LoadingState label="Loading tokens…" />
|
|
279
|
+
) : isError ? (
|
|
280
|
+
<ErrorState message="Could not load tokens." onRetry={() => void refetch()} />
|
|
281
|
+
) : (
|
|
282
|
+
<div className="rounded-2xl overflow-x-auto" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
|
|
283
|
+
<DataTable sort={sort} className="w-full min-w-[760px] text-sm">
|
|
284
|
+
<thead>
|
|
285
|
+
<tr style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
|
|
286
|
+
{['Name', 'Token', 'Permissions', 'Expires', 'Last used', ''].map((h) => (
|
|
287
|
+
<DataTh
|
|
288
|
+
key={h}
|
|
289
|
+
resizable={h !== ''}
|
|
290
|
+
{...(h !== '' && h !== 'Permissions' && { sortKey: h.toLowerCase() })}
|
|
291
|
+
className={`px-4 py-3 font-mono text-xs uppercase tracking-wider ${h === '' ? 'text-right' : 'text-left'}`}
|
|
292
|
+
style={{ color: 'var(--m3-secondary)', background: 'var(--m3-surface-container)' }}
|
|
293
|
+
>
|
|
294
|
+
{h === '' ? <span className="sr-only">Actions</span> : h}
|
|
295
|
+
</DataTh>
|
|
296
|
+
))}
|
|
297
|
+
</tr>
|
|
298
|
+
</thead>
|
|
299
|
+
<tbody>
|
|
300
|
+
{sortedTokens.map((token, i) => (
|
|
301
|
+
<tr key={token.id} style={{ borderTop: i > 0 ? '1px solid var(--m3-outline-variant)' : 'none' }}>
|
|
302
|
+
<td className="px-4 py-3 font-medium" style={{ color: 'var(--m3-on-surface)' }}>{token.name}</td>
|
|
303
|
+
<td className="px-4 py-3 font-mono text-xs" style={{ color: 'var(--m3-secondary)' }}>{token.prefix}…</td>
|
|
304
|
+
<td className="px-4 py-3">
|
|
305
|
+
<ul className="flex flex-wrap gap-1.5" aria-label={`Permissions of ${token.name}`}>
|
|
306
|
+
{permissionLabels(token.scopes, resources).map((label) => (
|
|
307
|
+
<li key={label} className="text-xs font-semibold px-2 py-0.5 rounded-lg" style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-on-surface)' }}>
|
|
308
|
+
{label}
|
|
309
|
+
</li>
|
|
310
|
+
))}
|
|
311
|
+
</ul>
|
|
312
|
+
</td>
|
|
313
|
+
<td className="px-4 py-3 font-mono text-xs" style={{ color: 'var(--m3-secondary)' }}>{expiry(token)}</td>
|
|
314
|
+
<td className="px-4 py-3 font-mono text-xs" style={{ color: 'var(--m3-secondary)' }}>
|
|
315
|
+
{token.lastUsedAt === null ? 'Never' : formatDate(token.lastUsedAt)}
|
|
316
|
+
</td>
|
|
317
|
+
<td className="px-4 py-3">
|
|
318
|
+
<div className="flex items-center justify-end">
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
onClick={() => setRevokeTarget(token)}
|
|
322
|
+
title="Revoke token"
|
|
323
|
+
aria-label={`Revoke ${token.name}`}
|
|
324
|
+
className="w-8 h-8 flex items-center justify-center rounded-lg transition-colors text-xs focus-ring"
|
|
325
|
+
style={{ color: 'var(--m3-secondary)' }}
|
|
326
|
+
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--m3-error-container)'; e.currentTarget.style.color = 'var(--m3-on-error-container)' }}
|
|
327
|
+
onMouseLeave={(e) => { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--m3-secondary)' }}
|
|
328
|
+
>
|
|
329
|
+
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>delete</span>
|
|
330
|
+
</button>
|
|
331
|
+
</div>
|
|
332
|
+
</td>
|
|
333
|
+
</tr>
|
|
334
|
+
))}
|
|
335
|
+
{tokens.length === 0 && (
|
|
336
|
+
<EmptyTableRow
|
|
337
|
+
colSpan={7}
|
|
338
|
+
icon="key"
|
|
339
|
+
title="No API tokens yet."
|
|
340
|
+
description={<><EmptyStateLink onClick={openCreate}>Create a token</EmptyStateLink> to call the admin API from scripts and CI.</>}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
343
|
+
</tbody>
|
|
344
|
+
</DataTable>
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
|
|
348
|
+
{revokeTarget && (
|
|
349
|
+
<ConfirmModal
|
|
350
|
+
title="Revoke token"
|
|
351
|
+
message={`Revoke "${revokeTarget.name}"? Anything still using it stops working immediately.`}
|
|
352
|
+
confirmLabel="Revoke"
|
|
353
|
+
pending={revokeMutation.isPending}
|
|
354
|
+
pendingLabel="Revoking…"
|
|
355
|
+
onConfirm={() => revokeMutation.mutate(revokeTarget.id)}
|
|
356
|
+
onCancel={() => setRevokeTarget(null)}
|
|
357
|
+
/>
|
|
358
|
+
)}
|
|
359
|
+
</PageContainer>
|
|
360
|
+
)
|
|
361
|
+
}
|