flagon-io/g1t

public

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

g1t/apps/sudo/app/routes/audit.tsx

171 lines7,274 bytesCodeBlame
1import { ChevronRight, UserRound } from "lucide-react";
2import { Link } from "react-router";
3
4import type { AdminAction } from "@g1t/contracts";
5
6import type { Route } from "./+types/audit";
7import { Button, EmptyState, Input, Notice, PageHeader, Select, When } from "~/components/ui";
8import {
9 AUDIT_ACTIONS,
10 AUDIT_PAGE,
11 accountName,
12 accountPath,
13 actionLabel,
14 auditHref,
15 olderBefore,
16 parseAction,
17 parseBefore,
18 parseBy,
19} from "~/lib/ledgers";
20import { admin } from "~/lib/services.server";
21import { settle } from "~/lib/settle";
22import { requireStaff } from "~/lib/staff";
23
24export const meta: Route.MetaFunction = () => [{ title: "Audit log · sudo" }, { name: "robots", content: "noindex, nofollow" }];
25
26export async function loader({ request, context }: Route.LoaderArgs) {
27 const staff = requireStaff(context);
28 const url = new URL(request.url);
29 const by = parseBy(url.searchParams.get("by"));
30 const action = parseAction(url.searchParams.get("action"));
31 const before = parseBefore(url.searchParams.get("before"));
32 // Enterprises' names, so a line about one reads as its name, not its id.
33 const [result, accounts] = await Promise.all([
34 settle(admin.audit({ by: by ?? undefined, action: action ?? undefined, before: before ?? undefined })),
35 settle(admin.accounts()),
36 ]);
37 const names = Object.fromEntries(
38 (accounts.ok ? accounts.value : []).filter((row) => row.account.kind === "enterprise").map((row) => [row.account.id, row.account.name]),
39 );
40 const actions = result.ok ? result.value : [];
41 return {
42 me: staff.email,
43 by,
44 action,
45 before,
46 actions,
47 names,
48 older: olderBefore(actions),
49 error: result.ok ? null : result.error,
50 };
51}
52
53/** Where a change came from: Stripe's lines are about Stripe, not a person. */
54function isStripe(entry: AdminAction) {
55 return entry.action === "stripe" || entry.action === "webhook" || entry.by === "stripe";
56}
57
58export default function Audit({ loaderData }: Route.ComponentProps) {
59 const { me, by, action, before, actions, names, older, error } = loaderData;
60 const nameMap = new Map(Object.entries(names));
61 const filtered = by != null || action != null;
62
63 return (
64 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
65 <PageHeader
66 title="Audit log"
67 description="Every change made in sudo, and what Stripe told billing, newest first, across every customer. Each names who made it."
68 />
69
70 <form method="get" action="/audit" className="mt-6 flex flex-col gap-2 sm:flex-row sm:items-center">
71 <Input name="by" type="search" defaultValue={by ?? ""} placeholder="Staff email, such as you@g1t.sh" aria-label="Made by" className="font-mono text-xs sm:w-72" />
72 <Select name="action" defaultValue={action ?? ""} aria-label="Kind of change" className="sm:w-60">
73 <option value="">Every kind of change</option>
74 {Object.entries(AUDIT_ACTIONS).map(([value, label]) => (
75 <option key={value} value={value}>
76 {label}
77 </option>
78 ))}
79 </Select>
80 <div className="flex items-center gap-2">
81 <Button type="submit" variant="quiet" className="py-2">
82 Apply
83 </Button>
84 <Link to={auditHref({ by: me })} className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
85 Mine
86 </Link>
87 {filtered && (
88 <Link to="/audit" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
89 Clear
90 </Link>
91 )}
92 </div>
93 </form>
94
95 {before && (
96 <p className="mt-4 text-xs text-faint">
97 Changes before <When at={before} time />.{" "}
98 <Link to={auditHref({ by, action })} className="text-merged hover:underline">
99 Back to the newest
100 </Link>
101 </p>
102 )}
103
104 {error ? (
105 <div className="mt-4">
106 <Notice tone="warn">Billing did not answer for the audit log: {error}</Notice>
107 </div>
108 ) : actions.length === 0 ? (
109 <div className="mt-4">
110 <EmptyState title={filtered || before ? "No changes match" : "No changes yet"}>
111 {filtered ? "Try another staff email or kind of change, or clear the filters." : "Changes appear here as staff make them."}
112 </EmptyState>
113 </div>
114 ) : (
115 <ol className="mt-4 divide-y divide-line rounded-lg border border-line bg-surface">
116 {actions.map((entry) => {
117 const path = accountPath(entry.account);
118 const stripe = isStripe(entry);
119 return (
120 <li key={entry.id} className="flex flex-col gap-1.5 px-4 py-3 sm:flex-row sm:items-start sm:gap-4 sm:px-5">
121 <p className="shrink-0 text-xs whitespace-nowrap text-faint sm:w-44 sm:pt-0.5">
122 <When at={entry.createdAt} time />
123 </p>
124 <div className="min-w-0 grow">
125 <p className="flex flex-wrap items-center gap-x-2 text-sm">
126 <span className={`font-medium ${stripe ? "text-info" : "text-merged"}`}>{actionLabel(entry.action)}</span>
127 {path && (
128 <Link to={path} className="inline-flex items-center gap-0.5 text-fg-soft hover:text-fg hover:underline hover:underline-offset-4">
129 <span className={entry.account.startsWith("ws_") ? "font-mono text-xs" : ""}>{accountName(entry.account, nameMap)}</span>
130 <ChevronRight size={12} className="text-faint" />
131 </Link>
132 )}
133 </p>
134 {entry.detail && <p className="mt-0.5 text-sm break-words text-muted">{entry.detail}</p>}
135 </div>
136 <p className="flex shrink-0 items-center gap-1 text-xs text-faint sm:w-56 sm:justify-end sm:pt-0.5">
137 {stripe && entry.by === "stripe" ? (
138 "Stripe"
139 ) : (
140 <Link to={auditHref({ by: entry.by, action })} title={`Every change by ${entry.by}`} className="inline-flex min-w-0 items-center gap-1 hover:text-fg">
141 <UserRound size={11} className="shrink-0" />
142 <span className="truncate font-mono">{entry.by.toLowerCase() === me.toLowerCase() ? "you" : entry.by}</span>
143 </Link>
144 )}
145 </p>
146 </li>
147 );
148 })}
149 </ol>
150 )}
151
152 {(older || actions.length > 0) && !error && (
153 <nav aria-label="Pages" className="mt-4 flex items-center justify-between gap-3 text-sm">
154 <span className="text-xs text-faint">
155 {actions.length} change{actions.length === 1 ? "" : "s"}
156 {older ? `, ${AUDIT_PAGE} a page` : ""}
157 </span>
158 {older && (
159 <Link
160 to={auditHref({ by, action, before: older })}
161 className="inline-flex items-center gap-1 rounded-md border border-line px-3 py-1.5 text-muted hover:border-line-strong hover:text-fg"
162 >
163 Older
164 <ChevronRight size={14} />
165 </Link>
166 )}
167 </nav>
168 )}
169 </main>
170 );
171}