Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alerts@yourdomain.com>

# Optional. Override the seeded admin account (defaults shown).
# SEED_ADMIN_EMAIL=admin@datashield.local
# SEED_ADMIN_PASSWORD=ChangeMe123!
20 changes: 20 additions & 0 deletions prisma/migrations/20260615200752_add_webhooks/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
17 changes: 17 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ model Company {
dashboardPresets DashboardPreset[]
directoryConnections DirectoryConnection[]
apiCredentials ApiCredential[]
webhooks Webhook[]
}

model DirectoryConnection {
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/app/(dashboard)/alerts/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="h-full overflow-y-auto p-6">
<div className="mb-6">
<h2 className="text-lg font-semibold text-foreground">Alerts</h2>
<p className="text-sm text-muted-foreground">
Triage breach detections, acknowledge and resolve incidents
</p>
</div>
<AlertTable data={alerts} />
</div>
)
}
7 changes: 6 additions & 1 deletion src/app/(dashboard)/data-api/page.tsx
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -17,6 +19,8 @@ export default async function DataApiPage() {
lastUsedAt: c.lastUsedAt?.toISOString() ?? null,
}))

const webhooks = await listWebhooks(session!.user.companyId)

return (
<div className="h-full overflow-y-auto p-6">
<div className="mb-8">
Expand All @@ -26,8 +30,9 @@ export default async function DataApiPage() {
</p>
</div>

<div className="mx-auto max-w-3xl">
<div className="mx-auto max-w-3xl space-y-6">
<ApiCredentials initial={serialized} isAdmin={isAdmin} />
<Webhooks initial={webhooks} isAdmin={isAdmin} />
</div>
</div>
)
Expand Down
4 changes: 4 additions & 0 deletions src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
<Providers>
<div className="flex h-screen overflow-hidden">
<Sidebar
companyName={session.user.name ?? ""}
userEmail={session.user.email ?? ""}
openAlerts={openAlerts}
/>
<div className="flex flex-1 flex-col overflow-hidden">
<Topbar />
Expand Down
29 changes: 29 additions & 0 deletions src/app/api/alerts/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server"
import { requireAuth } from "@/lib/apiAuth"
import { prisma } from "@/lib/prisma"
import { AlertStatus } from "@prisma/client"

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

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 })
}
8 changes: 3 additions & 5 deletions src/app/api/credentials/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down
12 changes: 5 additions & 7 deletions src/app/api/credentials/route.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 },
Expand All @@ -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))
Expand Down
8 changes: 3 additions & 5 deletions src/app/api/directory/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down
8 changes: 3 additions & 5 deletions src/app/api/directory/[id]/sync/route.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
Expand All @@ -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

Expand Down
8 changes: 3 additions & 5 deletions src/app/api/directory/[id]/test/route.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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

Expand Down
12 changes: 5 additions & 7 deletions src/app/api/directory/route.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -16,8 +16,8 @@ const REQUIRED_FIELDS: Record<string, string[]> = {
}

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 },
Expand All @@ -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 {
Expand Down
14 changes: 11 additions & 3 deletions src/app/api/employees/scan/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
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<string>()

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

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(
Expand Down
6 changes: 3 additions & 3 deletions src/app/api/reports/export/route.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -17,8 +17,8 @@ function isSection(value: string): value is CsvSection {
}

export async function GET(request: Request): Promise<Response> {
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 })
Expand Down
36 changes: 36 additions & 0 deletions src/app/api/webhooks/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server"
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, error } = await requireAdmin()
if (error) return error

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, error } = await requireAdmin()
if (error) return error

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 })
}
Loading
Loading