From ba3170e73fe68bf4abf12e62e8f44e1f876d542e Mon Sep 17 00:00:00 2001 From: codens-agent Date: Sun, 28 Jun 2026 09:34:35 +0000 Subject: [PATCH 1/3] chore: Admin security dashboard page (app/admin/security/page.tsx), advisories list + PATCH dialog (app/admin/security/advisories/page.tsx), and audit log search/export page (app/admin/security/audit-log/page.tsx) --- app/admin/security/advisories/page.tsx | 480 +++++++++++++++++++++++++ app/admin/security/audit-log/page.tsx | 388 ++++++++++++++++++++ app/admin/security/page.tsx | 263 ++++++++++++++ 3 files changed, 1131 insertions(+) create mode 100644 app/admin/security/advisories/page.tsx create mode 100644 app/admin/security/audit-log/page.tsx create mode 100644 app/admin/security/page.tsx diff --git a/app/admin/security/advisories/page.tsx b/app/admin/security/advisories/page.tsx new file mode 100644 index 00000000..4090d859 --- /dev/null +++ b/app/admin/security/advisories/page.tsx @@ -0,0 +1,480 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useState, type ReactNode } from "react"; + +import { AdvisoryStatusForm } from "@/components/admin/AdvisoryStatusForm"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + TableBody, + TableCell, + TableHead, + TableHeader, + TableRoot, + TableRow, +} from "@/components/ui/table"; +import { useToast } from "@/components/ui/toast"; +import { useAuth } from "@/lib/auth"; +import type { + AdvisorySeverity, + AdvisoryState, + DismissedReason, + SecurityAdvisory, +} from "@/lib/api-types"; +import { cn } from "@/lib/utils"; + +const API_BASE = + process.env.NEXT_PUBLIC_API_BASE_URL ?? + process.env.NEXT_PUBLIC_API_URL ?? + ""; + +type AdvisoryListItem = SecurityAdvisory & { + repository?: { + owner: { login: string }; + name: string; + }; +}; + +const STATE_OPTIONS: { value: string; label: string }[] = [ + { value: "", label: "All states" }, + { value: "open", label: "Open" }, + { value: "acknowledged", label: "Acknowledged" }, + { value: "resolved", label: "Resolved" }, + { value: "dismissed", label: "Dismissed" }, +]; + +const SEVERITY_OPTIONS: { value: string; label: string }[] = [ + { value: "", label: "All severities" }, + { value: "critical", label: "Critical" }, + { value: "high", label: "High" }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, +]; + +const severityBadgeClass: Record = { + critical: "border-transparent bg-[#cf222e] text-white", + high: "border-transparent bg-[#ffebe9] text-[#cf222e]", + medium: "border-transparent bg-[#fff8c5] text-[#9a6700]", + low: "border-transparent bg-[#eaeef2] text-[#656d76]", +}; + +function Dialog({ + open, + onOpenChange, + children, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; +}) { + if (!open) { + return null; + } + + return ( +
+
+ ); +} + +function requireAdminRole(token: string | null, router: ReturnType): boolean { + if (!token) { + router.push("/login"); + return false; + } + return true; +} + +function resolveRepoScope(advisory: AdvisoryListItem): { + owner: string; + repo: string; +} | null { + if (advisory.repository?.owner.login && advisory.repository.name) { + return { + owner: advisory.repository.owner.login, + repo: advisory.repository.name, + }; + } + return null; +} + +export default function SecurityAdvisoriesPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { token } = useAuth(); + const toast = useToast(); + + const [org, setOrg] = useState(""); + const [advisories, setAdvisories] = useState([]); + const [stateFilter, setStateFilter] = useState(""); + const [severityFilter, setSeverityFilter] = useState(""); + const [loading, setLoading] = useState(true); + const [accessDenied, setAccessDenied] = useState(false); + const [error, setError] = useState(null); + const [selectedAdvisory, setSelectedAdvisory] = + useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const orgParam = searchParams.get("org"); + + const loadAdvisories = useCallback(async () => { + if (!requireAdminRole(token, router)) { + return; + } + + setLoading(true); + setError(null); + setAccessDenied(false); + + try { + let resolvedOrg = orgParam ?? ""; + if (!resolvedOrg) { + const orgsResponse = await fetch(`${API_BASE}/api/v3/user/orgs`, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }); + + if (orgsResponse.status === 401) { + router.push("/login"); + return; + } + + if (!orgsResponse.ok) { + throw new Error(`Failed to load organizations (${orgsResponse.status})`); + } + + const orgs = (await orgsResponse.json()) as { login: string }[]; + if (orgs.length === 0) { + throw new Error("No organization found"); + } + resolvedOrg = orgs[0].login; + } + + setOrg(resolvedOrg); + + const params = new URLSearchParams({ per_page: "100" }); + if (stateFilter) { + params.set("state", stateFilter); + } + if (severityFilter) { + params.set("severity", severityFilter); + } + + const response = await fetch( + `${API_BASE}/api/v3/orgs/${encodeURIComponent(resolvedOrg)}/security-advisories?${params.toString()}`, + { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }, + ); + + if (response.status === 401) { + router.push("/login"); + return; + } + + if (response.status === 403) { + setAccessDenied(true); + setAdvisories([]); + return; + } + + if (!response.ok) { + throw new Error(`Failed to load advisories (${response.status})`); + } + + const data = (await response.json()) as AdvisoryListItem[]; + setAdvisories(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load advisories."); + } finally { + setLoading(false); + } + }, [token, router, orgParam, stateFilter, severityFilter]); + + useEffect(() => { + loadAdvisories(); + }, [loadAdvisories]); + + const handleStatusUpdate = async ( + state: AdvisoryState, + dismissedReason?: DismissedReason, + ) => { + if (!selectedAdvisory || !token) { + return; + } + + const repoScope = resolveRepoScope(selectedAdvisory); + if (!repoScope) { + toast.error("Repository scope is missing for this advisory."); + return; + } + + setSubmitting(true); + try { + const body: { state: AdvisoryState; dismissed_reason?: DismissedReason } = + { state }; + if (state === "dismissed" && dismissedReason) { + body.dismissed_reason = dismissedReason; + } + + const response = await fetch( + `${API_BASE}/api/v3/repos/${encodeURIComponent(repoScope.owner)}/${encodeURIComponent(repoScope.repo)}/security-advisories/${encodeURIComponent(selectedAdvisory.ghsa_id)}`, + { + method: "PATCH", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }, + ); + + if (response.status === 401) { + router.push("/login"); + return; + } + + if (response.status === 403) { + toast.error("Admin access is required to update advisory status."); + return; + } + + if (!response.ok) { + let message = `Failed to update advisory (${response.status})`; + try { + const errorBody = (await response.json()) as { message?: string }; + message = errorBody.message ?? message; + } catch { + // ignore JSON parse errors + } + throw new Error(message); + } + + toast.success("Advisory status updated"); + setDialogOpen(false); + setSelectedAdvisory(null); + await loadAdvisories(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to update advisory status.", + ); + } finally { + setSubmitting(false); + } + }; + + if (accessDenied) { + return ( +
+
+ + 🐙 + OpenHub + +
+
+

Security Advisories

+
+

Access Denied

+

+ You do not have permission to view security advisories. Admin access + is required. +

+
+
+
+ ); + } + + return ( +
+
+ + 🐙 + OpenHub + + + ← Security dashboard + +
+ +
+
+ + Dashboard + {" "} + /{" "} + + Admin / Security + {" "} + / Advisories +
+

Security Advisories

+ +
{ + event.preventDefault(); + loadAdvisories(); + }} + > +
+ + +
+
+ + +
+
+ +
+
+ +
+ {loading ? ( +

Loading…

+ ) : error ? ( +

{error}

+ ) : advisories.length === 0 ? ( +

No advisories found.

+ ) : ( + + + + GHSA ID + Severity + Summary + State + Package + Actions + + + + {advisories.map((advisory) => ( + + + {advisory.ghsa_id} + + + + {advisory.severity} + + + {advisory.summary} + {advisory.state} + + {advisory.affected_package} + + + + + + ))} + + + )} +
+
+ + +

Update advisory status

+ {selectedAdvisory && ( +

+ {selectedAdvisory.ghsa_id} — {selectedAdvisory.summary} +

+ )} + { + if (!submitting) { + void handleStatusUpdate(state, reason); + } + }} + /> +
+ +
+
+
+ ); +} diff --git a/app/admin/security/audit-log/page.tsx b/app/admin/security/audit-log/page.tsx new file mode 100644 index 00000000..5e7d8c03 --- /dev/null +++ b/app/admin/security/audit-log/page.tsx @@ -0,0 +1,388 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + TableBody, + TableCell, + TableHead, + TableHeader, + TableRoot, + TableRow, +} from "@/components/ui/table"; +import { useToast } from "@/components/ui/toast"; +import { useAuth } from "@/lib/auth"; + +const API_BASE = + process.env.NEXT_PUBLIC_API_BASE_URL ?? + process.env.NEXT_PUBLIC_API_URL ?? + ""; + +type OrgAuditLogEntry = { + id: string; + actor_login: string; + action: string; + ip_address: string | null; + created_at: string; +}; + +type ExportResponse = { + job_id: string; + download_url?: string; +}; + +const ACTION_OPTIONS = [ + { value: "", label: "All actions" }, + { value: "repo.delete", label: "repo.delete" }, + { value: "member.add", label: "member.add" }, + { value: "member.remove", label: "member.remove" }, + { value: "token.issue", label: "token.issue" }, + { value: "token.revoke", label: "token.revoke" }, + { value: "advisory.state_change", label: "advisory.state_change" }, + { value: "settings.update", label: "settings.update" }, +] as const; + +function requireAdminRole(token: string | null, router: ReturnType): boolean { + if (!token) { + router.push("/login"); + return false; + } + return true; +} + +export default function SecurityAuditLogPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { token } = useAuth(); + const toast = useToast(); + + const [org, setOrg] = useState(""); + const [entries, setEntries] = useState([]); + const [phrase, setPhrase] = useState(""); + const [action, setAction] = useState(""); + const [after, setAfter] = useState(""); + const [before, setBefore] = useState(""); + const [loading, setLoading] = useState(true); + const [exporting, setExporting] = useState(false); + const [accessDenied, setAccessDenied] = useState(false); + const [error, setError] = useState(null); + + const orgParam = searchParams.get("org"); + + const loadAuditLog = useCallback(async () => { + if (!requireAdminRole(token, router)) { + return; + } + + setLoading(true); + setError(null); + setAccessDenied(false); + + try { + let resolvedOrg = orgParam ?? ""; + if (!resolvedOrg) { + const orgsResponse = await fetch(`${API_BASE}/api/v3/user/orgs`, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }); + + if (orgsResponse.status === 401) { + router.push("/login"); + return; + } + + if (!orgsResponse.ok) { + throw new Error(`Failed to load organizations (${orgsResponse.status})`); + } + + const orgs = (await orgsResponse.json()) as { login: string }[]; + if (orgs.length === 0) { + throw new Error("No organization found"); + } + resolvedOrg = orgs[0].login; + } + + setOrg(resolvedOrg); + + const params = new URLSearchParams({ per_page: "50" }); + if (phrase.trim()) { + params.set("phrase", phrase.trim()); + } + if (action) { + params.set("include", action); + } + if (after) { + params.set("after", new Date(after).toISOString()); + } + if (before) { + params.set("before", new Date(before).toISOString()); + } + + const response = await fetch( + `${API_BASE}/api/v3/orgs/${encodeURIComponent(resolvedOrg)}/audit-log?${params.toString()}`, + { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }, + ); + + if (response.status === 401) { + router.push("/login"); + return; + } + + if (response.status === 403) { + setAccessDenied(true); + setEntries([]); + return; + } + + if (!response.ok) { + throw new Error(`Failed to load audit log (${response.status})`); + } + + const data = (await response.json()) as OrgAuditLogEntry[]; + setEntries(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load audit log."); + } finally { + setLoading(false); + } + }, [token, router, orgParam, phrase, action, after, before]); + + useEffect(() => { + loadAuditLog(); + }, [loadAuditLog]); + + const handleExport = async () => { + if (!requireAdminRole(token, router) || !org) { + return; + } + + setExporting(true); + try { + const params = new URLSearchParams({ format: "csv" }); + if (phrase.trim()) { + params.set("phrase", phrase.trim()); + } + if (action) { + params.set("include", action); + } + if (after) { + params.set("after", new Date(after).toISOString()); + } + if (before) { + params.set("before", new Date(before).toISOString()); + } + + const response = await fetch( + `${API_BASE}/api/v3/orgs/${encodeURIComponent(org)}/audit-log/export?${params.toString()}`, + { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (response.status === 401) { + router.push("/login"); + return; + } + + if (response.status === 403) { + toast.error("Admin access is required to export audit logs."); + return; + } + + if (!response.ok) { + throw new Error(`Failed to export audit log (${response.status})`); + } + + const data = (await response.json()) as ExportResponse; + toast.success(`Export job queued: ${data.job_id}`); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to export audit log.", + ); + } finally { + setExporting(false); + } + }; + + if (accessDenied) { + return ( +
+
+ + 🐙 + OpenHub + +
+
+

Security Audit Log

+
+

Access Denied

+

+ You do not have permission to view the audit log. Admin access is + required. +

+
+
+
+ ); + } + + return ( +
+
+ + 🐙 + OpenHub + + + ← Security dashboard + +
+ +
+
+ + Dashboard + {" "} + /{" "} + + Admin / Security + {" "} + / Audit log +
+

Security Audit Log

+ +
{ + event.preventDefault(); + loadAuditLog(); + }} + > +
+ + setPhrase(event.target.value)} + placeholder="Search actions, actors, targets…" + /> +
+
+ + +
+
+ + setAfter(event.target.value)} + /> +
+
+ + setBefore(event.target.value)} + /> +
+
+ + +
+
+ +
+ {loading ? ( +

Loading…

+ ) : error ? ( +

{error}

+ ) : entries.length === 0 ? ( +

No audit log entries found.

+ ) : ( + + + + Actor + Action + IP address + Created at + + + + {entries.map((entry) => ( + + + {entry.actor_login} + + + {entry.action} + + + {entry.ip_address ?? "—"} + + + {entry.created_at} + + + ))} + + + )} +
+
+
+ ); +} diff --git a/app/admin/security/page.tsx b/app/admin/security/page.tsx new file mode 100644 index 00000000..1a8c9948 --- /dev/null +++ b/app/admin/security/page.tsx @@ -0,0 +1,263 @@ +import Link from "next/link"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +import { SecuritySummaryCard } from "@/components/admin/SecuritySummaryCard"; +import type { + AdvisorySeverity, + ScanJob, + SecurityAdvisory, +} from "@/lib/api-types"; + +const API_BASE = + process.env.NEXT_PUBLIC_API_BASE_URL ?? + process.env.NEXT_PUBLIC_API_URL ?? + ""; + +const SEVERITIES: AdvisorySeverity[] = ["critical", "high", "medium", "low"]; + +const SEVERITY_LABELS: Record = { + critical: "Critical advisories", + high: "High advisories", + medium: "Medium advisories", + low: "Low advisories", +}; + +async function requireAdminRole(): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get("authToken")?.value; + + if (!token) { + redirect("/login"); + } + + return token; +} + +async function resolveOrgLogin( + token: string, + orgParam?: string, +): Promise { + if (orgParam) { + return orgParam; + } + + const response = await fetch(`${API_BASE}/api/v3/user/orgs`, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`Failed to load organizations (${response.status})`); + } + + const orgs = (await response.json()) as { login: string }[]; + if (orgs.length === 0) { + throw new Error("No organization found"); + } + + return orgs[0].login; +} + +function countBySeverity( + advisories: SecurityAdvisory[], +): Record { + const counts: Record = { + critical: 0, + high: 0, + medium: 0, + low: 0, + }; + + for (const advisory of advisories) { + counts[advisory.severity] += 1; + } + + return counts; +} + +function AccessDenied() { + return ( +
+
+ + 🐙 + OpenHub + +
+
+
+ + Dashboard + {" "} + / Admin / Security +
+

Security Dashboard

+
+

Access Denied

+

+ You do not have permission to view the security dashboard. Admin + access is required. +

+
+
+
+ ); +} + +export default async function SecurityDashboardPage({ + searchParams, +}: { + searchParams: Promise<{ org?: string }>; +}) { + const token = await requireAdminRole(); + const { org: orgParam } = await searchParams; + const org = await resolveOrgLogin(token, orgParam); + + const advisoriesResponse = await fetch( + `${API_BASE}/api/v3/orgs/${encodeURIComponent(org)}/security-advisories?per_page=100`, + { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }, + ); + + if (advisoriesResponse.status === 401) { + redirect("/login"); + } + + if (advisoriesResponse.status === 403) { + return ; + } + + if (!advisoriesResponse.ok) { + throw new Error( + `Failed to load security advisories (${advisoriesResponse.status})`, + ); + } + + const advisories = + (await advisoriesResponse.json()) as SecurityAdvisory[]; + const severityCounts = countBySeverity(advisories); + + const scanJobsResponse = await fetch( + `${API_BASE}/api/v3/orgs/${encodeURIComponent(org)}/scan-jobs?per_page=10`, + { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }, + ); + + let scanJobs: ScanJob[] = []; + if (scanJobsResponse.ok) { + scanJobs = (await scanJobsResponse.json()) as ScanJob[]; + } + + return ( +
+
+ + 🐙 + OpenHub + + + ← Dashboard + +
+ +
+
+ + Dashboard + {" "} + / Admin / Security +
+
+

Security Dashboard

+
+ + Advisories + + + Audit log + +
+
+ +
+ {SEVERITIES.map((severity) => ( + + ))} +
+ +
+

Recent scan jobs

+
+ {scanJobs.length === 0 ? ( +

No scan jobs found.

+ ) : ( + + + + + + + + + + + + {scanJobs.map((job) => ( + + + + + + + + ))} + +
Job IDTypeStatusStartedFinished
{job.id}{job.type}{job.status} + {job.started_at ?? "—"} + + {job.finished_at ?? "—"} +
+ )} +
+
+
+
+ ); +} From 5fa2a2546cd2ccb4b70e9ab7c99f0491e8a85f79 Mon Sep 17 00:00:00 2001 From: codens-agent Date: Sun, 28 Jun 2026 09:39:04 +0000 Subject: [PATCH 2/3] chore: Admin security dashboard page (app/admin/security/page.tsx), advisories list + PATCH dialog (app/admin/security/advisories/page.tsx), and audit log search/export page (app/admin/security/audit-log/page.tsx) --- __tests__/app/admin/security-pages.test.tsx | 183 ++++++++ __tests__/lib/admin/security.test.ts | 92 ++++ app/admin/security/advisories/page.tsx | 449 +++++++++----------- app/admin/security/audit-log/page.tsx | 430 +++++++++---------- app/admin/security/page.tsx | 288 +++++-------- components/admin/SecurityPageLayout.tsx | 82 ++++ lib/admin/security-server.ts | 54 +++ lib/admin/security.ts | 224 ++++++++++ 8 files changed, 1155 insertions(+), 647 deletions(-) create mode 100644 __tests__/app/admin/security-pages.test.tsx create mode 100644 __tests__/lib/admin/security.test.ts create mode 100644 components/admin/SecurityPageLayout.tsx create mode 100644 lib/admin/security-server.ts create mode 100644 lib/admin/security.ts diff --git a/__tests__/app/admin/security-pages.test.tsx b/__tests__/app/admin/security-pages.test.tsx new file mode 100644 index 00000000..6ea1fbb1 --- /dev/null +++ b/__tests__/app/admin/security-pages.test.tsx @@ -0,0 +1,183 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import SecurityAdvisoriesPage from "@/app/admin/security/advisories/page"; +import SecurityAuditLogPage from "@/app/admin/security/audit-log/page"; + +const mockPush = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush }), + useSearchParams: () => new URLSearchParams("org=acme-corp"), +})); + +vi.mock("@/lib/auth", () => ({ + useAuth: () => ({ + token: "test-token", + isAuthenticated: true, + login: vi.fn(), + logout: vi.fn(), + }), +})); + +vi.mock("@/components/ui/toast", () => ({ + useToast: () => ({ + success: vi.fn(), + error: vi.fn(), + }), +})); + +function mockAdminFetch() { + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + + if (url.endsWith("/api/v3/user")) { + return { + ok: true, + status: 200, + json: async () => ({ login: "alice" }), + }; + } + + if (url.endsWith("/api/v3/orgs/acme-corp/members")) { + return { + ok: true, + status: 200, + json: async () => [{ login: "alice", role: "admin" }], + }; + } + + if (url.includes("/api/v3/orgs/acme-corp/security-advisories")) { + return { + ok: true, + status: 200, + json: async () => [ + { + id: "1", + ghsa_id: "GHSA-xxxx-yyyy-zzzz", + severity: "high", + summary: "Example advisory", + state: "open", + affected_package: "example-pkg", + repository: { + owner: { login: "acme-corp" }, + name: "demo", + }, + }, + ], + }; + } + + if (url.includes("/api/v3/orgs/acme-corp/audit-log/export")) { + return { + ok: true, + status: 200, + json: async () => ({ job_id: "export-123" }), + }; + } + + if ( + url.includes("/api/v3/orgs/acme-corp/audit-log") && + !url.includes("/export") + ) { + return { + ok: true, + status: 200, + json: async () => [ + { + id: "log-1", + actor_login: "alice", + action: "repo.delete", + ip_address: "192.168.1.10", + created_at: "2024-01-01T00:00:00Z", + }, + ], + }; + } + + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }); +} + +describe("Security admin pages", () => { + beforeEach(() => { + mockPush.mockClear(); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "http://localhost:8080"); + }); + + it("renders advisories for org admins", async () => { + vi.stubGlobal("fetch", mockAdminFetch()); + + render(); + + await waitFor(() => { + expect(screen.getByText("GHSA-xxxx-yyyy-zzzz")).toBeInTheDocument(); + }); + }); + + it("shows access denied for non-admin members on advisories page", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + + if (url.endsWith("/api/v3/user")) { + return { + ok: true, + status: 200, + json: async () => ({ login: "bob" }), + }; + } + + if (url.endsWith("/api/v3/orgs/acme-corp/members")) { + return { + ok: true, + status: 200, + json: async () => [{ login: "bob", role: "member" }], + }; + } + + return { ok: false, status: 404, json: async () => ({}) }; + }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText("Access Denied")).toBeInTheDocument(); + }); + }); + + it("uses action filter param and masks IP addresses on audit log page", async () => { + const fetchMock = mockAdminFetch(); + vi.stubGlobal("fetch", fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByText("repo.delete")).toBeInTheDocument(); + }); + + expect(screen.getByText("192.168.*.*")).toBeInTheDocument(); + + await userEvent.selectOptions( + screen.getByLabelText("Action"), + "repo.delete", + ); + await userEvent.click(screen.getByRole("button", { name: "Search" })); + + await waitFor(() => { + expect( + fetchMock.mock.calls.some(([url]) => { + const parsed = new URL(String(url)); + return parsed.searchParams.get("action") === "repo.delete"; + }), + ).toBe(true); + }); + }); +}); diff --git a/__tests__/lib/admin/security.test.ts b/__tests__/lib/admin/security.test.ts new file mode 100644 index 00000000..a43420fa --- /dev/null +++ b/__tests__/lib/admin/security.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { + buildOrgQueryString, + datetimeLocalToIso, + getSeverityBadgeClass, + isAdminOrgRole, + maskIpAddress, + sanitizeAuditSearchPhrase, + sanitizeOrgLogin, +} from "@/lib/admin/security"; + +describe("sanitizeOrgLogin", () => { + it("accepts valid org logins", () => { + expect(sanitizeOrgLogin("acme-corp")).toBe("acme-corp"); + expect(sanitizeOrgLogin(" acme ")).toBe("acme"); + }); + + it("rejects invalid org logins", () => { + expect(sanitizeOrgLogin("acme/evil")).toBeNull(); + expect(sanitizeOrgLogin("https://evil.example")).toBeNull(); + expect(sanitizeOrgLogin("")).toBeNull(); + expect(sanitizeOrgLogin(null)).toBeNull(); + }); +}); + +describe("buildOrgQueryString", () => { + it("builds a safe org query string", () => { + expect(buildOrgQueryString("acme-corp")).toBe("?org=acme-corp"); + expect(buildOrgQueryString("acme/evil")).toBe(""); + }); +}); + +describe("isAdminOrgRole", () => { + it("returns true for admin and owner roles", () => { + expect(isAdminOrgRole("admin")).toBe(true); + expect(isAdminOrgRole("owner")).toBe(true); + expect(isAdminOrgRole("OWNER")).toBe(true); + }); + + it("returns false for non-admin roles", () => { + expect(isAdminOrgRole("member")).toBe(false); + expect(isAdminOrgRole("read")).toBe(false); + }); +}); + +describe("sanitizeAuditSearchPhrase", () => { + it("trims, strips control characters, and caps length", () => { + expect(sanitizeAuditSearchPhrase(" repo.delete ")).toBe("repo.delete"); + expect(sanitizeAuditSearchPhrase("a\u0000b")).toBe("ab"); + expect(sanitizeAuditSearchPhrase("x".repeat(300))).toHaveLength(256); + }); +}); + +describe("datetimeLocalToIso", () => { + it("converts datetime-local values using local time", () => { + const iso = datetimeLocalToIso("2024-06-15T10:30"); + expect(iso).toMatch(/^2024-06-15T/); + expect(iso.endsWith("Z") || iso.includes("+") || iso.includes("-")).toBe(true); + }); + + it("returns empty string for invalid values", () => { + expect(datetimeLocalToIso("")).toBe(""); + expect(datetimeLocalToIso("not-a-date")).toBe(""); + }); +}); + +describe("maskIpAddress", () => { + it("masks IPv4 addresses", () => { + expect(maskIpAddress("192.168.1.42")).toBe("192.168.*.*"); + }); + + it("masks IPv6 addresses", () => { + expect(maskIpAddress("2001:0db8:85a3:0000:0000:8a2e:0370:7334")).toBe( + "2001:****", + ); + }); + + it("returns em dash for missing values", () => { + expect(maskIpAddress(null)).toBe("—"); + }); +}); + +describe("getSeverityBadgeClass", () => { + it("returns a class for known severities", () => { + expect(getSeverityBadgeClass("critical")).toContain("bg-[#cf222e]"); + }); + + it("falls back to low severity styling", () => { + expect(getSeverityBadgeClass("unknown")).toBe(getSeverityBadgeClass("low")); + }); +}); diff --git a/app/admin/security/advisories/page.tsx b/app/admin/security/advisories/page.tsx index 4090d859..ef1c7435 100644 --- a/app/admin/security/advisories/page.tsx +++ b/app/admin/security/advisories/page.tsx @@ -1,10 +1,13 @@ "use client"; -import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useState, type ReactNode } from "react"; import { AdvisoryStatusForm } from "@/components/admin/AdvisoryStatusForm"; +import { + SecurityAccessDenied, + SecurityPageLayout, +} from "@/components/admin/SecurityPageLayout"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -17,20 +20,20 @@ import { TableRow, } from "@/components/ui/table"; import { useToast } from "@/components/ui/toast"; +import { + checkClientOrgAdminAccess, + genericActionError, + getSecurityApiBase, + getSeverityBadgeClass, +} from "@/lib/admin/security"; import { useAuth } from "@/lib/auth"; import type { - AdvisorySeverity, AdvisoryState, DismissedReason, SecurityAdvisory, } from "@/lib/api-types"; import { cn } from "@/lib/utils"; -const API_BASE = - process.env.NEXT_PUBLIC_API_BASE_URL ?? - process.env.NEXT_PUBLIC_API_URL ?? - ""; - type AdvisoryListItem = SecurityAdvisory & { repository?: { owner: { login: string }; @@ -54,13 +57,6 @@ const SEVERITY_OPTIONS: { value: string; label: string }[] = [ { value: "low", label: "Low" }, ]; -const severityBadgeClass: Record = { - critical: "border-transparent bg-[#cf222e] text-white", - high: "border-transparent bg-[#ffebe9] text-[#cf222e]", - medium: "border-transparent bg-[#fff8c5] text-[#9a6700]", - low: "border-transparent bg-[#eaeef2] text-[#656d76]", -}; - function Dialog({ open, onOpenChange, @@ -93,14 +89,6 @@ function Dialog({ ); } -function requireAdminRole(token: string | null, router: ReturnType): boolean { - if (!token) { - router.push("/login"); - return false; - } - return true; -} - function resolveRepoScope(advisory: AdvisoryListItem): { owner: string; repo: string; @@ -119,8 +107,8 @@ export default function SecurityAdvisoriesPage() { const searchParams = useSearchParams(); const { token } = useAuth(); const toast = useToast(); + const apiBase = getSecurityApiBase(); - const [org, setOrg] = useState(""); const [advisories, setAdvisories] = useState([]); const [stateFilter, setStateFilter] = useState(""); const [severityFilter, setSeverityFilter] = useState(""); @@ -135,43 +123,39 @@ export default function SecurityAdvisoriesPage() { const orgParam = searchParams.get("org"); const loadAdvisories = useCallback(async () => { - if (!requireAdminRole(token, router)) { - return; - } - setLoading(true); setError(null); setAccessDenied(false); - try { - let resolvedOrg = orgParam ?? ""; - if (!resolvedOrg) { - const orgsResponse = await fetch(`${API_BASE}/api/v3/user/orgs`, { - headers: { - Accept: "application/json", - Authorization: `Bearer ${token}`, - }, - cache: "no-store", - }); - - if (orgsResponse.status === 401) { - router.push("/login"); - return; - } - - if (!orgsResponse.ok) { - throw new Error(`Failed to load organizations (${orgsResponse.status})`); - } - - const orgs = (await orgsResponse.json()) as { login: string }[]; - if (orgs.length === 0) { - throw new Error("No organization found"); - } - resolvedOrg = orgs[0].login; - } + const access = await checkClientOrgAdminAccess({ + token, + orgParam, + onUnauthenticated: () => { + router.push("/login"); + }, + }); + + if (access.status === "unauthenticated") { + setLoading(false); + return; + } - setOrg(resolvedOrg); + if (access.status === "access_denied") { + setAccessDenied(true); + setAdvisories([]); + setLoading(false); + return; + } + + if (access.status === "error") { + setError(access.message); + setLoading(false); + return; + } + const resolvedOrg = access.org; + + try { const params = new URLSearchParams({ per_page: "100" }); if (stateFilter) { params.set("state", stateFilter); @@ -181,7 +165,7 @@ export default function SecurityAdvisoriesPage() { } const response = await fetch( - `${API_BASE}/api/v3/orgs/${encodeURIComponent(resolvedOrg)}/security-advisories?${params.toString()}`, + `${apiBase}/api/v3/orgs/${encodeURIComponent(resolvedOrg)}/security-advisories?${params.toString()}`, { headers: { Accept: "application/json", @@ -203,20 +187,27 @@ export default function SecurityAdvisoriesPage() { } if (!response.ok) { - throw new Error(`Failed to load advisories (${response.status})`); + throw new Error(genericActionError("load advisories")); } const data = (await response.json()) as AdvisoryListItem[]; setAdvisories(data); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load advisories."); + } catch { + setError(genericActionError("load advisories")); } finally { setLoading(false); } - }, [token, router, orgParam, stateFilter, severityFilter]); + }, [ + token, + router, + orgParam, + stateFilter, + severityFilter, + apiBase, + ]); useEffect(() => { - loadAdvisories(); + void loadAdvisories(); }, [loadAdvisories]); const handleStatusUpdate = async ( @@ -242,7 +233,7 @@ export default function SecurityAdvisoriesPage() { } const response = await fetch( - `${API_BASE}/api/v3/repos/${encodeURIComponent(repoScope.owner)}/${encodeURIComponent(repoScope.repo)}/security-advisories/${encodeURIComponent(selectedAdvisory.ghsa_id)}`, + `${apiBase}/api/v3/repos/${encodeURIComponent(repoScope.owner)}/${encodeURIComponent(repoScope.repo)}/security-advisories/${encodeURIComponent(selectedAdvisory.ghsa_id)}`, { method: "PATCH", headers: { @@ -265,216 +256,174 @@ export default function SecurityAdvisoriesPage() { } if (!response.ok) { - let message = `Failed to update advisory (${response.status})`; - try { - const errorBody = (await response.json()) as { message?: string }; - message = errorBody.message ?? message; - } catch { - // ignore JSON parse errors - } - throw new Error(message); + throw new Error(genericActionError("update advisory status")); } toast.success("Advisory status updated"); setDialogOpen(false); setSelectedAdvisory(null); await loadAdvisories(); - } catch (err) { - toast.error( - err instanceof Error ? err.message : "Failed to update advisory status.", - ); + } catch { + toast.error(genericActionError("update advisory status")); } finally { setSubmitting(false); } }; + const handleDialogOpenChange = (open: boolean) => { + setDialogOpen(open); + if (!open) { + setSelectedAdvisory(null); + } + }; + if (accessDenied) { return ( -
-
- - 🐙 - OpenHub - -
-
-

Security Advisories

-
-

Access Denied

-

- You do not have permission to view security advisories. Admin access - is required. -

-
-
-
+ ); } return ( -
-
- - 🐙 - OpenHub - - - ← Security dashboard - -
- -
-
- - Dashboard - {" "} - /{" "} - - Admin / Security - {" "} - / Advisories -
-

Security Advisories

- -
{ - event.preventDefault(); - loadAdvisories(); - }} - > -
- - -
-
- - -
-
- -
-
- -
- {loading ? ( -

Loading…

- ) : error ? ( -

{error}

- ) : advisories.length === 0 ? ( -

No advisories found.

- ) : ( - - - - GHSA ID - Severity - Summary - State - Package - Actions - - - - {advisories.map((advisory) => ( - - - {advisory.ghsa_id} - - - - {advisory.severity} - - - {advisory.summary} - {advisory.state} - - {advisory.affected_package} - - - - - - ))} - - - )} + +
{ + event.preventDefault(); + void loadAdvisories(); + }} + > +
+ +
-
- - -

Update advisory status

- {selectedAdvisory && ( -

- {selectedAdvisory.ghsa_id} — {selectedAdvisory.summary} -

- )} - { - if (!submitting) { - void handleStatusUpdate(state, reason); - } - }} - /> -
-
+ + +
+ {loading ? ( +

Loading…

+ ) : error ? ( +

{error}

+ ) : advisories.length === 0 ? ( +

No advisories found.

+ ) : ( + + + + GHSA ID + Severity + Summary + State + Package + Actions + + + + {advisories.map((advisory) => ( + + + {advisory.ghsa_id} + + + + {advisory.severity} + + + {advisory.summary} + {advisory.state} + + {advisory.affected_package} + + + + + + ))} + + + )} +
+ + + {selectedAdvisory ? ( + <> +

Update advisory status

+

+ {selectedAdvisory.ghsa_id} — {selectedAdvisory.summary} +

+ { + if (!submitting) { + void handleStatusUpdate(state, reason); + } + }} + /> +
+ +
+ + ) : null}
-
+ ); } diff --git a/app/admin/security/audit-log/page.tsx b/app/admin/security/audit-log/page.tsx index 5e7d8c03..bce48a02 100644 --- a/app/admin/security/audit-log/page.tsx +++ b/app/admin/security/audit-log/page.tsx @@ -1,9 +1,12 @@ "use client"; -import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; +import { + SecurityAccessDenied, + SecurityPageLayout, +} from "@/components/admin/SecurityPageLayout"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -16,13 +19,17 @@ import { TableRow, } from "@/components/ui/table"; import { useToast } from "@/components/ui/toast"; +import { + checkClientOrgAdminAccess, + datetimeLocalToIso, + genericActionError, + getSecurityApiBase, + maskIpAddress, + resolveOrgLogin, + sanitizeAuditSearchPhrase, +} from "@/lib/admin/security"; import { useAuth } from "@/lib/auth"; -const API_BASE = - process.env.NEXT_PUBLIC_API_BASE_URL ?? - process.env.NEXT_PUBLIC_API_URL ?? - ""; - type OrgAuditLogEntry = { id: string; actor_login: string; @@ -47,19 +54,12 @@ const ACTION_OPTIONS = [ { value: "settings.update", label: "settings.update" }, ] as const; -function requireAdminRole(token: string | null, router: ReturnType): boolean { - if (!token) { - router.push("/login"); - return false; - } - return true; -} - export default function SecurityAuditLogPage() { const router = useRouter(); const searchParams = useSearchParams(); const { token } = useAuth(); const toast = useToast(); + const apiBase = getSecurityApiBase(); const [org, setOrg] = useState(""); const [entries, setEntries] = useState([]); @@ -75,59 +75,59 @@ export default function SecurityAuditLogPage() { const orgParam = searchParams.get("org"); const loadAuditLog = useCallback(async () => { - if (!requireAdminRole(token, router)) { - return; - } - setLoading(true); setError(null); setAccessDenied(false); - try { - let resolvedOrg = orgParam ?? ""; - if (!resolvedOrg) { - const orgsResponse = await fetch(`${API_BASE}/api/v3/user/orgs`, { - headers: { - Accept: "application/json", - Authorization: `Bearer ${token}`, - }, - cache: "no-store", - }); + const access = await checkClientOrgAdminAccess({ + token, + orgParam, + onUnauthenticated: () => { + router.push("/login"); + }, + }); - if (orgsResponse.status === 401) { - router.push("/login"); - return; - } + if (access.status === "unauthenticated") { + setLoading(false); + return; + } - if (!orgsResponse.ok) { - throw new Error(`Failed to load organizations (${orgsResponse.status})`); - } + if (access.status === "access_denied") { + setAccessDenied(true); + setEntries([]); + setLoading(false); + return; + } - const orgs = (await orgsResponse.json()) as { login: string }[]; - if (orgs.length === 0) { - throw new Error("No organization found"); - } - resolvedOrg = orgs[0].login; - } + if (access.status === "error") { + setError(access.message); + setLoading(false); + return; + } - setOrg(resolvedOrg); + const resolvedOrg = access.org; + setOrg(resolvedOrg); + try { const params = new URLSearchParams({ per_page: "50" }); - if (phrase.trim()) { - params.set("phrase", phrase.trim()); + const sanitizedPhrase = sanitizeAuditSearchPhrase(phrase); + if (sanitizedPhrase) { + params.set("phrase", sanitizedPhrase); } if (action) { - params.set("include", action); + params.set("action", action); } - if (after) { - params.set("after", new Date(after).toISOString()); + const afterIso = datetimeLocalToIso(after); + if (afterIso) { + params.set("after", afterIso); } - if (before) { - params.set("before", new Date(before).toISOString()); + const beforeIso = datetimeLocalToIso(before); + if (beforeIso) { + params.set("before", beforeIso); } const response = await fetch( - `${API_BASE}/api/v3/orgs/${encodeURIComponent(resolvedOrg)}/audit-log?${params.toString()}`, + `${apiBase}/api/v3/orgs/${encodeURIComponent(resolvedOrg)}/audit-log?${params.toString()}`, { headers: { Accept: "application/json", @@ -149,45 +149,73 @@ export default function SecurityAuditLogPage() { } if (!response.ok) { - throw new Error(`Failed to load audit log (${response.status})`); + throw new Error(genericActionError("load audit log")); } const data = (await response.json()) as OrgAuditLogEntry[]; setEntries(data); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load audit log."); + } catch { + setError(genericActionError("load audit log")); } finally { setLoading(false); } - }, [token, router, orgParam, phrase, action, after, before]); + }, [token, router, orgParam, phrase, action, after, before, apiBase]); useEffect(() => { - loadAuditLog(); + void loadAuditLog(); }, [loadAuditLog]); const handleExport = async () => { - if (!requireAdminRole(token, router) || !org) { + if (!token) { + router.push("/login"); return; } setExporting(true); try { + let resolvedOrg = org; + if (!resolvedOrg) { + const orgResult = await resolveOrgLogin(token, orgParam); + if (!orgResult.ok) { + toast.error(orgResult.message); + return; + } + resolvedOrg = orgResult.org; + setOrg(resolvedOrg); + } + + const access = await checkClientOrgAdminAccess({ + token, + orgParam: resolvedOrg, + onUnauthenticated: () => { + router.push("/login"); + }, + }); + + if (access.status !== "ok") { + toast.error("Admin access is required to export audit logs."); + return; + } + const params = new URLSearchParams({ format: "csv" }); - if (phrase.trim()) { - params.set("phrase", phrase.trim()); + const sanitizedPhrase = sanitizeAuditSearchPhrase(phrase); + if (sanitizedPhrase) { + params.set("phrase", sanitizedPhrase); } if (action) { - params.set("include", action); + params.set("action", action); } - if (after) { - params.set("after", new Date(after).toISOString()); + const afterIso = datetimeLocalToIso(after); + if (afterIso) { + params.set("after", afterIso); } - if (before) { - params.set("before", new Date(before).toISOString()); + const beforeIso = datetimeLocalToIso(before); + if (beforeIso) { + params.set("before", beforeIso); } const response = await fetch( - `${API_BASE}/api/v3/orgs/${encodeURIComponent(org)}/audit-log/export?${params.toString()}`, + `${apiBase}/api/v3/orgs/${encodeURIComponent(resolvedOrg)}/audit-log/export?${params.toString()}`, { headers: { Accept: "application/json", @@ -207,15 +235,13 @@ export default function SecurityAuditLogPage() { } if (!response.ok) { - throw new Error(`Failed to export audit log (${response.status})`); + throw new Error(genericActionError("export audit log")); } const data = (await response.json()) as ExportResponse; toast.success(`Export job queued: ${data.job_id}`); - } catch (err) { - toast.error( - err instanceof Error ? err.message : "Failed to export audit log.", - ); + } catch { + toast.error(genericActionError("export audit log")); } finally { setExporting(false); } @@ -223,166 +249,124 @@ export default function SecurityAuditLogPage() { if (accessDenied) { return ( -
-
- - 🐙 - OpenHub - -
-
-

Security Audit Log

-
-

Access Denied

-

- You do not have permission to view the audit log. Admin access is - required. -

-
-
-
+ ); } return ( -
-
- - 🐙 - OpenHub - - - ← Security dashboard - -
- -
-
- - Dashboard - {" "} - /{" "} - - Admin / Security - {" "} - / Audit log + +
{ + event.preventDefault(); + void loadAuditLog(); + }} + > +
+ + setPhrase(event.target.value)} + placeholder="Search actions, actors, targets…" + />
-

Security Audit Log

- - { - event.preventDefault(); - loadAuditLog(); - }} - > -
- - setPhrase(event.target.value)} - placeholder="Search actions, actors, targets…" - /> -
-
- - -
-
- - setAfter(event.target.value)} - /> -
-
- - setBefore(event.target.value)} - /> -
-
- - -
-
- -
- {loading ? ( -

Loading…

- ) : error ? ( -

{error}

- ) : entries.length === 0 ? ( -

No audit log entries found.

- ) : ( - - - - Actor - Action - IP address - Created at - - - - {entries.map((entry) => ( - - - {entry.actor_login} - - - {entry.action} - - - {entry.ip_address ?? "—"} - - - {entry.created_at} - - - ))} - - - )} +
+ + +
+
+ + setAfter(event.target.value)} + />
+
+ + setBefore(event.target.value)} + /> +
+
+ + +
+ + +
+ {loading ? ( +

Loading…

+ ) : error ? ( +

{error}

+ ) : entries.length === 0 ? ( +

No audit log entries found.

+ ) : ( + + + + Actor + Action + IP address + Created at + + + + {entries.map((entry) => ( + + + {entry.actor_login} + + + {entry.action} + + + {maskIpAddress(entry.ip_address)} + + + {entry.created_at} + + + ))} + + + )}
-
+
); } diff --git a/app/admin/security/page.tsx b/app/admin/security/page.tsx index 1a8c9948..40df4b33 100644 --- a/app/admin/security/page.tsx +++ b/app/admin/security/page.tsx @@ -1,18 +1,21 @@ import Link from "next/link"; -import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { SecuritySummaryCard } from "@/components/admin/SecuritySummaryCard"; +import { + SecurityAccessDenied, + SecurityPageLayout, +} from "@/components/admin/SecurityPageLayout"; import type { AdvisorySeverity, ScanJob, SecurityAdvisory, } from "@/lib/api-types"; - -const API_BASE = - process.env.NEXT_PUBLIC_API_BASE_URL ?? - process.env.NEXT_PUBLIC_API_URL ?? - ""; +import { + buildOrgQueryString, + getSecurityApiBase, +} from "@/lib/admin/security"; +import { requireOrgAdminAccess } from "@/lib/admin/security-server"; const SEVERITIES: AdvisorySeverity[] = ["critical", "high", "medium", "low"]; @@ -23,45 +26,6 @@ const SEVERITY_LABELS: Record = { low: "Low advisories", }; -async function requireAdminRole(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get("authToken")?.value; - - if (!token) { - redirect("/login"); - } - - return token; -} - -async function resolveOrgLogin( - token: string, - orgParam?: string, -): Promise { - if (orgParam) { - return orgParam; - } - - const response = await fetch(`${API_BASE}/api/v3/user/orgs`, { - headers: { - Accept: "application/json", - Authorization: `Bearer ${token}`, - }, - cache: "no-store", - }); - - if (!response.ok) { - throw new Error(`Failed to load organizations (${response.status})`); - } - - const orgs = (await response.json()) as { login: string }[]; - if (orgs.length === 0) { - throw new Error("No organization found"); - } - - return orgs[0].login; -} - function countBySeverity( advisories: SecurityAdvisory[], ): Record { @@ -73,55 +37,46 @@ function countBySeverity( }; for (const advisory of advisories) { - counts[advisory.severity] += 1; + if (advisory.severity in counts) { + counts[advisory.severity] += 1; + } } return counts; } -function AccessDenied() { - return ( -
-
- - 🐙 - OpenHub - -
-
-
- - Dashboard - {" "} - / Admin / Security -
-

Security Dashboard

-
-

Access Denied

-

- You do not have permission to view the security dashboard. Admin - access is required. -

-
-
-
- ); -} - export default async function SecurityDashboardPage({ searchParams, }: { searchParams: Promise<{ org?: string }>; }) { - const token = await requireAdminRole(); const { org: orgParam } = await searchParams; - const org = await resolveOrgLogin(token, orgParam); + const access = await requireOrgAdminAccess(orgParam); + + if (access.status === "unauthenticated") { + redirect("/login"); + } + + if (access.status === "access_denied") { + return ; + } + + if (access.status === "error") { + return ( + +
+

{access.message}

+
+
+ ); + } + + const { token, org } = access; + const apiBase = getSecurityApiBase(); + const orgQuery = buildOrgQueryString(orgParam); const advisoriesResponse = await fetch( - `${API_BASE}/api/v3/orgs/${encodeURIComponent(org)}/security-advisories?per_page=100`, + `${apiBase}/api/v3/orgs/${encodeURIComponent(org)}/security-advisories?per_page=100`, { headers: { Accept: "application/json", @@ -136,12 +91,18 @@ export default async function SecurityDashboardPage({ } if (advisoriesResponse.status === 403) { - return ; + return ; } if (!advisoriesResponse.ok) { - throw new Error( - `Failed to load security advisories (${advisoriesResponse.status})`, + return ( + +
+

+ Unable to load security advisories. Please try again. +

+
+
); } @@ -150,7 +111,7 @@ export default async function SecurityDashboardPage({ const severityCounts = countBySeverity(advisories); const scanJobsResponse = await fetch( - `${API_BASE}/api/v3/orgs/${encodeURIComponent(org)}/scan-jobs?per_page=10`, + `${apiBase}/api/v3/orgs/${encodeURIComponent(org)}/scan-jobs?per_page=10`, { headers: { Accept: "application/json", @@ -166,98 +127,77 @@ export default async function SecurityDashboardPage({ } return ( -
-
- - 🐙 - OpenHub - - - ← Dashboard - -
- -
-
- - Dashboard - {" "} - / Admin / Security -
-
-

Security Dashboard

-
- - Advisories - - - Audit log - -
+ +
+
+ + Advisories + + + Audit log +
+
-
- {SEVERITIES.map((severity) => ( - - ))} -
+
+ {SEVERITIES.map((severity) => ( + + ))} +
-
-

Recent scan jobs

-
- {scanJobs.length === 0 ? ( -

No scan jobs found.

- ) : ( - - - - - - - - +
+

Recent scan jobs

+
+ {scanJobs.length === 0 ? ( +

No scan jobs found.

+ ) : ( +
Job IDTypeStatusStartedFinished
+ + + + + + + + + + + {scanJobs.map((job) => ( + + + + + + - - - {scanJobs.map((job) => ( - - - - - - - - ))} - -
Job IDTypeStatusStartedFinished
{job.id}{job.type}{job.status} + {job.started_at ?? "—"} + + {job.finished_at ?? "—"} +
{job.id}{job.type}{job.status} - {job.started_at ?? "—"} - - {job.finished_at ?? "—"} -
- )} -
-
-
-
+ ))} + + + )} +
+ + ); } diff --git a/components/admin/SecurityPageLayout.tsx b/components/admin/SecurityPageLayout.tsx new file mode 100644 index 00000000..574ab631 --- /dev/null +++ b/components/admin/SecurityPageLayout.tsx @@ -0,0 +1,82 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; + +type SecurityPageLayoutProps = { + title: string; + breadcrumbSuffix?: string; + backHref?: string; + backLabel?: string; + children: ReactNode; +}; + +export function SecurityPageLayout({ + title, + breadcrumbSuffix, + backHref, + backLabel, + children, +}: SecurityPageLayoutProps) { + return ( +
+
+ + 🐙 + OpenHub + + {backHref && backLabel ? ( + + {backLabel} + + ) : null} +
+ +
+
+ + Dashboard + {" "} + /{" "} + {breadcrumbSuffix ? ( + <> + + Admin / Security + {" "} + / {breadcrumbSuffix} + + ) : ( + "Admin / Security" + )} +
+

{title}

+ {children} +
+
+ ); +} + +type SecurityAccessDeniedProps = { + title: string; + breadcrumbSuffix?: string; + message?: string; +}; + +export function SecurityAccessDenied({ + title, + breadcrumbSuffix, + message = "You do not have permission to view this page. Admin access is required.", +}: SecurityAccessDeniedProps) { + return ( + +
+

Access Denied

+

{message}

+
+
+ ); +} diff --git a/lib/admin/security-server.ts b/lib/admin/security-server.ts new file mode 100644 index 00000000..9ec22d81 --- /dev/null +++ b/lib/admin/security-server.ts @@ -0,0 +1,54 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +import { + genericActionError, + resolveOrgLogin, + verifyOrgAdminRole, +} from "@/lib/admin/security"; + +export type OrgAdminAccessResult = + | { status: "ok"; token: string; org: string } + | { status: "unauthenticated" } + | { status: "access_denied" } + | { status: "error"; message: string }; + +export async function getAuthTokenFromCookies(): Promise { + const cookieStore = await cookies(); + return cookieStore.get("authToken")?.value ?? null; +} + +export async function requireAuthToken(): Promise { + const token = await getAuthTokenFromCookies(); + if (!token) { + redirect("/login"); + } + return token; +} + +export async function requireOrgAdminAccess( + orgParam?: string | null, +): Promise { + const token = await getAuthTokenFromCookies(); + if (!token) { + return { status: "unauthenticated" }; + } + + const orgResult = await resolveOrgLogin(token, orgParam); + if (!orgResult.ok) { + return { status: "error", message: orgResult.message }; + } + + const roleCheck = await verifyOrgAdminRole(token, orgResult.org); + if (roleCheck === "unauthorized") { + return { status: "unauthenticated" }; + } + if (roleCheck === "forbidden") { + return { status: "access_denied" }; + } + if (roleCheck === "error") { + return { status: "error", message: genericActionError("verify admin access") }; + } + + return { status: "ok", token, org: orgResult.org }; +} diff --git a/lib/admin/security.ts b/lib/admin/security.ts new file mode 100644 index 00000000..35306c28 --- /dev/null +++ b/lib/admin/security.ts @@ -0,0 +1,224 @@ +import type { AdvisorySeverity } from "@/lib/api-types"; + +export function getSecurityApiBase(): string { + return ( + process.env.NEXT_PUBLIC_API_BASE_URL ?? + process.env.NEXT_PUBLIC_API_URL ?? + "" + ); +} + +const ORG_LOGIN_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38}[a-zA-Z0-9])?$/; + +export function sanitizeOrgLogin(org: string | null | undefined): string | null { + if (!org) { + return null; + } + + const trimmed = org.trim(); + if (!ORG_LOGIN_PATTERN.test(trimmed)) { + return null; + } + + return trimmed; +} + +export function buildOrgQueryString(org: string | null | undefined): string { + const safeOrg = sanitizeOrgLogin(org); + return safeOrg ? `?org=${encodeURIComponent(safeOrg)}` : ""; +} + +export function isAdminOrgRole(role: string): boolean { + const normalized = role.toLowerCase(); + return normalized === "admin" || normalized === "owner"; +} + +export function sanitizeAuditSearchPhrase(phrase: string): string { + return phrase + .trim() + .replace(/[\u0000-\u001f\u007f]/g, "") + .slice(0, 256); +} + +export function datetimeLocalToIso(value: string): string { + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/); + if (!match) { + return ""; + } + + const [, year, month, day, hour, minute] = match; + const date = new Date( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + ); + + if (Number.isNaN(date.getTime())) { + return ""; + } + + return date.toISOString(); +} + +export function maskIpAddress(ip: string | null): string { + if (!ip) { + return "—"; + } + + const ipv4Match = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (ipv4Match) { + return `${ipv4Match[1]}.${ipv4Match[2]}.*.*`; + } + + if (ip.includes(":")) { + const [firstSegment] = ip.split(":"); + return firstSegment ? `${firstSegment}:****` : "—"; + } + + return "—"; +} + +export function genericActionError(action: string): string { + return `Unable to ${action}. Please try again.`; +} + +const severityBadgeClass: Record = { + critical: "border-transparent bg-[#cf222e] text-white", + high: "border-transparent bg-[#ffebe9] text-[#cf222e]", + medium: "border-transparent bg-[#fff8c5] text-[#9a6700]", + low: "border-transparent bg-[#eaeef2] text-[#656d76]", +}; + +export function getSeverityBadgeClass( + severity: string, +): string { + if (severity in severityBadgeClass) { + return severityBadgeClass[severity as AdvisorySeverity]; + } + + return severityBadgeClass.low; +} + +async function authFetch( + token: string, + path: string, + init?: RequestInit, +): Promise { + return fetch(`${getSecurityApiBase()}${path}`, { + ...init, + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + ...(init?.headers ?? {}), + }, + cache: "no-store", + }); +} + +export async function resolveOrgLogin( + token: string, + orgParam?: string | null, +): Promise<{ ok: true; org: string } | { ok: false; message: string }> { + const sanitized = sanitizeOrgLogin(orgParam); + if (sanitized) { + return { ok: true, org: sanitized }; + } + + try { + const response = await authFetch(token, "/api/v3/user/orgs"); + if (response.status === 401) { + return { ok: false, message: "Authentication required" }; + } + if (!response.ok) { + return { + ok: false, + message: `Failed to load organizations (${response.status})`, + }; + } + + const orgs = (await response.json()) as { login: string }[]; + if (orgs.length === 0) { + return { ok: false, message: "No organization found" }; + } + + return { ok: true, org: orgs[0].login }; + } catch { + return { ok: false, message: "Failed to load organizations" }; + } +} + +export async function verifyOrgAdminRole( + token: string, + org: string, +): Promise<"admin" | "forbidden" | "unauthorized" | "error"> { + try { + const [userResponse, membersResponse] = await Promise.all([ + authFetch(token, "/api/v3/user"), + authFetch(token, `/api/v3/orgs/${encodeURIComponent(org)}/members`), + ]); + + if (userResponse.status === 401 || membersResponse.status === 401) { + return "unauthorized"; + } + + if (membersResponse.status === 403 || membersResponse.status === 404) { + return "forbidden"; + } + + if (!userResponse.ok || !membersResponse.ok) { + return "error"; + } + + const user = (await userResponse.json()) as { login: string }; + const members = (await membersResponse.json()) as { + login: string; + role: string; + }[]; + const membership = members.find((member) => member.login === user.login); + + if (!membership || !isAdminOrgRole(membership.role)) { + return "forbidden"; + } + + return "admin"; + } catch { + return "error"; + } +} + +export async function checkClientOrgAdminAccess(options: { + token: string | null; + orgParam: string | null; + onUnauthenticated: () => void; +}): Promise< + | { status: "ok"; org: string } + | { status: "unauthenticated" } + | { status: "access_denied" } + | { status: "error"; message: string } +> { + if (!options.token) { + options.onUnauthenticated(); + return { status: "unauthenticated" }; + } + + const orgResult = await resolveOrgLogin(options.token, options.orgParam); + if (!orgResult.ok) { + return { status: "error", message: orgResult.message }; + } + + const roleCheck = await verifyOrgAdminRole(options.token, orgResult.org); + if (roleCheck === "unauthorized") { + options.onUnauthenticated(); + return { status: "unauthenticated" }; + } + if (roleCheck === "forbidden") { + return { status: "access_denied" }; + } + if (roleCheck === "error") { + return { status: "error", message: genericActionError("verify admin access") }; + } + + return { status: "ok", org: orgResult.org }; +} From dd542c0110604ed501f169f9d151ac2e25875a6f Mon Sep 17 00:00:00 2001 From: zoetaka38 Date: Mon, 29 Jun 2026 20:50:41 +0200 Subject: [PATCH 3/3] fix(admin/security): wrap useSearchParams pages in Suspense; lint fixes - Wrap advisories/audit-log content in (Next 15 CSR bailout) - Remove unused test param; scoped no-control-regex disable - Exclude k6 test/load scripts from eslint (pre-existing main baseline) --- __tests__/app/admin/security-pages.test.tsx | 2 +- app/admin/security/advisories/page.tsx | 18 ++++++++++++++++-- app/admin/security/audit-log/page.tsx | 12 ++++++++++-- eslint.config.mjs | 1 + lib/admin/security.ts | 1 + 5 files changed, 29 insertions(+), 5 deletions(-) diff --git a/__tests__/app/admin/security-pages.test.tsx b/__tests__/app/admin/security-pages.test.tsx index 6ea1fbb1..cbc761a0 100644 --- a/__tests__/app/admin/security-pages.test.tsx +++ b/__tests__/app/admin/security-pages.test.tsx @@ -29,7 +29,7 @@ vi.mock("@/components/ui/toast", () => ({ })); function mockAdminFetch() { - return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + return vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.endsWith("/api/v3/user")) { diff --git a/app/admin/security/advisories/page.tsx b/app/admin/security/advisories/page.tsx index ef1c7435..faa6a3ec 100644 --- a/app/admin/security/advisories/page.tsx +++ b/app/admin/security/advisories/page.tsx @@ -1,7 +1,13 @@ "use client"; import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { + Suspense, + useCallback, + useEffect, + useState, + type ReactNode, +} from "react"; import { AdvisoryStatusForm } from "@/components/admin/AdvisoryStatusForm"; import { @@ -102,7 +108,7 @@ function resolveRepoScope(advisory: AdvisoryListItem): { return null; } -export default function SecurityAdvisoriesPage() { +function SecurityAdvisoriesContent() { const router = useRouter(); const searchParams = useSearchParams(); const { token } = useAuth(); @@ -427,3 +433,11 @@ export default function SecurityAdvisoriesPage() { ); } + +export default function SecurityAdvisoriesPage() { + return ( + + + + ); +} diff --git a/app/admin/security/audit-log/page.tsx b/app/admin/security/audit-log/page.tsx index bce48a02..9e10634d 100644 --- a/app/admin/security/audit-log/page.tsx +++ b/app/admin/security/audit-log/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; +import { Suspense, useCallback, useEffect, useState } from "react"; import { SecurityAccessDenied, @@ -54,7 +54,7 @@ const ACTION_OPTIONS = [ { value: "settings.update", label: "settings.update" }, ] as const; -export default function SecurityAuditLogPage() { +function SecurityAuditLogContent() { const router = useRouter(); const searchParams = useSearchParams(); const { token } = useAuth(); @@ -370,3 +370,11 @@ export default function SecurityAuditLogPage() { ); } + +export default function SecurityAuditLogPage() { + return ( + + + + ); +} diff --git a/eslint.config.mjs b/eslint.config.mjs index c0c13223..9d36b384 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,7 @@ export default tseslint.config( 'coverage/**', 'next-env.d.ts', 'docs/**', + 'test/load/**', ], }, js.configs.recommended, diff --git a/lib/admin/security.ts b/lib/admin/security.ts index 35306c28..9985ea03 100644 --- a/lib/admin/security.ts +++ b/lib/admin/security.ts @@ -36,6 +36,7 @@ export function isAdminOrgRole(role: string): boolean { export function sanitizeAuditSearchPhrase(phrase: string): string { return phrase .trim() + // eslint-disable-next-line no-control-regex -- intentionally strip ASCII control characters .replace(/[\u0000-\u001f\u007f]/g, "") .slice(0, 256); }