Skip to content
2 changes: 1 addition & 1 deletion prisma/seed.dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ async function main() {
await prisma.breachRecord.upsert({
where: { employeeId_breachId: { employeeId: employee.id, breachId: breach.id } },
update: {},
create: { employeeId: employee.id, breachId: breach.id, exposedData: r.data, detectedAt: new Date(r.date) },
create: { employeeId: employee.id, breachId: breach.id, exposedData: r.data, artifacts: [], sources: ["HIBP"], detectedAt: new Date(r.date) },
})
}

Expand Down
202 changes: 202 additions & 0 deletions src/app/api/dashboard/detail/[kind]/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { NextResponse } from "next/server"
import { requireAuth } from "@/lib/apiAuth"
import { prisma } from "@/lib/prisma"

// Rich detail for the dashboard drawer. Each widget element links to one of
// these entities; the drawer fetches the full record on click so it can show
// far more than the compact row carries.

type Field = { label: string; value: string | number }
type Group = { heading?: string; fields: Field[] }
type DetailResponse = {
title: string
subtitle?: string
variant?: string
tags?: string[]
groups: Group[]
}

const fmtDate = (d: Date | string) =>
new Date(d).toLocaleDateString("en-US", { day: "numeric", month: "short", year: "numeric" })

const prettyList = (xs: string[]) =>
xs.length ? xs.map((x) => x.replace(/_/g, " ")).join(", ") : "None"

export async function GET(
_req: Request,
{ params }: { params: Promise<{ kind: string; id: string }> }
) {
const { session, error } = await requireAuth()
if (error) return error

const { kind, id } = await params
const companyId = session.user.companyId

if (kind === "employee") {
const emp = await prisma.employee.findFirst({
where: { id, companyId },
include: {
breachRecords: { include: { breach: true }, orderBy: { detectedAt: "desc" } },
alerts: { include: { breach: true }, orderBy: { createdAt: "desc" } },
},
})
if (!emp) return NextResponse.json({ error: "Not found" }, { status: 404 })

const openAlerts = emp.alerts.filter((a) => a.status === "OPEN").length
const groups: Group[] = [
{
fields: [
{ label: "Email", value: emp.email },
{ label: "Department", value: emp.department ?? "Unknown" },
{ label: "MFA", value: emp.mfaEnabled == null ? "Unknown" : emp.mfaEnabled ? "Enabled" : "Disabled" },
{ label: "Breaches", value: emp.breachRecords.length },
{ label: "Open alerts", value: openAlerts },
],
},
...emp.breachRecords.map((r) => ({
heading: r.breach.name,
fields: [
{ label: "Breach date", value: fmtDate(r.breach.breachDate) },
{ label: "Detected", value: fmtDate(r.detectedAt) },
{ label: "Exposed data", value: prettyList(r.exposedData) },
...(r.artifacts.length ? [{ label: "Artifacts", value: prettyList(r.artifacts) }] : []),
{ label: "Reported by", value: r.sources.length ? r.sources.join(", ") : r.breach.source },
...(r.malwareFamily ? [{ label: "Malware", value: r.malwareFamily }] : []),
...(r.machineId ? [{ label: "Machine ID", value: r.machineId }] : []),
...(r.capturedAt ? [{ label: "Captured", value: fmtDate(r.capturedAt) }] : []),
],
})),
]

const empRemediations = await prisma.remediationAction.findMany({
where: { employeeId: id, companyId },
orderBy: { createdAt: "desc" },
take: 20,
})
if (empRemediations.length) {
groups.push({
heading: "Remediation",
fields: empRemediations.map((a) => ({
label: `${a.action} (${a.status})`,
value: fmtDate(a.createdAt),
})),
})
}

return NextResponse.json({
title: `${emp.firstName} ${emp.lastName}`,
subtitle: emp.department ?? emp.email,
variant: openAlerts > 0 ? "critical" : emp.breachRecords.length > 0 ? "high" : "ok",
groups,
} satisfies DetailResponse)
}

if (kind === "breach") {
const breach = await prisma.breach.findFirst({
where: { id },
include: {
records: { include: { employee: true }, orderBy: { detectedAt: "desc" } },
},
})
// Scope: only expose breaches that hit this company's employees.
const own = breach?.records.filter((r) => r.employee.companyId === companyId) ?? []
if (!breach || own.length === 0) return NextResponse.json({ error: "Not found" }, { status: 404 })

const groups: Group[] = [
{
fields: [
{ label: "Source", value: breach.source },
{ label: "Breach date", value: fmtDate(breach.breachDate) },
{ label: "Affected employees", value: own.length },
{ label: "Data types", value: prettyList(breach.dataTypes) },
...(breach.description ? [{ label: "Description", value: breach.description }] : []),
],
},
...own.slice(0, 50).map((r) => ({
heading: `${r.employee.firstName} ${r.employee.lastName}`,
fields: [
{ label: "Department", value: r.employee.department ?? "Unknown" },
{ label: "Exposed data", value: prettyList(r.exposedData) },
...(r.artifacts.length ? [{ label: "Artifacts", value: prettyList(r.artifacts) }] : []),
{ label: "Detected", value: fmtDate(r.detectedAt) },
],
})),
]

return NextResponse.json({
title: breach.name,
subtitle: String(breach.source),
variant: "medium",
tags: breach.dataTypes.map((t) => t.replace(/_/g, " ")),
groups,
} satisfies DetailResponse)
}

if (kind === "alert") {
const alert = await prisma.alert.findFirst({
where: { id, companyId },
include: {
employee: { include: { breachRecords: { include: { breach: true } } } },
breach: true,
},
})
if (!alert) return NextResponse.json({ error: "Not found" }, { status: 404 })

const groups: Group[] = [
{
fields: [
{ label: "Severity", value: alert.severity },
{ label: "Confidence", value: alert.confidence },
{ label: "Status", value: alert.status },
{ label: "Created", value: fmtDate(alert.createdAt) },
{ label: "Message", value: alert.message },
],
},
]
if (alert.employee) {
groups.push({
heading: `${alert.employee.firstName} ${alert.employee.lastName}`,
fields: [
{ label: "Email", value: alert.employee.email },
{ label: "Department", value: alert.employee.department ?? "Unknown" },
{ label: "Total breaches", value: alert.employee.breachRecords.length },
{ label: "MFA", value: alert.employee.mfaEnabled == null ? "Unknown" : alert.employee.mfaEnabled ? "Enabled" : "Disabled" },
],
})
}
if (alert.breach) {
groups.push({
heading: alert.breach.name,
fields: [
{ label: "Source", value: alert.breach.source },
{ label: "Breach date", value: fmtDate(alert.breach.breachDate) },
{ label: "Data types", value: prettyList(alert.breach.dataTypes) },
],
})
}

const alertRemediations = await prisma.remediationAction.findMany({
where: { alertId: id, companyId },
orderBy: { createdAt: "desc" },
take: 20,
})
if (alertRemediations.length) {
groups.push({
heading: "Remediation",
fields: alertRemediations.map((a) => ({
label: `${a.action} (${a.status})`,
value: fmtDate(a.createdAt),
})),
})
}

return NextResponse.json({
title: alert.employee ? `${alert.employee.firstName} ${alert.employee.lastName}` : "Alert",
subtitle: alert.breach?.name ?? String(alert.severity),
variant: String(alert.severity).toLowerCase(),
groups,
} satisfies DetailResponse)
}

return NextResponse.json({ error: "Unknown detail kind" }, { status: 400 })
}
3 changes: 1 addition & 2 deletions src/components/dashboard/AlertStatusBreakdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,9 @@ export function AlertStatusBreakdown({ data }: { data: StatusCounts }) {
const total = data.open + data.acknowledged + data.resolved

return (
<div className="flex h-full flex-col rounded-xl border border-border bg-card p-5">
<div className="flex h-full flex-col rounded-xl border border-border/60 bg-card p-5 shadow-sm">
<div className="mb-4 shrink-0">
<h2 className="text-sm font-medium text-foreground">{title}</h2>
<p className="text-xs text-muted-foreground">{total} total alerts</p>
</div>

{total === 0 ? (
Expand Down
3 changes: 1 addition & 2 deletions src/components/dashboard/AlertVelocity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,10 @@ export function AlertVelocity({ data }: { data: VelocityData[] }) {
const isEmpty = total === 0

return (
<div className="flex h-full flex-col rounded-xl border border-border bg-card p-5">
<div className="flex h-full flex-col rounded-xl border border-border/60 bg-card p-5 shadow-sm">
<div className="mb-3 shrink-0 flex items-start justify-between">
<div>
<h2 className="text-sm font-medium text-foreground">{title}</h2>
<p className="text-xs text-muted-foreground">Daily alerts — last 30 days</p>
</div>
<div className="flex items-center gap-1 rounded-md px-2 py-1" style={{ background: `${trendColor}1a` }}>
<TrendIcon className="size-3.5" style={{ color: trendColor }} />
Expand Down
3 changes: 1 addition & 2 deletions src/components/dashboard/AlertsByDepartment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,9 @@ export function AlertsByDepartment({ data }: { data: DeptData[] }) {
const isEmpty = data.length === 0 || data.every((d) => d.total === 0)

return (
<div className="flex h-full flex-col rounded-xl border border-border bg-card p-5">
<div className="flex h-full flex-col rounded-xl border border-border/60 bg-card p-5 shadow-sm">
<div className="mb-4 shrink-0">
<h2 className="text-sm font-medium text-foreground">{title}</h2>
<p className="text-xs text-muted-foreground">Alert volume per department</p>
</div>

{isEmpty ? (
Expand Down
3 changes: 1 addition & 2 deletions src/components/dashboard/AlertsByMonth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,10 @@ export function AlertsByMonth({ data }: { data: MonthData[] }) {
const isEmpty = sliced.every((d) => d.critical + d.high + d.medium + d.low === 0)

return (
<div className="flex h-full flex-col rounded-xl border border-border bg-card p-5">
<div className="flex h-full flex-col rounded-xl border border-border/60 bg-card p-5 shadow-sm">
<div className="mb-4 shrink-0 flex items-start justify-between">
<div>
<h2 className="text-sm font-medium text-foreground">{title}</h2>
<p className="text-xs text-muted-foreground">Alert volume — {period.label}</p>
</div>
{editing && (
<button
Expand Down
21 changes: 16 additions & 5 deletions src/components/dashboard/AlertsFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState } from "react"
import { useWidgetTitle } from "@/hooks/useWidgetTitle"
import { useDetailDrawer, type DetailVariant } from "@/contexts/DetailDrawerContext"
import { cn } from "@/lib/utils"

type Alert = {
Expand Down Expand Up @@ -40,16 +41,16 @@ const STATUS_LABELS: Record<string, string> = {

export function AlertsFeed({ data }: { data: Alert[] }) {
const { title } = useWidgetTitle("alerts-feed", "Recent Alerts")
const { openRef } = useDetailDrawer()
const [filter, setFilter] = useState<"ALL" | "OPEN" | "RESOLVED">("ALL")

const filtered = data.filter((a) => filter === "ALL" || a.status === filter)

return (
<div className="flex h-full flex-col rounded-xl border border-border bg-card p-5">
<div className="flex h-full flex-col rounded-xl border border-border/60 bg-card p-5 shadow-sm">
<div className="mb-3 shrink-0 flex items-start justify-between">
<div>
<h2 className="text-sm font-medium text-foreground">{title}</h2>
<p className="text-xs text-muted-foreground">{data.length} recent alerts</p>
</div>
</div>

Expand All @@ -75,10 +76,20 @@ export function AlertsFeed({ data }: { data: Alert[] }) {
) : (
<div className="flex-1 min-h-0 overflow-y-auto space-y-1.5">
{filtered.map((alert) => (
<div
<button
key={alert.id}
type="button"
onClick={() =>
openRef({
kind: "alert",
id: alert.id,
title: alert.employeeName ?? "Unknown employee",
subtitle: alert.breachName ?? undefined,
variant: alert.severity.toLowerCase() as DetailVariant,
})
}
className={cn(
"flex items-center gap-3 rounded-lg border border-border border-l-2 bg-background px-3 py-2.5",
"flex w-full items-center gap-3 rounded-lg border border-border border-l-2 bg-background px-3 py-2.5 text-left transition-colors hover:border-primary/40 hover:bg-muted",
SEVERITY_BORDER[alert.severity]
)}
>
Expand All @@ -100,7 +111,7 @@ export function AlertsFeed({ data }: { data: Alert[] }) {
{new Date(alert.createdAt).toLocaleDateString("en-US", { day: "numeric", month: "short" })}
</span>
</div>
</div>
</button>
))}
</div>
)}
Expand Down
3 changes: 1 addition & 2 deletions src/components/dashboard/BreachSourceDonut.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,9 @@ export function BreachSourceDonut({ data }: { data: BreachSource[] }) {
const total = data.length

return (
<div className="flex h-full flex-col rounded-xl border border-border bg-card p-5">
<div className="flex h-full flex-col rounded-xl border border-border/60 bg-card p-5 shadow-sm">
<div className="mb-4 shrink-0">
<h2 className="text-sm font-medium text-foreground">{title}</h2>
<p className="text-xs text-muted-foreground">{total} breach{total !== 1 ? "es" : ""} by origin</p>
</div>

{total === 0 ? (
Expand Down
21 changes: 16 additions & 5 deletions src/components/dashboard/BreachSourcesList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client"

import { useWidgetTitle } from "@/hooks/useWidgetTitle"
import { useDetailDrawer } from "@/contexts/DetailDrawerContext"
import { cn } from "@/lib/utils"
import { ShieldAlert, Globe, Eye } from "lucide-react"

Expand All @@ -27,13 +28,13 @@ const SOURCE_COLORS: Record<string, string> = {

export function BreachSourcesList({ data }: { data: BreachSource[] }) {
const { title } = useWidgetTitle("breach-sources", "Breach Sources")
const { openRef } = useDetailDrawer()

return (
<div className="flex h-full flex-col rounded-xl border border-border bg-card p-5">
<div className="flex h-full flex-col rounded-xl border border-border/60 bg-card p-5 shadow-sm">
<div className="mb-4 shrink-0 flex items-start justify-between">
<div>
<h2 className="text-sm font-medium text-foreground">{title}</h2>
<p className="text-xs text-muted-foreground">Sites that exposed your company data</p>
</div>
<ShieldAlert className="size-4 text-muted-foreground shrink-0" />
</div>
Expand All @@ -45,9 +46,19 @@ export function BreachSourcesList({ data }: { data: BreachSource[] }) {
) : (
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
{data.map((breach) => (
<div
<button
key={breach.id}
className="flex items-start justify-between gap-3 rounded-lg border border-border bg-background p-3"
type="button"
onClick={() =>
openRef({
kind: "breach",
id: breach.id,
title: breach.name,
subtitle: SOURCE_LABELS[breach.source] ?? breach.source,
variant: "medium",
})
}
className="flex w-full items-start justify-between gap-3 rounded-lg border border-border bg-background p-3 text-left transition-colors hover:border-primary/40 hover:bg-muted"
>
<div className="flex items-start gap-2.5 min-w-0">
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-muted">
Expand Down Expand Up @@ -82,7 +93,7 @@ export function BreachSourcesList({ data }: { data: BreachSource[] }) {
<span className="text-muted-foreground">emp.</span>
</div>
</div>
</div>
</button>
))}
</div>
)}
Expand Down
3 changes: 1 addition & 2 deletions src/components/dashboard/BreachTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,9 @@ export function BreachTimeline({ data }: { data: BreachSource[] }) {
const filtered = config.filter === "ALL" ? sorted : sorted.filter((b) => b.source === config.filter)

return (
<div className="flex h-full flex-col rounded-xl border border-border bg-card p-5">
<div className="flex h-full flex-col rounded-xl border border-border/60 bg-card p-5 shadow-sm">
<div className="mb-3 shrink-0">
<h2 className="text-sm font-medium text-foreground">{title}</h2>
<p className="text-xs text-muted-foreground">{filtered.length} breaches</p>
</div>

{/* Source filter */}
Expand Down
Loading