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