@actuate-media/cms-admin 0.31.0 → 0.33.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/dist/__tests__/views/seo-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-settings.render.test.js +152 -0
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -0
- package/dist/__tests__/views/settings-save-footer.render.test.js +16 -9
- package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -1
- package/dist/__tests__/views/user-detail-drawer.render.test.js +31 -0
- package/dist/__tests__/views/user-detail-drawer.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/MediaPickerModal.d.ts +15 -1
- package/dist/components/MediaPickerModal.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.js +17 -1
- package/dist/components/MediaPickerModal.js.map +1 -1
- package/dist/views/Settings.js +2 -2
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/SeoSettingsTab.d.ts +7 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -0
- package/dist/views/settings/SeoSettingsTab.js +126 -0
- package/dist/views/settings/SeoSettingsTab.js.map +1 -0
- package/dist/views/settings/useSeoSettings.d.ts +79 -0
- package/dist/views/settings/useSeoSettings.d.ts.map +1 -0
- package/dist/views/settings/useSeoSettings.js +278 -0
- package/dist/views/settings/useSeoSettings.js.map +1 -0
- package/dist/views/users/UserDetailDrawer.d.ts.map +1 -1
- package/dist/views/users/UserDetailDrawer.js +45 -5
- package/dist/views/users/UserDetailDrawer.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/views/seo-settings.render.test.tsx +219 -0
- package/src/__tests__/views/settings-save-footer.render.test.tsx +16 -9
- package/src/__tests__/views/user-detail-drawer.render.test.tsx +43 -0
- package/src/components/MediaPickerModal.tsx +39 -2
- package/src/views/Settings.tsx +2 -2
- package/src/views/settings/SeoSettingsTab.tsx +658 -0
- package/src/views/settings/useSeoSettings.ts +374 -0
- package/src/views/users/UserDetailDrawer.tsx +169 -24
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { cmsApi } from '../../lib/api.js'
|
|
5
|
+
import type { SettingsSourceMetadata, ValidationIssue } from './useGeneralSettings.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Settings → SEO ("Meta Defaults / Verification / Sitemap") form state.
|
|
9
|
+
*
|
|
10
|
+
* Single source of truth: every field reads from and writes back to the SAME
|
|
11
|
+
* shared SEO config service used by Settings → General and the /seo workspace.
|
|
12
|
+
* - meta defaults + verification → `/seo/config` (`site` overrides)
|
|
13
|
+
* - sitemap auto-generate / ping → `/seo/crawl-settings` (`module`)
|
|
14
|
+
* - sitemap status (read-only) → `/seo/sitemap/status`
|
|
15
|
+
*
|
|
16
|
+
* It deliberately does NOT own site URL or the document robots defaults — those
|
|
17
|
+
* are owned by Settings → General (also via `/seo/config`) and must not be
|
|
18
|
+
* duplicated here.
|
|
19
|
+
*/
|
|
20
|
+
export interface SeoSettingsForm {
|
|
21
|
+
/** Site-wide title template (`%page% — %site%`), applied when a page has none. */
|
|
22
|
+
titleTemplate: string
|
|
23
|
+
/** Fallback meta description when a page/post defines none. */
|
|
24
|
+
defaultMetaDescription: string
|
|
25
|
+
/**
|
|
26
|
+
* Fallback social (Open Graph / Twitter) image URL. Kept as a denormalized
|
|
27
|
+
* cache of the selected asset's public URL so the synchronous public renderer
|
|
28
|
+
* doesn't need a DB lookup. Empty = use the dynamic /og.png fallback.
|
|
29
|
+
*/
|
|
30
|
+
defaultOgImage: string
|
|
31
|
+
/** Canonical reference to the selected media asset (source of truth). */
|
|
32
|
+
defaultSocialImageAssetId: string
|
|
33
|
+
googleVerification: string
|
|
34
|
+
bingVerification: string
|
|
35
|
+
yandexVerification: string
|
|
36
|
+
pinterestVerification: string
|
|
37
|
+
facebookDomainVerification: string
|
|
38
|
+
/** Rebuild the sitemap whenever content is published (`module`). */
|
|
39
|
+
autoGenerateSitemap: boolean
|
|
40
|
+
/** Notify Google/Bing when the sitemap changes (`module`). */
|
|
41
|
+
pingOnPublish: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SitemapSummary {
|
|
45
|
+
sitemapUrl: string
|
|
46
|
+
lastGeneratedAt: string | null
|
|
47
|
+
pagesIncluded: number
|
|
48
|
+
pagesExcluded: number
|
|
49
|
+
warnings: string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const EMPTY_FORM: SeoSettingsForm = {
|
|
53
|
+
titleTemplate: '',
|
|
54
|
+
defaultMetaDescription: '',
|
|
55
|
+
defaultOgImage: '',
|
|
56
|
+
defaultSocialImageAssetId: '',
|
|
57
|
+
googleVerification: '',
|
|
58
|
+
bingVerification: '',
|
|
59
|
+
yandexVerification: '',
|
|
60
|
+
pinterestVerification: '',
|
|
61
|
+
facebookDomainVerification: '',
|
|
62
|
+
autoGenerateSitemap: true,
|
|
63
|
+
pingOnPublish: true,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Recommended max length for the default meta description (SERP truncation). */
|
|
67
|
+
export const META_DESCRIPTION_MAX = 160
|
|
68
|
+
/** Below this, the description is likely too thin to be useful. */
|
|
69
|
+
export const META_DESCRIPTION_MIN = 70
|
|
70
|
+
|
|
71
|
+
// Mirror of cms-core's `SITE_TITLE_TOKENS` (kept tiny + stable so the client can
|
|
72
|
+
// validate without importing the full cms-core module graph). The server
|
|
73
|
+
// re-validates on save via `validateTitleTemplate`, so this is a UX aid only.
|
|
74
|
+
const SITE_TITLE_TOKENS = ['page', 'site', 'type', 'separator', 'author', 'date', 'category', 'tag']
|
|
75
|
+
const TOKEN_RE = /%(\w+)%/g
|
|
76
|
+
|
|
77
|
+
export function validateTitleTemplateClient(template: string): {
|
|
78
|
+
valid: boolean
|
|
79
|
+
unknownTokens: string[]
|
|
80
|
+
} {
|
|
81
|
+
if (!template || template.trim() === '') return { valid: true, unknownTokens: [] }
|
|
82
|
+
const known = new Set(SITE_TITLE_TOKENS)
|
|
83
|
+
const unknown: string[] = []
|
|
84
|
+
let hasKnown = false
|
|
85
|
+
for (const m of template.matchAll(TOKEN_RE)) {
|
|
86
|
+
const t = m[1]!
|
|
87
|
+
if (known.has(t)) hasKnown = true
|
|
88
|
+
else if (!unknown.includes(t)) unknown.push(t)
|
|
89
|
+
}
|
|
90
|
+
const literal = template.replace(TOKEN_RE, '').trim()
|
|
91
|
+
if (unknown.length > 0) return { valid: false, unknownTokens: unknown }
|
|
92
|
+
if (!hasKnown && literal === '') return { valid: false, unknownTokens: [] }
|
|
93
|
+
return { valid: true, unknownTokens: [] }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validate(form: SeoSettingsForm): ValidationIssue[] {
|
|
97
|
+
const issues: ValidationIssue[] = []
|
|
98
|
+
const tpl = validateTitleTemplateClient(form.titleTemplate)
|
|
99
|
+
if (!tpl.valid) {
|
|
100
|
+
issues.push({
|
|
101
|
+
field: 'titleTemplate',
|
|
102
|
+
message:
|
|
103
|
+
tpl.unknownTokens.length > 0
|
|
104
|
+
? `Unsupported token${tpl.unknownTokens.length > 1 ? 's' : ''}: ${tpl.unknownTokens
|
|
105
|
+
.map((t) => `%${t}%`)
|
|
106
|
+
.join(', ')}`
|
|
107
|
+
: 'Template must include at least one token or some literal text.',
|
|
108
|
+
severity: 'error',
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
const desc = form.defaultMetaDescription.trim()
|
|
112
|
+
if (desc.length > META_DESCRIPTION_MAX) {
|
|
113
|
+
issues.push({
|
|
114
|
+
field: 'defaultMetaDescription',
|
|
115
|
+
message: `Default description is ${desc.length} characters — search engines truncate around ${META_DESCRIPTION_MAX}.`,
|
|
116
|
+
severity: 'warning',
|
|
117
|
+
})
|
|
118
|
+
} else if (desc.length > 0 && desc.length < META_DESCRIPTION_MIN) {
|
|
119
|
+
issues.push({
|
|
120
|
+
field: 'defaultMetaDescription',
|
|
121
|
+
message: `Default description is short (${desc.length} characters). Aim for ${META_DESCRIPTION_MIN}–${META_DESCRIPTION_MAX}.`,
|
|
122
|
+
severity: 'warning',
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
return issues
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface SeoConfigResponse {
|
|
129
|
+
static?: { site?: Record<string, any> | null }
|
|
130
|
+
overrides?: { site?: Record<string, any> | null } | null
|
|
131
|
+
effective?: { site?: Record<string, any> | null }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type SaveState = 'idle' | 'saving' | 'success' | 'error'
|
|
135
|
+
|
|
136
|
+
export interface UseSeoSettings {
|
|
137
|
+
loading: boolean
|
|
138
|
+
loadError: string | null
|
|
139
|
+
form: SeoSettingsForm
|
|
140
|
+
setField: <K extends keyof SeoSettingsForm>(key: K, value: SeoSettingsForm[K]) => void
|
|
141
|
+
dirty: boolean
|
|
142
|
+
issues: ValidationIssue[]
|
|
143
|
+
hasErrors: boolean
|
|
144
|
+
sources: Record<string, SettingsSourceMetadata>
|
|
145
|
+
/** Effective site name (read-only here; edited in General/advanced) for preview. */
|
|
146
|
+
siteName: string
|
|
147
|
+
/** Effective site URL for preview + warnings. */
|
|
148
|
+
siteUrl: string
|
|
149
|
+
isProduction: boolean
|
|
150
|
+
sitemap: SitemapSummary | null
|
|
151
|
+
saveState: SaveState
|
|
152
|
+
saveError: string | null
|
|
153
|
+
save: () => Promise<void>
|
|
154
|
+
reset: () => void
|
|
155
|
+
refetch: () => void
|
|
156
|
+
regenerateSitemap: () => Promise<void>
|
|
157
|
+
regenerating: boolean
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function useSeoSettings(canEdit: boolean): UseSeoSettings {
|
|
161
|
+
const [loading, setLoading] = useState(true)
|
|
162
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
163
|
+
const [baseline, setBaseline] = useState<SeoSettingsForm>(EMPTY_FORM)
|
|
164
|
+
const [form, setForm] = useState<SeoSettingsForm>(EMPTY_FORM)
|
|
165
|
+
const [overrideSite, setOverrideSite] = useState<Record<string, any>>({})
|
|
166
|
+
const [sources, setSources] = useState<Record<string, SettingsSourceMetadata>>({})
|
|
167
|
+
const [siteName, setSiteName] = useState('')
|
|
168
|
+
const [siteUrl, setSiteUrl] = useState('')
|
|
169
|
+
const [isProduction, setIsProduction] = useState(false)
|
|
170
|
+
const [sitemap, setSitemap] = useState<SitemapSummary | null>(null)
|
|
171
|
+
const [saveState, setSaveState] = useState<SaveState>('idle')
|
|
172
|
+
const [saveError, setSaveError] = useState<string | null>(null)
|
|
173
|
+
const [regenerating, setRegenerating] = useState(false)
|
|
174
|
+
const [reloadKey, setReloadKey] = useState(0)
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
let cancelled = false
|
|
178
|
+
setLoading(true)
|
|
179
|
+
setLoadError(null)
|
|
180
|
+
Promise.all([
|
|
181
|
+
cmsApi<SeoConfigResponse>('/seo/config'),
|
|
182
|
+
cmsApi<Record<string, any>>('/seo/crawl-settings'),
|
|
183
|
+
cmsApi<SitemapSummary>('/seo/sitemap/status'),
|
|
184
|
+
cmsApi<{ environment?: string }>('/seo/index-status'),
|
|
185
|
+
])
|
|
186
|
+
.then(([seoRes, crawlRes, sitemapRes, statusRes]) => {
|
|
187
|
+
if (cancelled) return
|
|
188
|
+
if (seoRes.error && seoRes.status !== 404) setLoadError(seoRes.error)
|
|
189
|
+
|
|
190
|
+
const seo = seoRes.data ?? {}
|
|
191
|
+
const effectiveSite = seo.effective?.site ?? {}
|
|
192
|
+
const staticSite = seo.static?.site ?? {}
|
|
193
|
+
const overSite = seo.overrides?.site ?? {}
|
|
194
|
+
const verification = effectiveSite.verification ?? {}
|
|
195
|
+
const crawl = crawlRes.data ?? {}
|
|
196
|
+
|
|
197
|
+
const loaded: SeoSettingsForm = {
|
|
198
|
+
titleTemplate: String(effectiveSite.titleTemplate ?? ''),
|
|
199
|
+
defaultMetaDescription: String(effectiveSite.defaultMetaDescription ?? ''),
|
|
200
|
+
defaultOgImage: String(effectiveSite.defaultOgImage ?? ''),
|
|
201
|
+
defaultSocialImageAssetId: String(effectiveSite.defaultSocialImageAssetId ?? ''),
|
|
202
|
+
googleVerification: String(verification.googleSearchConsole ?? ''),
|
|
203
|
+
bingVerification: String(verification.bingWebmasterTools ?? ''),
|
|
204
|
+
yandexVerification: String(verification.yandex ?? ''),
|
|
205
|
+
pinterestVerification: String(verification.pinterest ?? ''),
|
|
206
|
+
facebookDomainVerification: String(verification.facebookDomain ?? ''),
|
|
207
|
+
autoGenerateSitemap: crawl.autoRegenerateSitemapOnPublish !== false,
|
|
208
|
+
pingOnPublish: crawl.pingSearchEnginesOnPublish !== false,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// `editable` reflects the field's MANAGEMENT source (config vs database),
|
|
212
|
+
// not the user's role — role gates the inputs via `canEdit`. This keeps
|
|
213
|
+
// the source badge (and the field's accessible label) stable across
|
|
214
|
+
// roles, and only surfaces it when a value is locked by config.
|
|
215
|
+
const configManaged = (key: string): SettingsSourceMetadata =>
|
|
216
|
+
staticSite[key]
|
|
217
|
+
? { source: 'config', editable: false, reason: 'Set in actuate.config.ts' }
|
|
218
|
+
: { source: 'database', editable: true }
|
|
219
|
+
|
|
220
|
+
setSources({
|
|
221
|
+
titleTemplate: configManaged('titleTemplate'),
|
|
222
|
+
defaultMetaDescription: configManaged('defaultMetaDescription'),
|
|
223
|
+
defaultOgImage: configManaged('defaultOgImage'),
|
|
224
|
+
verification: { source: 'database', editable: true },
|
|
225
|
+
sitemap: { source: 'database', editable: true },
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
setBaseline(loaded)
|
|
229
|
+
setForm(loaded)
|
|
230
|
+
setOverrideSite(overSite ?? {})
|
|
231
|
+
setSiteName(String(effectiveSite.siteName ?? ''))
|
|
232
|
+
setSiteUrl(String(effectiveSite.siteUrl ?? ''))
|
|
233
|
+
if (statusRes.data?.environment)
|
|
234
|
+
setIsProduction(statusRes.data.environment === 'production')
|
|
235
|
+
if (!sitemapRes.error && sitemapRes.data) setSitemap(sitemapRes.data)
|
|
236
|
+
})
|
|
237
|
+
.catch(() => {
|
|
238
|
+
if (!cancelled) setLoadError('Failed to load SEO settings.')
|
|
239
|
+
})
|
|
240
|
+
.finally(() => {
|
|
241
|
+
if (!cancelled) setLoading(false)
|
|
242
|
+
})
|
|
243
|
+
return () => {
|
|
244
|
+
cancelled = true
|
|
245
|
+
}
|
|
246
|
+
}, [reloadKey, canEdit])
|
|
247
|
+
|
|
248
|
+
const setField = useCallback(
|
|
249
|
+
<K extends keyof SeoSettingsForm>(key: K, value: SeoSettingsForm[K]) => {
|
|
250
|
+
setForm((prev) => ({ ...prev, [key]: value }))
|
|
251
|
+
setSaveState('idle')
|
|
252
|
+
},
|
|
253
|
+
[],
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
const dirty = useMemo(
|
|
257
|
+
() => (Object.keys(form) as Array<keyof SeoSettingsForm>).some((k) => form[k] !== baseline[k]),
|
|
258
|
+
[form, baseline],
|
|
259
|
+
)
|
|
260
|
+
const issues = useMemo(() => validate(form), [form])
|
|
261
|
+
const hasErrors = issues.some((i) => i.severity === 'error')
|
|
262
|
+
|
|
263
|
+
const refetchSitemap = useCallback(async () => {
|
|
264
|
+
const res = await cmsApi<SitemapSummary>('/seo/sitemap/status')
|
|
265
|
+
if (!res.error && res.data) setSitemap(res.data)
|
|
266
|
+
}, [])
|
|
267
|
+
|
|
268
|
+
const save = useCallback(async () => {
|
|
269
|
+
if (!canEdit || hasErrors) return
|
|
270
|
+
setSaveState('saving')
|
|
271
|
+
setSaveError(null)
|
|
272
|
+
|
|
273
|
+
const siteChanged =
|
|
274
|
+
form.titleTemplate !== baseline.titleTemplate ||
|
|
275
|
+
form.defaultMetaDescription !== baseline.defaultMetaDescription ||
|
|
276
|
+
form.defaultOgImage !== baseline.defaultOgImage ||
|
|
277
|
+
form.defaultSocialImageAssetId !== baseline.defaultSocialImageAssetId ||
|
|
278
|
+
form.googleVerification !== baseline.googleVerification ||
|
|
279
|
+
form.bingVerification !== baseline.bingVerification ||
|
|
280
|
+
form.yandexVerification !== baseline.yandexVerification ||
|
|
281
|
+
form.pinterestVerification !== baseline.pinterestVerification ||
|
|
282
|
+
form.facebookDomainVerification !== baseline.facebookDomainVerification
|
|
283
|
+
const sitemapChanged =
|
|
284
|
+
form.autoGenerateSitemap !== baseline.autoGenerateSitemap ||
|
|
285
|
+
form.pingOnPublish !== baseline.pingOnPublish
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
// 1. Meta defaults + verification → shared /seo/config (merge over the
|
|
289
|
+
// current override site so General's siteUrl/robots aren't wiped).
|
|
290
|
+
if (siteChanged) {
|
|
291
|
+
const nextSite: Record<string, any> = {
|
|
292
|
+
...overrideSite,
|
|
293
|
+
titleTemplate: form.titleTemplate.trim() || undefined,
|
|
294
|
+
defaultMetaDescription: form.defaultMetaDescription.trim() || undefined,
|
|
295
|
+
defaultOgImage: form.defaultOgImage.trim() || undefined,
|
|
296
|
+
defaultSocialImageAssetId: form.defaultSocialImageAssetId.trim() || undefined,
|
|
297
|
+
verification: {
|
|
298
|
+
...(overrideSite.verification ?? {}),
|
|
299
|
+
googleSearchConsole: form.googleVerification.trim() || undefined,
|
|
300
|
+
bingWebmasterTools: form.bingVerification.trim() || undefined,
|
|
301
|
+
yandex: form.yandexVerification.trim() || undefined,
|
|
302
|
+
pinterest: form.pinterestVerification.trim() || undefined,
|
|
303
|
+
facebookDomain: form.facebookDomainVerification.trim() || undefined,
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
const res = await cmsApi('/seo/config', {
|
|
307
|
+
method: 'PUT',
|
|
308
|
+
body: JSON.stringify({ site: nextSite }),
|
|
309
|
+
})
|
|
310
|
+
if (res.error) throw new Error(res.error)
|
|
311
|
+
setOverrideSite(nextSite)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 2. Sitemap toggles → shared /seo/crawl-settings (`module`).
|
|
315
|
+
if (sitemapChanged) {
|
|
316
|
+
const res = await cmsApi('/seo/crawl-settings', {
|
|
317
|
+
method: 'PUT',
|
|
318
|
+
body: JSON.stringify({
|
|
319
|
+
autoRegenerateSitemapOnPublish: form.autoGenerateSitemap,
|
|
320
|
+
pingSearchEnginesOnPublish: form.pingOnPublish,
|
|
321
|
+
}),
|
|
322
|
+
})
|
|
323
|
+
if (res.error) throw new Error(res.error)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
setBaseline(form)
|
|
327
|
+
setSaveState('success')
|
|
328
|
+
if (sitemapChanged) await refetchSitemap()
|
|
329
|
+
} catch (err) {
|
|
330
|
+
setSaveState('error')
|
|
331
|
+
setSaveError(err instanceof Error ? err.message : 'Failed to save SEO settings.')
|
|
332
|
+
}
|
|
333
|
+
}, [canEdit, hasErrors, form, baseline, overrideSite, refetchSitemap])
|
|
334
|
+
|
|
335
|
+
const reset = useCallback(() => {
|
|
336
|
+
setForm(baseline)
|
|
337
|
+
setSaveState('idle')
|
|
338
|
+
setSaveError(null)
|
|
339
|
+
}, [baseline])
|
|
340
|
+
|
|
341
|
+
const refetch = useCallback(() => setReloadKey((k) => k + 1), [])
|
|
342
|
+
|
|
343
|
+
const regenerateSitemap = useCallback(async () => {
|
|
344
|
+
setRegenerating(true)
|
|
345
|
+
try {
|
|
346
|
+
const res = await cmsApi<SitemapSummary>('/seo/sitemap/regenerate', { method: 'POST' })
|
|
347
|
+
if (!res.error && res.data) setSitemap((prev) => ({ ...(prev ?? res.data!), ...res.data! }))
|
|
348
|
+
} finally {
|
|
349
|
+
setRegenerating(false)
|
|
350
|
+
}
|
|
351
|
+
}, [])
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
loading,
|
|
355
|
+
loadError,
|
|
356
|
+
form,
|
|
357
|
+
setField,
|
|
358
|
+
dirty,
|
|
359
|
+
issues,
|
|
360
|
+
hasErrors,
|
|
361
|
+
sources,
|
|
362
|
+
siteName,
|
|
363
|
+
siteUrl,
|
|
364
|
+
isProduction,
|
|
365
|
+
sitemap,
|
|
366
|
+
saveState,
|
|
367
|
+
saveError,
|
|
368
|
+
save,
|
|
369
|
+
reset,
|
|
370
|
+
refetch,
|
|
371
|
+
regenerateSitemap,
|
|
372
|
+
regenerating,
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -227,6 +227,126 @@ function ReauthDialog({
|
|
|
227
227
|
)
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// Transfer content modal
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
interface TransferMember {
|
|
235
|
+
id: string
|
|
236
|
+
name: string
|
|
237
|
+
email: string
|
|
238
|
+
status: string
|
|
239
|
+
isCurrentUser: boolean
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function TransferContentModal({
|
|
243
|
+
open,
|
|
244
|
+
onClose,
|
|
245
|
+
fromUser,
|
|
246
|
+
onTransferred,
|
|
247
|
+
}: {
|
|
248
|
+
open: boolean
|
|
249
|
+
onClose: () => void
|
|
250
|
+
fromUser: { id: string; name: string; contentOwnedCount: number }
|
|
251
|
+
onTransferred: () => void
|
|
252
|
+
}) {
|
|
253
|
+
// Only fetch the directory while the picker is open.
|
|
254
|
+
const members = useApiData<TransferMember[]>(open ? '/users' : null)
|
|
255
|
+
const [toUserId, setToUserId] = useState('')
|
|
256
|
+
const [busy, setBusy] = useState(false)
|
|
257
|
+
|
|
258
|
+
const recipients = (members.data ?? []).filter(
|
|
259
|
+
(m) => m.id !== fromUser.id && m.status === 'active',
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const submit = async () => {
|
|
263
|
+
if (!toUserId || busy) return
|
|
264
|
+
setBusy(true)
|
|
265
|
+
try {
|
|
266
|
+
const res = await cmsApi<{
|
|
267
|
+
documents: number
|
|
268
|
+
media: number
|
|
269
|
+
templates: number
|
|
270
|
+
total: number
|
|
271
|
+
}>(`/users/${fromUser.id}/transfer-content`, {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
body: JSON.stringify({ toUserId }),
|
|
274
|
+
})
|
|
275
|
+
if (res.error) {
|
|
276
|
+
toast.error(res.error)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
const c = res.data
|
|
280
|
+
toast.success(
|
|
281
|
+
c && c.total > 0
|
|
282
|
+
? `Transferred ${c.total} item${c.total === 1 ? '' : 's'} (${c.documents} docs, ${c.media} media, ${c.templates} templates).`
|
|
283
|
+
: 'Nothing to transfer.',
|
|
284
|
+
)
|
|
285
|
+
setToUserId('')
|
|
286
|
+
onClose()
|
|
287
|
+
onTransferred()
|
|
288
|
+
} finally {
|
|
289
|
+
setBusy(false)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<Modal
|
|
295
|
+
open={open}
|
|
296
|
+
onClose={() => {
|
|
297
|
+
setToUserId('')
|
|
298
|
+
onClose()
|
|
299
|
+
}}
|
|
300
|
+
title="Transfer content ownership"
|
|
301
|
+
actions={
|
|
302
|
+
<>
|
|
303
|
+
<Button
|
|
304
|
+
variant="ghost"
|
|
305
|
+
onClick={() => {
|
|
306
|
+
setToUserId('')
|
|
307
|
+
onClose()
|
|
308
|
+
}}
|
|
309
|
+
>
|
|
310
|
+
Cancel
|
|
311
|
+
</Button>
|
|
312
|
+
<Button variant="primary" loading={busy} disabled={!toUserId || busy} onClick={submit}>
|
|
313
|
+
Transfer
|
|
314
|
+
</Button>
|
|
315
|
+
</>
|
|
316
|
+
}
|
|
317
|
+
>
|
|
318
|
+
<p className="text-muted-foreground mb-3 text-sm">
|
|
319
|
+
Reassign {fromUser.name}'s authored documents, uploaded media, and templates to another
|
|
320
|
+
active member. Version history and audit records are left unchanged.
|
|
321
|
+
</p>
|
|
322
|
+
<label htmlFor="transfer-to" className="text-card-foreground mb-1 block text-sm font-medium">
|
|
323
|
+
Transfer to
|
|
324
|
+
</label>
|
|
325
|
+
{members.loading ? (
|
|
326
|
+
<Skeleton variant="table-row" lines={1} />
|
|
327
|
+
) : recipients.length === 0 ? (
|
|
328
|
+
<p className="text-muted-foreground text-sm">
|
|
329
|
+
No other active members to receive the content.
|
|
330
|
+
</p>
|
|
331
|
+
) : (
|
|
332
|
+
<select
|
|
333
|
+
id="transfer-to"
|
|
334
|
+
value={toUserId}
|
|
335
|
+
onChange={(e) => setToUserId(e.target.value)}
|
|
336
|
+
className="border-border bg-input-background text-foreground focus:ring-brand w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
|
|
337
|
+
>
|
|
338
|
+
<option value="">Select a member…</option>
|
|
339
|
+
{recipients.map((m) => (
|
|
340
|
+
<option key={m.id} value={m.id}>
|
|
341
|
+
{m.name} ({m.email})
|
|
342
|
+
</option>
|
|
343
|
+
))}
|
|
344
|
+
</select>
|
|
345
|
+
)}
|
|
346
|
+
</Modal>
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
230
350
|
// ---------------------------------------------------------------------------
|
|
231
351
|
// Small presentational bits
|
|
232
352
|
// ---------------------------------------------------------------------------
|
|
@@ -309,6 +429,7 @@ export function UserDetailDrawer({ userId, open, onOpenChange, onChanged }: User
|
|
|
309
429
|
onConfirm: () => void
|
|
310
430
|
} | null>(null)
|
|
311
431
|
const [pending, setPending] = useState<string | null>(null)
|
|
432
|
+
const [showTransfer, setShowTransfer] = useState(false)
|
|
312
433
|
|
|
313
434
|
const refreshAfterChange = useCallback(() => {
|
|
314
435
|
detail.refetch()
|
|
@@ -469,13 +590,25 @@ export function UserDetailDrawer({ userId, open, onOpenChange, onChanged }: User
|
|
|
469
590
|
)}
|
|
470
591
|
</dl>
|
|
471
592
|
{u.contentOwnedCount > 0 && (
|
|
472
|
-
<div className="border-info/30 bg-info/5
|
|
593
|
+
<div className="border-info/30 bg-info/5 mt-4 flex items-start gap-2 rounded-md border p-3">
|
|
473
594
|
<FileText size={14} className="text-info mt-0.5 shrink-0" aria-hidden />
|
|
474
|
-
<
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
595
|
+
<div className="min-w-0 flex-1">
|
|
596
|
+
<p className="text-muted-foreground text-xs">
|
|
597
|
+
This user owns {u.contentOwnedCount} content{' '}
|
|
598
|
+
{u.contentOwnedCount === 1 ? 'item' : 'items'}. Revoking access suspends the
|
|
599
|
+
account and preserves their content and authorship — or reassign it to another
|
|
600
|
+
member first.
|
|
601
|
+
</p>
|
|
602
|
+
<Button
|
|
603
|
+
size="sm"
|
|
604
|
+
variant="outline"
|
|
605
|
+
className="mt-2"
|
|
606
|
+
leftIcon={<FileText size={14} aria-hidden />}
|
|
607
|
+
onClick={() => setShowTransfer(true)}
|
|
608
|
+
>
|
|
609
|
+
Transfer content
|
|
610
|
+
</Button>
|
|
611
|
+
</div>
|
|
479
612
|
</div>
|
|
480
613
|
)}
|
|
481
614
|
</Tabs.Content>
|
|
@@ -898,27 +1031,39 @@ export function UserDetailDrawer({ userId, open, onOpenChange, onChanged }: User
|
|
|
898
1031
|
</Tabs.Content>
|
|
899
1032
|
</Tabs.Root>
|
|
900
1033
|
)}
|
|
901
|
-
</Drawer>
|
|
902
1034
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1035
|
+
{/* Auxiliary dialogs live inside the Drawer subtree so Radix's focus
|
|
1036
|
+
scope and aria-hidden management treat them as part of the active
|
|
1037
|
+
dialog rather than hiding them as background content. */}
|
|
1038
|
+
{confirm && (
|
|
1039
|
+
<ConfirmDialog
|
|
1040
|
+
open={!!confirm}
|
|
1041
|
+
onClose={() => setConfirm(null)}
|
|
1042
|
+
onConfirm={confirm.onConfirm}
|
|
1043
|
+
title={confirm.title}
|
|
1044
|
+
description={confirm.description}
|
|
1045
|
+
confirmLabel={confirm.confirmLabel}
|
|
1046
|
+
destructive={confirm.destructive}
|
|
1047
|
+
/>
|
|
1048
|
+
)}
|
|
1049
|
+
|
|
1050
|
+
<ReauthDialog
|
|
1051
|
+
open={!!reauth}
|
|
1052
|
+
onClose={() => setReauth(null)}
|
|
1053
|
+
onSubmit={async (password) => {
|
|
1054
|
+
if (reauth) await reauth.run(password)
|
|
1055
|
+
}}
|
|
912
1056
|
/>
|
|
913
|
-
)}
|
|
914
1057
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1058
|
+
{u && (
|
|
1059
|
+
<TransferContentModal
|
|
1060
|
+
open={showTransfer}
|
|
1061
|
+
onClose={() => setShowTransfer(false)}
|
|
1062
|
+
fromUser={{ id: u.id, name: u.name, contentOwnedCount: u.contentOwnedCount }}
|
|
1063
|
+
onTransferred={refreshAfterChange}
|
|
1064
|
+
/>
|
|
1065
|
+
)}
|
|
1066
|
+
</Drawer>
|
|
922
1067
|
</>
|
|
923
1068
|
)
|
|
924
1069
|
}
|