@prenta/admin 1.17.14 → 1.17.17
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/CHANGELOG.md +20 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -25
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/pages-service-fetch.test.js +3 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -1
- package/dist/__tests__/views/admin-lazy-route.render.test.d.ts +2 -0
- package/dist/__tests__/views/admin-lazy-route.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/admin-lazy-route.render.test.js +30 -0
- package/dist/__tests__/views/admin-lazy-route.render.test.js.map +1 -0
- package/dist/__tests__/views/admin-lazy-views.test.d.ts +2 -0
- package/dist/__tests__/views/admin-lazy-views.test.d.ts.map +1 -0
- package/dist/__tests__/views/admin-lazy-views.test.js +67 -0
- package/dist/__tests__/views/admin-lazy-views.test.js.map +1 -0
- package/dist/__tests__/views/audit-log-export.test.d.ts +2 -0
- package/dist/__tests__/views/audit-log-export.test.d.ts.map +1 -0
- package/dist/__tests__/views/audit-log-export.test.js +11 -0
- package/dist/__tests__/views/audit-log-export.test.js.map +1 -0
- package/dist/__tests__/views/collection-list-paging.test.d.ts +2 -0
- package/dist/__tests__/views/collection-list-paging.test.d.ts.map +1 -0
- package/dist/__tests__/views/collection-list-paging.test.js +101 -0
- package/dist/__tests__/views/collection-list-paging.test.js.map +1 -0
- package/dist/__tests__/views/trash.render.test.js +35 -0
- package/dist/__tests__/views/trash.render.test.js.map +1 -1
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +22 -2
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/router/admin-routes.d.ts +2 -2
- package/dist/router/admin-routes.js +3 -3
- package/dist/router/admin-routes.js.map +1 -1
- package/dist/views/CollectionList.d.ts +13 -0
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +80 -22
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/SEO.d.ts +1 -44
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +1 -46
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/Trash.d.ts.map +1 -1
- package/dist/views/Trash.js +21 -2
- package/dist/views/Trash.js.map +1 -1
- package/dist/views/lazy-views.d.ts +30 -0
- package/dist/views/lazy-views.d.ts.map +1 -0
- package/dist/views/lazy-views.js +37 -0
- package/dist/views/lazy-views.js.map +1 -0
- package/dist/views/seo/seo-routes.d.ts +16 -0
- package/dist/views/seo/seo-routes.d.ts.map +1 -0
- package/dist/views/seo/seo-routes.js +53 -0
- package/dist/views/seo/seo-routes.js.map +1 -0
- package/dist/views/settings/AuditLogExportCard.d.ts +9 -0
- package/dist/views/settings/AuditLogExportCard.d.ts.map +1 -0
- package/dist/views/settings/AuditLogExportCard.js +66 -0
- package/dist/views/settings/AuditLogExportCard.js.map +1 -0
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +2 -1
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/package.json +4 -4
- package/src/AdminRoot.tsx +31 -25
- package/src/__tests__/lib/pages-service-fetch.test.ts +5 -0
- package/src/__tests__/views/admin-lazy-route.render.test.tsx +39 -0
- package/src/__tests__/views/admin-lazy-views.test.ts +70 -0
- package/src/__tests__/views/audit-log-export.test.ts +17 -0
- package/src/__tests__/views/collection-list-paging.test.tsx +112 -0
- package/src/__tests__/views/trash.render.test.tsx +40 -0
- package/src/lib/pages-service.ts +26 -2
- package/src/router/admin-routes.ts +3 -3
- package/src/views/CollectionList.tsx +121 -40
- package/src/views/SEO.tsx +1 -48
- package/src/views/Trash.tsx +68 -16
- package/src/views/lazy-views.tsx +79 -0
- package/src/views/seo/seo-routes.ts +58 -0
- package/src/views/settings/AuditLogExportCard.tsx +131 -0
- package/src/views/settings/SecurityTab.tsx +3 -1
package/src/views/Trash.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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}
|