@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,118 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId, useState } from 'react'
|
|
4
|
+
import { MFA_GRACE_PERIOD_OPTIONS } from '@actuate-media/cms-core/security-center'
|
|
5
|
+
import { ConfirmDangerousSettingDialog, SettingsToggleRow } from './components.js'
|
|
6
|
+
import { SecurityCard, StatusRow } from './securityPrimitives.js'
|
|
7
|
+
import type { SecurityForm } from './useSecuritySettings.js'
|
|
8
|
+
|
|
9
|
+
function graceLabel(days: number): string {
|
|
10
|
+
if (days === 0) return 'Immediately'
|
|
11
|
+
if (days === 1) return '1 day'
|
|
12
|
+
return `${days} days`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function AuthMfaCard({
|
|
16
|
+
form,
|
|
17
|
+
setField,
|
|
18
|
+
canEdit,
|
|
19
|
+
viewerHasMfa,
|
|
20
|
+
withMfa,
|
|
21
|
+
totalAdmins,
|
|
22
|
+
}: {
|
|
23
|
+
form: SecurityForm
|
|
24
|
+
setField: <K extends keyof SecurityForm>(key: K, value: SecurityForm[K]) => void
|
|
25
|
+
canEdit: boolean
|
|
26
|
+
viewerHasMfa: boolean
|
|
27
|
+
withMfa: number
|
|
28
|
+
totalAdmins: number
|
|
29
|
+
}) {
|
|
30
|
+
const headingId = useId()
|
|
31
|
+
const graceId = useId()
|
|
32
|
+
const [confirmEnable, setConfirmEnable] = useState(false)
|
|
33
|
+
|
|
34
|
+
function requestMfaToggle(value: boolean) {
|
|
35
|
+
if (value && !form.mfaRequired) {
|
|
36
|
+
setConfirmEnable(true)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
setField('mfaRequired', value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Lockout-safe: the toggle can't be turned on until the acting admin has
|
|
43
|
+
// their own MFA configured (the server enforces this too).
|
|
44
|
+
const mfaToggleDisabled = !canEdit || (!form.mfaRequired && !viewerHasMfa)
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<SecurityCard
|
|
48
|
+
headingId={headingId}
|
|
49
|
+
title="Authentication & MFA"
|
|
50
|
+
description="Require multi-factor authentication for admin accounts and control the enrollment window."
|
|
51
|
+
>
|
|
52
|
+
<div className="space-y-4">
|
|
53
|
+
<SettingsToggleRow
|
|
54
|
+
label="Require MFA for admin users"
|
|
55
|
+
description="Admins must set up a TOTP authenticator app. Existing admins are prompted to enroll within the grace period."
|
|
56
|
+
checked={form.mfaRequired}
|
|
57
|
+
disabled={mfaToggleDisabled}
|
|
58
|
+
onChange={requestMfaToggle}
|
|
59
|
+
/>
|
|
60
|
+
|
|
61
|
+
<div className="flex items-center justify-between gap-4">
|
|
62
|
+
<label htmlFor={graceId} className="flex-1">
|
|
63
|
+
<span className="text-foreground text-base font-medium">Enrollment grace period</span>
|
|
64
|
+
<p className="text-muted-foreground mt-0.5 text-sm">
|
|
65
|
+
How long existing admins have to set up MFA before it is enforced.
|
|
66
|
+
</p>
|
|
67
|
+
</label>
|
|
68
|
+
<select
|
|
69
|
+
id={graceId}
|
|
70
|
+
value={form.mfaGracePeriodDays}
|
|
71
|
+
disabled={!canEdit || !form.mfaRequired}
|
|
72
|
+
onChange={(e) => setField('mfaGracePeriodDays', Number(e.target.value))}
|
|
73
|
+
className="border-border bg-input-background text-foreground focus-visible:ring-ring rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
74
|
+
>
|
|
75
|
+
{MFA_GRACE_PERIOD_OPTIONS.map((d) => (
|
|
76
|
+
<option key={d} value={d}>
|
|
77
|
+
{graceLabel(d)}
|
|
78
|
+
</option>
|
|
79
|
+
))}
|
|
80
|
+
</select>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div className="border-border mt-4 space-y-2 border-t pt-4">
|
|
85
|
+
<StatusRow
|
|
86
|
+
status={viewerHasMfa ? 'pass' : 'warning'}
|
|
87
|
+
label="Your account"
|
|
88
|
+
value={viewerHasMfa ? 'MFA enabled' : 'MFA not set up'}
|
|
89
|
+
/>
|
|
90
|
+
{!viewerHasMfa && (
|
|
91
|
+
<p className="text-muted-foreground text-sm">
|
|
92
|
+
Set up MFA on your own account before requiring it for everyone, or you may lock
|
|
93
|
+
yourself out.
|
|
94
|
+
</p>
|
|
95
|
+
)}
|
|
96
|
+
<StatusRow
|
|
97
|
+
status={totalAdmins === 0 ? 'unknown' : withMfa === totalAdmins ? 'pass' : 'warning'}
|
|
98
|
+
label="Admin MFA coverage"
|
|
99
|
+
value={totalAdmins === 0 ? 'No admins' : `${withMfa} of ${totalAdmins} admins`}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<ConfirmDangerousSettingDialog
|
|
104
|
+
open={confirmEnable}
|
|
105
|
+
title="Require MFA for all admin users?"
|
|
106
|
+
description={`Admins without MFA will be prompted to enroll and must complete setup within the grace period (${graceLabel(
|
|
107
|
+
form.mfaGracePeriodDays,
|
|
108
|
+
)}). Make sure your own account has MFA configured first.`}
|
|
109
|
+
confirmLabel="Require MFA"
|
|
110
|
+
onConfirm={() => {
|
|
111
|
+
setField('mfaRequired', true)
|
|
112
|
+
setConfirmEnable(false)
|
|
113
|
+
}}
|
|
114
|
+
onCancel={() => setConfirmEnable(false)}
|
|
115
|
+
/>
|
|
116
|
+
</SecurityCard>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,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,162 @@
|
|
|
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§ion=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
|
+
/>
|
|
110
|
+
|
|
111
|
+
<div id="security-sessions">
|
|
112
|
+
<SessionPolicyCard
|
|
113
|
+
form={form}
|
|
114
|
+
setField={setField}
|
|
115
|
+
canEdit={canEdit}
|
|
116
|
+
sessionMaxAgeHours={data.sessionMaxAgeHours}
|
|
117
|
+
activeSessions={data.activeSessions}
|
|
118
|
+
revokeState={revokeState}
|
|
119
|
+
onRevokeAll={() => void revokeAllOtherSessions()}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div id="security-ip-allowlist">
|
|
124
|
+
<AccessProtectionCard
|
|
125
|
+
ipAllowlist={data.ipAllowlist}
|
|
126
|
+
secrets={data.secrets}
|
|
127
|
+
csrfEnabled={data.csrfEnabled}
|
|
128
|
+
uploadValidation={data.uploadValidation}
|
|
129
|
+
sources={data.sources}
|
|
130
|
+
onNavigate={onNavigate}
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<div id="security-audit">
|
|
135
|
+
<SecretsAuditCard
|
|
136
|
+
secrets={data.secrets}
|
|
137
|
+
auditEnabled={data.auditEnabled}
|
|
138
|
+
failedLogins24h={data.failedLogins24h}
|
|
139
|
+
users={data.users}
|
|
140
|
+
apiKeys={data.apiKeys}
|
|
141
|
+
sources={data.sources}
|
|
142
|
+
onNavigate={onNavigate}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{saveState === 'error' && (
|
|
147
|
+
<p className="text-destructive text-sm" role="alert">
|
|
148
|
+
Failed to save security settings. Please try again.
|
|
149
|
+
</p>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
<SettingsSaveBar
|
|
153
|
+
dirty={dirty}
|
|
154
|
+
canEdit={canEdit}
|
|
155
|
+
hasErrors={hasErrors}
|
|
156
|
+
saveState={saveState}
|
|
157
|
+
onSave={() => void save()}
|
|
158
|
+
onReset={reset}
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId, useState } from 'react'
|
|
4
|
+
import { Loader2 } from 'lucide-react'
|
|
5
|
+
import { MAX_CONCURRENT_SESSION_OPTIONS } from '@actuate-media/cms-core/security-center'
|
|
6
|
+
import { ConfirmDangerousSettingDialog } from './components.js'
|
|
7
|
+
import { SecurityCard, StatusRow } from './securityPrimitives.js'
|
|
8
|
+
import type { SecurityForm } from './useSecuritySettings.js'
|
|
9
|
+
|
|
10
|
+
function sessionsLabel(n: number): string {
|
|
11
|
+
return n === 0 ? 'Unlimited' : String(n)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function SessionPolicyCard({
|
|
15
|
+
form,
|
|
16
|
+
setField,
|
|
17
|
+
canEdit,
|
|
18
|
+
sessionMaxAgeHours,
|
|
19
|
+
activeSessions,
|
|
20
|
+
revokeState,
|
|
21
|
+
onRevokeAll,
|
|
22
|
+
}: {
|
|
23
|
+
form: SecurityForm
|
|
24
|
+
setField: <K extends keyof SecurityForm>(key: K, value: SecurityForm[K]) => void
|
|
25
|
+
canEdit: boolean
|
|
26
|
+
sessionMaxAgeHours: number
|
|
27
|
+
activeSessions: number
|
|
28
|
+
revokeState: 'idle' | 'working' | 'done' | 'error'
|
|
29
|
+
onRevokeAll: () => void
|
|
30
|
+
}) {
|
|
31
|
+
const headingId = useId()
|
|
32
|
+
const maxId = useId()
|
|
33
|
+
const [confirmRevoke, setConfirmRevoke] = useState(false)
|
|
34
|
+
|
|
35
|
+
const maxAgeDays = Math.round(sessionMaxAgeHours / 24)
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<SecurityCard
|
|
39
|
+
headingId={headingId}
|
|
40
|
+
title="Session Policy"
|
|
41
|
+
description="Control how many sessions an account can hold and revoke active sessions."
|
|
42
|
+
>
|
|
43
|
+
<div className="space-y-4">
|
|
44
|
+
<div className="flex items-center justify-between gap-4">
|
|
45
|
+
<label htmlFor={maxId} className="flex-1">
|
|
46
|
+
<span className="text-foreground text-base font-medium">
|
|
47
|
+
Maximum concurrent sessions
|
|
48
|
+
</span>
|
|
49
|
+
<p className="text-muted-foreground mt-0.5 text-sm">
|
|
50
|
+
Oldest sessions are signed out when a user exceeds this. Choose Unlimited to disable
|
|
51
|
+
the cap.
|
|
52
|
+
</p>
|
|
53
|
+
</label>
|
|
54
|
+
<select
|
|
55
|
+
id={maxId}
|
|
56
|
+
value={form.maxConcurrentSessions}
|
|
57
|
+
disabled={!canEdit}
|
|
58
|
+
onChange={(e) => setField('maxConcurrentSessions', Number(e.target.value))}
|
|
59
|
+
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"
|
|
60
|
+
>
|
|
61
|
+
{MAX_CONCURRENT_SESSION_OPTIONS.map((n) => (
|
|
62
|
+
<option key={n} value={n}>
|
|
63
|
+
{sessionsLabel(n)}
|
|
64
|
+
</option>
|
|
65
|
+
))}
|
|
66
|
+
</select>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="border-border mt-4 space-y-2 border-t pt-4">
|
|
71
|
+
<StatusRow status="pass" label="Active admin sessions" value={String(activeSessions)} />
|
|
72
|
+
<StatusRow
|
|
73
|
+
status="pass"
|
|
74
|
+
label="Maximum session age"
|
|
75
|
+
value={`${maxAgeDays} day${maxAgeDays === 1 ? '' : 's'}`}
|
|
76
|
+
/>
|
|
77
|
+
<StatusRow status="pass" label="Revoke sessions on password change" value="Enabled" />
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div className="border-border mt-4 flex items-center justify-between gap-3 border-t pt-4">
|
|
81
|
+
<div>
|
|
82
|
+
<p className="text-foreground text-sm font-medium">Revoke other sessions</p>
|
|
83
|
+
<p className="text-muted-foreground text-sm">
|
|
84
|
+
Sign out every session except this one. Useful after a suspected compromise.
|
|
85
|
+
</p>
|
|
86
|
+
{revokeState === 'done' && (
|
|
87
|
+
<p className="text-success mt-1 text-sm" aria-live="polite">
|
|
88
|
+
Other sessions revoked.
|
|
89
|
+
</p>
|
|
90
|
+
)}
|
|
91
|
+
{revokeState === 'error' && (
|
|
92
|
+
<p className="text-destructive mt-1 text-sm" aria-live="polite">
|
|
93
|
+
Could not revoke sessions. Try again.
|
|
94
|
+
</p>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
disabled={!canEdit || revokeState === 'working'}
|
|
100
|
+
onClick={() => setConfirmRevoke(true)}
|
|
101
|
+
className="border-border text-foreground hover:bg-accent inline-flex shrink-0 items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
102
|
+
>
|
|
103
|
+
{revokeState === 'working' && (
|
|
104
|
+
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
|
|
105
|
+
)}
|
|
106
|
+
Revoke other sessions
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<ConfirmDangerousSettingDialog
|
|
111
|
+
open={confirmRevoke}
|
|
112
|
+
title="Revoke all other sessions?"
|
|
113
|
+
description="Every active session except your current one will be signed out immediately. Those users (and your other devices) will need to sign in again."
|
|
114
|
+
confirmLabel="Revoke sessions"
|
|
115
|
+
onConfirm={() => {
|
|
116
|
+
setConfirmRevoke(false)
|
|
117
|
+
onRevokeAll()
|
|
118
|
+
}}
|
|
119
|
+
onCancel={() => setConfirmRevoke(false)}
|
|
120
|
+
/>
|
|
121
|
+
</SecurityCard>
|
|
122
|
+
)
|
|
123
|
+
}
|