@actuate-media/cms-admin 0.42.0 → 0.42.2

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 (93) hide show
  1. package/dist/__tests__/lib/post-editor-service.test.js +120 -1
  2. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  3. package/dist/__tests__/views/portal-container.test.d.ts +2 -0
  4. package/dist/__tests__/views/portal-container.test.d.ts.map +1 -0
  5. package/dist/__tests__/views/portal-container.test.js +42 -0
  6. package/dist/__tests__/views/portal-container.test.js.map +1 -0
  7. package/dist/__tests__/views/post-section-editor.test.d.ts +2 -0
  8. package/dist/__tests__/views/post-section-editor.test.d.ts.map +1 -0
  9. package/dist/__tests__/views/post-section-editor.test.js +96 -0
  10. package/dist/__tests__/views/post-section-editor.test.js.map +1 -0
  11. package/dist/components/LocaleSwitcher.d.ts.map +1 -1
  12. package/dist/components/LocaleSwitcher.js +2 -1
  13. package/dist/components/LocaleSwitcher.js.map +1 -1
  14. package/dist/components/SEOPanel.d.ts.map +1 -1
  15. package/dist/components/SEOPanel.js +2 -1
  16. package/dist/components/SEOPanel.js.map +1 -1
  17. package/dist/components/forms/Drawer.d.ts.map +1 -1
  18. package/dist/components/forms/Drawer.js +2 -1
  19. package/dist/components/forms/Drawer.js.map +1 -1
  20. package/dist/components/seo/Drawer.d.ts.map +1 -1
  21. package/dist/components/seo/Drawer.js +2 -1
  22. package/dist/components/seo/Drawer.js.map +1 -1
  23. package/dist/layout/Header.d.ts.map +1 -1
  24. package/dist/layout/Header.js +2 -1
  25. package/dist/layout/Header.js.map +1 -1
  26. package/dist/layout/Sidebar.d.ts.map +1 -1
  27. package/dist/layout/Sidebar.js +2 -1
  28. package/dist/layout/Sidebar.js.map +1 -1
  29. package/dist/lib/collection-schema.d.ts +29 -0
  30. package/dist/lib/collection-schema.d.ts.map +1 -0
  31. package/dist/lib/collection-schema.js +54 -0
  32. package/dist/lib/collection-schema.js.map +1 -0
  33. package/dist/lib/page-editor-service.d.ts.map +1 -1
  34. package/dist/lib/page-editor-service.js +2 -32
  35. package/dist/lib/page-editor-service.js.map +1 -1
  36. package/dist/lib/portal-container.d.ts +12 -0
  37. package/dist/lib/portal-container.d.ts.map +1 -0
  38. package/dist/lib/portal-container.js +16 -0
  39. package/dist/lib/portal-container.js.map +1 -0
  40. package/dist/lib/post-editor-service.d.ts +1 -0
  41. package/dist/lib/post-editor-service.d.ts.map +1 -1
  42. package/dist/lib/post-editor-service.js +50 -9
  43. package/dist/lib/post-editor-service.js.map +1 -1
  44. package/dist/views/ApiKeys.d.ts.map +1 -1
  45. package/dist/views/ApiKeys.js +3 -2
  46. package/dist/views/ApiKeys.js.map +1 -1
  47. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  48. package/dist/views/Pages/PagesListView.js +2 -1
  49. package/dist/views/Pages/PagesListView.js.map +1 -1
  50. package/dist/views/Redirects.d.ts.map +1 -1
  51. package/dist/views/Redirects.js +2 -1
  52. package/dist/views/Redirects.js.map +1 -1
  53. package/dist/views/Users.d.ts.map +1 -1
  54. package/dist/views/Users.js +4 -3
  55. package/dist/views/Users.js.map +1 -1
  56. package/dist/views/page-builder/BlockPicker.d.ts.map +1 -1
  57. package/dist/views/page-builder/BlockPicker.js +2 -1
  58. package/dist/views/page-builder/BlockPicker.js.map +1 -1
  59. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  60. package/dist/views/page-editor/PageSectionEditor.js +5 -2
  61. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  62. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  63. package/dist/views/page-editor/SectionsPanel.js +2 -1
  64. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  65. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  66. package/dist/views/post-editor/PostSectionEditor.js +27 -4
  67. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  68. package/dist/views/settings/components.d.ts.map +1 -1
  69. package/dist/views/settings/components.js +2 -1
  70. package/dist/views/settings/components.js.map +1 -1
  71. package/package.json +2 -2
  72. package/src/__tests__/lib/post-editor-service.test.ts +136 -0
  73. package/src/__tests__/views/portal-container.test.tsx +56 -0
  74. package/src/__tests__/views/post-section-editor.test.tsx +147 -0
  75. package/src/components/LocaleSwitcher.tsx +2 -1
  76. package/src/components/SEOPanel.tsx +2 -1
  77. package/src/components/forms/Drawer.tsx +2 -1
  78. package/src/components/seo/Drawer.tsx +2 -1
  79. package/src/layout/Header.tsx +2 -1
  80. package/src/layout/Sidebar.tsx +2 -1
  81. package/src/lib/collection-schema.ts +62 -0
  82. package/src/lib/page-editor-service.ts +3 -35
  83. package/src/lib/portal-container.ts +14 -0
  84. package/src/lib/post-editor-service.ts +54 -8
  85. package/src/views/ApiKeys.tsx +3 -2
  86. package/src/views/Pages/PagesListView.tsx +2 -1
  87. package/src/views/Redirects.tsx +2 -1
  88. package/src/views/Users.tsx +4 -3
  89. package/src/views/page-builder/BlockPicker.tsx +2 -1
  90. package/src/views/page-editor/PageSectionEditor.tsx +5 -2
  91. package/src/views/page-editor/SectionsPanel.tsx +2 -1
  92. package/src/views/post-editor/PostSectionEditor.tsx +49 -2
  93. package/src/views/settings/components.tsx +2 -1
@@ -20,8 +20,10 @@
20
20
  * so the field-access layer doesn't strip them on write.
21
21
  */
22
22
  import { cmsApi } from './api.js'
23
+ import { missingCollectionFields } from './collection-schema.js'
23
24
  import {
24
25
  getSectionType,
26
+ generateSectionId,
25
27
  createSection,
26
28
  addSection,
27
29
  removeSection,
@@ -93,6 +95,30 @@ export interface ValidationResult {
93
95
  warnings: string[]
94
96
  }
95
97
 
98
+ // ─── Schema guard ────────────────────────────────────────────────────
99
+
100
+ /**
101
+ * The Post Editor persists these as top-level keys on the post-type
102
+ * collection's documents. The server's field-access layer silently drops any
103
+ * key not declared as a field on the collection, so a post-type collection
104
+ * missing e.g. `sections` would discard all section content on save with no
105
+ * error. Mirrors the Page Editor's `missingPageFields` guard.
106
+ */
107
+ const REQUIRED_POST_FIELDS = [
108
+ 'sections',
109
+ 'excerpt',
110
+ 'featuredImage',
111
+ 'body',
112
+ 'category',
113
+ 'publishDate',
114
+ 'metaTitle',
115
+ 'metaDescription',
116
+ ] as const
117
+
118
+ export function missingPostFields(config: unknown, postType: string): string[] {
119
+ return missingCollectionFields(config, postType, REQUIRED_POST_FIELDS)
120
+ }
121
+
96
122
  // ─── Validation ──────────────────────────────────────────────────────
97
123
 
98
124
  function isBlank(v: unknown): boolean {
@@ -113,7 +139,9 @@ export function validatePost(post: EditorPost, forPublish = false): ValidationRe
113
139
 
114
140
  if (forPublish) {
115
141
  const visible = post.sections.filter((s) => s.visible)
116
- for (const section of visible) {
142
+ // Unmanaged (externally-managed) sections have no registered field schema,
143
+ // so they can't be content-validated here and must not block publish.
144
+ for (const section of visible.filter((s) => !s.unmanaged)) {
117
145
  const def = getSectionType(section.sectionType)
118
146
  const label = section.name || def?.name || 'Section'
119
147
  const result = validateSectionContent(section.sectionType, section.content, section.settings)
@@ -133,7 +161,7 @@ export function validatePost(post: EditorPost, forPublish = false): ValidationRe
133
161
  export function validateTemplate(template: EditorPostTemplate): ValidationResult {
134
162
  const errors: string[] = []
135
163
  const warnings: string[] = []
136
- for (const section of template.sections.filter((s) => s.visible)) {
164
+ for (const section of template.sections.filter((s) => s.visible && !s.unmanaged)) {
137
165
  const def = getSectionType(section.sectionType)
138
166
  const label = section.name || def?.name || 'Section'
139
167
  const result = validateSectionContent(section.sectionType, section.content, section.settings)
@@ -175,7 +203,9 @@ function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
175
203
  category: dataStr(data, 'category'),
176
204
  status: doc.status ?? 'DRAFT',
177
205
  publishDate: (data.publishDate as string) ?? doc.scheduledAt ?? null,
178
- sections: coerceSections(data.sections),
206
+ // Preserve unknown (externally-managed) sections so the editor can show
207
+ // them read-only and round-trip them on save instead of dropping them.
208
+ sections: coerceSections(data.sections, { onUnknown: 'preserve' }),
179
209
  seoTitle: dataStr(data, 'metaTitle'),
180
210
  seoDescription: dataStr(data, 'metaDescription'),
181
211
  publishedAt: doc.publishedAt ?? null,
@@ -216,7 +246,7 @@ export async function fetchTemplateForEditor(postType: string): Promise<EditorPo
216
246
  const r = res.data
217
247
  const docId = r.docId ?? null
218
248
  const header = coercePostHeader(r.header)
219
- let sections = coerceSections(r.sections)
249
+ let sections = coerceSections(r.sections, { onUnknown: 'preserve' })
220
250
  // Defend against an older server (or an empty stored template): an unsaved
221
251
  // type should still seed the default sections so authors aren't faced with a
222
252
  // blank canvas.
@@ -230,7 +260,10 @@ export async function fetchTemplateForEditor(postType: string): Promise<EditorPo
230
260
  export async function saveTemplate(template: EditorPostTemplate): Promise<EditorPostTemplate> {
231
261
  const res = await cmsApi<TemplateApiResponse>(templateEndpoint(template.postType), {
232
262
  method: 'PUT',
233
- body: JSON.stringify({ header: template.header, sections: template.sections }),
263
+ body: JSON.stringify({
264
+ header: template.header,
265
+ sections: template.sections.map(stripUnmanagedMarker),
266
+ }),
234
267
  })
235
268
  if (res.error || !res.data) throw new Error(res.error ?? 'Failed to save template')
236
269
  const r = res.data
@@ -238,7 +271,7 @@ export async function saveTemplate(template: EditorPostTemplate): Promise<Editor
238
271
  docId: r.docId ?? null,
239
272
  postType: template.postType,
240
273
  header: coercePostHeader(r.header),
241
- sections: coerceSections(r.sections),
274
+ sections: coerceSections(r.sections, { onUnknown: 'preserve' }),
242
275
  }
243
276
  }
244
277
 
@@ -263,6 +296,15 @@ interface PostWritePayload {
263
296
  metaDescription: string
264
297
  }
265
298
 
299
+ /**
300
+ * Strip the derived `unmanaged` marker before persisting — it is recomputed
301
+ * from the registry on read, so persisting it would only stale.
302
+ */
303
+ function stripUnmanagedMarker(section: PageSection): Omit<PageSection, 'unmanaged'> {
304
+ const { unmanaged: _unmanaged, ...rest } = section
305
+ return rest
306
+ }
307
+
266
308
  function toWriteBody(post: EditorPost, extra?: Record<string, unknown>): string {
267
309
  const payload: PostWritePayload & Record<string, unknown> = {
268
310
  title: post.title,
@@ -272,7 +314,9 @@ function toWriteBody(post: EditorPost, extra?: Record<string, unknown>): string
272
314
  body: post.body,
273
315
  category: post.category,
274
316
  publishDate: post.publishDate,
275
- sections: post.sections,
317
+ // Unmanaged sections are part of `post.sections`, so they round-trip on
318
+ // every save (no data loss).
319
+ sections: post.sections.map(stripUnmanagedMarker),
276
320
  metaTitle: post.seoTitle,
277
321
  metaDescription: post.seoDescription,
278
322
  ...extra,
@@ -318,7 +362,9 @@ export async function emptyPostFromTemplate(postType: string): Promise<EditorPos
318
362
  const template = await fetchTemplateForEditor(postType)
319
363
  // Clone with fresh ids so the post owns its own sections, decoupled from
320
364
  // the template (editing the post never mutates the type template).
321
- sections = template.sections.map((s) => ({ ...s, id: createSection(s.sectionType).id }))
365
+ // `generateSectionId` (not `createSection`) because preserved unmanaged
366
+ // template sections have no registered type and `createSection` throws.
367
+ sections = template.sections.map((s) => ({ ...s, id: generateSectionId() }))
322
368
  } catch {
323
369
  // No template / fetch failure → start from an empty section list.
324
370
  sections = []
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import * as Dialog from '@radix-ui/react-dialog'
4
+ import { adminPortalContainer } from '../lib/portal-container.js'
4
5
  import {
5
6
  Copy,
6
7
  KeyRound,
@@ -345,7 +346,7 @@ function CreateApiKeyDialog({ open, onClose, onCreated }: CreateApiKeyDialogProp
345
346
 
346
347
  return (
347
348
  <Dialog.Root open={open} onOpenChange={(o) => !o && onClose()}>
348
- <Dialog.Portal>
349
+ <Dialog.Portal container={adminPortalContainer()}>
349
350
  <Dialog.Overlay className="fixed inset-0 z-40 bg-black/40" />
350
351
  <Dialog.Content className="border-border bg-card fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border p-6 shadow-xl">
351
352
  <Dialog.Title className="text-card-foreground mb-4 flex items-center gap-2 text-lg font-medium">
@@ -493,7 +494,7 @@ function RevealKeyDialog({
493
494
  const [shown, setShown] = useState(false)
494
495
  return (
495
496
  <Dialog.Root open onOpenChange={(o) => !o && onClose()}>
496
- <Dialog.Portal>
497
+ <Dialog.Portal container={adminPortalContainer()}>
497
498
  <Dialog.Overlay className="fixed inset-0 z-40 bg-black/40" />
498
499
  <Dialog.Content className="border-border bg-card fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border p-6 shadow-xl">
499
500
  <Dialog.Title className="text-card-foreground mb-2 flex items-center gap-2 text-lg font-medium">
@@ -14,6 +14,7 @@ import {
14
14
  } from 'lucide-react'
15
15
  import { toast } from 'sonner'
16
16
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
17
+ import { adminPortalContainer } from '../../lib/portal-container.js'
17
18
  import {
18
19
  applyPagesQuery,
19
20
  bulkChangePageTags,
@@ -738,7 +739,7 @@ function TagMenu({
738
739
  <ChevronDown className="h-3 w-3" aria-hidden />
739
740
  </button>
740
741
  </DropdownMenu.Trigger>
741
- <DropdownMenu.Portal>
742
+ <DropdownMenu.Portal container={adminPortalContainer()}>
742
743
  <DropdownMenu.Content
743
744
  align="start"
744
745
  sideOffset={4}
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import * as Dialog from '@radix-ui/react-dialog'
4
+ import { adminPortalContainer } from '../lib/portal-container.js'
4
5
  import {
5
6
  Pencil,
6
7
  Plus,
@@ -278,7 +279,7 @@ export function Redirects({ onNavigate }: RedirectsProps) {
278
279
  )}
279
280
 
280
281
  <Dialog.Root open={showAddDialog} onOpenChange={setShowAddDialog}>
281
- <Dialog.Portal>
282
+ <Dialog.Portal container={adminPortalContainer()}>
282
283
  <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
283
284
  <Dialog.Content className="fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6 shadow-lg">
284
285
  <Dialog.Title className="mb-4 text-lg font-semibold text-gray-900">
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import * as Dialog from '@radix-ui/react-dialog'
4
+ import { adminPortalContainer } from '../lib/portal-container.js'
4
5
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
5
6
  import {
6
7
  Users as UsersIcon,
@@ -357,7 +358,7 @@ function RoleDropdown({
357
358
  <ChevronDown size={12} aria-hidden />
358
359
  </button>
359
360
  </DropdownMenu.Trigger>
360
- <DropdownMenu.Portal>
361
+ <DropdownMenu.Portal container={adminPortalContainer()}>
361
362
  <DropdownMenu.Content
362
363
  align="start"
363
364
  sideOffset={4}
@@ -483,7 +484,7 @@ function InviteModal({
483
484
  if (!v) reset()
484
485
  }}
485
486
  >
486
- <Dialog.Portal>
487
+ <Dialog.Portal container={adminPortalContainer()}>
487
488
  <Dialog.Overlay className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm" />
488
489
  <Dialog.Content className="border-border bg-card fixed top-1/2 left-1/2 z-50 max-h-[90vh] w-[calc(100vw-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-lg border p-6 shadow-lg focus:outline-none">
489
490
  <div className="mb-4 flex items-center justify-between">
@@ -1187,7 +1188,7 @@ function RowActions({
1187
1188
  <MoreHorizontal size={16} aria-hidden />
1188
1189
  </button>
1189
1190
  </DropdownMenu.Trigger>
1190
- <DropdownMenu.Portal>
1191
+ <DropdownMenu.Portal container={adminPortalContainer()}>
1191
1192
  <DropdownMenu.Content
1192
1193
  align="end"
1193
1194
  sideOffset={4}
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
4
4
  import * as Dialog from '@radix-ui/react-dialog'
5
+ import { adminPortalContainer } from '../../lib/portal-container.js'
5
6
  import {
6
7
  Star,
7
8
  Type,
@@ -126,7 +127,7 @@ export function BlockPicker({ open, onClose, onSelect }: BlockPickerProps) {
126
127
 
127
128
  return (
128
129
  <Dialog.Root open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
129
- <Dialog.Portal>
130
+ <Dialog.Portal container={adminPortalContainer()}>
130
131
  <Dialog.Overlay className="motion-safe:animate-in motion-safe:fade-in-0 fixed inset-0 z-50 bg-black/50" />
131
132
  <Dialog.Content
132
133
  className="bg-card border-border fixed top-1/2 left-1/2 z-50 flex max-h-[80vh] w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl border shadow-2xl"
@@ -58,8 +58,11 @@ interface PageSectionEditorProps {
58
58
 
59
59
  function deriveCan(session: any): { canEdit: boolean; canPublish: boolean } {
60
60
  const role: string | undefined = session?.user?.role
61
- // No session info (e.g. local dev) allow; otherwise gate by role.
62
- if (!role) return { canEdit: true, canPublish: true }
61
+ // Default closed: when we can't identify a role, hide the edit/publish
62
+ // affordances rather than exposing them. The server independently enforces
63
+ // access on every write, so this is a UX guard — but it should fail safe.
64
+ // (Matches the Post editor's behavior.)
65
+ if (!role) return { canEdit: false, canPublish: false }
63
66
  const editors = ['ADMIN', 'EDITOR', 'AUTHOR']
64
67
  const publishers = ['ADMIN', 'EDITOR']
65
68
  return { canEdit: editors.includes(role), canPublish: publishers.includes(role) }
@@ -28,6 +28,7 @@ import {
28
28
  } from '@dnd-kit/sortable'
29
29
  import { CSS } from '@dnd-kit/utilities'
30
30
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
31
+ import { adminPortalContainer } from '../../lib/portal-container.js'
31
32
  import type { PageSection } from '../../lib/page-editor-service.js'
32
33
  import { sectionIconFor } from './section-icons.js'
33
34
 
@@ -238,7 +239,7 @@ function SortableSectionItem({
238
239
  <MoreVertical className="h-4 w-4" aria-hidden />
239
240
  </button>
240
241
  </DropdownMenu.Trigger>
241
- <DropdownMenu.Portal>
242
+ <DropdownMenu.Portal container={adminPortalContainer()}>
242
243
  <DropdownMenu.Content
243
244
  align="end"
244
245
  sideOffset={4}
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useCallback, useEffect, useMemo, useState } from 'react'
4
- import { AlertTriangle, Loader2 } from 'lucide-react'
4
+ import { AlertTriangle, Loader2, Lock } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
6
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
7
7
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
@@ -13,6 +13,7 @@ import {
13
13
  emptyPostFromTemplate,
14
14
  fetchPostForEditor,
15
15
  fetchTemplateForEditor,
16
+ missingPostFields,
16
17
  moveSection,
17
18
  publishPost,
18
19
  removeSection,
@@ -123,12 +124,35 @@ export function PostSectionEditor({
123
124
  return col?.labels?.singular ?? postType
124
125
  }, [config, postType])
125
126
 
127
+ // A misconfigured post-type collection (missing the fields the editor
128
+ // writes) would silently drop content on save — detect it up front.
129
+ // Memoized to a string/null so the load effect can depend on it without
130
+ // re-running when `config`'s object identity changes between renders.
131
+ const schemaError = useMemo(() => {
132
+ const missing = missingPostFields(config, postType)
133
+ if (missing.length === 0) return null
134
+ const plural = missing.length > 1
135
+ return (
136
+ `The "${postType}" collection is missing required field${plural ? 's' : ''}: ` +
137
+ `${missing.join(', ')}. Declare ${plural ? 'them' : 'it'} in your CMS config ` +
138
+ `so the post editor can save without losing content.`
139
+ )
140
+ }, [config, postType])
141
+
126
142
  // ─── Load ──────────────────────────────────────────────────────────
127
143
  useEffect(() => {
128
144
  let cancelled = false
129
145
  setLoading(true)
130
146
  setLoadError(null)
131
147
 
148
+ // Fail loud on a misconfigured post-type collection rather than letting
149
+ // the user build a post whose content would be silently dropped on save.
150
+ if (schemaError) {
151
+ setLoadError(schemaError)
152
+ setLoading(false)
153
+ return
154
+ }
155
+
132
156
  // Always load the type template header (the post editor renders it but
133
157
  // never edits it — header design lives at the type level).
134
158
  fetchTemplateForEditor(postType)
@@ -160,7 +184,7 @@ export function PostSectionEditor({
160
184
  return () => {
161
185
  cancelled = true
162
186
  }
163
- }, [postType, documentId])
187
+ }, [postType, documentId, schemaError])
164
188
 
165
189
  // ─── Unsaved-changes guard ───────────────────────────────────────────
166
190
  useEffect(() => {
@@ -424,6 +448,12 @@ export function PostSectionEditor({
424
448
 
425
449
  const selected = post.sections.find((s) => s.id === selectedId) ?? null
426
450
 
451
+ // Sections whose type has no registered definition (seed/code-owned, e.g.
452
+ // `opal-*`). They are preserved verbatim on save but can't be edited here.
453
+ const unmanagedTypes = Array.from(
454
+ new Set(post.sections.filter((s) => s.unmanaged).map((s) => s.sectionType)),
455
+ )
456
+
427
457
  // Section types the canvas renders as placeholders — no built-in body
428
458
  // component and no consumer-registered admin renderer. When present the
429
459
  // editor surfaces "structure view" messaging and promotes "View on site"
@@ -460,6 +490,23 @@ export function PostSectionEditor({
460
490
  structuralTypes={structuralTypes}
461
491
  />
462
492
 
493
+ {unmanagedTypes.length > 0 && (
494
+ <div
495
+ role="status"
496
+ className="border-border bg-muted/50 flex items-start gap-3 border-b px-4 py-2.5"
497
+ >
498
+ <Lock className="text-muted-foreground mt-0.5 shrink-0" size={16} aria-hidden />
499
+ <p className="text-muted-foreground flex-1 text-sm">
500
+ This post has{' '}
501
+ <span className="text-foreground font-medium">
502
+ {unmanagedTypes.length} section type{unmanagedTypes.length > 1 ? 's' : ''}
503
+ </span>{' '}
504
+ managed outside the editor ({unmanagedTypes.join(', ')}). They are preserved on save
505
+ but edited in your seed script or code, not here.
506
+ </p>
507
+ </div>
508
+ )}
509
+
463
510
  {validation && (
464
511
  <ValidationSummary
465
512
  errors={validation.errors}
@@ -3,6 +3,7 @@
3
3
  import * as Dialog from '@radix-ui/react-dialog'
4
4
  import { AlertTriangle, Check, Loader2, Lock } from 'lucide-react'
5
5
  import { useId, type ReactNode } from 'react'
6
+ import { adminPortalContainer } from '../../lib/portal-container.js'
6
7
  import type { SettingsSourceMetadata, ValidationIssue } from './useGeneralSettings.js'
7
8
 
8
9
  /**
@@ -290,7 +291,7 @@ export function ConfirmDangerousSettingDialog({
290
291
  }) {
291
292
  return (
292
293
  <Dialog.Root open={open} onOpenChange={(o) => !o && onCancel()}>
293
- <Dialog.Portal>
294
+ <Dialog.Portal container={adminPortalContainer()}>
294
295
  <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
295
296
  <Dialog.Content className="bg-card text-card-foreground fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg p-6 shadow-lg">
296
297
  <div className="mb-3 flex items-center gap-2">