Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

audit.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

audit.tsxBlame234 lines · 1 contributor
6fc53bdClaude1/**
2 * Audit log UI — personal audit (who has done what with *my* account) and
3 * per-repo audit (who has done what in *my* repo). Reads the `audit_log`
4 * table written by `src/lib/notify.ts#audit()`.
5 */
6
7import { Hono } from "hono";
8import { desc, eq, and } from "drizzle-orm";
9import { db } from "../db";
10import { auditLog, repositories, users } from "../db/schema";
11import type { AuthEnv } from "../middleware/auth";
12import { requireAuth } from "../middleware/auth";
13import { Layout } from "../views/layout";
14
15const audit = new Hono<AuthEnv>();
16
17audit.use("/settings/audit", requireAuth);
18audit.use("/:owner/:repo/settings/audit", requireAuth);
19
20const LIMIT = 200;
21
22type AuditRow = {
23 id: string;
24 action: string;
25 targetType: string | null;
26 targetId: string | null;
27 ip: string | null;
28 userAgent: string | null;
29 metadata: string | null;
30 createdAt: Date;
31 actor: string | null;
32};
33
34function renderMetadata(raw: string | null): string {
35 if (!raw) return "";
36 try {
37 const parsed = JSON.parse(raw);
38 return JSON.stringify(parsed);
39 } catch {
40 return raw;
41 }
42}
43
44function timeAgo(d: Date): string {
45 const ms = Date.now() - d.getTime();
46 const s = Math.floor(ms / 1000);
47 if (s < 60) return `${s}s ago`;
48 const m = Math.floor(s / 60);
49 if (m < 60) return `${m}m ago`;
50 const h = Math.floor(m / 60);
51 if (h < 24) return `${h}h ago`;
52 const days = Math.floor(h / 24);
53 if (days < 30) return `${days}d ago`;
54 return d.toISOString().slice(0, 10);
55}
56
57function AuditTable({ rows }: { rows: AuditRow[] }) {
58 if (rows.length === 0) {
59 return (
60 <div class="empty-state">
61 <h2>No audit events yet</h2>
62 <p>Sensitive actions will appear here as they happen.</p>
63 </div>
64 );
65 }
66 return (
67 <div class="audit-log">
68 <table class="audit-table">
69 <thead>
70 <tr>
71 <th>When</th>
72 <th>Actor</th>
73 <th>Action</th>
74 <th>Target</th>
75 <th>IP</th>
76 <th>Details</th>
77 </tr>
78 </thead>
79 <tbody>
80 {rows.map((r) => (
81 <tr>
82 <td class="audit-when" title={r.createdAt.toISOString()}>
83 {timeAgo(new Date(r.createdAt))}
84 </td>
85 <td>{r.actor || <span class="audit-muted">system</span>}</td>
86 <td>
87 <code class="audit-action">{r.action}</code>
88 </td>
89 <td class="audit-target">
90 {r.targetType ? (
91 <span>
92 {r.targetType}
93 {r.targetId ? <code> {r.targetId.slice(0, 8)}</code> : null}
94 </span>
95 ) : (
96 <span class="audit-muted">—</span>
97 )}
98 </td>
99 <td>
100 <code class="audit-ip">{r.ip || "—"}</code>
101 </td>
102 <td class="audit-meta">
103 {r.metadata ? (
104 <code title={r.metadata}>{renderMetadata(r.metadata).slice(0, 80)}</code>
105 ) : (
106 <span class="audit-muted">—</span>
107 )}
108 </td>
109 </tr>
110 ))}
111 </tbody>
112 </table>
113 </div>
114 );
115}
116
117// Personal audit — events where userId = current user.
118audit.get("/settings/audit", async (c) => {
119 const user = c.get("user")!;
120 let rows: AuditRow[] = [];
121 try {
122 const raw = await db
123 .select({
124 id: auditLog.id,
125 action: auditLog.action,
126 targetType: auditLog.targetType,
127 targetId: auditLog.targetId,
128 ip: auditLog.ip,
129 userAgent: auditLog.userAgent,
130 metadata: auditLog.metadata,
131 createdAt: auditLog.createdAt,
132 actor: users.username,
133 })
134 .from(auditLog)
135 .leftJoin(users, eq(users.id, auditLog.userId))
136 .where(eq(auditLog.userId, user.id))
137 .orderBy(desc(auditLog.createdAt))
138 .limit(LIMIT);
139 rows = raw as AuditRow[];
140 } catch (err) {
141 console.error("[audit] personal:", err);
142 }
143
144 return c.html(
145 <Layout title="Audit log" user={user}>
146 <div class="settings-container" style="max-width: 1000px">
147 <h2>Audit log</h2>
148 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
149 The most recent {LIMIT} sensitive actions tied to your account — logins,
150 token activity, merges, deploys, branch protection changes.
151 </p>
152 <AuditTable rows={rows} />
153 </div>
154 </Layout>
155 );
156});
157
158// Per-repo audit — events with repositoryId = this repo. Owner-only.
159audit.get("/:owner/:repo/settings/audit", async (c) => {
160 const user = c.get("user")!;
161 const { owner, repo } = c.req.param();
162
163 let repoRow: { id: string; ownerId: string; name: string } | null = null;
164 try {
165 const [r] = await db
166 .select({ id: repositories.id, ownerId: repositories.ownerId, name: repositories.name })
167 .from(repositories)
168 .innerJoin(users, eq(users.id, repositories.ownerId))
169 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
170 .limit(1);
171 repoRow = (r as any) || null;
172 } catch (err) {
173 console.error("[audit] repo lookup:", err);
174 }
175 if (!repoRow) return c.notFound();
176 if (repoRow.ownerId !== user.id) {
177 return c.html(
178 <Layout title="Audit log" user={user}>
179 <div class="empty-state">
180 <h2>Forbidden</h2>
181 <p>Only the repository owner can view the audit log.</p>
182 </div>
183 </Layout>,
184 403
185 );
186 }
187
188 let rows: AuditRow[] = [];
189 try {
190 const raw = await db
191 .select({
192 id: auditLog.id,
193 action: auditLog.action,
194 targetType: auditLog.targetType,
195 targetId: auditLog.targetId,
196 ip: auditLog.ip,
197 userAgent: auditLog.userAgent,
198 metadata: auditLog.metadata,
199 createdAt: auditLog.createdAt,
200 actor: users.username,
201 })
202 .from(auditLog)
203 .leftJoin(users, eq(users.id, auditLog.userId))
204 .where(eq(auditLog.repositoryId, repoRow.id))
205 .orderBy(desc(auditLog.createdAt))
206 .limit(LIMIT);
207 rows = raw as AuditRow[];
208 } catch (err) {
209 console.error("[audit] repo:", err);
210 }
211
212 return c.html(
213 <Layout title={`${owner}/${repo} — audit`} user={user}>
214 <div class="settings-container" style="max-width: 1000px">
215 <div class="breadcrumb">
216 <a href={`/${owner}/${repo}`}>
217 {owner}/{repo}
218 </a>
219 <span>/</span>
220 <a href={`/${owner}/${repo}/settings`}>settings</a>
221 <span>/</span>
222 <span>audit</span>
223 </div>
224 <h2>Audit log</h2>
225 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
226 Who did what in <code>{owner}/{repo}</code> — most recent {LIMIT} events.
227 </p>
228 <AuditTable rows={rows} />
229 </div>
230 </Layout>
231 );
232});
233
234export default audit;