@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.
- package/CHANGELOG.md +16 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +29 -5
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.js +88 -0
- package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
- package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
- package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.js +25 -6
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.js +105 -0
- package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
- package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.js +85 -4
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/collections/EntryDetailPane.d.ts +10 -0
- package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
- package/dist/components/collections/EntryDetailPane.js +92 -0
- package/dist/components/collections/EntryDetailPane.js.map +1 -0
- package/dist/components/forms/FieldsPanel.js +1 -1
- package/dist/components/forms/FieldsPanel.js.map +1 -1
- package/dist/components/forms/FormSchemaPane.d.ts +8 -0
- package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaPane.js +95 -0
- package/dist/components/forms/FormSchemaPane.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +9 -0
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorPane.js +175 -0
- package/dist/components/seo/SeoEditorPane.js.map +1 -0
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Button.d.ts +1 -1
- package/dist/components/ui/StatusBadge.d.ts +1 -1
- package/dist/hooks/useIdleLogout.d.ts +18 -0
- package/dist/hooks/useIdleLogout.d.ts.map +1 -0
- package/dist/hooks/useIdleLogout.js +66 -0
- package/dist/hooks/useIdleLogout.js.map +1 -0
- package/dist/lib/api.d.ts +1 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +7 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/post-types-service.d.ts +44 -0
- package/dist/lib/post-types-service.d.ts.map +1 -0
- package/dist/lib/post-types-service.js +83 -0
- package/dist/lib/post-types-service.js.map +1 -0
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +23 -4
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +25 -21
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
- package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
- package/dist/views/Posts/PostTypeEditor.js +159 -0
- package/dist/views/Posts/PostTypeEditor.js.map +1 -0
- package/dist/views/Posts/PostTypesView.d.ts +3 -2
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +51 -2
- package/dist/views/page-builder/PageBuilder.js.map +1 -1
- package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
- package/dist/views/page-builder/PageTemplates.js +113 -6
- package/dist/views/page-builder/PageTemplates.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +22 -6
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
- package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
- package/dist/views/settings/AccessProtectionCard.js +13 -10
- package/dist/views/settings/AccessProtectionCard.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +3 -3
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
- package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
- package/dist/views/settings/SessionPolicyCard.js +12 -5
- package/dist/views/settings/SessionPolicyCard.js.map +1 -1
- package/dist/views/settings/useSecuritySettings.d.ts +13 -1
- package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
- package/dist/views/settings/useSecuritySettings.js +47 -4
- package/dist/views/settings/useSecuritySettings.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +36 -7
- package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
- package/src/__tests__/lib/post-types-service.test.ts +112 -0
- package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
- package/src/__tests__/views/forms-list.render.test.tsx +34 -6
- package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
- package/src/__tests__/views/page-templates.render.test.tsx +142 -0
- package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
- package/src/__tests__/views/security-settings.render.test.tsx +111 -4
- package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
- package/src/components/collections/EntryDetailPane.tsx +182 -0
- package/src/components/forms/FieldsPanel.tsx +3 -1
- package/src/components/forms/FormSchemaPane.tsx +192 -0
- package/src/components/seo/SeoEditorPane.tsx +533 -0
- package/src/hooks/useIdleLogout.ts +77 -0
- package/src/lib/api.ts +8 -2
- package/src/lib/post-types-service.ts +129 -0
- package/src/views/CollectionList.tsx +158 -110
- package/src/views/Forms.tsx +154 -126
- package/src/views/Posts/PostTypeEditor.tsx +568 -0
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
- package/src/views/page-builder/PageBuilder.tsx +69 -2
- package/src/views/page-builder/PageTemplates.tsx +340 -57
- package/src/views/seo/ContentTab.tsx +113 -80
- package/src/views/settings/AccessProtectionCard.tsx +83 -53
- package/src/views/settings/SecurityTab.tsx +23 -2
- package/src/views/settings/SessionPolicyCard.tsx +45 -9
- package/src/views/settings/useSecuritySettings.ts +78 -22
- package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
- package/dist/components/forms/FormSchemaDrawer.js +0 -99
- package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.js +0 -172
- package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
- package/src/components/forms/FormSchemaDrawer.tsx +0 -192
- 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
|
|
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 {
|
|
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 [
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
<
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
4
|
-
import {
|
|
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
|
|
31
|
-
const
|
|
32
|
-
|
|
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
|
|
40
|
-
description="
|
|
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
|
-
{/* ──
|
|
52
|
+
{/* ── Allowed IP addresses ─────────────────────────────────────────── */}
|
|
43
53
|
<div className="space-y-2">
|
|
44
54
|
<div className="flex items-center justify-between gap-2">
|
|
45
|
-
<
|
|
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
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
value={
|
|
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
|
-
{
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 {
|
|
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 {
|
|
6
|
-
|
|
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="
|
|
41
|
-
description="Control how many sessions an account can hold
|
|
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={
|
|
111
|
+
value={timeoutLabel(form.timeoutHours)}
|
|
76
112
|
/>
|
|
77
113
|
<StatusRow status="pass" label="Revoke sessions on password change" value="Enabled" />
|
|
78
114
|
</div>
|