diff --git a/AGENTS.md b/AGENTS.md index 569e597..294c1e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,12 @@ npx wrangler secret put ADMIN_SESSION_SECRET # recommended npx wrangler secret put ADMIN_PASSWORD ``` -API: `/api/admin/*` (see `worker/admin.ts`). OAuth start: `GET /api/admin/auth/google`. Not a public merch CMS yet. +API: `/api/admin/*` (see `worker/admin.ts`). OAuth start: `GET /api/admin/auth/google`. +Activity log: `GET /api/admin/audit` (auth required) — logins, denials, logouts, config saves in KV `audit_log` (ring buffer, last 500). + +**Google allowlist:** after Google returns a verified email, the Worker checks it against `ADMIN_ALLOWED_EMAILS` (comma-separated, case-insensitive). Not on the list → access denied + audit entry. + +Not a public merch CMS yet. ## Git / PR workflow diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index a7ddae2..50fe7d8 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -11,6 +11,7 @@ import { LayoutDashboard, LogOut, MessageSquareQuote, + ScrollText, Save, Sparkles, Users, @@ -23,6 +24,21 @@ type Tab = | 'conbal' | 'avatars' | 'editor' + | 'audit' + +type AuditEntry = { + id: string + at: string + action: 'login' | 'login_denied' | 'logout' | 'config_save' + actor: { + email?: string + name?: string + provider?: 'google' | 'password' + } + detail: string + meta?: Record + ip?: string +} type TargetingCategory = { id: string @@ -100,8 +116,39 @@ const TABS: { id: Tab; label: string; icon: typeof Database }[] = [ { id: 'conbal', label: 'Conbal', icon: MessageSquareQuote }, { id: 'avatars', label: 'Avatars', icon: Users }, { id: 'editor', label: 'Editor in Chief', icon: BookOpen }, + { id: 'audit', label: 'Activity log', icon: ScrollText }, ] +function actionLabel(action: AuditEntry['action']): string { + switch (action) { + case 'login': + return 'Login' + case 'login_denied': + return 'Login denied' + case 'logout': + return 'Logout' + case 'config_save': + return 'Config save' + default: + return action + } +} + +function actionClass(action: AuditEntry['action']): string { + switch (action) { + case 'login': + return 'text-bamboo' + case 'login_denied': + return 'text-red-300' + case 'logout': + return 'text-white/60' + case 'config_save': + return 'text-sky-300' + default: + return 'text-white/70' + } +} + type SessionUser = { provider: 'google' | 'password' email?: string @@ -123,6 +170,12 @@ export function Admin() { const [flash, setFlash] = useState | null>(null) const [library, setLibrary] = useState | null>(null) const [conbal, setConbal] = useState | null>(null) + const [audit, setAudit] = useState<{ + entries: AuditEntry[] + cap?: number + allowlistNote?: string + allowedEmails?: string[] + } | null>(null) const [busy, setBusy] = useState(false) const refreshSession = useCallback(async () => { @@ -252,11 +305,38 @@ export function Admin() { } } + async function loadAudit() { + setBusy(true) + try { + const res = await api<{ + entries: AuditEntry[] + cap?: number + allowlistNote?: string + allowedEmails?: string[] + }>('/api/admin/audit?limit=150') + setAudit({ + entries: res.entries || [], + cap: res.cap, + allowlistNote: res.allowlistNote, + allowedEmails: res.allowedEmails, + }) + } catch (err) { + setAudit({ + entries: [], + allowlistNote: + err instanceof Error ? err.message : 'Failed to load activity log', + }) + } finally { + setBusy(false) + } + } + useEffect(() => { if (!authed) return if (tab === 'flash' && !flash) void loadFlash() if (tab === 'library' && !library) void loadLibrary() if (tab === 'conbal' && !conbal) void loadConbal() + if (tab === 'audit') void loadAudit() }, [tab, authed]) // eslint-disable-line react-hooks/exhaustive-deps if (authed === null) { @@ -1129,6 +1209,112 @@ export function Admin() { )} + + {tab === 'audit' && ( +
+
+

+ Activity log +

+ +
+

+ Logins, denied attempts, logouts, and config saves. Stored in + admin KV (last {audit?.cap ?? 500} events). +

+ +
+

+ Who can sign in with Google? +

+

+ {audit?.allowlistNote || + 'Emails listed in Worker secret ADMIN_ALLOWED_EMAILS.'} +

+ {audit?.allowedEmails && audit.allowedEmails.length > 0 ? ( +
    + {audit.allowedEmails.map((e) => ( +
  • {e}
  • + ))} +
+ ) : ( +

+ No allowlisted emails configured. +

+ )} +
+ + {audit ? ( + audit.entries.length === 0 ? ( +

+ No events yet — log in or save config to start the log. +

+ ) : ( +
+
+ + + + + + + + + + + + {audit.entries.map((row) => ( + + + + + + + + ))} + +
WhenActionUserDetailIP
+ {new Date(row.at).toLocaleString()} + + {actionLabel(row.action)} + +
+ {row.actor.name || row.actor.email || '—'} +
+ {row.actor.email && row.actor.name ? ( +
+ {row.actor.email} +
+ ) : null} + {row.actor.provider ? ( +
+ via {row.actor.provider} +
+ ) : null} +
+ {row.detail} + + {row.ip || '—'} +
+
+
+ ) + ) : ( +

Loading activity log…

+ )} +
+ )} diff --git a/worker/admin.ts b/worker/admin.ts index 592eee8..88c7c0c 100644 --- a/worker/admin.ts +++ b/worker/admin.ts @@ -1,7 +1,7 @@ /** * iBamboo Admin API — POC control plane at /api/admin/* * Auth: Google OAuth (email allowlist) + optional password fallback. - * State: KV ADMIN_KV key "config". + * State: KV ADMIN_KV key "config"; audit log key "audit_log". */ export type AdminEnv = { @@ -99,11 +99,33 @@ export type AdminConfig = { const COOKIE = 'ibamboo_admin_session' const OAUTH_STATE_COOKIE = 'ibamboo_admin_oauth_state' const CONFIG_KEY = 'config' +const AUDIT_KEY = 'audit_log' +const AUDIT_MAX = 500 const SESSION_MAX_AGE_SEC = 12 * 60 * 60 const GOOGLE_AUTH = 'https://accounts.google.com/o/oauth2/v2/auth' const GOOGLE_TOKEN = 'https://oauth2.googleapis.com/token' const GOOGLE_USERINFO = 'https://openidconnect.googleapis.com/v1/userinfo' +export type AuditAction = + | 'login' + | 'login_denied' + | 'logout' + | 'config_save' + +export type AuditEntry = { + id: string + at: string + action: AuditAction + actor: { + email?: string + name?: string + provider?: 'google' | 'password' + } + detail: string + meta?: Record + ip?: string +} + function defaultConfig(): AdminConfig { const now = new Date().toISOString() return { @@ -394,10 +416,6 @@ async function readSession( } } -async function isAuthed(request: Request, env: AdminEnv): Promise { - return Boolean(await readSession(request, env)) -} - function parseAllowedEmails(csv: string | undefined): string[] { return (csv || '') .split(',') @@ -523,6 +541,76 @@ async function saveConfig(env: AdminEnv, cfg: AdminConfig): Promise { await env.ADMIN_KV.put(CONFIG_KEY, JSON.stringify(cfg)) } +function clientIp(request: Request): string | undefined { + return ( + request.headers.get('CF-Connecting-IP') || + request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() || + undefined + ) +} + +function newAuditId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(8)) + return b64urlEncode(bytes) +} + +async function loadAuditLog(env: AdminEnv): Promise { + if (!env.ADMIN_KV) return [] + const raw = await env.ADMIN_KV.get(AUDIT_KEY) + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as AuditEntry[] + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +async function appendAudit( + env: AdminEnv, + entry: Omit & { at?: string }, +): Promise { + if (!env.ADMIN_KV) return + const full: AuditEntry = { + id: newAuditId(), + at: entry.at || new Date().toISOString(), + action: entry.action, + actor: entry.actor || {}, + detail: entry.detail, + meta: entry.meta, + ip: entry.ip, + } + try { + const existing = await loadAuditLog(env) + const next = [full, ...existing].slice(0, AUDIT_MAX) + await env.ADMIN_KV.put(AUDIT_KEY, JSON.stringify(next)) + } catch (e) { + console.error('audit append failed', e) + } +} + +/** Which top-level config sections differ (for audit detail). */ +function configChangeSummary( + before: AdminConfig, + after: AdminConfig, +): string[] { + const sections: (keyof AdminConfig)[] = [ + 'editorInChief', + 'avatars', + 'conbal', + 'flash', + 'library', + 'featureFlags', + ] + const changed: string[] = [] + for (const key of sections) { + if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) { + changed.push(String(key)) + } + } + return changed +} + export async function handleAdmin( request: Request, env: AdminEnv, @@ -617,9 +705,21 @@ export async function handleAdmin( ) const profile = await fetchGoogleUser(token) if (profile.email_verified !== true) { + await appendAudit(env, { + action: 'login_denied', + actor: { email: profile.email, name: profile.name, provider: 'google' }, + detail: 'Google email not verified', + ip: clientIp(request), + }) return adminErrorRedirect(origin, 'Google email is not verified') } if (!isEmailAllowed(profile.email, env)) { + await appendAudit(env, { + action: 'login_denied', + actor: { email: profile.email, name: profile.name, provider: 'google' }, + detail: 'Email not on ADMIN_ALLOWED_EMAILS allowlist', + ip: clientIp(request), + }) return adminErrorRedirect( origin, `Access denied for ${profile.email}`, @@ -631,6 +731,16 @@ export async function handleAdmin( name: profile.name, picture: profile.picture, }) + await appendAudit(env, { + action: 'login', + actor: { + email: profile.email, + name: profile.name, + provider: 'google', + }, + detail: 'Signed in with Google', + ip: clientIp(request), + }) const headers = new Headers({ Location: `${origin}/admin?auth=ok`, 'Cache-Control': 'no-store', @@ -641,6 +751,12 @@ export async function handleAdmin( return new Response(null, { status: 302, headers }) } catch (e) { console.error('Google OAuth callback failed', e) + await appendAudit(env, { + action: 'login_denied', + actor: { provider: 'google' }, + detail: 'Google OAuth callback failed', + ip: clientIp(request), + }) return adminErrorRedirect(origin, 'Google sign-in failed') } } @@ -663,6 +779,12 @@ export async function handleAdmin( return json({ ok: false, error: 'Invalid JSON' }, 400) } if (body.password !== env.ADMIN_PASSWORD) { + await appendAudit(env, { + action: 'login_denied', + actor: { provider: 'password', email: 'password@local' }, + detail: 'Invalid password', + ip: clientIp(request), + }) return json({ ok: false, error: 'Invalid password' }, 401) } try { @@ -671,6 +793,16 @@ export async function handleAdmin( email: 'password@local', name: 'Password operator', }) + await appendAudit(env, { + action: 'login', + actor: { + provider: 'password', + email: 'password@local', + name: 'Password operator', + }, + detail: 'Signed in with password', + ip: clientIp(request), + }) return json({ ok: true }, 200, { 'Set-Cookie': setCookie }) } catch (e) { return json( @@ -684,6 +816,19 @@ export async function handleAdmin( } if (path === '/api/admin/logout' && method === 'POST') { + const session = await readSession(request, env) + if (session) { + await appendAudit(env, { + action: 'logout', + actor: { + email: session.email, + name: session.name, + provider: session.provider, + }, + detail: 'Signed out', + ip: clientIp(request), + }) + } return json( { ok: true }, 200, @@ -716,7 +861,8 @@ export async function handleAdmin( } // Everything below requires auth - if (!(await isAuthed(request, env))) { + const session = await readSession(request, env) + if (!session) { return json({ ok: false, error: 'Unauthorized' }, 401) } @@ -753,8 +899,23 @@ export async function handleAdmin( : current.featureFlags, updatedAt: new Date().toISOString(), } + const changed = configChangeSummary(current, next) try { await saveConfig(env, next) + await appendAudit(env, { + action: 'config_save', + actor: { + email: session.email, + name: session.name, + provider: session.provider, + }, + detail: + changed.length > 0 + ? `Saved config: ${changed.join(', ')}` + : 'Saved config (no section changes detected)', + meta: { sections: changed }, + ip: clientIp(request), + }) } catch (e) { return json( { @@ -767,6 +928,27 @@ export async function handleAdmin( return json({ ok: true, config: next }) } + // Activity / audit log + if (path === '/api/admin/audit' && method === 'GET') { + const all = await loadAuditLog(env) + const rawLimit = Number(url.searchParams.get('limit') || 100) + const limit = Math.min( + 200, + Math.max(1, Number.isFinite(rawLimit) ? rawLimit : 100), + ) + const entries = all.slice(0, limit) + return json({ + ok: true, + entries, + returned: entries.length, + total: all.length, + cap: AUDIT_MAX, + allowlistNote: + 'Google sign-in is allowed only if the account email is listed in Worker secret ADMIN_ALLOWED_EMAILS (comma-separated, case-insensitive).', + allowedEmails: parseAllowedEmails(env.ADMIN_ALLOWED_EMAILS), + }) + } + // Live flash catalog snapshot (auth-gated operator probe) if (path === '/api/admin/flash/status' && method === 'GET') { const cfg = await loadConfig(env)