security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states

This commit is contained in:
2026-03-21 17:37:01 +02:00
parent f07eddf29e
commit 5ba25fb956
142 changed files with 30397 additions and 2287 deletions

View File

@@ -0,0 +1,213 @@
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>
)
}