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.
| 6fc53bd | 1 | /** |
| 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()`. | |
| 4db6242 | 5 | * |
| 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. | |
| 6fc53bd | 19 | */ |
| 4db6242 | 20 | /* eslint-disable @typescript-eslint/no-explicit-any */ |
| 6fc53bd | 21 | |
| 22 | import { Hono } from "hono"; | |
| 23 | import { desc, eq, and } from "drizzle-orm"; | |
| 24 | import { db } from "../db"; | |
| 25 | import { auditLog, repositories, users } from "../db/schema"; | |
| 26 | import type { AuthEnv } from "../middleware/auth"; | |
| 27 | import { requireAuth } from "../middleware/auth"; | |
| 28 | import { Layout } from "../views/layout"; | |
| 29 | ||
| 30 | const audit = new Hono<AuthEnv>(); | |
| 31 | ||
| 32 | audit.use("/settings/audit", requireAuth); | |
| 33 | audit.use("/:owner/:repo/settings/audit", requireAuth); | |
| 34 | ||
| 35 | const LIMIT = 200; | |
| 36 | ||
| 37 | type 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 | ||
| 4db6242 | 49 | function 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 | ||
| 59 | function compactMetadata(raw: string | null): string { | |
| 6fc53bd | 60 | if (!raw) return ""; |
| 61 | try { | |
| 62 | const parsed = JSON.parse(raw); | |
| 63 | return JSON.stringify(parsed); | |
| 64 | } catch { | |
| 65 | return raw; | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | function 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 | ||
| 4db6242 | 82 | /** |
| 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 | */ | |
| 86 | function 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 | ||
| 93 | function 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 | ||
| 102 | function 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 | ||
| 110 | function 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 | ||
| 195 | function AuditList({ rows }: { rows: AuditRow[] }) { | |
| 6fc53bd | 196 | if (rows.length === 0) { |
| 197 | return ( | |
| 4db6242 | 198 | <div class="audit-empty"> |
| 6fc53bd | 199 | <h2>No audit events yet</h2> |
| 200 | <p>Sensitive actions will appear here as they happen.</p> | |
| 201 | </div> | |
| 202 | ); | |
| 203 | } | |
| 204 | return ( | |
| 4db6242 | 205 | <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))} | |
| 6fc53bd | 247 | </span> |
| 4db6242 | 248 | </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> | |
| 6fc53bd | 284 | )} |
| 4db6242 | 285 | </div> |
| 286 | </li> | |
| 287 | ); | |
| 288 | })} | |
| 289 | </ol> | |
| 6fc53bd | 290 | </div> |
| 291 | ); | |
| 292 | } | |
| 293 | ||
| 4db6242 | 294 | // --------------------------------------------------------------------------- |
| 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 | ||
| 299 | const 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 | ||
| 697 | const 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 | ||
| 755 | function 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> | |
| c6018a5 | 792 | <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> | |
| 4db6242 | 798 | </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 | ||
| 6fc53bd | 809 | // Personal audit — events where userId = current user. |
| 810 | audit.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( | |
| 4db6242 | 837 | <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 | /> | |
| 6fc53bd | 849 | ); |
| 850 | }); | |
| 851 | ||
| 852 | // Per-repo audit — events with repositoryId = this repo. Owner-only. | |
| 853 | audit.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( | |
| 4db6242 | 907 | <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 | <> | |
| 6fc53bd | 922 | <a href={`/${owner}/${repo}`}> |
| 923 | {owner}/{repo} | |
| 924 | </a> | |
| 4db6242 | 925 | <span class="audit-breadcrumb-sep">/</span> |
| 6fc53bd | 926 | <a href={`/${owner}/${repo}/settings`}>settings</a> |
| 4db6242 | 927 | <span class="audit-breadcrumb-sep">/</span> |
| 6fc53bd | 928 | <span>audit</span> |
| 4db6242 | 929 | </> |
| 930 | } | |
| 931 | rows={rows} | |
| 932 | /> | |
| 6fc53bd | 933 | ); |
| 934 | }); | |
| 935 | ||
| 936 | export default audit; |