@actuate-media/cms-admin 0.27.0 → 0.29.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 (90) hide show
  1. package/dist/AdminRoot.d.ts +7 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +7 -2
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/views/account-security.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/account-security.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/account-security.render.test.js +98 -0
  8. package/dist/__tests__/views/account-security.render.test.js.map +1 -0
  9. package/dist/__tests__/views/login-mfa.render.test.d.ts +2 -0
  10. package/dist/__tests__/views/login-mfa.render.test.d.ts.map +1 -0
  11. package/dist/__tests__/views/login-mfa.render.test.js +73 -0
  12. package/dist/__tests__/views/login-mfa.render.test.js.map +1 -0
  13. package/dist/__tests__/views/security-settings.render.test.d.ts +2 -0
  14. package/dist/__tests__/views/security-settings.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/security-settings.render.test.js +175 -0
  16. package/dist/__tests__/views/security-settings.render.test.js.map +1 -0
  17. package/dist/actuate-admin.css +1 -1
  18. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  19. package/dist/components/Breadcrumbs.js +1 -0
  20. package/dist/components/Breadcrumbs.js.map +1 -1
  21. package/dist/views/Login.d.ts +16 -1
  22. package/dist/views/Login.d.ts.map +1 -1
  23. package/dist/views/Login.js +55 -3
  24. package/dist/views/Login.js.map +1 -1
  25. package/dist/views/Profile.d.ts +12 -0
  26. package/dist/views/Profile.d.ts.map +1 -0
  27. package/dist/views/Profile.js +16 -0
  28. package/dist/views/Profile.js.map +1 -0
  29. package/dist/views/Settings.d.ts.map +1 -1
  30. package/dist/views/Settings.js +3 -11
  31. package/dist/views/Settings.js.map +1 -1
  32. package/dist/views/profile/AccountSecurity.d.ts +2 -0
  33. package/dist/views/profile/AccountSecurity.d.ts.map +1 -0
  34. package/dist/views/profile/AccountSecurity.js +117 -0
  35. package/dist/views/profile/AccountSecurity.js.map +1 -0
  36. package/dist/views/profile/useMfaEnrollment.d.ts +35 -0
  37. package/dist/views/profile/useMfaEnrollment.d.ts.map +1 -0
  38. package/dist/views/profile/useMfaEnrollment.js +103 -0
  39. package/dist/views/profile/useMfaEnrollment.js.map +1 -0
  40. package/dist/views/settings/AccessProtectionCard.d.ts +10 -0
  41. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -0
  42. package/dist/views/settings/AccessProtectionCard.js +20 -0
  43. package/dist/views/settings/AccessProtectionCard.js.map +1 -0
  44. package/dist/views/settings/AuthMfaCard.d.ts +11 -0
  45. package/dist/views/settings/AuthMfaCard.d.ts.map +1 -0
  46. package/dist/views/settings/AuthMfaCard.js +34 -0
  47. package/dist/views/settings/AuthMfaCard.js.map +1 -0
  48. package/dist/views/settings/SecretsAuditCard.d.ts +11 -0
  49. package/dist/views/settings/SecretsAuditCard.d.ts.map +1 -0
  50. package/dist/views/settings/SecretsAuditCard.js +22 -0
  51. package/dist/views/settings/SecretsAuditCard.js.map +1 -0
  52. package/dist/views/settings/SecurityStatusCard.d.ts +6 -0
  53. package/dist/views/settings/SecurityStatusCard.d.ts.map +1 -0
  54. package/dist/views/settings/SecurityStatusCard.js +35 -0
  55. package/dist/views/settings/SecurityStatusCard.js.map +1 -0
  56. package/dist/views/settings/SecurityTab.d.ts +5 -0
  57. package/dist/views/settings/SecurityTab.d.ts.map +1 -0
  58. package/dist/views/settings/SecurityTab.js +41 -0
  59. package/dist/views/settings/SecurityTab.js.map +1 -0
  60. package/dist/views/settings/SessionPolicyCard.d.ts +11 -0
  61. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -0
  62. package/dist/views/settings/SessionPolicyCard.js +21 -0
  63. package/dist/views/settings/SessionPolicyCard.js.map +1 -0
  64. package/dist/views/settings/securityPrimitives.d.ts +21 -0
  65. package/dist/views/settings/securityPrimitives.d.ts.map +1 -0
  66. package/dist/views/settings/securityPrimitives.js +31 -0
  67. package/dist/views/settings/securityPrimitives.js.map +1 -0
  68. package/dist/views/settings/useSecuritySettings.d.ts +37 -0
  69. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -0
  70. package/dist/views/settings/useSecuritySettings.js +154 -0
  71. package/dist/views/settings/useSecuritySettings.js.map +1 -0
  72. package/package.json +3 -2
  73. package/src/AdminRoot.tsx +18 -0
  74. package/src/__tests__/views/account-security.render.test.tsx +140 -0
  75. package/src/__tests__/views/login-mfa.render.test.tsx +88 -0
  76. package/src/__tests__/views/security-settings.render.test.tsx +212 -0
  77. package/src/components/Breadcrumbs.tsx +1 -0
  78. package/src/views/Login.tsx +247 -94
  79. package/src/views/Profile.tsx +54 -0
  80. package/src/views/Settings.tsx +3 -33
  81. package/src/views/profile/AccountSecurity.tsx +406 -0
  82. package/src/views/profile/useMfaEnrollment.ts +120 -0
  83. package/src/views/settings/AccessProtectionCard.tsx +143 -0
  84. package/src/views/settings/AuthMfaCard.tsx +133 -0
  85. package/src/views/settings/SecretsAuditCard.tsx +129 -0
  86. package/src/views/settings/SecurityStatusCard.tsx +110 -0
  87. package/src/views/settings/SecurityTab.tsx +163 -0
  88. package/src/views/settings/SessionPolicyCard.tsx +123 -0
  89. package/src/views/settings/securityPrimitives.tsx +75 -0
  90. 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,133 @@
1
+ 'use client'
2
+
3
+ import { useId, useState } from 'react'
4
+ import { ShieldCheck } from 'lucide-react'
5
+ import { MFA_GRACE_PERIOD_OPTIONS } from '@actuate-media/cms-core/security-center'
6
+ import { ConfirmDangerousSettingDialog, SettingsToggleRow } from './components.js'
7
+ import { SecurityCard, StatusRow } from './securityPrimitives.js'
8
+ import type { SecurityForm } from './useSecuritySettings.js'
9
+
10
+ function graceLabel(days: number): string {
11
+ if (days === 0) return 'Immediately'
12
+ if (days === 1) return '1 day'
13
+ return `${days} days`
14
+ }
15
+
16
+ export function AuthMfaCard({
17
+ form,
18
+ setField,
19
+ canEdit,
20
+ viewerHasMfa,
21
+ withMfa,
22
+ totalAdmins,
23
+ onNavigate,
24
+ }: {
25
+ form: SecurityForm
26
+ setField: <K extends keyof SecurityForm>(key: K, value: SecurityForm[K]) => void
27
+ canEdit: boolean
28
+ viewerHasMfa: boolean
29
+ withMfa: number
30
+ totalAdmins: number
31
+ onNavigate?: (path: string) => void
32
+ }) {
33
+ const headingId = useId()
34
+ const graceId = useId()
35
+ const [confirmEnable, setConfirmEnable] = useState(false)
36
+
37
+ function requestMfaToggle(value: boolean) {
38
+ if (value && !form.mfaRequired) {
39
+ setConfirmEnable(true)
40
+ return
41
+ }
42
+ setField('mfaRequired', value)
43
+ }
44
+
45
+ // Lockout-safe: the toggle can't be turned on until the acting admin has
46
+ // their own MFA configured (the server enforces this too).
47
+ const mfaToggleDisabled = !canEdit || (!form.mfaRequired && !viewerHasMfa)
48
+
49
+ return (
50
+ <SecurityCard
51
+ headingId={headingId}
52
+ title="Authentication & MFA"
53
+ description="Require multi-factor authentication for admin accounts and control the enrollment window."
54
+ >
55
+ <div className="space-y-4">
56
+ <SettingsToggleRow
57
+ label="Require MFA for admin users"
58
+ description="Admins must set up a TOTP authenticator app. Existing admins are prompted to enroll within the grace period."
59
+ checked={form.mfaRequired}
60
+ disabled={mfaToggleDisabled}
61
+ onChange={requestMfaToggle}
62
+ />
63
+
64
+ <div className="flex items-center justify-between gap-4">
65
+ <label htmlFor={graceId} className="flex-1">
66
+ <span className="text-foreground text-base font-medium">Enrollment grace period</span>
67
+ <p className="text-muted-foreground mt-0.5 text-sm">
68
+ How long existing admins have to set up MFA before it is enforced.
69
+ </p>
70
+ </label>
71
+ <select
72
+ id={graceId}
73
+ value={form.mfaGracePeriodDays}
74
+ disabled={!canEdit || !form.mfaRequired}
75
+ onChange={(e) => setField('mfaGracePeriodDays', Number(e.target.value))}
76
+ 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"
77
+ >
78
+ {MFA_GRACE_PERIOD_OPTIONS.map((d) => (
79
+ <option key={d} value={d}>
80
+ {graceLabel(d)}
81
+ </option>
82
+ ))}
83
+ </select>
84
+ </div>
85
+ </div>
86
+
87
+ <div className="border-border mt-4 space-y-2 border-t pt-4">
88
+ <StatusRow
89
+ status={viewerHasMfa ? 'pass' : 'warning'}
90
+ label="Your account"
91
+ value={viewerHasMfa ? 'MFA enabled' : 'MFA not set up'}
92
+ />
93
+ {!viewerHasMfa && (
94
+ <div className="space-y-2">
95
+ <p className="text-muted-foreground text-sm">
96
+ Set up MFA on your own account before requiring it for everyone, or you may lock
97
+ yourself out.
98
+ </p>
99
+ {onNavigate && (
100
+ <button
101
+ type="button"
102
+ onClick={() => onNavigate('/profile')}
103
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors"
104
+ >
105
+ <ShieldCheck size={15} aria-hidden="true" />
106
+ Set up MFA
107
+ </button>
108
+ )}
109
+ </div>
110
+ )}
111
+ <StatusRow
112
+ status={totalAdmins === 0 ? 'unknown' : withMfa === totalAdmins ? 'pass' : 'warning'}
113
+ label="Admin MFA coverage"
114
+ value={totalAdmins === 0 ? 'No admins' : `${withMfa} of ${totalAdmins} admins`}
115
+ />
116
+ </div>
117
+
118
+ <ConfirmDangerousSettingDialog
119
+ open={confirmEnable}
120
+ title="Require MFA for all admin users?"
121
+ description={`Admins without MFA will be prompted to enroll and must complete setup within the grace period (${graceLabel(
122
+ form.mfaGracePeriodDays,
123
+ )}). Make sure your own account has MFA configured first.`}
124
+ confirmLabel="Require MFA"
125
+ onConfirm={() => {
126
+ setField('mfaRequired', true)
127
+ setConfirmEnable(false)
128
+ }}
129
+ onCancel={() => setConfirmEnable(false)}
130
+ />
131
+ </SecurityCard>
132
+ )
133
+ }
@@ -0,0 +1,129 @@
1
+ 'use client'
2
+
3
+ import { useId } from 'react'
4
+ import type {
5
+ ApiKeysSecuritySummary,
6
+ CheckStatus,
7
+ SecretsStatus,
8
+ SecuritySourceMetadata,
9
+ UsersSecuritySummary,
10
+ } from '@actuate-media/cms-core/security-center'
11
+ import { SettingsSourceBadge } from './components.js'
12
+ import { SecurityCard, StatusRow } from './securityPrimitives.js'
13
+
14
+ function secretValue(status: CheckStatus): string {
15
+ switch (status) {
16
+ case 'pass':
17
+ return 'Configured'
18
+ case 'warning':
19
+ return 'Not configured'
20
+ case 'fail':
21
+ return 'Missing / invalid'
22
+ default:
23
+ return 'Unknown'
24
+ }
25
+ }
26
+
27
+ export function SecretsAuditCard({
28
+ secrets,
29
+ auditEnabled,
30
+ failedLogins24h,
31
+ users,
32
+ apiKeys,
33
+ sources,
34
+ onNavigate,
35
+ }: {
36
+ secrets: SecretsStatus
37
+ auditEnabled: boolean
38
+ failedLogins24h: number
39
+ users: UsersSecuritySummary
40
+ apiKeys: ApiKeysSecuritySummary
41
+ sources: Record<string, SecuritySourceMetadata>
42
+ onNavigate?: (path: string) => void
43
+ }) {
44
+ const headingId = useId()
45
+
46
+ return (
47
+ <SecurityCard
48
+ headingId={headingId}
49
+ title="Secrets, Encryption & Audit"
50
+ description="Platform secret status (values are never shown), audit logging, and cross-module risk."
51
+ >
52
+ <div className="space-y-2">
53
+ <StatusRow
54
+ status={secrets.cmsSecret}
55
+ label="CMS signing secret"
56
+ value={secretValue(secrets.cmsSecret)}
57
+ badge={<SettingsSourceBadge meta={sources.cmsSecret} />}
58
+ />
59
+ <StatusRow
60
+ status={secrets.encryptionKey}
61
+ label="Encryption key"
62
+ value={secretValue(secrets.encryptionKey)}
63
+ badge={<SettingsSourceBadge meta={sources.encryptionKey} />}
64
+ />
65
+ <StatusRow status="pass" label="API keys hashed at rest" value="Yes" />
66
+ <StatusRow
67
+ status={secrets.webhookSecretsEncrypted ? 'pass' : 'warning'}
68
+ label="Webhook secrets encrypted"
69
+ value={secrets.webhookSecretsEncrypted ? 'Yes' : 'Plaintext fallback'}
70
+ />
71
+ </div>
72
+
73
+ <div className="border-border mt-4 space-y-2 border-t pt-4">
74
+ <StatusRow
75
+ status={auditEnabled ? 'pass' : 'warning'}
76
+ label="Audit logging"
77
+ value={auditEnabled ? 'Enabled' : 'Disabled'}
78
+ badge={<SettingsSourceBadge meta={sources.audit} />}
79
+ />
80
+ <StatusRow
81
+ status={failedLogins24h >= 20 ? 'warning' : 'pass'}
82
+ label="Failed logins (24h)"
83
+ value={String(failedLogins24h)}
84
+ />
85
+ </div>
86
+
87
+ {/* Cross-module summaries — link out, never duplicate management here. */}
88
+ <div className="border-border mt-4 space-y-3 border-t pt-4">
89
+ <div className="flex items-start justify-between gap-3">
90
+ <div className="text-sm">
91
+ <p className="text-foreground font-medium">Users</p>
92
+ <p className="text-muted-foreground">
93
+ {users.totalAdmins} admin{users.totalAdmins === 1 ? '' : 's'},{' '}
94
+ {users.adminsWithoutMfa} without MFA
95
+ {users.inactive > 0 ? `, ${users.inactive} inactive` : ''}
96
+ </p>
97
+ </div>
98
+ {onNavigate && (
99
+ <button
100
+ type="button"
101
+ onClick={() => onNavigate('/settings?tab=users')}
102
+ className="text-brand shrink-0 text-sm hover:underline"
103
+ >
104
+ Review users →
105
+ </button>
106
+ )}
107
+ </div>
108
+ <div className="flex items-start justify-between gap-3">
109
+ <div className="text-sm">
110
+ <p className="text-foreground font-medium">API keys</p>
111
+ <p className="text-muted-foreground">
112
+ {apiKeys.active} active, {apiKeys.broadScope} broad-scope, {apiKeys.withoutExpiration}{' '}
113
+ without expiry
114
+ </p>
115
+ </div>
116
+ {onNavigate && (
117
+ <button
118
+ type="button"
119
+ onClick={() => onNavigate('/settings?tab=api-keys')}
120
+ className="text-brand shrink-0 text-sm hover:underline"
121
+ >
122
+ Review API keys →
123
+ </button>
124
+ )}
125
+ </div>
126
+ </div>
127
+ </SecurityCard>
128
+ )
129
+ }
@@ -0,0 +1,110 @@
1
+ 'use client'
2
+
3
+ import { useId } from 'react'
4
+ import { Shield, ShieldAlert, ShieldCheck, ShieldX } from 'lucide-react'
5
+ import type { SecurityStatus, SecurityWarning } from '@actuate-media/cms-core/security-center'
6
+ import { CheckIcon } from './securityPrimitives.js'
7
+
8
+ const LEVEL_STYLES: Record<
9
+ SecurityStatus['status'],
10
+ { pill: string; Icon: typeof Shield; word: string }
11
+ > = {
12
+ secure: {
13
+ pill: 'bg-success/15 text-success border-success/30',
14
+ Icon: ShieldCheck,
15
+ word: 'Secure',
16
+ },
17
+ needs_attention: {
18
+ pill: 'bg-warning/15 text-warning border-warning/30',
19
+ Icon: ShieldAlert,
20
+ word: 'Needs attention',
21
+ },
22
+ at_risk: {
23
+ pill: 'bg-destructive/15 text-destructive border-destructive/30',
24
+ Icon: ShieldX,
25
+ word: 'At risk',
26
+ },
27
+ unknown: { pill: 'bg-muted text-muted-foreground border-border', Icon: Shield, word: 'Unknown' },
28
+ }
29
+
30
+ const SEVERITY_STYLES: Record<SecurityWarning['severity'], string> = {
31
+ critical: 'border-destructive/30 bg-destructive/10 text-destructive',
32
+ warning: 'border-warning/30 bg-warning/10 text-warning',
33
+ info: 'border-border bg-muted text-muted-foreground',
34
+ }
35
+
36
+ export function SecurityStatusCard({
37
+ status,
38
+ onNavigate,
39
+ }: {
40
+ status: SecurityStatus
41
+ onNavigate?: (path: string) => void
42
+ }) {
43
+ const headingId = useId()
44
+ const style = LEVEL_STYLES[status.status]
45
+ const { Icon } = style
46
+
47
+ return (
48
+ <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
49
+ <div className="mb-1 flex items-start justify-between gap-3">
50
+ <h3 id={headingId} className="text-foreground text-base font-medium">
51
+ Security Status
52
+ </h3>
53
+ <span
54
+ className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-sm font-medium ${style.pill}`}
55
+ >
56
+ <Icon size={15} aria-hidden="true" />
57
+ {/* Visible text label means the status never relies on color alone. */}
58
+ {style.word}
59
+ </span>
60
+ </div>
61
+ <p className="text-muted-foreground mb-4 text-sm">{status.description}</p>
62
+
63
+ <div className="grid grid-cols-1 gap-x-6 gap-y-1 sm:grid-cols-2">
64
+ {status.checks.map((check) => (
65
+ <button
66
+ key={check.key}
67
+ type="button"
68
+ disabled={!check.actionHref || !onNavigate}
69
+ onClick={() => check.actionHref && onNavigate?.(check.actionHref)}
70
+ className="enabled:hover:bg-accent/50 flex items-center justify-between gap-3 rounded-md py-1.5 text-left text-sm disabled:cursor-default"
71
+ >
72
+ <span className="flex items-center gap-2">
73
+ <CheckIcon status={check.status} />
74
+ <span className="text-foreground">{check.label}</span>
75
+ </span>
76
+ {check.value !== undefined && (
77
+ <span className="text-muted-foreground text-right">{check.value}</span>
78
+ )}
79
+ </button>
80
+ ))}
81
+ </div>
82
+
83
+ {status.warnings.length > 0 && (
84
+ <div className="mt-4 space-y-2" aria-live="polite">
85
+ {status.warnings.map((w) => (
86
+ <div
87
+ key={w.id}
88
+ role="note"
89
+ className={`flex items-start justify-between gap-3 rounded-lg border p-3 text-sm ${SEVERITY_STYLES[w.severity]}`}
90
+ >
91
+ <div>
92
+ <p className="font-medium">{w.title}</p>
93
+ <p className="mt-0.5 opacity-90">{w.description}</p>
94
+ </div>
95
+ {w.actionHref && w.actionLabel && onNavigate && (
96
+ <button
97
+ type="button"
98
+ onClick={() => onNavigate(w.actionHref!)}
99
+ className="shrink-0 self-center rounded-md border border-current/30 px-2.5 py-1 text-xs font-medium hover:opacity-80"
100
+ >
101
+ {w.actionLabel}
102
+ </button>
103
+ )}
104
+ </div>
105
+ ))}
106
+ </div>
107
+ )}
108
+ </section>
109
+ )
110
+ }
@@ -0,0 +1,163 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+ import { Loader2, ShieldAlert } from 'lucide-react'
5
+ import { SettingsSaveBar, SettingsValidationSummary } from './components.js'
6
+ import { AccessProtectionCard } from './AccessProtectionCard.js'
7
+ import { AuthMfaCard } from './AuthMfaCard.js'
8
+ import { SecretsAuditCard } from './SecretsAuditCard.js'
9
+ import { SecurityStatusCard } from './SecurityStatusCard.js'
10
+ import { SessionPolicyCard } from './SessionPolicyCard.js'
11
+ import { useSecuritySettings } from './useSecuritySettings.js'
12
+
13
+ function CardSkeleton() {
14
+ return (
15
+ <div className="border-border bg-card animate-pulse rounded-lg border p-4">
16
+ <div className="bg-muted mb-3 h-4 w-40 rounded" />
17
+ <div className="bg-muted mb-4 h-3 w-72 rounded" />
18
+ <div className="space-y-3">
19
+ <div className="bg-muted h-3 w-full rounded" />
20
+ <div className="bg-muted h-3 w-2/3 rounded" />
21
+ </div>
22
+ </div>
23
+ )
24
+ }
25
+
26
+ export function SecurityTab({
27
+ role,
28
+ onNavigate,
29
+ }: {
30
+ role?: string
31
+ onNavigate?: (path: string) => void
32
+ }) {
33
+ const canEdit = role === 'ADMIN'
34
+ const {
35
+ loading,
36
+ loadError,
37
+ data,
38
+ form,
39
+ setField,
40
+ dirty,
41
+ issues,
42
+ hasErrors,
43
+ saveState,
44
+ save,
45
+ reset,
46
+ revokeState,
47
+ revokeAllOtherSessions,
48
+ } = useSecuritySettings(canEdit)
49
+
50
+ // Deep-link support: /settings?tab=security&section=sessions|ip-allowlist|audit
51
+ useEffect(() => {
52
+ if (loading || typeof window === 'undefined') return
53
+ const section = new URLSearchParams(window.location.search).get('section')
54
+ if (!section) return
55
+ const el = document.getElementById(`security-${section}`)
56
+ el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
57
+ }, [loading])
58
+
59
+ if (loading) {
60
+ return (
61
+ <div className="space-y-4">
62
+ <CardSkeleton />
63
+ <CardSkeleton />
64
+ <CardSkeleton />
65
+ </div>
66
+ )
67
+ }
68
+
69
+ if (loadError || !data) {
70
+ const forbidden = /forbidden|permission|insufficient/i.test(loadError ?? '')
71
+ return (
72
+ <div className="border-border bg-card text-muted-foreground flex items-start gap-3 rounded-lg border p-6 text-sm">
73
+ <ShieldAlert size={20} className="text-warning mt-0.5 shrink-0" aria-hidden="true" />
74
+ <div>
75
+ <p className="text-foreground font-medium">
76
+ {forbidden
77
+ ? 'You do not have access to security settings'
78
+ : 'Security settings unavailable'}
79
+ </p>
80
+ <p className="mt-1">
81
+ {forbidden
82
+ ? 'Ask an administrator to review the Security tab.'
83
+ : (loadError ?? 'The security service could not be reached. Try again shortly.')}
84
+ </p>
85
+ </div>
86
+ </div>
87
+ )
88
+ }
89
+
90
+ return (
91
+ <div className="space-y-4 pb-4">
92
+ <SecurityStatusCard status={data.status} onNavigate={onNavigate} />
93
+
94
+ {issues.length > 0 && <SettingsValidationSummary issues={issues} />}
95
+
96
+ {!canEdit && (
97
+ <p className="text-muted-foreground text-sm">
98
+ You have read-only access. Only administrators can change security settings.
99
+ </p>
100
+ )}
101
+
102
+ <AuthMfaCard
103
+ form={form}
104
+ setField={setField}
105
+ canEdit={canEdit}
106
+ viewerHasMfa={data.viewerHasMfa}
107
+ withMfa={data.users.withMfa}
108
+ totalAdmins={data.users.totalAdmins}
109
+ onNavigate={onNavigate}
110
+ />
111
+
112
+ <div id="security-sessions">
113
+ <SessionPolicyCard
114
+ form={form}
115
+ setField={setField}
116
+ canEdit={canEdit}
117
+ sessionMaxAgeHours={data.sessionMaxAgeHours}
118
+ activeSessions={data.activeSessions}
119
+ revokeState={revokeState}
120
+ onRevokeAll={() => void revokeAllOtherSessions()}
121
+ />
122
+ </div>
123
+
124
+ <div id="security-ip-allowlist">
125
+ <AccessProtectionCard
126
+ ipAllowlist={data.ipAllowlist}
127
+ secrets={data.secrets}
128
+ csrfEnabled={data.csrfEnabled}
129
+ uploadValidation={data.uploadValidation}
130
+ sources={data.sources}
131
+ onNavigate={onNavigate}
132
+ />
133
+ </div>
134
+
135
+ <div id="security-audit">
136
+ <SecretsAuditCard
137
+ secrets={data.secrets}
138
+ auditEnabled={data.auditEnabled}
139
+ failedLogins24h={data.failedLogins24h}
140
+ users={data.users}
141
+ apiKeys={data.apiKeys}
142
+ sources={data.sources}
143
+ onNavigate={onNavigate}
144
+ />
145
+ </div>
146
+
147
+ {saveState === 'error' && (
148
+ <p className="text-destructive text-sm" role="alert">
149
+ Failed to save security settings. Please try again.
150
+ </p>
151
+ )}
152
+
153
+ <SettingsSaveBar
154
+ dirty={dirty}
155
+ canEdit={canEdit}
156
+ hasErrors={hasErrors}
157
+ saveState={saveState}
158
+ onSave={() => void save()}
159
+ onReset={reset}
160
+ />
161
+ </div>
162
+ )
163
+ }