Files
Nexus/apps/web/src/components/admin/WebhooksClient.tsx
T
Hartmut 4a5edeef3e
CI / Unit Tests (pull_request) Successful in 5m46s
CI / Lint (pull_request) Failing after 3m49s
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Failing after 35s
CI / Architecture Guardrails (pull_request) Failing after 2m14s
CI / Typecheck (pull_request) Successful in 4m22s
CI / Build (pull_request) Has been skipped
CI / Release Images (pull_request) Has been skipped
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI
- @capakraken/* → @nexus/* across 12 packages (root + 11 workspaces),
  1551 import lines migrated via codemod
- User-visible brand strings renamed (emails, page titles, PWA
  manifest, mobile header, MFA backup-codes header, tooltips, signin
  page, invite page, weekly digest, install prompt)
- TOTP issuer "CapaKraken" → "Nexus" (existing secrets still valid;
  re-enrollment relabels them in users' authenticator apps)
- Function rename: assertCapaKrakenDbTarget → assertNexusDbTarget
- LocalStorage migration shim in apps/web/src/app/layout.tsx copies
  capakraken_* → nexus_* on first load (guarded by nexus_migrated_v1
  sentinel; runs once per browser, then never again)
- Service-worker cache name capakraken-v2 → nexus-v2 with one-time
  caches.delete('capakraken-v2') from the same shim
- Email-domain fixtures @capakraken.{dev,app} → @nexus.{dev,app} in
  seed data, e2e specs, SMTP default fallback
- Dockerfile.dev / Dockerfile.prod / all .github/workflows/*.yml
  pnpm --filter @capakraken/* → @nexus/*
- README, CLAUDE.md, LEARNINGS.md, all docs/*.md, .env.example,
  tooling/deploy/.env.production.example brand sweep

Phase 1 deliberately leaves untouched (handled in Phase 3 cutover):
- PostgreSQL DB name "capakraken" and POSTGRES_USER "capakraken"
- Volume names capakraken_pgdata etc.
- Compose project name "capakraken" / "capakraken-prod"
- db-target-guard default expectedDatabase
- env-var CAPAKRAKEN_EXPECTED_DB_NAME
- Container DNS names in docker-compose.ci.yml

Quality gates green: pnpm typecheck (7/7), pnpm test:unit (7/7),
pnpm lint (0 errors), check:exports/imports/architecture all pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:10:44 +02:00

400 lines
13 KiB
TypeScript

"use client";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
const WEBHOOK_EVENTS = [
"allocation.created",
"allocation.updated",
"allocation.deleted",
"project.created",
"project.status_changed",
"vacation.approved",
"estimate.submitted",
"estimate.approved",
] as const;
const EVENT_LABELS: Record<string, string> = {
"allocation.created": "Allocation Created",
"allocation.updated": "Allocation Updated",
"allocation.deleted": "Allocation Deleted",
"project.created": "Project Created",
"project.status_changed": "Project Status Changed",
"vacation.approved": "Vacation Approved",
"estimate.submitted": "Estimate Submitted",
"estimate.approved": "Estimate Approved",
};
const INPUT_CLASS = "app-input";
const LABEL_CLASS = "app-label";
const PRIMARY_BUTTON =
"rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:opacity-50";
const SECONDARY_BUTTON =
"rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800";
const DANGER_BUTTON =
"rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:opacity-50";
interface WebhookFormData {
name: string;
url: string;
secret: string;
events: string[];
isActive: boolean;
}
const emptyForm: WebhookFormData = {
name: "",
url: "",
secret: "",
events: [],
isActive: true,
};
function maskUrl(url: string): string {
try {
const u = new URL(url);
const host = u.hostname;
// Show scheme + host, mask the rest
if (u.pathname.length > 1) {
return `${u.protocol}//${host}/****`;
}
return `${u.protocol}//${host}`;
} catch {
return "****";
}
}
export function WebhooksClient() {
const utils = trpc.useUtils();
const { data: webhooks, isLoading } = trpc.webhook.list.useQuery();
const createMut = trpc.webhook.create.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate();
setModalOpen(false);
},
});
const updateMut = trpc.webhook.update.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate();
setModalOpen(false);
},
});
const deleteMut = trpc.webhook.delete.useMutation({
onSuccess: () => void utils.webhook.list.invalidate(),
});
const testMut = trpc.webhook.test.useMutation();
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<WebhookFormData>(emptyForm);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [testResult, setTestResult] = useState<{
id: string;
success: boolean;
statusCode: number;
statusText: string;
} | null>(null);
function openCreateModal() {
setEditingId(null);
setForm(emptyForm);
setModalOpen(true);
}
function openEditModal(wh: {
id: string;
name: string;
url: string;
secret: string | null;
events: string[];
isActive: boolean;
}) {
setEditingId(wh.id);
setForm({
name: wh.name,
url: wh.url,
secret: wh.secret ?? "",
events: wh.events,
isActive: wh.isActive,
});
setModalOpen(true);
}
function toggleEvent(event: string) {
setForm((prev) => ({
...prev,
events: prev.events.includes(event)
? prev.events.filter((e) => e !== event)
: [...prev.events, event],
}));
}
function handleSubmit() {
if (editingId) {
updateMut.mutate({
id: editingId,
data: {
name: form.name,
url: form.url,
...(form.secret ? { secret: form.secret } : { secret: null }),
events: form.events,
isActive: form.isActive,
},
});
} else {
createMut.mutate({
name: form.name,
url: form.url,
...(form.secret ? { secret: form.secret } : {}),
events: form.events,
isActive: form.isActive,
});
}
}
function handleTest(id: string) {
setTestResult(null);
testMut.mutate(
{ id },
{
onSuccess: (result) => {
setTestResult({ id, ...result });
},
},
);
}
function handleToggleActive(id: string, currentActive: boolean) {
updateMut.mutate({ id, data: { isActive: !currentActive } });
}
const isSaving = createMut.isPending || updateMut.isPending;
return (
<div className="mx-auto max-w-4xl space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Webhooks</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure outbound webhooks to notify external services about events in Nexus.
</p>
</div>
<button className={PRIMARY_BUTTON} onClick={openCreateModal}>
Add Webhook
</button>
</div>
{/* Webhook List */}
{isLoading ? (
<div className="app-surface p-8 text-center text-gray-500">Loading...</div>
) : !webhooks?.length ? (
<div className="app-surface p-8 text-center text-gray-500 dark:text-gray-400">
No webhooks configured yet.
</div>
) : (
<div className="space-y-3">
{webhooks.map((wh) => (
<div key={wh.id} className="app-surface flex items-center gap-4 p-4">
{/* Active indicator */}
<div
className={`h-3 w-3 shrink-0 rounded-full ${
wh.isActive ? "bg-green-500" : "bg-gray-300 dark:bg-gray-600"
}`}
title={wh.isActive ? "Active" : "Inactive"}
/>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">{wh.name}</span>
{wh.url.includes("hooks.slack.com") && (
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
Slack
</span>
)}
</div>
<div className="mt-0.5 truncate text-sm text-gray-500 dark:text-gray-400">
{maskUrl(wh.url)}
</div>
<div className="mt-1 flex flex-wrap gap-1">
{wh.events.map((ev) => (
<span
key={ev}
className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-400"
>
{EVENT_LABELS[ev] ?? ev}
</span>
))}
</div>
{/* Test result */}
{testResult && testResult.id === wh.id && (
<div
className={`mt-1 text-xs ${
testResult.success
? "text-green-600 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}`}
>
Test: {testResult.statusCode} {testResult.statusText}
</div>
)}
</div>
{/* Actions */}
<div className="flex shrink-0 items-center gap-2">
<button
className={SECONDARY_BUTTON}
onClick={() => handleTest(wh.id)}
disabled={testMut.isPending}
title="Send test payload"
>
Test
</button>
<button
className={SECONDARY_BUTTON}
onClick={() => handleToggleActive(wh.id, wh.isActive)}
disabled={updateMut.isPending}
>
{wh.isActive ? "Disable" : "Enable"}
</button>
<button className={SECONDARY_BUTTON} onClick={() => openEditModal(wh)}>
Edit
</button>
{deleteConfirmId === wh.id ? (
<div className="flex items-center gap-1">
<button
className={DANGER_BUTTON}
onClick={() => {
deleteMut.mutate({ id: wh.id });
setDeleteConfirmId(null);
}}
disabled={deleteMut.isPending}
>
Confirm
</button>
<button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(null)}>
Cancel
</button>
</div>
) : (
<button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(wh.id)}>
Delete
</button>
)}
</div>
</div>
))}
</div>
)}
{/* Modal */}
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="app-surface-strong mx-4 w-full max-w-lg space-y-5 rounded-2xl p-6 shadow-xl">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingId ? "Edit Webhook" : "Create Webhook"}
</h2>
{/* Name */}
<div>
<label className={LABEL_CLASS}>Name</label>
<input
className={INPUT_CLASS}
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="e.g. Slack Notifications"
/>
</div>
{/* URL */}
<div>
<label className={LABEL_CLASS}>URL</label>
<input
className={INPUT_CLASS}
value={form.url}
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
placeholder="https://hooks.slack.com/services/..."
/>
</div>
{/* Secret */}
<div>
<label className={LABEL_CLASS}>Secret (optional)</label>
<input
className={INPUT_CLASS}
type="password"
value={form.secret}
onChange={(e) => setForm((prev) => ({ ...prev, secret: e.target.value }))}
placeholder="HMAC signing secret"
autoComplete="new-password"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
If set, requests include an X-Webhook-Signature header (HMAC-SHA256).
</p>
</div>
{/* Events */}
<div>
<label className={LABEL_CLASS}>Events</label>
<div className="mt-1 grid grid-cols-2 gap-2">
{WEBHOOK_EVENTS.map((ev) => (
<label
key={ev}
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900/70"
>
<input
type="checkbox"
checked={form.events.includes(ev)}
onChange={() => toggleEvent(ev)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-gray-700 dark:text-gray-300">
{EVENT_LABELS[ev] ?? ev}
</span>
</label>
))}
</div>
</div>
{/* Active toggle */}
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={form.isActive}
onChange={(e) => setForm((prev) => ({ ...prev, isActive: e.target.checked }))}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Active
</label>
{/* Error display */}
{(createMut.error || updateMut.error) && (
<p className="text-sm text-red-600 dark:text-red-400">
{createMut.error?.message ?? updateMut.error?.message}
</p>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
className={SECONDARY_BUTTON}
onClick={() => setModalOpen(false)}
disabled={isSaving}
>
Cancel
</button>
<button
className={PRIMARY_BUTTON}
onClick={handleSubmit}
disabled={isSaving || !form.name || !form.url || form.events.length === 0}
>
{isSaving ? "Saving..." : editingId ? "Update" : "Create"}
</button>
</div>
</div>
</div>
)}
</div>
);
}