Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

131 linesCodeBlameRaw
1import { Plus } from "lucide-react";
2import { useState } from "react";
3import { data } from "react-router";
4
5import type { CustomPattern } from "@g1t/contracts";
6
7import type { Route } from "./+types/security-patterns";
8import { page } from "../../lib/meta";
9import { CARD, PatternEditor, patternFields } from "../../components/security-suite";
10import { WorkspaceSecurityHeading, WorkspaceSecurityTabs } from "../../components/workspace-security-tabs";
11import { Badge } from "../../components/ui/badge";
12import { Button } from "../../components/ui/button";
13import { Card } from "../../components/ui/card";
14import { securitySuite } from "../../lib/services.server";
15import { assertSameOrigin, getViewer, managesSecurity, requireUser, roleIn, unwrap } from "../../lib/session.server";
16
17export function meta({ params, ...args }: Route.MetaArgs) {
18 return page(args, { title: `Custom patterns · ${params.owner} · g1t` });
19}
20
21export async function loader({ params, context }: Route.LoaderArgs) {
22 const viewer = getViewer(context);
23 const role = roleIn(viewer, params.owner);
24 if (!role) throw data(null, { status: 404 });
25 return { list: unwrap(await securitySuite.patterns(params.owner, null, viewer)), owner: managesSecurity(viewer, params.owner) };
26}
27
28export async function action({ params, context, request }: Route.ActionArgs) {
29 assertSameOrigin(request);
30 const user = requireUser(context, request);
31 const form = await request.formData();
32 const intent = String(form.get("intent") ?? "");
33 if (intent === "save_pattern") {
34 const saved = await securitySuite.savePattern(user, params.owner, null, patternFields(form));
35 return saved.ok ? { ok: true, saved: saved.value } : { ok: false, error: saved.error.message };
36 }
37 if (intent === "dry_run") {
38 const ran = await securitySuite.dryRun(user, params.owner, null, patternFields(form));
39 return ran.ok ? { ok: true, dryRun: ran.value } : { ok: false, error: ran.error.message };
40 }
41 if (intent === "delete_pattern") {
42 const deleted = await securitySuite.deletePattern(user, params.owner, null, String(form.get("id") ?? ""));
43 return deleted.ok ? { ok: true } : { ok: false, error: deleted.error.message };
44 }
45 return { ok: false, error: "Unknown action." };
46}
47
48function draftOf(pattern: CustomPattern | null) {
49 return {
50 id: pattern?.id,
51 name: pattern?.name ?? "",
52 pattern: pattern?.pattern ?? "",
53 before: pattern?.before ?? "",
54 after: pattern?.after ?? "",
55 testStrings: (pattern?.testStrings ?? []).join("\n"),
56 published: pattern?.state === "published",
57 };
58}
59
60export default function WorkspacePatterns({ loaderData, params }: Route.ComponentProps) {
61 const { list, owner } = loaderData;
62 const [editing, setEditing] = useState<CustomPattern | "new" | null>(null);
63 return (
64 <div>
65 <WorkspaceSecurityHeading
66 title="Custom patterns"
67 about="Secret formats of the workspace's own, found in every repository's pushes and history alongside the built-in ones. Without the g1t plan they cover public repositories only."
68 />
69 <WorkspaceSecurityTabs owner={params.owner} />
70 {!list.entitled && (
71 <p className="mb-4 rounded-md border border-warn/30 bg-warn/5 px-3 py-2 text-sm text-warn">
72 This workspace is not on the g1t plan, which Security and quality comes with: its patterns run on public repositories only. An owner can start the
73 plan in Billing.
74 </p>
75 )}
76 {owner && editing === null && (
77 <Button type="button" onClick={() => setEditing("new")} variant="outline" size="sm" className="mb-4 px-3 text-sm font-normal">
78 <Plus size={14} /> New pattern
79 </Button>
80 )}
81 {editing !== null && (
82 <section className={`${CARD} mb-6 p-4`}>
83 <div className="mb-3 flex items-center justify-between">
84 <h2 className="text-base font-semibold">{editing === "new" ? "New pattern" : editing.name}</h2>
85 <Button type="button" onClick={() => setEditing(null)} variant="link" size="inline" className="text-sm text-muted hover:text-fg font-normal">
86 Close
87 </Button>
88 </div>
89 <PatternEditor
90 key={editing === "new" ? "new" : editing.id}
91 action={`/${params.owner}/-/security/patterns`}
92 draft={draftOf(editing === "new" ? null : editing)}
93 />
94 </section>
95 )}
96 {list.patterns.length === 0 ? (
97 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-sm text-muted">
98 <p>No custom patterns yet.</p>
99 </Card>
100 ) : (
101 <ul className={`${CARD} divide-y divide-line`}>
102 {list.patterns.map((pattern) => (
103 <li key={pattern.id} className="flex flex-col gap-1 px-4 py-3 sm:flex-row sm:items-center sm:gap-3">
104 <div className="min-w-0 grow">
105 <div className="flex flex-wrap items-center gap-2">
106 <span className="text-sm font-medium">{pattern.name}</span>
107 <Badge tone={pattern.state === "published" ? "accent" : "neutral"}>{pattern.state === "published" ? "Published" : "Draft"}</Badge>
108 </div>
109 <p className="mt-0.5 truncate font-mono text-xs text-muted">{pattern.pattern}</p>
110 </div>
111 <span className="shrink-0 text-xs text-faint">
112 {pattern.openAlerts} open {pattern.openAlerts === 1 ? "alert" : "alerts"}
113 </span>
114 {owner && (
115 <Button
116 type="button"
117 onClick={() => setEditing(pattern)}
118 variant="outline"
119 size="inline"
120 className="px-2.5 py-1 text-xs text-muted font-normal"
121 >
122 Edit
123 </Button>
124 )}
125 </li>
126 ))}
127 </ul>
128 )}
129 </div>
130 );
131}