@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.
@@ -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&apos;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&apos;ve saved it
506
+ </button>
507
+ </div>
508
+ </Dialog.Content>
509
+ </Dialog.Portal>
510
+ </Dialog.Root>
511
+ )
512
+ }