g1t/apps/web/app/routes/workspace/security.tsx

90 lines4,078 bytesCodeBlame
1import { ChevronRight } from "lucide-react";
2import { Link, data } from "react-router";
3
4import { SEVERITIES, type SeverityCounts } from "@g1t/contracts";
5
6import type { Route } from "./+types/security";
7import { page } from "../../lib/meta";
8import { SeverityCountsGrid, SeverityCountsInline } from "../../components/security";
9import { TimeAgo } from "../../components/ui";
10import { Badge } from "../../components/ui/badge";
11import { repos, security } from "../../lib/services.server";
12import { getViewer, roleIn, unwrap } from "../../lib/session.server";
13
14export function meta({ params, ...args }: Route.MetaArgs) {
15 return page(args, { title: `Security · ${params.owner} · g1t` });
16}
17
18export async function loader({ params, context }: Route.LoaderArgs) {
19 const viewer = getViewer(context);
20 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
21 const [scanned, current] = await Promise.all([
22 security.workspace(params.owner, viewer),
23 repos.list(viewer, { namespace: params.owner.toLowerCase() }),
24 ]);
25 // Only repositories that are still there: a deleted one's alerts stay
26 // with it for its 30 days, but not on this page.
27 const live = new Set(current.map((repo) => repo.id));
28 const projects = unwrap(scanned).filter((project) => live.has(project.repoId));
29 const total = Object.fromEntries(SEVERITIES.map((severity) => [severity, 0])) as SeverityCounts;
30 for (const project of projects) {
31 for (const severity of SEVERITIES) total[severity] += project.counts[severity];
32 }
33 // Most to fix first.
34 projects.sort(
35 (a, b) =>
36 SEVERITIES.reduce((order, severity) => order || b.counts[severity] - a.counts[severity], 0) || a.name.localeCompare(b.name),
37 );
38 return { projects, total };
39}
40
41export default function WorkspaceSecurity({ loaderData, params }: Route.ComponentProps) {
42 const { projects, total } = loaderData;
43 return (
44 <div className="space-y-6">
45 <div>
46 <SeverityCountsGrid counts={total} />
47 <p className="mt-2 text-xs text-faint">
48 Open alerts by severity. A secret in the history that looks real counts as critical; blocked pushes and likely test
49 values do not.
50 </p>
51 </div>
52 {projects.length === 0 ? (
53 <p className="rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted">
54 No project has been scanned yet. Each one is scanned on its next push to its default branch, or when its Security
55 page is first opened.
56 </p>
57 ) : (
58 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
59 {projects.map((project) => (
60 <li key={project.repoId}>
61 <Link
62 to={`/${params.owner}/${project.name}/security`}
63 className="group flex flex-col gap-2 px-4 py-3 transition-colors hover:bg-raised/50 sm:flex-row sm:items-center"
64 >
65 <span className="min-w-0 grow">
66 <span className="flex flex-wrap items-center gap-2">
67 <span className="font-mono text-sm font-medium">{project.name}</span>
68 {!project.upkeep && <Badge>security updates off</Badge>}
69 </span>
70 <span className="mt-0.5 block text-xs text-faint">
71 {project.secrets} {project.secrets === 1 ? "secret" : "secrets"} · {project.vulnerabilities}{" "}
72 {project.vulnerabilities === 1 ? "vulnerability" : "vulnerabilities"}
73 {project.dependenciesScannedAt && (
74 <>
75 {" "}
76 · read <TimeAgo at={project.dependenciesScannedAt} />
77 </>
78 )}
79 </span>
80 </span>
81 <SeverityCountsInline counts={project.counts} />
82 <ChevronRight size={15} className="hidden shrink-0 text-faint group-hover:text-fg sm:block" />
83 </Link>
84 </li>
85 ))}
86 </ul>
87 )}
88 </div>
89 );
90}