@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.
Files changed (35) hide show
  1. package/dist/__tests__/views/seo-settings.render.test.d.ts +2 -0
  2. package/dist/__tests__/views/seo-settings.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/views/seo-settings.render.test.js +152 -0
  4. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -0
  5. package/dist/__tests__/views/settings-save-footer.render.test.js +16 -9
  6. package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -1
  7. package/dist/__tests__/views/user-detail-drawer.render.test.js +31 -0
  8. package/dist/__tests__/views/user-detail-drawer.render.test.js.map +1 -1
  9. package/dist/actuate-admin.css +1 -1
  10. package/dist/components/MediaPickerModal.d.ts +15 -1
  11. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  12. package/dist/components/MediaPickerModal.js +17 -1
  13. package/dist/components/MediaPickerModal.js.map +1 -1
  14. package/dist/views/Settings.js +2 -2
  15. package/dist/views/Settings.js.map +1 -1
  16. package/dist/views/settings/SeoSettingsTab.d.ts +7 -0
  17. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -0
  18. package/dist/views/settings/SeoSettingsTab.js +126 -0
  19. package/dist/views/settings/SeoSettingsTab.js.map +1 -0
  20. package/dist/views/settings/useSeoSettings.d.ts +79 -0
  21. package/dist/views/settings/useSeoSettings.d.ts.map +1 -0
  22. package/dist/views/settings/useSeoSettings.js +278 -0
  23. package/dist/views/settings/useSeoSettings.js.map +1 -0
  24. package/dist/views/users/UserDetailDrawer.d.ts.map +1 -1
  25. package/dist/views/users/UserDetailDrawer.js +45 -5
  26. package/dist/views/users/UserDetailDrawer.js.map +1 -1
  27. package/package.json +2 -2
  28. package/src/__tests__/views/seo-settings.render.test.tsx +219 -0
  29. package/src/__tests__/views/settings-save-footer.render.test.tsx +16 -9
  30. package/src/__tests__/views/user-detail-drawer.render.test.tsx +43 -0
  31. package/src/components/MediaPickerModal.tsx +39 -2
  32. package/src/views/Settings.tsx +2 -2
  33. package/src/views/settings/SeoSettingsTab.tsx +658 -0
  34. package/src/views/settings/useSeoSettings.ts +374 -0
  35. 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}&apos;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 text-muted-foreground mt-4 flex items-start gap-2 rounded-md border p-3 text-xs">
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
- <span>
475
- This user owns {u.contentOwnedCount} content{' '}
476
- {u.contentOwnedCount === 1 ? 'item' : 'items'}. Revoking access suspends the
477
- account and preserves their content and authorship.
478
- </span>
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
- {confirm && (
904
- <ConfirmDialog
905
- open={!!confirm}
906
- onClose={() => setConfirm(null)}
907
- onConfirm={confirm.onConfirm}
908
- title={confirm.title}
909
- description={confirm.description}
910
- confirmLabel={confirm.confirmLabel}
911
- destructive={confirm.destructive}
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
- <ReauthDialog
916
- open={!!reauth}
917
- onClose={() => setReauth(null)}
918
- onSubmit={async (password) => {
919
- if (reauth) await reauth.run(password)
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
  }