@actuate-media/cms-admin 0.67.0 → 0.68.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 (146) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +29 -5
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
  6. package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
  7. package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
  8. package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
  9. package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
  10. package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/post-types-service.test.js +88 -0
  12. package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
  13. package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
  14. package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
  15. package/dist/__tests__/views/forms-list.render.test.js +25 -6
  16. package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
  17. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
  18. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
  19. package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
  20. package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
  21. package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
  22. package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
  23. package/dist/__tests__/views/page-templates.render.test.js +105 -0
  24. package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
  25. package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
  26. package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
  27. package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
  28. package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
  29. package/dist/__tests__/views/security-settings.render.test.js +85 -4
  30. package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
  31. package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
  32. package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
  33. package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
  34. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
  35. package/dist/actuate-admin.css +1 -1
  36. package/dist/components/collections/EntryDetailPane.d.ts +10 -0
  37. package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
  38. package/dist/components/collections/EntryDetailPane.js +92 -0
  39. package/dist/components/collections/EntryDetailPane.js.map +1 -0
  40. package/dist/components/forms/FieldsPanel.js +1 -1
  41. package/dist/components/forms/FieldsPanel.js.map +1 -1
  42. package/dist/components/forms/FormSchemaPane.d.ts +8 -0
  43. package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
  44. package/dist/components/forms/FormSchemaPane.js +95 -0
  45. package/dist/components/forms/FormSchemaPane.js.map +1 -0
  46. package/dist/components/seo/SeoEditorPane.d.ts +9 -0
  47. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
  48. package/dist/components/seo/SeoEditorPane.js +175 -0
  49. package/dist/components/seo/SeoEditorPane.js.map +1 -0
  50. package/dist/components/ui/Badge.d.ts +1 -1
  51. package/dist/components/ui/Button.d.ts +1 -1
  52. package/dist/components/ui/StatusBadge.d.ts +1 -1
  53. package/dist/hooks/useIdleLogout.d.ts +18 -0
  54. package/dist/hooks/useIdleLogout.d.ts.map +1 -0
  55. package/dist/hooks/useIdleLogout.js +66 -0
  56. package/dist/hooks/useIdleLogout.js.map +1 -0
  57. package/dist/lib/api.d.ts +1 -0
  58. package/dist/lib/api.d.ts.map +1 -1
  59. package/dist/lib/api.js +7 -1
  60. package/dist/lib/api.js.map +1 -1
  61. package/dist/lib/post-types-service.d.ts +44 -0
  62. package/dist/lib/post-types-service.d.ts.map +1 -0
  63. package/dist/lib/post-types-service.js +83 -0
  64. package/dist/lib/post-types-service.js.map +1 -0
  65. package/dist/views/CollectionList.d.ts.map +1 -1
  66. package/dist/views/CollectionList.js +23 -4
  67. package/dist/views/CollectionList.js.map +1 -1
  68. package/dist/views/Forms.d.ts.map +1 -1
  69. package/dist/views/Forms.js +25 -21
  70. package/dist/views/Forms.js.map +1 -1
  71. package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
  72. package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
  73. package/dist/views/Posts/PostTypeEditor.js +159 -0
  74. package/dist/views/Posts/PostTypeEditor.js.map +1 -0
  75. package/dist/views/Posts/PostTypesView.d.ts +3 -2
  76. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  77. package/dist/views/Posts/PostTypesView.js +1 -1
  78. package/dist/views/Posts/PostTypesView.js.map +1 -1
  79. package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
  80. package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
  81. package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
  82. package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
  83. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  84. package/dist/views/page-builder/PageBuilder.js +51 -2
  85. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  86. package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
  87. package/dist/views/page-builder/PageTemplates.js +113 -6
  88. package/dist/views/page-builder/PageTemplates.js.map +1 -1
  89. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  90. package/dist/views/seo/ContentTab.js +22 -6
  91. package/dist/views/seo/ContentTab.js.map +1 -1
  92. package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
  93. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
  94. package/dist/views/settings/AccessProtectionCard.js +13 -10
  95. package/dist/views/settings/AccessProtectionCard.js.map +1 -1
  96. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  97. package/dist/views/settings/SecurityTab.js +3 -3
  98. package/dist/views/settings/SecurityTab.js.map +1 -1
  99. package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
  100. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
  101. package/dist/views/settings/SessionPolicyCard.js +12 -5
  102. package/dist/views/settings/SessionPolicyCard.js.map +1 -1
  103. package/dist/views/settings/useSecuritySettings.d.ts +13 -1
  104. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
  105. package/dist/views/settings/useSecuritySettings.js +47 -4
  106. package/dist/views/settings/useSecuritySettings.js.map +1 -1
  107. package/package.json +2 -2
  108. package/src/AdminRoot.tsx +36 -7
  109. package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
  110. package/src/__tests__/lib/post-types-service.test.ts +112 -0
  111. package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
  112. package/src/__tests__/views/forms-list.render.test.tsx +34 -6
  113. package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
  114. package/src/__tests__/views/page-templates.render.test.tsx +142 -0
  115. package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
  116. package/src/__tests__/views/security-settings.render.test.tsx +111 -4
  117. package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
  118. package/src/components/collections/EntryDetailPane.tsx +182 -0
  119. package/src/components/forms/FieldsPanel.tsx +3 -1
  120. package/src/components/forms/FormSchemaPane.tsx +192 -0
  121. package/src/components/seo/SeoEditorPane.tsx +533 -0
  122. package/src/hooks/useIdleLogout.ts +77 -0
  123. package/src/lib/api.ts +8 -2
  124. package/src/lib/post-types-service.ts +129 -0
  125. package/src/views/CollectionList.tsx +158 -110
  126. package/src/views/Forms.tsx +154 -126
  127. package/src/views/Posts/PostTypeEditor.tsx +568 -0
  128. package/src/views/Posts/PostTypesView.tsx +5 -4
  129. package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
  130. package/src/views/page-builder/PageBuilder.tsx +69 -2
  131. package/src/views/page-builder/PageTemplates.tsx +340 -57
  132. package/src/views/seo/ContentTab.tsx +113 -80
  133. package/src/views/settings/AccessProtectionCard.tsx +83 -53
  134. package/src/views/settings/SecurityTab.tsx +23 -2
  135. package/src/views/settings/SessionPolicyCard.tsx +45 -9
  136. package/src/views/settings/useSecuritySettings.ts +78 -22
  137. package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
  138. package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
  139. package/dist/components/forms/FormSchemaDrawer.js +0 -99
  140. package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
  141. package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
  142. package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
  143. package/dist/components/seo/SeoEditorDrawer.js +0 -172
  144. package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
  145. package/src/components/forms/FormSchemaDrawer.tsx +0 -192
  146. package/src/components/seo/SeoEditorDrawer.tsx +0 -529
@@ -1,7 +1,13 @@
1
1
  'use client'
2
2
 
3
+ /**
4
+ * Content SEO tab — pages/posts list with per-item SEO editing in an inline
5
+ * 380px inspector pane (design split-pane pattern; previously an overlay
6
+ * drawer). Row click toggles the pane; deep links
7
+ * (`?tab=content&entityType=…&entityId=…`) open it on mount.
8
+ */
3
9
  import { useMemo, useState } from 'react'
4
- import { Search, FileText, Pencil } from 'lucide-react'
10
+ import { Search, FileText } from 'lucide-react'
5
11
  import {
6
12
  fetchSeoContentRecords,
7
13
  applyContentSeoQuery,
@@ -15,7 +21,8 @@ import {
15
21
  ScorePill,
16
22
  SeoStatusBadge,
17
23
  } from '../../components/seo/primitives.js'
18
- import { SeoEditorDrawer } from '../../components/seo/SeoEditorDrawer.js'
24
+ import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../../components/ui/InspectorPane.js'
25
+ import { SeoEditorPane } from '../../components/seo/SeoEditorPane.js'
19
26
  import { useSeoResource } from './useSeoResource.js'
20
27
 
21
28
  const FILTERS: { id: ContentSeoFilter; label: string }[] = [
@@ -27,6 +34,8 @@ const FILTERS: { id: ContentSeoFilter; label: string }[] = [
27
34
  const inputCls =
28
35
  'w-full rounded-md border border-border bg-input-background py-2 pl-9 pr-3 text-base text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
29
36
 
37
+ type Selection = { type: string; id: string } | null
38
+
30
39
  export function ContentTab({
31
40
  initialEntityType,
32
41
  initialEntityId,
@@ -37,7 +46,7 @@ export function ContentTab({
37
46
  const { data, loading, error, refetch } = useSeoResource(fetchSeoContentRecords, [])
38
47
  const [search, setSearch] = useState('')
39
48
  const [filter, setFilter] = useState<ContentSeoFilter>('all')
40
- const [editor, setEditor] = useState<{ type: string; id: string } | null>(
49
+ const [selection, setSelection] = useState<Selection>(
41
50
  initialEntityType && initialEntityId ? { type: initialEntityType, id: initialEntityId } : null,
42
51
  )
43
52
 
@@ -46,6 +55,10 @@ export function ContentTab({
46
55
  [data, search, filter],
47
56
  )
48
57
 
58
+ const toggleRow = (type: string, id: string) => {
59
+ setSelection((prev) => (prev?.type === type && prev.id === id ? null : { type, id }))
60
+ }
61
+
49
62
  return (
50
63
  <div className="space-y-4">
51
64
  <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -81,84 +94,104 @@ export function ContentTab({
81
94
  </div>
82
95
  </div>
83
96
 
84
- <SectionCard>
85
- {loading ? (
86
- <SeoLoading label="Loading content…" />
87
- ) : error ? (
88
- <SeoErrorState message={error} onRetry={refetch} />
89
- ) : rows.length === 0 ? (
90
- <SeoEmptyState
91
- icon={<FileText size={24} />}
92
- title={search || filter !== 'all' ? 'No matching content' : 'No content yet'}
93
- description={
94
- search || filter !== 'all'
95
- ? 'Try adjusting your search or filter.'
96
- : 'Publish pages or posts to manage their SEO here.'
97
- }
98
- />
99
- ) : (
100
- <div className="overflow-x-auto">
101
- <table className="w-full text-sm" aria-label="Content SEO">
102
- <thead>
103
- <tr className="text-muted-foreground border-border border-b text-left">
104
- <th className="py-2 pr-4 font-medium">Title</th>
105
- <th className="py-2 pr-4 font-medium">Type</th>
106
- <th className="py-2 pr-4 font-medium">Meta</th>
107
- <th className="py-2 pr-4 text-right font-medium">Score</th>
108
- <th className="py-2 text-right font-medium">
109
- <span className="sr-only">Actions</span>
110
- </th>
111
- </tr>
112
- </thead>
113
- <tbody className="divide-border divide-y">
114
- {rows.map((r) => (
115
- <tr key={`${r.entityType}-${r.entityId}`} className="hover:bg-accent/40">
116
- <td className="py-2.5 pr-4">
117
- <span className="text-foreground block font-medium">{r.title}</span>
118
- <span className="text-muted-foreground block truncate">{r.url}</span>
119
- </td>
120
- <td className="py-2.5 pr-4 capitalize">
121
- <span className="text-muted-foreground">{r.entityType}</span>
122
- </td>
123
- <td className="py-2.5 pr-4">
124
- <span className="flex flex-wrap gap-1.5">
125
- <SeoStatusBadge
126
- label={r.metaTitle ? 'Title' : 'No title'}
127
- tone={r.metaTitle ? 'good' : 'critical'}
128
- />
129
- <SeoStatusBadge
130
- label={r.metaDescription ? 'Desc' : 'No desc'}
131
- tone={r.metaDescription ? 'good' : 'critical'}
132
- />
133
- {r.noindex && <SeoStatusBadge label="No-index" tone="fair" />}
134
- </span>
135
- </td>
136
- <td className="py-2.5 pr-4 text-right">
137
- <ScorePill score={r.seoScore} />
138
- </td>
139
- <td className="py-2.5 text-right">
140
- <button
141
- onClick={() => setEditor({ type: r.entityType, id: r.entityId })}
142
- className="text-primary hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm focus-visible:ring-2 focus-visible:outline-none"
143
- >
144
- <Pencil className="h-3.5 w-3.5" aria-hidden /> Edit
145
- </button>
146
- </td>
147
- </tr>
148
- ))}
149
- </tbody>
150
- </table>
151
- </div>
152
- )}
153
- </SectionCard>
97
+ {/* The SEO page scrolls (tabbed layout), so the split area takes a
98
+ viewport-derived height to keep the pane's body/footer independently
99
+ scrollable, matching the design's fixed-height content region. */}
100
+ <SplitPaneLayout className="h-[calc(100vh-280px)] min-h-[440px]">
101
+ <SplitPaneList>
102
+ <SectionCard>
103
+ {loading ? (
104
+ <SeoLoading label="Loading content…" />
105
+ ) : error ? (
106
+ <SeoErrorState message={error} onRetry={refetch} />
107
+ ) : rows.length === 0 ? (
108
+ <SeoEmptyState
109
+ icon={<FileText size={24} />}
110
+ title={search || filter !== 'all' ? 'No matching content' : 'No content yet'}
111
+ description={
112
+ search || filter !== 'all'
113
+ ? 'Try adjusting your search or filter.'
114
+ : 'Publish pages or posts to manage their SEO here.'
115
+ }
116
+ />
117
+ ) : (
118
+ <div className="overflow-x-auto">
119
+ <table className="w-full text-sm" aria-label="Content SEO">
120
+ <thead>
121
+ <tr className="text-muted-foreground border-border border-b text-left">
122
+ <th className="py-2 pr-4 font-medium">Title</th>
123
+ <th className="py-2 pr-4 font-medium">Type</th>
124
+ <th className="py-2 pr-4 font-medium">Meta</th>
125
+ <th className="py-2 text-right font-medium">Score</th>
126
+ </tr>
127
+ </thead>
128
+ <tbody className="divide-border divide-y">
129
+ {rows.map((r) => {
130
+ const isSelected =
131
+ selection?.type === r.entityType && selection.id === r.entityId
132
+ return (
133
+ <tr
134
+ key={`${r.entityType}-${r.entityId}`}
135
+ className={`cursor-pointer transition-colors ${
136
+ isSelected ? 'bg-[var(--acc-l)]' : 'hover:bg-accent/40'
137
+ }`}
138
+ onClick={() => toggleRow(r.entityType, r.entityId)}
139
+ >
140
+ <td className="py-2.5 pr-4">
141
+ <button
142
+ type="button"
143
+ onClick={(e) => {
144
+ e.stopPropagation()
145
+ toggleRow(r.entityType, r.entityId)
146
+ }}
147
+ aria-expanded={isSelected}
148
+ className="text-left"
149
+ >
150
+ <span className="text-foreground block font-medium">{r.title}</span>
151
+ <span className="text-muted-foreground block truncate">{r.url}</span>
152
+ </button>
153
+ </td>
154
+ <td className="py-2.5 pr-4 capitalize">
155
+ <span className="text-muted-foreground">{r.entityType}</span>
156
+ </td>
157
+ <td className="py-2.5 pr-4">
158
+ <span className="flex flex-wrap gap-1.5">
159
+ <SeoStatusBadge
160
+ label={r.metaTitle ? 'Title' : 'No title'}
161
+ tone={r.metaTitle ? 'good' : 'critical'}
162
+ />
163
+ <SeoStatusBadge
164
+ label={r.metaDescription ? 'Desc' : 'No desc'}
165
+ tone={r.metaDescription ? 'good' : 'critical'}
166
+ />
167
+ {r.noindex && <SeoStatusBadge label="No-index" tone="fair" />}
168
+ </span>
169
+ </td>
170
+ <td className="py-2.5 text-right">
171
+ <ScorePill score={r.seoScore} />
172
+ </td>
173
+ </tr>
174
+ )
175
+ })}
176
+ </tbody>
177
+ </table>
178
+ </div>
179
+ )}
180
+ </SectionCard>
181
+ </SplitPaneList>
154
182
 
155
- <SeoEditorDrawer
156
- open={editor !== null}
157
- onOpenChange={(o) => !o && setEditor(null)}
158
- entityType={editor?.type ?? null}
159
- entityId={editor?.id ?? null}
160
- onSaved={refetch}
161
- />
183
+ <InspectorPane open={selection !== null} width={380} aria-label="SEO editor">
184
+ {selection && (
185
+ <SeoEditorPane
186
+ key={`${selection.type}-${selection.id}`}
187
+ entityType={selection.type}
188
+ entityId={selection.id}
189
+ onClose={() => setSelection(null)}
190
+ onSaved={refetch}
191
+ />
192
+ )}
193
+ </InspectorPane>
194
+ </SplitPaneLayout>
162
195
  </div>
163
196
  )
164
197
  }
@@ -1,17 +1,21 @@
1
1
  'use client'
2
2
 
3
- import { useId, useState } from 'react'
4
- import { Check, X } from 'lucide-react'
3
+ import { useId } from 'react'
4
+ import { AlertTriangle } from 'lucide-react'
5
5
  import {
6
6
  validateIpRange,
7
7
  type IpAllowlistStatus,
8
8
  type SecretsStatus,
9
9
  type SecuritySourceMetadata,
10
10
  } from '@actuate-media/cms-core/security-center'
11
- import { SettingsSourceBadge } from './components.js'
11
+ import { SettingsSourceBadge, SettingsToggleRow } from './components.js'
12
12
  import { SecurityCard, StatusRow } from './securityPrimitives.js'
13
+ import { parseAllowlistText, type SecurityForm } from './useSecuritySettings.js'
13
14
 
14
15
  export function AccessProtectionCard({
16
+ form,
17
+ setField,
18
+ canEdit,
15
19
  ipAllowlist,
16
20
  secrets,
17
21
  csrfEnabled,
@@ -19,6 +23,9 @@ export function AccessProtectionCard({
19
23
  sources,
20
24
  onNavigate,
21
25
  }: {
26
+ form: SecurityForm
27
+ setField: <K extends keyof SecurityForm>(key: K, value: SecurityForm[K]) => void
28
+ canEdit: boolean
22
29
  ipAllowlist: IpAllowlistStatus
23
30
  secrets: SecretsStatus
24
31
  csrfEnabled: boolean
@@ -27,30 +34,80 @@ export function AccessProtectionCard({
27
34
  onNavigate?: (path: string) => void
28
35
  }) {
29
36
  const headingId = useId()
30
- const ipTestId = useId()
31
- const [ipTest, setIpTest] = useState('')
32
- const ipTestValid = ipTest.trim() === '' ? null : validateIpRange(ipTest)
37
+ const allowlistId = useId()
38
+ const allowlistHintId = useId()
39
+
40
+ const entries = parseAllowlistText(form.ipAllowlistText)
41
+ const invalidEntries = entries.filter((entry) => !validateIpRange(entry))
42
+ const configRanges = ipAllowlist.configRanges ?? []
33
43
 
34
44
  const distributed = secrets.rateLimitBackend === 'distributed'
35
45
 
36
46
  return (
37
47
  <SecurityCard
38
48
  headingId={headingId}
39
- title="Access & Request Protection"
40
- description="Network-level access restrictions and request-level protections. These are enforced by the platform; edit network rules in config."
49
+ title="Access Control"
50
+ description="Restrict where the admin and CMS API can be reached from, and control the audit trail."
41
51
  >
42
- {/* ── Access restrictions ─────────────────────────────────────────── */}
52
+ {/* ── Allowed IP addresses ─────────────────────────────────────────── */}
43
53
  <div className="space-y-2">
44
54
  <div className="flex items-center justify-between gap-2">
45
- <span className="text-foreground text-sm font-medium">Admin IP allowlist</span>
55
+ <label htmlFor={allowlistId} className="text-foreground text-base font-medium">
56
+ Allowed IP addresses
57
+ </label>
46
58
  <SettingsSourceBadge meta={sources.ipAllowlist} />
47
59
  </div>
48
- <StatusRow
49
- status={ipAllowlist.enabled ? 'pass' : 'unknown'}
50
- label="Status"
51
- value={ipAllowlist.enabled ? `${ipAllowlist.ranges.length} range(s)` : 'Not configured'}
60
+ <textarea
61
+ id={allowlistId}
62
+ rows={3}
63
+ value={form.ipAllowlistText}
64
+ disabled={!canEdit}
65
+ onChange={(e) => setField('ipAllowlistText', e.target.value)}
66
+ placeholder={'203.0.113.0/24\n198.51.100.42'}
67
+ aria-describedby={allowlistHintId}
68
+ aria-invalid={invalidEntries.length > 0 || undefined}
69
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full resize-y rounded-md border px-3 py-2 font-mono text-sm focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
52
70
  />
53
- {ipAllowlist.currentIp && (
71
+ <p id={allowlistHintId} className="text-muted-foreground text-sm">
72
+ Restrict admin access to these ranges. One IP or CIDR range per line; leave empty to allow
73
+ all addresses.
74
+ </p>
75
+ {invalidEntries.length > 0 && (
76
+ <p className="text-destructive text-sm" role="alert">
77
+ Invalid entries: {invalidEntries.join(', ')}
78
+ </p>
79
+ )}
80
+ {entries.length > 0 && (
81
+ <div className="border-warning/30 bg-warning/10 text-warning flex items-start gap-2 rounded-md border p-2.5 text-sm">
82
+ <AlertTriangle size={15} className="mt-0.5 shrink-0" aria-hidden="true" />
83
+ <span>
84
+ Make sure your own IP
85
+ {ipAllowlist.currentIp ? (
86
+ <>
87
+ {' '}
88
+ (<span className="font-mono">{ipAllowlist.currentIp}</span>)
89
+ </>
90
+ ) : null}{' '}
91
+ is covered — saving a list that excludes it will lock you out of the admin.
92
+ </span>
93
+ </div>
94
+ )}
95
+ {configRanges.length > 0 && (
96
+ <div className="pt-1">
97
+ <p className="text-muted-foreground text-sm">
98
+ Always enforced from <code className="font-mono">admin.ipAllowlist</code> in
99
+ actuate.config.ts:
100
+ </p>
101
+ <div className="text-muted-foreground mt-1 flex flex-wrap gap-1.5">
102
+ {configRanges.map((r) => (
103
+ <code key={r} className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">
104
+ {r}
105
+ </code>
106
+ ))}
107
+ </div>
108
+ </div>
109
+ )}
110
+ {ipAllowlist.currentIp && ipAllowlist.enabled && (
54
111
  <StatusRow
55
112
  status={
56
113
  ipAllowlist.currentIpAllowed === null
@@ -68,44 +125,17 @@ export function AccessProtectionCard({
68
125
  }
69
126
  />
70
127
  )}
71
- {ipAllowlist.enabled && (
72
- <div className="text-muted-foreground flex flex-wrap gap-1.5 pt-1">
73
- {ipAllowlist.ranges.map((r) => (
74
- <code key={r} className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">
75
- {r}
76
- </code>
77
- ))}
78
- </div>
79
- )}
80
- <div className="pt-1">
81
- <label htmlFor={ipTestId} className="text-muted-foreground mb-1 block text-sm">
82
- Validate an IP or CIDR range
83
- </label>
84
- <div className="flex items-center gap-2">
85
- <input
86
- id={ipTestId}
87
- type="text"
88
- value={ipTest}
89
- onChange={(e) => setIpTest(e.target.value)}
90
- placeholder="e.g. 203.0.113.0/24"
91
- className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full max-w-xs rounded-md border px-3 py-1.5 font-mono text-sm focus-visible:ring-2 focus-visible:outline-none"
92
- />
93
- {ipTestValid === true && (
94
- <span className="text-success flex items-center gap-1 text-sm">
95
- <Check size={15} aria-hidden="true" /> Valid
96
- </span>
97
- )}
98
- {ipTestValid === false && (
99
- <span className="text-destructive flex items-center gap-1 text-sm">
100
- <X size={15} aria-hidden="true" /> Invalid
101
- </span>
102
- )}
103
- </div>
104
- <p className="text-muted-foreground mt-1 text-xs">
105
- Add validated ranges to <code className="font-mono">admin.ipAllowlist</code> in
106
- actuate.config.ts; the middleware enforces them.
107
- </p>
108
- </div>
128
+ </div>
129
+
130
+ {/* ── Audit logging ────────────────────────────────────────────────── */}
131
+ <div className="border-border mt-4 border-t pt-4">
132
+ <SettingsToggleRow
133
+ label="Audit logging"
134
+ description="Record every content change, login and settings update. Security-critical events (failed logins, MFA and security policy changes) are always recorded."
135
+ checked={form.auditLoggingEnabled}
136
+ disabled={!canEdit}
137
+ onChange={(value) => setField('auditLoggingEnabled', value)}
138
+ />
109
139
  </div>
110
140
 
111
141
  {/* ── Request protection ──────────────────────────────────────────── */}
@@ -2,7 +2,11 @@
2
2
 
3
3
  import { useEffect } from 'react'
4
4
  import { Loader2, ShieldAlert } from 'lucide-react'
5
- import { SettingsSaveBar, SettingsValidationSummary } from './components.js'
5
+ import {
6
+ ConfirmDangerousSettingDialog,
7
+ SettingsSaveBar,
8
+ SettingsValidationSummary,
9
+ } from './components.js'
6
10
  import { AccessProtectionCard } from './AccessProtectionCard.js'
7
11
  import { AuthMfaCard } from './AuthMfaCard.js'
8
12
  import { SecretsAuditCard } from './SecretsAuditCard.js'
@@ -43,6 +47,8 @@ export function SecurityTab({
43
47
  saveState,
44
48
  save,
45
49
  reset,
50
+ lockoutPrompt,
51
+ dismissLockoutPrompt,
46
52
  revokeState,
47
53
  revokeAllOtherSessions,
48
54
  } = useSecuritySettings(canEdit)
@@ -114,7 +120,6 @@ export function SecurityTab({
114
120
  form={form}
115
121
  setField={setField}
116
122
  canEdit={canEdit}
117
- sessionMaxAgeHours={data.sessionMaxAgeHours}
118
123
  activeSessions={data.activeSessions}
119
124
  revokeState={revokeState}
120
125
  onRevokeAll={() => void revokeAllOtherSessions()}
@@ -123,6 +128,9 @@ export function SecurityTab({
123
128
 
124
129
  <div id="security-ip-allowlist">
125
130
  <AccessProtectionCard
131
+ form={form}
132
+ setField={setField}
133
+ canEdit={canEdit}
126
134
  ipAllowlist={data.ipAllowlist}
127
135
  secrets={data.secrets}
128
136
  csrfEnabled={data.csrfEnabled}
@@ -150,6 +158,19 @@ export function SecurityTab({
150
158
  </p>
151
159
  )}
152
160
 
161
+ {/* Server-side IP lockout guard: the save was rejected because the new
162
+ allowlist excludes the caller's own IP. Confirm to save anyway. */}
163
+ <ConfirmDangerousSettingDialog
164
+ open={lockoutPrompt !== null}
165
+ title="This allowlist may lock you out"
166
+ description={
167
+ lockoutPrompt ?? 'Your current IP is not covered by the allowlist you are about to save.'
168
+ }
169
+ confirmLabel="Save anyway"
170
+ onConfirm={() => void save({ confirmLockout: true })}
171
+ onCancel={dismissLockoutPrompt}
172
+ />
173
+
153
174
  <SettingsSaveBar
154
175
  dirty={dirty}
155
176
  canEdit={canEdit}
@@ -2,8 +2,11 @@
2
2
 
3
3
  import { useId, useState } from 'react'
4
4
  import { Loader2 } from 'lucide-react'
5
- import { MAX_CONCURRENT_SESSION_OPTIONS } from '@actuate-media/cms-core/security-center'
6
- import { ConfirmDangerousSettingDialog } from './components.js'
5
+ import {
6
+ MAX_CONCURRENT_SESSION_OPTIONS,
7
+ SESSION_TIMEOUT_HOURS_OPTIONS,
8
+ } from '@actuate-media/cms-core/security-center'
9
+ import { ConfirmDangerousSettingDialog, SettingsToggleRow } from './components.js'
7
10
  import { SecurityCard, StatusRow } from './securityPrimitives.js'
8
11
  import type { SecurityForm } from './useSecuritySettings.js'
9
12
 
@@ -11,11 +14,16 @@ function sessionsLabel(n: number): string {
11
14
  return n === 0 ? 'Unlimited' : String(n)
12
15
  }
13
16
 
17
+ function timeoutLabel(hours: number): string {
18
+ if (hours === 1) return '1 hour'
19
+ if (hours === 168) return '7 days'
20
+ return `${hours} hours`
21
+ }
22
+
14
23
  export function SessionPolicyCard({
15
24
  form,
16
25
  setField,
17
26
  canEdit,
18
- sessionMaxAgeHours,
19
27
  activeSessions,
20
28
  revokeState,
21
29
  onRevokeAll,
@@ -23,24 +31,52 @@ export function SessionPolicyCard({
23
31
  form: SecurityForm
24
32
  setField: <K extends keyof SecurityForm>(key: K, value: SecurityForm[K]) => void
25
33
  canEdit: boolean
26
- sessionMaxAgeHours: number
27
34
  activeSessions: number
28
35
  revokeState: 'idle' | 'working' | 'done' | 'error'
29
36
  onRevokeAll: () => void
30
37
  }) {
31
38
  const headingId = useId()
32
39
  const maxId = useId()
40
+ const timeoutId = useId()
33
41
  const [confirmRevoke, setConfirmRevoke] = useState(false)
34
42
 
35
- const maxAgeDays = Math.round(sessionMaxAgeHours / 24)
36
-
37
43
  return (
38
44
  <SecurityCard
39
45
  headingId={headingId}
40
- title="Session Policy"
41
- description="Control how many sessions an account can hold and revoke active sessions."
46
+ title="Sessions"
47
+ description="Control session lifetime, idle sign-out, and how many sessions an account can hold."
42
48
  >
43
49
  <div className="space-y-4">
50
+ <div className="flex items-center justify-between gap-4">
51
+ <label htmlFor={timeoutId} className="flex-1">
52
+ <span className="text-foreground text-base font-medium">Session timeout</span>
53
+ <p className="text-muted-foreground mt-0.5 text-sm">
54
+ Members are signed out automatically after this period of inactivity.
55
+ </p>
56
+ </label>
57
+ <select
58
+ id={timeoutId}
59
+ value={form.timeoutHours}
60
+ disabled={!canEdit}
61
+ onChange={(e) => setField('timeoutHours', Number(e.target.value))}
62
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
63
+ >
64
+ {SESSION_TIMEOUT_HOURS_OPTIONS.map((h) => (
65
+ <option key={h} value={h}>
66
+ {timeoutLabel(h)}
67
+ </option>
68
+ ))}
69
+ </select>
70
+ </div>
71
+
72
+ <SettingsToggleRow
73
+ label="Idle auto-logout"
74
+ description="End the session after the timeout above when the tab is inactive."
75
+ checked={form.idleLogoutEnabled}
76
+ disabled={!canEdit}
77
+ onChange={(value) => setField('idleLogoutEnabled', value)}
78
+ />
79
+
44
80
  <div className="flex items-center justify-between gap-4">
45
81
  <label htmlFor={maxId} className="flex-1">
46
82
  <span className="text-foreground text-base font-medium">
@@ -72,7 +108,7 @@ export function SessionPolicyCard({
72
108
  <StatusRow
73
109
  status="pass"
74
110
  label="Maximum session age"
75
- value={`${maxAgeDays} day${maxAgeDays === 1 ? '' : 's'}`}
111
+ value={timeoutLabel(form.timeoutHours)}
76
112
  />
77
113
  <StatusRow status="pass" label="Revoke sessions on password change" value="Enabled" />
78
114
  </div>