@actuate-media/cms-admin 0.80.1 → 0.82.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 +12 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/PublishSyncCard.d.ts +27 -0
- package/dist/components/PublishSyncCard.d.ts.map +1 -0
- package/dist/components/PublishSyncCard.js +67 -0
- package/dist/components/PublishSyncCard.js.map +1 -0
- package/dist/fields/NavBuilderField.d.ts.map +1 -1
- package/dist/fields/NavBuilderField.js +70 -6
- package/dist/fields/NavBuilderField.js.map +1 -1
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +3 -2
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +4 -2
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/WebhooksTab.d.ts +4 -0
- package/dist/views/settings/WebhooksTab.d.ts.map +1 -0
- package/dist/views/settings/WebhooksTab.js +101 -0
- package/dist/views/settings/WebhooksTab.js.map +1 -0
- package/package.json +3 -3
- package/src/components/PublishSyncCard.tsx +178 -0
- package/src/fields/NavBuilderField.tsx +140 -29
- package/src/views/DocumentEdit.tsx +9 -1
- package/src/views/Settings.tsx +9 -0
- package/src/views/settings/WebhooksTab.tsx +280 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shows CMS Published vs publish-webhook delivery for a document (#677).
|
|
5
|
+
* Never claims the public host is "live" — only webhook notify state.
|
|
6
|
+
*/
|
|
7
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
8
|
+
import { AlertTriangle, Info, RefreshCw, RotateCcw } from 'lucide-react'
|
|
9
|
+
import { toast } from 'sonner'
|
|
10
|
+
import { cmsApi } from '../lib/api.js'
|
|
11
|
+
|
|
12
|
+
export interface PublishSyncStatus {
|
|
13
|
+
documentId: string
|
|
14
|
+
cmsStatus: string
|
|
15
|
+
publishedAt: string | null
|
|
16
|
+
publishWebhookConfigured: boolean
|
|
17
|
+
webhookDelivery: {
|
|
18
|
+
state: 'not_configured' | 'no_delivery' | 'pending' | 'retrying' | 'delivered' | 'failed'
|
|
19
|
+
label: string
|
|
20
|
+
deliveries: Array<{
|
|
21
|
+
id: string
|
|
22
|
+
status: string
|
|
23
|
+
endpointName: string | null
|
|
24
|
+
endpointUrl: string
|
|
25
|
+
attempts: number
|
|
26
|
+
maxAttempts: number
|
|
27
|
+
}>
|
|
28
|
+
}
|
|
29
|
+
publicHostNote: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PublishSyncCardProps {
|
|
33
|
+
documentId: string
|
|
34
|
+
/** When status flips to PUBLISHED, refresh sync state. */
|
|
35
|
+
docStatus: string
|
|
36
|
+
onNavigate?: (path: string) => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function stateClass(state: PublishSyncStatus['webhookDelivery']['state']): string {
|
|
40
|
+
switch (state) {
|
|
41
|
+
case 'failed':
|
|
42
|
+
return 'border-destructive/30 bg-destructive/10 text-destructive'
|
|
43
|
+
case 'retrying':
|
|
44
|
+
case 'pending':
|
|
45
|
+
return 'border-border bg-accent text-accent-foreground'
|
|
46
|
+
case 'delivered':
|
|
47
|
+
return 'border-border bg-muted text-foreground'
|
|
48
|
+
default:
|
|
49
|
+
return 'border-border bg-muted text-muted-foreground'
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function PublishSyncCard({ documentId, docStatus, onNavigate }: PublishSyncCardProps) {
|
|
54
|
+
const [status, setStatus] = useState<PublishSyncStatus | null>(null)
|
|
55
|
+
const [loading, setLoading] = useState(false)
|
|
56
|
+
const [retryingId, setRetryingId] = useState<string | null>(null)
|
|
57
|
+
|
|
58
|
+
const load = useCallback(async () => {
|
|
59
|
+
if (!documentId || docStatus !== 'PUBLISHED') {
|
|
60
|
+
setStatus(null)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
setLoading(true)
|
|
64
|
+
const res = await cmsApi<PublishSyncStatus>(
|
|
65
|
+
`/documents/${encodeURIComponent(documentId)}/publish-sync`,
|
|
66
|
+
)
|
|
67
|
+
setLoading(false)
|
|
68
|
+
if (res.error) {
|
|
69
|
+
setStatus(null)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
setStatus(res.data ?? null)
|
|
73
|
+
}, [documentId, docStatus])
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
void load()
|
|
77
|
+
}, [load])
|
|
78
|
+
|
|
79
|
+
if (docStatus !== 'PUBLISHED') return null
|
|
80
|
+
|
|
81
|
+
async function retry(deliveryId: string) {
|
|
82
|
+
setRetryingId(deliveryId)
|
|
83
|
+
const res = await cmsApi<{ status: string; error?: string }>(
|
|
84
|
+
`/webhooks/deliveries/${encodeURIComponent(deliveryId)}/retry`,
|
|
85
|
+
{ method: 'POST' },
|
|
86
|
+
)
|
|
87
|
+
setRetryingId(null)
|
|
88
|
+
if (res.error) {
|
|
89
|
+
toast.error(res.error)
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (res.data?.status === 'success') {
|
|
93
|
+
toast.success('Publish webhook delivered')
|
|
94
|
+
} else {
|
|
95
|
+
toast.error(res.data?.error ?? 'Webhook delivery still failing')
|
|
96
|
+
}
|
|
97
|
+
void load()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const state = status?.webhookDelivery.state
|
|
101
|
+
const failed = status?.webhookDelivery.deliveries.filter(
|
|
102
|
+
(d) => d.status === 'failed' || d.status === 'retrying',
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
107
|
+
<div className="mb-3 flex items-center justify-between gap-2">
|
|
108
|
+
<h3 className="text-foreground font-medium">Public host sync</h3>
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
onClick={() => void load()}
|
|
112
|
+
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs"
|
|
113
|
+
aria-label="Refresh publish sync status"
|
|
114
|
+
>
|
|
115
|
+
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
|
116
|
+
Refresh
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<p className="text-foreground mb-2 text-sm">
|
|
121
|
+
<span className="font-medium">CMS:</span> Published
|
|
122
|
+
</p>
|
|
123
|
+
|
|
124
|
+
{status ? (
|
|
125
|
+
<>
|
|
126
|
+
<div
|
|
127
|
+
className={`mb-2 rounded-md border px-3 py-2 text-sm ${stateClass(status.webhookDelivery.state)}`}
|
|
128
|
+
role="status"
|
|
129
|
+
>
|
|
130
|
+
{state === 'failed' || state === 'retrying' ? (
|
|
131
|
+
<AlertTriangle size={16} className="mb-1 inline" aria-hidden />
|
|
132
|
+
) : (
|
|
133
|
+
<Info size={16} className="mb-1 inline" aria-hidden />
|
|
134
|
+
)}{' '}
|
|
135
|
+
{status.webhookDelivery.label}
|
|
136
|
+
</div>
|
|
137
|
+
<p className="text-muted-foreground text-xs">{status.publicHostNote}</p>
|
|
138
|
+
{failed && failed.length > 0 && (
|
|
139
|
+
<ul className="mt-3 space-y-2">
|
|
140
|
+
{failed.map((d) => (
|
|
141
|
+
<li
|
|
142
|
+
key={d.id}
|
|
143
|
+
className="text-foreground flex items-center justify-between gap-2 text-xs"
|
|
144
|
+
>
|
|
145
|
+
<span className="truncate">
|
|
146
|
+
{d.endpointName || d.endpointUrl} · {d.status} ({d.attempts}/{d.maxAttempts})
|
|
147
|
+
</span>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
onClick={() => void retry(d.id)}
|
|
151
|
+
disabled={retryingId === d.id}
|
|
152
|
+
className="border-border hover:bg-accent inline-flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 disabled:opacity-50"
|
|
153
|
+
>
|
|
154
|
+
<RotateCcw size={12} />
|
|
155
|
+
Retry
|
|
156
|
+
</button>
|
|
157
|
+
</li>
|
|
158
|
+
))}
|
|
159
|
+
</ul>
|
|
160
|
+
)}
|
|
161
|
+
{onNavigate && (
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
onClick={() => onNavigate('/settings?tab=webhooks')}
|
|
165
|
+
className="text-primary mt-3 text-xs font-medium hover:underline"
|
|
166
|
+
>
|
|
167
|
+
Manage publish webhooks
|
|
168
|
+
</button>
|
|
169
|
+
)}
|
|
170
|
+
</>
|
|
171
|
+
) : (
|
|
172
|
+
<p className="text-muted-foreground text-sm">
|
|
173
|
+
{loading ? 'Checking webhook delivery…' : 'Unable to load sync status.'}
|
|
174
|
+
</p>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState } from 'react'
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { AlertTriangle, Plus, Trash2 } from 'lucide-react'
|
|
4
5
|
import { Button } from '../components/ui/Button.js'
|
|
6
|
+
import { cmsApi } from '../lib/api.js'
|
|
5
7
|
|
|
6
8
|
interface NavNode {
|
|
7
9
|
id: string
|
|
@@ -17,44 +19,135 @@ export interface NavBuilderFieldProps {
|
|
|
17
19
|
helpText?: string
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
function coerceNodes(raw: unknown): NavNode[] {
|
|
23
|
+
if (!Array.isArray(raw)) return []
|
|
24
|
+
return raw
|
|
25
|
+
.filter((n): n is Record<string, unknown> => typeof n === 'object' && n !== null)
|
|
26
|
+
.map((n) => ({
|
|
27
|
+
id: typeof n.id === 'string' && n.id ? n.id : crypto.randomUUID(),
|
|
28
|
+
label: typeof n.label === 'string' ? n.label : 'New Item',
|
|
29
|
+
url: typeof n.url === 'string' ? n.url : '/',
|
|
30
|
+
children: coerceNodes(n.children),
|
|
31
|
+
}))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function updateNode(nodes: NavNode[], id: string, patch: Partial<NavNode>): NavNode[] {
|
|
35
|
+
return nodes.map((n) => {
|
|
36
|
+
if (n.id === id) return { ...n, ...patch, children: n.children }
|
|
37
|
+
return { ...n, children: updateNode(n.children, id, patch) }
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function removeNode(nodes: NavNode[], id: string): NavNode[] {
|
|
42
|
+
return nodes
|
|
43
|
+
.filter((n) => n.id !== id)
|
|
44
|
+
.map((n) => ({ ...n, children: removeNode(n.children, id) }))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function addChild(nodes: NavNode[], parentId: string): NavNode[] {
|
|
48
|
+
return nodes.map((n) => {
|
|
49
|
+
if (n.id === parentId) {
|
|
50
|
+
return {
|
|
51
|
+
...n,
|
|
52
|
+
children: [
|
|
53
|
+
...n.children,
|
|
54
|
+
{ id: crypto.randomUUID(), label: 'New Item', url: '/', children: [] },
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { ...n, children: addChild(n.children, parentId) }
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
20
62
|
export function NavBuilderField({ label, value = [], onChange, helpText }: NavBuilderFieldProps) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
63
|
+
const nodes = coerceNodes(value)
|
|
64
|
+
const [warnings, setWarnings] = useState<string[]>([])
|
|
65
|
+
const [checking, setChecking] = useState(false)
|
|
66
|
+
|
|
67
|
+
const treeKey = JSON.stringify(nodes)
|
|
24
68
|
|
|
25
|
-
|
|
26
|
-
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
let cancelled = false
|
|
71
|
+
const timer = window.setTimeout(() => {
|
|
72
|
+
setChecking(true)
|
|
73
|
+
void cmsApi<{ warnings?: string[] }>('/navigations/validate-targets', {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
body: treeKey
|
|
76
|
+
? JSON.stringify({ items: JSON.parse(treeKey) as NavNode[] })
|
|
77
|
+
: '{"items":[]}',
|
|
78
|
+
}).then((res) => {
|
|
79
|
+
if (cancelled) return
|
|
80
|
+
setWarnings(Array.isArray(res.data?.warnings) ? res.data.warnings : [])
|
|
81
|
+
setChecking(false)
|
|
82
|
+
})
|
|
83
|
+
}, 400)
|
|
84
|
+
return () => {
|
|
85
|
+
cancelled = true
|
|
86
|
+
window.clearTimeout(timer)
|
|
87
|
+
}
|
|
88
|
+
}, [treeKey])
|
|
89
|
+
|
|
90
|
+
function addItem() {
|
|
91
|
+
onChange([...nodes, { id: crypto.randomUUID(), label: 'New Item', url: '/', children: [] }])
|
|
27
92
|
}
|
|
28
93
|
|
|
29
94
|
function renderNode(node: NavNode, depth: number) {
|
|
95
|
+
const itemWarnings = warnings.filter(
|
|
96
|
+
(w) => w.includes(`"${node.label}"`) || w.includes(node.url),
|
|
97
|
+
)
|
|
30
98
|
return (
|
|
31
99
|
<div
|
|
32
100
|
key={node.id}
|
|
33
|
-
className="
|
|
101
|
+
className="border-border bg-card rounded-md border p-3"
|
|
34
102
|
style={{ marginLeft: depth * 24 }}
|
|
35
103
|
>
|
|
36
|
-
<div className="flex items-
|
|
37
|
-
<
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
104
|
+
<div className="flex flex-wrap items-end gap-2">
|
|
105
|
+
<label className="min-w-[8rem] flex-1">
|
|
106
|
+
<span className="text-muted-foreground mb-1 block text-sm">Label</span>
|
|
107
|
+
<input
|
|
108
|
+
type="text"
|
|
109
|
+
value={node.label}
|
|
110
|
+
onChange={(e) => onChange(updateNode(nodes, node.id, { label: e.target.value }))}
|
|
111
|
+
className="border-border bg-input-background text-foreground w-full rounded-md border px-2 py-1.5 text-sm"
|
|
112
|
+
/>
|
|
113
|
+
</label>
|
|
114
|
+
<label className="min-w-[10rem] flex-[1.2]">
|
|
115
|
+
<span className="text-muted-foreground mb-1 block text-sm">URL</span>
|
|
116
|
+
<input
|
|
117
|
+
type="text"
|
|
118
|
+
value={node.url}
|
|
119
|
+
onChange={(e) => onChange(updateNode(nodes, node.id, { url: e.target.value }))}
|
|
120
|
+
className="border-border bg-input-background text-foreground w-full rounded-md border px-2 py-1.5 font-mono text-sm"
|
|
121
|
+
aria-invalid={itemWarnings.length > 0}
|
|
122
|
+
/>
|
|
123
|
+
</label>
|
|
124
|
+
<div className="flex gap-1 pb-0.5">
|
|
125
|
+
<Button
|
|
126
|
+
type="button"
|
|
127
|
+
variant="secondary"
|
|
128
|
+
size="sm"
|
|
129
|
+
onClick={() => onChange(addChild(nodes, node.id))}
|
|
130
|
+
aria-label={`Add child under ${node.label}`}
|
|
131
|
+
>
|
|
132
|
+
<Plus size={16} />
|
|
133
|
+
</Button>
|
|
134
|
+
<Button
|
|
135
|
+
type="button"
|
|
136
|
+
variant="secondary"
|
|
137
|
+
size="sm"
|
|
138
|
+
onClick={() => onChange(removeNode(nodes, node.id))}
|
|
139
|
+
aria-label={`Remove ${node.label}`}
|
|
44
140
|
>
|
|
45
|
-
<
|
|
46
|
-
</
|
|
47
|
-
<span className="font-medium">{node.label}</span>
|
|
48
|
-
<span className="text-xs text-[var(--muted-foreground)]">{node.url}</span>
|
|
141
|
+
<Trash2 size={16} />
|
|
142
|
+
</Button>
|
|
49
143
|
</div>
|
|
50
|
-
<button
|
|
51
|
-
type="button"
|
|
52
|
-
onClick={() => removeItem(node.id)}
|
|
53
|
-
className="text-xs text-[var(--muted-foreground)] hover:text-[var(--destructive)]"
|
|
54
|
-
>
|
|
55
|
-
Remove
|
|
56
|
-
</button>
|
|
57
144
|
</div>
|
|
145
|
+
{itemWarnings.length > 0 && (
|
|
146
|
+
<p className="text-destructive mt-2 flex items-start gap-1.5 text-sm" role="status">
|
|
147
|
+
<AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden />
|
|
148
|
+
<span>No published page matches this internal URL.</span>
|
|
149
|
+
</p>
|
|
150
|
+
)}
|
|
58
151
|
{node.children.length > 0 && (
|
|
59
152
|
<div className="mt-2 space-y-2">
|
|
60
153
|
{node.children.map((child) => renderNode(child, depth + 1))}
|
|
@@ -66,12 +159,30 @@ export function NavBuilderField({ label, value = [], onChange, helpText }: NavBu
|
|
|
66
159
|
|
|
67
160
|
return (
|
|
68
161
|
<div>
|
|
69
|
-
<label className="mb-2 block text-sm font-medium">{label}</label>
|
|
70
|
-
|
|
162
|
+
<label className="text-foreground mb-2 block text-sm font-medium">{label}</label>
|
|
163
|
+
{warnings.length > 0 && (
|
|
164
|
+
<div
|
|
165
|
+
className="border-border bg-muted text-foreground mb-3 rounded-md border p-3 text-sm"
|
|
166
|
+
role="status"
|
|
167
|
+
aria-live="polite"
|
|
168
|
+
>
|
|
169
|
+
<p className="mb-1 flex items-center gap-1.5 font-medium">
|
|
170
|
+
<AlertTriangle size={16} aria-hidden />
|
|
171
|
+
{warnings.length} navigation link{warnings.length === 1 ? '' : 's'} may 404
|
|
172
|
+
{checking ? ' (checking…)' : ''}
|
|
173
|
+
</p>
|
|
174
|
+
<ul className="text-muted-foreground list-inside list-disc">
|
|
175
|
+
{warnings.map((w) => (
|
|
176
|
+
<li key={w}>{w}</li>
|
|
177
|
+
))}
|
|
178
|
+
</ul>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
<div className="space-y-2">{nodes.map((node) => renderNode(node, 0))}</div>
|
|
71
182
|
<Button variant="secondary" size="sm" onClick={addItem} className="mt-2">
|
|
72
183
|
Add Nav Item
|
|
73
184
|
</Button>
|
|
74
|
-
{helpText && <p className="mt-1 text-xs
|
|
185
|
+
{helpText && <p className="text-muted-foreground mt-1 text-xs">{helpText}</p>}
|
|
75
186
|
</div>
|
|
76
187
|
)
|
|
77
188
|
}
|
|
@@ -26,6 +26,7 @@ import { SEOPanel } from '../components/SEOPanel.js'
|
|
|
26
26
|
import type { SEOData } from '../components/SEOPanel.js'
|
|
27
27
|
import { SchedulePublishDialog } from '../components/SchedulePublishDialog.js'
|
|
28
28
|
import { SharePreviewLinkDialog } from '../components/SharePreviewLinkDialog.js'
|
|
29
|
+
import { PublishSyncCard } from '../components/PublishSyncCard.js'
|
|
29
30
|
import { cmsApi } from '../lib/api.js'
|
|
30
31
|
|
|
31
32
|
export interface DocumentEditProps {
|
|
@@ -226,7 +227,7 @@ export function DocumentEdit({
|
|
|
226
227
|
if (res.error) {
|
|
227
228
|
toast.error(res.error)
|
|
228
229
|
} else {
|
|
229
|
-
toast.success('
|
|
230
|
+
toast.success('Published in CMS')
|
|
230
231
|
setDocStatus('PUBLISHED')
|
|
231
232
|
setInitialValues(values)
|
|
232
233
|
setInitialSeoData(seoData)
|
|
@@ -386,6 +387,13 @@ export function DocumentEdit({
|
|
|
386
387
|
</div>
|
|
387
388
|
|
|
388
389
|
<div className="space-y-6">
|
|
390
|
+
{!isNew && documentId && (
|
|
391
|
+
<PublishSyncCard
|
|
392
|
+
documentId={documentId}
|
|
393
|
+
docStatus={docStatus}
|
|
394
|
+
onNavigate={onNavigate}
|
|
395
|
+
/>
|
|
396
|
+
)}
|
|
389
397
|
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
|
|
390
398
|
<h3 className="mb-4 font-medium">Status</h3>
|
|
391
399
|
<div className="space-y-3">
|
package/src/views/Settings.tsx
CHANGED
|
@@ -25,6 +25,7 @@ import { AppearanceTab } from './settings/AppearanceTab.js'
|
|
|
25
25
|
import { SecurityTab } from './settings/SecurityTab.js'
|
|
26
26
|
import { SettingsCard } from './settings/components.js'
|
|
27
27
|
import { UpdatesTab } from './settings/UpdatesTab.js'
|
|
28
|
+
import { WebhooksTab } from './settings/WebhooksTab.js'
|
|
28
29
|
import { Users as UsersView } from './Users.js'
|
|
29
30
|
import { ApiKeys as ApiKeysView } from './ApiKeys.js'
|
|
30
31
|
|
|
@@ -93,6 +94,7 @@ export function Settings({
|
|
|
93
94
|
'api-keys',
|
|
94
95
|
'seo',
|
|
95
96
|
'email',
|
|
97
|
+
'webhooks',
|
|
96
98
|
'ai',
|
|
97
99
|
'updates',
|
|
98
100
|
] as const,
|
|
@@ -228,6 +230,9 @@ export function Settings({
|
|
|
228
230
|
Email
|
|
229
231
|
</span>
|
|
230
232
|
</Tabs.Trigger>
|
|
233
|
+
<Tabs.Trigger value="webhooks" className={tabTriggerClass}>
|
|
234
|
+
Webhooks
|
|
235
|
+
</Tabs.Trigger>
|
|
231
236
|
<Tabs.Trigger value="ai" className={tabTriggerClass}>
|
|
232
237
|
<span className="flex items-center gap-1.5">
|
|
233
238
|
<Bot className="h-4 w-4" />
|
|
@@ -329,6 +334,10 @@ export function Settings({
|
|
|
329
334
|
<EmailSettingsTab role={session?.user?.role} />
|
|
330
335
|
</Tabs.Content>
|
|
331
336
|
|
|
337
|
+
<Tabs.Content value="webhooks" className="space-y-4">
|
|
338
|
+
<WebhooksTab role={session?.user?.role} />
|
|
339
|
+
</Tabs.Content>
|
|
340
|
+
|
|
332
341
|
<Tabs.Content value="ai" className="space-y-4">
|
|
333
342
|
<AISettingsTab role={session?.user?.role} config={config} />
|
|
334
343
|
</Tabs.Content>
|