@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.
- package/CHANGELOG.md +13 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/views/appearance-settings.render.test.js +3 -0
- package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/mcp-connect.render.test.d.ts +2 -0
- package/dist/__tests__/views/mcp-connect.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/mcp-connect.render.test.js +55 -0
- package/dist/__tests__/views/mcp-connect.render.test.js.map +1 -0
- package/dist/__tests__/views/mcp-connect.test.d.ts +2 -0
- package/dist/__tests__/views/mcp-connect.test.d.ts.map +1 -0
- package/dist/__tests__/views/mcp-connect.test.js +37 -0
- package/dist/__tests__/views/mcp-connect.test.js.map +1 -0
- package/dist/lib/appearance-api.d.ts +8 -0
- package/dist/lib/appearance-api.d.ts.map +1 -1
- package/dist/lib/appearance-api.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/router/admin-routes.d.ts.map +1 -1
- package/dist/router/admin-routes.js +1 -0
- package/dist/router/admin-routes.js.map +1 -1
- package/dist/views/ApiKeys.d.ts +1 -1
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +2 -2
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/McpConsent.d.ts +4 -0
- package/dist/views/McpConsent.d.ts.map +1 -0
- package/dist/views/McpConsent.js +48 -0
- package/dist/views/McpConsent.js.map +1 -0
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +5 -3
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
- package/dist/views/page-editor/EditorCanvas.js +2 -1
- package/dist/views/page-editor/EditorCanvas.js.map +1 -1
- package/dist/views/page-editor/SectionThemeRoot.d.ts +10 -0
- package/dist/views/page-editor/SectionThemeRoot.d.ts.map +1 -0
- package/dist/views/page-editor/SectionThemeRoot.js +27 -0
- package/dist/views/page-editor/SectionThemeRoot.js.map +1 -0
- package/dist/views/page-editor/sections/AuthorBioSection.js +1 -1
- package/dist/views/page-editor/sections/AuthorBioSection.js.map +1 -1
- package/dist/views/page-editor/sections/ByTheNumbersSection.js +1 -1
- package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -1
- package/dist/views/page-editor/sections/CtaBannerSection.js +1 -1
- package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -1
- package/dist/views/page-editor/sections/FeatureHighlightSection.js +2 -2
- package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -1
- package/dist/views/page-editor/sections/HeroBannerSection.js +1 -1
- package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -1
- package/dist/views/page-editor/sections/parts.d.ts.map +1 -1
- package/dist/views/page-editor/sections/parts.js +11 -15
- package/dist/views/page-editor/sections/parts.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +3 -2
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/settings/AppearanceTab.d.ts.map +1 -1
- package/dist/views/settings/AppearanceTab.js +2 -1
- package/dist/views/settings/AppearanceTab.js.map +1 -1
- package/dist/views/settings/McpConnectTab.d.ts +9 -0
- package/dist/views/settings/McpConnectTab.d.ts.map +1 -0
- package/dist/views/settings/McpConnectTab.js +71 -0
- package/dist/views/settings/McpConnectTab.js.map +1 -0
- package/dist/views/settings/PhiSafeguardsCard.d.ts +9 -0
- package/dist/views/settings/PhiSafeguardsCard.d.ts.map +1 -0
- package/dist/views/settings/PhiSafeguardsCard.js +70 -0
- package/dist/views/settings/PhiSafeguardsCard.js.map +1 -0
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +2 -1
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/dist/views/settings/SiteThemeCard.d.ts +7 -0
- package/dist/views/settings/SiteThemeCard.d.ts.map +1 -0
- package/dist/views/settings/SiteThemeCard.js +23 -0
- package/dist/views/settings/SiteThemeCard.js.map +1 -0
- package/dist/views/settings/mcp-connect.d.ts +25 -0
- package/dist/views/settings/mcp-connect.d.ts.map +1 -0
- package/dist/views/settings/mcp-connect.js +166 -0
- package/dist/views/settings/mcp-connect.js.map +1 -0
- package/dist/views/settings/useAppearanceSettings.d.ts +3 -1
- package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
- package/dist/views/settings/useAppearanceSettings.js +27 -1
- package/dist/views/settings/useAppearanceSettings.js.map +1 -1
- package/package.json +4 -4
- package/src/AdminRoot.tsx +5 -0
- package/src/__tests__/views/appearance-settings.render.test.tsx +8 -0
- package/src/__tests__/views/mcp-connect.render.test.tsx +64 -0
- package/src/__tests__/views/mcp-connect.test.ts +42 -0
- package/src/lib/appearance-api.ts +8 -0
- package/src/router/admin-routes.ts +1 -0
- package/src/views/ApiKeys.tsx +5 -4
- package/src/views/McpConsent.tsx +101 -0
- package/src/views/Settings.tsx +13 -0
- package/src/views/page-editor/EditorCanvas.tsx +2 -1
- package/src/views/page-editor/SectionThemeRoot.tsx +33 -0
- package/src/views/page-editor/sections/AuthorBioSection.tsx +2 -2
- package/src/views/page-editor/sections/ByTheNumbersSection.tsx +1 -1
- package/src/views/page-editor/sections/CtaBannerSection.tsx +1 -1
- package/src/views/page-editor/sections/FeatureHighlightSection.tsx +3 -3
- package/src/views/page-editor/sections/HeroBannerSection.tsx +2 -2
- package/src/views/page-editor/sections/parts.tsx +12 -15
- package/src/views/post-editor/PostEditorCanvas.tsx +21 -18
- package/src/views/settings/AppearanceTab.tsx +2 -0
- package/src/views/settings/McpConnectTab.tsx +212 -0
- package/src/views/settings/PhiSafeguardsCard.tsx +85 -0
- package/src/views/settings/SecurityTab.tsx +3 -0
- package/src/views/settings/SiteThemeCard.tsx +71 -0
- package/src/views/settings/mcp-connect.ts +209 -0
- 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="
|
|
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="
|
|
42
|
-
<ImageIcon className="
|
|
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-
|
|
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-
|
|
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
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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':
|
|
95
|
-
|
|
96
|
-
'solid-
|
|
97
|
-
|
|
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-
|
|
122
|
+
? 'bg-[var(--section-bg)] text-[var(--section-accent)] hover:bg-[var(--section-accent-soft)]'
|
|
126
123
|
: tone === 'ghost'
|
|
127
|
-
? 'border border-
|
|
128
|
-
: 'bg-
|
|
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
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
}
|