@prenta/admin 1.17.14 → 1.17.16

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +4 -25
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/lib/pages-service-fetch.test.js +3 -0
  6. package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -1
  7. package/dist/__tests__/views/admin-lazy-route.render.test.d.ts +2 -0
  8. package/dist/__tests__/views/admin-lazy-route.render.test.d.ts.map +1 -0
  9. package/dist/__tests__/views/admin-lazy-route.render.test.js +30 -0
  10. package/dist/__tests__/views/admin-lazy-route.render.test.js.map +1 -0
  11. package/dist/__tests__/views/admin-lazy-views.test.d.ts +2 -0
  12. package/dist/__tests__/views/admin-lazy-views.test.d.ts.map +1 -0
  13. package/dist/__tests__/views/admin-lazy-views.test.js +67 -0
  14. package/dist/__tests__/views/admin-lazy-views.test.js.map +1 -0
  15. package/dist/__tests__/views/audit-log-export.test.d.ts +2 -0
  16. package/dist/__tests__/views/audit-log-export.test.d.ts.map +1 -0
  17. package/dist/__tests__/views/audit-log-export.test.js +11 -0
  18. package/dist/__tests__/views/audit-log-export.test.js.map +1 -0
  19. package/dist/__tests__/views/trash.render.test.js +35 -0
  20. package/dist/__tests__/views/trash.render.test.js.map +1 -1
  21. package/dist/lib/pages-service.d.ts.map +1 -1
  22. package/dist/lib/pages-service.js +22 -2
  23. package/dist/lib/pages-service.js.map +1 -1
  24. package/dist/router/admin-routes.d.ts +2 -2
  25. package/dist/router/admin-routes.js +3 -3
  26. package/dist/router/admin-routes.js.map +1 -1
  27. package/dist/views/CollectionList.d.ts.map +1 -1
  28. package/dist/views/CollectionList.js +23 -2
  29. package/dist/views/CollectionList.js.map +1 -1
  30. package/dist/views/SEO.d.ts +1 -44
  31. package/dist/views/SEO.d.ts.map +1 -1
  32. package/dist/views/SEO.js +1 -46
  33. package/dist/views/SEO.js.map +1 -1
  34. package/dist/views/Trash.d.ts.map +1 -1
  35. package/dist/views/Trash.js +21 -2
  36. package/dist/views/Trash.js.map +1 -1
  37. package/dist/views/lazy-views.d.ts +30 -0
  38. package/dist/views/lazy-views.d.ts.map +1 -0
  39. package/dist/views/lazy-views.js +37 -0
  40. package/dist/views/lazy-views.js.map +1 -0
  41. package/dist/views/seo/seo-routes.d.ts +16 -0
  42. package/dist/views/seo/seo-routes.d.ts.map +1 -0
  43. package/dist/views/seo/seo-routes.js +53 -0
  44. package/dist/views/seo/seo-routes.js.map +1 -0
  45. package/dist/views/settings/AuditLogExportCard.d.ts +9 -0
  46. package/dist/views/settings/AuditLogExportCard.d.ts.map +1 -0
  47. package/dist/views/settings/AuditLogExportCard.js +66 -0
  48. package/dist/views/settings/AuditLogExportCard.js.map +1 -0
  49. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  50. package/dist/views/settings/SecurityTab.js +2 -1
  51. package/dist/views/settings/SecurityTab.js.map +1 -1
  52. package/package.json +3 -3
  53. package/src/AdminRoot.tsx +31 -25
  54. package/src/__tests__/lib/pages-service-fetch.test.ts +5 -0
  55. package/src/__tests__/views/admin-lazy-route.render.test.tsx +39 -0
  56. package/src/__tests__/views/admin-lazy-views.test.ts +70 -0
  57. package/src/__tests__/views/audit-log-export.test.ts +17 -0
  58. package/src/__tests__/views/trash.render.test.tsx +40 -0
  59. package/src/lib/pages-service.ts +26 -2
  60. package/src/router/admin-routes.ts +3 -3
  61. package/src/views/CollectionList.tsx +21 -2
  62. package/src/views/SEO.tsx +1 -48
  63. package/src/views/Trash.tsx +68 -16
  64. package/src/views/lazy-views.tsx +79 -0
  65. package/src/views/seo/seo-routes.ts +58 -0
  66. package/src/views/settings/AuditLogExportCard.tsx +131 -0
  67. package/src/views/settings/SecurityTab.tsx +3 -1
@@ -12,13 +12,13 @@
12
12
  * the `admin-routes` drift test keeps `ADMIN_STATIC_ROUTE_PATTERNS` in sync with
13
13
  * the literal `matchRoute('…')` calls in AdminRoot.
14
14
  */
15
- import { SEO_DEEP_PATHS } from '../views/SEO.js'
15
+ import { SEO_DEEP_PATHS } from '../views/seo/seo-routes.js'
16
16
 
17
17
  /**
18
18
  * Every static route pattern `AdminRoot.renderView()` resolves via a literal
19
19
  * `matchRoute('…')` call. Dynamic collection routes are derived per-config in
20
- * {@link collectionRoutePatterns}; the SEO deep paths are added from the view's
21
- * own {@link SEO_DEEP_PATHS} so the two can't drift.
20
+ * {@link collectionRoutePatterns}; the SEO deep paths are added from
21
+ * {@link SEO_DEEP_PATHS} so the two can't drift.
22
22
  */
23
23
  export const ADMIN_STATIC_ROUTE_PATTERNS: readonly string[] = [
24
24
  '/',
@@ -102,6 +102,25 @@ function facetCellValue(doc: any, facet: FacetDef): string {
102
102
  return ''
103
103
  }
104
104
 
105
+ const SKIPPED_LIST_FIELDS = new Set(['richText', 'blocks', 'json', 'media', 'relationship'])
106
+
107
+ /** Scalar field names the table and inspector show. Content blobs are not requested. */
108
+ function listPickParam(config: any, slug: string, facets: FacetDef[]): string {
109
+ const names = new Set<string>(['title', 'slug'])
110
+ const fields = resolveCollection(config, slug)?.fields
111
+ if (fields && typeof fields === 'object') {
112
+ for (const [name, field] of Object.entries(fields) as Array<[string, any]>) {
113
+ if (!field || field.hidden) continue
114
+ if (typeof field.type === 'string' && SKIPPED_LIST_FIELDS.has(field.type)) continue
115
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) names.add(name)
116
+ }
117
+ }
118
+ for (const facet of facets) {
119
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(facet.name)) names.add(facet.name)
120
+ }
121
+ return [...names].slice(0, 32).join(',')
122
+ }
123
+
105
124
  function statusColor(status: string): string {
106
125
  switch (status?.toUpperCase()) {
107
126
  case 'PUBLISHED':
@@ -183,14 +202,14 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
183
202
  }, [search])
184
203
 
185
204
  const endpoint = useMemo(() => {
186
- let url = `/collections/${collectionSlug}?page=${page}&pageSize=25`
205
+ let url = `/collections/${collectionSlug}?page=${page}&pageSize=25&pick=${listPickParam(config, collectionSlug, facets)}`
187
206
  if (debouncedSearch) url += `&search=${encodeURIComponent(debouncedSearch)}`
188
207
  if (sort) url += `&sort=${sort}&order=${order}`
189
208
  for (const [field, value] of Object.entries(filters)) {
190
209
  if (value) url += `&filter.${encodeURIComponent(field)}=${encodeURIComponent(value)}`
191
210
  }
192
211
  return url
193
- }, [collectionSlug, page, debouncedSearch, sort, order, filters])
212
+ }, [collectionSlug, page, debouncedSearch, sort, order, filters, config, facets])
194
213
 
195
214
  const { data, loading, error, refetch } = useApiData<{ docs: any[]; total: number }>(endpoint)
196
215
  const docs = data?.docs ?? []
package/src/views/SEO.tsx CHANGED
@@ -37,6 +37,7 @@ import { AuditTab } from './seo/AuditTab.js'
37
37
  import { LinksTab } from './seo/LinksTab.js'
38
38
  import { ProposalsTab } from './seo/ProposalsTab.js'
39
39
  import { AiVisibilityTab } from './seo/AiVisibilityTab.js'
40
+ export { SEO_DEEP_PATHS, seoTabForPath } from './seo/seo-routes.js'
40
41
 
41
42
  export interface SEOProps {
42
43
  onNavigate?: (path: string) => void
@@ -61,54 +62,6 @@ function normalizeTab(tab: string | undefined): TabId {
61
62
  return (valid.includes(tab ?? '') ? tab : 'overview') as TabId
62
63
  }
63
64
 
64
- /**
65
- * SEO deep paths the admin router resolves onto the tabbed SEO view — including
66
- * every path plugin-seo advertises in the sidebar. `matchRoute` is exact, so any
67
- * of these NOT listed here falls through to the generic 404 (which is exactly
68
- * how `/seo/link-health` and `/seo/sitemap` used to break).
69
- */
70
- export const SEO_DEEP_PATHS = [
71
- '/seo/link-health',
72
- '/seo/sitemap',
73
- '/seo/redirects',
74
- '/seo/content',
75
- '/seo/canonicals',
76
- '/seo/technical',
77
- '/seo/links',
78
- '/seo/audit',
79
- '/seo/proposals',
80
- '/seo/ai-visibility',
81
- '/seo',
82
- ] as const
83
-
84
- /** Map an SEO deep path to the tab it should open, or null if it isn't one. */
85
- export function seoTabForPath(pathname: string): TabId | null {
86
- switch (pathname) {
87
- case '/seo/link-health':
88
- return 'links'
89
- case '/seo/sitemap':
90
- return 'technical'
91
- case '/seo/redirects':
92
- return 'redirects'
93
- case '/seo/content':
94
- case '/seo/canonicals':
95
- return 'content'
96
- case '/seo/technical':
97
- case '/seo/links':
98
- return 'technical'
99
- case '/seo/audit':
100
- return 'audit'
101
- case '/seo/proposals':
102
- return 'proposals'
103
- case '/seo/ai-visibility':
104
- return 'ai-visibility'
105
- case '/seo':
106
- return 'overview'
107
- default:
108
- return null
109
- }
110
- }
111
-
112
65
  interface UrlState {
113
66
  tab: string | null
114
67
  entityType: string | null
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
4
4
  import { Loader2, RotateCcw, Trash2 } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
6
  import { cmsApi } from '../lib/api.js'
7
+ import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
7
8
  import { EmptyState } from '../components/ui/EmptyState.js'
8
9
 
9
10
  export interface TrashProps {
@@ -84,6 +85,8 @@ export function Trash({ onNavigate, config }: TrashProps) {
84
85
  const [loading, setLoading] = useState(true)
85
86
  const [error, setError] = useState<string | null>(null)
86
87
  const [restoringId, setRestoringId] = useState<string | null>(null)
88
+ const [purgeTarget, setPurgeTarget] = useState<TrashRow | null>(null)
89
+ const [purgingId, setPurgingId] = useState<string | null>(null)
87
90
 
88
91
  const load = useCallback(async () => {
89
92
  setLoading(true)
@@ -91,7 +94,7 @@ export function Trash({ onNavigate, config }: TrashProps) {
91
94
  const results = await Promise.all(
92
95
  collections.map(async (slug) => {
93
96
  const res = await cmsApi<ListEnvelope>(
94
- `/collections/${encodeURIComponent(slug)}?includeDeleted=true&pageSize=50&sort=updatedAt&order=desc`,
97
+ `/collections/${encodeURIComponent(slug)}?includeDeleted=true&pageSize=50&sort=updatedAt&order=desc&pick=title,slug`,
95
98
  )
96
99
  if (res.error) return [] as TrashRow[]
97
100
  return (res.data?.docs ?? []).map((doc) => asRow(slug, doc))
@@ -135,12 +138,30 @@ export function Trash({ onNavigate, config }: TrashProps) {
135
138
  onNavigate(editorPath(row.collection, row.id))
136
139
  }
137
140
 
141
+ async function handlePurge(row: TrashRow) {
142
+ setPurgingId(row.id)
143
+ const res = await cmsApi(
144
+ `/collections/${encodeURIComponent(row.collection)}/${encodeURIComponent(row.id)}/purge`,
145
+ { method: 'POST' },
146
+ )
147
+ setPurgingId(null)
148
+ if (res.error) {
149
+ toast.error(res.error)
150
+ return
151
+ }
152
+ toast.success(`Permanently deleted "${row.title}"`)
153
+ setRows((current) =>
154
+ current.filter((item) => item.id !== row.id || item.collection !== row.collection),
155
+ )
156
+ }
157
+
138
158
  return (
139
159
  <div className="p-4 pr-8">
140
160
  <div className="mb-4">
141
161
  <h1 className="text-foreground mb-1">Trash</h1>
142
162
  <p className="text-muted-foreground text-sm">
143
- Soft-deleted pages and posts stay here for 30 days, then are permanently removed.
163
+ Soft-deleted pages and posts stay here for 30 days. Restore one, or delete it permanently
164
+ now.
144
165
  </p>
145
166
  </div>
146
167
 
@@ -181,20 +202,36 @@ export function Trash({ onNavigate, config }: TrashProps) {
181
202
  {formatWhen(row.deletedAt ?? row.updatedAt)}
182
203
  </td>
183
204
  <td className="px-4 py-2.5 text-right">
184
- <button
185
- type="button"
186
- className="text-foreground hover:bg-accent focus-visible:ring-ring inline-flex min-h-11 items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
187
- onClick={() => void handleRestore(row)}
188
- disabled={restoringId === row.id}
189
- aria-label={`Restore ${row.title}`}
190
- >
191
- {restoringId === row.id ? (
192
- <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
193
- ) : (
194
- <RotateCcw className="h-4 w-4" aria-hidden />
195
- )}
196
- Restore
197
- </button>
205
+ <div className="flex items-center justify-end gap-2">
206
+ <button
207
+ type="button"
208
+ className="text-foreground hover:bg-accent focus-visible:ring-ring inline-flex min-h-11 items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
209
+ onClick={() => void handleRestore(row)}
210
+ disabled={restoringId === row.id || purgingId === row.id}
211
+ aria-label={`Restore ${row.title}`}
212
+ >
213
+ {restoringId === row.id ? (
214
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
215
+ ) : (
216
+ <RotateCcw className="h-4 w-4" aria-hidden />
217
+ )}
218
+ Restore
219
+ </button>
220
+ <button
221
+ type="button"
222
+ className="text-destructive hover:bg-accent focus-visible:ring-ring inline-flex min-h-11 items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
223
+ onClick={() => setPurgeTarget(row)}
224
+ disabled={restoringId === row.id || purgingId === row.id}
225
+ aria-label={`Delete ${row.title} permanently`}
226
+ >
227
+ {purgingId === row.id ? (
228
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
229
+ ) : (
230
+ <Trash2 className="h-4 w-4" aria-hidden />
231
+ )}
232
+ Delete
233
+ </button>
234
+ </div>
198
235
  </td>
199
236
  </tr>
200
237
  ))}
@@ -202,6 +239,21 @@ export function Trash({ onNavigate, config }: TrashProps) {
202
239
  </table>
203
240
  </div>
204
241
  )}
242
+ <ConfirmDialog
243
+ open={purgeTarget !== null}
244
+ onClose={() => setPurgeTarget(null)}
245
+ onConfirm={() => {
246
+ if (purgeTarget) void handlePurge(purgeTarget)
247
+ }}
248
+ title="Delete permanently?"
249
+ description={
250
+ purgeTarget
251
+ ? `"${purgeTarget.title}" will be removed from the trash and cannot be restored.`
252
+ : 'This document will be removed from the trash and cannot be restored.'
253
+ }
254
+ confirmLabel="Delete permanently"
255
+ destructive
256
+ />
205
257
  </div>
206
258
  )
207
259
  }
@@ -0,0 +1,79 @@
1
+ import { lazy } from 'react'
2
+ import { Skeleton } from '../components/ui/Skeleton.js'
3
+
4
+ /**
5
+ * Route-level splits. Each view is a dynamic import so the admin shell
6
+ * (login, layout, dashboard) does not download the editors up front.
7
+ * Named exports are adapted to the default export `React.lazy` expects.
8
+ */
9
+
10
+ export const CollectionList = lazy(() =>
11
+ import('./CollectionList.js').then((m) => ({ default: m.CollectionList })),
12
+ )
13
+ export const DocumentEdit = lazy(() =>
14
+ import('./DocumentEdit.js').then((m) => ({ default: m.DocumentEdit })),
15
+ )
16
+ export const MediaBrowser = lazy(() =>
17
+ import('./MediaBrowser.js').then((m) => ({ default: m.MediaBrowser })),
18
+ )
19
+ export const Settings = lazy(() => import('./Settings.js').then((m) => ({ default: m.Settings })))
20
+ export const Profile = lazy(() => import('./Profile.js').then((m) => ({ default: m.Profile })))
21
+ export const Forms = lazy(() => import('./Forms.js').then((m) => ({ default: m.Forms })))
22
+ export const FormEditor = lazy(() =>
23
+ import('./FormEditor.js').then((m) => ({ default: m.FormEditor })),
24
+ )
25
+ export const FormSubmissions = lazy(() =>
26
+ import('./FormSubmissions.js').then((m) => ({ default: m.FormSubmissions })),
27
+ )
28
+ export const FormEntries = lazy(() =>
29
+ import('./FormEntries.js').then((m) => ({ default: m.FormEntries })),
30
+ )
31
+ export const FormDeliveries = lazy(() =>
32
+ import('./FormDeliveries.js').then((m) => ({ default: m.FormDeliveries })),
33
+ )
34
+ export const SEO = lazy(() => import('./SEO.js').then((m) => ({ default: m.SEO })))
35
+ export const ScriptTags = lazy(() =>
36
+ import('./ScriptTags.js').then((m) => ({ default: m.ScriptTags })),
37
+ )
38
+ export const PageSectionEditor = lazy(() =>
39
+ import('./page-editor/PageSectionEditor.js').then((m) => ({ default: m.PageSectionEditor })),
40
+ )
41
+ export const Trash = lazy(() => import('./Trash.js').then((m) => ({ default: m.Trash })))
42
+ export const PagesListView = lazy(() =>
43
+ import('./Pages/PagesListView.js').then((m) => ({ default: m.PagesListView })),
44
+ )
45
+ export const PageTagManager = lazy(() =>
46
+ import('./Pages/PageTagManager.js').then((m) => ({ default: m.PageTagManager })),
47
+ )
48
+ export const PostsListView = lazy(() =>
49
+ import('./Posts/PostsListView.js').then((m) => ({ default: m.PostsListView })),
50
+ )
51
+ export const PostTypesView = lazy(() =>
52
+ import('./Posts/PostTypesView.js').then((m) => ({ default: m.PostTypesView })),
53
+ )
54
+ export const PostTypeEditor = lazy(() =>
55
+ import('./Posts/PostTypeEditor.js').then((m) => ({ default: m.PostTypeEditor })),
56
+ )
57
+ export const NewPostPicker = lazy(() =>
58
+ import('./Posts/NewPostPicker.js').then((m) => ({ default: m.NewPostPicker })),
59
+ )
60
+ export const NewPostTypeInstructions = lazy(() =>
61
+ import('./Posts/NewPostTypeInstructions.js').then((m) => ({
62
+ default: m.NewPostTypeInstructions,
63
+ })),
64
+ )
65
+ export const PostSectionEditor = lazy(() =>
66
+ import('./post-editor/PostSectionEditor.js').then((m) => ({ default: m.PostSectionEditor })),
67
+ )
68
+ export const PostTemplateEditor = lazy(() =>
69
+ import('./post-editor/PostTemplateEditor.js').then((m) => ({ default: m.PostTemplateEditor })),
70
+ )
71
+
72
+ export function ViewFallback() {
73
+ return (
74
+ <div className="p-6" aria-busy="true" aria-live="polite">
75
+ <p className="text-muted-foreground text-sm">Loading</p>
76
+ <Skeleton className="mt-4" />
77
+ </div>
78
+ )
79
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * SEO deep-link table. Kept out of `SEO.tsx` so the admin shell can route
3
+ * these paths without loading the SEO view.
4
+ */
5
+
6
+ const SEO_TAB_IDS = [
7
+ 'overview',
8
+ 'content',
9
+ 'links',
10
+ 'technical',
11
+ 'ai-visibility',
12
+ 'redirects',
13
+ 'audit',
14
+ 'proposals',
15
+ ] as const
16
+
17
+ export type SeoTabId = (typeof SEO_TAB_IDS)[number]
18
+
19
+ /**
20
+ * SEO deep paths the admin router resolves onto the tabbed SEO view — including
21
+ * every path plugin-seo advertises in the sidebar. `matchRoute` is exact, so any
22
+ * of these NOT listed here falls through to the generic 404.
23
+ */
24
+ export const SEO_DEEP_PATHS = [
25
+ '/seo/link-health',
26
+ '/seo/sitemap',
27
+ '/seo/redirects',
28
+ '/seo/content',
29
+ '/seo/canonicals',
30
+ '/seo/technical',
31
+ '/seo/links',
32
+ '/seo/audit',
33
+ '/seo/proposals',
34
+ '/seo/ai-visibility',
35
+ '/seo',
36
+ ] as const
37
+
38
+ const SEO_TAB_BY_PATH: Record<(typeof SEO_DEEP_PATHS)[number], SeoTabId> = {
39
+ '/seo/link-health': 'links',
40
+ '/seo/sitemap': 'technical',
41
+ '/seo/redirects': 'redirects',
42
+ '/seo/content': 'content',
43
+ '/seo/canonicals': 'content',
44
+ '/seo/technical': 'technical',
45
+ '/seo/links': 'technical',
46
+ '/seo/audit': 'audit',
47
+ '/seo/proposals': 'proposals',
48
+ '/seo/ai-visibility': 'ai-visibility',
49
+ '/seo': 'overview',
50
+ }
51
+
52
+ /** Map an SEO deep path to the tab it should open, or null if it isn't one. */
53
+ export function seoTabForPath(pathname: string): SeoTabId | null {
54
+ if (Object.prototype.hasOwnProperty.call(SEO_TAB_BY_PATH, pathname)) {
55
+ return SEO_TAB_BY_PATH[pathname as keyof typeof SEO_TAB_BY_PATH]
56
+ }
57
+ return null
58
+ }
@@ -0,0 +1,131 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { Download } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import { Button } from '../../components/ui/Button.js'
7
+ import { getApiBase } from '../../lib/api.js'
8
+ import { SecurityCard } from './securityPrimitives.js'
9
+
10
+ export type AuditExportFormat = 'csv' | 'json'
11
+
12
+ export function auditExportPath(options: {
13
+ format: AuditExportFormat
14
+ from: string
15
+ to: string
16
+ }): string {
17
+ const params = new URLSearchParams({ format: options.format })
18
+ if (options.from) params.set('from', options.from)
19
+ if (options.to) params.set('to', options.to)
20
+ return `/security/audit-log/export?${params.toString()}`
21
+ }
22
+
23
+ async function downloadAuditExport(path: string): Promise<{ error?: string; truncated?: boolean }> {
24
+ let res: Response
25
+ try {
26
+ res = await fetch(`${getApiBase()}${path}`, { credentials: 'include' })
27
+ } catch (err) {
28
+ return { error: err instanceof Error ? err.message : 'Network error' }
29
+ }
30
+ if (!res.ok) {
31
+ const body = (await res.json().catch(() => ({}))) as { error?: string }
32
+ return { error: body.error || `Export failed (${res.status})` }
33
+ }
34
+ const blob = await res.blob()
35
+ const disposition = res.headers.get('content-disposition') ?? ''
36
+ const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? 'audit-log.csv'
37
+ const url = URL.createObjectURL(blob)
38
+ const link = document.createElement('a')
39
+ link.href = url
40
+ link.download = filename
41
+ link.click()
42
+ URL.revokeObjectURL(url)
43
+ return { truncated: res.headers.get('x-audit-export-truncated') === 'true' }
44
+ }
45
+
46
+ /** Admin download of the audit log as CSV or JSON, optionally limited to a date range. */
47
+ export function AuditLogExportCard() {
48
+ const [from, setFrom] = useState('')
49
+ const [to, setTo] = useState('')
50
+ const [pending, setPending] = useState<AuditExportFormat | null>(null)
51
+ const [error, setError] = useState<string | null>(null)
52
+
53
+ async function exportAs(format: AuditExportFormat) {
54
+ setPending(format)
55
+ setError(null)
56
+ const result = await downloadAuditExport(auditExportPath({ format, from, to }))
57
+ setPending(null)
58
+ if (result.error) {
59
+ setError(result.error)
60
+ return
61
+ }
62
+ if (result.truncated) {
63
+ toast.success('Export downloaded. It was capped at the newest 10,000 events.')
64
+ return
65
+ }
66
+ toast.success('Audit log exported')
67
+ }
68
+
69
+ return (
70
+ <SecurityCard
71
+ title="Audit log export"
72
+ description="Download recorded security and content events. A date range limits the file. The newest 10,000 matching events are included."
73
+ >
74
+ <form
75
+ className="flex flex-col gap-4"
76
+ onSubmit={(event) => {
77
+ event.preventDefault()
78
+ void exportAs('csv')
79
+ }}
80
+ >
81
+ <div className="flex flex-col gap-4 sm:flex-row">
82
+ <label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm">
83
+ <span className="text-foreground font-medium">From</span>
84
+ <input
85
+ type="date"
86
+ value={from}
87
+ onChange={(event) => setFrom(event.target.value)}
88
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring min-h-11 rounded-md border px-3 text-sm focus-visible:ring-2 focus-visible:outline-none"
89
+ />
90
+ </label>
91
+ <label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm">
92
+ <span className="text-foreground font-medium">To</span>
93
+ <input
94
+ type="date"
95
+ value={to}
96
+ onChange={(event) => setTo(event.target.value)}
97
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring min-h-11 rounded-md border px-3 text-sm focus-visible:ring-2 focus-visible:outline-none"
98
+ />
99
+ </label>
100
+ </div>
101
+ <div className="flex flex-wrap gap-2">
102
+ <Button
103
+ type="submit"
104
+ className="min-h-11"
105
+ loading={pending === 'csv'}
106
+ disabled={pending !== null}
107
+ leftIcon={<Download size={16} aria-hidden />}
108
+ >
109
+ Export CSV
110
+ </Button>
111
+ <Button
112
+ type="button"
113
+ variant="outline"
114
+ className="min-h-11"
115
+ loading={pending === 'json'}
116
+ disabled={pending !== null}
117
+ leftIcon={<Download size={16} aria-hidden />}
118
+ onClick={() => void exportAs('json')}
119
+ >
120
+ Export JSON
121
+ </Button>
122
+ </div>
123
+ {error && (
124
+ <p className="text-destructive text-sm" role="alert">
125
+ {error}
126
+ </p>
127
+ )}
128
+ </form>
129
+ </SecurityCard>
130
+ )
131
+ }
@@ -10,6 +10,7 @@ import {
10
10
  } from './components.js'
11
11
  import { AccessProtectionCard } from './AccessProtectionCard.js'
12
12
  import { AuthMfaCard } from './AuthMfaCard.js'
13
+ import { AuditLogExportCard } from './AuditLogExportCard.js'
13
14
  import { SecretsAuditCard } from './SecretsAuditCard.js'
14
15
  import { SecurityStatusCard } from './SecurityStatusCard.js'
15
16
  import { CaptchaStatusCard } from './CaptchaStatusCard.js'
@@ -144,7 +145,8 @@ export function SecurityTab({
144
145
  />
145
146
  </div>
146
147
 
147
- <div id="security-audit">
148
+ <div id="security-audit" className="space-y-4">
149
+ {canEdit && <AuditLogExportCard />}
148
150
  <SecretsAuditCard
149
151
  secrets={data.secrets}
150
152
  auditEnabled={data.auditEnabled}