@prenta/admin 1.17.13 → 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.
- package/CHANGELOG.md +20 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +6 -26
- 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/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.map +1 -1
- package/dist/views/CollectionList.js +23 -2
- 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 +5 -5
- package/src/AdminRoot.tsx +33 -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/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 +21 -2
- 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/lib/pages-service.ts
CHANGED
|
@@ -33,6 +33,28 @@ export type PageStatus = 'DRAFT' | 'PUBLISHED' | 'SCHEDULED' | 'ARCHIVED'
|
|
|
33
33
|
const PAGES_COLLECTION = 'pages'
|
|
34
34
|
const PAGE_TAGS_COLLECTION = 'page-tags'
|
|
35
35
|
|
|
36
|
+
/** Keys the pages table reads. `sections` and other content blobs stay off the wire. */
|
|
37
|
+
const PAGE_LIST_PICK = [
|
|
38
|
+
'title',
|
|
39
|
+
'slug',
|
|
40
|
+
'path',
|
|
41
|
+
'parentPageId',
|
|
42
|
+
'tags',
|
|
43
|
+
'primaryTag',
|
|
44
|
+
'seoScore',
|
|
45
|
+
'seo_score',
|
|
46
|
+
'metaScore',
|
|
47
|
+
'metaTitle',
|
|
48
|
+
'seoTitle',
|
|
49
|
+
'metaDescription',
|
|
50
|
+
'seoDescription',
|
|
51
|
+
'templateId',
|
|
52
|
+
'isHomepage',
|
|
53
|
+
'isSystemPage',
|
|
54
|
+
].join(',')
|
|
55
|
+
|
|
56
|
+
const TAG_LIST_PICK = ['title', 'slug', 'description', 'color', 'sortOrder', 'active'].join(',')
|
|
57
|
+
|
|
36
58
|
export interface PageTag {
|
|
37
59
|
id: string // == slug (stable join key; pages store slugs in data.tags)
|
|
38
60
|
docId: string // underlying document id — required for tag CRUD
|
|
@@ -217,7 +239,7 @@ function docToTag(d: DocumentApiRow, pageCount: number): PageTag {
|
|
|
217
239
|
|
|
218
240
|
async function fetchTagDocs(): Promise<DocumentApiRow[]> {
|
|
219
241
|
const res = await cmsApi<ListEnvelope>(
|
|
220
|
-
`/collections/${PAGE_TAGS_COLLECTION}?pageSize=500&sort=updatedAt&order=desc`,
|
|
242
|
+
`/collections/${PAGE_TAGS_COLLECTION}?pageSize=500&sort=updatedAt&order=desc&pick=${TAG_LIST_PICK}`,
|
|
221
243
|
)
|
|
222
244
|
return (res.data?.docs ?? []) as DocumentApiRow[]
|
|
223
245
|
}
|
|
@@ -366,7 +388,9 @@ export async function fetchPagesDataset(): Promise<PagesDataset> {
|
|
|
366
388
|
const authorsMap = await fetchAuthorsMap()
|
|
367
389
|
|
|
368
390
|
const [pagesRes, tagDocs] = await Promise.all([
|
|
369
|
-
cmsApi<ListEnvelope>(
|
|
391
|
+
cmsApi<ListEnvelope>(
|
|
392
|
+
`/collections/${PAGES_COLLECTION}?pageSize=500&sort=updatedAt&order=desc&pick=${PAGE_LIST_PICK}`,
|
|
393
|
+
),
|
|
370
394
|
fetchTagDocs(),
|
|
371
395
|
])
|
|
372
396
|
|
|
@@ -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/
|
|
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
|
|
21
|
-
*
|
|
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
|
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}
|