@actuate-media/cms-admin 0.8.2 → 0.10.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/components/SchedulePublishDialog.d.ts +18 -0
- package/dist/components/SchedulePublishDialog.d.ts.map +1 -0
- package/dist/components/SchedulePublishDialog.js +106 -0
- package/dist/components/SchedulePublishDialog.js.map +1 -0
- package/dist/components/SharePreviewLinkDialog.d.ts +17 -0
- package/dist/components/SharePreviewLinkDialog.d.ts.map +1 -0
- package/dist/components/SharePreviewLinkDialog.js +83 -0
- package/dist/components/SharePreviewLinkDialog.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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 +154 -0
- package/dist/views/ApiKeys.js.map +1 -0
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +15 -3
- package/dist/views/DocumentEdit.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +5 -0
- package/src/components/SchedulePublishDialog.tsx +241 -0
- package/src/components/SharePreviewLinkDialog.tsx +227 -0
- package/src/index.ts +5 -0
- package/src/layout/Sidebar.tsx +3 -0
- package/src/views/ApiKeys.tsx +512 -0
- package/src/views/DocumentEdit.tsx +81 -1
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
AlertTriangle,
|
|
6
|
+
Calendar,
|
|
7
|
+
Eye,
|
|
8
|
+
EyeOff,
|
|
9
|
+
Save,
|
|
10
|
+
Copy,
|
|
11
|
+
Loader2,
|
|
12
|
+
Clock,
|
|
13
|
+
Share2,
|
|
14
|
+
XCircle,
|
|
15
|
+
} from 'lucide-react'
|
|
5
16
|
import { toast } from 'sonner'
|
|
6
17
|
import { FieldRenderer } from '../fields/FieldRenderer.js'
|
|
7
18
|
import { RelationshipField } from '../fields/RelationshipField.js'
|
|
@@ -12,6 +23,8 @@ import { VersionHistory } from '../components/VersionHistory.js'
|
|
|
12
23
|
import { PresenceIndicator } from '../components/PresenceIndicator.js'
|
|
13
24
|
import { SEOPanel } from '../components/SEOPanel.js'
|
|
14
25
|
import type { SEOData } from '../components/SEOPanel.js'
|
|
26
|
+
import { SchedulePublishDialog } from '../components/SchedulePublishDialog.js'
|
|
27
|
+
import { SharePreviewLinkDialog } from '../components/SharePreviewLinkDialog.js'
|
|
15
28
|
import { cmsApi } from '../lib/api.js'
|
|
16
29
|
|
|
17
30
|
export interface DocumentEditProps {
|
|
@@ -40,7 +53,11 @@ export function DocumentEdit({
|
|
|
40
53
|
const [loading, setLoading] = useState(!isNew)
|
|
41
54
|
const [showPreview, setShowPreview] = useState(false)
|
|
42
55
|
const [showVersions, setShowVersions] = useState(false)
|
|
56
|
+
const [showSchedule, setShowSchedule] = useState(false)
|
|
57
|
+
const [showShareLink, setShowShareLink] = useState(false)
|
|
43
58
|
const [docStatus, setDocStatus] = useState<string>('DRAFT')
|
|
59
|
+
const [scheduledAt, setScheduledAt] = useState<string | null>(null)
|
|
60
|
+
const [scheduledUnpublishAt, setScheduledUnpublishAt] = useState<string | null>(null)
|
|
44
61
|
const hasLoadedRef = useRef(false)
|
|
45
62
|
|
|
46
63
|
const collections = config?.collections
|
|
@@ -125,6 +142,8 @@ export function DocumentEdit({
|
|
|
125
142
|
setValues(merged)
|
|
126
143
|
setInitialValues(merged)
|
|
127
144
|
setDocStatus(doc.status ?? 'DRAFT')
|
|
145
|
+
setScheduledAt(doc.scheduledAt ?? null)
|
|
146
|
+
setScheduledUnpublishAt(doc.scheduledUnpublishAt ?? null)
|
|
128
147
|
|
|
129
148
|
const loadedSeo: SEOData = {}
|
|
130
149
|
for (const key of SEO_FIELDS) {
|
|
@@ -309,6 +328,14 @@ export function DocumentEdit({
|
|
|
309
328
|
</button>
|
|
310
329
|
{!isNew && (
|
|
311
330
|
<>
|
|
331
|
+
<button
|
|
332
|
+
onClick={() => setShowShareLink(true)}
|
|
333
|
+
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
|
|
334
|
+
title="Share a signed preview link for the current draft"
|
|
335
|
+
>
|
|
336
|
+
<Share2 className="h-4 w-4" />
|
|
337
|
+
Share preview
|
|
338
|
+
</button>
|
|
312
339
|
<button
|
|
313
340
|
onClick={() => setShowVersions(true)}
|
|
314
341
|
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
|
|
@@ -379,6 +406,31 @@ export function DocumentEdit({
|
|
|
379
406
|
Unpublish
|
|
380
407
|
</Button>
|
|
381
408
|
)}
|
|
409
|
+
{!isNew && (
|
|
410
|
+
<button
|
|
411
|
+
onClick={() => setShowSchedule(true)}
|
|
412
|
+
className="inline-flex w-full items-center justify-center gap-1.5 rounded-md border border-[var(--border)] bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
|
413
|
+
>
|
|
414
|
+
<Calendar className="h-4 w-4" />
|
|
415
|
+
{scheduledAt || scheduledUnpublishAt ? 'Reschedule' : 'Schedule'}
|
|
416
|
+
</button>
|
|
417
|
+
)}
|
|
418
|
+
{!isNew && (scheduledAt || scheduledUnpublishAt) && (
|
|
419
|
+
<div className="space-y-1 rounded-md border border-blue-100 bg-blue-50 px-3 py-2 text-xs text-blue-900">
|
|
420
|
+
{scheduledAt && (
|
|
421
|
+
<div className="flex items-center gap-1.5">
|
|
422
|
+
<Calendar className="h-3.5 w-3.5" />
|
|
423
|
+
Publishes {new Date(scheduledAt).toLocaleString()}
|
|
424
|
+
</div>
|
|
425
|
+
)}
|
|
426
|
+
{scheduledUnpublishAt && (
|
|
427
|
+
<div className="flex items-center gap-1.5">
|
|
428
|
+
<XCircle className="h-3.5 w-3.5" />
|
|
429
|
+
Unpublishes {new Date(scheduledUnpublishAt).toLocaleString()}
|
|
430
|
+
</div>
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
)}
|
|
382
434
|
</div>
|
|
383
435
|
</div>
|
|
384
436
|
|
|
@@ -495,6 +547,34 @@ export function DocumentEdit({
|
|
|
495
547
|
}}
|
|
496
548
|
/>
|
|
497
549
|
)}
|
|
550
|
+
|
|
551
|
+
{!isNew && documentId && (
|
|
552
|
+
<SchedulePublishDialog
|
|
553
|
+
collectionSlug={collectionSlug}
|
|
554
|
+
documentId={documentId}
|
|
555
|
+
scheduledAt={scheduledAt}
|
|
556
|
+
scheduledUnpublishAt={scheduledUnpublishAt}
|
|
557
|
+
open={showSchedule}
|
|
558
|
+
onClose={() => setShowSchedule(false)}
|
|
559
|
+
onScheduled={(next) => {
|
|
560
|
+
setDocStatus(next.status)
|
|
561
|
+
setScheduledAt(next.scheduledAt)
|
|
562
|
+
setScheduledUnpublishAt(next.scheduledUnpublishAt)
|
|
563
|
+
}}
|
|
564
|
+
/>
|
|
565
|
+
)}
|
|
566
|
+
|
|
567
|
+
{!isNew && documentId && (
|
|
568
|
+
<SharePreviewLinkDialog
|
|
569
|
+
collectionSlug={collectionSlug}
|
|
570
|
+
documentId={documentId}
|
|
571
|
+
documentSlug={docSlug}
|
|
572
|
+
siteUrl={config?.siteUrl}
|
|
573
|
+
urlPrefix={collection?.urlPrefix ?? collectionSlug}
|
|
574
|
+
open={showShareLink}
|
|
575
|
+
onClose={() => setShowShareLink(false)}
|
|
576
|
+
/>
|
|
577
|
+
)}
|
|
498
578
|
</div>
|
|
499
579
|
)
|
|
500
580
|
}
|