214 lines
8.0 KiB
TypeScript
214 lines
8.0 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { IconPlus, IconTrash } from '@tabler/icons-react'
|
|
import { api } from '../../lib/api'
|
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
|
import { useConfirm } from '../../hooks/useConfirm'
|
|
|
|
interface Webhook {
|
|
id: string
|
|
url: string
|
|
secret: string
|
|
events: string[]
|
|
active: boolean
|
|
createdAt: string
|
|
}
|
|
|
|
const ALL_EVENTS = [
|
|
{ value: 'status_changed', label: 'Status changed' },
|
|
{ value: 'post_created', label: 'Post created' },
|
|
{ value: 'comment_added', label: 'Comment added' },
|
|
]
|
|
|
|
export default function AdminWebhooks() {
|
|
useDocumentTitle('Webhooks')
|
|
const confirm = useConfirm()
|
|
const [webhooks, setWebhooks] = useState<Webhook[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showForm, setShowForm] = useState(false)
|
|
const [url, setUrl] = useState('')
|
|
const [events, setEvents] = useState<string[]>(['status_changed', 'post_created', 'comment_added'])
|
|
const [newSecret, setNewSecret] = useState<string | null>(null)
|
|
const [error, setError] = useState('')
|
|
|
|
const fetchWebhooks = () => {
|
|
api.get<{ webhooks: Webhook[] }>('/admin/webhooks')
|
|
.then((r) => setWebhooks(r.webhooks))
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false))
|
|
}
|
|
|
|
useEffect(fetchWebhooks, [])
|
|
|
|
const create = async () => {
|
|
if (!url.trim() || events.length === 0) return
|
|
setError('')
|
|
try {
|
|
const res = await api.post<Webhook>('/admin/webhooks', { url: url.trim(), events })
|
|
setNewSecret(res.secret)
|
|
setUrl('')
|
|
setShowForm(false)
|
|
fetchWebhooks()
|
|
} catch {
|
|
setError('Failed to create webhook')
|
|
}
|
|
}
|
|
|
|
const toggle = async (id: string, active: boolean) => {
|
|
try {
|
|
await api.put(`/admin/webhooks/${id}`, { active: !active })
|
|
fetchWebhooks()
|
|
} catch {}
|
|
}
|
|
|
|
const remove = async (id: string) => {
|
|
if (!await confirm('Delete this webhook?')) return
|
|
try {
|
|
await api.delete(`/admin/webhooks/${id}`)
|
|
fetchWebhooks()
|
|
} catch {}
|
|
}
|
|
|
|
return (
|
|
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1
|
|
className="font-bold"
|
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
|
>
|
|
Webhooks
|
|
</h1>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setShowForm(!showForm)}
|
|
className="btn btn-admin flex items-center gap-1"
|
|
aria-expanded={showForm}
|
|
style={{ fontSize: 'var(--text-sm)' }}
|
|
>
|
|
<IconPlus size={14} stroke={2} />
|
|
Add webhook
|
|
</button>
|
|
<Link to="/admin" className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>Back</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{newSecret && (
|
|
<div className="card p-4 mb-4" style={{ border: '1px solid var(--success)', background: 'rgba(34, 197, 94, 0.08)' }}>
|
|
<p className="text-xs font-medium mb-1" style={{ color: 'var(--success)' }}>Webhook created - copy the signing secret now (it won't be shown again):</p>
|
|
<code
|
|
className="block p-2 rounded text-xs"
|
|
style={{ background: 'var(--bg)', color: 'var(--text)', wordBreak: 'break-all', fontFamily: 'var(--font-mono)' }}
|
|
>
|
|
{newSecret}
|
|
</code>
|
|
<button onClick={() => setNewSecret(null)} className="btn btn-ghost text-xs mt-2">Dismiss</button>
|
|
</div>
|
|
)}
|
|
|
|
{showForm && (
|
|
<div className="card p-4 mb-6">
|
|
<div className="mb-3">
|
|
<label htmlFor="webhook-url" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>URL</label>
|
|
<input
|
|
id="webhook-url"
|
|
className="input w-full"
|
|
placeholder="https://example.com/webhook"
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
type="url"
|
|
/>
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Events</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{ALL_EVENTS.map((ev) => {
|
|
const active = events.includes(ev.value)
|
|
return (
|
|
<button
|
|
key={ev.value}
|
|
type="button"
|
|
onClick={() => setEvents((evs) =>
|
|
active ? evs.filter((e) => e !== ev.value) : [...evs, ev.value]
|
|
)}
|
|
className="px-2 py-1 rounded text-xs"
|
|
style={{
|
|
background: active ? 'var(--admin-subtle)' : 'var(--surface-hover)',
|
|
color: active ? 'var(--admin-accent)' : 'var(--text-tertiary)',
|
|
border: active ? '1px solid rgba(6, 182, 212, 0.3)' : '1px solid transparent',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{ev.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
{error && <p role="alert" className="text-xs mb-2" style={{ color: 'var(--error)' }}>{error}</p>}
|
|
<div className="flex gap-2">
|
|
<button onClick={create} className="btn btn-admin">Create</button>
|
|
<button onClick={() => setShowForm(false)} className="btn btn-ghost">Cancel</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div>
|
|
<div className="progress-bar mb-4" />
|
|
{[0, 1].map((i) => (
|
|
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.3 }}>
|
|
<div className="skeleton h-4" style={{ width: '50%' }} />
|
|
<div className="skeleton h-4 w-16" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : webhooks.length === 0 ? (
|
|
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No webhooks configured</p>
|
|
) : (
|
|
<div className="flex flex-col gap-1">
|
|
{webhooks.map((wh) => (
|
|
<div
|
|
key={wh.id}
|
|
className="flex items-center justify-between p-3 rounded-lg"
|
|
style={{ background: 'var(--surface)', border: '1px solid var(--border)', opacity: wh.active ? 1 : 0.5 }}
|
|
>
|
|
<div className="flex-1 min-w-0 mr-2">
|
|
<div className="font-medium truncate" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
|
{wh.url}
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
|
{wh.events.map((ev) => (
|
|
<span key={ev} className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'var(--surface-hover)', color: 'var(--text-tertiary)' }}>
|
|
{ev.replace(/_/g, ' ')}
|
|
</span>
|
|
))}
|
|
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
|
Signed
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => toggle(wh.id, wh.active)}
|
|
className="text-xs px-2 rounded"
|
|
style={{ minHeight: 44, color: wh.active ? 'var(--warning)' : 'var(--success)', background: 'none', border: 'none', cursor: 'pointer' }}
|
|
>
|
|
{wh.active ? 'Disable' : 'Enable'}
|
|
</button>
|
|
<button
|
|
onClick={() => remove(wh.id)}
|
|
className="text-xs px-2 rounded"
|
|
aria-label="Delete webhook"
|
|
style={{ minHeight: 44, color: 'var(--error)', background: 'none', border: 'none', cursor: 'pointer' }}
|
|
>
|
|
<IconTrash size={14} stroke={2} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|