9d43e4b113
CRITICAL — Authentication & Access: - TOTP MFA: otpauth-based, QR setup UI, sign-in flow integration, admin disable override, /account/security self-service page - Session Timeouts: 8h absolute (maxAge), 30min idle (updateAge) - Failed Auth Logging: Pino warn for invalid password/user/totp, info for successful login, audit entries for all auth events - Concurrent Session Limit: ActiveSession model, oldest-kick strategy, max 3 per user (configurable in SystemSettings) CRITICAL — HTTP Security: - HSTS: max-age=31536000; includeSubDomains - CSP: script/style/img/font/connect-src with Gemini/OpenAI whitelist - X-XSS-Protection: 0 (CSP replaces legacy) - Auth page cache: no-store, no-cache, must-revalidate - Rate Limiting: 100/15min general API, 5/15min auth (Map-based) Data Protection: - XSS Sanitization: DOMPurify on comment bodies - autocomplete="new-password" on all password/secret fields - SameSite=Strict on all cookies (Credentials-only, no OAuth) - File Upload Magic Bytes validation (PNG/JPEG/WebP/GIF/BMP/TIFF) Logging & Monitoring: - Login/Logout audit entries (Auth entityType) - External API call logging with timing (OpenAI, Gemini) - Input validation failure logging at warn level - Concurrent session tracking in ActiveSession table Documentation: - docs/security-architecture.md (11 sections) - docs/sdlc.md (CI pipeline, security gates, incident response) - .gitea/PULL_REQUEST_TEMPLATE.md (security checklist) Schema: User.totpSecret/totpEnabled, SystemSettings.sessionMaxAge/ sessionIdleTimeout/maxConcurrentSessions, ActiveSession model Tests: 310 engine + 37 staffing pass. TypeScript clean. Co-Authored-By: claude-flow <ruv@ruv.net>
418 lines
14 KiB
TypeScript
418 lines
14 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 CapaKraken.
|
|
</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>
|
|
);
|
|
}
|