Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame956 lines · 2 contributors
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()`.
4db6242Claude5 *
6 * Visual recipe (2026 polish — mirrors admin-integrations / admin-ops /
7 * admin-deploys-page):
8 * - Gradient hairline strip across the top of the hero (purple→cyan, 2px)
9 * - Soft radial orb in the corner of the hero
10 * - Eyebrow with pill icon + actor name
11 * - Display headline with gradient-text on the verb ("Trail.")
12 * - Searchable filter pills (action / actor / target)
13 * - Each row is a card with avatar + action verb + target + timestamp
14 * - IDs in monospace, expand-on-click for full metadata JSON
15 *
16 * Scoped CSS — every class prefixed `.audit-page-` so this surface can't
17 * bleed into other admin pages. The legacy `.audit-table` global class
18 * (still used by deployments.tsx) is left untouched.
6fc53bdClaude19 */
4db6242Claude20/* eslint-disable @typescript-eslint/no-explicit-any */
6fc53bdClaude21
22import { Hono } from "hono";
23import { desc, eq, and } from "drizzle-orm";
24import { db } from "../db";
25import { auditLog, repositories, users } from "../db/schema";
26import type { AuthEnv } from "../middleware/auth";
27import { requireAuth } from "../middleware/auth";
28import { Layout } from "../views/layout";
9b776daccantynz-alt29import { SettingsNav, settingsNavStyles } from "../views/components";
6fc53bdClaude30
31const audit = new Hono<AuthEnv>();
32
33audit.use("/settings/audit", requireAuth);
34audit.use("/:owner/:repo/settings/audit", requireAuth);
35
36const LIMIT = 200;
37
38type AuditRow = {
39 id: string;
40 action: string;
41 targetType: string | null;
42 targetId: string | null;
43 ip: string | null;
44 userAgent: string | null;
45 metadata: string | null;
46 createdAt: Date;
47 actor: string | null;
48};
49
4db6242Claude50function prettyMetadata(raw: string | null): string {
51 if (!raw) return "";
52 try {
53 const parsed = JSON.parse(raw);
54 return JSON.stringify(parsed, null, 2);
55 } catch {
56 return raw;
57 }
58}
59
60function compactMetadata(raw: string | null): string {
6fc53bdClaude61 if (!raw) return "";
62 try {
63 const parsed = JSON.parse(raw);
64 return JSON.stringify(parsed);
65 } catch {
66 return raw;
67 }
68}
69
70function timeAgo(d: Date): string {
71 const ms = Date.now() - d.getTime();
72 const s = Math.floor(ms / 1000);
73 if (s < 60) return `${s}s ago`;
74 const m = Math.floor(s / 60);
75 if (m < 60) return `${m}m ago`;
76 const h = Math.floor(m / 60);
77 if (h < 24) return `${h}h ago`;
78 const days = Math.floor(h / 24);
79 if (days < 30) return `${days}d ago`;
80 return d.toISOString().slice(0, 10);
81}
82
4db6242Claude83/**
84 * Deterministic hex colour for a username — same name maps to the same
85 * orb tint so the operator can scan the feed by actor at a glance.
86 */
87function actorHue(name: string | null): number {
88 const v = name || "system";
89 let h = 0;
90 for (let i = 0; i < v.length; i++) h = (h * 31 + v.charCodeAt(i)) % 360;
91 return h;
92}
93
94function initials(name: string | null): string {
95 const n = (name || "?").trim();
96 if (!n) return "?";
97 const parts = n.split(/[_\-.\s]+/).filter(Boolean);
98 if (parts.length === 0) return n.slice(0, 2).toUpperCase();
99 if (parts.length === 1) return n.slice(0, 2).toUpperCase();
100 return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
101}
102
103function IconShield() {
104 return (
105 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
106 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
107 </svg>
108 );
109}
110
111function FilterPills({ rows }: { rows: AuditRow[] }) {
112 // Build distinct action / actor / target-type sets for the filter pills.
113 // We render them as data-filter buttons; the inline JS toggles them and
114 // hides non-matching rows.
115 const actions = Array.from(new Set(rows.map((r) => r.action))).sort();
116 const actors = Array.from(
117 new Set(rows.map((r) => r.actor || "system"))
118 ).sort();
119 const targets = Array.from(
120 new Set(
121 rows
122 .map((r) => r.targetType)
123 .filter((t): t is string => typeof t === "string" && t.length > 0)
124 )
125 ).sort();
126
127 if (actions.length === 0 && actors.length === 0 && targets.length === 0) {
128 return null;
129 }
130
131 return (
132 <div class="audit-filters" data-audit-filters>
133 <div class="audit-filter-search">
134 <input
135 type="search"
136 placeholder="Filter by action, actor, target, ID…"
137 aria-label="Filter audit events"
138 data-audit-search
139 />
140 </div>
141 {actions.length > 0 && (
142 <div class="audit-filter-group" data-audit-group="action">
143 <span class="audit-filter-label">Action</span>
144 <div class="audit-filter-pills">
145 {actions.slice(0, 12).map((a) => (
146 <button
147 type="button"
148 class="audit-filter-pill"
149 data-audit-filter="action"
150 data-audit-value={a}
151 >
152 <code>{a}</code>
153 </button>
154 ))}
155 </div>
156 </div>
157 )}
158 {actors.length > 0 && (
159 <div class="audit-filter-group" data-audit-group="actor">
160 <span class="audit-filter-label">Actor</span>
161 <div class="audit-filter-pills">
162 {actors.slice(0, 12).map((a) => (
163 <button
164 type="button"
165 class="audit-filter-pill"
166 data-audit-filter="actor"
167 data-audit-value={a}
168 >
169 @{a}
170 </button>
171 ))}
172 </div>
173 </div>
174 )}
175 {targets.length > 0 && (
176 <div class="audit-filter-group" data-audit-group="target">
177 <span class="audit-filter-label">Target</span>
178 <div class="audit-filter-pills">
179 {targets.slice(0, 12).map((a) => (
180 <button
181 type="button"
182 class="audit-filter-pill"
183 data-audit-filter="target"
184 data-audit-value={a}
185 >
186 {a}
187 </button>
188 ))}
189 </div>
190 </div>
191 )}
192 </div>
193 );
194}
195
196function AuditList({ rows }: { rows: AuditRow[] }) {
6fc53bdClaude197 if (rows.length === 0) {
198 return (
4db6242Claude199 <div class="audit-empty">
6fc53bdClaude200 <h2>No audit events yet</h2>
201 <p>Sensitive actions will appear here as they happen.</p>
202 </div>
203 );
204 }
205 return (
4db6242Claude206 <div>
207 <FilterPills rows={rows} />
208 <div class="audit-count" data-audit-count>
209 Showing <strong data-audit-visible>{rows.length}</strong> of {rows.length} events
210 </div>
211 <ol class="audit-feed" aria-label="Audit events">
212 {rows.map((r) => {
213 const actor = r.actor || "system";
214 const hue = actorHue(actor);
215 const metaJson = prettyMetadata(r.metadata);
216 const metaCompact = compactMetadata(r.metadata);
217 const haystack = [
218 r.action,
219 actor,
220 r.targetType || "",
221 r.targetId || "",
222 r.ip || "",
223 metaCompact,
224 ]
225 .join(" ")
226 .toLowerCase();
227 return (
228 <li
229 class="audit-card"
230 data-audit-row
231 data-audit-action={r.action}
232 data-audit-actor={actor}
233 data-audit-target={r.targetType || ""}
234 data-audit-haystack={haystack}
235 >
236 <div class="audit-card-head">
237 <span
238 class="audit-avatar"
239 aria-hidden="true"
240 style={`background:hsl(${hue} 65% 18%);color:hsl(${hue} 75% 78%);box-shadow:inset 0 0 0 1px hsl(${hue} 70% 40% / 0.55)`}
241 >
242 {initials(actor)}
243 </span>
244 <div class="audit-card-actor">
245 <span class="audit-card-name">{actor}</span>
246 <span class="audit-card-when" title={r.createdAt.toISOString()}>
247 {timeAgo(new Date(r.createdAt))}
6fc53bdClaude248 </span>
4db6242Claude249 </div>
250 <span class="audit-card-spacer" />
251 <code class="audit-card-action">{r.action}</code>
252 </div>
253 <div class="audit-card-body">
254 <div class="audit-card-target">
255 {r.targetType ? (
256 <>
257 <span class="audit-card-key">Target</span>
258 <span class="audit-card-val">{r.targetType}</span>
259 {r.targetId && (
260 <code class="audit-card-id" title={r.targetId}>
261 {r.targetId.slice(0, 8)}
262 </code>
263 )}
264 </>
265 ) : (
266 <span class="audit-card-muted">no target</span>
267 )}
268 </div>
269 <div class="audit-card-ip">
270 <span class="audit-card-key">IP</span>
271 <code class="audit-card-val">{r.ip || "—"}</code>
272 </div>
273 {r.metadata && (
274 <details class="audit-card-meta">
275 <summary>
276 <span class="audit-card-key">Metadata</span>
277 <code class="audit-card-meta-preview">
278 {metaCompact.slice(0, 80)}
279 {metaCompact.length > 80 ? "…" : ""}
280 </code>
281 <span class="audit-card-meta-chev" aria-hidden="true">▾</span>
282 </summary>
283 <pre class="audit-card-meta-pre">{metaJson}</pre>
284 </details>
6fc53bdClaude285 )}
4db6242Claude286 </div>
287 </li>
288 );
289 })}
290 </ol>
6fc53bdClaude291 </div>
292 );
293}
294
4db6242Claude295// ---------------------------------------------------------------------------
296// Scoped CSS — `.audit-page-*` for the wrap so we don't collide with the
297// legacy `.audit-table` class still used by deployments.tsx.
298// ---------------------------------------------------------------------------
299
300const AUDIT_CSS = `
301 .audit-wrap {
302 max-width: 1100px;
303 margin: 0 auto;
304 padding: var(--space-6) var(--space-4) var(--space-12);
305 }
306
307 /* ─── Hero ─── */
308 .audit-hero {
309 position: relative;
310 margin-bottom: var(--space-5);
311 padding: clamp(28px, 4vw, 44px) clamp(24px, 4vw, 44px);
312 background: var(--bg-elevated);
313 border: 1px solid var(--border);
314 border-radius: 18px;
315 overflow: hidden;
316 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 18px 44px -16px rgba(0,0,0,0.42);
317 }
318 .audit-hero::before {
319 content: '';
320 position: absolute;
321 top: 0; left: 0; right: 0;
322 height: 2px;
6fd5915Claude323 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
4db6242Claude324 opacity: 0.75;
325 pointer-events: none;
326 }
327 .audit-hero-orb {
328 position: absolute;
329 inset: -30% -10% auto auto;
330 width: 460px; height: 460px;
6fd5915Claude331 background: radial-gradient(circle, rgba(91,110,232,0.22), rgba(95,143,160,0.10) 45%, transparent 70%);
4db6242Claude332 filter: blur(80px);
333 opacity: 0.7;
334 pointer-events: none;
335 z-index: 0;
336 }
337 .audit-hero-inner { position: relative; z-index: 1; max-width: 720px; }
338 .audit-eyebrow {
339 display: inline-flex;
340 align-items: center;
341 gap: 8px;
342 font-size: 12px;
343 color: var(--text-muted);
344 margin-bottom: 14px;
345 letter-spacing: 0.02em;
346 }
347 .audit-eyebrow-pill {
348 display: inline-flex;
349 align-items: center;
350 justify-content: center;
351 width: 18px; height: 18px;
352 border-radius: 6px;
6fd5915Claude353 background: rgba(91,110,232,0.14);
354 color: #5b6ee8;
355 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
4db6242Claude356 }
357 .audit-eyebrow-who { color: var(--accent); font-weight: 600; }
358 .audit-title {
359 font-family: var(--font-display);
360 font-size: clamp(28px, 4vw, 40px);
361 font-weight: 800;
362 letter-spacing: -0.028em;
363 line-height: 1.05;
364 margin: 0 0 var(--space-2);
365 color: var(--text-strong);
366 }
367 .audit-title-grad {
6fd5915Claude368 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
4db6242Claude369 -webkit-background-clip: text;
370 background-clip: text;
371 -webkit-text-fill-color: transparent;
372 color: transparent;
373 }
374 .audit-sub {
375 font-size: 14.5px;
376 color: var(--text-muted);
377 margin: 0;
378 line-height: 1.55;
379 max-width: 620px;
380 }
381 .audit-sub code {
382 font-family: var(--font-mono);
383 font-size: 12.5px;
384 background: rgba(255,255,255,0.04);
385 border: 1px solid var(--border);
386 padding: 1px 6px;
387 border-radius: 5px;
388 color: var(--text);
389 }
390
391 .audit-breadcrumb {
392 font-family: var(--font-mono);
393 font-size: 11.5px;
394 text-transform: uppercase;
395 letter-spacing: 0.14em;
396 color: var(--text-muted);
397 margin-bottom: 10px;
398 display: flex;
399 gap: 6px;
400 align-items: center;
401 flex-wrap: wrap;
402 }
403 .audit-breadcrumb a {
404 color: var(--text-muted);
405 text-decoration: none;
406 }
407 .audit-breadcrumb a:hover { color: var(--text-strong); }
408 .audit-breadcrumb-sep { opacity: 0.5; }
409
410 /* ─── Filters ─── */
411 .audit-filters {
412 margin-bottom: var(--space-4);
413 padding: var(--space-4) var(--space-5);
414 background: var(--bg-elevated);
415 border: 1px solid var(--border);
416 border-radius: 14px;
417 display: flex;
418 flex-direction: column;
419 gap: var(--space-3);
420 }
421 .audit-filter-search input {
422 width: 100%;
423 padding: 10px 14px;
424 font-size: 13px;
425 color: var(--text);
426 background: rgba(255,255,255,0.025);
427 border: 1px solid var(--border-strong);
428 border-radius: 10px;
429 outline: none;
430 font-family: var(--font-mono);
431 transition: border-color 120ms ease, box-shadow 120ms ease;
432 box-sizing: border-box;
433 }
434 .audit-filter-search input:focus {
6fd5915Claude435 border-color: rgba(91,110,232,0.50);
436 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
4db6242Claude437 }
438 .audit-filter-group {
439 display: flex;
440 gap: 10px;
441 align-items: center;
442 flex-wrap: wrap;
443 }
444 .audit-filter-label {
445 font-family: var(--font-mono);
446 font-size: 10.5px;
447 text-transform: uppercase;
448 letter-spacing: 0.14em;
449 color: var(--text-muted);
450 font-weight: 700;
451 width: 60px;
452 flex-shrink: 0;
453 }
454 .audit-filter-pills {
455 display: flex;
456 gap: 6px;
457 flex-wrap: wrap;
458 flex: 1;
459 }
460 .audit-filter-pill {
461 display: inline-flex;
462 align-items: center;
463 gap: 4px;
464 padding: 4px 10px;
465 border-radius: 9999px;
466 background: rgba(255,255,255,0.04);
467 border: 1px solid var(--border);
468 color: var(--text);
469 font-size: 11.5px;
470 cursor: pointer;
471 font: inherit;
472 transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
473 }
474 .audit-filter-pill code {
475 font-family: var(--font-mono);
476 font-size: 11px;
477 color: inherit;
478 background: transparent;
479 padding: 0;
480 }
481 .audit-filter-pill:hover {
6fd5915Claude482 background: rgba(91,110,232,0.10);
483 border-color: rgba(91,110,232,0.40);
4db6242Claude484 color: var(--text-strong);
485 }
486 .audit-filter-pill.is-active {
6fd5915Claude487 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.14));
488 border-color: rgba(91,110,232,0.50);
4db6242Claude489 color: #e9d5ff;
6fd5915Claude490 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
4db6242Claude491 }
492 .audit-count {
493 margin-bottom: 12px;
494 font-size: 12px;
495 color: var(--text-muted);
496 font-family: var(--font-mono);
497 }
498 .audit-count strong { color: var(--text-strong); font-weight: 700; }
499
500 /* ─── Feed cards ─── */
501 .audit-feed {
502 list-style: none;
503 padding: 0;
504 margin: 0;
505 display: flex;
506 flex-direction: column;
507 gap: 8px;
508 }
509 .audit-card {
510 background: var(--bg-elevated);
511 border: 1px solid var(--border);
512 border-radius: 12px;
513 padding: 12px 14px;
514 transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
515 }
516 .audit-card:hover {
517 border-color: var(--border-strong);
518 transform: translateY(-1px);
519 box-shadow: 0 8px 22px -16px rgba(0,0,0,0.5);
520 }
521 .audit-card[hidden] { display: none; }
522 .audit-card-head {
523 display: flex;
524 align-items: center;
525 gap: 12px;
526 flex-wrap: wrap;
527 }
528 .audit-avatar {
529 display: inline-flex;
530 align-items: center;
531 justify-content: center;
532 width: 32px; height: 32px;
533 border-radius: 9999px;
534 font-family: var(--font-mono);
535 font-size: 11px;
536 font-weight: 700;
537 letter-spacing: 0.04em;
538 flex-shrink: 0;
539 }
540 .audit-card-actor {
541 display: flex;
542 flex-direction: column;
543 gap: 2px;
544 min-width: 0;
545 }
546 .audit-card-name {
547 font-size: 13px;
548 font-weight: 600;
549 color: var(--text-strong);
550 }
551 .audit-card-when {
552 font-family: var(--font-mono);
553 font-size: 11.5px;
554 color: var(--text-muted);
555 font-variant-numeric: tabular-nums;
556 }
557 .audit-card-spacer { flex: 1; }
558 .audit-card-action {
559 font-family: var(--font-mono);
560 font-size: 12px;
561 color: var(--text-strong);
6fd5915Claude562 background: linear-gradient(135deg, rgba(91,110,232,0.10), rgba(95,143,160,0.06));
563 border: 1px solid rgba(91,110,232,0.22);
4db6242Claude564 padding: 3px 9px;
565 border-radius: 9999px;
566 }
567 .audit-card-body {
568 margin-top: 10px;
569 padding-top: 10px;
570 border-top: 1px solid var(--border);
571 display: grid;
572 grid-template-columns: 1fr 1fr;
573 gap: 8px 16px;
574 font-size: 12.5px;
575 }
576 .audit-card-target,
577 .audit-card-ip {
578 display: flex;
579 align-items: center;
580 gap: 8px;
581 min-width: 0;
582 }
583 .audit-card-key {
584 font-family: var(--font-mono);
585 font-size: 10.5px;
586 text-transform: uppercase;
587 letter-spacing: 0.14em;
588 color: var(--text-muted);
589 font-weight: 700;
590 flex-shrink: 0;
591 }
592 .audit-card-val {
593 color: var(--text);
594 font-size: 12.5px;
595 }
596 .audit-card-ip .audit-card-val {
597 font-family: var(--font-mono);
598 font-size: 11.5px;
599 color: var(--text);
600 background: rgba(255,255,255,0.04);
601 border: 1px solid var(--border);
602 padding: 1px 6px;
603 border-radius: 4px;
604 }
605 .audit-card-id {
606 font-family: var(--font-mono);
607 font-size: 11.5px;
608 color: var(--text-strong);
609 background: rgba(255,255,255,0.04);
610 border: 1px solid var(--border);
611 padding: 1px 6px;
612 border-radius: 4px;
613 }
614 .audit-card-muted {
615 color: var(--text-muted);
616 font-style: italic;
617 font-size: 12px;
618 }
619 .audit-card-meta {
620 grid-column: 1 / -1;
621 }
622 .audit-card-meta summary {
623 display: flex;
624 align-items: center;
625 gap: 10px;
626 cursor: pointer;
627 padding: 8px 10px;
628 border-radius: 8px;
629 background: rgba(255,255,255,0.025);
630 border: 1px solid var(--border);
631 list-style: none;
632 transition: background 120ms ease, border-color 120ms ease;
633 }
634 .audit-card-meta summary::-webkit-details-marker { display: none; }
635 .audit-card-meta summary:hover {
6fd5915Claude636 background: rgba(91,110,232,0.06);
637 border-color: rgba(91,110,232,0.32);
4db6242Claude638 }
639 .audit-card-meta-preview {
640 flex: 1;
641 font-family: var(--font-mono);
642 font-size: 11.5px;
643 color: var(--text-muted);
644 background: transparent;
645 padding: 0;
646 min-width: 0;
647 overflow: hidden;
648 text-overflow: ellipsis;
649 white-space: nowrap;
650 }
651 .audit-card-meta-chev {
652 color: var(--text-muted);
653 transition: transform 120ms ease;
654 font-size: 11px;
655 }
656 .audit-card-meta[open] .audit-card-meta-chev { transform: rotate(180deg); }
657 .audit-card-meta-pre {
658 margin: 8px 0 0;
659 padding: 12px 14px;
660 background: rgba(0,0,0,0.32);
661 border: 1px solid var(--border);
662 border-radius: 8px;
663 font-family: var(--font-mono);
664 font-size: 11.5px;
665 color: var(--text);
666 line-height: 1.55;
667 overflow-x: auto;
668 white-space: pre;
669 }
670
671 /* ─── Empty state ─── */
672 .audit-empty {
673 padding: clamp(36px, 6vw, 56px) clamp(24px, 4vw, 44px);
674 border: 1px dashed var(--border-strong, var(--border));
675 border-radius: 16px;
676 background: rgba(255,255,255,0.015);
677 text-align: center;
678 }
679 .audit-empty h2 {
680 font-family: var(--font-display);
681 font-size: 20px;
682 margin: 0 0 6px;
683 color: var(--text-strong);
684 }
685 .audit-empty p {
686 margin: 0;
687 color: var(--text-muted);
688 font-size: 13.5px;
689 }
690
691 @media (max-width: 640px) {
692 .audit-wrap { padding: var(--space-4) var(--space-3) var(--space-8); }
693 .audit-card-body { grid-template-columns: 1fr; }
694 .audit-filter-label { width: auto; }
695 }
696`;
697
698const AUDIT_JS = `
699(function(){
700 var root = document.querySelector('[data-audit-filters]');
701 var rows = Array.prototype.slice.call(document.querySelectorAll('[data-audit-row]'));
702 var visibleEl = document.querySelector('[data-audit-visible]');
703 if (rows.length === 0) return;
704
705 var search = root && root.querySelector('[data-audit-search]');
706 var pills = root ? Array.prototype.slice.call(root.querySelectorAll('[data-audit-filter]')) : [];
707
708 // active[group] = Set of selected values
709 var active = { action: new Set(), actor: new Set(), target: new Set() };
710
711 function apply() {
712 var q = (search && search.value || '').trim().toLowerCase();
713 var visible = 0;
714 rows.forEach(function(row){
715 var action = row.getAttribute('data-audit-action') || '';
716 var actor = row.getAttribute('data-audit-actor') || '';
717 var target = row.getAttribute('data-audit-target') || '';
718 var hay = row.getAttribute('data-audit-haystack') || '';
719 var ok = true;
720 if (active.action.size && !active.action.has(action)) ok = false;
721 if (ok && active.actor.size && !active.actor.has(actor)) ok = false;
722 if (ok && active.target.size && !active.target.has(target)) ok = false;
723 if (ok && q && hay.indexOf(q) === -1) ok = false;
724 row.hidden = !ok;
725 if (ok) visible++;
726 });
727 if (visibleEl) visibleEl.textContent = String(visible);
728 }
729
730 pills.forEach(function(btn){
731 btn.addEventListener('click', function(){
732 var group = btn.getAttribute('data-audit-filter');
733 var value = btn.getAttribute('data-audit-value');
734 if (!group || !value || !active[group]) return;
735 if (active[group].has(value)) {
736 active[group].delete(value);
737 btn.classList.remove('is-active');
738 } else {
739 active[group].add(value);
740 btn.classList.add('is-active');
741 }
742 apply();
743 });
744 });
745
746 if (search) {
747 var t = null;
748 search.addEventListener('input', function(){
749 if (t) clearTimeout(t);
750 t = setTimeout(apply, 80);
751 });
752 }
753})();
754`;
755
756function AuditPage({
757 user,
758 title,
759 subtitle,
760 eyebrowSuffix,
761 rows,
762 breadcrumb,
9b776daccantynz-alt763 showSettingsNav,
4db6242Claude764}: {
765 user: any;
766 title: string;
767 subtitle: any;
768 eyebrowSuffix: string;
769 rows: AuditRow[];
770 breadcrumb?: any;
9b776daccantynz-alt771 /** Set only by the personal /settings/audit call site — the repo-scoped
772 * /:owner/:repo/settings/audit variant reuses this same component and
773 * must NOT show the account-settings sidebar. */
774 showSettingsNav?: boolean;
4db6242Claude775}) {
9b776daccantynz-alt776 const body = (
777 <div class="audit-wrap">
778 <section class="audit-hero">
779 <div class="audit-hero-orb" aria-hidden="true" />
780 <div class="audit-hero-inner">
781 {breadcrumb && <div class="audit-breadcrumb">{breadcrumb}</div>}
782 <div class="audit-eyebrow">
783 <span class="audit-eyebrow-pill" aria-hidden="true">
784 <IconShield />
785 </span>
786 Audit log · <span class="audit-eyebrow-who">{user.username}</span>
787 {eyebrowSuffix && (
788 <>
789 {" "}· <span>{eyebrowSuffix}</span>
790 </>
791 )}
792 </div>
793 <h1 class="audit-title">
794 <span class="audit-title-grad">Trail.</span>
795 </h1>
796 <p class="audit-sub">{subtitle}</p>
797 <p class="audit-sub" style="margin-top: 8px; font-size: 13px;">
798 <strong>Also surfaces:</strong> findings from the AI proactive
799 monitor (<code>action=ai.monitor.*</code>) — stale TODOs, stuck
800 PRs, suspicious patterns it filed automatically. Filter by
801 actor <code>system</code> to see only the monitor's events.
802 </p>
803 </div>
804 </section>
805
806 <AuditList rows={rows} />
807 </div>
808 );
4db6242Claude809 return (
810 <Layout title={title} user={user}>
9b776daccantynz-alt811 {showSettingsNav ? (
812 <>
813 <style dangerouslySetInnerHTML={{ __html: settingsNavStyles }} />
814 <div class="settings-shell" style="max-width:1500px;margin:0 auto;padding:var(--space-4)">
815 <SettingsNav active="audit" />
816 <div class="settings-content">{body}</div>
4db6242Claude817 </div>
9b776daccantynz-alt818 </>
819 ) : (
820 body
821 )}
4db6242Claude822 <style dangerouslySetInnerHTML={{ __html: AUDIT_CSS }} />
823 <script dangerouslySetInnerHTML={{ __html: AUDIT_JS }} />
824 </Layout>
825 );
826}
827
6fc53bdClaude828// Personal audit — events where userId = current user.
829audit.get("/settings/audit", async (c) => {
830 const user = c.get("user")!;
831 let rows: AuditRow[] = [];
832 try {
833 const raw = await db
834 .select({
835 id: auditLog.id,
836 action: auditLog.action,
837 targetType: auditLog.targetType,
838 targetId: auditLog.targetId,
839 ip: auditLog.ip,
840 userAgent: auditLog.userAgent,
841 metadata: auditLog.metadata,
842 createdAt: auditLog.createdAt,
843 actor: users.username,
844 })
845 .from(auditLog)
846 .leftJoin(users, eq(users.id, auditLog.userId))
847 .where(eq(auditLog.userId, user.id))
848 .orderBy(desc(auditLog.createdAt))
849 .limit(LIMIT);
850 rows = raw as AuditRow[];
851 } catch (err) {
852 console.error("[audit] personal:", err);
853 }
854
855 return c.html(
4db6242Claude856 <AuditPage
857 user={user}
858 title="Audit log"
859 eyebrowSuffix="personal"
860 subtitle={
861 <>
862 The most recent {LIMIT} sensitive actions tied to your account —
863 logins, token activity, merges, deploys, branch protection changes.
864 </>
865 }
866 rows={rows}
9b776daccantynz-alt867 showSettingsNav
4db6242Claude868 />
6fc53bdClaude869 );
870});
871
872// Per-repo audit — events with repositoryId = this repo. Owner-only.
873audit.get("/:owner/:repo/settings/audit", async (c) => {
874 const user = c.get("user")!;
875 const { owner, repo } = c.req.param();
876
877 let repoRow: { id: string; ownerId: string; name: string } | null = null;
878 try {
879 const [r] = await db
880 .select({ id: repositories.id, ownerId: repositories.ownerId, name: repositories.name })
881 .from(repositories)
882 .innerJoin(users, eq(users.id, repositories.ownerId))
883 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
884 .limit(1);
885 repoRow = (r as any) || null;
886 } catch (err) {
887 console.error("[audit] repo lookup:", err);
888 }
889 if (!repoRow) return c.notFound();
890 if (repoRow.ownerId !== user.id) {
891 return c.html(
892 <Layout title="Audit log" user={user}>
893 <div class="empty-state">
894 <h2>Forbidden</h2>
895 <p>Only the repository owner can view the audit log.</p>
896 </div>
897 </Layout>,
898 403
899 );
900 }
901
902 let rows: AuditRow[] = [];
903 try {
904 const raw = await db
905 .select({
906 id: auditLog.id,
907 action: auditLog.action,
908 targetType: auditLog.targetType,
909 targetId: auditLog.targetId,
910 ip: auditLog.ip,
911 userAgent: auditLog.userAgent,
912 metadata: auditLog.metadata,
913 createdAt: auditLog.createdAt,
914 actor: users.username,
915 })
916 .from(auditLog)
917 .leftJoin(users, eq(users.id, auditLog.userId))
918 .where(eq(auditLog.repositoryId, repoRow.id))
919 .orderBy(desc(auditLog.createdAt))
920 .limit(LIMIT);
921 rows = raw as AuditRow[];
922 } catch (err) {
923 console.error("[audit] repo:", err);
924 }
925
926 return c.html(
4db6242Claude927 <AuditPage
928 user={user}
929 title={`${owner}/${repo} — audit`}
930 eyebrowSuffix={`repo ${owner}/${repo}`}
931 subtitle={
932 <>
933 Who did what in{" "}
934 <code>
935 {owner}/{repo}
936 </code>{" "}
937 — most recent {LIMIT} events.
938 </>
939 }
940 breadcrumb={
941 <>
6fc53bdClaude942 <a href={`/${owner}/${repo}`}>
943 {owner}/{repo}
944 </a>
4db6242Claude945 <span class="audit-breadcrumb-sep">/</span>
6fc53bdClaude946 <a href={`/${owner}/${repo}/settings`}>settings</a>
4db6242Claude947 <span class="audit-breadcrumb-sep">/</span>
6fc53bdClaude948 <span>audit</span>
4db6242Claude949 </>
950 }
951 rows={rows}
952 />
6fc53bdClaude953 );
954});
955
956export default audit;