@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,154 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { validateSecuritySettings, } from '@actuate-media/cms-core/security-center';
|
|
4
|
+
import { cmsApi } from '../../lib/api.js';
|
|
5
|
+
const EMPTY_FORM = {
|
|
6
|
+
mfaRequired: false,
|
|
7
|
+
mfaGracePeriodDays: 7,
|
|
8
|
+
maxConcurrentSessions: 5,
|
|
9
|
+
};
|
|
10
|
+
function formFromSettings(s) {
|
|
11
|
+
return {
|
|
12
|
+
mfaRequired: s.mfa.required,
|
|
13
|
+
mfaGracePeriodDays: s.mfa.gracePeriodDays,
|
|
14
|
+
maxConcurrentSessions: s.session.maxConcurrentSessions,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function formToSettings(f) {
|
|
18
|
+
return {
|
|
19
|
+
mfa: { required: f.mfaRequired, gracePeriodDays: f.mfaGracePeriodDays },
|
|
20
|
+
session: { maxConcurrentSessions: f.maxConcurrentSessions },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Loads the Security Center (`GET /security`) — computed posture, cross-module
|
|
25
|
+
* summaries, secret/encryption verdicts — and tracks the small editable policy
|
|
26
|
+
* (MFA requirement + grace, concurrent-session cap). Save is lockout-safe: the
|
|
27
|
+
* client mirrors the server's MFA guard so the operator gets immediate feedback,
|
|
28
|
+
* and the server re-checks authoritatively.
|
|
29
|
+
*/
|
|
30
|
+
export function useSecuritySettings(canEdit) {
|
|
31
|
+
const [loading, setLoading] = useState(true);
|
|
32
|
+
const [loadError, setLoadError] = useState(null);
|
|
33
|
+
const [data, setData] = useState(null);
|
|
34
|
+
const [baseline, setBaseline] = useState(EMPTY_FORM);
|
|
35
|
+
const [form, setForm] = useState(EMPTY_FORM);
|
|
36
|
+
const [saveState, setSaveState] = useState('idle');
|
|
37
|
+
const [saveError, setSaveError] = useState(null);
|
|
38
|
+
const [revokeState, setRevokeState] = useState('idle');
|
|
39
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
setLoading(true);
|
|
43
|
+
setLoadError(null);
|
|
44
|
+
cmsApi('/security')
|
|
45
|
+
.then((res) => {
|
|
46
|
+
if (cancelled)
|
|
47
|
+
return;
|
|
48
|
+
if (res.error || !res.data) {
|
|
49
|
+
setLoadError(res.error ?? 'Failed to load security settings.');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setData(res.data);
|
|
53
|
+
const f = formFromSettings(res.data.settings);
|
|
54
|
+
setBaseline(f);
|
|
55
|
+
setForm(f);
|
|
56
|
+
})
|
|
57
|
+
.catch(() => {
|
|
58
|
+
if (!cancelled)
|
|
59
|
+
setLoadError('Failed to load security settings.');
|
|
60
|
+
})
|
|
61
|
+
.finally(() => {
|
|
62
|
+
if (!cancelled)
|
|
63
|
+
setLoading(false);
|
|
64
|
+
});
|
|
65
|
+
return () => {
|
|
66
|
+
cancelled = true;
|
|
67
|
+
};
|
|
68
|
+
}, [reloadKey]);
|
|
69
|
+
const setField = useCallback((key, value) => {
|
|
70
|
+
setForm((prev) => ({ ...prev, [key]: value }));
|
|
71
|
+
setSaveState('idle');
|
|
72
|
+
}, []);
|
|
73
|
+
const dirty = useMemo(() => Object.keys(form).some((k) => form[k] !== baseline[k]), [form, baseline]);
|
|
74
|
+
const issues = useMemo(() => {
|
|
75
|
+
const list = validateSecuritySettings(formToSettings(form));
|
|
76
|
+
// Client mirror of the server's lockout-safe MFA guard: block enabling
|
|
77
|
+
// org-wide MFA when the current admin has not set up MFA themselves.
|
|
78
|
+
if (form.mfaRequired && !baseline.mfaRequired && data && !data.viewerHasMfa) {
|
|
79
|
+
list.push({
|
|
80
|
+
field: 'mfaRequired',
|
|
81
|
+
message: 'Set up MFA for your own account before requiring it for everyone — otherwise you would lock yourself out.',
|
|
82
|
+
severity: 'error',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return list;
|
|
86
|
+
}, [form, baseline.mfaRequired, data]);
|
|
87
|
+
const hasErrors = issues.some((i) => i.severity === 'error');
|
|
88
|
+
const save = useCallback(async () => {
|
|
89
|
+
if (!canEdit || hasErrors || !dirty)
|
|
90
|
+
return;
|
|
91
|
+
setSaveState('saving');
|
|
92
|
+
setSaveError(null);
|
|
93
|
+
try {
|
|
94
|
+
const res = await cmsApi('/security', {
|
|
95
|
+
method: 'PUT',
|
|
96
|
+
body: JSON.stringify(formToSettings(form)),
|
|
97
|
+
});
|
|
98
|
+
if (res.error || !res.data)
|
|
99
|
+
throw new Error(res.error ?? 'Failed to save security settings.');
|
|
100
|
+
setData(res.data);
|
|
101
|
+
const f = formFromSettings(res.data.settings);
|
|
102
|
+
setBaseline(f);
|
|
103
|
+
setForm(f);
|
|
104
|
+
setSaveState('success');
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
setSaveState('error');
|
|
108
|
+
setSaveError(err instanceof Error ? err.message : 'Failed to save security settings.');
|
|
109
|
+
}
|
|
110
|
+
}, [canEdit, hasErrors, dirty, form]);
|
|
111
|
+
const reset = useCallback(() => {
|
|
112
|
+
setForm(baseline);
|
|
113
|
+
setSaveState('idle');
|
|
114
|
+
setSaveError(null);
|
|
115
|
+
}, [baseline]);
|
|
116
|
+
const refetch = useCallback(() => setReloadKey((k) => k + 1), []);
|
|
117
|
+
const revokeAllOtherSessions = useCallback(async () => {
|
|
118
|
+
setRevokeState('working');
|
|
119
|
+
try {
|
|
120
|
+
const res = await cmsApi('/security/sessions/revoke-all', {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
});
|
|
123
|
+
if (res.error)
|
|
124
|
+
throw new Error(res.error);
|
|
125
|
+
setRevokeState('done');
|
|
126
|
+
// Refresh the active-session count.
|
|
127
|
+
setReloadKey((k) => k + 1);
|
|
128
|
+
return res.data?.revoked ?? 0;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
setRevokeState('error');
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}, []);
|
|
135
|
+
return {
|
|
136
|
+
loading,
|
|
137
|
+
loadError,
|
|
138
|
+
data,
|
|
139
|
+
form,
|
|
140
|
+
setField,
|
|
141
|
+
dirty,
|
|
142
|
+
issues,
|
|
143
|
+
hasErrors,
|
|
144
|
+
canEdit,
|
|
145
|
+
saveState,
|
|
146
|
+
saveError,
|
|
147
|
+
save,
|
|
148
|
+
reset,
|
|
149
|
+
refetch,
|
|
150
|
+
revokeState,
|
|
151
|
+
revokeAllOtherSessions,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=useSecuritySettings.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useSecuritySettings.js","sourceRoot":"","sources":["../../../src/views/settings/useSecuritySettings.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actuate-media/cms-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/actuate-media/actuatecms.git",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"tailwindcss": "^4.0.0",
|
|
88
88
|
"typescript": "^5.7.0",
|
|
89
89
|
"vitest": "^4.1.0",
|
|
90
|
-
"@actuate-media/cms-core": "0.
|
|
90
|
+
"@actuate-media/cms-core": "0.37.0",
|
|
91
91
|
"@actuate-media/component-blocks": "0.2.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
// The Security tab reads `/security` (computed posture + summaries + editable
|
|
6
|
+
// policy), saves via PUT `/security`, and revokes sessions via POST.
|
|
7
|
+
let securityData: any
|
|
8
|
+
|
|
9
|
+
const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string; body?: unknown }) => {
|
|
10
|
+
const method = options?.method ?? 'GET'
|
|
11
|
+
if (endpoint === '/security' && method === 'GET') return { data: securityData, status: 200 }
|
|
12
|
+
if (endpoint === '/security' && method === 'PUT') {
|
|
13
|
+
const body = JSON.parse(String(options?.body ?? '{}'))
|
|
14
|
+
securityData = {
|
|
15
|
+
...securityData,
|
|
16
|
+
settings: {
|
|
17
|
+
mfa: { required: body.mfa.required, gracePeriodDays: body.mfa.gracePeriodDays },
|
|
18
|
+
session: { maxConcurrentSessions: body.session.maxConcurrentSessions },
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
return { data: securityData, status: 200 }
|
|
22
|
+
}
|
|
23
|
+
if (endpoint === '/security/sessions/revoke-all' && method === 'POST')
|
|
24
|
+
return { data: { revoked: 2 }, status: 200 }
|
|
25
|
+
return { data: {}, status: 200 }
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
vi.mock('../../lib/api.js', () => ({ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any) }))
|
|
29
|
+
|
|
30
|
+
const { SecurityTab } = await import('../../views/settings/SecurityTab.js')
|
|
31
|
+
|
|
32
|
+
function baseData(overrides: Record<string, unknown> = {}) {
|
|
33
|
+
return {
|
|
34
|
+
settings: {
|
|
35
|
+
mfa: { required: false, gracePeriodDays: 7 },
|
|
36
|
+
session: { maxConcurrentSessions: 5 },
|
|
37
|
+
},
|
|
38
|
+
status: {
|
|
39
|
+
status: 'needs_attention',
|
|
40
|
+
score: 88,
|
|
41
|
+
label: 'Needs attention',
|
|
42
|
+
description: 'Some recommended controls are missing.',
|
|
43
|
+
checks: [
|
|
44
|
+
{ key: 'mfa', label: 'MFA coverage', status: 'warning', value: '1 of 2 admins' },
|
|
45
|
+
{ key: 'csrf', label: 'CSRF protection', status: 'pass', value: 'Enabled' },
|
|
46
|
+
],
|
|
47
|
+
warnings: [
|
|
48
|
+
{
|
|
49
|
+
id: 'mfa-not-required',
|
|
50
|
+
severity: 'warning',
|
|
51
|
+
category: 'auth',
|
|
52
|
+
title: 'MFA is not required in production',
|
|
53
|
+
description: 'Requiring MFA reduces account-takeover risk.',
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
lastCheckedAt: '2026-06-01T00:00:00.000Z',
|
|
57
|
+
},
|
|
58
|
+
users: {
|
|
59
|
+
totalUsers: 3,
|
|
60
|
+
totalAdmins: 2,
|
|
61
|
+
withMfa: 1,
|
|
62
|
+
withoutMfa: 2,
|
|
63
|
+
adminsWithoutMfa: 1,
|
|
64
|
+
inactive: 0,
|
|
65
|
+
},
|
|
66
|
+
apiKeys: { active: 1, withoutExpiration: 0, broadScope: 0, staleUnused90d: 0 },
|
|
67
|
+
secrets: {
|
|
68
|
+
cmsSecret: 'pass',
|
|
69
|
+
encryptionKey: 'pass',
|
|
70
|
+
rateLimitBackend: 'distributed',
|
|
71
|
+
apiKeysHashed: true,
|
|
72
|
+
webhookSecretsEncrypted: true,
|
|
73
|
+
},
|
|
74
|
+
ipAllowlist: {
|
|
75
|
+
enabled: false,
|
|
76
|
+
source: 'none',
|
|
77
|
+
ranges: [],
|
|
78
|
+
currentIp: '1.2.3.4',
|
|
79
|
+
currentIpAllowed: null,
|
|
80
|
+
},
|
|
81
|
+
failedLogins24h: 0,
|
|
82
|
+
activeSessions: 2,
|
|
83
|
+
csrfEnabled: true,
|
|
84
|
+
uploadValidation: true,
|
|
85
|
+
auditEnabled: true,
|
|
86
|
+
sessionMaxAgeHours: 168,
|
|
87
|
+
viewerHasMfa: true,
|
|
88
|
+
environment: 'production',
|
|
89
|
+
sources: {
|
|
90
|
+
ipAllowlist: { source: 'config', editable: false },
|
|
91
|
+
csrf: { source: 'computed', editable: false },
|
|
92
|
+
rateLimiting: { source: 'environment', editable: false },
|
|
93
|
+
uploadValidation: { source: 'computed', editable: false },
|
|
94
|
+
cmsSecret: { source: 'environment', editable: false },
|
|
95
|
+
encryptionKey: { source: 'environment', editable: false },
|
|
96
|
+
audit: { source: 'computed', editable: false },
|
|
97
|
+
},
|
|
98
|
+
...overrides,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
cmsApi.mockClear()
|
|
104
|
+
securityData = baseData()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('SecurityTab', () => {
|
|
108
|
+
it('renders the status card and all security sections with a disabled save bar', async () => {
|
|
109
|
+
render(<SecurityTab role="ADMIN" />)
|
|
110
|
+
|
|
111
|
+
await screen.findByText('Security Status')
|
|
112
|
+
expect(screen.getByText('Authentication & MFA')).toBeTruthy()
|
|
113
|
+
expect(screen.getByText('Session Policy')).toBeTruthy()
|
|
114
|
+
expect(screen.getByText('Access & Request Protection')).toBeTruthy()
|
|
115
|
+
expect(screen.getByText('Secrets, Encryption & Audit')).toBeTruthy()
|
|
116
|
+
// Status badge + warning surfaced.
|
|
117
|
+
expect(screen.getByText('Needs attention')).toBeTruthy()
|
|
118
|
+
expect(screen.getByText('MFA is not required in production')).toBeTruthy()
|
|
119
|
+
expect(
|
|
120
|
+
(screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement).disabled,
|
|
121
|
+
).toBe(true)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('enables Save when concurrent-session cap changes and persists via PUT /security', async () => {
|
|
125
|
+
render(<SecurityTab role="ADMIN" />)
|
|
126
|
+
await screen.findByText('Security Status')
|
|
127
|
+
|
|
128
|
+
const select = screen.getByLabelText(/Maximum concurrent sessions/) as HTMLSelectElement
|
|
129
|
+
fireEvent.change(select, { target: { value: '3' } })
|
|
130
|
+
|
|
131
|
+
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
|
|
132
|
+
expect(save.disabled).toBe(false)
|
|
133
|
+
fireEvent.click(save)
|
|
134
|
+
|
|
135
|
+
await waitFor(() =>
|
|
136
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
137
|
+
'/security',
|
|
138
|
+
expect.objectContaining({
|
|
139
|
+
method: 'PUT',
|
|
140
|
+
body: expect.stringContaining('"maxConcurrentSessions":3'),
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('blocks enabling Require MFA (lockout-safe) when the acting admin has no MFA', async () => {
|
|
147
|
+
securityData = baseData({ viewerHasMfa: false })
|
|
148
|
+
render(<SecurityTab role="ADMIN" />)
|
|
149
|
+
await screen.findByText('Security Status')
|
|
150
|
+
|
|
151
|
+
const toggle = screen.getByRole('switch', {
|
|
152
|
+
name: 'Require MFA for admin users',
|
|
153
|
+
}) as HTMLButtonElement
|
|
154
|
+
// Toggle is disabled until the admin sets up their own MFA.
|
|
155
|
+
expect(toggle.disabled).toBe(true)
|
|
156
|
+
expect(screen.getByText(/Set up MFA on your own account/i)).toBeTruthy()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('confirms before requiring MFA and then persists it (admin has MFA)', async () => {
|
|
160
|
+
render(<SecurityTab role="ADMIN" />)
|
|
161
|
+
await screen.findByText('Security Status')
|
|
162
|
+
|
|
163
|
+
fireEvent.click(screen.getByRole('switch', { name: 'Require MFA for admin users' }))
|
|
164
|
+
// Confirmation dialog appears; nothing saved yet.
|
|
165
|
+
const confirm = await screen.findByRole('button', { name: 'Require MFA' })
|
|
166
|
+
fireEvent.click(confirm)
|
|
167
|
+
|
|
168
|
+
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
|
|
169
|
+
expect(save.disabled).toBe(false)
|
|
170
|
+
fireEvent.click(save)
|
|
171
|
+
|
|
172
|
+
await waitFor(() =>
|
|
173
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
174
|
+
'/security',
|
|
175
|
+
expect.objectContaining({
|
|
176
|
+
method: 'PUT',
|
|
177
|
+
body: expect.stringContaining('"required":true'),
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('revokes other sessions through a confirmation flow', async () => {
|
|
184
|
+
render(<SecurityTab role="ADMIN" />)
|
|
185
|
+
await screen.findByText('Security Status')
|
|
186
|
+
|
|
187
|
+
fireEvent.click(screen.getByRole('button', { name: 'Revoke other sessions' }))
|
|
188
|
+
const confirm = await screen.findByRole('button', { name: 'Revoke sessions' })
|
|
189
|
+
fireEvent.click(confirm)
|
|
190
|
+
|
|
191
|
+
await waitFor(() =>
|
|
192
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
193
|
+
'/security/sessions/revoke-all',
|
|
194
|
+
expect.objectContaining({ method: 'POST' }),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
await screen.findByText('Other sessions revoked.')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('is read-only for non-admin roles', async () => {
|
|
201
|
+
render(<SecurityTab role="EDITOR" />)
|
|
202
|
+
await screen.findByText('Security Status')
|
|
203
|
+
|
|
204
|
+
expect(screen.getByText(/read-only access/i)).toBeTruthy()
|
|
205
|
+
const toggle = screen.getByRole('switch', {
|
|
206
|
+
name: 'Require MFA for admin users',
|
|
207
|
+
}) as HTMLButtonElement
|
|
208
|
+
expect(toggle.disabled).toBe(true)
|
|
209
|
+
const select = screen.getByLabelText(/Maximum concurrent sessions/) as HTMLSelectElement
|
|
210
|
+
expect(select.disabled).toBe(true)
|
|
211
|
+
})
|
|
212
|
+
})
|
package/src/views/Settings.tsx
CHANGED
|
@@ -30,6 +30,7 @@ import { RelationshipField } from '../fields/RelationshipField.js'
|
|
|
30
30
|
import { SEOConfigPanel } from '../components/SEOConfigPanel.js'
|
|
31
31
|
import { GeneralSettingsTab } from './settings/GeneralSettingsTab.js'
|
|
32
32
|
import { AppearanceTab } from './settings/AppearanceTab.js'
|
|
33
|
+
import { SecurityTab } from './settings/SecurityTab.js'
|
|
33
34
|
import { Users as UsersView } from './Users.js'
|
|
34
35
|
import { ApiKeys as ApiKeysView } from './ApiKeys.js'
|
|
35
36
|
|
|
@@ -44,7 +45,7 @@ export interface SettingsProps {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
/** Tabs that drive their own persistence (no global "Save Changes" footer). */
|
|
47
|
-
const SELF_MANAGED_TABS = new Set(['general', 'appearance', 'users', 'api-keys'])
|
|
48
|
+
const SELF_MANAGED_TABS = new Set(['general', 'appearance', 'security', 'users', 'api-keys'])
|
|
48
49
|
|
|
49
50
|
function readSettingsTabFromUrl(): string | null {
|
|
50
51
|
if (typeof window === 'undefined') return null
|
|
@@ -59,9 +60,6 @@ export function Settings({
|
|
|
59
60
|
}: SettingsProps = {}) {
|
|
60
61
|
const { data, loading, error, refetch } = useApiData<any>('/globals/settings')
|
|
61
62
|
|
|
62
|
-
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false)
|
|
63
|
-
const [sessionTimeout, setSessionTimeout] = useState(false)
|
|
64
|
-
const [ipWhitelist, setIpWhitelist] = useState(false)
|
|
65
63
|
const [activeTab, setActiveTab] = useState<string>(() => readSettingsTabFromUrl() ?? initialTab)
|
|
66
64
|
const [saving, setSaving] = useState(false)
|
|
67
65
|
|
|
@@ -138,9 +136,6 @@ export function Settings({
|
|
|
138
136
|
|
|
139
137
|
useEffect(() => {
|
|
140
138
|
if (data) {
|
|
141
|
-
setTwoFactorEnabled(data.twoFactorEnabled ?? false)
|
|
142
|
-
setSessionTimeout(data.sessionTimeout ?? false)
|
|
143
|
-
setIpWhitelist(data.ipWhitelist ?? false)
|
|
144
139
|
setAiProvider(data.aiProvider ?? 'anthropic')
|
|
145
140
|
setAiAltTags(data.aiAltTags ?? true)
|
|
146
141
|
setAiMediaCategorize(data.aiMediaCategorize ?? false)
|
|
@@ -167,9 +162,6 @@ export function Settings({
|
|
|
167
162
|
method: 'PUT',
|
|
168
163
|
body: JSON.stringify({
|
|
169
164
|
...(current.data ?? {}),
|
|
170
|
-
twoFactorEnabled,
|
|
171
|
-
sessionTimeout,
|
|
172
|
-
ipWhitelist,
|
|
173
165
|
aiProvider,
|
|
174
166
|
aiAltTags,
|
|
175
167
|
aiMediaCategorize,
|
|
@@ -351,29 +343,7 @@ export function Settings({
|
|
|
351
343
|
)}
|
|
352
344
|
|
|
353
345
|
<Tabs.Content value="security" className="space-y-4">
|
|
354
|
-
<
|
|
355
|
-
<h3 className="mb-4 text-sm font-semibold text-gray-900">Security Settings</h3>
|
|
356
|
-
<div className="space-y-4">
|
|
357
|
-
<ToggleSetting
|
|
358
|
-
label="Two-Factor Authentication"
|
|
359
|
-
description="Require 2FA for all admin users"
|
|
360
|
-
checked={twoFactorEnabled}
|
|
361
|
-
onChange={setTwoFactorEnabled}
|
|
362
|
-
/>
|
|
363
|
-
<ToggleSetting
|
|
364
|
-
label="Session Timeout"
|
|
365
|
-
description="Automatically log out inactive users after 30 minutes"
|
|
366
|
-
checked={sessionTimeout}
|
|
367
|
-
onChange={setSessionTimeout}
|
|
368
|
-
/>
|
|
369
|
-
<ToggleSetting
|
|
370
|
-
label="IP Whitelist"
|
|
371
|
-
description="Only allow access from approved IP addresses"
|
|
372
|
-
checked={ipWhitelist}
|
|
373
|
-
onChange={setIpWhitelist}
|
|
374
|
-
/>
|
|
375
|
-
</div>
|
|
376
|
-
</div>
|
|
346
|
+
<SecurityTab role={session?.user?.role} onNavigate={onNavigate} />
|
|
377
347
|
</Tabs.Content>
|
|
378
348
|
|
|
379
349
|
<Tabs.Content value="seo" className="space-y-4">
|
|
@@ -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
|
+
}
|