@actuate-media/cms-admin 0.8.2 → 0.9.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/LICENSE +21 -21
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +3 -2
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/views/ApiKeys.d.ts +5 -0
- package/dist/views/ApiKeys.d.ts.map +1 -0
- package/dist/views/ApiKeys.js +152 -0
- package/dist/views/ApiKeys.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +5 -0
- package/src/index.ts +1 -0
- package/src/layout/Sidebar.tsx +3 -0
- package/src/views/ApiKeys.tsx +512 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as Dialog from '@radix-ui/react-dialog'
|
|
4
|
+
import {
|
|
5
|
+
Copy,
|
|
6
|
+
KeyRound,
|
|
7
|
+
Loader2,
|
|
8
|
+
Plus,
|
|
9
|
+
Shield,
|
|
10
|
+
Trash2,
|
|
11
|
+
AlertTriangle,
|
|
12
|
+
Eye,
|
|
13
|
+
EyeOff,
|
|
14
|
+
} from 'lucide-react'
|
|
15
|
+
import { type FormEvent, useState } from 'react'
|
|
16
|
+
import { toast } from 'sonner'
|
|
17
|
+
import { useApiData } from '../lib/useApiData.js'
|
|
18
|
+
import { cmsApi } from '../lib/api.js'
|
|
19
|
+
|
|
20
|
+
export interface ApiKeysProps {
|
|
21
|
+
onNavigate?: (path: string) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ApiKeyRecord {
|
|
25
|
+
id: string
|
|
26
|
+
name: string
|
|
27
|
+
keyPrefix: string
|
|
28
|
+
scopes: ApiKeyScopes
|
|
29
|
+
ipRestrictions: string[] | null
|
|
30
|
+
expiresAt: string | null
|
|
31
|
+
lastUsedAt: string | null
|
|
32
|
+
revokedAt: string | null
|
|
33
|
+
createdAt: string
|
|
34
|
+
user?: { id: string; name: string | null; email: string }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ApiKeyScopes {
|
|
38
|
+
admin?: boolean
|
|
39
|
+
collections?: string[]
|
|
40
|
+
actions?: ('read' | 'create' | 'update' | 'delete')[]
|
|
41
|
+
globals?: string[]
|
|
42
|
+
media?: boolean
|
|
43
|
+
pageBuilder?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatScopes(scopes: ApiKeyScopes): string {
|
|
47
|
+
if (scopes.admin) return 'Full admin'
|
|
48
|
+
const parts: string[] = []
|
|
49
|
+
if (scopes.collections?.length) {
|
|
50
|
+
const cols = scopes.collections.includes('*')
|
|
51
|
+
? 'all collections'
|
|
52
|
+
: scopes.collections.join(', ')
|
|
53
|
+
const acts = scopes.actions?.join('/') ?? 'read'
|
|
54
|
+
parts.push(`${acts} on ${cols}`)
|
|
55
|
+
}
|
|
56
|
+
if (scopes.globals?.length) parts.push(`globals: ${scopes.globals.join(', ')}`)
|
|
57
|
+
if (scopes.media) parts.push('media')
|
|
58
|
+
if (scopes.pageBuilder) parts.push('pageBuilder')
|
|
59
|
+
return parts.length > 0 ? parts.join(' · ') : 'No scopes'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatDate(value: string | null): string {
|
|
63
|
+
if (!value) return '—'
|
|
64
|
+
try {
|
|
65
|
+
return new Date(value).toLocaleString()
|
|
66
|
+
} catch {
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ApiKeys(_props: ApiKeysProps) {
|
|
72
|
+
const { data, loading, error, refetch } = useApiData<ApiKeyRecord[]>('/api-keys')
|
|
73
|
+
const [showCreate, setShowCreate] = useState(false)
|
|
74
|
+
const [createdKey, setCreatedKey] = useState<{ key: string; record: ApiKeyRecord } | null>(null)
|
|
75
|
+
|
|
76
|
+
const keys = data ?? []
|
|
77
|
+
|
|
78
|
+
const handleRevoke = async (id: string, name: string) => {
|
|
79
|
+
if (!confirm(`Revoke API key "${name}"? This cannot be undone.`)) return
|
|
80
|
+
const password = prompt('Confirm your password to revoke this API key:')
|
|
81
|
+
if (!password) return
|
|
82
|
+
const res = await cmsApi(`/api-keys/${id}`, {
|
|
83
|
+
method: 'DELETE',
|
|
84
|
+
headers: { 'x-reauth-password': password },
|
|
85
|
+
})
|
|
86
|
+
if (res.error) {
|
|
87
|
+
toast.error(res.error)
|
|
88
|
+
} else {
|
|
89
|
+
toast.success('API key revoked')
|
|
90
|
+
refetch()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (loading) {
|
|
95
|
+
return (
|
|
96
|
+
<div className="p-4 sm:p-6 flex items-center justify-center h-64">
|
|
97
|
+
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div className="p-4 sm:p-6">
|
|
104
|
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6">
|
|
105
|
+
<div>
|
|
106
|
+
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 flex items-center gap-2">
|
|
107
|
+
<KeyRound className="w-5 h-5 text-gray-500" />
|
|
108
|
+
API Keys
|
|
109
|
+
</h1>
|
|
110
|
+
<p className="text-sm text-gray-500 mt-1">
|
|
111
|
+
Long-lived credentials for programmatic access. Use the{' '}
|
|
112
|
+
<code className="px-1 py-0.5 rounded bg-gray-100 text-xs">Authorization: Bearer</code>{' '}
|
|
113
|
+
header instead of session cookies. API key requests skip CSRF.
|
|
114
|
+
</p>
|
|
115
|
+
</div>
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={() => setShowCreate(true)}
|
|
119
|
+
className="inline-flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
|
|
120
|
+
>
|
|
121
|
+
<Plus className="w-4 h-4" />
|
|
122
|
+
New API Key
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{error && (
|
|
127
|
+
<div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
|
|
128
|
+
<AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
|
|
129
|
+
<span className="text-sm text-red-800 flex-1">{error}</span>
|
|
130
|
+
<button
|
|
131
|
+
onClick={refetch}
|
|
132
|
+
className="px-3 py-1 text-sm text-red-700 border border-red-300 rounded-lg hover:bg-red-100 transition-colors"
|
|
133
|
+
>
|
|
134
|
+
Retry
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{keys.length === 0 ? (
|
|
140
|
+
<div className="bg-white rounded-lg border border-gray-200 p-10 text-center">
|
|
141
|
+
<KeyRound className="w-8 h-8 text-gray-300 mx-auto mb-3" />
|
|
142
|
+
<p className="text-sm text-gray-500 mb-1">No API keys yet</p>
|
|
143
|
+
<p className="text-xs text-gray-400 mb-4">
|
|
144
|
+
Create one to let AI agents, CI jobs, or external integrations call the CMS API.
|
|
145
|
+
</p>
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={() => setShowCreate(true)}
|
|
149
|
+
className="inline-flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
|
|
150
|
+
>
|
|
151
|
+
<Plus className="w-4 h-4" />
|
|
152
|
+
Create your first API key
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
) : (
|
|
156
|
+
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
157
|
+
<div className="overflow-x-auto">
|
|
158
|
+
<table className="w-full">
|
|
159
|
+
<thead className="bg-gray-50 border-b border-gray-200">
|
|
160
|
+
<tr>
|
|
161
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Name</th>
|
|
162
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Token</th>
|
|
163
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Scopes</th>
|
|
164
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
|
|
165
|
+
Last used
|
|
166
|
+
</th>
|
|
167
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Expires</th>
|
|
168
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Status</th>
|
|
169
|
+
<th className="px-4 py-2"></th>
|
|
170
|
+
</tr>
|
|
171
|
+
</thead>
|
|
172
|
+
<tbody className="divide-y divide-gray-200">
|
|
173
|
+
{keys.map((k) => {
|
|
174
|
+
const revoked = !!k.revokedAt
|
|
175
|
+
const expired = k.expiresAt && new Date(k.expiresAt).getTime() < Date.now()
|
|
176
|
+
return (
|
|
177
|
+
<tr key={k.id} className={revoked || expired ? 'opacity-60' : undefined}>
|
|
178
|
+
<td className="px-4 py-3 text-sm">
|
|
179
|
+
<div className="font-medium text-gray-900">{k.name}</div>
|
|
180
|
+
{k.user && (
|
|
181
|
+
<div className="text-xs text-gray-500">
|
|
182
|
+
created by {k.user.name ?? k.user.email}
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</td>
|
|
186
|
+
<td className="px-4 py-3 text-sm font-mono text-gray-700">{k.keyPrefix}…</td>
|
|
187
|
+
<td className="px-4 py-3 text-sm text-gray-700 max-w-xs">
|
|
188
|
+
{formatScopes(k.scopes)}
|
|
189
|
+
</td>
|
|
190
|
+
<td className="px-4 py-3 text-sm text-gray-600">
|
|
191
|
+
{formatDate(k.lastUsedAt)}
|
|
192
|
+
</td>
|
|
193
|
+
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(k.expiresAt)}</td>
|
|
194
|
+
<td className="px-4 py-3">
|
|
195
|
+
{revoked ? (
|
|
196
|
+
<span className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">
|
|
197
|
+
Revoked
|
|
198
|
+
</span>
|
|
199
|
+
) : expired ? (
|
|
200
|
+
<span className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
|
|
201
|
+
Expired
|
|
202
|
+
</span>
|
|
203
|
+
) : (
|
|
204
|
+
<span className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
|
|
205
|
+
Active
|
|
206
|
+
</span>
|
|
207
|
+
)}
|
|
208
|
+
</td>
|
|
209
|
+
<td className="px-4 py-3 text-right">
|
|
210
|
+
{!revoked && (
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
onClick={() => handleRevoke(k.id, k.name)}
|
|
214
|
+
className="p-1.5 hover:bg-red-50 rounded transition-colors"
|
|
215
|
+
title="Revoke"
|
|
216
|
+
>
|
|
217
|
+
<Trash2 className="w-4 h-4 text-red-600" />
|
|
218
|
+
</button>
|
|
219
|
+
)}
|
|
220
|
+
</td>
|
|
221
|
+
</tr>
|
|
222
|
+
)
|
|
223
|
+
})}
|
|
224
|
+
</tbody>
|
|
225
|
+
</table>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
<CreateApiKeyDialog
|
|
231
|
+
open={showCreate}
|
|
232
|
+
onClose={() => setShowCreate(false)}
|
|
233
|
+
onCreated={(created) => {
|
|
234
|
+
setShowCreate(false)
|
|
235
|
+
setCreatedKey(created)
|
|
236
|
+
refetch()
|
|
237
|
+
}}
|
|
238
|
+
/>
|
|
239
|
+
|
|
240
|
+
{createdKey && <RevealKeyDialog created={createdKey} onClose={() => setCreatedKey(null)} />}
|
|
241
|
+
</div>
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
interface CreateApiKeyDialogProps {
|
|
246
|
+
open: boolean
|
|
247
|
+
onClose: () => void
|
|
248
|
+
onCreated: (created: { key: string; record: ApiKeyRecord }) => void
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function CreateApiKeyDialog({ open, onClose, onCreated }: CreateApiKeyDialogProps) {
|
|
252
|
+
const [name, setName] = useState('')
|
|
253
|
+
const [preset, setPreset] = useState<'admin' | 'content' | 'readonly' | 'custom'>('content')
|
|
254
|
+
const [pageBuilder, setPageBuilder] = useState(true)
|
|
255
|
+
const [media, setMedia] = useState(true)
|
|
256
|
+
const [expiresInDays, setExpiresInDays] = useState<string>('')
|
|
257
|
+
const [password, setPassword] = useState('')
|
|
258
|
+
const [submitting, setSubmitting] = useState(false)
|
|
259
|
+
|
|
260
|
+
function buildScopes(): ApiKeyScopes {
|
|
261
|
+
if (preset === 'admin') return { admin: true }
|
|
262
|
+
if (preset === 'readonly') {
|
|
263
|
+
return {
|
|
264
|
+
collections: ['*'],
|
|
265
|
+
actions: ['read'],
|
|
266
|
+
globals: ['*'],
|
|
267
|
+
media: false,
|
|
268
|
+
pageBuilder: false,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (preset === 'content') {
|
|
272
|
+
return {
|
|
273
|
+
collections: ['*'],
|
|
274
|
+
actions: ['read', 'create', 'update'],
|
|
275
|
+
globals: ['*'],
|
|
276
|
+
media,
|
|
277
|
+
pageBuilder,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
collections: ['*'],
|
|
282
|
+
actions: ['read'],
|
|
283
|
+
media,
|
|
284
|
+
pageBuilder,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function handleSubmit(e: FormEvent) {
|
|
289
|
+
e.preventDefault()
|
|
290
|
+
if (!name.trim()) return toast.error('Name is required')
|
|
291
|
+
if (!password) return toast.error('Password confirmation is required')
|
|
292
|
+
|
|
293
|
+
setSubmitting(true)
|
|
294
|
+
try {
|
|
295
|
+
const scopes = buildScopes()
|
|
296
|
+
const body: Record<string, unknown> = { name: name.trim(), scopes }
|
|
297
|
+
const days = parseInt(expiresInDays, 10)
|
|
298
|
+
if (!isNaN(days) && days > 0) {
|
|
299
|
+
body.expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString()
|
|
300
|
+
}
|
|
301
|
+
const res = await cmsApi<{ key: string } & ApiKeyRecord>('/api-keys', {
|
|
302
|
+
method: 'POST',
|
|
303
|
+
headers: { 'x-reauth-password': password, 'Content-Type': 'application/json' },
|
|
304
|
+
body: JSON.stringify(body),
|
|
305
|
+
})
|
|
306
|
+
if (res.error || !res.data) {
|
|
307
|
+
toast.error(res.error ?? 'Failed to create API key')
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
const { key, ...record } = res.data
|
|
311
|
+
onCreated({ key, record: record as ApiKeyRecord })
|
|
312
|
+
setName('')
|
|
313
|
+
setPassword('')
|
|
314
|
+
setExpiresInDays('')
|
|
315
|
+
} finally {
|
|
316
|
+
setSubmitting(false)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<Dialog.Root open={open} onOpenChange={(o) => !o && onClose()}>
|
|
322
|
+
<Dialog.Portal>
|
|
323
|
+
<Dialog.Overlay className="fixed inset-0 bg-black/40 z-40" />
|
|
324
|
+
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg shadow-xl w-full max-w-md p-6 z-50">
|
|
325
|
+
<Dialog.Title className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
|
326
|
+
<Shield className="w-5 h-5 text-blue-600" />
|
|
327
|
+
New API Key
|
|
328
|
+
</Dialog.Title>
|
|
329
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
330
|
+
<div>
|
|
331
|
+
<label className="text-sm font-medium text-gray-700 block mb-1">Name</label>
|
|
332
|
+
<input
|
|
333
|
+
type="text"
|
|
334
|
+
value={name}
|
|
335
|
+
onChange={(e) => setName(e.target.value)}
|
|
336
|
+
placeholder="e.g. AI agent — production"
|
|
337
|
+
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
338
|
+
required
|
|
339
|
+
maxLength={100}
|
|
340
|
+
/>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
<div>
|
|
344
|
+
<label className="text-sm font-medium text-gray-700 block mb-2">Preset</label>
|
|
345
|
+
<div className="grid grid-cols-2 gap-2">
|
|
346
|
+
{(['content', 'readonly', 'admin', 'custom'] as const).map((p) => (
|
|
347
|
+
<button
|
|
348
|
+
key={p}
|
|
349
|
+
type="button"
|
|
350
|
+
onClick={() => setPreset(p)}
|
|
351
|
+
className={`px-3 py-2 text-sm rounded-lg border transition-colors text-left ${
|
|
352
|
+
preset === p
|
|
353
|
+
? 'border-blue-500 bg-blue-50 text-blue-900'
|
|
354
|
+
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
|
|
355
|
+
}`}
|
|
356
|
+
>
|
|
357
|
+
<div className="font-medium capitalize">{p}</div>
|
|
358
|
+
<div className="text-xs text-gray-500">
|
|
359
|
+
{p === 'content' && 'Read/create/update content'}
|
|
360
|
+
{p === 'readonly' && 'Read-only access'}
|
|
361
|
+
{p === 'admin' && 'Full admin access'}
|
|
362
|
+
{p === 'custom' && 'Pick capabilities'}
|
|
363
|
+
</div>
|
|
364
|
+
</button>
|
|
365
|
+
))}
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
{(preset === 'content' || preset === 'custom') && (
|
|
370
|
+
<div className="space-y-2 rounded-lg border border-gray-200 p-3 bg-gray-50">
|
|
371
|
+
<label className="flex items-center gap-2 text-sm text-gray-700">
|
|
372
|
+
<input
|
|
373
|
+
type="checkbox"
|
|
374
|
+
checked={media}
|
|
375
|
+
onChange={(e) => setMedia(e.target.checked)}
|
|
376
|
+
className="rounded border-gray-300"
|
|
377
|
+
/>
|
|
378
|
+
Allow media uploads
|
|
379
|
+
</label>
|
|
380
|
+
<label className="flex items-center gap-2 text-sm text-gray-700">
|
|
381
|
+
<input
|
|
382
|
+
type="checkbox"
|
|
383
|
+
checked={pageBuilder}
|
|
384
|
+
onChange={(e) => setPageBuilder(e.target.checked)}
|
|
385
|
+
className="rounded border-gray-300"
|
|
386
|
+
/>
|
|
387
|
+
Allow AI page generation
|
|
388
|
+
</label>
|
|
389
|
+
</div>
|
|
390
|
+
)}
|
|
391
|
+
|
|
392
|
+
<div>
|
|
393
|
+
<label className="text-sm font-medium text-gray-700 block mb-1">
|
|
394
|
+
Expires in (days)
|
|
395
|
+
</label>
|
|
396
|
+
<input
|
|
397
|
+
type="number"
|
|
398
|
+
min={1}
|
|
399
|
+
placeholder="Never (leave blank)"
|
|
400
|
+
value={expiresInDays}
|
|
401
|
+
onChange={(e) => setExpiresInDays(e.target.value)}
|
|
402
|
+
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
403
|
+
/>
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
<div>
|
|
407
|
+
<label className="text-sm font-medium text-gray-700 block mb-1">
|
|
408
|
+
Confirm password
|
|
409
|
+
</label>
|
|
410
|
+
<input
|
|
411
|
+
type="password"
|
|
412
|
+
value={password}
|
|
413
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
414
|
+
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
415
|
+
required
|
|
416
|
+
/>
|
|
417
|
+
<p className="text-xs text-gray-500 mt-1">
|
|
418
|
+
Creating an API key is a sensitive action and requires re-authentication.
|
|
419
|
+
</p>
|
|
420
|
+
</div>
|
|
421
|
+
|
|
422
|
+
<div className="flex items-center justify-end gap-2 pt-2">
|
|
423
|
+
<button
|
|
424
|
+
type="button"
|
|
425
|
+
onClick={onClose}
|
|
426
|
+
className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
427
|
+
>
|
|
428
|
+
Cancel
|
|
429
|
+
</button>
|
|
430
|
+
<button
|
|
431
|
+
type="submit"
|
|
432
|
+
disabled={submitting}
|
|
433
|
+
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
|
434
|
+
>
|
|
435
|
+
{submitting ? 'Creating…' : 'Create key'}
|
|
436
|
+
</button>
|
|
437
|
+
</div>
|
|
438
|
+
</form>
|
|
439
|
+
</Dialog.Content>
|
|
440
|
+
</Dialog.Portal>
|
|
441
|
+
</Dialog.Root>
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function RevealKeyDialog({
|
|
446
|
+
created,
|
|
447
|
+
onClose,
|
|
448
|
+
}: {
|
|
449
|
+
created: { key: string; record: ApiKeyRecord }
|
|
450
|
+
onClose: () => void
|
|
451
|
+
}) {
|
|
452
|
+
const [shown, setShown] = useState(false)
|
|
453
|
+
return (
|
|
454
|
+
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
|
|
455
|
+
<Dialog.Portal>
|
|
456
|
+
<Dialog.Overlay className="fixed inset-0 bg-black/40 z-40" />
|
|
457
|
+
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg shadow-xl w-full max-w-md p-6 z-50">
|
|
458
|
+
<Dialog.Title className="text-lg font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
|
459
|
+
<Shield className="w-5 h-5 text-green-600" />
|
|
460
|
+
API key created
|
|
461
|
+
</Dialog.Title>
|
|
462
|
+
<Dialog.Description className="text-sm text-gray-600 mb-4">
|
|
463
|
+
Copy this key now — it will not be shown again. Treat it like a password.
|
|
464
|
+
</Dialog.Description>
|
|
465
|
+
|
|
466
|
+
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 mb-4 flex items-start gap-2">
|
|
467
|
+
<AlertTriangle className="w-4 h-4 text-amber-600 shrink-0 mt-0.5" />
|
|
468
|
+
<p className="text-xs text-amber-900">
|
|
469
|
+
We only store a hash of this key. If you lose it, you'll need to revoke and
|
|
470
|
+
create a new one.
|
|
471
|
+
</p>
|
|
472
|
+
</div>
|
|
473
|
+
|
|
474
|
+
<div className="flex items-center gap-2 mb-4">
|
|
475
|
+
<code className="flex-1 px-3 py-2 bg-gray-100 rounded text-sm font-mono break-all">
|
|
476
|
+
{shown ? created.key : '•'.repeat(Math.min(48, created.key.length))}
|
|
477
|
+
</code>
|
|
478
|
+
<button
|
|
479
|
+
type="button"
|
|
480
|
+
onClick={() => setShown((s) => !s)}
|
|
481
|
+
className="p-2 border border-gray-300 rounded hover:bg-gray-50"
|
|
482
|
+
title={shown ? 'Hide' : 'Show'}
|
|
483
|
+
>
|
|
484
|
+
{shown ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
485
|
+
</button>
|
|
486
|
+
<button
|
|
487
|
+
type="button"
|
|
488
|
+
onClick={() => {
|
|
489
|
+
navigator.clipboard.writeText(created.key)
|
|
490
|
+
toast.success('Copied to clipboard')
|
|
491
|
+
}}
|
|
492
|
+
className="p-2 border border-gray-300 rounded hover:bg-gray-50"
|
|
493
|
+
title="Copy"
|
|
494
|
+
>
|
|
495
|
+
<Copy className="w-4 h-4" />
|
|
496
|
+
</button>
|
|
497
|
+
</div>
|
|
498
|
+
|
|
499
|
+
<div className="flex justify-end">
|
|
500
|
+
<button
|
|
501
|
+
type="button"
|
|
502
|
+
onClick={onClose}
|
|
503
|
+
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
|
504
|
+
>
|
|
505
|
+
I've saved it
|
|
506
|
+
</button>
|
|
507
|
+
</div>
|
|
508
|
+
</Dialog.Content>
|
|
509
|
+
</Dialog.Portal>
|
|
510
|
+
</Dialog.Root>
|
|
511
|
+
)
|
|
512
|
+
}
|