@prenta/admin 2.4.0 → 2.6.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 (107) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +4 -0
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/views/appearance-settings.render.test.js +3 -0
  6. package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -1
  7. package/dist/__tests__/views/mcp-connect.render.test.d.ts +2 -0
  8. package/dist/__tests__/views/mcp-connect.render.test.d.ts.map +1 -0
  9. package/dist/__tests__/views/mcp-connect.render.test.js +55 -0
  10. package/dist/__tests__/views/mcp-connect.render.test.js.map +1 -0
  11. package/dist/__tests__/views/mcp-connect.test.d.ts +2 -0
  12. package/dist/__tests__/views/mcp-connect.test.d.ts.map +1 -0
  13. package/dist/__tests__/views/mcp-connect.test.js +37 -0
  14. package/dist/__tests__/views/mcp-connect.test.js.map +1 -0
  15. package/dist/lib/appearance-api.d.ts +8 -0
  16. package/dist/lib/appearance-api.d.ts.map +1 -1
  17. package/dist/lib/appearance-api.js.map +1 -1
  18. package/dist/prenta-admin.css +1 -1
  19. package/dist/router/admin-routes.d.ts.map +1 -1
  20. package/dist/router/admin-routes.js +1 -0
  21. package/dist/router/admin-routes.js.map +1 -1
  22. package/dist/views/ApiKeys.d.ts +1 -1
  23. package/dist/views/ApiKeys.d.ts.map +1 -1
  24. package/dist/views/ApiKeys.js +2 -2
  25. package/dist/views/ApiKeys.js.map +1 -1
  26. package/dist/views/McpConsent.d.ts +4 -0
  27. package/dist/views/McpConsent.d.ts.map +1 -0
  28. package/dist/views/McpConsent.js +48 -0
  29. package/dist/views/McpConsent.js.map +1 -0
  30. package/dist/views/Settings.d.ts.map +1 -1
  31. package/dist/views/Settings.js +5 -3
  32. package/dist/views/Settings.js.map +1 -1
  33. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  34. package/dist/views/page-editor/EditorCanvas.js +2 -1
  35. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  36. package/dist/views/page-editor/SectionThemeRoot.d.ts +10 -0
  37. package/dist/views/page-editor/SectionThemeRoot.d.ts.map +1 -0
  38. package/dist/views/page-editor/SectionThemeRoot.js +27 -0
  39. package/dist/views/page-editor/SectionThemeRoot.js.map +1 -0
  40. package/dist/views/page-editor/sections/AuthorBioSection.js +1 -1
  41. package/dist/views/page-editor/sections/AuthorBioSection.js.map +1 -1
  42. package/dist/views/page-editor/sections/ByTheNumbersSection.js +1 -1
  43. package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -1
  44. package/dist/views/page-editor/sections/CtaBannerSection.js +1 -1
  45. package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -1
  46. package/dist/views/page-editor/sections/FeatureHighlightSection.js +2 -2
  47. package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -1
  48. package/dist/views/page-editor/sections/HeroBannerSection.js +1 -1
  49. package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -1
  50. package/dist/views/page-editor/sections/parts.d.ts.map +1 -1
  51. package/dist/views/page-editor/sections/parts.js +11 -15
  52. package/dist/views/page-editor/sections/parts.js.map +1 -1
  53. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  54. package/dist/views/post-editor/PostEditorCanvas.js +3 -2
  55. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  56. package/dist/views/settings/AppearanceTab.d.ts.map +1 -1
  57. package/dist/views/settings/AppearanceTab.js +2 -1
  58. package/dist/views/settings/AppearanceTab.js.map +1 -1
  59. package/dist/views/settings/McpConnectTab.d.ts +9 -0
  60. package/dist/views/settings/McpConnectTab.d.ts.map +1 -0
  61. package/dist/views/settings/McpConnectTab.js +71 -0
  62. package/dist/views/settings/McpConnectTab.js.map +1 -0
  63. package/dist/views/settings/PhiSafeguardsCard.d.ts +9 -0
  64. package/dist/views/settings/PhiSafeguardsCard.d.ts.map +1 -0
  65. package/dist/views/settings/PhiSafeguardsCard.js +70 -0
  66. package/dist/views/settings/PhiSafeguardsCard.js.map +1 -0
  67. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  68. package/dist/views/settings/SecurityTab.js +2 -1
  69. package/dist/views/settings/SecurityTab.js.map +1 -1
  70. package/dist/views/settings/SiteThemeCard.d.ts +7 -0
  71. package/dist/views/settings/SiteThemeCard.d.ts.map +1 -0
  72. package/dist/views/settings/SiteThemeCard.js +23 -0
  73. package/dist/views/settings/SiteThemeCard.js.map +1 -0
  74. package/dist/views/settings/mcp-connect.d.ts +25 -0
  75. package/dist/views/settings/mcp-connect.d.ts.map +1 -0
  76. package/dist/views/settings/mcp-connect.js +166 -0
  77. package/dist/views/settings/mcp-connect.js.map +1 -0
  78. package/dist/views/settings/useAppearanceSettings.d.ts +3 -1
  79. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
  80. package/dist/views/settings/useAppearanceSettings.js +27 -1
  81. package/dist/views/settings/useAppearanceSettings.js.map +1 -1
  82. package/package.json +4 -4
  83. package/src/AdminRoot.tsx +5 -0
  84. package/src/__tests__/views/appearance-settings.render.test.tsx +8 -0
  85. package/src/__tests__/views/mcp-connect.render.test.tsx +64 -0
  86. package/src/__tests__/views/mcp-connect.test.ts +42 -0
  87. package/src/lib/appearance-api.ts +8 -0
  88. package/src/router/admin-routes.ts +1 -0
  89. package/src/views/ApiKeys.tsx +5 -4
  90. package/src/views/McpConsent.tsx +101 -0
  91. package/src/views/Settings.tsx +13 -0
  92. package/src/views/page-editor/EditorCanvas.tsx +2 -1
  93. package/src/views/page-editor/SectionThemeRoot.tsx +33 -0
  94. package/src/views/page-editor/sections/AuthorBioSection.tsx +2 -2
  95. package/src/views/page-editor/sections/ByTheNumbersSection.tsx +1 -1
  96. package/src/views/page-editor/sections/CtaBannerSection.tsx +1 -1
  97. package/src/views/page-editor/sections/FeatureHighlightSection.tsx +3 -3
  98. package/src/views/page-editor/sections/HeroBannerSection.tsx +2 -2
  99. package/src/views/page-editor/sections/parts.tsx +12 -15
  100. package/src/views/post-editor/PostEditorCanvas.tsx +21 -18
  101. package/src/views/settings/AppearanceTab.tsx +2 -0
  102. package/src/views/settings/McpConnectTab.tsx +212 -0
  103. package/src/views/settings/PhiSafeguardsCard.tsx +85 -0
  104. package/src/views/settings/SecurityTab.tsx +3 -0
  105. package/src/views/settings/SiteThemeCard.tsx +71 -0
  106. package/src/views/settings/mcp-connect.ts +209 -0
  107. package/src/views/settings/useAppearanceSettings.ts +39 -0
@@ -14,7 +14,7 @@ export function FeatureHighlightSection({ section }: { section: PageSection }) {
14
14
  const text = (
15
15
  <div className="flex-1">
16
16
  {label && (
17
- <span className="bg-brand mb-3 inline-block rounded px-2 py-0.5 text-xs font-medium text-white">
17
+ <span className="mb-3 inline-block rounded bg-[var(--section-accent)] px-2 py-0.5 text-xs font-medium text-[var(--section-accent-foreground)]">
18
18
  {label}
19
19
  </span>
20
20
  )}
@@ -38,8 +38,8 @@ export function FeatureHighlightSection({ section }: { section: PageSection }) {
38
38
  className="aspect-[4/3] w-full rounded-xl object-cover"
39
39
  />
40
40
  ) : (
41
- <div className="bg-brand/10 flex aspect-[4/3] w-full items-center justify-center rounded-xl">
42
- <ImageIcon className="text-brand h-10 w-10" aria-hidden />
41
+ <div className="flex aspect-[4/3] w-full items-center justify-center rounded-xl bg-[var(--section-accent-soft)]">
42
+ <ImageIcon className="h-10 w-10 text-[var(--section-accent)]" aria-hidden />
43
43
  </div>
44
44
  )}
45
45
  </div>
@@ -12,7 +12,7 @@ export function HeroBannerSection({ section }: { section: PageSection }) {
12
12
  <div className="mx-auto max-w-3xl px-6 text-center">
13
13
  {eyebrow && (
14
14
  <p
15
- className={`mb-3 text-sm font-medium tracking-wide uppercase ${dark ? 'text-brand-foreground/80' : 'text-brand'}`}
15
+ className={`mb-3 text-sm font-medium tracking-wide uppercase ${dark ? 'text-white/80' : 'text-[var(--section-accent)]'}`}
16
16
  >
17
17
  {eyebrow}
18
18
  </p>
@@ -22,7 +22,7 @@ export function HeroBannerSection({ section }: { section: PageSection }) {
22
22
  </h1>
23
23
  {body && (
24
24
  <p
25
- className={`@pv-md:text-lg mx-auto mt-5 max-w-2xl text-base ${dark ? 'text-brand-foreground/80' : 'text-muted-foreground'}`}
25
+ className={`@pv-md:text-lg mx-auto mt-5 max-w-2xl text-base ${dark ? 'text-white/80' : 'text-[var(--section-muted)]'}`}
26
26
  >
27
27
  {body}
28
28
  </p>
@@ -71,14 +71,10 @@ export function isDarkBackground(settings: SectionSettings): boolean {
71
71
  *
72
72
  * They were, and it made the canvas lie. `bg-card` and `bg-brand` follow the
73
73
  * admin's light/dark theme, so an editor working in dark mode saw a hero in
74
- * `#9D71F0` and body sections on a dark surface — while the live page renders
75
- * `violet-600` on white, whatever theme the editor happens to prefer. The
76
- * canvas exists to show the page as visitors get it; tracking the chrome's
77
- * theme is the one thing it must not do.
78
- *
79
- * In light mode this changes almost nothing: `--brand` resolves to `#7C3AED`,
80
- * which is `violet-600`. The whole difference was in dark mode, which is
81
- * exactly where it was least likely to be noticed.
74
+ * `#9D71F0` and body sections on a dark surface — while the live page paints
75
+ * from the site theme variables, defaulting to violet on white. The canvas
76
+ * exists to show the page as visitors get it; tracking the chrome's theme is
77
+ * the one thing it must not do.
82
78
  *
83
79
  * ── Why the strings are duplicated from sections-react ────────────────────
84
80
  *
@@ -91,10 +87,11 @@ export function isDarkBackground(settings: SectionSettings): boolean {
91
87
  * `section-class-parity.test.ts` asserts the strings still match the site's.
92
88
  */
93
89
  const PRESET_CLASSES: Record<BackgroundPreset, string> = {
94
- 'gradient-purple': 'bg-gradient-to-br from-violet-600 to-violet-700 text-white',
95
- 'solid-purple': 'bg-violet-600 text-white',
96
- 'solid-muted': 'bg-zinc-50 text-zinc-900',
97
- plain: 'bg-white text-zinc-900',
90
+ 'gradient-purple':
91
+ 'bg-gradient-to-br from-[var(--section-accent)] to-[var(--section-accent-hover)] text-[var(--section-accent-foreground)]',
92
+ 'solid-purple': 'bg-[var(--section-accent)] text-[var(--section-accent-foreground)]',
93
+ 'solid-muted': 'bg-zinc-50 text-[var(--section-fg)]',
94
+ plain: 'bg-[var(--section-bg)] text-[var(--section-fg)]',
98
95
  }
99
96
 
100
97
  export function backgroundClass(settings: SectionSettings): string {
@@ -122,10 +119,10 @@ export function PreviewButton({
122
119
  '@pv-sm:w-auto inline-flex w-full items-center justify-center rounded-lg px-5 py-2.5 text-sm font-medium transition-colors'
123
120
  const toneClass =
124
121
  tone === 'onDark'
125
- ? 'bg-card text-brand hover:bg-brand/10'
122
+ ? 'bg-[var(--section-bg)] text-[var(--section-accent)] hover:bg-[var(--section-accent-soft)]'
126
123
  : tone === 'ghost'
127
- ? 'border border-border text-foreground hover:bg-muted'
128
- : 'bg-brand text-white hover:bg-brand'
124
+ ? 'border border-zinc-300 text-zinc-700 hover:bg-zinc-50'
125
+ : 'bg-[var(--section-accent)] text-[var(--section-accent-foreground)] hover:bg-[var(--section-accent-hover)]'
129
126
  // Editor preview: links are inert (preventDefault) so clicking content
130
127
  // selects the section instead of navigating away.
131
128
  return (
@@ -5,6 +5,7 @@ import { CanvasEditTip } from '../../components/CanvasEditTip.js'
5
5
  import type { Zoom } from '../../lib/breakpoints.js'
6
6
  import type { PageSection, PostHeaderConfig } from '../../lib/post-editor-service.js'
7
7
  import { CanvasScroll, CanvasSurface, useSectionRefs } from '../page-editor/CanvasSurface.js'
8
+ import { SectionThemeRoot } from '../page-editor/SectionThemeRoot.js'
8
9
  import { SectionRenderer, type PostRenderContext } from '../page-editor/sections/index.js'
9
10
  import { StructureNotice } from '../page-editor/StructureNotice.js'
10
11
  import { type ViewportId } from '../page-editor/viewports.js'
@@ -88,24 +89,26 @@ export function PostEditorCanvas({
88
89
  )
89
90
  }
90
91
  >
91
- <PostHeader config={header} context={context} />
92
- {sections.length === 0 ? (
93
- <EmptyBody onAddSection={reviewing ? undefined : onAddSection} />
94
- ) : (
95
- sections.map((section) => (
96
- <SectionRenderer
97
- key={section.id}
98
- section={section}
99
- context={context}
100
- editable
101
- reviewing={reviewing}
102
- changed={changedSectionIds?.has(section.id) ?? false}
103
- selected={section.id === selectedId}
104
- onSelect={onSelect}
105
- registerRef={registerRef}
106
- />
107
- ))
108
- )}
92
+ <SectionThemeRoot>
93
+ <PostHeader config={header} context={context} />
94
+ {sections.length === 0 ? (
95
+ <EmptyBody onAddSection={reviewing ? undefined : onAddSection} />
96
+ ) : (
97
+ sections.map((section) => (
98
+ <SectionRenderer
99
+ key={section.id}
100
+ section={section}
101
+ context={context}
102
+ editable
103
+ reviewing={reviewing}
104
+ changed={changedSectionIds?.has(section.id) ?? false}
105
+ selected={section.id === selectedId}
106
+ onSelect={onSelect}
107
+ registerRef={registerRef}
108
+ />
109
+ ))
110
+ )}
111
+ </SectionThemeRoot>
109
112
  </CanvasSurface>
110
113
  </CanvasScroll>
111
114
  )
@@ -3,6 +3,7 @@
3
3
  import { AlertTriangle, Loader2 } from 'lucide-react'
4
4
 
5
5
  import { AdminThemeCard } from './AdminThemeCard.js'
6
+ import { SiteThemeCard } from './SiteThemeCard.js'
6
7
  import { BrandPreviewCard } from './BrandPreviewCard.js'
7
8
  import { BrandingCard } from './BrandingCard.js'
8
9
  import { SettingsSaveBar, SettingsValidationSummary } from './components.js'
@@ -65,6 +66,7 @@ export function AppearanceTab({ role, config }: AppearanceTabProps) {
65
66
 
66
67
  <BrandingCard s={s} canEdit={canEdit} />
67
68
  <AdminThemeCard s={s} canEdit={canEdit} />
69
+ <SiteThemeCard s={s} canEdit={canEdit} />
68
70
  <TypographyMotionCard s={s} canEdit={canEdit} />
69
71
  <BrandPreviewCard s={s} />
70
72
 
@@ -0,0 +1,212 @@
1
+ 'use client'
2
+
3
+ import * as Tabs from '@radix-ui/react-tabs'
4
+ import { Copy, Loader2 } from 'lucide-react'
5
+ import { useEffect, useState } from 'react'
6
+ import { toast } from 'sonner'
7
+
8
+ import { cmsApi } from '../../lib/api.js'
9
+ import { SettingsCard } from './components.js'
10
+ import { mcpClientGuides, type McpClientGuide, type McpClientId } from './mcp-connect.js'
11
+
12
+ type Grant = {
13
+ id: string
14
+ clientId: string
15
+ scope: string
16
+ revokedAt: string | null
17
+ }
18
+
19
+ async function copyText(value: string, label: string) {
20
+ try {
21
+ await navigator.clipboard.writeText(value)
22
+ toast.success(`${label} copied`)
23
+ } catch {
24
+ toast.error('Could not copy. Select the text and copy it manually.')
25
+ }
26
+ }
27
+
28
+ function ClientPanel({ guide, onCreateKey }: { guide: McpClientGuide; onCreateKey?: () => void }) {
29
+ const [manual, setManual] = useState(false)
30
+
31
+ return (
32
+ <div className="grid gap-6 lg:grid-cols-2">
33
+ <div className="space-y-4">
34
+ <div className="space-y-1">
35
+ <h3 className="text-lg font-medium">{guide.title}</h3>
36
+ <p className="text-muted-foreground text-sm">{guide.summary}</p>
37
+ </div>
38
+ <button
39
+ type="button"
40
+ className="bg-primary text-primary-foreground focus-visible:ring-ring inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium hover:opacity-90 focus-visible:ring-2 focus-visible:outline-none"
41
+ onClick={() => void copyText(guide.copyText, guide.action.replace(/^Copy /, ''))}
42
+ >
43
+ <Copy size={16} aria-hidden="true" />
44
+ {guide.action}
45
+ </button>
46
+ <div>
47
+ <button
48
+ type="button"
49
+ className="text-primary focus-visible:ring-ring text-sm font-medium underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:outline-none"
50
+ aria-expanded={manual}
51
+ onClick={() => setManual((open) => !open)}
52
+ >
53
+ Prefer manual configuration?
54
+ </button>
55
+ {manual && (
56
+ <ol className="text-muted-foreground mt-3 list-decimal space-y-2 pl-5 text-sm">
57
+ {guide.manual.map((step) => (
58
+ <li key={step}>{step}</li>
59
+ ))}
60
+ </ol>
61
+ )}
62
+ </div>
63
+ {guide.needsApiKey && onCreateKey && (
64
+ <button
65
+ type="button"
66
+ className="text-foreground focus-visible:ring-ring text-sm font-medium underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:outline-none"
67
+ onClick={onCreateKey}
68
+ >
69
+ Create a Content API key
70
+ </button>
71
+ )}
72
+ </div>
73
+ <div className="border-border bg-muted relative rounded-lg border">
74
+ <button
75
+ type="button"
76
+ className="text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute top-2 right-2 rounded-md p-2 focus-visible:ring-2 focus-visible:outline-none"
77
+ aria-label={guide.action}
78
+ onClick={() => void copyText(guide.copyText, guide.action.replace(/^Copy /, ''))}
79
+ >
80
+ <Copy size={16} aria-hidden="true" />
81
+ </button>
82
+ <pre className="max-h-80 overflow-auto p-4 pr-12 font-mono text-sm whitespace-pre-wrap">
83
+ {guide.panel}
84
+ </pre>
85
+ </div>
86
+ </div>
87
+ )
88
+ }
89
+
90
+ /**
91
+ * Settings → MCP. One tab per client, matching the "copy this and paste it
92
+ * there" setup other site builders use. The URL comes from the server so the
93
+ * Host header is never trusted.
94
+ */
95
+ export function McpConnectTab({ onNavigate }: { onNavigate?: (path: string) => void }) {
96
+ const [mcpUrl, setMcpUrl] = useState('')
97
+ const [loadError, setLoadError] = useState<string | null>(null)
98
+ const [loading, setLoading] = useState(true)
99
+ const [grants, setGrants] = useState<Grant[]>([])
100
+ const [client, setClient] = useState<McpClientId>('claude-code')
101
+
102
+ function loadGrants() {
103
+ void cmsApi<Grant[]>('/mcp/oauth/grants').then((res) => {
104
+ if (res.data) setGrants(res.data.filter((grant) => !grant.revokedAt))
105
+ })
106
+ }
107
+
108
+ useEffect(() => {
109
+ let cancelled = false
110
+ void cmsApi<{ mcpUrl: string }>('/mcp/oauth/setup').then((res) => {
111
+ if (cancelled) return
112
+ setLoading(false)
113
+ if (res.error || !res.data?.mcpUrl) {
114
+ setLoadError(
115
+ res.error ?? 'This site has no public URL yet, so there is nothing to connect.',
116
+ )
117
+ return
118
+ }
119
+ setMcpUrl(res.data.mcpUrl)
120
+ })
121
+ loadGrants()
122
+ return () => {
123
+ cancelled = true
124
+ }
125
+ }, [])
126
+
127
+ async function revoke(id: string) {
128
+ const res = await cmsApi<{ note: string }>(`/mcp/oauth/grants/${encodeURIComponent(id)}`, {
129
+ method: 'DELETE',
130
+ })
131
+ if (res.error) {
132
+ toast.error(res.error)
133
+ return
134
+ }
135
+ toast.success(res.data?.note ?? 'Disconnected')
136
+ loadGrants()
137
+ }
138
+
139
+ const guides = mcpUrl ? mcpClientGuides(mcpUrl) : []
140
+
141
+ return (
142
+ <div className="space-y-6">
143
+ <div className="space-y-1">
144
+ <h2 className="text-lg font-medium">MCP</h2>
145
+ <p className="text-muted-foreground text-sm">
146
+ Connect an AI agent to this site. It can draft and edit pages, then you approve what goes
147
+ live.
148
+ </p>
149
+ </div>
150
+
151
+ {loading ? (
152
+ <div className="text-muted-foreground flex items-center gap-2 text-sm">
153
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
154
+ Loading connection details
155
+ </div>
156
+ ) : loadError ? (
157
+ <p role="alert" className="text-destructive text-sm">
158
+ {loadError}
159
+ </p>
160
+ ) : (
161
+ <Tabs.Root value={client} onValueChange={(value) => setClient(value as McpClientId)}>
162
+ <Tabs.List
163
+ aria-label="AI clients"
164
+ className="border-border mb-6 flex gap-1 overflow-x-auto border-b"
165
+ >
166
+ {guides.map((guide) => (
167
+ <Tabs.Trigger
168
+ key={guide.id}
169
+ value={guide.id}
170
+ className="text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground focus-visible:ring-ring px-3 py-2 text-sm font-medium focus-visible:ring-2 focus-visible:outline-none data-[state=active]:border-b-2"
171
+ >
172
+ {guide.label}
173
+ </Tabs.Trigger>
174
+ ))}
175
+ </Tabs.List>
176
+ {guides.map((guide) => (
177
+ <Tabs.Content key={guide.id} value={guide.id} className="focus-visible:outline-none">
178
+ <ClientPanel
179
+ guide={guide}
180
+ onCreateKey={onNavigate ? () => onNavigate('/settings?tab=api-keys') : undefined}
181
+ />
182
+ </Tabs.Content>
183
+ ))}
184
+ </Tabs.Root>
185
+ )}
186
+
187
+ <SettingsCard
188
+ title="Connected apps"
189
+ description="Revoke stops new requests immediately. An access token already issued can keep working for up to 15 minutes."
190
+ >
191
+ {grants.length === 0 ? (
192
+ <p className="text-muted-foreground text-sm">No apps connected yet.</p>
193
+ ) : (
194
+ <ul className="space-y-2">
195
+ {grants.map((grant) => (
196
+ <li key={grant.id} className="flex items-center justify-between gap-3 text-sm">
197
+ <span className="min-w-0 truncate">{grant.clientId}</span>
198
+ <button
199
+ type="button"
200
+ className="text-destructive focus-visible:ring-ring shrink-0 font-medium hover:underline focus-visible:ring-2 focus-visible:outline-none"
201
+ onClick={() => void revoke(grant.id)}
202
+ >
203
+ Revoke
204
+ </button>
205
+ </li>
206
+ ))}
207
+ </ul>
208
+ )}
209
+ </SettingsCard>
210
+ </div>
211
+ )
212
+ }
@@ -0,0 +1,85 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { toast } from 'sonner'
5
+ import { cmsApi } from '../../lib/api.js'
6
+ import { Toggle } from '../../components/ui/Toggle.js'
7
+ import { SettingsCard } from './components.js'
8
+
9
+ /**
10
+ * Disclosure boundary for form submissions. Turning this on refuses lead
11
+ * values to API keys, MCP, and vision alt-text. It is a technical safeguard,
12
+ * not a compliance certification.
13
+ */
14
+ export function PhiSafeguardsCard({ canEdit }: { canEdit: boolean }) {
15
+ const [enabled, setEnabled] = useState(false)
16
+ const [loading, setLoading] = useState(true)
17
+ const [saving, setSaving] = useState(false)
18
+ const [hidden, setHidden] = useState(false)
19
+
20
+ useEffect(() => {
21
+ let cancelled = false
22
+ cmsApi<{ phiSafeguards: boolean }>('/privacy')
23
+ .then((res) => {
24
+ if (cancelled) return
25
+ if ('error' in res && res.error) {
26
+ setHidden(true)
27
+ return
28
+ }
29
+ if ('data' in res) setEnabled(Boolean(res.data?.phiSafeguards))
30
+ })
31
+ .catch(() => {
32
+ if (!cancelled) setHidden(true)
33
+ })
34
+ .finally(() => {
35
+ if (!cancelled) setLoading(false)
36
+ })
37
+ return () => {
38
+ cancelled = true
39
+ }
40
+ }, [])
41
+
42
+ if (hidden || loading) return null
43
+
44
+ async function onToggle(next: boolean) {
45
+ if (!canEdit || saving) return
46
+ setSaving(true)
47
+ const previous = enabled
48
+ setEnabled(next)
49
+ try {
50
+ const res = await cmsApi('/privacy', {
51
+ method: 'PUT',
52
+ body: JSON.stringify({ phiSafeguards: next }),
53
+ })
54
+ if ('error' in res && res.error) throw new Error(res.error)
55
+ toast.success(
56
+ next ? 'Health-information protection is on' : 'Health-information protection is off',
57
+ )
58
+ } catch {
59
+ setEnabled(previous)
60
+ toast.error('Could not update health-information protection')
61
+ } finally {
62
+ setSaving(false)
63
+ }
64
+ }
65
+
66
+ return (
67
+ <SettingsCard
68
+ title="Protect health information"
69
+ description="When on, API keys and connected AI apps cannot read form submission contents, and image alt-text generation is refused. People signed in to the admin can still open the inbox. This is a technical safeguard. It does not certify the site for any regulation."
70
+ busy={saving}
71
+ >
72
+ <div className="flex items-center justify-between gap-4">
73
+ <label htmlFor="phi-safeguards" className="text-sm text-[var(--txt)]">
74
+ Hide lead contents from API keys and AI
75
+ </label>
76
+ <Toggle
77
+ id="phi-safeguards"
78
+ checked={enabled}
79
+ disabled={!canEdit || saving}
80
+ onCheckedChange={(checked) => void onToggle(checked)}
81
+ />
82
+ </div>
83
+ </SettingsCard>
84
+ )
85
+ }
@@ -16,6 +16,7 @@ import { SecurityStatusCard } from './SecurityStatusCard.js'
16
16
  import { CaptchaStatusCard } from './CaptchaStatusCard.js'
17
17
  import { SessionPolicyCard } from './SessionPolicyCard.js'
18
18
  import { useSecuritySettings } from './useSecuritySettings.js'
19
+ import { PhiSafeguardsCard } from './PhiSafeguardsCard.js'
19
20
 
20
21
  function CardSkeleton() {
21
22
  return (
@@ -145,6 +146,8 @@ export function SecurityTab({
145
146
  />
146
147
  </div>
147
148
 
149
+ <PhiSafeguardsCard canEdit={canEdit || role === 'OWNER'} />
150
+
148
151
  <div id="security-audit" className="space-y-4">
149
152
  {canEdit && <AuditLogExportCard />}
150
153
  <SecretsAuditCard
@@ -0,0 +1,71 @@
1
+ 'use client'
2
+
3
+ import type { PublicTheme } from '@prenta/core/appearance'
4
+
5
+ import type { UseAppearanceSettings } from './useAppearanceSettings.js'
6
+
7
+ const FIELDS: { key: keyof PublicTheme; label: string; hint: string }[] = [
8
+ { key: 'accent', label: 'Accent', hint: 'Buttons and highlights. Example: #7c3aed' },
9
+ { key: 'background', label: 'Background', hint: 'Page surface. Example: #ffffff' },
10
+ { key: 'foreground', label: 'Text', hint: 'Body text. Example: #18181b' },
11
+ { key: 'muted', label: 'Muted text', hint: 'Secondary text. Example: #71717a' },
12
+ { key: 'fontFamily', label: 'Font', hint: 'A font already on the site. Example: Georgia, serif' },
13
+ { key: 'radius', label: 'Corner radius', hint: 'Example: 0.75rem' },
14
+ ]
15
+
16
+ function isColorKey(key: keyof PublicTheme): boolean {
17
+ return key === 'accent' || key === 'background' || key === 'foreground' || key === 'muted'
18
+ }
19
+
20
+ /** Public site theme. Empty fields keep the built-in section palette. */
21
+ export function SiteThemeCard({ s, canEdit }: { s: UseAppearanceSettings; canEdit: boolean }) {
22
+ return (
23
+ <section className="border-border bg-card space-y-4 rounded-lg border p-6">
24
+ <h3 className="text-lg font-medium">Site theme</h3>
25
+ <p className="text-muted-foreground text-sm">
26
+ Colors and type for published pages. Leave a field empty to keep the default.
27
+ </p>
28
+ <div className="grid gap-4 sm:grid-cols-2">
29
+ {FIELDS.map(({ key, label, hint }) => {
30
+ const value = s.appearance.publicTheme[key] ?? ''
31
+ const issue = s.issues.find((item) => item.field === `publicTheme.${key}`)
32
+ const id = `site-theme-${key}`
33
+ return (
34
+ <div key={key} className="space-y-1">
35
+ <label htmlFor={id} className="text-sm font-medium">
36
+ {label}
37
+ </label>
38
+ <div className="flex items-center gap-2">
39
+ {isColorKey(key) && value ? (
40
+ <span
41
+ aria-hidden
42
+ className="border-border size-8 shrink-0 rounded-md border"
43
+ style={{ background: value }}
44
+ />
45
+ ) : null}
46
+ <input
47
+ id={id}
48
+ value={value}
49
+ disabled={!canEdit}
50
+ aria-invalid={issue ? true : undefined}
51
+ aria-describedby={issue ? `${id}-error` : `${id}-hint`}
52
+ onChange={(event) => s.setPublicTheme({ [key]: event.target.value })}
53
+ className="border-input bg-background w-full rounded-md border px-3 py-2 text-sm disabled:opacity-60"
54
+ />
55
+ </div>
56
+ {issue ? (
57
+ <p id={`${id}-error`} className="text-destructive text-sm">
58
+ {issue.message}
59
+ </p>
60
+ ) : (
61
+ <p id={`${id}-hint`} className="text-muted-foreground text-sm">
62
+ {hint}
63
+ </p>
64
+ )}
65
+ </div>
66
+ )
67
+ })}
68
+ </div>
69
+ </section>
70
+ )
71
+ }