@actuate-media/cms-admin 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/views/appearance-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/appearance-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/appearance-settings.render.test.js +95 -0
- package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/security-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.js +175 -0
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/ThemeProvider.d.ts +47 -4
- package/dist/components/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider.js +173 -27
- package/dist/components/ThemeProvider.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Button.d.ts +1 -1
- package/dist/components/ui/Card.d.ts +2 -2
- package/dist/components/ui/Input.d.ts +1 -1
- package/dist/components/ui/Select.d.ts +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +47 -8
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +5 -17
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/AccessProtectionCard.d.ts +10 -0
- package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -0
- package/dist/views/settings/AccessProtectionCard.js +20 -0
- package/dist/views/settings/AccessProtectionCard.js.map +1 -0
- package/dist/views/settings/AdminThemeCard.d.ts +6 -0
- package/dist/views/settings/AdminThemeCard.d.ts.map +1 -0
- package/dist/views/settings/AdminThemeCard.js +31 -0
- package/dist/views/settings/AdminThemeCard.js.map +1 -0
- package/dist/views/settings/AppearanceTab.d.ts +7 -0
- package/dist/views/settings/AppearanceTab.d.ts.map +1 -0
- package/dist/views/settings/AppearanceTab.js +24 -0
- package/dist/views/settings/AppearanceTab.js.map +1 -0
- package/dist/views/settings/AuthMfaCard.d.ts +10 -0
- package/dist/views/settings/AuthMfaCard.d.ts.map +1 -0
- package/dist/views/settings/AuthMfaCard.js +33 -0
- package/dist/views/settings/AuthMfaCard.js.map +1 -0
- package/dist/views/settings/BrandPreviewCard.d.ts +5 -0
- package/dist/views/settings/BrandPreviewCard.d.ts.map +1 -0
- package/dist/views/settings/BrandPreviewCard.js +18 -0
- package/dist/views/settings/BrandPreviewCard.js.map +1 -0
- package/dist/views/settings/BrandingCard.d.ts +6 -0
- package/dist/views/settings/BrandingCard.d.ts.map +1 -0
- package/dist/views/settings/BrandingCard.js +38 -0
- package/dist/views/settings/BrandingCard.js.map +1 -0
- package/dist/views/settings/SecretsAuditCard.d.ts +11 -0
- package/dist/views/settings/SecretsAuditCard.d.ts.map +1 -0
- package/dist/views/settings/SecretsAuditCard.js +22 -0
- package/dist/views/settings/SecretsAuditCard.js.map +1 -0
- package/dist/views/settings/SecurityStatusCard.d.ts +6 -0
- package/dist/views/settings/SecurityStatusCard.d.ts.map +1 -0
- package/dist/views/settings/SecurityStatusCard.js +35 -0
- package/dist/views/settings/SecurityStatusCard.js.map +1 -0
- package/dist/views/settings/SecurityTab.d.ts +5 -0
- package/dist/views/settings/SecurityTab.d.ts.map +1 -0
- package/dist/views/settings/SecurityTab.js +41 -0
- package/dist/views/settings/SecurityTab.js.map +1 -0
- package/dist/views/settings/SessionPolicyCard.d.ts +11 -0
- package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -0
- package/dist/views/settings/SessionPolicyCard.js +21 -0
- package/dist/views/settings/SessionPolicyCard.js.map +1 -0
- package/dist/views/settings/TypographyMotionCard.d.ts +6 -0
- package/dist/views/settings/TypographyMotionCard.d.ts.map +1 -0
- package/dist/views/settings/TypographyMotionCard.js +8 -0
- package/dist/views/settings/TypographyMotionCard.js.map +1 -0
- package/dist/views/settings/securityPrimitives.d.ts +21 -0
- package/dist/views/settings/securityPrimitives.d.ts.map +1 -0
- package/dist/views/settings/securityPrimitives.js +31 -0
- package/dist/views/settings/securityPrimitives.js.map +1 -0
- package/dist/views/settings/useAppearanceSettings.d.ts +69 -0
- package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -0
- package/dist/views/settings/useAppearanceSettings.js +344 -0
- package/dist/views/settings/useAppearanceSettings.js.map +1 -0
- package/dist/views/settings/useGeneralSettings.d.ts +4 -1
- package/dist/views/settings/useGeneralSettings.d.ts.map +1 -1
- package/dist/views/settings/useGeneralSettings.js.map +1 -1
- package/dist/views/settings/useSecuritySettings.d.ts +37 -0
- package/dist/views/settings/useSecuritySettings.d.ts.map +1 -0
- package/dist/views/settings/useSecuritySettings.js +154 -0
- package/dist/views/settings/useSecuritySettings.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/views/appearance-settings.render.test.tsx +132 -0
- package/src/__tests__/views/security-settings.render.test.tsx +212 -0
- package/src/components/ThemeProvider.tsx +249 -36
- package/src/layout/Sidebar.tsx +81 -13
- package/src/styles/theme.css +32 -2
- package/src/views/Settings.tsx +5 -68
- package/src/views/settings/AccessProtectionCard.tsx +143 -0
- package/src/views/settings/AdminThemeCard.tsx +145 -0
- package/src/views/settings/AppearanceTab.tsx +81 -0
- package/src/views/settings/AuthMfaCard.tsx +118 -0
- package/src/views/settings/BrandPreviewCard.tsx +125 -0
- package/src/views/settings/BrandingCard.tsx +332 -0
- package/src/views/settings/SecretsAuditCard.tsx +129 -0
- package/src/views/settings/SecurityStatusCard.tsx +110 -0
- package/src/views/settings/SecurityTab.tsx +162 -0
- package/src/views/settings/SessionPolicyCard.tsx +123 -0
- package/src/views/settings/TypographyMotionCard.tsx +65 -0
- package/src/views/settings/securityPrimitives.tsx +75 -0
- package/src/views/settings/useAppearanceSettings.ts +513 -0
- package/src/views/settings/useGeneralSettings.ts +4 -1
- package/src/views/settings/useSecuritySettings.ts +197 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId, useState } from 'react'
|
|
4
|
+
import { Check, X } from 'lucide-react'
|
|
5
|
+
import {
|
|
6
|
+
validateIpRange,
|
|
7
|
+
type IpAllowlistStatus,
|
|
8
|
+
type SecretsStatus,
|
|
9
|
+
type SecuritySourceMetadata,
|
|
10
|
+
} from '@actuate-media/cms-core/security-center'
|
|
11
|
+
import { SettingsSourceBadge } from './components.js'
|
|
12
|
+
import { SecurityCard, StatusRow } from './securityPrimitives.js'
|
|
13
|
+
|
|
14
|
+
export function AccessProtectionCard({
|
|
15
|
+
ipAllowlist,
|
|
16
|
+
secrets,
|
|
17
|
+
csrfEnabled,
|
|
18
|
+
uploadValidation,
|
|
19
|
+
sources,
|
|
20
|
+
onNavigate,
|
|
21
|
+
}: {
|
|
22
|
+
ipAllowlist: IpAllowlistStatus
|
|
23
|
+
secrets: SecretsStatus
|
|
24
|
+
csrfEnabled: boolean
|
|
25
|
+
uploadValidation: boolean
|
|
26
|
+
sources: Record<string, SecuritySourceMetadata>
|
|
27
|
+
onNavigate?: (path: string) => void
|
|
28
|
+
}) {
|
|
29
|
+
const headingId = useId()
|
|
30
|
+
const ipTestId = useId()
|
|
31
|
+
const [ipTest, setIpTest] = useState('')
|
|
32
|
+
const ipTestValid = ipTest.trim() === '' ? null : validateIpRange(ipTest)
|
|
33
|
+
|
|
34
|
+
const distributed = secrets.rateLimitBackend === 'distributed'
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<SecurityCard
|
|
38
|
+
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."
|
|
41
|
+
>
|
|
42
|
+
{/* ── Access restrictions ─────────────────────────────────────────── */}
|
|
43
|
+
<div className="space-y-2">
|
|
44
|
+
<div className="flex items-center justify-between gap-2">
|
|
45
|
+
<span className="text-foreground text-sm font-medium">Admin IP allowlist</span>
|
|
46
|
+
<SettingsSourceBadge meta={sources.ipAllowlist} />
|
|
47
|
+
</div>
|
|
48
|
+
<StatusRow
|
|
49
|
+
status={ipAllowlist.enabled ? 'pass' : 'unknown'}
|
|
50
|
+
label="Status"
|
|
51
|
+
value={ipAllowlist.enabled ? `${ipAllowlist.ranges.length} range(s)` : 'Not configured'}
|
|
52
|
+
/>
|
|
53
|
+
{ipAllowlist.currentIp && (
|
|
54
|
+
<StatusRow
|
|
55
|
+
status={
|
|
56
|
+
ipAllowlist.currentIpAllowed === null
|
|
57
|
+
? 'unknown'
|
|
58
|
+
: ipAllowlist.currentIpAllowed
|
|
59
|
+
? 'pass'
|
|
60
|
+
: 'fail'
|
|
61
|
+
}
|
|
62
|
+
label="Your current IP"
|
|
63
|
+
value={
|
|
64
|
+
<span className="font-mono text-xs">
|
|
65
|
+
{ipAllowlist.currentIp}
|
|
66
|
+
{ipAllowlist.currentIpAllowed === false && ' (blocked)'}
|
|
67
|
+
</span>
|
|
68
|
+
}
|
|
69
|
+
/>
|
|
70
|
+
)}
|
|
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>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{/* ── Request protection ──────────────────────────────────────────── */}
|
|
112
|
+
<div className="border-border mt-4 space-y-2 border-t pt-4">
|
|
113
|
+
<StatusRow
|
|
114
|
+
status={csrfEnabled ? 'pass' : 'fail'}
|
|
115
|
+
label="CSRF protection"
|
|
116
|
+
value={csrfEnabled ? 'Enabled' : 'Disabled'}
|
|
117
|
+
badge={<SettingsSourceBadge meta={sources.csrf} />}
|
|
118
|
+
/>
|
|
119
|
+
<StatusRow
|
|
120
|
+
status={distributed ? 'pass' : 'warning'}
|
|
121
|
+
label="Rate limiting"
|
|
122
|
+
value={distributed ? 'Distributed (Upstash)' : 'In-memory'}
|
|
123
|
+
badge={<SettingsSourceBadge meta={sources.rateLimiting} />}
|
|
124
|
+
/>
|
|
125
|
+
<StatusRow
|
|
126
|
+
status={uploadValidation ? 'pass' : 'warning'}
|
|
127
|
+
label="Upload validation"
|
|
128
|
+
value="MIME + magic bytes + SVG sanitization"
|
|
129
|
+
badge={<SettingsSourceBadge meta={sources.uploadValidation} />}
|
|
130
|
+
/>
|
|
131
|
+
{onNavigate && (
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
onClick={() => onNavigate('/media')}
|
|
135
|
+
className="text-brand mt-1 text-sm hover:underline"
|
|
136
|
+
>
|
|
137
|
+
Manage upload rules in Media →
|
|
138
|
+
</button>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
</SecurityCard>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ACCENT_PRESET_LIST } from '@actuate-media/cms-core/appearance'
|
|
4
|
+
import type { ColorMode, SidebarDensity } from '@actuate-media/cms-core/appearance'
|
|
5
|
+
import { Check, Monitor, Moon, Sun } from 'lucide-react'
|
|
6
|
+
|
|
7
|
+
import { SettingsSourceBadge } from './components.js'
|
|
8
|
+
import type { UseAppearanceSettings } from './useAppearanceSettings.js'
|
|
9
|
+
|
|
10
|
+
const COLOR_MODES: { value: ColorMode; label: string; Icon: typeof Sun }[] = [
|
|
11
|
+
{ value: 'light', label: 'Light', Icon: Sun },
|
|
12
|
+
{ value: 'dark', label: 'Dark', Icon: Moon },
|
|
13
|
+
{ value: 'system', label: 'System', Icon: Monitor },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
const DENSITIES: { value: SidebarDensity; label: string }[] = [
|
|
17
|
+
{ value: 'default', label: 'Default' },
|
|
18
|
+
{ value: 'compact', label: 'Compact' },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
function SegmentedGroup<T extends string>({
|
|
22
|
+
label,
|
|
23
|
+
value,
|
|
24
|
+
options,
|
|
25
|
+
canEdit,
|
|
26
|
+
onChange,
|
|
27
|
+
}: {
|
|
28
|
+
label: string
|
|
29
|
+
value: T
|
|
30
|
+
options: { value: T; label: string; Icon?: typeof Sun }[]
|
|
31
|
+
canEdit: boolean
|
|
32
|
+
onChange: (v: T) => void
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
role="radiogroup"
|
|
37
|
+
aria-label={label}
|
|
38
|
+
className="border-border bg-muted inline-flex flex-wrap rounded-md border p-0.5"
|
|
39
|
+
>
|
|
40
|
+
{options.map(({ value: v, label: text, Icon }) => {
|
|
41
|
+
const active = value === v
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
key={v}
|
|
45
|
+
type="button"
|
|
46
|
+
role="radio"
|
|
47
|
+
aria-checked={active}
|
|
48
|
+
disabled={!canEdit}
|
|
49
|
+
onClick={() => onChange(v)}
|
|
50
|
+
className={`inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
51
|
+
active
|
|
52
|
+
? 'bg-card text-foreground shadow-sm'
|
|
53
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
54
|
+
}`}
|
|
55
|
+
>
|
|
56
|
+
{Icon && <Icon size={14} aria-hidden="true" />}
|
|
57
|
+
{text}
|
|
58
|
+
</button>
|
|
59
|
+
)
|
|
60
|
+
})}
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function AdminThemeCard({ s, canEdit }: { s: UseAppearanceSettings; canEdit: boolean }) {
|
|
66
|
+
return (
|
|
67
|
+
<section className="border-border bg-card rounded-lg border p-6">
|
|
68
|
+
<header className="mb-4">
|
|
69
|
+
<h3 className="text-foreground text-lg font-medium">Admin Theme</h3>
|
|
70
|
+
<p className="text-muted-foreground mt-0.5 text-sm">
|
|
71
|
+
Controls the default look of the admin. Individual users can override the color mode.
|
|
72
|
+
</p>
|
|
73
|
+
</header>
|
|
74
|
+
|
|
75
|
+
<div className="space-y-5">
|
|
76
|
+
<div>
|
|
77
|
+
<div className="mb-2 flex items-center gap-2">
|
|
78
|
+
<span className="text-foreground text-base font-medium">Color mode</span>
|
|
79
|
+
<SettingsSourceBadge meta={s.sources.colorMode} />
|
|
80
|
+
</div>
|
|
81
|
+
<SegmentedGroup
|
|
82
|
+
label="Color mode"
|
|
83
|
+
value={s.appearance.colorMode}
|
|
84
|
+
options={COLOR_MODES}
|
|
85
|
+
canEdit={canEdit}
|
|
86
|
+
onChange={s.setColorMode}
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div>
|
|
91
|
+
<div className="mb-2 flex items-center gap-2">
|
|
92
|
+
<span className="text-foreground text-base font-medium">Accent color</span>
|
|
93
|
+
<SettingsSourceBadge meta={s.sources.accent} />
|
|
94
|
+
</div>
|
|
95
|
+
<div
|
|
96
|
+
role="radiogroup"
|
|
97
|
+
aria-label="Accent color"
|
|
98
|
+
className="flex flex-wrap items-center gap-3"
|
|
99
|
+
>
|
|
100
|
+
{ACCENT_PRESET_LIST.map((preset) => {
|
|
101
|
+
const active = s.appearance.accent === preset.name
|
|
102
|
+
const editable = s.sources.accent?.editable !== false
|
|
103
|
+
return (
|
|
104
|
+
<button
|
|
105
|
+
key={preset.name}
|
|
106
|
+
type="button"
|
|
107
|
+
role="radio"
|
|
108
|
+
aria-checked={active}
|
|
109
|
+
aria-label={preset.label}
|
|
110
|
+
title={preset.label}
|
|
111
|
+
disabled={!canEdit || !editable}
|
|
112
|
+
onClick={() => s.setAccent(preset.name)}
|
|
113
|
+
className={`focus-visible:ring-ring relative flex h-9 w-9 items-center justify-center rounded-full transition-transform focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
114
|
+
active ? 'ring-foreground ring-2 ring-offset-2' : ''
|
|
115
|
+
}`}
|
|
116
|
+
style={{ backgroundColor: preset.swatch }}
|
|
117
|
+
>
|
|
118
|
+
{active && <Check size={16} className="text-white" aria-hidden="true" />}
|
|
119
|
+
</button>
|
|
120
|
+
)
|
|
121
|
+
})}
|
|
122
|
+
{/* Text label so accent is not communicated by color alone. */}
|
|
123
|
+
<span className="text-muted-foreground text-sm">
|
|
124
|
+
{ACCENT_PRESET_LIST.find((p) => p.name === s.appearance.accent)?.label ??
|
|
125
|
+
s.appearance.accent}
|
|
126
|
+
</span>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div>
|
|
131
|
+
<div className="mb-2 flex items-center gap-2">
|
|
132
|
+
<span className="text-foreground text-base font-medium">Sidebar density</span>
|
|
133
|
+
</div>
|
|
134
|
+
<SegmentedGroup
|
|
135
|
+
label="Sidebar density"
|
|
136
|
+
value={s.appearance.density}
|
|
137
|
+
options={DENSITIES}
|
|
138
|
+
canEdit={canEdit}
|
|
139
|
+
onChange={s.setDensity}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</section>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { AlertTriangle, Loader2 } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
import { AdminThemeCard } from './AdminThemeCard.js'
|
|
6
|
+
import { BrandPreviewCard } from './BrandPreviewCard.js'
|
|
7
|
+
import { BrandingCard } from './BrandingCard.js'
|
|
8
|
+
import { SettingsSaveBar, SettingsValidationSummary } from './components.js'
|
|
9
|
+
import { TypographyMotionCard } from './TypographyMotionCard.js'
|
|
10
|
+
import { useAppearanceSettings } from './useAppearanceSettings.js'
|
|
11
|
+
|
|
12
|
+
export interface AppearanceTabProps {
|
|
13
|
+
/** Current user's role. Only ADMIN can persist changes. */
|
|
14
|
+
role?: string
|
|
15
|
+
config?: Record<string, unknown>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function CardSkeleton() {
|
|
19
|
+
return <div className="border-border bg-card h-48 animate-pulse rounded-lg border" />
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function AppearanceTab({ role, config }: AppearanceTabProps) {
|
|
23
|
+
const canEdit = role === 'ADMIN'
|
|
24
|
+
const s = useAppearanceSettings(canEdit, config)
|
|
25
|
+
|
|
26
|
+
if (s.loading) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="space-y-4" aria-busy="true">
|
|
29
|
+
<CardSkeleton />
|
|
30
|
+
<CardSkeleton />
|
|
31
|
+
<CardSkeleton />
|
|
32
|
+
</div>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (s.loadError) {
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
role="alert"
|
|
40
|
+
className="border-destructive/30 bg-destructive/10 text-destructive flex items-center justify-between gap-4 rounded-lg border p-4 text-sm"
|
|
41
|
+
>
|
|
42
|
+
<span className="flex items-center gap-2">
|
|
43
|
+
<AlertTriangle size={16} aria-hidden="true" /> {s.loadError}
|
|
44
|
+
</span>
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
onClick={s.reload}
|
|
48
|
+
className="border-destructive/40 text-destructive hover:bg-destructive/10 inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5"
|
|
49
|
+
>
|
|
50
|
+
<Loader2 size={14} aria-hidden="true" /> Retry
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="space-y-4">
|
|
58
|
+
{!canEdit && (
|
|
59
|
+
<p className="text-muted-foreground border-border bg-muted rounded-lg border p-3 text-sm">
|
|
60
|
+
You have read-only access to appearance settings.
|
|
61
|
+
</p>
|
|
62
|
+
)}
|
|
63
|
+
|
|
64
|
+
<SettingsValidationSummary issues={s.issues} />
|
|
65
|
+
|
|
66
|
+
<BrandingCard s={s} canEdit={canEdit} />
|
|
67
|
+
<AdminThemeCard s={s} canEdit={canEdit} />
|
|
68
|
+
<TypographyMotionCard s={s} canEdit={canEdit} />
|
|
69
|
+
<BrandPreviewCard s={s} />
|
|
70
|
+
|
|
71
|
+
<SettingsSaveBar
|
|
72
|
+
dirty={s.dirty}
|
|
73
|
+
canEdit={canEdit}
|
|
74
|
+
hasErrors={s.issues.some((i) => i.severity === 'error')}
|
|
75
|
+
saveState={s.saveState}
|
|
76
|
+
onSave={s.save}
|
|
77
|
+
onReset={s.reset}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId, useState } from 'react'
|
|
4
|
+
import { MFA_GRACE_PERIOD_OPTIONS } from '@actuate-media/cms-core/security-center'
|
|
5
|
+
import { ConfirmDangerousSettingDialog, SettingsToggleRow } from './components.js'
|
|
6
|
+
import { SecurityCard, StatusRow } from './securityPrimitives.js'
|
|
7
|
+
import type { SecurityForm } from './useSecuritySettings.js'
|
|
8
|
+
|
|
9
|
+
function graceLabel(days: number): string {
|
|
10
|
+
if (days === 0) return 'Immediately'
|
|
11
|
+
if (days === 1) return '1 day'
|
|
12
|
+
return `${days} days`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function AuthMfaCard({
|
|
16
|
+
form,
|
|
17
|
+
setField,
|
|
18
|
+
canEdit,
|
|
19
|
+
viewerHasMfa,
|
|
20
|
+
withMfa,
|
|
21
|
+
totalAdmins,
|
|
22
|
+
}: {
|
|
23
|
+
form: SecurityForm
|
|
24
|
+
setField: <K extends keyof SecurityForm>(key: K, value: SecurityForm[K]) => void
|
|
25
|
+
canEdit: boolean
|
|
26
|
+
viewerHasMfa: boolean
|
|
27
|
+
withMfa: number
|
|
28
|
+
totalAdmins: number
|
|
29
|
+
}) {
|
|
30
|
+
const headingId = useId()
|
|
31
|
+
const graceId = useId()
|
|
32
|
+
const [confirmEnable, setConfirmEnable] = useState(false)
|
|
33
|
+
|
|
34
|
+
function requestMfaToggle(value: boolean) {
|
|
35
|
+
if (value && !form.mfaRequired) {
|
|
36
|
+
setConfirmEnable(true)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
setField('mfaRequired', value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Lockout-safe: the toggle can't be turned on until the acting admin has
|
|
43
|
+
// their own MFA configured (the server enforces this too).
|
|
44
|
+
const mfaToggleDisabled = !canEdit || (!form.mfaRequired && !viewerHasMfa)
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<SecurityCard
|
|
48
|
+
headingId={headingId}
|
|
49
|
+
title="Authentication & MFA"
|
|
50
|
+
description="Require multi-factor authentication for admin accounts and control the enrollment window."
|
|
51
|
+
>
|
|
52
|
+
<div className="space-y-4">
|
|
53
|
+
<SettingsToggleRow
|
|
54
|
+
label="Require MFA for admin users"
|
|
55
|
+
description="Admins must set up a TOTP authenticator app. Existing admins are prompted to enroll within the grace period."
|
|
56
|
+
checked={form.mfaRequired}
|
|
57
|
+
disabled={mfaToggleDisabled}
|
|
58
|
+
onChange={requestMfaToggle}
|
|
59
|
+
/>
|
|
60
|
+
|
|
61
|
+
<div className="flex items-center justify-between gap-4">
|
|
62
|
+
<label htmlFor={graceId} className="flex-1">
|
|
63
|
+
<span className="text-foreground text-base font-medium">Enrollment grace period</span>
|
|
64
|
+
<p className="text-muted-foreground mt-0.5 text-sm">
|
|
65
|
+
How long existing admins have to set up MFA before it is enforced.
|
|
66
|
+
</p>
|
|
67
|
+
</label>
|
|
68
|
+
<select
|
|
69
|
+
id={graceId}
|
|
70
|
+
value={form.mfaGracePeriodDays}
|
|
71
|
+
disabled={!canEdit || !form.mfaRequired}
|
|
72
|
+
onChange={(e) => setField('mfaGracePeriodDays', Number(e.target.value))}
|
|
73
|
+
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"
|
|
74
|
+
>
|
|
75
|
+
{MFA_GRACE_PERIOD_OPTIONS.map((d) => (
|
|
76
|
+
<option key={d} value={d}>
|
|
77
|
+
{graceLabel(d)}
|
|
78
|
+
</option>
|
|
79
|
+
))}
|
|
80
|
+
</select>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div className="border-border mt-4 space-y-2 border-t pt-4">
|
|
85
|
+
<StatusRow
|
|
86
|
+
status={viewerHasMfa ? 'pass' : 'warning'}
|
|
87
|
+
label="Your account"
|
|
88
|
+
value={viewerHasMfa ? 'MFA enabled' : 'MFA not set up'}
|
|
89
|
+
/>
|
|
90
|
+
{!viewerHasMfa && (
|
|
91
|
+
<p className="text-muted-foreground text-sm">
|
|
92
|
+
Set up MFA on your own account before requiring it for everyone, or you may lock
|
|
93
|
+
yourself out.
|
|
94
|
+
</p>
|
|
95
|
+
)}
|
|
96
|
+
<StatusRow
|
|
97
|
+
status={totalAdmins === 0 ? 'unknown' : withMfa === totalAdmins ? 'pass' : 'warning'}
|
|
98
|
+
label="Admin MFA coverage"
|
|
99
|
+
value={totalAdmins === 0 ? 'No admins' : `${withMfa} of ${totalAdmins} admins`}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<ConfirmDangerousSettingDialog
|
|
104
|
+
open={confirmEnable}
|
|
105
|
+
title="Require MFA for all admin users?"
|
|
106
|
+
description={`Admins without MFA will be prompted to enroll and must complete setup within the grace period (${graceLabel(
|
|
107
|
+
form.mfaGracePeriodDays,
|
|
108
|
+
)}). Make sure your own account has MFA configured first.`}
|
|
109
|
+
confirmLabel="Require MFA"
|
|
110
|
+
onConfirm={() => {
|
|
111
|
+
setField('mfaRequired', true)
|
|
112
|
+
setConfirmEnable(false)
|
|
113
|
+
}}
|
|
114
|
+
onCancel={() => setConfirmEnable(false)}
|
|
115
|
+
/>
|
|
116
|
+
</SecurityCard>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ResolvedAdminBrand } from '@actuate-media/cms-core/appearance'
|
|
4
|
+
|
|
5
|
+
import type { UseAppearanceSettings } from './useAppearanceSettings.js'
|
|
6
|
+
|
|
7
|
+
function Initials({ initials, size = 'h-8 w-8 text-sm' }: { initials: string; size?: string }) {
|
|
8
|
+
return (
|
|
9
|
+
<span
|
|
10
|
+
className={`bg-brand text-brand-foreground inline-flex shrink-0 items-center justify-center rounded-md font-medium ${size}`}
|
|
11
|
+
aria-hidden="true"
|
|
12
|
+
>
|
|
13
|
+
{initials}
|
|
14
|
+
</span>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Expanded sidebar lockup preview (primary → mark+title → initials). */
|
|
19
|
+
function SidebarPreview({ admin, dark }: { admin: ResolvedAdminBrand; dark?: boolean }) {
|
|
20
|
+
const logo = dark && admin.dark ? admin.dark : admin.primary
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
className={`flex h-14 items-center gap-2.5 rounded-md border px-3 ${
|
|
24
|
+
dark ? 'border-border bg-foreground' : 'bg-sidebar border-sidebar-border'
|
|
25
|
+
}`}
|
|
26
|
+
>
|
|
27
|
+
{logo ? (
|
|
28
|
+
<img src={logo.url} alt={logo.alt} className="h-8 w-auto object-contain" />
|
|
29
|
+
) : admin.mark ? (
|
|
30
|
+
<>
|
|
31
|
+
<img src={admin.mark.url} alt={admin.mark.alt} className="h-8 w-8 object-contain" />
|
|
32
|
+
<span className={dark ? 'text-background text-sm' : 'text-sidebar-foreground text-sm'}>
|
|
33
|
+
{admin.initials}
|
|
34
|
+
</span>
|
|
35
|
+
</>
|
|
36
|
+
) : (
|
|
37
|
+
<>
|
|
38
|
+
<Initials initials={admin.initials} />
|
|
39
|
+
<span className={dark ? 'text-background text-sm' : 'text-sidebar-foreground text-sm'}>
|
|
40
|
+
Admin
|
|
41
|
+
</span>
|
|
42
|
+
</>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function PreviewTile({ label, children }: { label: string; children: React.ReactNode }) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="space-y-1.5">
|
|
51
|
+
<span className="text-muted-foreground text-xs font-medium">{label}</span>
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function BrandPreviewCard({ s }: { s: UseAppearanceSettings }) {
|
|
58
|
+
const { admin, public: pub, faviconUrl } = s.preview
|
|
59
|
+
return (
|
|
60
|
+
<section className="border-border bg-card rounded-lg border p-6">
|
|
61
|
+
<header className="mb-4 flex items-center justify-between gap-2">
|
|
62
|
+
<div>
|
|
63
|
+
<h3 className="text-foreground text-lg font-medium">Brand Preview</h3>
|
|
64
|
+
<p className="text-muted-foreground mt-0.5 text-sm">
|
|
65
|
+
How your brand renders across the admin and public site.
|
|
66
|
+
</p>
|
|
67
|
+
</div>
|
|
68
|
+
{s.dirty && (
|
|
69
|
+
<span className="text-warning bg-warning/10 border-warning/30 rounded-md border px-2 py-0.5 text-xs">
|
|
70
|
+
Unsaved
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
</header>
|
|
74
|
+
|
|
75
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
76
|
+
<PreviewTile label="Admin sidebar">
|
|
77
|
+
<SidebarPreview admin={admin} />
|
|
78
|
+
</PreviewTile>
|
|
79
|
+
|
|
80
|
+
<PreviewTile label="Collapsed sidebar">
|
|
81
|
+
<div className="bg-sidebar border-sidebar-border flex h-14 w-14 items-center justify-center rounded-md border">
|
|
82
|
+
{admin.mark ? (
|
|
83
|
+
<img src={admin.mark.url} alt={admin.mark.alt} className="h-8 w-8 object-contain" />
|
|
84
|
+
) : (
|
|
85
|
+
<Initials initials={admin.initials} />
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
</PreviewTile>
|
|
89
|
+
|
|
90
|
+
<PreviewTile label="Dark mode">
|
|
91
|
+
<SidebarPreview admin={admin} dark />
|
|
92
|
+
</PreviewTile>
|
|
93
|
+
|
|
94
|
+
<PreviewTile label="Public header">
|
|
95
|
+
<div className="border-border bg-background flex h-14 items-center gap-2.5 rounded-md border px-3">
|
|
96
|
+
{pub.primary ? (
|
|
97
|
+
<img
|
|
98
|
+
src={pub.primary.url}
|
|
99
|
+
alt={pub.primary.alt}
|
|
100
|
+
className="h-8 w-auto object-contain"
|
|
101
|
+
/>
|
|
102
|
+
) : pub.mark ? (
|
|
103
|
+
<>
|
|
104
|
+
<img src={pub.mark.url} alt={pub.mark.alt} className="h-8 w-8 object-contain" />
|
|
105
|
+
<span className="text-foreground text-sm">{pub.initials}</span>
|
|
106
|
+
</>
|
|
107
|
+
) : (
|
|
108
|
+
<span className="text-foreground text-sm font-medium">{pub.initials}</span>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</PreviewTile>
|
|
112
|
+
|
|
113
|
+
<PreviewTile label="Favicon">
|
|
114
|
+
<div className="border-border bg-muted flex h-14 w-14 items-center justify-center rounded-md border p-2">
|
|
115
|
+
{faviconUrl ? (
|
|
116
|
+
<img src={faviconUrl} alt="Favicon preview" className="h-8 w-8 object-contain" />
|
|
117
|
+
) : (
|
|
118
|
+
<span className="text-muted-foreground text-xs">None</span>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
</PreviewTile>
|
|
122
|
+
</div>
|
|
123
|
+
</section>
|
|
124
|
+
)
|
|
125
|
+
}
|