@actuate-media/cms-admin 0.27.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/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/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +3 -11
- 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/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/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/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/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/security-settings.render.test.tsx +212 -0
- package/src/views/Settings.tsx +3 -33
- package/src/views/settings/AccessProtectionCard.tsx +143 -0
- package/src/views/settings/AuthMfaCard.tsx +118 -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/securityPrimitives.tsx +75 -0
- package/src/views/settings/useSecuritySettings.ts +197 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { AlertTriangle, Check, CircleHelp, X } from 'lucide-react'
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
5
|
+
import type { CheckStatus } from '@actuate-media/cms-core/security-center'
|
|
6
|
+
|
|
7
|
+
/** Small status icon used in check/summary rows. Text label always accompanies it. */
|
|
8
|
+
export function CheckIcon({ status }: { status: CheckStatus }) {
|
|
9
|
+
switch (status) {
|
|
10
|
+
case 'pass':
|
|
11
|
+
return <Check size={15} className="text-success shrink-0" aria-hidden="true" />
|
|
12
|
+
case 'warning':
|
|
13
|
+
return <AlertTriangle size={15} className="text-warning shrink-0" aria-hidden="true" />
|
|
14
|
+
case 'fail':
|
|
15
|
+
return <X size={15} className="text-destructive shrink-0" aria-hidden="true" />
|
|
16
|
+
default:
|
|
17
|
+
return <CircleHelp size={15} className="text-muted-foreground shrink-0" aria-hidden="true" />
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STATUS_WORD: Record<CheckStatus, string> = {
|
|
22
|
+
pass: 'Pass',
|
|
23
|
+
warning: 'Warning',
|
|
24
|
+
fail: 'Fail',
|
|
25
|
+
unknown: 'Unknown',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A status row: icon + label on the left, value on the right. */
|
|
29
|
+
export function StatusRow({
|
|
30
|
+
status,
|
|
31
|
+
label,
|
|
32
|
+
value,
|
|
33
|
+
badge,
|
|
34
|
+
}: {
|
|
35
|
+
status: CheckStatus
|
|
36
|
+
label: string
|
|
37
|
+
value?: ReactNode
|
|
38
|
+
badge?: ReactNode
|
|
39
|
+
}) {
|
|
40
|
+
return (
|
|
41
|
+
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
|
|
42
|
+
<span className="flex items-center gap-2">
|
|
43
|
+
<CheckIcon status={status} />
|
|
44
|
+
{/* Screen-reader text so status is never conveyed by color/icon alone. */}
|
|
45
|
+
<span className="sr-only">{STATUS_WORD[status]}: </span>
|
|
46
|
+
<span className="text-foreground">{label}</span>
|
|
47
|
+
{badge}
|
|
48
|
+
</span>
|
|
49
|
+
{value !== undefined && <span className="text-muted-foreground text-right">{value}</span>}
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Section wrapper matching the other settings cards. */
|
|
55
|
+
export function SecurityCard({
|
|
56
|
+
headingId,
|
|
57
|
+
title,
|
|
58
|
+
description,
|
|
59
|
+
children,
|
|
60
|
+
}: {
|
|
61
|
+
headingId: string
|
|
62
|
+
title: string
|
|
63
|
+
description: string
|
|
64
|
+
children: ReactNode
|
|
65
|
+
}) {
|
|
66
|
+
return (
|
|
67
|
+
<section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
|
|
68
|
+
<h3 id={headingId} className="text-foreground mb-1 text-base font-medium">
|
|
69
|
+
{title}
|
|
70
|
+
</h3>
|
|
71
|
+
<p className="text-muted-foreground mb-4 text-sm">{description}</p>
|
|
72
|
+
{children}
|
|
73
|
+
</section>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
validateSecuritySettings,
|
|
6
|
+
type SecurityCenterData,
|
|
7
|
+
type SecuritySettings,
|
|
8
|
+
} from '@actuate-media/cms-core/security-center'
|
|
9
|
+
import { cmsApi } from '../../lib/api.js'
|
|
10
|
+
import type { ValidationIssue } from './useGeneralSettings.js'
|
|
11
|
+
|
|
12
|
+
/** The subset of security policy the tab can actually edit + enforce. */
|
|
13
|
+
export interface SecurityForm {
|
|
14
|
+
mfaRequired: boolean
|
|
15
|
+
mfaGracePeriodDays: number
|
|
16
|
+
maxConcurrentSessions: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type SaveState = 'idle' | 'saving' | 'success' | 'error'
|
|
20
|
+
|
|
21
|
+
const EMPTY_FORM: SecurityForm = {
|
|
22
|
+
mfaRequired: false,
|
|
23
|
+
mfaGracePeriodDays: 7,
|
|
24
|
+
maxConcurrentSessions: 5,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formFromSettings(s: SecuritySettings): SecurityForm {
|
|
28
|
+
return {
|
|
29
|
+
mfaRequired: s.mfa.required,
|
|
30
|
+
mfaGracePeriodDays: s.mfa.gracePeriodDays,
|
|
31
|
+
maxConcurrentSessions: s.session.maxConcurrentSessions,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formToSettings(f: SecurityForm): SecuritySettings {
|
|
36
|
+
return {
|
|
37
|
+
mfa: { required: f.mfaRequired, gracePeriodDays: f.mfaGracePeriodDays },
|
|
38
|
+
session: { maxConcurrentSessions: f.maxConcurrentSessions },
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UseSecuritySettings {
|
|
43
|
+
loading: boolean
|
|
44
|
+
loadError: string | null
|
|
45
|
+
data: SecurityCenterData | null
|
|
46
|
+
form: SecurityForm
|
|
47
|
+
setField: <K extends keyof SecurityForm>(key: K, value: SecurityForm[K]) => void
|
|
48
|
+
dirty: boolean
|
|
49
|
+
issues: ValidationIssue[]
|
|
50
|
+
hasErrors: boolean
|
|
51
|
+
canEdit: boolean
|
|
52
|
+
saveState: SaveState
|
|
53
|
+
saveError: string | null
|
|
54
|
+
save: () => Promise<void>
|
|
55
|
+
reset: () => void
|
|
56
|
+
refetch: () => void
|
|
57
|
+
revokeState: 'idle' | 'working' | 'done' | 'error'
|
|
58
|
+
revokeAllOtherSessions: () => Promise<number | null>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Loads the Security Center (`GET /security`) — computed posture, cross-module
|
|
63
|
+
* summaries, secret/encryption verdicts — and tracks the small editable policy
|
|
64
|
+
* (MFA requirement + grace, concurrent-session cap). Save is lockout-safe: the
|
|
65
|
+
* client mirrors the server's MFA guard so the operator gets immediate feedback,
|
|
66
|
+
* and the server re-checks authoritatively.
|
|
67
|
+
*/
|
|
68
|
+
export function useSecuritySettings(canEdit: boolean): UseSecuritySettings {
|
|
69
|
+
const [loading, setLoading] = useState(true)
|
|
70
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
71
|
+
const [data, setData] = useState<SecurityCenterData | null>(null)
|
|
72
|
+
const [baseline, setBaseline] = useState<SecurityForm>(EMPTY_FORM)
|
|
73
|
+
const [form, setForm] = useState<SecurityForm>(EMPTY_FORM)
|
|
74
|
+
const [saveState, setSaveState] = useState<SaveState>('idle')
|
|
75
|
+
const [saveError, setSaveError] = useState<string | null>(null)
|
|
76
|
+
const [revokeState, setRevokeState] = useState<'idle' | 'working' | 'done' | 'error'>('idle')
|
|
77
|
+
const [reloadKey, setReloadKey] = useState(0)
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
let cancelled = false
|
|
81
|
+
setLoading(true)
|
|
82
|
+
setLoadError(null)
|
|
83
|
+
cmsApi<SecurityCenterData>('/security')
|
|
84
|
+
.then((res) => {
|
|
85
|
+
if (cancelled) return
|
|
86
|
+
if (res.error || !res.data) {
|
|
87
|
+
setLoadError(res.error ?? 'Failed to load security settings.')
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
setData(res.data)
|
|
91
|
+
const f = formFromSettings(res.data.settings)
|
|
92
|
+
setBaseline(f)
|
|
93
|
+
setForm(f)
|
|
94
|
+
})
|
|
95
|
+
.catch(() => {
|
|
96
|
+
if (!cancelled) setLoadError('Failed to load security settings.')
|
|
97
|
+
})
|
|
98
|
+
.finally(() => {
|
|
99
|
+
if (!cancelled) setLoading(false)
|
|
100
|
+
})
|
|
101
|
+
return () => {
|
|
102
|
+
cancelled = true
|
|
103
|
+
}
|
|
104
|
+
}, [reloadKey])
|
|
105
|
+
|
|
106
|
+
const setField = useCallback(<K extends keyof SecurityForm>(key: K, value: SecurityForm[K]) => {
|
|
107
|
+
setForm((prev) => ({ ...prev, [key]: value }))
|
|
108
|
+
setSaveState('idle')
|
|
109
|
+
}, [])
|
|
110
|
+
|
|
111
|
+
const dirty = useMemo(
|
|
112
|
+
() => (Object.keys(form) as Array<keyof SecurityForm>).some((k) => form[k] !== baseline[k]),
|
|
113
|
+
[form, baseline],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
const issues = useMemo<ValidationIssue[]>(() => {
|
|
117
|
+
const list = validateSecuritySettings(formToSettings(form))
|
|
118
|
+
// Client mirror of the server's lockout-safe MFA guard: block enabling
|
|
119
|
+
// org-wide MFA when the current admin has not set up MFA themselves.
|
|
120
|
+
if (form.mfaRequired && !baseline.mfaRequired && data && !data.viewerHasMfa) {
|
|
121
|
+
list.push({
|
|
122
|
+
field: 'mfaRequired',
|
|
123
|
+
message:
|
|
124
|
+
'Set up MFA for your own account before requiring it for everyone — otherwise you would lock yourself out.',
|
|
125
|
+
severity: 'error',
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
return list
|
|
129
|
+
}, [form, baseline.mfaRequired, data])
|
|
130
|
+
|
|
131
|
+
const hasErrors = issues.some((i) => i.severity === 'error')
|
|
132
|
+
|
|
133
|
+
const save = useCallback(async () => {
|
|
134
|
+
if (!canEdit || hasErrors || !dirty) return
|
|
135
|
+
setSaveState('saving')
|
|
136
|
+
setSaveError(null)
|
|
137
|
+
try {
|
|
138
|
+
const res = await cmsApi<SecurityCenterData>('/security', {
|
|
139
|
+
method: 'PUT',
|
|
140
|
+
body: JSON.stringify(formToSettings(form)),
|
|
141
|
+
})
|
|
142
|
+
if (res.error || !res.data) throw new Error(res.error ?? 'Failed to save security settings.')
|
|
143
|
+
setData(res.data)
|
|
144
|
+
const f = formFromSettings(res.data.settings)
|
|
145
|
+
setBaseline(f)
|
|
146
|
+
setForm(f)
|
|
147
|
+
setSaveState('success')
|
|
148
|
+
} catch (err) {
|
|
149
|
+
setSaveState('error')
|
|
150
|
+
setSaveError(err instanceof Error ? err.message : 'Failed to save security settings.')
|
|
151
|
+
}
|
|
152
|
+
}, [canEdit, hasErrors, dirty, form])
|
|
153
|
+
|
|
154
|
+
const reset = useCallback(() => {
|
|
155
|
+
setForm(baseline)
|
|
156
|
+
setSaveState('idle')
|
|
157
|
+
setSaveError(null)
|
|
158
|
+
}, [baseline])
|
|
159
|
+
|
|
160
|
+
const refetch = useCallback(() => setReloadKey((k) => k + 1), [])
|
|
161
|
+
|
|
162
|
+
const revokeAllOtherSessions = useCallback(async (): Promise<number | null> => {
|
|
163
|
+
setRevokeState('working')
|
|
164
|
+
try {
|
|
165
|
+
const res = await cmsApi<{ revoked: number }>('/security/sessions/revoke-all', {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
})
|
|
168
|
+
if (res.error) throw new Error(res.error)
|
|
169
|
+
setRevokeState('done')
|
|
170
|
+
// Refresh the active-session count.
|
|
171
|
+
setReloadKey((k) => k + 1)
|
|
172
|
+
return res.data?.revoked ?? 0
|
|
173
|
+
} catch {
|
|
174
|
+
setRevokeState('error')
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
}, [])
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
loading,
|
|
181
|
+
loadError,
|
|
182
|
+
data,
|
|
183
|
+
form,
|
|
184
|
+
setField,
|
|
185
|
+
dirty,
|
|
186
|
+
issues,
|
|
187
|
+
hasErrors,
|
|
188
|
+
canEdit,
|
|
189
|
+
saveState,
|
|
190
|
+
saveError,
|
|
191
|
+
save,
|
|
192
|
+
reset,
|
|
193
|
+
refetch,
|
|
194
|
+
revokeState,
|
|
195
|
+
revokeAllOtherSessions,
|
|
196
|
+
}
|
|
197
|
+
}
|