@actuate-media/cms-admin 0.26.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 (105) hide show
  1. package/dist/__tests__/views/appearance-settings.render.test.d.ts +2 -0
  2. package/dist/__tests__/views/appearance-settings.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/views/appearance-settings.render.test.js +95 -0
  4. package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -0
  5. package/dist/__tests__/views/security-settings.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/security-settings.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/security-settings.render.test.js +175 -0
  8. package/dist/__tests__/views/security-settings.render.test.js.map +1 -0
  9. package/dist/actuate-admin.css +1 -1
  10. package/dist/components/ThemeProvider.d.ts +47 -4
  11. package/dist/components/ThemeProvider.d.ts.map +1 -1
  12. package/dist/components/ThemeProvider.js +173 -27
  13. package/dist/components/ThemeProvider.js.map +1 -1
  14. package/dist/components/ui/Badge.d.ts +1 -1
  15. package/dist/components/ui/Button.d.ts +1 -1
  16. package/dist/components/ui/Card.d.ts +2 -2
  17. package/dist/components/ui/Input.d.ts +1 -1
  18. package/dist/components/ui/Select.d.ts +1 -1
  19. package/dist/layout/Sidebar.d.ts.map +1 -1
  20. package/dist/layout/Sidebar.js +47 -8
  21. package/dist/layout/Sidebar.js.map +1 -1
  22. package/dist/views/Settings.d.ts.map +1 -1
  23. package/dist/views/Settings.js +5 -17
  24. package/dist/views/Settings.js.map +1 -1
  25. package/dist/views/settings/AccessProtectionCard.d.ts +10 -0
  26. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -0
  27. package/dist/views/settings/AccessProtectionCard.js +20 -0
  28. package/dist/views/settings/AccessProtectionCard.js.map +1 -0
  29. package/dist/views/settings/AdminThemeCard.d.ts +6 -0
  30. package/dist/views/settings/AdminThemeCard.d.ts.map +1 -0
  31. package/dist/views/settings/AdminThemeCard.js +31 -0
  32. package/dist/views/settings/AdminThemeCard.js.map +1 -0
  33. package/dist/views/settings/AppearanceTab.d.ts +7 -0
  34. package/dist/views/settings/AppearanceTab.d.ts.map +1 -0
  35. package/dist/views/settings/AppearanceTab.js +24 -0
  36. package/dist/views/settings/AppearanceTab.js.map +1 -0
  37. package/dist/views/settings/AuthMfaCard.d.ts +10 -0
  38. package/dist/views/settings/AuthMfaCard.d.ts.map +1 -0
  39. package/dist/views/settings/AuthMfaCard.js +33 -0
  40. package/dist/views/settings/AuthMfaCard.js.map +1 -0
  41. package/dist/views/settings/BrandPreviewCard.d.ts +5 -0
  42. package/dist/views/settings/BrandPreviewCard.d.ts.map +1 -0
  43. package/dist/views/settings/BrandPreviewCard.js +18 -0
  44. package/dist/views/settings/BrandPreviewCard.js.map +1 -0
  45. package/dist/views/settings/BrandingCard.d.ts +6 -0
  46. package/dist/views/settings/BrandingCard.d.ts.map +1 -0
  47. package/dist/views/settings/BrandingCard.js +38 -0
  48. package/dist/views/settings/BrandingCard.js.map +1 -0
  49. package/dist/views/settings/SecretsAuditCard.d.ts +11 -0
  50. package/dist/views/settings/SecretsAuditCard.d.ts.map +1 -0
  51. package/dist/views/settings/SecretsAuditCard.js +22 -0
  52. package/dist/views/settings/SecretsAuditCard.js.map +1 -0
  53. package/dist/views/settings/SecurityStatusCard.d.ts +6 -0
  54. package/dist/views/settings/SecurityStatusCard.d.ts.map +1 -0
  55. package/dist/views/settings/SecurityStatusCard.js +35 -0
  56. package/dist/views/settings/SecurityStatusCard.js.map +1 -0
  57. package/dist/views/settings/SecurityTab.d.ts +5 -0
  58. package/dist/views/settings/SecurityTab.d.ts.map +1 -0
  59. package/dist/views/settings/SecurityTab.js +41 -0
  60. package/dist/views/settings/SecurityTab.js.map +1 -0
  61. package/dist/views/settings/SessionPolicyCard.d.ts +11 -0
  62. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -0
  63. package/dist/views/settings/SessionPolicyCard.js +21 -0
  64. package/dist/views/settings/SessionPolicyCard.js.map +1 -0
  65. package/dist/views/settings/TypographyMotionCard.d.ts +6 -0
  66. package/dist/views/settings/TypographyMotionCard.d.ts.map +1 -0
  67. package/dist/views/settings/TypographyMotionCard.js +8 -0
  68. package/dist/views/settings/TypographyMotionCard.js.map +1 -0
  69. package/dist/views/settings/securityPrimitives.d.ts +21 -0
  70. package/dist/views/settings/securityPrimitives.d.ts.map +1 -0
  71. package/dist/views/settings/securityPrimitives.js +31 -0
  72. package/dist/views/settings/securityPrimitives.js.map +1 -0
  73. package/dist/views/settings/useAppearanceSettings.d.ts +69 -0
  74. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -0
  75. package/dist/views/settings/useAppearanceSettings.js +344 -0
  76. package/dist/views/settings/useAppearanceSettings.js.map +1 -0
  77. package/dist/views/settings/useGeneralSettings.d.ts +4 -1
  78. package/dist/views/settings/useGeneralSettings.d.ts.map +1 -1
  79. package/dist/views/settings/useGeneralSettings.js.map +1 -1
  80. package/dist/views/settings/useSecuritySettings.d.ts +37 -0
  81. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -0
  82. package/dist/views/settings/useSecuritySettings.js +154 -0
  83. package/dist/views/settings/useSecuritySettings.js.map +1 -0
  84. package/package.json +2 -2
  85. package/src/__tests__/views/appearance-settings.render.test.tsx +132 -0
  86. package/src/__tests__/views/security-settings.render.test.tsx +212 -0
  87. package/src/components/ThemeProvider.tsx +249 -36
  88. package/src/layout/Sidebar.tsx +81 -13
  89. package/src/styles/theme.css +32 -2
  90. package/src/views/Settings.tsx +5 -68
  91. package/src/views/settings/AccessProtectionCard.tsx +143 -0
  92. package/src/views/settings/AdminThemeCard.tsx +145 -0
  93. package/src/views/settings/AppearanceTab.tsx +81 -0
  94. package/src/views/settings/AuthMfaCard.tsx +118 -0
  95. package/src/views/settings/BrandPreviewCard.tsx +125 -0
  96. package/src/views/settings/BrandingCard.tsx +332 -0
  97. package/src/views/settings/SecretsAuditCard.tsx +129 -0
  98. package/src/views/settings/SecurityStatusCard.tsx +110 -0
  99. package/src/views/settings/SecurityTab.tsx +162 -0
  100. package/src/views/settings/SessionPolicyCard.tsx +123 -0
  101. package/src/views/settings/TypographyMotionCard.tsx +65 -0
  102. package/src/views/settings/securityPrimitives.tsx +75 -0
  103. package/src/views/settings/useAppearanceSettings.ts +513 -0
  104. package/src/views/settings/useGeneralSettings.ts +4 -1
  105. package/src/views/settings/useSecuritySettings.ts +197 -0
@@ -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&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
+ />
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
+ }
@@ -0,0 +1,65 @@
1
+ 'use client'
2
+
3
+ import { SettingsToggleRow } from './components.js'
4
+ import { AVAILABLE_FONTS } from '../../components/ThemeProvider.js'
5
+ import type { UseAppearanceSettings } from './useAppearanceSettings.js'
6
+
7
+ export function TypographyMotionCard({
8
+ s,
9
+ canEdit,
10
+ }: {
11
+ s: UseAppearanceSettings
12
+ canEdit: boolean
13
+ }) {
14
+ return (
15
+ <section className="border-border bg-card rounded-lg border p-6">
16
+ <header className="mb-4">
17
+ <h3 className="text-foreground text-lg font-medium">Typography &amp; Motion</h3>
18
+ <p className="text-muted-foreground mt-0.5 text-sm">
19
+ Admin UI font and motion preferences.
20
+ </p>
21
+ </header>
22
+
23
+ <div className="space-y-5">
24
+ <div>
25
+ <label
26
+ htmlFor="content-font"
27
+ className="text-foreground mb-1 block text-base font-medium"
28
+ >
29
+ Content font
30
+ </label>
31
+ <p className="text-muted-foreground mb-2 text-sm">
32
+ Applies to the admin UI. Falls back to the system font if not installed.
33
+ </p>
34
+ <select
35
+ id="content-font"
36
+ value={s.appearance.font}
37
+ disabled={!canEdit}
38
+ onChange={(e) => s.setFont(e.target.value)}
39
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full max-w-md rounded-md border px-3 py-2 text-base focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
40
+ >
41
+ {AVAILABLE_FONTS.map((font) => (
42
+ <option key={font} value={font}>
43
+ {font}
44
+ </option>
45
+ ))}
46
+ {/* Preserve a config-defined font that isn't in the curated list. */}
47
+ {!AVAILABLE_FONTS.includes(s.appearance.font as (typeof AVAILABLE_FONTS)[number]) && (
48
+ <option value={s.appearance.font}>{s.appearance.font}</option>
49
+ )}
50
+ </select>
51
+ </div>
52
+
53
+ <div className="border-border border-t pt-4">
54
+ <SettingsToggleRow
55
+ label="Reduce motion"
56
+ description="Minimize animations and transitions across the admin. Focus and state changes are preserved."
57
+ checked={s.appearance.reduceMotion}
58
+ onChange={s.setReduceMotion}
59
+ disabled={!canEdit}
60
+ />
61
+ </div>
62
+ </div>
63
+ </section>
64
+ )
65
+ }
@@ -0,0 +1,75 @@
1
+ 'use client'
2
+
3
+ import { AlertTriangle, Check, CircleHelp, X } from 'lucide-react'
4
+ import type { ReactNode } from 'react'
5
+ import type { CheckStatus } from '@actuate-media/cms-core/security-center'
6
+
7
+ /** Small status icon used in check/summary rows. Text label always accompanies it. */
8
+ export function CheckIcon({ status }: { status: CheckStatus }) {
9
+ switch (status) {
10
+ case 'pass':
11
+ return <Check size={15} className="text-success shrink-0" aria-hidden="true" />
12
+ case 'warning':
13
+ return <AlertTriangle size={15} className="text-warning shrink-0" aria-hidden="true" />
14
+ case 'fail':
15
+ return <X size={15} className="text-destructive shrink-0" aria-hidden="true" />
16
+ default:
17
+ return <CircleHelp size={15} className="text-muted-foreground shrink-0" aria-hidden="true" />
18
+ }
19
+ }
20
+
21
+ const STATUS_WORD: Record<CheckStatus, string> = {
22
+ pass: 'Pass',
23
+ warning: 'Warning',
24
+ fail: 'Fail',
25
+ unknown: 'Unknown',
26
+ }
27
+
28
+ /** A status row: icon + label on the left, value on the right. */
29
+ export function StatusRow({
30
+ status,
31
+ label,
32
+ value,
33
+ badge,
34
+ }: {
35
+ status: CheckStatus
36
+ label: string
37
+ value?: ReactNode
38
+ badge?: ReactNode
39
+ }) {
40
+ return (
41
+ <div className="flex items-center justify-between gap-3 py-1.5 text-sm">
42
+ <span className="flex items-center gap-2">
43
+ <CheckIcon status={status} />
44
+ {/* Screen-reader text so status is never conveyed by color/icon alone. */}
45
+ <span className="sr-only">{STATUS_WORD[status]}: </span>
46
+ <span className="text-foreground">{label}</span>
47
+ {badge}
48
+ </span>
49
+ {value !== undefined && <span className="text-muted-foreground text-right">{value}</span>}
50
+ </div>
51
+ )
52
+ }
53
+
54
+ /** Section wrapper matching the other settings cards. */
55
+ export function SecurityCard({
56
+ headingId,
57
+ title,
58
+ description,
59
+ children,
60
+ }: {
61
+ headingId: string
62
+ title: string
63
+ description: string
64
+ children: ReactNode
65
+ }) {
66
+ return (
67
+ <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
68
+ <h3 id={headingId} className="text-foreground mb-1 text-base font-medium">
69
+ {title}
70
+ </h3>
71
+ <p className="text-muted-foreground mb-4 text-sm">{description}</p>
72
+ {children}
73
+ </section>
74
+ )
75
+ }