From 149952e4608c1b9a7a0bdb3e78c04e6e2543b666 Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Mon, 15 Jun 2026 21:58:04 +0200 Subject: [PATCH 1/8] feat(alerts): add alerts feed page with inline triage List company alerts with severity color coding, search and severity/status filters. Acknowledge and resolve inline via a company-scoped PATCH route. --- src/app/(dashboard)/alerts/page.tsx | 20 ++++ src/app/api/alerts/[id]/route.ts | 29 +++++ src/components/alerts/AlertTable.tsx | 152 +++++++++++++++++++++++++++ src/lib/alerts.ts | 36 +++++++ 4 files changed, 237 insertions(+) create mode 100644 src/app/(dashboard)/alerts/page.tsx create mode 100644 src/app/api/alerts/[id]/route.ts create mode 100644 src/components/alerts/AlertTable.tsx create mode 100644 src/lib/alerts.ts diff --git a/src/app/(dashboard)/alerts/page.tsx b/src/app/(dashboard)/alerts/page.tsx new file mode 100644 index 0000000..2da14f4 --- /dev/null +++ b/src/app/(dashboard)/alerts/page.tsx @@ -0,0 +1,20 @@ +import { auth } from "@/auth" +import { getAlerts } from "@/lib/alerts" +import { AlertTable } from "@/components/alerts/AlertTable" + +export default async function AlertsPage() { + const session = await auth() + const alerts = await getAlerts(session!.user.companyId) + + return ( +
+
+

Alerts

+

+ Triage breach detections, acknowledge and resolve incidents +

+
+ +
+ ) +} diff --git a/src/app/api/alerts/[id]/route.ts b/src/app/api/alerts/[id]/route.ts new file mode 100644 index 0000000..844cab3 --- /dev/null +++ b/src/app/api/alerts/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { AlertStatus } from "@prisma/client" + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { id } = await params + const body = await req.json() + const status = body.status as AlertStatus + + if (!Object.values(AlertStatus).includes(status)) { + return NextResponse.json({ error: "Invalid status" }, { status: 400 }) + } + + const { count } = await prisma.alert.updateMany({ + where: { id, companyId: session.user.companyId }, + data: { status }, + }) + + if (count === 0) return NextResponse.json({ error: "Not found" }, { status: 404 }) + + return NextResponse.json({ id, status }) +} diff --git a/src/components/alerts/AlertTable.tsx b/src/components/alerts/AlertTable.tsx new file mode 100644 index 0000000..60186f1 --- /dev/null +++ b/src/components/alerts/AlertTable.tsx @@ -0,0 +1,152 @@ +"use client" + +import { useState, useMemo } from "react" +import { useRouter } from "next/navigation" +import { Search, Check, CheckCheck, Loader2 } from "lucide-react" +import { RiskBadge } from "@/components/ui/RiskBadge" +import type { AlertRow } from "@/lib/alerts" +import type { RiskLevel } from "@/lib/employees" +import { cn } from "@/lib/utils" + +const SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const +const STATUSES = ["OPEN", "ACKNOWLEDGED", "RESOLVED"] as const + +const borderBySeverity: Record = { + CRITICAL: "border-l-severity-critical", + HIGH: "border-l-severity-high", + MEDIUM: "border-l-severity-medium", + LOW: "border-l-severity-low", +} + +const statusLabel: Record = { + OPEN: "Open", + ACKNOWLEDGED: "Acknowledged", + RESOLVED: "Resolved", +} + +function timeAgo(date: Date): string { + const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000) + if (s < 60) return "just now" + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + const d = Math.floor(h / 24) + return `${d}d ago` +} + +export function AlertTable({ data }: { data: AlertRow[] }) { + const router = useRouter() + const [search, setSearch] = useState("") + const [severity, setSeverity] = useState("") + const [status, setStatus] = useState("") + const [pending, setPending] = useState(null) + + const filtered = useMemo(() => { + return data.filter((a) => { + const q = search.toLowerCase() + const matchSearch = + !q || + a.message.toLowerCase().includes(q) || + (a.employeeEmail?.toLowerCase().includes(q) ?? false) || + (a.breachName?.toLowerCase().includes(q) ?? false) + const matchSeverity = !severity || a.severity === severity + const matchStatus = !status || a.status === status + return matchSearch && matchSeverity && matchStatus + }) + }, [data, search, severity, status]) + + async function updateStatus(id: string, next: string) { + setPending(id) + const res = await fetch(`/api/alerts/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: next }), + }) + setPending(null) + if (res.ok) router.refresh() + } + + return ( + <> +
+
+ + setSearch(e.target.value)} + className="w-full rounded-lg border border-input bg-card py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/20" + /> +
+ + +
+ + {filtered.length === 0 ? ( +
+ No alerts found +
+ ) : ( +
+ {filtered.map((a) => ( +
+ +
+

{a.message}

+

+ {a.employeeEmail ?? "Unknown employee"} + {a.breachName ? ` - ${a.breachName}` : ""} - {timeAgo(a.createdAt)} +

+
+ {statusLabel[a.status]} +
+ {a.status === "OPEN" && ( + + )} + {a.status !== "RESOLVED" && ( + + )} +
+
+ ))} +
+ )} + + ) +} diff --git a/src/lib/alerts.ts b/src/lib/alerts.ts new file mode 100644 index 0000000..1f950d0 --- /dev/null +++ b/src/lib/alerts.ts @@ -0,0 +1,36 @@ +import { prisma } from "@/lib/prisma" +import type { Severity, AlertStatus } from "@prisma/client" + +export type AlertRow = { + id: string + severity: Severity + status: AlertStatus + message: string + employeeName: string | null + employeeEmail: string | null + breachName: string | null + createdAt: Date +} + +export function getOpenAlertCount(companyId: string): Promise { + return prisma.alert.count({ where: { companyId, status: "OPEN" } }) +} + +export async function getAlerts(companyId: string): Promise { + const alerts = await prisma.alert.findMany({ + where: { companyId }, + include: { employee: true, breach: true }, + orderBy: { createdAt: "desc" }, + }) + + return alerts.map((a) => ({ + id: a.id, + severity: a.severity, + status: a.status, + message: a.message, + employeeName: a.employee ? `${a.employee.firstName} ${a.employee.lastName}` : null, + employeeEmail: a.employee?.email ?? null, + breachName: a.breach?.name ?? null, + createdAt: a.createdAt, + })) +} From 6c854facf468f2795a0845827375b3a05329d078 Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Mon, 15 Jun 2026 21:58:08 +0200 Subject: [PATCH 2/8] feat(alerts): show open alert count badge in sidebar --- src/app/(dashboard)/layout.tsx | 4 ++++ src/components/layout/Sidebar.tsx | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index effccec..7fe2686 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation" import { Sidebar } from "@/components/layout/Sidebar" import { Topbar } from "@/components/layout/Topbar" import { Providers } from "@/components/providers" +import { getOpenAlertCount } from "@/lib/alerts" export default async function DashboardLayout({ children, @@ -12,12 +13,15 @@ export default async function DashboardLayout({ const session = await auth() if (!session) redirect("/login") + const openAlerts = await getOpenAlertCount(session.user.companyId) + return (
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 0b215f9..3088ae3 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -26,9 +26,10 @@ const navItems = [ interface SidebarProps { companyName: string userEmail: string + openAlerts: number } -export function Sidebar({ companyName, userEmail }: SidebarProps) { +export function Sidebar({ companyName, userEmail, openAlerts }: SidebarProps) { const pathname = usePathname() return ( @@ -59,6 +60,11 @@ export function Sidebar({ companyName, userEmail }: SidebarProps) { )} /> {label} + {href === "/alerts" && openAlerts > 0 && ( + + {openAlerts > 99 ? "99+" : openAlerts} + + )} ) })} From 6a7c4324195f10e2e8c9886aa9e5215b7a695bfc Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Mon, 15 Jun 2026 21:59:25 +0200 Subject: [PATCH 3/8] feat(export): add client CSV export on employees and alerts Export the currently filtered rows to CSV from each page, generated client-side without a round trip. --- src/components/alerts/AlertTable.tsx | 26 ++++++++++++++++++++- src/components/employees/EmployeeTable.tsx | 27 +++++++++++++++++++++- src/lib/csv.ts | 16 +++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 src/lib/csv.ts diff --git a/src/components/alerts/AlertTable.tsx b/src/components/alerts/AlertTable.tsx index 60186f1..4e9fdb6 100644 --- a/src/components/alerts/AlertTable.tsx +++ b/src/components/alerts/AlertTable.tsx @@ -2,10 +2,11 @@ import { useState, useMemo } from "react" import { useRouter } from "next/navigation" -import { Search, Check, CheckCheck, Loader2 } from "lucide-react" +import { Search, Check, CheckCheck, Loader2, Download } from "lucide-react" import { RiskBadge } from "@/components/ui/RiskBadge" import type { AlertRow } from "@/lib/alerts" import type { RiskLevel } from "@/lib/employees" +import { downloadCsv } from "@/lib/csv" import { cn } from "@/lib/utils" const SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const @@ -56,6 +57,21 @@ export function AlertTable({ data }: { data: AlertRow[] }) { }) }, [data, search, severity, status]) + function exportCsv() { + downloadCsv( + "datashield-alerts.csv", + ["severity", "status", "employee", "breach", "message", "created"], + filtered.map((a) => [ + a.severity, + a.status, + a.employeeEmail ?? "", + a.breachName ?? "", + a.message, + new Date(a.createdAt).toISOString(), + ]) + ) + } + async function updateStatus(id: string, next: string) { setPending(id) const res = await fetch(`/api/alerts/${id}`, { @@ -95,6 +111,14 @@ export function AlertTable({ data }: { data: AlertRow[] }) { {STATUSES.map((s) => )} +
{filtered.length === 0 ? ( diff --git a/src/components/employees/EmployeeTable.tsx b/src/components/employees/EmployeeTable.tsx index 392796d..f65ed3e 100644 --- a/src/components/employees/EmployeeTable.tsx +++ b/src/components/employees/EmployeeTable.tsx @@ -11,10 +11,11 @@ import { createColumnHelper, type SortingState, } from "@tanstack/react-table" -import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight, Search } from "lucide-react" +import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight, Search, Download } from "lucide-react" import { RiskBadge } from "@/components/ui/RiskBadge" import { EmployeeDrawer } from "@/components/employees/EmployeeDrawer" import type { EmployeeRow, RiskLevel } from "@/lib/employees" +import { downloadCsv } from "@/lib/csv" import { cn } from "@/lib/utils" const col = createColumnHelper() @@ -82,6 +83,22 @@ export function EmployeeTable({ data }: { data: EmployeeRow[] }) { }) }, [data, search, department, riskFilter]) + function exportCsv() { + downloadCsv( + "datashield-employees.csv", + ["name", "email", "department", "risk", "breaches", "exposed data", "last detected"], + filtered.map((e) => [ + `${e.firstName} ${e.lastName}`, + e.email, + e.department ?? "", + e.riskLevel, + e.breachCount, + e.exposedDataTypes.join("; "), + e.lastDetectedAt ? new Date(e.lastDetectedAt).toISOString().slice(0, 10) : "", + ]) + ) + } + const table = useReactTable({ data: filtered, columns, @@ -123,6 +140,14 @@ export function EmployeeTable({ data }: { data: EmployeeRow[] }) { {RISK_LEVELS.map((r) => )} +
{/* Table */} diff --git a/src/lib/csv.ts b/src/lib/csv.ts new file mode 100644 index 0000000..b91bd08 --- /dev/null +++ b/src/lib/csv.ts @@ -0,0 +1,16 @@ +type Cell = string | number + +function escapeCell(value: Cell): string { + const s = String(value) + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s +} + +export function downloadCsv(filename: string, headers: string[], rows: Cell[][]) { + const csv = [headers, ...rows].map((row) => row.map(escapeCell).join(",")).join("\n") + const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" })) + const link = document.createElement("a") + link.href = url + link.download = filename + link.click() + URL.revokeObjectURL(url) +} From f9f48d6914ee82b14927b98352da20115b2434ea Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Mon, 15 Jun 2026 22:01:33 +0200 Subject: [PATCH 4/8] feat(alerts): email admins on new breach exposure Send one Resend email per new breach record to company admins, with the employee, breach, exposed data and a link to the alerts page. Gated on RESEND_API_KEY and EMAIL_FROM; failures never abort a scan. --- .env.example | 5 +++++ src/lib/email.ts | 44 ++++++++++++++++++++++++++++++++++++++++++ src/lib/scan/runner.ts | 29 ++++++++++++++++++++++++---- 3 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 src/lib/email.ts diff --git a/.env.example b/.env.example index 36538f0..7c937cd 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,11 @@ DIRECTORY_ENCRYPTION_KEY=change-me-to-a-long-random-32-char-secret # Optional. Enables Have I Been Pwned breach lookups. HIBP_API_KEY= +# Optional. Enables email alerts to company admins on new breach exposures. +# Both must be set, otherwise notifications are skipped silently. +RESEND_API_KEY= +EMAIL_FROM=DataShield + # Optional. Override the seeded admin account (defaults shown). # SEED_ADMIN_EMAIL=admin@datashield.local # SEED_ADMIN_PASSWORD=ChangeMe123! diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..ab08da8 --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,44 @@ +const KEY = process.env.RESEND_API_KEY +const FROM = process.env.EMAIL_FROM +const APP_URL = process.env.AUTH_URL ?? "http://localhost:3000" + +export function emailEnabled(): boolean { + return Boolean(KEY && FROM) +} + +type BreachAlert = { + employeeName: string + breachName: string + dataTypes: string[] + severity: string +} + +function render(a: BreachAlert): string { + const types = a.dataTypes.length ? a.dataTypes.join(", ") : "unknown data" + return [ + `
`, + `

${a.severity} exposure detected.

`, + `

${a.employeeName} was found in the ${a.breachName} breach.

`, + `

Exposed data: ${types}.

`, + `

View the alert in DataShield

`, + `
`, + ].join("") +} + +export async function sendBreachAlert(recipients: string[], alert: BreachAlert): Promise { + if (!emailEnabled() || recipients.length === 0) return + try { + await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + from: FROM, + to: recipients, + subject: `New breach exposure: ${alert.employeeName}`, + html: render(alert), + }), + }) + } catch { + // Notification failures must never abort a scan. + } +} diff --git a/src/lib/scan/runner.ts b/src/lib/scan/runner.ts index 6c5b5a7..c33fddf 100644 --- a/src/lib/scan/runner.ts +++ b/src/lib/scan/runner.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/prisma" import { decryptConfig } from "@/lib/directory/crypto" +import { emailEnabled, sendBreachAlert } from "@/lib/email" import { providerById } from "./registry" import { sleep } from "./normalize" import type { BreachProvider, Finding } from "./types" @@ -52,7 +53,8 @@ async function persistFinding( employee: EmployeeWithRecords, finding: Finding, source: BreachSource, - known: Set + known: Set, + recipients: string[] ): Promise { const breach = await prisma.breach.upsert({ where: { name: finding.name }, @@ -66,6 +68,9 @@ async function persistFinding( }) if (known.has(breach.id)) return false + const severity = severityFor(finding.dataTypes) + const employeeName = `${employee.firstName} ${employee.lastName}` + await prisma.breachRecord.create({ data: { employeeId: employee.id, breachId: breach.id, exposedData: finding.dataTypes }, }) @@ -74,15 +79,30 @@ async function persistFinding( companyId, employeeId: employee.id, breachId: breach.id, - severity: severityFor(finding.dataTypes), + severity, status: "OPEN", - message: `${employee.firstName} ${employee.lastName} found in ${finding.name} breach.`, + message: `${employeeName} found in ${finding.name} breach.`, }, }) + await sendBreachAlert(recipients, { + employeeName, + breachName: finding.name, + dataTypes: finding.dataTypes, + severity, + }) known.add(breach.id) return true } +async function notifyRecipients(companyId: string): Promise { + if (!emailEnabled()) return [] + const admins = await prisma.user.findMany({ + where: { companyId, role: "ADMIN" }, + select: { email: true }, + }) + return admins.map((a) => a.email) +} + // Scan every employee against every active provider. A provider error is isolated // (we move on to the next one) so it never aborts the whole scan. export async function runScan( @@ -93,6 +113,7 @@ export async function runScan( where: { companyId }, include: { breachRecords: { select: { breachId: true } } }, }) + const recipients = await notifyRecipients(companyId) let newRecords = 0 for (const employee of employees) { @@ -105,7 +126,7 @@ export async function runScan( continue } for (const finding of findings) { - if (await persistFinding(companyId, employee, finding, provider.source, known)) { + if (await persistFinding(companyId, employee, finding, provider.source, known, recipients)) { newRecords++ } } From 578782702dde04dbb9d88188d6f92d4c39e7d613 Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Mon, 15 Jun 2026 22:06:16 +0200 Subject: [PATCH 5/8] feat(security): rate limit login and scan endpoints In-memory fixed-window limiter: 10 login attempts/min per email to slow brute force, 5 scans/min per company to protect the expensive breach lookups. --- src/app/api/employees/scan/route.ts | 8 ++++++++ src/auth.ts | 3 +++ src/lib/rateLimit.ts | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 src/lib/rateLimit.ts diff --git a/src/app/api/employees/scan/route.ts b/src/app/api/employees/scan/route.ts index 18f7341..c95df4d 100644 --- a/src/app/api/employees/scan/route.ts +++ b/src/app/api/employees/scan/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" +import { rateLimit } from "@/lib/rateLimit" import { loadActiveProviders, runScan } from "@/lib/scan/runner" const runningScans = new Set() @@ -10,6 +11,13 @@ export async function POST() { const companyId = session.user.companyId + if (!rateLimit(`scan:${companyId}`, 5, 60_000)) { + return NextResponse.json( + { error: "Too many scans. Try again in a minute." }, + { status: 429 } + ) + } + const providers = await loadActiveProviders(companyId) if (!providers.length) { return NextResponse.json( diff --git a/src/auth.ts b/src/auth.ts index af47553..85993b0 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -2,6 +2,7 @@ import NextAuth from "next-auth" import Credentials from "next-auth/providers/credentials" import { authConfig } from "@/auth.config" import { prisma } from "@/lib/prisma" +import { rateLimit } from "@/lib/rateLimit" import bcrypt from "bcryptjs" export const { handlers, signIn, signOut, auth } = NextAuth({ @@ -15,6 +16,8 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ async authorize(credentials) { if (!credentials?.email || !credentials?.password) return null + if (!rateLimit(`login:${credentials.email}`, 10, 60_000)) return null + const user = await prisma.user.findUnique({ where: { email: credentials.email as string }, include: { company: true }, diff --git a/src/lib/rateLimit.ts b/src/lib/rateLimit.ts new file mode 100644 index 0000000..f474b68 --- /dev/null +++ b/src/lib/rateLimit.ts @@ -0,0 +1,18 @@ +type Entry = { count: number; reset: number } + +const buckets = new Map() + +// Fixed-window counter. Returns false once the limit is reached for the +// current window. In-memory and per-instance, sufficient for a single-node +// deployment; move to a shared store if the app is scaled horizontally. +export function rateLimit(key: string, limit: number, windowMs: number): boolean { + const now = Date.now() + const entry = buckets.get(key) + if (!entry || now > entry.reset) { + buckets.set(key, { count: 1, reset: now + windowMs }) + return true + } + if (entry.count >= limit) return false + entry.count++ + return true +} From 97015071f90f507ece3fbc94c44b7fe920cc59cb Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Mon, 15 Jun 2026 22:11:14 +0200 Subject: [PATCH 6/8] feat(webhooks): notify external endpoints on new exposure Add Webhook model with AES-encrypted URLs. Admins manage endpoints on the Data API page (add, enable, test, delete) with a minimum severity filter. New breach exposures are dispatched alongside email. --- .../20260615200752_add_webhooks/migration.sql | 20 +++ prisma/schema.prisma | 17 ++ src/app/(dashboard)/data-api/page.tsx | 7 +- src/app/api/webhooks/[id]/route.ts | 40 +++++ src/app/api/webhooks/[id]/test/route.ts | 22 +++ src/app/api/webhooks/route.ts | 51 ++++++ src/components/credentials/Webhooks.tsx | 157 ++++++++++++++++++ src/lib/scan/runner.ts | 26 ++- src/lib/webhooks.ts | 82 +++++++++ 9 files changed, 412 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20260615200752_add_webhooks/migration.sql create mode 100644 src/app/api/webhooks/[id]/route.ts create mode 100644 src/app/api/webhooks/[id]/test/route.ts create mode 100644 src/app/api/webhooks/route.ts create mode 100644 src/components/credentials/Webhooks.tsx create mode 100644 src/lib/webhooks.ts diff --git a/prisma/migrations/20260615200752_add_webhooks/migration.sql b/prisma/migrations/20260615200752_add_webhooks/migration.sql new file mode 100644 index 0000000..464c613 --- /dev/null +++ b/prisma/migrations/20260615200752_add_webhooks/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "Webhook" ( + "id" TEXT NOT NULL, + "companyId" TEXT NOT NULL, + "label" TEXT NOT NULL, + "encryptedUrl" TEXT NOT NULL, + "urlHint" TEXT NOT NULL, + "minSeverity" "Severity" NOT NULL DEFAULT 'MEDIUM', + "enabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Webhook_companyId_idx" ON "Webhook"("companyId"); + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d5c4e21..88a806b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,6 +20,7 @@ model Company { dashboardPresets DashboardPreset[] directoryConnections DirectoryConnection[] apiCredentials ApiCredential[] + webhooks Webhook[] } model DirectoryConnection { @@ -200,6 +201,22 @@ model Alert { breach Breach? @relation(fields: [breachId], references: [id], onDelete: SetNull) } +model Webhook { + id String @id @default(cuid()) + companyId String + label String + encryptedUrl String + urlHint String + minSeverity Severity @default(MEDIUM) + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + company Company @relation(fields: [companyId], references: [id], onDelete: Cascade) + + @@index([companyId]) +} + enum Severity { CRITICAL HIGH diff --git a/src/app/(dashboard)/data-api/page.tsx b/src/app/(dashboard)/data-api/page.tsx index d658471..9f677b5 100644 --- a/src/app/(dashboard)/data-api/page.tsx +++ b/src/app/(dashboard)/data-api/page.tsx @@ -1,6 +1,8 @@ import { auth } from "@/auth" import { prisma } from "@/lib/prisma" import { ApiCredentials } from "@/components/credentials/ApiCredentials" +import { Webhooks } from "@/components/credentials/Webhooks" +import { listWebhooks } from "@/lib/webhooks" export default async function DataApiPage() { const session = await auth() @@ -17,6 +19,8 @@ export default async function DataApiPage() { lastUsedAt: c.lastUsedAt?.toISOString() ?? null, })) + const webhooks = await listWebhooks(session!.user.companyId) + return (
@@ -26,8 +30,9 @@ export default async function DataApiPage() {

-
+
+
) diff --git a/src/app/api/webhooks/[id]/route.ts b/src/app/api/webhooks/[id]/route.ts new file mode 100644 index 0000000..4f67e7c --- /dev/null +++ b/src/app/api/webhooks/[id]/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { Severity } from "@prisma/client" + +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await auth() + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + if (session.user.role !== "ADMIN") + return NextResponse.json({ error: "Admin only" }, { status: 403 }) + + const { id } = await params + const body = (await req.json()) as { enabled?: boolean; minSeverity?: string } + + const data: { enabled?: boolean; minSeverity?: Severity } = {} + if (typeof body.enabled === "boolean") data.enabled = body.enabled + if (body.minSeverity && body.minSeverity in Severity) + data.minSeverity = body.minSeverity as Severity + + const { count } = await prisma.webhook.updateMany({ + where: { id, companyId: session.user.companyId }, + data, + }) + if (count === 0) return NextResponse.json({ error: "Not found" }, { status: 404 }) + return NextResponse.json({ id, ...data }) +} + +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await auth() + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + if (session.user.role !== "ADMIN") + return NextResponse.json({ error: "Admin only" }, { status: 403 }) + + const { id } = await params + const { count } = await prisma.webhook.deleteMany({ + where: { id, companyId: session.user.companyId }, + }) + if (count === 0) return NextResponse.json({ error: "Not found" }, { status: 404 }) + return NextResponse.json({ ok: true }) +} diff --git a/src/app/api/webhooks/[id]/test/route.ts b/src/app/api/webhooks/[id]/test/route.ts new file mode 100644 index 0000000..0041dbf --- /dev/null +++ b/src/app/api/webhooks/[id]/test/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { decryptConfig } from "@/lib/directory/crypto" +import { sendTestWebhook } from "@/lib/webhooks" + +export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await auth() + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + if (session.user.role !== "ADMIN") + return NextResponse.json({ error: "Admin only" }, { status: 403 }) + + const { id } = await params + const webhook = await prisma.webhook.findFirst({ + where: { id, companyId: session.user.companyId }, + select: { encryptedUrl: true }, + }) + if (!webhook) return NextResponse.json({ error: "Not found" }, { status: 404 }) + + const delivered = await sendTestWebhook(decryptConfig<{ url: string }>(webhook.encryptedUrl).url) + return NextResponse.json({ delivered }) +} diff --git a/src/app/api/webhooks/route.ts b/src/app/api/webhooks/route.ts new file mode 100644 index 0000000..b3ddda1 --- /dev/null +++ b/src/app/api/webhooks/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { encryptConfig } from "@/lib/directory/crypto" +import { listWebhooks, urlHint } from "@/lib/webhooks" +import { Severity } from "@prisma/client" + +export async function GET() { + const session = await auth() + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + return NextResponse.json(await listWebhooks(session.user.companyId)) +} + +export async function POST(req: Request) { + const session = await auth() + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + if (session.user.role !== "ADMIN") + return NextResponse.json({ error: "Admin only" }, { status: 403 }) + + const { label, url, minSeverity } = (await req.json()) as { + label?: string + url?: string + minSeverity?: string + } + + if (!label?.trim()) return NextResponse.json({ error: "Missing label" }, { status: 400 }) + + let parsed: URL + try { + parsed = new URL(url ?? "") + } catch { + return NextResponse.json({ error: "Invalid URL" }, { status: 400 }) + } + if (parsed.protocol !== "https:") + return NextResponse.json({ error: "URL must use https" }, { status: 400 }) + + const severity = + minSeverity && minSeverity in Severity ? (minSeverity as Severity) : Severity.MEDIUM + + const webhook = await prisma.webhook.create({ + data: { + companyId: session.user.companyId, + label: label.trim(), + encryptedUrl: encryptConfig({ url: parsed.toString() }), + urlHint: urlHint(parsed.toString()), + minSeverity: severity, + }, + select: { id: true, label: true, urlHint: true, minSeverity: true, enabled: true }, + }) + return NextResponse.json(webhook, { status: 201 }) +} diff --git a/src/components/credentials/Webhooks.tsx b/src/components/credentials/Webhooks.tsx new file mode 100644 index 0000000..c1545b7 --- /dev/null +++ b/src/components/credentials/Webhooks.tsx @@ -0,0 +1,157 @@ +"use client" + +import { useState } from "react" +import { Webhook, Trash2, Loader2, Send, Check, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import type { WebhookRow } from "@/lib/webhooks" + +const SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const + +type Props = { + initial: WebhookRow[] + isAdmin: boolean +} + +export function Webhooks({ initial, isAdmin }: Props) { + const [hooks, setHooks] = useState(initial) + const [label, setLabel] = useState("") + const [url, setUrl] = useState("") + const [minSeverity, setMinSeverity] = useState("MEDIUM") + const [busy, setBusy] = useState(null) + const [error, setError] = useState(null) + const [tested, setTested] = useState>({}) + + async function add() { + setBusy("add") + setError(null) + const res = await fetch("/api/webhooks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label, url, minSeverity }), + }) + const data = await res.json() + setBusy(null) + if (!res.ok) return setError(data.error ?? "Failed to add webhook") + setHooks((h) => [data, ...h]) + setLabel("") + setUrl("") + } + + async function toggle(id: string, enabled: boolean) { + setBusy(id) + await fetch(`/api/webhooks/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }) + setHooks((h) => h.map((w) => (w.id === id ? { ...w, enabled } : w))) + setBusy(null) + } + + async function remove(id: string) { + setBusy(id) + await fetch(`/api/webhooks/${id}`, { method: "DELETE" }) + setHooks((h) => h.filter((w) => w.id !== id)) + setBusy(null) + } + + async function test(id: string) { + setBusy(id) + const res = await fetch(`/api/webhooks/${id}/test`, { method: "POST" }) + const data = await res.json() + setTested((t) => ({ ...t, [id]: Boolean(data.delivered) })) + setBusy(null) + } + + return ( +
+
+ +

Notification webhooks

+
+

+ POST a JSON payload to Slack, Teams or any HTTPS endpoint when a new exposure is detected. +

+ +
+ {hooks.length === 0 && ( +

No webhooks configured

+ )} + {hooks.map((w) => ( +
+
+

{w.label}

+

+ {w.urlHint} - min {w.minSeverity} +

+
+ {tested[w.id] !== undefined && + (tested[w.id] ? ( + + ) : ( + + ))} + {isAdmin && ( + <> + {w.enabled ? "On" : "Off"} + + + + + )} +
+ ))} +
+ + {isAdmin && ( +
+ setLabel(e.target.value)} + className="w-32 rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-none" + /> + setUrl(e.target.value)} + className="min-w-0 flex-1 rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-none" + /> + + +
+ )} + {error &&

{error}

} +
+ ) +} diff --git a/src/lib/scan/runner.ts b/src/lib/scan/runner.ts index c33fddf..31804ce 100644 --- a/src/lib/scan/runner.ts +++ b/src/lib/scan/runner.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/prisma" import { decryptConfig } from "@/lib/directory/crypto" import { emailEnabled, sendBreachAlert } from "@/lib/email" +import { dispatchWebhooks, loadActiveWebhooks } from "@/lib/webhooks" import { providerById } from "./registry" import { sleep } from "./normalize" import type { BreachProvider, Finding } from "./types" @@ -48,13 +49,18 @@ function severityFor(dataTypes: string[]): Severity { // Persist a finding: create the breach if needed, then the record and alert when // this employee was not already linked to it. Returns true if a record was created. +type Notify = { + recipients: string[] + webhooks: Awaited> +} + async function persistFinding( companyId: string, employee: EmployeeWithRecords, finding: Finding, source: BreachSource, known: Set, - recipients: string[] + notify: Notify ): Promise { const breach = await prisma.breach.upsert({ where: { name: finding.name }, @@ -84,12 +90,11 @@ async function persistFinding( message: `${employeeName} found in ${finding.name} breach.`, }, }) - await sendBreachAlert(recipients, { - employeeName, - breachName: finding.name, - dataTypes: finding.dataTypes, - severity, - }) + + const event = { employeeName, breachName: finding.name, dataTypes: finding.dataTypes, severity } + await sendBreachAlert(notify.recipients, event) + await dispatchWebhooks(notify.webhooks, event) + known.add(breach.id) return true } @@ -113,7 +118,10 @@ export async function runScan( where: { companyId }, include: { breachRecords: { select: { breachId: true } } }, }) - const recipients = await notifyRecipients(companyId) + const notify: Notify = { + recipients: await notifyRecipients(companyId), + webhooks: await loadActiveWebhooks(companyId), + } let newRecords = 0 for (const employee of employees) { @@ -126,7 +134,7 @@ export async function runScan( continue } for (const finding of findings) { - if (await persistFinding(companyId, employee, finding, provider.source, known, recipients)) { + if (await persistFinding(companyId, employee, finding, provider.source, known, notify)) { newRecords++ } } diff --git a/src/lib/webhooks.ts b/src/lib/webhooks.ts new file mode 100644 index 0000000..2659c80 --- /dev/null +++ b/src/lib/webhooks.ts @@ -0,0 +1,82 @@ +import { prisma } from "@/lib/prisma" +import { decryptConfig } from "@/lib/directory/crypto" +import type { Severity } from "@prisma/client" + +export type WebhookRow = { + id: string + label: string + urlHint: string + minSeverity: Severity + enabled: boolean +} + +export type WebhookEvent = { + employeeName: string + breachName: string + dataTypes: string[] + severity: Severity +} + +type ActiveWebhook = { url: string; minSeverity: Severity } + +const SEVERITY_RANK: Record = { LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 } + +export function urlHint(url: string): string { + try { + return new URL(url).host + } catch { + return "invalid-url" + } +} + +export function listWebhooks(companyId: string): Promise { + return prisma.webhook.findMany({ + where: { companyId }, + orderBy: { createdAt: "desc" }, + select: { id: true, label: true, urlHint: true, minSeverity: true, enabled: true }, + }) +} + +async function post(url: string, event: WebhookEvent): Promise { + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: `${event.severity} exposure: ${event.employeeName} found in ${event.breachName}`, + ...event, + }), + }) + return res.ok + } catch { + return false + } +} + +export function sendTestWebhook(url: string): Promise { + return post(url, { + employeeName: "Test User", + breachName: "Test Breach", + dataTypes: ["email"], + severity: "LOW", + }) +} + +export async function loadActiveWebhooks(companyId: string): Promise { + const hooks = await prisma.webhook.findMany({ + where: { companyId, enabled: true }, + select: { encryptedUrl: true, minSeverity: true }, + }) + return hooks.map((h) => ({ + url: decryptConfig<{ url: string }>(h.encryptedUrl).url, + minSeverity: h.minSeverity, + })) +} + +export async function dispatchWebhooks(hooks: ActiveWebhook[], event: WebhookEvent): Promise { + await Promise.all( + hooks + .filter((h) => SEVERITY_RANK[event.severity] >= SEVERITY_RANK[h.minSeverity]) + .map((h) => post(h.url, event)) + ) +} From b5fd358d9330a234b3cf506934ee8d00abf4cd36 Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Mon, 15 Jun 2026 22:26:47 +0200 Subject: [PATCH 7/8] refactor(api): centralize auth checks in requireAuth/requireAdmin Single source of truth for route authorization, replacing the duplicated session/role guards. Behavior unchanged: config routes stay admin-only, operational routes (scan, alert triage) stay open to any authenticated user. Dashboard preset routes keep their own ownership logic. --- src/app/api/alerts/[id]/route.ts | 6 +++--- src/app/api/credentials/[id]/route.ts | 8 +++----- src/app/api/credentials/route.ts | 12 +++++------ src/app/api/directory/[id]/route.ts | 8 +++----- src/app/api/directory/[id]/sync/route.ts | 8 +++----- src/app/api/directory/[id]/test/route.ts | 8 +++----- src/app/api/directory/route.ts | 12 +++++------ src/app/api/employees/scan/route.ts | 6 +++--- src/app/api/reports/export/route.ts | 6 +++--- src/app/api/webhooks/[id]/route.ts | 14 +++++-------- src/app/api/webhooks/[id]/test/route.ts | 8 +++----- src/app/api/webhooks/route.ts | 12 +++++------ src/lib/apiAuth.ts | 26 ++++++++++++++++++++++++ 13 files changed, 70 insertions(+), 64 deletions(-) create mode 100644 src/lib/apiAuth.ts diff --git a/src/app/api/alerts/[id]/route.ts b/src/app/api/alerts/[id]/route.ts index 844cab3..af3c515 100644 --- a/src/app/api/alerts/[id]/route.ts +++ b/src/app/api/alerts/[id]/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAuth } from "@/lib/apiAuth" import { prisma } from "@/lib/prisma" import { AlertStatus } from "@prisma/client" @@ -7,8 +7,8 @@ export async function PATCH( req: Request, { params }: { params: Promise<{ id: string }> } ) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const { session, error } = await requireAuth() + if (error) return error const { id } = await params const body = await req.json() diff --git a/src/app/api/credentials/[id]/route.ts b/src/app/api/credentials/[id]/route.ts index 213fe1f..0ee7c88 100644 --- a/src/app/api/credentials/[id]/route.ts +++ b/src/app/api/credentials/[id]/route.ts @@ -1,15 +1,13 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAdmin } from "@/lib/apiAuth" import { prisma } from "@/lib/prisma" export async function DELETE( _req: Request, { params }: { params: Promise<{ id: string }> } ) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const { id } = await params diff --git a/src/app/api/credentials/route.ts b/src/app/api/credentials/route.ts index deeddb6..0a32dc1 100644 --- a/src/app/api/credentials/route.ts +++ b/src/app/api/credentials/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAuth, requireAdmin } from "@/lib/apiAuth" import { prisma } from "@/lib/prisma" import { encryptConfig } from "@/lib/directory/crypto" import { keyHint } from "@/lib/credentials/service" @@ -17,8 +17,8 @@ const SELECT = { } as const export async function GET() { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const { session, error } = await requireAuth() + if (error) return error const credentials = await prisma.apiCredential.findMany({ where: { companyId: session.user.companyId }, @@ -29,10 +29,8 @@ export async function GET() { } export async function POST(req: Request) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const { provider, key } = (await req.json()) as { provider?: string; key?: string } if (!provider || !PROVIDER_IDS.has(provider as ApiProvider)) diff --git a/src/app/api/directory/[id]/route.ts b/src/app/api/directory/[id]/route.ts index 3f04433..24cfbb2 100644 --- a/src/app/api/directory/[id]/route.ts +++ b/src/app/api/directory/[id]/route.ts @@ -1,15 +1,13 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAdmin } from "@/lib/apiAuth" import { prisma } from "@/lib/prisma" export async function DELETE( _req: Request, { params }: { params: Promise<{ id: string }> } ) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const { id } = await params diff --git a/src/app/api/directory/[id]/sync/route.ts b/src/app/api/directory/[id]/sync/route.ts index bbcc708..5b3bf16 100644 --- a/src/app/api/directory/[id]/sync/route.ts +++ b/src/app/api/directory/[id]/sync/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAdmin } from "@/lib/apiAuth" import { syncDirectoryConnection } from "@/lib/directory/sync" const runningSync = new Set() @@ -8,10 +8,8 @@ export async function POST( _req: Request, { params }: { params: Promise<{ id: string }> } ) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const { id } = await params diff --git a/src/app/api/directory/[id]/test/route.ts b/src/app/api/directory/[id]/test/route.ts index 69a62df..de76233 100644 --- a/src/app/api/directory/[id]/test/route.ts +++ b/src/app/api/directory/[id]/test/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAdmin } from "@/lib/apiAuth" import { prisma } from "@/lib/prisma" import { decryptConfig } from "@/lib/directory/crypto" import { testAzureConnection } from "@/lib/directory/azure" @@ -13,10 +13,8 @@ export async function POST( _req: Request, { params }: { params: Promise<{ id: string }> } ) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const { id } = await params diff --git a/src/app/api/directory/route.ts b/src/app/api/directory/route.ts index 3cec556..da69022 100644 --- a/src/app/api/directory/route.ts +++ b/src/app/api/directory/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server" import { randomUUID } from "crypto" -import { auth } from "@/auth" +import { requireAuth, requireAdmin } from "@/lib/apiAuth" import { prisma } from "@/lib/prisma" import { encryptConfig } from "@/lib/directory/crypto" import type { DirectoryType } from "@prisma/client" @@ -16,8 +16,8 @@ const REQUIRED_FIELDS: Record = { } export async function GET() { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const { session, error } = await requireAuth() + if (error) return error const connections = await prisma.directoryConnection.findMany({ where: { companyId: session.user.companyId }, @@ -38,10 +38,8 @@ export async function GET() { } export async function POST(req: Request) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const body = await req.json() const { type, name, config } = body as { diff --git a/src/app/api/employees/scan/route.ts b/src/app/api/employees/scan/route.ts index c95df4d..9e0e98b 100644 --- a/src/app/api/employees/scan/route.ts +++ b/src/app/api/employees/scan/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAuth } from "@/lib/apiAuth" import { rateLimit } from "@/lib/rateLimit" import { loadActiveProviders, runScan } from "@/lib/scan/runner" const runningScans = new Set() export async function POST() { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const { session, error } = await requireAuth() + if (error) return error const companyId = session.user.companyId diff --git a/src/app/api/reports/export/route.ts b/src/app/api/reports/export/route.ts index f7b8bad..9be8b77 100644 --- a/src/app/api/reports/export/route.ts +++ b/src/app/api/reports/export/route.ts @@ -1,4 +1,4 @@ -import { auth } from "@/auth" +import { requireAuth } from "@/lib/apiAuth" import { getReportData } from "@/lib/reports" import { reportCsv, type CsvSection } from "@/lib/reports/csv" @@ -17,8 +17,8 @@ function isSection(value: string): value is CsvSection { } export async function GET(request: Request): Promise { - const session = await auth() - if (!session) return new Response("Unauthorized", { status: 401 }) + const { session, error } = await requireAuth() + if (error) return error const section = new URL(request.url).searchParams.get("section") ?? "all" if (!isSection(section)) return new Response("Invalid section", { status: 400 }) diff --git a/src/app/api/webhooks/[id]/route.ts b/src/app/api/webhooks/[id]/route.ts index 4f67e7c..0b77d7a 100644 --- a/src/app/api/webhooks/[id]/route.ts +++ b/src/app/api/webhooks/[id]/route.ts @@ -1,13 +1,11 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAdmin } from "@/lib/apiAuth" import { prisma } from "@/lib/prisma" import { Severity } from "@prisma/client" export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const { id } = await params const body = (await req.json()) as { enabled?: boolean; minSeverity?: string } @@ -26,10 +24,8 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st } export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const { id } = await params const { count } = await prisma.webhook.deleteMany({ diff --git a/src/app/api/webhooks/[id]/test/route.ts b/src/app/api/webhooks/[id]/test/route.ts index 0041dbf..64f8b23 100644 --- a/src/app/api/webhooks/[id]/test/route.ts +++ b/src/app/api/webhooks/[id]/test/route.ts @@ -1,14 +1,12 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAdmin } from "@/lib/apiAuth" import { prisma } from "@/lib/prisma" import { decryptConfig } from "@/lib/directory/crypto" import { sendTestWebhook } from "@/lib/webhooks" export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const { id } = await params const webhook = await prisma.webhook.findFirst({ diff --git a/src/app/api/webhooks/route.ts b/src/app/api/webhooks/route.ts index b3ddda1..824234d 100644 --- a/src/app/api/webhooks/route.ts +++ b/src/app/api/webhooks/route.ts @@ -1,21 +1,19 @@ import { NextResponse } from "next/server" -import { auth } from "@/auth" +import { requireAuth, requireAdmin } from "@/lib/apiAuth" import { prisma } from "@/lib/prisma" import { encryptConfig } from "@/lib/directory/crypto" import { listWebhooks, urlHint } from "@/lib/webhooks" import { Severity } from "@prisma/client" export async function GET() { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const { session, error } = await requireAuth() + if (error) return error return NextResponse.json(await listWebhooks(session.user.companyId)) } export async function POST(req: Request) { - const session = await auth() - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - if (session.user.role !== "ADMIN") - return NextResponse.json({ error: "Admin only" }, { status: 403 }) + const { session, error } = await requireAdmin() + if (error) return error const { label, url, minSeverity } = (await req.json()) as { label?: string diff --git a/src/lib/apiAuth.ts b/src/lib/apiAuth.ts new file mode 100644 index 0000000..f27e9b5 --- /dev/null +++ b/src/lib/apiAuth.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server" +import type { Session } from "next-auth" +import { auth } from "@/auth" + +// Single source of truth for API route authorization. +// Role model: ADMIN configures the workspace (API keys, directory +// connections, webhooks); VIEWER operates (reads data, runs scans, +// triages alerts). Callers do `const { session, error } = await requireX()` +// and return `error` when present. +type Guard = { session: Session; error: null } | { session: null; error: NextResponse } + +const unauthorized = () => NextResponse.json({ error: "Unauthorized" }, { status: 401 }) +const forbidden = () => NextResponse.json({ error: "Admin only" }, { status: 403 }) + +export async function requireAuth(): Promise { + const session = await auth() + if (!session) return { session: null, error: unauthorized() } + return { session, error: null } +} + +export async function requireAdmin(): Promise { + const session = await auth() + if (!session) return { session: null, error: unauthorized() } + if (session.user.role !== "ADMIN") return { session: null, error: forbidden() } + return { session, error: null } +} From 2788dd737ce36f22505a8778df835d430a7fa439 Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Mon, 15 Jun 2026 22:33:35 +0200 Subject: [PATCH 8/8] refactor(topbar): remove non-functional notification bell --- src/components/layout/Topbar.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/components/layout/Topbar.tsx b/src/components/layout/Topbar.tsx index c2389aa..5bb037c 100644 --- a/src/components/layout/Topbar.tsx +++ b/src/components/layout/Topbar.tsx @@ -1,7 +1,6 @@ "use client" import { usePathname } from "next/navigation" -import { Bell } from "lucide-react" const pageTitles: Record = { "/dashboard": "Dashboard", @@ -23,13 +22,8 @@ export function Topbar() { const title = getTitle(pathname) return ( -
+

{title}

-
- -
) }