750 lines
29 KiB
TypeScript
750 lines
29 KiB
TypeScript
|
|
import { useStore } from '@nanostores/react'
|
||
|
|
import { useState } from 'react'
|
||
|
|
import { $apiKeys, createAPIKey, $deleteAPIKey, $creating } from '../stores/apiKeys'
|
||
|
|
import { $webhooks, createWebhook, updateWebhook, $deleteWebhook, testWebhook, fetchWebhookDeliveries } from '../stores/webhooks'
|
||
|
|
import { $billing } from '../stores/billing'
|
||
|
|
import { addToast } from '../stores/app'
|
||
|
|
import { EmptyState, APIPageSkeleton, PageHeader } from '../components/shared'
|
||
|
|
import { Icons } from '../components/shared/Icons'
|
||
|
|
import { Button, Input, Modal, UsageIndicator } from '../components/ui'
|
||
|
|
import type { Webhook, WebhookDelivery, WebhookEvent } from '../types'
|
||
|
|
|
||
|
|
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||
|
|
|
||
|
|
interface QueryParam {
|
||
|
|
name: string
|
||
|
|
description: string
|
||
|
|
default?: string
|
||
|
|
options?: string[] // For select dropdowns
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Endpoint {
|
||
|
|
method: HttpMethod
|
||
|
|
path: string
|
||
|
|
description: string
|
||
|
|
queryParams?: QueryParam[]
|
||
|
|
requestBody?: string
|
||
|
|
responseExample: string
|
||
|
|
}
|
||
|
|
|
||
|
|
const endpoints: Endpoint[] = [
|
||
|
|
{
|
||
|
|
method: 'GET',
|
||
|
|
path: '/api/v1/posts',
|
||
|
|
description: 'List published posts with pagination',
|
||
|
|
queryParams: [
|
||
|
|
{ name: 'limit', description: 'Results per page (max 100)', default: '20' },
|
||
|
|
{ name: 'offset', description: 'Skip N results', default: '0' },
|
||
|
|
{ name: 'tag', description: 'Filter by tag' },
|
||
|
|
{ name: 'include', description: 'Include extra fields', options: ['', 'content'] },
|
||
|
|
],
|
||
|
|
responseExample: `{
|
||
|
|
"posts": [
|
||
|
|
{
|
||
|
|
"id": "uuid",
|
||
|
|
"slug": "hello-world",
|
||
|
|
"title": "Hello World",
|
||
|
|
"description": "My first post",
|
||
|
|
"tags": ["intro"],
|
||
|
|
"date": "2024-01-15",
|
||
|
|
"draft": false
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"total": 42,
|
||
|
|
"limit": 20,
|
||
|
|
"offset": 0
|
||
|
|
}`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
method: 'POST',
|
||
|
|
path: '/api/v1/posts',
|
||
|
|
description: 'Create a new post',
|
||
|
|
requestBody: `{
|
||
|
|
"title": "My New Post",
|
||
|
|
"slug": "my-new-post",
|
||
|
|
"content": "# Markdown content",
|
||
|
|
"description": "Optional description",
|
||
|
|
"tags": ["tutorial"],
|
||
|
|
"draft": false
|
||
|
|
}`,
|
||
|
|
responseExample: `{
|
||
|
|
"id": "uuid",
|
||
|
|
"slug": "my-new-post",
|
||
|
|
"title": "My New Post",
|
||
|
|
...
|
||
|
|
}`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
method: 'GET',
|
||
|
|
path: '/api/v1/posts/{slug}',
|
||
|
|
description: 'Get a single post by slug (includes content)',
|
||
|
|
responseExample: `{
|
||
|
|
"id": "uuid",
|
||
|
|
"slug": "hello-world",
|
||
|
|
"title": "Hello World",
|
||
|
|
"description": "My first post",
|
||
|
|
"content": {
|
||
|
|
"markdown": "# Full markdown...",
|
||
|
|
"html": "<h1>Full markdown...</h1>"
|
||
|
|
},
|
||
|
|
"tags": ["intro"],
|
||
|
|
"date": "2024-01-15"
|
||
|
|
}`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
method: 'PUT',
|
||
|
|
path: '/api/v1/posts/{slug}',
|
||
|
|
description: 'Update an existing post',
|
||
|
|
requestBody: `{
|
||
|
|
"title": "Updated Title",
|
||
|
|
"content": "# Updated content"
|
||
|
|
}`,
|
||
|
|
responseExample: `{
|
||
|
|
"id": "uuid",
|
||
|
|
"slug": "hello-world",
|
||
|
|
"title": "Updated Title",
|
||
|
|
...
|
||
|
|
}`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
method: 'DELETE',
|
||
|
|
path: '/api/v1/posts/{slug}',
|
||
|
|
description: 'Delete a post permanently',
|
||
|
|
responseExample: `204 No Content`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
method: 'GET',
|
||
|
|
path: '/api/v1/settings',
|
||
|
|
description: 'Get public site configuration',
|
||
|
|
responseExample: `{
|
||
|
|
"site_name": "My Blog",
|
||
|
|
"site_description": "A developer blog",
|
||
|
|
"author_name": "Jane Doe",
|
||
|
|
"author_bio": "Software engineer",
|
||
|
|
"twitter_handle": "janedoe",
|
||
|
|
"accent_color": "#10b981"
|
||
|
|
}`,
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
const methodColors: Record<HttpMethod, string> = {
|
||
|
|
GET: 'bg-success/15 text-success',
|
||
|
|
POST: 'bg-blue-500/15 text-blue-500',
|
||
|
|
PUT: 'bg-warning/15 text-warning',
|
||
|
|
DELETE: 'bg-danger/15 text-danger',
|
||
|
|
}
|
||
|
|
|
||
|
|
const webhookEvents: { value: WebhookEvent; label: string }[] = [
|
||
|
|
{ value: 'post.published', label: 'Post Published' },
|
||
|
|
{ value: 'post.updated', label: 'Post Updated' },
|
||
|
|
{ value: 'post.deleted', label: 'Post Deleted' },
|
||
|
|
]
|
||
|
|
|
||
|
|
function EndpointCard({ endpoint, baseUrl, apiKey }: { endpoint: Endpoint; baseUrl: string; apiKey?: string }) {
|
||
|
|
const [expanded, setExpanded] = useState(false)
|
||
|
|
const [slugInput, setSlugInput] = useState('hello-world')
|
||
|
|
const [requestBody, setRequestBody] = useState(endpoint.requestBody || '')
|
||
|
|
const [queryParams, setQueryParams] = useState<Record<string, string>>(() => {
|
||
|
|
const initial: Record<string, string> = {}
|
||
|
|
endpoint.queryParams?.forEach(p => {
|
||
|
|
initial[p.name] = p.default || ''
|
||
|
|
})
|
||
|
|
return initial
|
||
|
|
})
|
||
|
|
const [response, setResponse] = useState<{ status: number; body: string; time: number } | null>(null)
|
||
|
|
const [loading, setLoading] = useState(false)
|
||
|
|
|
||
|
|
const basePath = endpoint.path.replace('{slug}', slugInput)
|
||
|
|
const queryString = Object.entries(queryParams)
|
||
|
|
.filter(([_, v]) => v !== '')
|
||
|
|
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
||
|
|
.join('&')
|
||
|
|
const actualPath = queryString ? `${basePath}?${queryString}` : basePath
|
||
|
|
const fullUrl = `${baseUrl}${actualPath}`
|
||
|
|
|
||
|
|
const curlExample = endpoint.requestBody
|
||
|
|
? `curl -X ${endpoint.method} "${fullUrl}" \\
|
||
|
|
-H "Authorization: Bearer ${apiKey || 'YOUR_API_KEY'}" \\
|
||
|
|
-H "Content-Type: application/json" \\
|
||
|
|
-d '${endpoint.requestBody.replace(/\n\s*/g, ' ')}'`
|
||
|
|
: `curl -X ${endpoint.method} "${fullUrl}" \\
|
||
|
|
-H "Authorization: Bearer ${apiKey || 'YOUR_API_KEY'}"`
|
||
|
|
|
||
|
|
const copyCode = (code: string) => {
|
||
|
|
navigator.clipboard.writeText(code)
|
||
|
|
addToast('Copied to clipboard', 'success')
|
||
|
|
}
|
||
|
|
|
||
|
|
const updateQueryParam = (name: string, value: string) => {
|
||
|
|
setQueryParams(prev => ({ ...prev, [name]: value }))
|
||
|
|
}
|
||
|
|
|
||
|
|
const sendRequest = async () => {
|
||
|
|
setLoading(true)
|
||
|
|
setResponse(null)
|
||
|
|
const start = Date.now()
|
||
|
|
try {
|
||
|
|
const headers: Record<string, string> = {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
}
|
||
|
|
if (apiKey) {
|
||
|
|
headers['Authorization'] = `Bearer ${apiKey}`
|
||
|
|
}
|
||
|
|
const res = await fetch(actualPath, {
|
||
|
|
method: endpoint.method,
|
||
|
|
headers,
|
||
|
|
body: endpoint.requestBody ? requestBody : undefined,
|
||
|
|
})
|
||
|
|
const time = Date.now() - start
|
||
|
|
let body: string
|
||
|
|
try {
|
||
|
|
const json = await res.json()
|
||
|
|
body = JSON.stringify(json, null, 2)
|
||
|
|
} catch {
|
||
|
|
body = res.status === 204 ? '(No Content)' : await res.text()
|
||
|
|
}
|
||
|
|
setResponse({ status: res.status, body, time })
|
||
|
|
} catch (err) {
|
||
|
|
setResponse({ status: 0, body: `Error: ${err instanceof Error ? err.message : 'Request failed'}`, time: Date.now() - start })
|
||
|
|
} finally {
|
||
|
|
setLoading(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="border border-border hover:border-muted transition-colors">
|
||
|
|
<button
|
||
|
|
onClick={() => setExpanded(!expanded)}
|
||
|
|
className="group w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-muted/5 transition-colors cursor-pointer"
|
||
|
|
>
|
||
|
|
<span className={`px-2 py-0.5 text-xs font-mono font-medium ${methodColors[endpoint.method]}`}>
|
||
|
|
{endpoint.method}
|
||
|
|
</span>
|
||
|
|
<code className="text-sm font-mono flex-1 group-hover:text-accent transition-colors">{endpoint.path}</code>
|
||
|
|
<span className="text-xs text-muted hidden sm:block">{endpoint.description}</span>
|
||
|
|
<Icons.ChevronRight className={`text-muted transition-transform group-hover:text-text ${expanded ? 'rotate-90' : ''}`} />
|
||
|
|
</button>
|
||
|
|
|
||
|
|
{expanded && (
|
||
|
|
<div className="border-t border-border bg-muted/5 p-4 space-y-4">
|
||
|
|
<p className="text-sm text-muted">{endpoint.description}</p>
|
||
|
|
|
||
|
|
{endpoint.path.includes('{slug}') && (
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<label className="text-xs text-muted w-16">slug:</label>
|
||
|
|
<Input
|
||
|
|
value={slugInput}
|
||
|
|
onChange={setSlugInput}
|
||
|
|
className="max-w-48 text-sm"
|
||
|
|
placeholder="post-slug"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{endpoint.queryParams && endpoint.queryParams.length > 0 && (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="text-xs text-muted">Query Parameters</div>
|
||
|
|
{endpoint.queryParams.map(param => (
|
||
|
|
<div key={param.name} className="flex items-center gap-2">
|
||
|
|
<label className="text-xs text-muted w-16 shrink-0">{param.name}:</label>
|
||
|
|
{param.options ? (
|
||
|
|
<select
|
||
|
|
value={queryParams[param.name] || ''}
|
||
|
|
onChange={e => updateQueryParam(param.name, e.target.value)}
|
||
|
|
className="px-2 py-1.5 text-sm bg-surface border border-border focus:border-accent focus:outline-none"
|
||
|
|
>
|
||
|
|
{param.options.map(opt => (
|
||
|
|
<option key={opt} value={opt}>{opt || '(none)'}</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
) : (
|
||
|
|
<Input
|
||
|
|
value={queryParams[param.name] || ''}
|
||
|
|
onChange={v => updateQueryParam(param.name, v)}
|
||
|
|
className="max-w-32 text-sm"
|
||
|
|
placeholder={param.default || ''}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
<span className="text-xs text-muted">{param.description}</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{endpoint.requestBody && (
|
||
|
|
<div>
|
||
|
|
<div className="text-xs text-muted mb-2">Request Body</div>
|
||
|
|
<textarea
|
||
|
|
value={requestBody}
|
||
|
|
onChange={e => setRequestBody(e.target.value)}
|
||
|
|
className="w-full p-3 bg-surface border border-border text-xs font-mono resize-y min-h-32"
|
||
|
|
spellCheck={false}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<Button
|
||
|
|
variant="primary"
|
||
|
|
Icon={loading ? undefined : Icons.Play}
|
||
|
|
onClick={sendRequest}
|
||
|
|
loading={loading}
|
||
|
|
>
|
||
|
|
{loading ? 'Sending...' : 'Try it'}
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
{response && (
|
||
|
|
<div>
|
||
|
|
<div className="flex items-center gap-3 mb-2">
|
||
|
|
<span className="text-xs text-muted">Response</span>
|
||
|
|
<span className={`px-2 py-0.5 text-xs font-mono ${response.status >= 200 && response.status < 300 ? 'bg-success/15 text-success' : response.status >= 400 ? 'bg-danger/15 text-danger' : 'bg-warning/15 text-warning'}`}>
|
||
|
|
{response.status || 'Error'}
|
||
|
|
</span>
|
||
|
|
<span className="text-xs text-muted">{response.time}ms</span>
|
||
|
|
</div>
|
||
|
|
<pre className="p-3 bg-surface border border-border text-xs font-mono max-h-64 overflow-y-auto whitespace-pre-wrap break-words">
|
||
|
|
{response.body}
|
||
|
|
</pre>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<div className="text-xs text-muted mb-2">Example Response</div>
|
||
|
|
<pre className="p-3 bg-surface border border-border text-xs font-mono overflow-x-auto">
|
||
|
|
{endpoint.responseExample}
|
||
|
|
</pre>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<div className="flex items-center justify-between mb-2">
|
||
|
|
<span className="text-xs text-muted">cURL</span>
|
||
|
|
<button onClick={() => copyCode(curlExample)} className="text-xs text-muted hover:text-text">
|
||
|
|
<Icons.Copy className="inline mr-1" />Copy
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<pre className="p-3 bg-surface border border-border text-xs font-mono overflow-x-auto whitespace-pre-wrap">
|
||
|
|
{curlExample}
|
||
|
|
</pre>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function WebhookCard({ webhook, onEdit, onDelete, onTest }: {
|
||
|
|
webhook: Webhook
|
||
|
|
onEdit: () => void
|
||
|
|
onDelete: () => void
|
||
|
|
onTest: () => void
|
||
|
|
}) {
|
||
|
|
const [showLogs, setShowLogs] = useState(false)
|
||
|
|
const [deliveries, setDeliveries] = useState<WebhookDelivery[]>([])
|
||
|
|
const [loadingLogs, setLoadingLogs] = useState(false)
|
||
|
|
|
||
|
|
const loadDeliveries = async () => {
|
||
|
|
setLoadingLogs(true)
|
||
|
|
try {
|
||
|
|
const data = await fetchWebhookDeliveries(webhook.id)
|
||
|
|
setDeliveries(data)
|
||
|
|
setShowLogs(true)
|
||
|
|
} catch {
|
||
|
|
addToast('Failed to load delivery logs', 'error')
|
||
|
|
} finally {
|
||
|
|
setLoadingLogs(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="border border-border p-4">
|
||
|
|
<div className="flex items-start justify-between gap-4">
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<div className="flex items-center gap-2 mb-1">
|
||
|
|
<span className={`w-2 h-2 rounded-full ${webhook.enabled ? (webhook.last_status === 'success' ? 'bg-success' : webhook.last_status === 'failed' ? 'bg-danger' : 'bg-muted') : 'bg-muted'}`} />
|
||
|
|
<span className="font-medium text-sm">{webhook.name}</span>
|
||
|
|
{!webhook.enabled && <span className="text-xs text-muted">(disabled)</span>}
|
||
|
|
</div>
|
||
|
|
<code className="text-xs text-muted break-all">{webhook.url}</code>
|
||
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
||
|
|
{webhook.events.map(event => (
|
||
|
|
<span key={event} className="px-1.5 py-0.5 text-xs bg-muted/20 text-muted">
|
||
|
|
{event}
|
||
|
|
</span>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
<button onClick={loadDeliveries} className="p-1.5 text-muted hover:text-text" title="View logs">
|
||
|
|
<Icons.List className="w-4 h-4" />
|
||
|
|
</button>
|
||
|
|
<button onClick={onTest} className="p-1.5 text-muted hover:text-text" title="Test">
|
||
|
|
<Icons.Play className="w-4 h-4" />
|
||
|
|
</button>
|
||
|
|
<button onClick={onEdit} className="p-1.5 text-muted hover:text-text" title="Edit">
|
||
|
|
<Icons.Edit className="w-4 h-4" />
|
||
|
|
</button>
|
||
|
|
<button onClick={onDelete} className="p-1.5 text-danger hover:text-danger/80" title="Delete">
|
||
|
|
<Icons.Trash className="w-4 h-4" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{showLogs && (
|
||
|
|
<div className="mt-4 pt-4 border-t border-border">
|
||
|
|
<div className="flex items-center justify-between mb-2">
|
||
|
|
<span className="text-xs font-medium text-muted">Recent Deliveries</span>
|
||
|
|
<button onClick={() => setShowLogs(false)} className="text-xs text-muted hover:text-text">Close</button>
|
||
|
|
</div>
|
||
|
|
{loadingLogs ? (
|
||
|
|
<div className="text-xs text-muted">Loading...</div>
|
||
|
|
) : deliveries.length === 0 ? (
|
||
|
|
<div className="text-xs text-muted">No deliveries yet</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||
|
|
{deliveries.slice(0, 10).map(d => (
|
||
|
|
<div key={d.id} className="flex items-center gap-2 text-xs">
|
||
|
|
<span className={`w-2 h-2 rounded-full ${d.status === 'success' ? 'bg-success' : 'bg-danger'}`} />
|
||
|
|
<span className="text-muted">{d.event}</span>
|
||
|
|
<span className="text-muted">•</span>
|
||
|
|
<span className={d.status === 'success' ? 'text-success' : 'text-danger'}>
|
||
|
|
{d.response_code || d.status}
|
||
|
|
</span>
|
||
|
|
<span className="text-muted ml-auto">
|
||
|
|
{new Date(d.created_at).toLocaleString()}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function APIPage() {
|
||
|
|
const { data: keys } = useStore($apiKeys)
|
||
|
|
const { data: webhooks } = useStore($webhooks)
|
||
|
|
const { data: billing } = useStore($billing)
|
||
|
|
const creating = useStore($creating)
|
||
|
|
const deleteKey = useStore($deleteAPIKey)
|
||
|
|
const deleteWebhookMutation = useStore($deleteWebhook)
|
||
|
|
|
||
|
|
const [showCreateKey, setShowCreateKey] = useState(false)
|
||
|
|
const [newKeyName, setNewKeyName] = useState('')
|
||
|
|
const [createdKey, setCreatedKey] = useState<string | null>(null)
|
||
|
|
const [deleteKeyModal, setDeleteKeyModal] = useState<string | null>(null)
|
||
|
|
|
||
|
|
const [showWebhookModal, setShowWebhookModal] = useState(false)
|
||
|
|
const [editingWebhook, setEditingWebhook] = useState<Webhook | null>(null)
|
||
|
|
const [webhookForm, setWebhookForm] = useState({ name: '', url: '', events: [] as WebhookEvent[], secret: '', enabled: true })
|
||
|
|
const [deleteWebhookModal, setDeleteWebhookModal] = useState<string | null>(null)
|
||
|
|
const [savingWebhook, setSavingWebhook] = useState(false)
|
||
|
|
|
||
|
|
const handleCreateKey = async () => {
|
||
|
|
if (!newKeyName.trim()) return
|
||
|
|
try {
|
||
|
|
const result = await createAPIKey(newKeyName)
|
||
|
|
setCreatedKey(result.key)
|
||
|
|
setNewKeyName('')
|
||
|
|
setShowCreateKey(false)
|
||
|
|
addToast('API key created', 'success')
|
||
|
|
} catch {
|
||
|
|
addToast('Failed to create API key', 'error')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleDeleteKey = async () => {
|
||
|
|
if (!deleteKeyModal) return
|
||
|
|
try {
|
||
|
|
await deleteKey.mutate(deleteKeyModal)
|
||
|
|
addToast('API key deleted', 'success')
|
||
|
|
setDeleteKeyModal(null)
|
||
|
|
} catch {
|
||
|
|
addToast('Failed to delete API key', 'error')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const copyKey = (key: string) => {
|
||
|
|
navigator.clipboard.writeText(key)
|
||
|
|
addToast('Copied to clipboard', 'success')
|
||
|
|
}
|
||
|
|
|
||
|
|
const formatDate = (dateStr: string | null) => {
|
||
|
|
if (!dateStr) return 'Never'
|
||
|
|
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||
|
|
}
|
||
|
|
|
||
|
|
const openWebhookModal = (webhook?: Webhook) => {
|
||
|
|
if (webhook) {
|
||
|
|
setEditingWebhook(webhook)
|
||
|
|
setWebhookForm({ name: webhook.name, url: webhook.url, events: webhook.events, secret: webhook.secret || '', enabled: webhook.enabled })
|
||
|
|
} else {
|
||
|
|
setEditingWebhook(null)
|
||
|
|
setWebhookForm({ name: '', url: '', events: [], secret: '', enabled: true })
|
||
|
|
}
|
||
|
|
setShowWebhookModal(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleSaveWebhook = async () => {
|
||
|
|
if (!webhookForm.name || !webhookForm.url || webhookForm.events.length === 0) {
|
||
|
|
addToast('Name, URL, and at least one event are required', 'error')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
setSavingWebhook(true)
|
||
|
|
try {
|
||
|
|
if (editingWebhook) {
|
||
|
|
await updateWebhook(editingWebhook.id, webhookForm)
|
||
|
|
addToast('Webhook updated', 'success')
|
||
|
|
} else {
|
||
|
|
await createWebhook(webhookForm)
|
||
|
|
addToast('Webhook created', 'success')
|
||
|
|
}
|
||
|
|
setShowWebhookModal(false)
|
||
|
|
} catch {
|
||
|
|
addToast('Failed to save webhook', 'error')
|
||
|
|
} finally {
|
||
|
|
setSavingWebhook(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleDeleteWebhook = async () => {
|
||
|
|
if (!deleteWebhookModal) return
|
||
|
|
try {
|
||
|
|
await deleteWebhookMutation.mutate(deleteWebhookModal)
|
||
|
|
addToast('Webhook deleted', 'success')
|
||
|
|
setDeleteWebhookModal(null)
|
||
|
|
} catch {
|
||
|
|
addToast('Failed to delete webhook', 'error')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleTestWebhook = async (id: string) => {
|
||
|
|
try {
|
||
|
|
await testWebhook(id)
|
||
|
|
addToast('Test webhook sent', 'success')
|
||
|
|
} catch {
|
||
|
|
addToast('Failed to send test webhook', 'error')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const toggleEvent = (event: WebhookEvent) => {
|
||
|
|
setWebhookForm(f => ({
|
||
|
|
...f,
|
||
|
|
events: f.events.includes(event) ? f.events.filter(e => e !== event) : [...f.events, event]
|
||
|
|
}))
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!keys) return <APIPageSkeleton />
|
||
|
|
|
||
|
|
const baseUrl = window.location.origin
|
||
|
|
const firstKey = keys.length > 0 ? keys[0].key : undefined
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
<PageHeader>
|
||
|
|
<Button variant="primary" Icon={Icons.Plus} onClick={() => setShowCreateKey(true)}>
|
||
|
|
Create Key
|
||
|
|
</Button>
|
||
|
|
</PageHeader>
|
||
|
|
|
||
|
|
<div className="-mx-6 lg:-mx-10 mt-6">
|
||
|
|
{createdKey && (
|
||
|
|
<div className="px-6 lg:px-10 py-4 bg-success/10 border-b border-success">
|
||
|
|
<div className="flex items-center gap-2 mb-2">
|
||
|
|
<Icons.CheckCircle className="text-success" />
|
||
|
|
<span className="text-sm font-medium">API Key Created</span>
|
||
|
|
</div>
|
||
|
|
<p className="text-xs text-muted mb-3">Copy this key now. You won't be able to see it again.</p>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<code className="flex-1 p-2 bg-surface border border-border text-xs font-mono truncate">{createdKey}</code>
|
||
|
|
<Button variant="secondary" Icon={Icons.Copy} onClick={() => copyKey(createdKey)}>Copy</Button>
|
||
|
|
</div>
|
||
|
|
<button onClick={() => setCreatedKey(null)} className="mt-2 text-xs text-muted hover:text-text">Dismiss</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* API Keys */}
|
||
|
|
<div className="px-6 lg:px-10 py-5">
|
||
|
|
<div className="text-xs font-medium text-muted uppercase tracking-wide">Your API Keys</div>
|
||
|
|
<div className="text-xs text-muted mt-0.5">Use these keys to authenticate API requests</div>
|
||
|
|
</div>
|
||
|
|
<div className="border-t border-border">
|
||
|
|
{keys.length === 0 ? (
|
||
|
|
<div className="px-6 lg:px-10 py-12">
|
||
|
|
<EmptyState
|
||
|
|
Icon={Icons.ApiKeys}
|
||
|
|
title="No API keys"
|
||
|
|
description="Create an API key to access the API"
|
||
|
|
action={<Button variant="primary" Icon={Icons.Plus} onClick={() => setShowCreateKey(true)}>Create Key</Button>}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
keys.map((key, i) => (
|
||
|
|
<div key={key.key} className={`flex flex-col sm:flex-row sm:items-center gap-4 px-6 lg:px-10 py-4 ${i > 0 ? 'border-t border-border' : ''}`}>
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<div className="text-sm font-medium">{key.name}</div>
|
||
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted mt-1">
|
||
|
|
<code className="font-mono">{key.key.slice(0, 8)}...{key.key.slice(-4)}</code>
|
||
|
|
<span className="hidden sm:inline">•</span>
|
||
|
|
<span>Created {formatDate(key.created_at)}</span>
|
||
|
|
<span className="hidden sm:inline">•</span>
|
||
|
|
<span>Last used {formatDate(key.last_used_at)}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<Button variant="ghost" Icon={Icons.Trash} className="text-danger hover:text-danger self-start sm:self-center" onClick={() => setDeleteKeyModal(key.key)}>Delete</Button>
|
||
|
|
</div>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="border-t border-border" />
|
||
|
|
|
||
|
|
{/* Webhooks */}
|
||
|
|
<div className="px-6 lg:px-10 py-5">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<div className="text-xs font-medium text-muted uppercase tracking-wide">Webhooks</div>
|
||
|
|
<div className="text-xs text-muted mt-0.5">Get notified when posts are published, updated, or deleted</div>
|
||
|
|
</div>
|
||
|
|
{billing && (
|
||
|
|
<UsageIndicator
|
||
|
|
used={billing.usage.webhooks}
|
||
|
|
max={billing.tiers[billing.current_tier].max_webhooks}
|
||
|
|
label="used"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="px-6 lg:px-10 py-6 space-y-3">
|
||
|
|
{webhooks && webhooks.length > 0 ? (
|
||
|
|
webhooks.map(webhook => (
|
||
|
|
<WebhookCard
|
||
|
|
key={webhook.id}
|
||
|
|
webhook={webhook}
|
||
|
|
onEdit={() => openWebhookModal(webhook)}
|
||
|
|
onDelete={() => setDeleteWebhookModal(webhook.id)}
|
||
|
|
onTest={() => handleTestWebhook(webhook.id)}
|
||
|
|
/>
|
||
|
|
))
|
||
|
|
) : (
|
||
|
|
<div className="text-sm text-muted">No webhooks configured</div>
|
||
|
|
)}
|
||
|
|
{billing && billing.usage.webhooks >= billing.tiers[billing.current_tier].max_webhooks ? (
|
||
|
|
<div className="flex items-center gap-3 p-3 border border-warning/30 bg-warning/5">
|
||
|
|
<Icons.AlertCircle className="text-warning flex-shrink-0" />
|
||
|
|
<span className="text-sm text-muted">Webhook limit reached.</span>
|
||
|
|
<Button variant="primary" href="/studio/billing">Upgrade</Button>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<Button variant="secondary" Icon={Icons.Plus} onClick={() => openWebhookModal()}>Add Webhook</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="border-t border-border" />
|
||
|
|
|
||
|
|
{/* API Reference */}
|
||
|
|
<div className="px-6 lg:px-10 py-5">
|
||
|
|
<div className="text-xs font-medium text-muted uppercase tracking-wide">API Reference</div>
|
||
|
|
<div className="text-xs text-muted mt-0.5">Base URL: <code className="text-text">{baseUrl}</code></div>
|
||
|
|
</div>
|
||
|
|
<div className="px-6 lg:px-10 py-6 space-y-2">
|
||
|
|
{endpoints.map((endpoint) => (
|
||
|
|
<EndpointCard key={`${endpoint.method}-${endpoint.path}`} endpoint={endpoint} baseUrl={baseUrl} apiKey={firstKey} />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="border-t border-border" />
|
||
|
|
|
||
|
|
{/* Authentication */}
|
||
|
|
<div className="px-6 lg:px-10 py-5">
|
||
|
|
<div className="text-xs font-medium text-muted uppercase tracking-wide">Authentication</div>
|
||
|
|
<div className="text-xs text-muted mt-0.5">How to authenticate your requests</div>
|
||
|
|
</div>
|
||
|
|
<div className="px-6 lg:px-10 py-6">
|
||
|
|
<p className="text-sm text-muted mb-3">All API requests require a Bearer token in the Authorization header:</p>
|
||
|
|
<pre className="p-3 bg-surface border border-border text-xs font-mono overflow-x-auto">
|
||
|
|
Authorization: Bearer {firstKey ? `${firstKey.slice(0, 8)}...` : 'YOUR_API_KEY'}
|
||
|
|
</pre>
|
||
|
|
<p className="text-xs text-muted mt-3">Keep API keys secure and never expose them in client-side code.</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Create Key Modal */}
|
||
|
|
<Modal open={showCreateKey} onClose={() => setShowCreateKey(false)} title="Create API Key">
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="space-y-1">
|
||
|
|
<label className="label">Key Name</label>
|
||
|
|
<Input value={newKeyName} onChange={setNewKeyName} placeholder="My API Key" />
|
||
|
|
<p className="text-xs text-muted">A name to identify this key</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex justify-end gap-2">
|
||
|
|
<Button variant="secondary" onClick={() => setShowCreateKey(false)}>Cancel</Button>
|
||
|
|
<Button variant="primary" onClick={handleCreateKey} loading={creating} disabled={!newKeyName.trim()}>Create</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</Modal>
|
||
|
|
|
||
|
|
{/* Delete Key Modal */}
|
||
|
|
<Modal open={!!deleteKeyModal} onClose={() => setDeleteKeyModal(null)} title="Delete API Key">
|
||
|
|
<p className="text-sm text-muted mb-4">Are you sure? Applications using this key will stop working.</p>
|
||
|
|
<div className="flex justify-end gap-2">
|
||
|
|
<Button variant="secondary" onClick={() => setDeleteKeyModal(null)}>Cancel</Button>
|
||
|
|
<Button variant="danger" onClick={handleDeleteKey} loading={deleteKey.loading}>Delete</Button>
|
||
|
|
</div>
|
||
|
|
</Modal>
|
||
|
|
|
||
|
|
{/* Webhook Modal */}
|
||
|
|
<Modal open={showWebhookModal} onClose={() => setShowWebhookModal(false)} title={editingWebhook ? 'Edit Webhook' : 'Add Webhook'}>
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="space-y-1">
|
||
|
|
<label className="label">Name</label>
|
||
|
|
<Input value={webhookForm.name} onChange={v => setWebhookForm(f => ({ ...f, name: v }))} placeholder="Discord Notification" />
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1">
|
||
|
|
<label className="label">URL</label>
|
||
|
|
<Input value={webhookForm.url} onChange={v => setWebhookForm(f => ({ ...f, url: v }))} placeholder="https://your-server.com/webhook" />
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<label className="label">Events</label>
|
||
|
|
<div className="flex flex-wrap gap-2">
|
||
|
|
{webhookEvents.map(e => (
|
||
|
|
<button
|
||
|
|
key={e.value}
|
||
|
|
onClick={() => toggleEvent(e.value)}
|
||
|
|
className={`px-3 py-1.5 text-xs border transition-colors ${webhookForm.events.includes(e.value) ? 'border-accent bg-accent/10 text-accent' : 'border-border text-muted hover:border-muted'}`}
|
||
|
|
>
|
||
|
|
{e.label}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1">
|
||
|
|
<label className="label">Secret (optional)</label>
|
||
|
|
<Input value={webhookForm.secret} onChange={v => setWebhookForm(f => ({ ...f, secret: v }))} placeholder="For HMAC signature verification" />
|
||
|
|
<p className="text-xs text-muted">Used to sign payloads with X-WriteKit-Signature header</p>
|
||
|
|
</div>
|
||
|
|
{editingWebhook && (
|
||
|
|
<label className="flex items-center gap-2 text-sm">
|
||
|
|
<input type="checkbox" checked={webhookForm.enabled} onChange={e => setWebhookForm(f => ({ ...f, enabled: e.target.checked }))} />
|
||
|
|
Enabled
|
||
|
|
</label>
|
||
|
|
)}
|
||
|
|
<div className="flex justify-end gap-2">
|
||
|
|
<Button variant="secondary" onClick={() => setShowWebhookModal(false)}>Cancel</Button>
|
||
|
|
<Button variant="primary" onClick={handleSaveWebhook} loading={savingWebhook}>{editingWebhook ? 'Save' : 'Create'}</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</Modal>
|
||
|
|
|
||
|
|
{/* Delete Webhook Modal */}
|
||
|
|
<Modal open={!!deleteWebhookModal} onClose={() => setDeleteWebhookModal(null)} title="Delete Webhook">
|
||
|
|
<p className="text-sm text-muted mb-4">Are you sure you want to delete this webhook?</p>
|
||
|
|
<div className="flex justify-end gap-2">
|
||
|
|
<Button variant="secondary" onClick={() => setDeleteWebhookModal(null)}>Cancel</Button>
|
||
|
|
<Button variant="danger" onClick={handleDeleteWebhook} loading={deleteWebhookMutation.loading}>Delete</Button>
|
||
|
|
</div>
|
||
|
|
</Modal>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|