audit-trail-client.tsx210 lines · main
| 1 | 'use client'; |
| 2 | |
| 3 | import { useCallback, useEffect, useState } from 'react'; |
| 4 | |
| 5 | type AuditRow = { |
| 6 | id: string; |
| 7 | tenantId: string; |
| 8 | projectId: string | null; |
| 9 | userId: string | null; |
| 10 | action: string; |
| 11 | ipHashHint: string | null; |
| 12 | userAgent: string | null; |
| 13 | metadata: Record<string, unknown>; |
| 14 | occurredAt: string; |
| 15 | }; |
| 16 | |
| 17 | /** Plain-language labels for operators (not engineers). */ |
| 18 | const ACTION_LABELS: Record<string, string> = { |
| 19 | 'signin.password': 'signed in with password', |
| 20 | 'signin.password.fail': 'password sign-in failed', |
| 21 | 'signup.password': 'created account with password', |
| 22 | 'signin.passwordless': 'signed in with one-time code', |
| 23 | 'signin.passwordless.code_created': 'one-time code sent', |
| 24 | 'signin.passwordless.fail': 'one-time code sign-in failed', |
| 25 | 'signin.social': 'signed in with social / OAuth', |
| 26 | 'signin.social.fail': 'social / OAuth sign-in failed', |
| 27 | 'signin.passkey': 'signed in with passkey', |
| 28 | 'signin.sso': 'signed in with SSO', |
| 29 | 'session.created': 'session started', |
| 30 | 'session.revoked': 'session ended / revoked', |
| 31 | 'mfa.totp.verified': 'authenticator code accepted', |
| 32 | 'mfa.totp.fail': 'authenticator code failed', |
| 33 | 'config.methods.updated': 'login methods changed', |
| 34 | 'config.sms_secrets.saved': 'SMS (Twilio) secrets saved', |
| 35 | 'config.oauth_secrets.saved': 'OAuth secrets saved', |
| 36 | 'config.branding.saved': 'branding saved', |
| 37 | 'm2m.client.created': 'machine client created', |
| 38 | 'm2m.client.revoked': 'machine client revoked', |
| 39 | 'm2m.token.issued': 'machine token issued', |
| 40 | 'm2m.token.fail': 'machine token request failed', |
| 41 | 'oidc.client.created': 'IdP app registered', |
| 42 | 'oidc.client.revoked': 'IdP app revoked', |
| 43 | 'oidc.consent.granted': 'user allowed an app', |
| 44 | 'oidc.consent.denied': 'user denied an app', |
| 45 | 'oidc.code.issued': 'login code issued to app', |
| 46 | 'oidc.token.issued': 'IdP tokens issued', |
| 47 | 'oidc.token.revoked': 'IdP token revoked', |
| 48 | }; |
| 49 | |
| 50 | function labelFor(action: string): string { |
| 51 | return ACTION_LABELS[action] ?? action.replace(/\./g, ' · '); |
| 52 | } |
| 53 | |
| 54 | function shortTime(iso: string): string { |
| 55 | try { |
| 56 | return new Date(iso).toLocaleString(); |
| 57 | } catch { |
| 58 | return iso; |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | function metaSummary(row: AuditRow): string | null { |
| 63 | const m = row.metadata ?? {}; |
| 64 | const parts: string[] = []; |
| 65 | if (typeof m.thirdPartyId === 'string') parts.push(m.thirdPartyId); |
| 66 | if (typeof m.fromNumber === 'string') parts.push(`from ${m.fromNumber}`); |
| 67 | if (typeof m.email === 'string') parts.push(m.email); |
| 68 | if (typeof m.reason === 'string') parts.push(m.reason); |
| 69 | if (parts.length === 0) return null; |
| 70 | return parts.join(' · '); |
| 71 | } |
| 72 | |
| 73 | /** |
| 74 | * Live security diary for one Auth project — sign-ins, fails, secret changes. |
| 75 | * Loads from briven-engine audit API (no raw IPs). |
| 76 | */ |
| 77 | export function AuthAuditTrailClient({ projectId }: { projectId: string }) { |
| 78 | const [items, setItems] = useState<AuditRow[]>([]); |
| 79 | const [loading, setLoading] = useState(true); |
| 80 | const [err, setErr] = useState<string | null>(null); |
| 81 | |
| 82 | const load = useCallback(async () => { |
| 83 | if (!projectId) return; |
| 84 | setLoading(true); |
| 85 | setErr(null); |
| 86 | const urls = [ |
| 87 | `/api/dashboard/auth-core/projects/${encodeURIComponent(projectId)}/audit?limit=50`, |
| 88 | `/api/v1/auth-core/projects/${encodeURIComponent(projectId)}/audit?limit=50`, |
| 89 | ]; |
| 90 | let lastStatus = 0; |
| 91 | let lastMessage = ''; |
| 92 | for (const url of urls) { |
| 93 | try { |
| 94 | const res = await fetch(url, { credentials: 'include', cache: 'no-store' }); |
| 95 | lastStatus = res.status; |
| 96 | if (res.status === 401) { |
| 97 | setErr('sign in to briven.tech to see the audit trail'); |
| 98 | setItems([]); |
| 99 | setLoading(false); |
| 100 | return; |
| 101 | } |
| 102 | if (!res.ok) { |
| 103 | lastMessage = (await res.text().catch(() => '')) || res.statusText; |
| 104 | continue; |
| 105 | } |
| 106 | const body = (await res.json()) as { items?: AuditRow[] }; |
| 107 | setItems(body.items ?? []); |
| 108 | setLoading(false); |
| 109 | return; |
| 110 | } catch (e) { |
| 111 | lastMessage = e instanceof Error ? e.message : String(e); |
| 112 | } |
| 113 | } |
| 114 | setErr( |
| 115 | lastMessage |
| 116 | ? `could not load audit (${lastStatus || '?'}) — ${lastMessage.slice(0, 120)}` |
| 117 | : 'could not load audit trail', |
| 118 | ); |
| 119 | setItems([]); |
| 120 | setLoading(false); |
| 121 | }, [projectId]); |
| 122 | |
| 123 | useEffect(() => { |
| 124 | void load(); |
| 125 | }, [load]); |
| 126 | |
| 127 | return ( |
| 128 | <div className="space-y-3"> |
| 129 | <div className="flex flex-wrap items-start justify-between gap-3"> |
| 130 | <div> |
| 131 | <h3 className="font-mono text-sm text-[var(--color-text)]"> |
| 132 | security diary |
| 133 | </h3> |
| 134 | <p className="mt-1 font-mono text-[11px] leading-relaxed text-[var(--color-text-muted)]"> |
| 135 | recent sign-ins, failed attempts, and config changes for this project. |
| 136 | we never store full IP addresses — only a short code for correlation. |
| 137 | </p> |
| 138 | </div> |
| 139 | <button |
| 140 | type="button" |
| 141 | onClick={() => void load()} |
| 142 | disabled={loading} |
| 143 | className="shrink-0 rounded-md border px-3 py-1.5 font-mono text-[11px] text-[var(--color-text)] disabled:opacity-50" |
| 144 | style={{ borderColor: 'var(--auth-accent-border, #FFFD74)' }} |
| 145 | > |
| 146 | {loading ? 'loading…' : 'refresh'} |
| 147 | </button> |
| 148 | </div> |
| 149 | |
| 150 | {err ? ( |
| 151 | <p className="font-mono text-xs text-[var(--color-text-muted)]">{err}</p> |
| 152 | ) : null} |
| 153 | |
| 154 | {!err && !loading && items.length === 0 ? ( |
| 155 | <p className="rounded border border-dashed border-[var(--color-border-subtle)] px-3 py-4 font-mono text-xs text-[var(--color-text-muted)]"> |
| 156 | no events yet — they appear when someone signs in, fails a login, or you |
| 157 | change providers / SMS / branding. |
| 158 | </p> |
| 159 | ) : null} |
| 160 | |
| 161 | {items.length > 0 ? ( |
| 162 | <ul className="divide-y divide-[var(--color-border-subtle)] rounded-md border border-[var(--color-border-subtle)]"> |
| 163 | {items.map((row) => { |
| 164 | const fail = row.action.includes('.fail'); |
| 165 | const config = row.action.startsWith('config.'); |
| 166 | const summary = metaSummary(row); |
| 167 | return ( |
| 168 | <li |
| 169 | key={row.id} |
| 170 | className="flex flex-col gap-0.5 px-3 py-2.5 font-mono text-[11px] sm:flex-row sm:items-baseline sm:justify-between sm:gap-4" |
| 171 | > |
| 172 | <div className="min-w-0"> |
| 173 | <p |
| 174 | className="text-[var(--color-text)]" |
| 175 | style={ |
| 176 | fail |
| 177 | ? { color: 'var(--color-error, #f87171)' } |
| 178 | : config |
| 179 | ? { color: 'var(--auth-accent, #FFFD74)' } |
| 180 | : undefined |
| 181 | } |
| 182 | > |
| 183 | {labelFor(row.action)} |
| 184 | </p> |
| 185 | <p className="mt-0.5 text-[var(--color-text-muted)]"> |
| 186 | {row.userId ? ( |
| 187 | <span title={row.userId}>user {row.userId.slice(0, 12)}…</span> |
| 188 | ) : ( |
| 189 | <span>no user</span> |
| 190 | )} |
| 191 | {row.ipHashHint ? ( |
| 192 | <span className="ml-2">· ip {row.ipHashHint}</span> |
| 193 | ) : null} |
| 194 | {summary ? <span className="ml-2">· {summary}</span> : null} |
| 195 | </p> |
| 196 | </div> |
| 197 | <time |
| 198 | className="shrink-0 text-[10px] text-[var(--color-text-muted)]" |
| 199 | dateTime={row.occurredAt} |
| 200 | > |
| 201 | {shortTime(row.occurredAt)} |
| 202 | </time> |
| 203 | </li> |
| 204 | ); |
| 205 | })} |
| 206 | </ul> |
| 207 | ) : null} |
| 208 | </div> |
| 209 | ); |
| 210 | } |