Skip to content
127 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.
73 An owner can start the plan in Billing.
74 </p>
75 )}
76 {owner && editing === null && (
77 <Button
78 type="button"
79 onClick={() => setEditing("new")}
80 variant="outline"
81 size="sm"
82 className="mb-4 px-3 text-sm font-normal"
83 >
84 <Plus size={14} /> New pattern
85 </Button>
86 )}
87 {editing !== null && (
88 <section className={`${CARD} mb-6 p-4`}>
89 <div className="mb-3 flex items-center justify-between">
90 <h2 className="text-base font-semibold">{editing === "new" ? "New pattern" : editing.name}</h2>
91 <Button type="button" onClick={() => setEditing(null)} variant="link" size="inline" className="text-sm text-muted hover:text-fg font-normal">
92 Close
93 </Button>
94 </div>
95 <PatternEditor key={editing === "new" ? "new" : editing.id} action={`/${params.owner}/-/security/patterns`} draft={draftOf(editing === "new" ? null : editing)} />
96 </section>
97 )}
98 {list.patterns.length === 0 ? (
99 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-sm text-muted">
100 <p>No custom patterns yet.</p>
101 </Card>
102 ) : (
103 <ul className={`${CARD} divide-y divide-line`}>
104 {list.patterns.map((pattern) => (
105 <li key={pattern.id} className="flex flex-col gap-1 px-4 py-3 sm:flex-row sm:items-center sm:gap-3">
106 <div className="min-w-0 grow">
107 <div className="flex flex-wrap items-center gap-2">
108 <span className="text-sm font-medium">{pattern.name}</span>
109 <Badge tone={pattern.state === "published" ? "accent" : "neutral"}>{pattern.state === "published" ? "Published" : "Draft"}</Badge>
110 </div>
111 <p className="mt-0.5 truncate font-mono text-xs text-muted">{pattern.pattern}</p>
112 </div>
113 <span className="shrink-0 text-xs text-faint">
114 {pattern.openAlerts} open {pattern.openAlerts === 1 ? "alert" : "alerts"}
115 </span>
116 {owner && (
117 <Button type="button" onClick={() => setEditing(pattern)} variant="outline" size="inline" className="px-2.5 py-1 text-xs text-muted font-normal">
118 Edit
119 </Button>
120 )}
121 </li>
122 ))}
123 </ul>
124 )}
125 </div>
126 );
127}