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

178 lines7,706 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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