@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.
Files changed (51) hide show
  1. package/dist/__tests__/views/security-settings.render.test.d.ts +2 -0
  2. package/dist/__tests__/views/security-settings.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/views/security-settings.render.test.js +175 -0
  4. package/dist/__tests__/views/security-settings.render.test.js.map +1 -0
  5. package/dist/actuate-admin.css +1 -1
  6. package/dist/views/Settings.d.ts.map +1 -1
  7. package/dist/views/Settings.js +3 -11
  8. package/dist/views/Settings.js.map +1 -1
  9. package/dist/views/settings/AccessProtectionCard.d.ts +10 -0
  10. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -0
  11. package/dist/views/settings/AccessProtectionCard.js +20 -0
  12. package/dist/views/settings/AccessProtectionCard.js.map +1 -0
  13. package/dist/views/settings/AuthMfaCard.d.ts +10 -0
  14. package/dist/views/settings/AuthMfaCard.d.ts.map +1 -0
  15. package/dist/views/settings/AuthMfaCard.js +33 -0
  16. package/dist/views/settings/AuthMfaCard.js.map +1 -0
  17. package/dist/views/settings/SecretsAuditCard.d.ts +11 -0
  18. package/dist/views/settings/SecretsAuditCard.d.ts.map +1 -0
  19. package/dist/views/settings/SecretsAuditCard.js +22 -0
  20. package/dist/views/settings/SecretsAuditCard.js.map +1 -0
  21. package/dist/views/settings/SecurityStatusCard.d.ts +6 -0
  22. package/dist/views/settings/SecurityStatusCard.d.ts.map +1 -0
  23. package/dist/views/settings/SecurityStatusCard.js +35 -0
  24. package/dist/views/settings/SecurityStatusCard.js.map +1 -0
  25. package/dist/views/settings/SecurityTab.d.ts +5 -0
  26. package/dist/views/settings/SecurityTab.d.ts.map +1 -0
  27. package/dist/views/settings/SecurityTab.js +41 -0
  28. package/dist/views/settings/SecurityTab.js.map +1 -0
  29. package/dist/views/settings/SessionPolicyCard.d.ts +11 -0
  30. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -0
  31. package/dist/views/settings/SessionPolicyCard.js +21 -0
  32. package/dist/views/settings/SessionPolicyCard.js.map +1 -0
  33. package/dist/views/settings/securityPrimitives.d.ts +21 -0
  34. package/dist/views/settings/securityPrimitives.d.ts.map +1 -0
  35. package/dist/views/settings/securityPrimitives.js +31 -0
  36. package/dist/views/settings/securityPrimitives.js.map +1 -0
  37. package/dist/views/settings/useSecuritySettings.d.ts +37 -0
  38. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -0
  39. package/dist/views/settings/useSecuritySettings.js +154 -0
  40. package/dist/views/settings/useSecuritySettings.js.map +1 -0
  41. package/package.json +2 -2
  42. package/src/__tests__/views/security-settings.render.test.tsx +212 -0
  43. package/src/views/Settings.tsx +3 -33
  44. package/src/views/settings/AccessProtectionCard.tsx +143 -0
  45. package/src/views/settings/AuthMfaCard.tsx +118 -0
  46. package/src/views/settings/SecretsAuditCard.tsx +129 -0
  47. package/src/views/settings/SecurityStatusCard.tsx +110 -0
  48. package/src/views/settings/SecurityTab.tsx +162 -0
  49. package/src/views/settings/SessionPolicyCard.tsx +123 -0
  50. package/src/views/settings/securityPrimitives.tsx +75 -0
  51. 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":"AAAA,YAAY,CAAA;AAEZ,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AACjE,OAAO,EACL,wBAAwB,GAGzB,MAAM,yCAAyC,CAAA;AAChD,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAYzC,MAAM,UAAU,GAAiB;IAC/B,WAAW,EAAE,KAAK;IAClB,kBAAkB,EAAE,CAAC;IACrB,qBAAqB,EAAE,CAAC;CACzB,CAAA;AAED,SAAS,gBAAgB,CAAC,CAAmB;IAC3C,OAAO;QACL,WAAW,EAAE,CAAC,CAAC,GAAG,CAAC,QAAQ;QAC3B,kBAAkB,EAAE,CAAC,CAAC,GAAG,CAAC,eAAe;QACzC,qBAAqB,EAAE,CAAC,CAAC,OAAO,CAAC,qBAAqB;KACvD,CAAA;AACH,CAAC;AAED,SAAS,cAAc,CAAC,CAAe;IACrC,OAAO;QACL,GAAG,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAC,WAAW,EAAE,eAAe,EAAE,CAAC,CAAC,kBAAkB,EAAE;QACvE,OAAO,EAAE,EAAE,qBAAqB,EAAE,CAAC,CAAC,qBAAqB,EAAE;KAC5D,CAAA;AACH,CAAC;AAqBD;;;;;;GAMG;AACH,MAAM,UAAU,mBAAmB,CAAC,OAAgB;IAClD,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAA;IAC5C,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,GAAG,QAAQ,CAAgB,IAAI,CAAC,CAAA;IAC/D,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAA4B,IAAI,CAAC,CAAA;IACjE,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,GAAG,QAAQ,CAAe,UAAU,CAAC,CAAA;IAClE,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAe,UAAU,CAAC,CAAA;IAC1D,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,GAAG,QAAQ,CAAY,MAAM,CAAC,CAAA;IAC7D,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,GAAG,QAAQ,CAAgB,IAAI,CAAC,CAAA;IAC/D,MAAM,CAAC,WAAW,EAAE,cAAc,CAAC,GAAG,QAAQ,CAAwC,MAAM,CAAC,CAAA;IAC7F,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAA;IAE7C,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,SAAS,GAAG,KAAK,CAAA;QACrB,UAAU,CAAC,IAAI,CAAC,CAAA;QAChB,YAAY,CAAC,IAAI,CAAC,CAAA;QAClB,MAAM,CAAqB,WAAW,CAAC;aACpC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE;YACZ,IAAI,SAAS;gBAAE,OAAM;YACrB,IAAI,GAAG,CAAC,KAAK,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;gBAC3B,YAAY,CAAC,GAAG,CAAC,KAAK,IAAI,mCAAmC,CAAC,CAAA;gBAC9D,OAAM;YACR,CAAC;YACD,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;YACjB,MAAM,CAAC,GAAG,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;YAC7C,WAAW,CAAC,CAAC,CAAC,CAAA;YACd,OAAO,CAAC,CAAC,CAAC,CAAA;QACZ,CAAC,CAAC;aACD,KAAK,CAAC,GAAG,EAAE;YACV,IAAI,CAAC,SAAS;gBAAE,YAAY,CAAC,mCAAmC,CAAC,CAAA;QACnE,CAAC,CAAC;aACD,OAAO,CAAC,GAAG,EAAE;YACZ,IAAI,CAAC,SAAS;gBAAE,UAAU,CAAC,KAAK,CAAC,CAAA;QACnC,CAAC,CAAC,CAAA;QACJ,OAAO,GAAG,EAAE;YACV,SAAS,GAAG,IAAI,CAAA;QAClB,CAAC,CAAA;IACH,CAAC,EAAE,CAAC,SAAS,CAAC,CAAC,CAAA;IAEf,MAAM,QAAQ,GAAG,WAAW,CAAC,CAA+B,GAAM,EAAE,KAAsB,EAAE,EAAE;QAC5F,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAA;QAC9C,YAAY,CAAC,MAAM,CAAC,CAAA;IACtB,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,MAAM,KAAK,GAAG,OAAO,CACnB,GAAG,EAAE,CAAE,MAAM,CAAC,IAAI,CAAC,IAAI,CAA+B,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,EAC3F,CAAC,IAAI,EAAE,QAAQ,CAAC,CACjB,CAAA;IAED,MAAM,MAAM,GAAG,OAAO,CAAoB,GAAG,EAAE;QAC7C,MAAM,IAAI,GAAG,wBAAwB,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC,CAAA;QAC3D,uEAAuE;QACvE,qEAAqE;QACrE,IAAI,IAAI,CAAC,WAAW,IAAI,CAAC,QAAQ,CAAC,WAAW,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,CAAC;YAC5E,IAAI,CAAC,IAAI,CAAC;gBACR,KAAK,EAAE,aAAa;gBACpB,OAAO,EACL,2GAA2G;gBAC7G,QAAQ,EAAE,OAAO;aAClB,CAAC,CAAA;QACJ,CAAC;QACD,OAAO,IAAI,CAAA;IACb,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC,CAAA;IAEtC,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAA;IAE5D,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,IAAI,EAAE;QAClC,IAAI,CAAC,OAAO,IAAI,SAAS,IAAI,CAAC,KAAK;YAAE,OAAM;QAC3C,YAAY,CAAC,QAAQ,CAAC,CAAA;QACtB,YAAY,CAAC,IAAI,CAAC,CAAA;QAClB,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,MAAM,CAAqB,WAAW,EAAE;gBACxD,MAAM,EAAE,KAAK;gBACb,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC;aAC3C,CAAC,CAAA;YACF,IAAI,GAAG,CAAC,KAAK,IAAI,CAAC,GAAG,CAAC,IAAI;gBAAE,MAAM,IAAI,KAAK,CAAC,GAAG,CAAC,KAAK,IAAI,mCAAmC,CAAC,CAAA;YAC7F,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;YACjB,MAAM,CAAC,GAAG,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;YAC7C,WAAW,CAAC,CAAC,CAAC,CAAA;YACd,OAAO,CAAC,CAAC,CAAC,CAAA;YACV,YAAY,CAAC,SAAS,CAAC,CAAA;QACzB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,YAAY,CAAC,OAAO,CAAC,CAAA;YACrB,YAAY,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,mCAAmC,CAAC,CAAA;QACxF,CAAC;IACH,CAAC,EAAE,CAAC,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,CAAA;IAErC,MAAM,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE;QAC7B,OAAO,CAAC,QAAQ,CAAC,CAAA;QACjB,YAAY,CAAC,MAAM,CAAC,CAAA;QACpB,YAAY,CAAC,IAAI,CAAC,CAAA;IACpB,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAA;IAEd,MAAM,OAAO,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;IAEjE,MAAM,sBAAsB,GAAG,WAAW,CAAC,KAAK,IAA4B,EAAE;QAC5E,cAAc,CAAC,SAAS,CAAC,CAAA;QACzB,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,MAAM,CAAsB,+BAA+B,EAAE;gBAC7E,MAAM,EAAE,MAAM;aACf,CAAC,CAAA;YACF,IAAI,GAAG,CAAC,KAAK;gBAAE,MAAM,IAAI,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;YACzC,cAAc,CAAC,MAAM,CAAC,CAAA;YACtB,oCAAoC;YACpC,YAAY,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAA;YAC1B,OAAO,GAAG,CAAC,IAAI,EAAE,OAAO,IAAI,CAAC,CAAA;QAC/B,CAAC;QAAC,MAAM,CAAC;YACP,cAAc,CAAC,OAAO,CAAC,CAAA;YACvB,OAAO,IAAI,CAAA;QACb,CAAC;IACH,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,OAAO;QACL,OAAO;QACP,SAAS;QACT,IAAI;QACJ,IAAI;QACJ,QAAQ;QACR,KAAK;QACL,MAAM;QACN,SAAS;QACT,OAAO;QACP,SAAS;QACT,SAAS;QACT,IAAI;QACJ,KAAK;QACL,OAAO;QACP,WAAW;QACX,sBAAsB;KACvB,CAAA;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-admin",
3
- "version": "0.27.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.36.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
+ })
@@ -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
- <div className="rounded-lg border border-gray-200 bg-white p-4">
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
+ }