From 532fc61d2502c2f4299f2ba2bb27b1189f08a54a Mon Sep 17 00:00:00 2001 From: "firat.sertgoz" Date: Tue, 14 Apr 2026 09:52:52 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20admin=20management=20panel=20=E2=80=94?= =?UTF-8?q?=20web=20UI=20for=20users=20and=20usage=20monitoring=20(#1963)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): add admin management panel * fix(web): address admin panel review findings * fix(web): address remaining admin review feedback * refactor(web): type admin api responses * fix(db): aggregate admin usage summary in sql * Add audit logging for admin privileged state-changes Add structured tracing (warn-level) to suspend, activate, delete, and update handlers so that privileged admin actions are recorded with the acting admin's user_id, the action performed, and the target user. Addresses security assessment item #1 from PR review. Co-Authored-By: Claude Opus 4.6 (1M context) * Fix PairingStore::new() call in test after staging merge Use PairingStore::new_noop() since the test doesn't need a real DB. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(admin): address PR #1963 review feedback - Fix total_jobs semantics: query agent_jobs directly instead of counting via LEFT JOIN on llm_calls (which missed jobs without LLM calls). Fixed in both libSQL and PostgreSQL backends. - Fix showConfirmModal XSS: escape message parameter internally instead of relying on callers to sanitize. - Add explicit ::numeric cast to PG COALESCE(SUM(cost), 0) to prevent integer type inference. - Use info! instead of warn! for successful admin audit events (update, suspend, activate, delete) — warn implies anomaly. - Add missing index on llm_calls.created_at for both PG (V21 migration) and libSQL (incremental migration 21). Co-Authored-By: Claude Opus 4.6 (1M context) * Address remaining admin panel review follow-ups * fix: address review comments — query consolidation, CSP, docs, security notes - Collapse 4 redundant llm_calls subqueries into single subquery (libsql + pg) - Add WARNING to V21 migration about table lock risk with CONCURRENTLY note - Add performance doc comments on admin_usage_summary full-table scan - Add CSP and noindex meta tags to admin.html - Add JSDoc for showConfirmModal documenting auto-escaping - Add sessionStorage threat model security comment - Add serde(flatten) collision risk doc on AdminUserDetailResponse - Add TODO(#1968) for inline styles migration to CSS custom properties - Add PG parity test stub for admin_usage_summary Co-Authored-By: Claude Opus 4.6 (1M context) * fix: renumber migration from V21/23 to V24 to avoid conflicts with staging Staging added V21 (backfill_conversation_source_channel), V22 (sandbox_restart_params), and V23 (list_workspace_files_escape_like). Renumber our llm_calls_created_at_index migration to V24 in both PG and libSQL. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review feedback — CSP, logging, validation, dispatch-exempt, tests - Remove 'unsafe-inline' from script-src CSP; move CSP to HTTP response header - Change audit tracing::info! to tracing::debug! (TUI corruption) - Add dispatch-exempt annotation on usage_summary_handler - Add server-side input validation on users_create_handler (name length, email, role) - Rename detailRowHtml to detailRowRawHtml with XSS safety comment - Add real PG integration test for admin_usage_summary with non-zero data Co-Authored-By: Claude Opus 4.6 (1M context) * fix(ci): skip test-only directories in no-panics check Files under `src/**/tests/*.rs` are Rust test sub-modules included behind `#[cfg(test)]` — they are never compiled in production builds. The no-panics checker was flagging `.unwrap()` and `assert!()` in helper functions at module level in these files as production code. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(admin): scope cost aggregates to 30d, drop external fonts, flatten detail response Addresses review feedback on #1963: - Scope all llm_calls aggregates to the 30d `since` window so the admin dashboard query is served by `idx_llm_calls_created_at` rather than a full table scan. Drops the unused all-time `total_cost` subquery from both libsql and postgres backends. - Self-contain the admin SPA — remove `fonts.googleapis.com` / `fonts.gstatic.com` link tags from admin.html and tighten the admin CSP to fully same-origin. Typography degrades to the system-font fallback already listed in `font-family`. - Fold `metadata` into `AdminUserInfo` (optional, skip-if-none) and remove the `#[serde(flatten)]` wrapper, eliminating the documented collision risk. - Add regression test asserting `since` actually bounds the LLM aggregates (future `since` should yield zero LLM counts without affecting non-windowed counts). --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: ilblackdragon@gmail.com --- crates/ironclaw_gateway/src/assets.rs | 14 + crates/ironclaw_gateway/static/admin.css | 749 ++++++++++++++ crates/ironclaw_gateway/static/admin.html | 98 ++ crates/ironclaw_gateway/static/admin.js | 951 ++++++++++++++++++ crates/ironclaw_gateway/static/index.html | 1 + crates/ironclaw_gateway/static/style.css | 204 +--- crates/ironclaw_gateway/static/theme.css | 187 ++++ .../V24__llm_calls_created_at_index.sql | 12 + scripts/check_no_panics.py | 22 +- src/channels/web/CLAUDE.md | 2 + src/channels/web/handlers/users.rs | 261 +++-- src/channels/web/server.rs | 76 +- src/channels/web/tests/multi_tenant.rs | 314 +++++- src/channels/web/types.rs | 112 +++ src/db/libsql/users.rs | 127 ++- src/db/libsql_migrations.rs | 7 + src/db/mod.rs | 24 + src/db/postgres.rs | 7 + src/history/store.rs | 196 ++++ 19 files changed, 3061 insertions(+), 303 deletions(-) create mode 100644 crates/ironclaw_gateway/static/admin.css create mode 100644 crates/ironclaw_gateway/static/admin.html create mode 100644 crates/ironclaw_gateway/static/admin.js create mode 100644 crates/ironclaw_gateway/static/theme.css create mode 100644 migrations/V24__llm_calls_created_at_index.sql diff --git a/crates/ironclaw_gateway/src/assets.rs b/crates/ironclaw_gateway/src/assets.rs index 3faca18eea..76a5434fe4 100644 --- a/crates/ironclaw_gateway/src/assets.rs +++ b/crates/ironclaw_gateway/src/assets.rs @@ -38,3 +38,17 @@ pub const I18N_KO_JS: &str = include_str!("../static/i18n/ko.js"); /// i18n integration with the app. pub const I18N_APP_JS: &str = include_str!("../static/i18n-app.js"); + +// ==================== Admin Panel ==================== + +/// Shared theme tokens (CSS custom properties). +pub const THEME_CSS: &str = include_str!("../static/theme.css"); + +/// Admin panel HTML shell. +pub const ADMIN_HTML: &str = include_str!("../static/admin.html"); + +/// Admin panel stylesheet. +pub const ADMIN_CSS: &str = include_str!("../static/admin.css"); + +/// Admin panel JavaScript. +pub const ADMIN_JS: &str = include_str!("../static/admin.js"); diff --git a/crates/ironclaw_gateway/static/admin.css b/crates/ironclaw_gateway/static/admin.css new file mode 100644 index 0000000000..d38e81ef52 --- /dev/null +++ b/crates/ironclaw_gateway/static/admin.css @@ -0,0 +1,749 @@ +/* IronClaw Admin Panel */ + +/* Shared theme tokens are loaded from /theme.css. */ + +/* === Base Reset === */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif; + font-size: var(--text-base); + color: var(--text); + background: var(--bg); + line-height: 1.5; + min-height: 100vh; +} + +/* === Auth Screen === */ +#auth-screen, #access-denied { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: var(--space-4); +} + +.auth-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-8); + width: 100%; + max-width: 380px; + box-shadow: var(--shadow-card); +} + +.auth-brand { + display: flex; + align-items: center; + gap: var(--space-3); + font-size: var(--text-xl); + font-weight: 600; + margin-bottom: var(--space-6); + color: var(--text); +} + +.auth-badge, .sidebar-badge { + font-size: var(--text-xs); + font-weight: 500; + background: var(--accent-subtle); + color: var(--accent); + padding: 2px 8px; + border-radius: 9999px; + vertical-align: middle; +} + +.auth-error { + background: var(--danger-subtle); + border: 1px solid var(--danger-border-subtle); + color: var(--danger); + padding: var(--space-3); + border-radius: var(--radius); + font-size: var(--text-sm); + margin-bottom: var(--space-4); +} + +.auth-form { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.auth-form input { + padding: 10px 14px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: var(--text-base); + font-family: var(--font-mono); + outline: none; + transition: border-color 0.2s; +} + +.auth-form input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--focus-ring); +} + +/* === Buttons === */ +.btn-primary { + background: var(--accent); + color: var(--text-on-accent); + border: none; + padding: 10px 20px; + border-radius: var(--radius); + font-size: var(--text-base); + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s; +} + +.btn-primary:hover { opacity: 0.9; } +.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text); + border: 1px solid var(--border); + padding: 10px 20px; + border-radius: var(--radius); + font-size: var(--text-base); + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.btn-secondary:hover { background: var(--hover-subtle); } + +.btn-small { + padding: 4px 10px; + font-size: var(--text-xs); + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-secondary); + color: var(--text); + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; +} + +.btn-small:hover { background: var(--bg-tertiary); } + +.btn-danger { + border-color: var(--danger); + color: var(--danger); +} + +.btn-danger:hover { + background: var(--danger); + color: var(--text-on-danger); +} + +/* === App Layout === */ +#app { + display: flex; + min-height: 100vh; +} + +/* === Sidebar === */ +#sidebar { + width: 220px; + min-width: 220px; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 0; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-4) var(--space-4); + font-size: var(--text-lg); + font-weight: 600; + border-bottom: 1px solid var(--border); + color: var(--text); +} + +.sidebar-nav { + flex: 1; + padding: var(--space-2) 0; + display: flex; + flex-direction: column; +} + +.nav-link { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 10px var(--space-4); + color: var(--text-secondary); + text-decoration: none; + font-size: var(--text-sm); + font-weight: 500; + transition: color 0.2s, background 0.2s; + border: none; + background: none; + cursor: pointer; + width: 100%; + text-align: left; + font-family: inherit; +} + +.nav-link:hover { + color: var(--text); + background: var(--hover-surface); +} + +.nav-link.active { + color: var(--accent); + background: var(--accent-subtle); +} + +.nav-link-soon { + opacity: 0.5; +} + +.nav-link-soon::after { + content: "soon"; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; + background: var(--bg-tertiary); + color: var(--text-muted); + padding: 1px 6px; + border-radius: 9999px; + margin-left: auto; +} + +.sidebar-footer { + border-top: 1px solid var(--border); + padding: var(--space-2) 0; +} + +.nav-link-back { color: var(--text-muted); } +.nav-link-logout { color: var(--text-muted); } + +/* === Main Content === */ +#content { + flex: 1; + padding: var(--space-6); + overflow-y: auto; + min-height: 100vh; +} + +/* === Page Header === */ +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-6); + flex-wrap: wrap; + gap: var(--space-3); +} + +.page-header h1 { + font-size: var(--text-2xl); + font-weight: 600; +} + +/* === Dashboard Metric Cards === */ +.metrics-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-4); + margin-bottom: var(--space-6); +} + +.metric-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-4) var(--space-6); +} + +.metric-label { + font-size: var(--text-xs); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; + margin-bottom: var(--space-1); +} + +.metric-value { + font-size: var(--text-2xl); + font-weight: 700; + color: var(--text); + font-family: var(--font-mono); +} + +.metric-value.accent { color: var(--accent); } +.metric-value.warning { color: var(--warning); } +.metric-value.danger { color: var(--danger); } + +/* === Data Tables === */ +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--border); + font-size: var(--text-xs); + font-weight: 500; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.data-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + font-size: var(--text-sm); + color: var(--text); +} + +.data-table tr:hover td { + background: var(--hover-surface); +} + +.data-table .mono { + font-family: var(--font-mono); + font-size: var(--text-xs); +} + +.data-table .actions { + display: flex; + gap: var(--space-1); + flex-wrap: wrap; +} + +/* === Badges === */ +.badge { + display: inline-block; + padding: 2px 10px; + border-radius: 9999px; + font-size: var(--text-xs); + font-weight: 500; + line-height: 1.5; +} + +.badge-admin { + background: var(--accent-subtle); + color: var(--accent); +} + +.badge-member { + background: var(--hover-surface); + color: var(--text-secondary); +} + +.badge-active { + background: var(--accent-subtle); + color: var(--accent); +} + +.badge-suspended { + background: var(--danger-subtle); + color: var(--danger); +} + +.badge-deactivated { + background: var(--hover-surface); + color: var(--text-muted); +} + +/* === Search & Filters === */ +.toolbar { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-4); + flex-wrap: wrap; +} + +.search-input { + padding: 8px 14px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: var(--text-sm); + min-width: 240px; + outline: none; + transition: border-color 0.2s; +} + +.search-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--focus-ring); +} + +.filter-btn { + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: 9999px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; + transition: all 0.2s; +} + +.filter-btn:hover { border-color: var(--accent); color: var(--text); } +.filter-btn.active { background: var(--accent-subtle); border-color: var(--accent-border-subtle); color: var(--accent); } + +/* === Forms === */ +.form-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-6); + margin-bottom: var(--space-6); +} + +.form-row { + display: flex; + gap: var(--space-3); + align-items: end; + flex-wrap: wrap; + margin-bottom: var(--space-3); +} + +.form-group { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.form-group label { + font-size: var(--text-xs); + font-weight: 500; + color: var(--text-secondary); +} + +.form-group input, +.form-group select { + padding: 8px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: var(--text-sm); + outline: none; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--focus-ring); +} + +.form-group select { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a1a1aa' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 30px; +} + +/* === Token Banner === */ +.token-banner { + background: var(--accent-subtle); + border: 1px solid var(--accent-border-subtle); + border-radius: var(--radius); + padding: var(--space-4); + margin-bottom: var(--space-4); +} + +.token-banner p { + font-size: var(--text-sm); + margin-bottom: var(--space-2); + color: var(--text); +} + +.token-banner .token-value { + font-family: var(--font-mono); + font-size: var(--text-sm); + background: var(--bg); + padding: var(--space-2) var(--space-3); + border-radius: 6px; + word-break: break-all; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} + +.token-banner .token-value code { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +/* === Detail Page === */ +.detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-6); + margin-bottom: var(--space-6); +} + +.detail-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-6); +} + +.detail-card h2 { + font-size: var(--text-lg); + font-weight: 600; + margin-bottom: var(--space-4); + color: var(--text); +} + +.detail-row { + display: flex; + justify-content: space-between; + padding: var(--space-2) 0; + border-bottom: 1px solid var(--border); + font-size: var(--text-sm); +} + +.detail-row:last-child { border-bottom: none; } + +.detail-label { + color: var(--text-secondary); + font-weight: 500; +} + +.detail-value { + color: var(--text); + text-align: right; +} + +/* === Usage Bars === */ +.usage-bar-cell { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.usage-bar { + height: 6px; + background: var(--accent); + border-radius: 3px; + min-width: 2px; +} + +/* === Period Selector === */ +.period-selector { + display: flex; + gap: 2px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2px; +} + +.period-btn { + padding: 6px 16px; + border: none; + background: none; + color: var(--text-secondary); + font-size: var(--text-sm); + font-weight: 500; + cursor: pointer; + border-radius: 6px; + transition: all 0.2s; +} + +.period-btn:hover { color: var(--text); } + +.period-btn.active { + background: var(--accent-subtle); + color: var(--accent); +} + +/* === Modal === */ +.modal-overlay { + position: fixed; + inset: 0; + background: var(--bg-overlay); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: var(--space-4); +} + +.modal-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-6); + max-width: 480px; + width: 100%; + box-shadow: var(--shadow-card); +} + +.modal-card h2 { + font-size: var(--text-lg); + font-weight: 600; + margin-bottom: var(--space-4); +} + +.modal-actions { + display: flex; + gap: var(--space-3); + justify-content: flex-end; + margin-top: var(--space-6); +} + +/* === Empty & Loading States === */ +.empty-state { + text-align: center; + padding: 60px var(--space-4); + color: var(--text-secondary); +} + +.empty-state svg { + margin-bottom: var(--space-4); + opacity: 0.3; +} + +.empty-state p { + font-size: var(--text-sm); +} + +.coming-soon { + text-align: center; + padding: 80px var(--space-4); +} + +.coming-soon h2 { + font-size: var(--text-xl); + font-weight: 600; + color: var(--text-secondary); + margin-bottom: var(--space-3); +} + +.coming-soon p { + color: var(--text-muted); + font-size: var(--text-sm); +} + +.loading { + text-align: center; + padding: 60px var(--space-4); + color: var(--text-secondary); +} + +.error-message { + background: var(--danger-subtle); + border: 1px solid var(--danger-border-subtle); + color: var(--danger); + padding: var(--space-4); + border-radius: var(--radius); + font-size: var(--text-sm); +} + +/* === Breadcrumb === */ +.breadcrumb { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--text-secondary); + margin-bottom: var(--space-4); +} + +.breadcrumb a { + color: var(--text-secondary); + text-decoration: none; +} + +.breadcrumb a:hover { color: var(--accent); } +.breadcrumb .sep { opacity: 0.5; } + +/* === Responsive === */ +@media (max-width: 768px) { + #app { + flex-direction: column; + } + + #sidebar { + width: 100%; + min-width: 100%; + border-right: none; + border-bottom: 1px solid var(--border); + } + + .sidebar-nav { + flex-direction: row; + overflow-x: auto; + padding: 0 var(--space-2); + } + + .nav-link { + white-space: nowrap; + padding: var(--space-3) var(--space-3); + } + + .sidebar-footer { + display: flex; + border-top: none; + padding: 0 var(--space-2); + } + + .sidebar-footer .nav-link { + flex: 1; + justify-content: center; + } + + #content { + padding: var(--space-4); + min-height: auto; + } + + .metrics-grid { + grid-template-columns: repeat(2, 1fr); + } + + .detail-grid { + grid-template-columns: 1fr; + } + + .data-table { + display: block; + overflow-x: auto; + } + + .search-input { + min-width: 100%; + } + + .toolbar { + flex-direction: column; + align-items: stretch; + } +} + +@media (max-width: 480px) { + .metrics-grid { + grid-template-columns: 1fr; + } +} diff --git a/crates/ironclaw_gateway/static/admin.html b/crates/ironclaw_gateway/static/admin.html new file mode 100644 index 0000000000..6989c76a04 --- /dev/null +++ b/crates/ironclaw_gateway/static/admin.html @@ -0,0 +1,98 @@ + + + + + + + IronClaw Admin + + + + + + + +
+
+
+ + + + IronClaw Admin +
+ +
+ + +
+
+
+ + + + + + + + + + diff --git a/crates/ironclaw_gateway/static/admin.js b/crates/ironclaw_gateway/static/admin.js new file mode 100644 index 0000000000..a35ac936c3 --- /dev/null +++ b/crates/ironclaw_gateway/static/admin.js @@ -0,0 +1,951 @@ +/* IronClaw Admin Panel */ + +// TODO(#1968): Inline style attributes throughout this file bypass the +// theme token system in admin.css. Migrate to CSS classes that reference +// CSS custom properties (--space-*, --text-*, --accent, etc.). + +(function () { + 'use strict'; + + // --------------------------------------------------------------------------- + // State + // --------------------------------------------------------------------------- + + var token = ''; + var oidcProxyAuth = false; + var currentProfile = null; + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function formatNumber(n) { + if (n == null) return '0'; + return Number(n).toLocaleString(); + } + + function formatTokenCount(n) { + if (n == null || n === 0) return '0'; + if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; + if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; + return String(n); + } + + function formatCost(v) { + if (v == null) return '$0.00'; + var n = parseFloat(v); + if (isNaN(n)) return '$0.00'; + return '$' + n.toFixed(2); + } + + function formatUptime(secs) { + if (!secs) return '0s'; + var d = Math.floor(secs / 86400); + var h = Math.floor((secs % 86400) / 3600); + var m = Math.floor((secs % 3600) / 60); + if (d > 0) return d + 'd ' + h + 'h'; + if (h > 0) return h + 'h ' + m + 'm'; + return m + 'm'; + } + + function formatRelativeTime(iso) { + if (!iso) return 'Never'; + var diff = (Date.now() - new Date(iso).getTime()) / 1000; + if (diff < 0) diff = 0; + if (diff < 60) return 'Just now'; + if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; + if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; + if (diff < 2592000) return Math.floor(diff / 86400) + 'd ago'; + return new Date(iso).toLocaleDateString(); + } + + function statusBadge(status) { + var cls = 'badge badge-' + escapeHtml(status || 'active'); + return '' + escapeHtml(status || 'active') + ''; + } + + function roleBadge(role) { + var cls = 'badge badge-' + escapeHtml(role || 'member'); + return '' + escapeHtml(role || 'member') + ''; + } + + function truncateId(id) { + if (!id) return ''; + return id.length > 12 ? id.slice(0, 12) + '\u2026' : id; + } + + // --------------------------------------------------------------------------- + // API + // --------------------------------------------------------------------------- + + function apiFetch(path, options) { + var opts = {}; + if (options) { + for (var k in options) { + if (Object.prototype.hasOwnProperty.call(options, k)) { + opts[k] = options[k]; + } + } + } + opts.headers = Object.assign({}, opts.headers || {}); + if (token && !oidcProxyAuth) { + opts.headers['Authorization'] = 'Bearer ' + token; + } + if (opts.body && typeof opts.body === 'object') { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(opts.body); + } + return fetch(path, opts).then(function (res) { + if (!res.ok) { + return res.text().then(function (body) { + var err = new Error(body || res.status + ' ' + res.statusText); + err.status = res.status; + throw err; + }); + } + if (res.status === 204) return null; + return res.json(); + }); + } + + // --------------------------------------------------------------------------- + // Auth + // --------------------------------------------------------------------------- + + function showAuth() { + document.getElementById('auth-screen').style.display = 'flex'; + document.getElementById('access-denied').style.display = 'none'; + document.getElementById('app').style.display = 'none'; + } + + function showAccessDenied() { + document.getElementById('auth-screen').style.display = 'none'; + document.getElementById('access-denied').style.display = 'flex'; + document.getElementById('app').style.display = 'none'; + } + + function showApp() { + document.getElementById('auth-screen').style.display = 'none'; + document.getElementById('access-denied').style.display = 'none'; + document.getElementById('app').style.display = 'flex'; + } + + function logout() { + token = ''; + oidcProxyAuth = false; + currentProfile = null; + sessionStorage.removeItem('ironclaw_token'); + showAuth(); + } + + function authenticate(t) { + oidcProxyAuth = false; + token = t; + return apiFetch('/api/profile').then(function (profile) { + currentProfile = profile; + if (profile.role !== 'admin') { + token = ''; + showAccessDenied(); + return false; + } + // Security note: sessionStorage is readable by any XSS payload running + // in this origin. We accept this risk because: (1) the token is session- + // scoped and cleared on tab close, (2) the CSP restricts script-src to + // 'self' only (no inline scripts), (3) migration to httpOnly cookies + // needs server-side session management (larger effort, tracked for + // follow-up). + sessionStorage.setItem('ironclaw_token', t); + showApp(); + route(); + return true; + }).catch(function (err) { + token = ''; + throw err; + }); + } + + function autoAuth() { + // Check sessionStorage + var saved = sessionStorage.getItem('ironclaw_token'); + if (saved) { + authenticate(saved).catch(function () { showAuth(); }); + return; + } + + // Check implicit auth (e.g. OIDC proxy cookie) by probing profile directly. + apiFetch('/api/profile').then(function (profile) { + oidcProxyAuth = true; + token = ''; + currentProfile = profile; + if (profile.role !== 'admin') { + showAccessDenied(); + } else { + showApp(); + route(); + } + }).catch(function () { + oidcProxyAuth = false; + showAuth(); + }); + } + + // --------------------------------------------------------------------------- + // Router + // --------------------------------------------------------------------------- + + function parseHash() { + var hash = window.location.hash || '#/'; + if (hash.charAt(0) === '#') hash = hash.slice(1); + if (!hash || hash.charAt(0) !== '/') hash = '/'; + return hash; + } + + function route() { + var path = parseHash(); + var content = document.getElementById('content'); + + // Update active nav link + var links = document.querySelectorAll('.nav-link[data-route]'); + for (var i = 0; i < links.length; i++) { + var r = links[i].getAttribute('data-route'); + var isActive = path === r || (r !== '/' && path.indexOf(r) === 0); + links[i].classList.toggle('active', isActive); + } + + // Route dispatch + if (path === '/') { + renderDashboard(content); + } else if (path === '/users') { + renderUsers(content); + } else if (path.indexOf('/users/') === 0) { + var userId = decodeURIComponent(path.slice(7)); + renderUserDetail(content, userId); + } else if (path === '/usage') { + renderUsage(content, 'day'); + } else if (path === '/workspaces') { + renderStub(content, 'Workspaces', 'Workspace management is coming soon.', '#1607'); + } else if (path === '/invitations') { + renderStub(content, 'Invitations', 'Invitation management is coming soon.', '#1608'); + } else { + content.innerHTML = '

Page not found

'; + } + } + + // --------------------------------------------------------------------------- + // Pages + // --------------------------------------------------------------------------- + + // --- Dashboard --- + + function renderDashboard(el) { + el.innerHTML = '
Loading dashboard...
'; + + Promise.all([ + apiFetch('/api/admin/usage/summary'), + apiFetch('/api/admin/users') + ]).then(function (results) { + var summary = results[0]; + var rawUsers = results[1] || {}; + var users = Array.isArray(rawUsers) ? rawUsers : (rawUsers.users || []); + + var u = summary.users || {}; + var j = summary.jobs || {}; + var usage = summary.usage_30d || {}; + + var html = ''; + + // Metrics + html += '
'; + html += metricCard('Total Users', formatNumber(u.total)); + html += metricCard('Active Users', formatNumber(u.active), 'accent'); + html += metricCard('Suspended', formatNumber(u.suspended), u.suspended > 0 ? 'danger' : ''); + html += metricCard('Admins', formatNumber(u.admins)); + html += metricCard('Total Jobs', formatNumber(j.total)); + html += metricCard('30d LLM Calls', formatNumber(usage.llm_calls)); + html += metricCard('30d Cost', formatCost(usage.total_cost), 'accent'); + html += metricCard('Uptime', formatUptime(summary.uptime_seconds)); + html += '
'; + + // Recent users table + var recent = users.slice().sort(function (a, b) { + var ta = a.last_active_at || a.created_at || ''; + var tb = b.last_active_at || b.created_at || ''; + return tb.localeCompare(ta); + }).slice(0, 5); + + html += '
'; + html += '

Recent Users

'; + if (recent.length === 0) { + html += '

No users yet

'; + } else { + html += ''; + html += ''; + html += ''; + for (var i = 0; i < recent.length; i++) { + var ru = recent[i]; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + } + html += '
NameRoleStatusJobsLast Active
' + escapeHtml(ru.display_name) + '' + roleBadge(ru.role) + '' + statusBadge(ru.status) + '' + formatNumber(ru.job_count) + '' + formatRelativeTime(ru.last_active_at) + '
'; + } + html += '
'; + + el.innerHTML = html; + }).catch(function (err) { + el.innerHTML = '
Failed to load dashboard: ' + escapeHtml(err.message) + '
'; + }); + } + + function metricCard(label, value, cls) { + return '
' + + '
' + escapeHtml(label) + '
' + + '
' + escapeHtml(value) + '
' + + '
'; + } + + // --- Users List --- + + var usersCache = null; + var usersFilter = 'all'; + var usersSearch = ''; + + function renderUsers(el) { + el.innerHTML = '
Loading users...
'; + usersCache = null; + usersFilter = 'all'; + usersSearch = ''; + + apiFetch('/api/admin/users').then(function (raw) { + usersCache = Array.isArray(raw) ? raw : (raw && raw.users ? raw.users : []); + renderUsersPage(el); + }).catch(function (err) { + el.innerHTML = '
Failed to load users: ' + escapeHtml(err.message) + '
'; + }); + } + + function renderUsersPage(el) { + var users = filterUsers(usersCache || []); + + var html = ''; + + // Create user form (hidden by default) + html += ''; + + // Token banner + html += ''; + + // Toolbar + html += '
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += '
'; + + // Table + if (users.length === 0) { + html += '

No users found

'; + } else { + html += ''; + html += ''; + html += ''; + html += ''; + for (var i = 0; i < users.length; i++) { + var u = users[i]; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + } + html += '
IDNameEmailRoleStatusJobsCostLast ActiveActions
' + escapeHtml(truncateId(u.id)) + '' + escapeHtml(u.display_name) + '' + escapeHtml(u.email || '') + '' + roleBadge(u.role) + '' + statusBadge(u.status) + '' + formatNumber(u.job_count) + '' + formatCost(u.total_cost) + '' + formatRelativeTime(u.last_active_at) + ''; + if (u.status === 'active') { + html += ''; + } else { + html += ''; + } + if (u.role === 'admin') { + html += ''; + } else { + html += ''; + } + html += ''; + html += '
'; + } + + el.innerHTML = html; + + // Search input handler + var searchEl = document.getElementById('users-search'); + if (searchEl) { + searchEl.addEventListener('input', function () { + usersSearch = searchEl.value; + renderUsersPage(el); + }); + searchEl.focus(); + searchEl.setSelectionRange(usersSearch.length, usersSearch.length); + } + } + + function filterUsers(users) { + var result = users; + if (usersFilter === 'active') { + result = result.filter(function (u) { return u.status === 'active'; }); + } else if (usersFilter === 'suspended') { + result = result.filter(function (u) { return u.status === 'suspended'; }); + } else if (usersFilter === 'admin') { + result = result.filter(function (u) { return u.role === 'admin'; }); + } + if (usersSearch) { + var q = usersSearch.toLowerCase(); + result = result.filter(function (u) { + return (u.display_name && u.display_name.toLowerCase().indexOf(q) >= 0) || + (u.email && u.email.toLowerCase().indexOf(q) >= 0) || + (u.id && u.id.toLowerCase().indexOf(q) >= 0); + }); + } + return result; + } + + // --- User Detail --- + + function renderUserDetail(el, userId) { + el.innerHTML = '
Loading user...
'; + + Promise.all([ + apiFetch('/api/admin/users/' + encodeURIComponent(userId)), + apiFetch('/api/admin/usage?user_id=' + encodeURIComponent(userId) + '&period=month') + ]).then(function (results) { + var user = results[0]; + var usageData = results[1]; + + var html = ''; + + html += ''; + + // Token banner slot + html += ''; + + // Profile + Stats grid + html += '
'; + + // Profile card + html += '

Profile

'; + html += detailRowRawHtml('ID', '' + escapeHtml(user.id) + ''); + html += detailRow('Email', user.email || 'Not set'); + html += detailRowRawHtml('Role', roleBadge(user.role)); + html += detailRowRawHtml('Status', statusBadge(user.status)); + html += detailRow('Created', formatRelativeTime(user.created_at)); + html += detailRow('Last Login', formatRelativeTime(user.last_login_at)); + if (user.created_by) { + html += detailRowRawHtml('Created By', '' + escapeHtml(truncateId(user.created_by)) + ''); + } + html += '
'; + + // Stats card + html += '

Summary

'; + html += detailRow('Jobs', formatNumber(user.job_count)); + html += detailRow('Total Cost', formatCost(user.total_cost)); + html += detailRow('Last Active', formatRelativeTime(user.last_active_at)); + html += '
'; + + html += '
'; + + // Role management + html += '
'; + html += '

Role Management

'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + + // Usage table + var entries = (usageData && usageData.usage) || []; + html += '
'; + html += '

Usage (Last 30 Days)

'; + if (entries.length === 0) { + html += '

No usage data

'; + } else { + html += ''; + html += ''; + html += ''; + for (var i = 0; i < entries.length; i++) { + var e = entries[i]; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + } + html += '
ModelCallsInput TokensOutput TokensCost
' + escapeHtml(e.model) + '' + formatNumber(e.call_count) + '' + formatTokenCount(e.input_tokens) + '' + formatTokenCount(e.output_tokens) + '' + formatCost(e.total_cost) + '
'; + } + html += '
'; + + el.innerHTML = html; + }).catch(function (err) { + el.innerHTML = '' + + '
Failed to load user: ' + escapeHtml(err.message) + '
'; + }); + } + + function detailRow(label, value) { + return '
' + escapeHtml(label) + '' + escapeHtml(value == null ? '' : String(value)) + '
'; + } + + // SAFETY: valueHtml is injected as raw HTML — callers MUST pre-escape any + // user-supplied content via escapeHtml() to prevent XSS. Prefer detailRow() + // for plain-text values; use this variant only when the value contains + // trusted markup (badges, , etc.). + function detailRowRawHtml(label, valueHtml) { + return '
' + escapeHtml(label) + '' + valueHtml + '
'; + } + + // --- Usage --- + + function renderUsage(el, period) { + el.innerHTML = '
Loading usage data...
'; + + apiFetch('/api/admin/usage?period=' + encodeURIComponent(period)).then(function (data) { + var entries = (data && data.usage) || []; + + var html = ''; + + if (entries.length === 0) { + html += '

No usage data for this period

'; + el.innerHTML = html; + return; + } + + // Aggregate by user + var byUser = {}; + var maxCost = 0; + for (var i = 0; i < entries.length; i++) { + var e = entries[i]; + if (!byUser[e.user_id]) { + byUser[e.user_id] = { user_id: e.user_id, calls: 0, input_tokens: 0, output_tokens: 0, cost: 0 }; + } + byUser[e.user_id].calls += e.call_count || 0; + byUser[e.user_id].input_tokens += e.input_tokens || 0; + byUser[e.user_id].output_tokens += e.output_tokens || 0; + byUser[e.user_id].cost += parseFloat(e.total_cost) || 0; + } + + var userList = Object.keys(byUser).map(function (k) { return byUser[k]; }); + userList.sort(function (a, b) { return b.cost - a.cost; }); + + for (var j = 0; j < userList.length; j++) { + if (userList[j].cost > maxCost) maxCost = userList[j].cost; + } + + // Summary row + var totalCalls = 0, totalInput = 0, totalOutput = 0, totalCostVal = 0; + for (var k = 0; k < userList.length; k++) { + totalCalls += userList[k].calls; + totalInput += userList[k].input_tokens; + totalOutput += userList[k].output_tokens; + totalCostVal += userList[k].cost; + } + + html += '
'; + html += metricCard('Total Calls', formatNumber(totalCalls)); + html += metricCard('Input Tokens', formatTokenCount(totalInput)); + html += metricCard('Output Tokens', formatTokenCount(totalOutput)); + html += metricCard('Total Cost', formatCost(totalCostVal.toFixed(2)), 'accent'); + html += '
'; + + // Per-user table + html += '
'; + html += '

Per-User Breakdown

'; + html += ''; + html += ''; + html += ''; + for (var m = 0; m < userList.length; m++) { + var uu = userList[m]; + var pct = maxCost > 0 ? (uu.cost / maxCost * 100) : 0; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + } + html += '
UserCallsInput TokensOutput TokensCost
' + escapeHtml(truncateId(uu.user_id)) + '' + formatNumber(uu.calls) + '' + formatTokenCount(uu.input_tokens) + '' + formatTokenCount(uu.output_tokens) + '' + formatCost(uu.cost.toFixed(2)) + '
'; + + // Per-model table + var byModel = {}; + for (var n = 0; n < entries.length; n++) { + var em = entries[n]; + if (!byModel[em.model]) { + byModel[em.model] = { model: em.model, calls: 0, input_tokens: 0, output_tokens: 0, cost: 0 }; + } + byModel[em.model].calls += em.call_count || 0; + byModel[em.model].input_tokens += em.input_tokens || 0; + byModel[em.model].output_tokens += em.output_tokens || 0; + byModel[em.model].cost += parseFloat(em.total_cost) || 0; + } + + var modelList = Object.keys(byModel).map(function (k) { return byModel[k]; }); + modelList.sort(function (a, b) { return b.cost - a.cost; }); + + html += '
'; + html += '

Per-Model Breakdown

'; + html += ''; + html += ''; + html += ''; + for (var p = 0; p < modelList.length; p++) { + var mm = modelList[p]; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + } + html += '
ModelCallsInput TokensOutput TokensCost
' + escapeHtml(mm.model) + '' + formatNumber(mm.calls) + '' + formatTokenCount(mm.input_tokens) + '' + formatTokenCount(mm.output_tokens) + '' + formatCost(mm.cost.toFixed(2)) + '
'; + + el.innerHTML = html; + }).catch(function (err) { + el.innerHTML = '
Failed to load usage: ' + escapeHtml(err.message) + '
'; + }); + } + + // --- Stub pages --- + + function renderStub(el, title, desc, issue) { + el.innerHTML = '
' + + '

' + escapeHtml(title) + '

' + + '

' + escapeHtml(desc) + '

' + + '

Tracking: ' + escapeHtml(issue) + '

' + + '
'; + } + + // --------------------------------------------------------------------------- + // Actions (event delegation) + // --------------------------------------------------------------------------- + + function handleAction(target) { + var action = target.getAttribute('data-action'); + if (!action) return; + + var id = target.getAttribute('data-id'); + var content = document.getElementById('content'); + + switch (action) { + case 'show-create-form': + var form = document.getElementById('create-user-form'); + if (form) form.style.display = 'block'; + break; + + case 'hide-create-form': + var formH = document.getElementById('create-user-form'); + if (formH) formH.style.display = 'none'; + break; + + case 'create-user': + createUser(content); + break; + + case 'suspend': + apiFetch('/api/admin/users/' + encodeURIComponent(id) + '/suspend', { method: 'POST' }) + .then(function () { refreshCurrentPage(); }) + .catch(function (err) { alert('Failed to suspend: ' + err.message); }); + break; + + case 'activate': + apiFetch('/api/admin/users/' + encodeURIComponent(id) + '/activate', { method: 'POST' }) + .then(function () { refreshCurrentPage(); }) + .catch(function (err) { alert('Failed to activate: ' + err.message); }); + break; + + case 'change-role': + var newRole = target.getAttribute('data-role'); + apiFetch('/api/admin/users/' + encodeURIComponent(id), { + method: 'PATCH', + body: { role: newRole } + }).then(function () { refreshCurrentPage(); }) + .catch(function (err) { alert('Failed to change role: ' + err.message); }); + break; + + case 'save-role': + var sel = document.getElementById('role-select'); + if (sel) { + apiFetch('/api/admin/users/' + encodeURIComponent(id), { + method: 'PATCH', + body: { role: sel.value } + }).then(function () { refreshCurrentPage(); }) + .catch(function (err) { alert('Failed to save role: ' + err.message); }); + } + break; + + case 'create-token': + var userName = target.getAttribute('data-name') || 'user'; + var tokenName = prompt('Token name for ' + userName + ':'); + if (!tokenName) return; + apiFetch('/api/tokens', { + method: 'POST', + body: { name: tokenName, user_id: id } + }).then(function (res) { + showTokenBanner(res.token || res.plaintext_token); + }).catch(function (err) { alert('Failed to create token: ' + err.message); }); + break; + + case 'delete-user': + var name = target.getAttribute('data-name') || id; + showConfirmModal( + 'Delete User', + 'Are you sure you want to delete "' + name + '"? This action cannot be undone.', + 'Delete', + function () { + apiFetch('/api/admin/users/' + encodeURIComponent(id), { method: 'DELETE' }) + .then(function () { window.location.hash = '#/users'; }) + .catch(function (err) { alert('Failed to delete: ' + err.message); }); + } + ); + break; + + case 'filter': + usersFilter = target.getAttribute('data-filter') || 'all'; + renderUsersPage(content); + break; + + case 'period': + var period = target.getAttribute('data-period') || 'day'; + renderUsage(content, period); + break; + + case 'copy-token': + var tokenVal = target.getAttribute('data-token'); + if (tokenVal && navigator.clipboard) { + navigator.clipboard.writeText(tokenVal); + target.textContent = 'Copied!'; + setTimeout(function () { target.textContent = 'Copy'; }, 2000); + } + break; + + case 'modal-close': + closeModal(); + break; + } + } + + function createUser(el) { + var nameEl = document.getElementById('new-user-name'); + var emailEl = document.getElementById('new-user-email'); + var roleEl = document.getElementById('new-user-role'); + if (!nameEl || !nameEl.value.trim()) { + alert('Display name is required'); + return; + } + var body = { display_name: nameEl.value.trim(), role: roleEl ? roleEl.value : 'member' }; + if (emailEl && emailEl.value.trim()) body.email = emailEl.value.trim(); + + apiFetch('/api/admin/users', { method: 'POST', body: body }).then(function (res) { + var formEl = document.getElementById('create-user-form'); + if (formEl) formEl.style.display = 'none'; + if (nameEl) nameEl.value = ''; + if (emailEl) emailEl.value = ''; + var createdToken = res && (res.token || res.plaintext_token); + + // Reload users list + apiFetch('/api/admin/users').then(function (raw) { + usersCache = Array.isArray(raw) ? raw : (raw && raw.users ? raw.users : []); + renderUsersPage(el); + if (createdToken) { + showTokenBanner(createdToken); + } + }); + }).catch(function (err) { + alert('Failed to create user: ' + err.message); + }); + } + + function showTokenBanner(tokenValue) { + var banner = document.getElementById('user-token-banner'); + if (!banner) return; + banner.innerHTML = '
' + + '

Token created! Copy this now — it will not be shown again.

' + + '
' + escapeHtml(tokenValue) + '' + + '
' + + '

Use this token in the admin login field.

' + + '
'; + banner.style.display = 'block'; + } + + function refreshCurrentPage() { + route(); + } + + // --------------------------------------------------------------------------- + // Modal + // --------------------------------------------------------------------------- + + /** + * Show a confirmation modal dialog. + * + * All string parameters are HTML-escaped via escapeHtml() before insertion + * into the DOM, so callers do not need to pre-sanitise user-supplied strings. + * + * @param {string} title - Dialog title (escaped before rendering). + * @param {string} message - Dialog body text (escaped before rendering). + * @param {string} confirmText - Text for the confirm button (escaped before rendering). + * @param {Function} onConfirm - Callback invoked when the user confirms. + */ + function showConfirmModal(title, message, confirmText, onConfirm) { + var overlay = document.getElementById('modal-overlay'); + var content = document.getElementById('modal-content'); + if (!overlay || !content) return; + + content.innerHTML = '

' + escapeHtml(title) + '

' + + '

' + escapeHtml(message) + '

' + + ''; + overlay.style.display = 'flex'; + + var confirmBtn = document.getElementById('modal-confirm'); + if (confirmBtn) { + confirmBtn.onclick = function () { + closeModal(); + onConfirm(); + }; + } + } + + function closeModal() { + var overlay = document.getElementById('modal-overlay'); + if (overlay) overlay.style.display = 'none'; + } + + // --------------------------------------------------------------------------- + // Event Listeners + // --------------------------------------------------------------------------- + + document.addEventListener('click', function (e) { + var target = e.target; + // Walk up to find data-action + while (target && target !== document) { + if (target.getAttribute && target.getAttribute('data-action')) { + e.preventDefault(); + handleAction(target); + return; + } + target = target.parentElement; + } + }); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') closeModal(); + }); + + // Modal overlay click to close + var overlay = document.getElementById('modal-overlay'); + if (overlay) { + overlay.addEventListener('click', function (e) { + if (e.target === overlay) closeModal(); + }); + } + + // Auth form + var connectBtn = document.getElementById('connect-btn'); + if (connectBtn) { + connectBtn.addEventListener('click', function () { + var input = document.getElementById('token-input'); + var errEl = document.getElementById('auth-error'); + if (!input || !input.value.trim()) return; + + connectBtn.disabled = true; + connectBtn.textContent = 'Connecting...'; + + authenticate(input.value.trim()).catch(function (err) { + if (errEl) { + errEl.textContent = 'Authentication failed: ' + err.message; + errEl.style.display = 'block'; + } + connectBtn.disabled = false; + connectBtn.textContent = 'Connect'; + }); + }); + } + + var tokenInput = document.getElementById('token-input'); + if (tokenInput) { + tokenInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter' && connectBtn) connectBtn.click(); + }); + } + + // Logout buttons + var logoutBtn = document.getElementById('logout-btn'); + if (logoutBtn) logoutBtn.addEventListener('click', logout); + var logoutDenied = document.getElementById('logout-btn-denied'); + if (logoutDenied) logoutDenied.addEventListener('click', logout); + + // Hash-based routing + window.addEventListener('hashchange', function () { + if (document.getElementById('app').style.display !== 'none') { + route(); + } + }); + + // --------------------------------------------------------------------------- + // Init + // --------------------------------------------------------------------------- + + autoAuth(); + +})(); diff --git a/crates/ironclaw_gateway/static/index.html b/crates/ironclaw_gateway/static/index.html index 45622f1a75..f5d631254a 100644 --- a/crates/ironclaw_gateway/static/index.html +++ b/crates/ironclaw_gateway/static/index.html @@ -8,6 +8,7 @@ + diff --git a/crates/ironclaw_gateway/static/style.css b/crates/ironclaw_gateway/static/style.css index 17cec36683..8a4255e030 100644 --- a/crates/ironclaw_gateway/static/style.css +++ b/crates/ironclaw_gateway/static/style.css @@ -1,118 +1,6 @@ /* IronClaw Web Gateway */ -:root { - --bg: #09090b; - --bg-secondary: #0f0f11; - --bg-tertiary: #1a1a1e; - --border: rgba(255, 255, 255, 0.08); - --text: #fafafa; - --text-secondary: #a1a1aa; - --accent: #34d399; - --accent-hover: #2fc48d; - --accent-soft: rgba(52, 211, 153, 0.15); - --success: #34d399; - --info: #60a5fa; - --warning: #F5A623; - --danger: #E64C4C; - --code-bg: #111113; - --radius: 8px; - --radius-lg: 12px; - --shadow: 0 2px 8px rgba(0, 0, 0, 0.4); - --font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace; - --bg-overlay: rgba(0, 0, 0, 0.5); - --bg-modal: #1a1a1a; - --border-modal: #333; - --border-soft: #2a2a2a; - --text-tertiary: #e0e0e0; - --text-muted: #888; - --text-dimmed: #666; - --text-on-accent: #09090b; - --accent-brand: #00D894; - --accent-brand-hover: #00be82; - --warning-bg: #1e1400; - --warning-border: #3a2a00; - --warning-text: #facc15; - --tab-bg: rgba(9, 9, 11, 0.75); - --popover-bg: rgba(15, 15, 17, 0.9); - --badge-sandbox-bg: rgba(136, 132, 216, 0.15); - --badge-sandbox-text: #b4b0e8; - --hover-surface: rgba(255, 255, 255, 0.03); - --focus-ring: rgba(52, 211, 153, 0.1); - --accent-subtle: rgba(52, 211, 153, 0.15); - --accent-border-subtle: rgba(52, 211, 153, 0.3); - --danger-subtle: rgba(230, 76, 76, 0.15); - --danger-border-subtle: rgba(230, 76, 76, 0.3); - --warning-subtle: rgba(245, 166, 35, 0.15); - --border-hover: rgba(255, 255, 255, 0.15); - --user-msg-bg: rgba(52, 211, 153, 0.08); - --user-msg-border: rgba(52, 211, 153, 0.2); - --danger-error-bg: rgba(230, 76, 76, 0.1); - --accent-tee-bg: rgba(52, 211, 153, 0.1); - --accent-tee-border: rgba(52, 211, 153, 0.25); - --accent-tee-hover: rgba(52, 211, 153, 0.18); - --text-on-danger: #fff; - --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4); - --shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.4); - --danger-error-border: rgba(230, 76, 76, 0.2); - --note-bg: rgba(255, 255, 255, 0.04); - --overlay-heavy: rgba(0, 0, 0, 0.6); - --highlight-bg: rgba(52, 211, 153, 0.3); - --hover-subtle: rgba(255, 255, 255, 0.06); - --transition-fast: 150ms ease; - --transition-base: 0.2s ease; - - /* Shadows (3-tier) */ - --shadow-sm: 0 1px 2px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.15); - --shadow-md: 0 4px 12px rgba(0,0,0,0.4), 0 2px 4px rgba(0,0,0,0.2); - --shadow-lg: 0 12px 40px rgba(0,0,0,0.5), 0 4px 12px rgba(0,0,0,0.3); - - /* Accent glow */ - --glow-accent: 0 0 20px rgba(52,211,153,0.1); - - /* Glass morphism */ - --glass-bg: rgba(9,9,11,0.72); - --glass-blur: blur(16px) saturate(180%); - - /* Spring easing */ - --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); - --ease-spring-gentle: cubic-bezier(0.22, 1.2, 0.36, 1); - --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); - - /* Surface highlight */ - --surface-highlight: inset 0 1px 0 rgba(255,255,255,0.05); - - /* Spacing scale */ - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-6: 24px; - --space-8: 32px; - - /* Typography scale */ - --text-xs: 11px; - --text-sm: 13px; - --text-base: 14px; - --text-lg: 16px; - --text-xl: 20px; - --text-2xl: 24px; - --text-3xl: 36px; - - /* Timing */ - --transition-slow: 300ms ease; - --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); - --duration-instant: 100ms; - --duration-fast: 150ms; - --duration-base: 250ms; - --duration-slow: 400ms; - - /* Legacy aliases (mapped to new theme tokens) */ - --accent-soft: var(--accent-subtle); - --accent-dim: var(--accent-subtle); - --bg-hover: var(--hover-surface); - --danger-soft: var(--danger-subtle); - --warning-soft: var(--warning-subtle); -} +/* Shared theme tokens are loaded from /theme.css. */ * { margin: 0; @@ -5727,96 +5615,6 @@ input[type="checkbox"]:focus-visible { Light Theme ============================================================ */ -[data-theme="light"] { - --bg: #ffffff; - --bg-secondary: #f5f5f7; - --bg-tertiary: #ebebed; - --border: rgba(0, 0, 0, 0.1); - --text: #1a1a2e; - --text-secondary: #555555; - --accent: #059669; - --accent-hover: #047857; - --success: #059669; - --info: #2563eb; - --warning: #d97706; - --danger: #dc2626; - --code-bg: #f0f0f2; - --shadow: 0 2px 8px rgba(0, 0, 0, 0.08); - --bg-overlay: rgba(0, 0, 0, 0.3); - --bg-modal: #ffffff; - --border-modal: #e0e0e0; - --border-soft: #e5e5e5; - --text-tertiary: #333333; - --text-muted: #777777; - --text-dimmed: #999999; - --text-on-accent: #ffffff; - --accent-brand: #059669; - --accent-brand-hover: #047857; - --warning-bg: #fffbeb; - --warning-border: #fde68a; - --warning-text: #92400e; - --tab-bg: rgba(255, 255, 255, 0.9); - --popover-bg: rgba(255, 255, 255, 0.95); - --badge-sandbox-bg: rgba(136, 132, 216, 0.1); - --badge-sandbox-text: #6b67b0; - --hover-surface: rgba(0, 0, 0, 0.03); - --focus-ring: rgba(5, 150, 105, 0.15); - --accent-subtle: rgba(5, 150, 105, 0.1); - --accent-border-subtle: rgba(5, 150, 105, 0.3); - --danger-subtle: rgba(220, 38, 38, 0.1); - --danger-border-subtle: rgba(220, 38, 38, 0.2); - --warning-subtle: rgba(217, 119, 6, 0.1); - --border-hover: rgba(0, 0, 0, 0.15); - --user-msg-bg: rgba(5, 150, 105, 0.08); - --user-msg-border: rgba(5, 150, 105, 0.2); - --danger-error-bg: rgba(220, 38, 38, 0.06); - --accent-tee-bg: rgba(5, 150, 105, 0.08); - --accent-tee-border: rgba(5, 150, 105, 0.2); - --accent-tee-hover: rgba(5, 150, 105, 0.15); - --text-on-danger: #fff; - --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.08); - --shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.08); - --shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.1); - --danger-error-border: rgba(220, 38, 38, 0.15); - --note-bg: rgba(0, 0, 0, 0.02); - --overlay-heavy: rgba(0, 0, 0, 0.4); - --highlight-bg: rgba(5, 150, 105, 0.2); - --hover-subtle: rgba(0, 0, 0, 0.04); - --shadow-sm: 0 1px 2px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04); - --shadow-md: 0 4px 12px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.04); - --glow-accent: 0 0 20px rgba(5,150,105,0.08); - --glass-bg: rgba(255,255,255,0.85); - --glass-blur: blur(16px) saturate(180%); - --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); - --ease-spring-gentle: cubic-bezier(0.22, 1.2, 0.36, 1); - --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); - --surface-highlight: inset 0 1px 0 rgba(255,255,255,0.8); - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-6: 24px; - --space-8: 32px; - --text-xs: 11px; - --text-sm: 13px; - --text-base: 14px; - --text-lg: 16px; - --text-xl: 20px; - --text-2xl: 24px; - --text-3xl: 36px; - --transition-slow: 300ms ease; - --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); - --duration-instant: 100ms; - --duration-fast: 150ms; - --duration-base: 250ms; - --duration-slow: 400ms; - --accent-soft: var(--accent-subtle); - --accent-dim: var(--accent-subtle); - --bg-hover: var(--hover-surface); - --danger-soft: var(--danger-subtle); - --warning-soft: var(--warning-subtle); -} - /* ============================================================ Theme transition (delayed via JS to avoid FOUC) ============================================================ */ diff --git a/crates/ironclaw_gateway/static/theme.css b/crates/ironclaw_gateway/static/theme.css new file mode 100644 index 0000000000..4980279250 --- /dev/null +++ b/crates/ironclaw_gateway/static/theme.css @@ -0,0 +1,187 @@ +/* Shared IronClaw theme tokens */ + +:root { + --bg: #09090b; + --bg-secondary: #0f0f11; + --bg-tertiary: #1a1a1e; + --border: rgba(255, 255, 255, 0.08); + --text: #fafafa; + --text-secondary: #a1a1aa; + --accent: #34d399; + --accent-hover: #2fc48d; + --accent-soft: rgba(52, 211, 153, 0.15); + --success: #34d399; + --info: #60a5fa; + --warning: #F5A623; + --danger: #E64C4C; + --code-bg: #111113; + --radius: 8px; + --radius-lg: 12px; + --shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + --font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace; + --bg-overlay: rgba(0, 0, 0, 0.5); + --bg-modal: #1a1a1a; + --border-modal: #333; + --border-soft: #2a2a2a; + --text-tertiary: #e0e0e0; + --text-muted: #888; + --text-dimmed: #666; + --text-on-accent: #09090b; + --accent-brand: #00D894; + --accent-brand-hover: #00be82; + --warning-bg: #1e1400; + --warning-border: #3a2a00; + --warning-text: #facc15; + --tab-bg: rgba(9, 9, 11, 0.75); + --popover-bg: rgba(15, 15, 17, 0.9); + --badge-sandbox-bg: rgba(136, 132, 216, 0.15); + --badge-sandbox-text: #b4b0e8; + --hover-surface: rgba(255, 255, 255, 0.03); + --focus-ring: rgba(52, 211, 153, 0.1); + --accent-subtle: rgba(52, 211, 153, 0.15); + --accent-border-subtle: rgba(52, 211, 153, 0.3); + --danger-subtle: rgba(230, 76, 76, 0.15); + --danger-border-subtle: rgba(230, 76, 76, 0.3); + --warning-subtle: rgba(245, 166, 35, 0.15); + --border-hover: rgba(255, 255, 255, 0.15); + --user-msg-bg: rgba(52, 211, 153, 0.08); + --user-msg-border: rgba(52, 211, 153, 0.2); + --danger-error-bg: rgba(230, 76, 76, 0.1); + --accent-tee-bg: rgba(52, 211, 153, 0.1); + --accent-tee-border: rgba(52, 211, 153, 0.25); + --accent-tee-hover: rgba(52, 211, 153, 0.18); + --text-on-danger: #fff; + --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4); + --shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.4); + --danger-error-border: rgba(230, 76, 76, 0.2); + --note-bg: rgba(255, 255, 255, 0.04); + --overlay-heavy: rgba(0, 0, 0, 0.6); + --highlight-bg: rgba(52, 211, 153, 0.3); + --hover-subtle: rgba(255, 255, 255, 0.06); + --transition-fast: 150ms ease; + --transition-base: 0.2s ease; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.15); + --shadow-md: 0 4px 12px rgba(0,0,0,0.4), 0 2px 4px rgba(0,0,0,0.2); + --shadow-lg: 0 12px 40px rgba(0,0,0,0.5), 0 4px 12px rgba(0,0,0,0.3); + --glow-accent: 0 0 20px rgba(52,211,153,0.1); + --glass-bg: rgba(9,9,11,0.72); + --glass-blur: blur(16px) saturate(180%); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-spring-gentle: cubic-bezier(0.22, 1.2, 0.36, 1); + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --surface-highlight: inset 0 1px 0 rgba(255,255,255,0.05); + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --space-8: 32px; + --text-xs: 11px; + --text-sm: 13px; + --text-base: 14px; + --text-lg: 16px; + --text-xl: 20px; + --text-2xl: 24px; + --text-3xl: 36px; + --transition-slow: 300ms ease; + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --duration-instant: 100ms; + --duration-fast: 150ms; + --duration-base: 250ms; + --duration-slow: 400ms; + --accent-soft: var(--accent-subtle); + --accent-dim: var(--accent-subtle); + --bg-hover: var(--hover-surface); + --danger-soft: var(--danger-subtle); + --warning-soft: var(--warning-subtle); +} + +[data-theme="light"] { + --bg: #ffffff; + --bg-secondary: #f5f5f7; + --bg-tertiary: #ebebed; + --border: rgba(0, 0, 0, 0.1); + --text: #1a1a2e; + --text-secondary: #555555; + --accent: #059669; + --accent-hover: #047857; + --success: #059669; + --info: #2563eb; + --warning: #d97706; + --danger: #dc2626; + --code-bg: #f0f0f2; + --shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + --bg-overlay: rgba(0, 0, 0, 0.3); + --bg-modal: #ffffff; + --border-modal: #e0e0e0; + --border-soft: #e5e5e5; + --text-tertiary: #333333; + --text-muted: #777777; + --text-dimmed: #999999; + --text-on-accent: #ffffff; + --accent-brand: #059669; + --accent-brand-hover: #047857; + --warning-bg: #fffbeb; + --warning-border: #fde68a; + --warning-text: #92400e; + --tab-bg: rgba(255, 255, 255, 0.9); + --popover-bg: rgba(255, 255, 255, 0.95); + --badge-sandbox-bg: rgba(136, 132, 216, 0.1); + --badge-sandbox-text: #6b67b0; + --hover-surface: rgba(0, 0, 0, 0.03); + --focus-ring: rgba(5, 150, 105, 0.15); + --accent-subtle: rgba(5, 150, 105, 0.1); + --accent-border-subtle: rgba(5, 150, 105, 0.3); + --danger-subtle: rgba(220, 38, 38, 0.1); + --danger-border-subtle: rgba(220, 38, 38, 0.2); + --warning-subtle: rgba(217, 119, 6, 0.1); + --border-hover: rgba(0, 0, 0, 0.15); + --user-msg-bg: rgba(5, 150, 105, 0.08); + --user-msg-border: rgba(5, 150, 105, 0.2); + --danger-error-bg: rgba(220, 38, 38, 0.06); + --accent-tee-bg: rgba(5, 150, 105, 0.08); + --accent-tee-border: rgba(5, 150, 105, 0.2); + --accent-tee-hover: rgba(5, 150, 105, 0.15); + --text-on-danger: #fff; + --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.08); + --shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.1); + --danger-error-border: rgba(220, 38, 38, 0.15); + --note-bg: rgba(0, 0, 0, 0.02); + --overlay-heavy: rgba(0, 0, 0, 0.4); + --highlight-bg: rgba(5, 150, 105, 0.2); + --hover-subtle: rgba(0, 0, 0, 0.04); + --shadow-sm: 0 1px 2px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04); + --shadow-md: 0 4px 12px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.04); + --glow-accent: 0 0 20px rgba(5,150,105,0.08); + --glass-bg: rgba(255,255,255,0.85); + --glass-blur: blur(16px) saturate(180%); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-spring-gentle: cubic-bezier(0.22, 1.2, 0.36, 1); + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --surface-highlight: inset 0 1px 0 rgba(255,255,255,0.8); + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --space-8: 32px; + --text-xs: 11px; + --text-sm: 13px; + --text-base: 14px; + --text-lg: 16px; + --text-xl: 20px; + --text-2xl: 24px; + --text-3xl: 36px; + --transition-slow: 300ms ease; + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --duration-instant: 100ms; + --duration-fast: 150ms; + --duration-base: 250ms; + --duration-slow: 400ms; + --accent-soft: var(--accent-subtle); + --accent-dim: var(--accent-subtle); + --bg-hover: var(--hover-surface); + --danger-soft: var(--danger-subtle); + --warning-soft: var(--warning-subtle); +} diff --git a/migrations/V24__llm_calls_created_at_index.sql b/migrations/V24__llm_calls_created_at_index.sql new file mode 100644 index 0000000000..d9605a5068 --- /dev/null +++ b/migrations/V24__llm_calls_created_at_index.sql @@ -0,0 +1,12 @@ +-- Add index on llm_calls.created_at to speed up time-range aggregations +-- used by the admin usage summary endpoint. +-- +-- WARNING: This takes a full table lock on llm_calls. For large deployments +-- with millions of rows, this could block writes for several seconds to +-- minutes. Refinery wraps migrations in a transaction, which prevents +-- using CREATE INDEX CONCURRENTLY. If this is a concern, apply the index +-- manually outside the migration framework: +-- +-- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_llm_calls_created_at +-- ON llm_calls(created_at); +CREATE INDEX IF NOT EXISTS idx_llm_calls_created_at ON llm_calls(created_at); diff --git a/scripts/check_no_panics.py b/scripts/check_no_panics.py index 87a2676ad8..d0295cce34 100644 --- a/scripts/check_no_panics.py +++ b/scripts/check_no_panics.py @@ -206,12 +206,25 @@ def line_test_contexts(lines: list[str]) -> list[bool]: return contexts +def is_test_only_path(path: str) -> bool: + """Return True for files that live inside a test-only directory. + + Files under ``src/**/tests/*.rs`` are Rust test sub-modules, typically + included behind ``#[cfg(test)]`` and never compiled in production. + Top-level ``tests/*.rs`` integration test files are already outside + ``src/`` / ``crates/`` and therefore never checked. + """ + parts = pathlib.PurePosixPath(path).parts + return "tests" in parts + + def changed_rust_files(base: str, head: str) -> list[pathlib.Path]: output = run_git("diff", "--name-only", f"{base}...{head}", "--", "src", "crates") files = [] for line in output.splitlines(): if line.endswith(".rs") and (line.startswith("src/") or line.startswith("crates/")): - files.append(pathlib.Path(line)) + if not is_test_only_path(line): + files.append(pathlib.Path(line)) return files @@ -363,6 +376,13 @@ class CheckNoPanicsTests(unittest.TestCase): self.assertFalse(contexts[4]) self.assertFalse(contexts[5]) + def test_test_only_path_detection(self) -> None: + self.assertTrue(is_test_only_path("src/channels/web/tests/multi_tenant.rs")) + self.assertTrue(is_test_only_path("crates/foo/src/tests/helpers.rs")) + self.assertFalse(is_test_only_path("src/channels/web/mod.rs")) + self.assertFalse(is_test_only_path("src/channels/web/test_helpers.rs")) + self.assertFalse(is_test_only_path("crates/foo/src/lib.rs")) + def test_lifetime_annotations_do_not_desync_braces(self) -> None: """Lifetime annotations ('a, 'static) must not be parsed as char literals. diff --git a/src/channels/web/CLAUDE.md b/src/channels/web/CLAUDE.md index 8dbb29220b..8d541b1cdf 100644 --- a/src/channels/web/CLAUDE.md +++ b/src/channels/web/CLAUDE.md @@ -107,6 +107,7 @@ Extension lifecycle note: | POST | `/api/admin/users/{id}/suspend` | Suspend a user | | POST | `/api/admin/users/{id}/activate` | Re-activate a user | | GET | `/api/admin/usage` | Per-user LLM usage stats | +| GET | `/api/admin/usage/summary` | System-wide usage summary for the admin dashboard | | GET | `/api/admin/users/{user_id}/secrets` | List a user's secrets (names only) | | PUT | `/api/admin/users/{user_id}/secrets/{name}` | Create or update a user's secret | | DELETE | `/api/admin/users/{user_id}/secrets/{name}` | Delete a user's secret | @@ -149,6 +150,7 @@ Extension lifecycle note: | Method | Path | Description | |--------|------|-------------| | GET | `/` | Single-page app HTML | +| GET | `/theme.css` | Shared theme tokens for the web and admin SPAs | | GET | `/style.css` | App stylesheet | | GET | `/app.js` | App JavaScript | | GET | `/favicon.ico` | Favicon (cached 1 day) | diff --git a/src/channels/web/handlers/users.rs b/src/channels/web/handlers/users.rs index 0e9f43e6d1..1595aca807 100644 --- a/src/channels/web/handlers/users.rs +++ b/src/channels/web/handlers/users.rs @@ -13,9 +13,41 @@ use uuid::Uuid; use crate::channels::web::auth::{AdminUser, AuthenticatedUser}; use crate::channels::web::server::GatewayState; +use crate::channels::web::types::{ + AdminUsageEntry, AdminUsageStatsResponse, AdminUsageSummaryJobs, AdminUsageSummaryResponse, + AdminUsageSummaryUsers, AdminUsageSummaryWindow, AdminUserCreateResponse, + AdminUserDeleteResponse, AdminUserDetailResponse, AdminUserInfo, AdminUserListResponse, + AdminUserProfileResponse, AdminUserStatusResponse, +}; use crate::db::{Database, UserRecord}; use crate::tools::permissions::ADMIN_SETTINGS_USER_ID; +fn admin_user_info_from_record( + user_record: &UserRecord, + db_stats: Option<&crate::db::UserSummaryStats>, +) -> AdminUserInfo { + let total_cost = db_stats.map_or(rust_decimal::Decimal::ZERO, |s| s.total_cost); + let last_active = db_stats + .and_then(|s| s.last_active_at) + .or(user_record.last_login_at); + + AdminUserInfo { + id: user_record.id.clone(), + email: user_record.email.clone(), + display_name: user_record.display_name.clone(), + status: user_record.status.clone(), + role: user_record.role.clone(), + created_at: user_record.created_at.to_rfc3339(), + updated_at: user_record.updated_at.to_rfc3339(), + last_login_at: user_record.last_login_at.map(|dt| dt.to_rfc3339()), + created_by: user_record.created_by.clone(), + job_count: db_stats.map_or(0, |s| s.job_count), + total_cost: total_cost.to_string(), + last_active_at: last_active.map(|dt| dt.to_rfc3339()), + metadata: None, + } +} + /// Check whether `user_id` is the sole active admin. Returns true if demoting, /// suspending, or deleting this user would leave zero admins. async fn is_last_admin(store: &dyn Database, user_id: &str) -> Result { @@ -32,7 +64,7 @@ pub async fn users_create_handler( State(state): State>, AdminUser(user): AdminUser, Json(body): Json, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), @@ -49,12 +81,29 @@ pub async fn users_create_handler( ))? .to_string(); + if display_name.len() > 200 { + return Err(( + StatusCode::BAD_REQUEST, + "display_name must be at most 200 characters".to_string(), + )); + } + let email = body .get("email") .and_then(|v| v.as_str()) .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(String::from); + + if let Some(ref e) = email + && (!e.contains('@') || e.len() < 3) + { + return Err(( + StatusCode::BAD_REQUEST, + "email must be a valid email address".to_string(), + )); + } + let role = body .get("role") .and_then(|v| v.as_str()) @@ -115,23 +164,23 @@ pub async fn users_create_handler( } })?; - Ok(Json(serde_json::json!({ - "id": user_record.id, - "email": user_record.email, - "display_name": user_record.display_name, - "status": user_record.status, - "role": user_record.role, - "token": plaintext_token, - "created_at": user_record.created_at.to_rfc3339(), - "created_by": user_record.created_by, - }))) + Ok(Json(AdminUserCreateResponse { + id: user_record.id, + email: user_record.email, + display_name: user_record.display_name, + status: user_record.status, + role: user_record.role, + token: plaintext_token, + created_at: user_record.created_at.to_rfc3339(), + created_by: user_record.created_by, + })) } /// GET /api/admin/users — list all users with inline usage stats. pub async fn users_list_handler( State(state): State>, - AdminUser(_user): AdminUser, -) -> Result, (StatusCode, String)> { + AdminUser(_admin): AdminUser, +) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), @@ -153,39 +202,21 @@ pub async fn users_list_handler( .map(|s| (s.user_id.clone(), s)) .collect(); - let mut users_json: Vec = Vec::with_capacity(users.len()); + let mut users_json: Vec = Vec::with_capacity(users.len()); for u in users { let db_stats = stats_map.get(&u.id); - let total_cost = db_stats.map_or(rust_decimal::Decimal::ZERO, |s| s.total_cost); - - // Last active: prefer DB timestamp, fall back to last_login_at. - let last_active = db_stats.and_then(|s| s.last_active_at).or(u.last_login_at); - - users_json.push(serde_json::json!({ - "id": u.id, - "email": u.email, - "display_name": u.display_name, - "status": u.status, - "role": u.role, - "created_at": u.created_at.to_rfc3339(), - "updated_at": u.updated_at.to_rfc3339(), - "last_login_at": u.last_login_at.map(|dt| dt.to_rfc3339()), - "created_by": u.created_by, - "job_count": db_stats.map_or(0, |s| s.job_count), - "total_cost": total_cost.to_string(), - "last_active_at": last_active.map(|dt| dt.to_rfc3339()), - })); + users_json.push(admin_user_info_from_record(&u, db_stats)); } - Ok(Json(serde_json::json!({ "users": users_json }))) + Ok(Json(AdminUserListResponse { users: users_json })) } /// GET /api/admin/users/{id} — get a single user. pub async fn users_detail_handler( State(state): State>, - AdminUser(_user): AdminUser, + AdminUser(_admin): AdminUser, Path(id): Path, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), @@ -197,27 +228,24 @@ pub async fn users_detail_handler( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?; - Ok(Json(serde_json::json!({ - "id": user_record.id, - "email": user_record.email, - "display_name": user_record.display_name, - "status": user_record.status, - "role": user_record.role, - "created_at": user_record.created_at.to_rfc3339(), - "updated_at": user_record.updated_at.to_rfc3339(), - "last_login_at": user_record.last_login_at.map(|dt| dt.to_rfc3339()), - "created_by": user_record.created_by, - "metadata": user_record.metadata, - }))) + let summary_stats = store + .user_summary_stats(Some(&id)) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let db_stats = summary_stats.first(); + let mut user_info = admin_user_info_from_record(&user_record, db_stats); + user_info.metadata = Some(user_record.metadata); + + Ok(Json(user_info)) } /// PATCH /api/admin/users/{id} — update a user's profile. pub async fn users_update_handler( State(state): State>, - AdminUser(_user): AdminUser, + AdminUser(admin): AdminUser, Path(id): Path, Json(body): Json, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), @@ -293,24 +321,26 @@ pub async fn users_update_handler( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?; - Ok(Json(serde_json::json!({ - "id": updated.id, - "email": updated.email, - "display_name": updated.display_name, - "status": updated.status, - "role": updated.role, - "created_at": updated.created_at.to_rfc3339(), - "updated_at": updated.updated_at.to_rfc3339(), - "metadata": updated.metadata, - }))) + tracing::debug!(admin = %admin.user_id, action = "user_updated", target_user = %id, "Admin updated user"); + + Ok(Json(AdminUserProfileResponse { + id: updated.id, + email: updated.email, + display_name: updated.display_name, + status: updated.status, + role: updated.role, + created_at: updated.created_at.to_rfc3339(), + updated_at: updated.updated_at.to_rfc3339(), + metadata: updated.metadata, + })) } /// POST /api/admin/users/{id}/suspend — suspend a user. pub async fn users_suspend_handler( State(state): State>, - AdminUser(_user): AdminUser, + AdminUser(admin): AdminUser, Path(id): Path, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), @@ -356,18 +386,20 @@ pub async fn users_suspend_handler( // metadata until the 60s TTL expires. crate::auth::invalidate_auth_descriptor_cache(&id).await; - Ok(Json(serde_json::json!({ - "id": id, - "status": "suspended", - }))) + tracing::debug!(admin = %admin.user_id, action = "user_suspended", target_user = %id, "Admin suspended user"); + + Ok(Json(AdminUserStatusResponse { + id, + status: "suspended".to_string(), + })) } /// POST /api/admin/users/{id}/activate — activate a user. pub async fn users_activate_handler( State(state): State>, - AdminUser(_user): AdminUser, + AdminUser(admin): AdminUser, Path(id): Path, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), @@ -390,18 +422,20 @@ pub async fn users_activate_handler( db_auth.invalidate_user(&id).await; } - Ok(Json(serde_json::json!({ - "id": id, - "status": "active", - }))) + tracing::debug!(admin = %admin.user_id, action = "user_activated", target_user = %id, "Admin activated user"); + + Ok(Json(AdminUserStatusResponse { + id, + status: "active".to_string(), + })) } /// DELETE /api/admin/users/{id} — delete a user and all their data. pub async fn users_delete_handler( State(state): State>, - AdminUser(_user): AdminUser, + AdminUser(admin): AdminUser, Path(id): Path, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), @@ -427,6 +461,7 @@ pub async fn users_delete_handler( return Err((StatusCode::NOT_FOUND, "User not found".to_string())); } + // Evict cached auth so deleted users lose access immediately. if let Some(ref db_auth) = state.db_auth { db_auth.invalidate_user(&id).await; } @@ -441,10 +476,9 @@ pub async fn users_delete_handler( // rows are gone. crate::auth::invalidate_auth_descriptor_cache(&id).await; - Ok(Json(serde_json::json!({ - "id": id, - "deleted": true, - }))) + tracing::debug!(admin = %admin.user_id, action = "user_deleted", target_user = %id, "Admin deleted user"); + + Ok(Json(AdminUserDeleteResponse { id, deleted: true })) } /// GET /api/profile — get the authenticated user's own profile. @@ -541,9 +575,9 @@ pub async fn profile_update_handler( /// GET /api/admin/usage — per-user LLM usage stats. pub async fn usage_stats_handler( State(state): State>, - AdminUser(_user): AdminUser, + AdminUser(_admin): AdminUser, axum::extract::Query(params): axum::extract::Query>, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "Database not available".to_string(), @@ -562,23 +596,60 @@ pub async fn usage_stats_handler( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let entries: Vec = stats + let entries: Vec = stats .iter() - .map(|s| { - serde_json::json!({ - "user_id": s.user_id, - "model": s.model, - "call_count": s.call_count, - "input_tokens": s.input_tokens, - "output_tokens": s.output_tokens, - "total_cost": s.total_cost.to_string(), - }) + .map(|s| AdminUsageEntry { + user_id: s.user_id.clone(), + model: s.model.clone(), + call_count: s.call_count, + input_tokens: s.input_tokens, + output_tokens: s.output_tokens, + total_cost: s.total_cost.to_string(), }) .collect(); - Ok(Json(serde_json::json!({ - "period": period, - "since": since.to_rfc3339(), - "usage": entries, - }))) + Ok(Json(AdminUsageStatsResponse { + period: period.to_string(), + since: since.to_rfc3339(), + usage: entries, + })) +} + +/// System-wide usage summary for the admin dashboard. +pub async fn usage_summary_handler( + State(state): State>, + AdminUser(_admin): AdminUser, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref(); // dispatch-exempt: admin read-only aggregation + let store = store.ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let since_30d = chrono::Utc::now() - chrono::Duration::days(30); + let summary = store + .admin_usage_summary(since_30d) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let uptime_seconds = state.startup_time.elapsed().as_secs(); + + Ok(Json(AdminUsageSummaryResponse { + users: AdminUsageSummaryUsers { + total: summary.total_users, + active: summary.active_users, + suspended: summary.suspended_users, + admins: summary.admin_users, + }, + jobs: AdminUsageSummaryJobs { + total: summary.total_jobs, + }, + usage_30d: AdminUsageSummaryWindow { + llm_calls: summary.llm_calls, + input_tokens: summary.input_tokens, + output_tokens: summary.output_tokens, + total_cost: summary.usage_cost.to_string(), + }, + uptime_seconds, + })) } diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 5bf5b561fc..d0a3de8e91 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -788,6 +788,10 @@ pub async fn start_server( "/api/admin/usage", get(super::handlers::users::usage_stats_handler), ) + .route( + "/api/admin/usage/summary", + get(super::handlers::users::usage_summary_handler), + ) // User self-service profile .route( "/api/profile", @@ -839,6 +843,7 @@ pub async fn start_server( // Static file routes (no auth, served from embedded strings) let statics = Router::new() .route("/", get(index_handler)) + .route("/theme.css", get(theme_css_handler)) .route("/style.css", get(css_handler)) .route("/app.js", get(js_handler)) .route("/theme-init.js", get(theme_init_handler)) @@ -847,7 +852,13 @@ pub async fn start_server( .route("/i18n/en.js", get(i18n_en_handler)) .route("/i18n/zh-CN.js", get(i18n_zh_handler)) .route("/i18n/ko.js", get(i18n_ko_handler)) - .route("/i18n-app.js", get(i18n_app_handler)); + .route("/i18n-app.js", get(i18n_app_handler)) + // Admin panel SPA (auth handled client-side + API layer) + .route("/admin", get(admin_html_handler)) + .route("/admin/", get(admin_html_handler)) + .route("/admin/{*path}", get(admin_html_handler)) + .route("/admin.css", get(admin_css_handler)) + .route("/admin.js", get(admin_js_handler)); // Project file serving (behind auth to prevent unauthorized file access). let projects = Router::new() @@ -1397,6 +1408,16 @@ async fn css_handler(State(state): State>, headers: HeaderMap) .into_response() } +async fn theme_css_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "text/css"), + (header::CACHE_CONTROL, "no-cache"), + ], + assets::THEME_CSS, + ) +} + async fn js_handler() -> impl IntoResponse { ( [ @@ -1477,6 +1498,59 @@ async fn i18n_app_handler() -> impl IntoResponse { ) } +// --- Admin panel static handlers --- + +async fn admin_html_handler() -> impl IntoResponse { + // Admin panel CSP — fully same-origin, no CDN allowances. + // Delivered as an HTTP header (not a tag) so the browser enforces + // it before any markup is parsed. + const ADMIN_CSP: &str = "default-src 'self'; \ + script-src 'self'; \ + style-src 'self' 'unsafe-inline'; \ + font-src 'self'; \ + connect-src 'self'; \ + img-src 'self' data:; \ + object-src 'none'; \ + frame-ancestors 'none'; \ + base-uri 'self'; \ + form-action 'self'"; + + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("text/html; charset=utf-8"), + ); + headers.insert( + header::CACHE_CONTROL, + header::HeaderValue::from_static("no-cache"), + ); + headers.insert( + header::HeaderName::from_static("content-security-policy"), + header::HeaderValue::from_static(ADMIN_CSP), + ); + (headers, assets::ADMIN_HTML) +} + +async fn admin_css_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "text/css"), + (header::CACHE_CONTROL, "no-cache"), + ], + assets::ADMIN_CSS, + ) +} + +async fn admin_js_handler() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/javascript"), + (header::CACHE_CONTROL, "no-cache"), + ], + assets::ADMIN_JS, + ) +} + // --- Health --- async fn health_handler() -> Json { diff --git a/src/channels/web/tests/multi_tenant.rs b/src/channels/web/tests/multi_tenant.rs index 531046d562..b85ba7a797 100644 --- a/src/channels/web/tests/multi_tenant.rs +++ b/src/channels/web/tests/multi_tenant.rs @@ -835,8 +835,8 @@ mod auth_enforcement { mod admin_role_enforcement { use super::*; use crate::channels::web::handlers::users::{ - users_activate_handler, users_detail_handler, users_list_handler, users_suspend_handler, - users_update_handler, + usage_summary_handler, users_activate_handler, users_detail_handler, users_list_handler, + users_suspend_handler, users_update_handler, }; use axum::routing::patch; @@ -872,6 +872,7 @@ mod admin_role_enforcement { "/api/admin/users/{id}/activate", post(users_activate_handler), ) + .route("/api/admin/usage/summary", get(usage_summary_handler)) .layer(middleware::from_fn_with_state( crate::channels::web::auth::CombinedAuthState::from(auth), auth_middleware, @@ -904,6 +905,7 @@ mod admin_role_enforcement { assert_forbidden_for_member(&app, Method::GET, "/api/admin/users/some-id").await; assert_forbidden_for_member(&app, Method::POST, "/api/admin/users/some-id/suspend").await; assert_forbidden_for_member(&app, Method::POST, "/api/admin/users/some-id/activate").await; + assert_forbidden_for_member(&app, Method::GET, "/api/admin/usage/summary").await; } #[tokio::test] @@ -922,6 +924,314 @@ mod admin_role_enforcement { StatusCode::FORBIDDEN, "admin should not get 403" ); + + let req = Request::builder() + .uri("/api/admin/usage/summary") + .header("Authorization", "Bearer tok-admin") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_ne!( + resp.status(), + StatusCode::FORBIDDEN, + "admin should not get 403 for usage summary" + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Admin API Contract Tests +// ═══════════════════════════════════════════════════════════════════════ + +#[cfg(feature = "libsql")] +mod admin_api_contracts { + use super::*; + use crate::channels::web::handlers::users::{ + usage_stats_handler, usage_summary_handler, users_create_handler, users_detail_handler, + users_list_handler, users_update_handler, + }; + use crate::channels::web::types::{ + AdminUsageStatsResponse, AdminUsageSummaryResponse, AdminUserCreateResponse, + AdminUserDetailResponse, AdminUserListResponse, + }; + use serde::de::DeserializeOwned; + + fn admin_router(state: Arc, auth: MultiAuthState) -> Router { + Router::new() + .route( + "/api/admin/users", + get(users_list_handler).post(users_create_handler), + ) + .route( + "/api/admin/users/{id}", + get(users_detail_handler).patch(users_update_handler), + ) + .route("/api/admin/usage", get(usage_stats_handler)) + .route("/api/admin/usage/summary", get(usage_summary_handler)) + .layer(middleware::from_fn_with_state( + crate::channels::web::auth::CombinedAuthState::from(auth), + auth_middleware, + )) + .with_state(state) + } + + fn test_user( + id: &str, + display_name: &str, + email: Option<&str>, + status: &str, + role: &str, + metadata: serde_json::Value, + ) -> crate::db::UserRecord { + let now = chrono::Utc::now(); + crate::db::UserRecord { + id: id.to_string(), + email: email.map(str::to_string), + display_name: display_name.to_string(), + status: status.to_string(), + role: role.to_string(), + created_at: now, + updated_at: now, + last_login_at: Some(now), + created_by: None, + metadata, + } + } + + async fn parse_json(resp: axum::response::Response) -> T { + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 64 * 1024) + .await + .unwrap(); + serde_json::from_slice(&body).unwrap() + } + + fn assert_rfc3339(ts: &str) { + chrono::DateTime::parse_from_rfc3339(ts).unwrap(); + } + + #[tokio::test] + async fn test_admin_create_user_response_contract() { + let (db, _dir) = test_db().await; + db.create_user(&test_user( + "alice", + "Alice", + Some("alice@example.com"), + "active", + "admin", + serde_json::json!({}), + )) + .await + .unwrap(); + let state = build_state(Some(db), None); + let app = admin_router(state, two_user_auth()); + + let req = Request::builder() + .method(Method::POST) + .uri("/api/admin/users") + .header("Authorization", "Bearer tok-alice") + .header("Content-Type", "application/json") + .body(Body::from( + serde_json::to_string( + &serde_json::json!({"display_name":"Carol","email":"carol@example.com","role":"member"}), + ) + .unwrap(), + )) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let body: AdminUserCreateResponse = parse_json(resp).await; + + assert_eq!(body.display_name, "Carol"); + assert_eq!(body.email.as_deref(), Some("carol@example.com")); + assert_eq!(body.status, "active"); + assert_eq!(body.role, "member"); + assert_eq!(body.created_by.as_deref(), Some("alice")); + assert_eq!(body.token.len(), 64); + } + + #[tokio::test] + async fn test_admin_user_list_response_contract() { + let (db, _dir) = test_db().await; + db.create_user(&test_user( + "carol", + "Carol", + Some("carol@example.com"), + "active", + "member", + serde_json::json!({"team":"ops"}), + )) + .await + .unwrap(); + + let state = build_state(Some(db), None); + let app = admin_router(state, two_user_auth()); + + let req = Request::builder() + .uri("/api/admin/users") + .header("Authorization", "Bearer tok-alice") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let body: AdminUserListResponse = parse_json(resp).await; + + let user = body.users.iter().find(|u| u.id == "carol").unwrap(); + assert_eq!(user.display_name, "Carol"); + assert_eq!(user.email.as_deref(), Some("carol@example.com")); + assert_eq!(user.job_count, 0); + assert_eq!(user.total_cost, "0"); + assert_rfc3339(&user.created_at); + assert_rfc3339(&user.updated_at); + assert_rfc3339(user.last_login_at.as_deref().unwrap()); + assert!(user.last_active_at.is_some()); + assert_rfc3339(user.last_active_at.as_deref().unwrap()); + } + + #[tokio::test] + async fn test_admin_user_detail_response_contract() { + let (db, _dir) = test_db().await; + db.create_user(&test_user( + "carol", + "Carol", + Some("carol@example.com"), + "active", + "member", + serde_json::json!({"team":"ops"}), + )) + .await + .unwrap(); + + let state = build_state(Some(db), None); + let app = admin_router(state, two_user_auth()); + + let req = Request::builder() + .uri("/api/admin/users/carol") + .header("Authorization", "Bearer tok-alice") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let body: AdminUserDetailResponse = parse_json(resp).await; + + assert_eq!(body.id, "carol"); + assert_eq!(body.display_name, "Carol"); + assert_eq!(body.job_count, 0); + assert_eq!(body.total_cost, "0"); + let metadata = body + .metadata + .as_ref() + .expect("metadata populated on detail"); + assert_eq!(metadata["team"], "ops"); + assert_rfc3339(&body.created_at); + assert_rfc3339(&body.updated_at); + assert_rfc3339(body.last_login_at.as_deref().unwrap()); + assert!(body.last_active_at.is_some()); + assert_rfc3339(body.last_active_at.as_deref().unwrap()); + } + + #[tokio::test] + async fn test_admin_usage_stats_response_contract() { + let (db, _dir) = test_db().await; + db.create_user(&test_user( + "carol", + "Carol", + Some("carol@example.com"), + "active", + "member", + serde_json::json!({}), + )) + .await + .unwrap(); + + let state = build_state(Some(db), None); + let app = admin_router(state, two_user_auth()); + + let req = Request::builder() + .uri("/api/admin/usage?period=month") + .header("Authorization", "Bearer tok-alice") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let body: AdminUsageStatsResponse = parse_json(resp).await; + + assert_eq!(body.period, "month"); + assert_rfc3339(&body.since); + assert!(body.usage.is_empty()); + } + + #[tokio::test] + async fn test_admin_usage_summary_response_contract() { + let (db, _dir) = test_db().await; + db.create_user(&test_user( + "alice", + "Alice", + Some("alice@example.com"), + "active", + "admin", + serde_json::json!({}), + )) + .await + .unwrap(); + db.create_user(&test_user( + "bob", + "Bob", + Some("bob@example.com"), + "suspended", + "member", + serde_json::json!({}), + )) + .await + .unwrap(); + + let state = build_state(Some(db), None); + let app = admin_router(state, two_user_auth()); + + let req = Request::builder() + .uri("/api/admin/usage/summary") + .header("Authorization", "Bearer tok-alice") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let body: AdminUsageSummaryResponse = parse_json(resp).await; + + assert_eq!(body.users.total, 2); + assert_eq!(body.users.active, 1); + assert_eq!(body.users.suspended, 1); + assert_eq!(body.users.admins, 1); + assert_eq!(body.jobs.total, 0); + assert_eq!(body.usage_30d.llm_calls, 0); + assert_eq!(body.usage_30d.total_cost, "0"); + } + + #[tokio::test] + async fn test_admin_cannot_demote_last_active_admin() { + let (db, _dir) = test_db().await; + db.create_user(&test_user( + "alice", + "Alice", + Some("alice@example.com"), + "active", + "admin", + serde_json::json!({}), + )) + .await + .unwrap(); + + let state = build_state(Some(db.clone()), None); + let app = admin_router(state, two_user_auth()); + + let req = Request::builder() + .method(Method::PATCH) + .uri("/api/admin/users/alice") + .header("Authorization", "Bearer tok-alice") + .header("Content-Type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({"role":"member"})).unwrap(), + )) + .unwrap(); + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::CONFLICT); + + let user = db.get_user("alice").await.unwrap().unwrap(); + assert_eq!(user.role, "admin"); } } diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index 610fc8729f..f5444c61ea 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -566,6 +566,118 @@ impl ActionResponse { } } +// --- Admin User Management --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminUserInfo { + pub id: String, + pub email: Option, + pub display_name: String, + pub status: String, + pub role: String, + pub created_at: String, + pub updated_at: String, + pub last_login_at: Option, + pub created_by: Option, + pub job_count: i64, + pub total_cost: String, + pub last_active_at: Option, + /// Present on the detail endpoint; omitted from list entries. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminUserListResponse { + pub users: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminUserCreateResponse { + pub id: String, + pub email: Option, + pub display_name: String, + pub status: String, + pub role: String, + pub token: String, + pub created_at: String, + pub created_by: Option, +} + +/// Detail is just `AdminUserInfo` with `metadata` populated. Kept as a named +/// alias so handler signatures stay explicit. +pub type AdminUserDetailResponse = AdminUserInfo; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminUserProfileResponse { + pub id: String, + pub email: Option, + pub display_name: String, + pub status: String, + pub role: String, + pub created_at: String, + pub updated_at: String, + pub metadata: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminUserStatusResponse { + pub id: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminUserDeleteResponse { + pub id: String, + pub deleted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminUsageEntry { + pub user_id: String, + pub model: String, + pub call_count: i64, + pub input_tokens: i64, + pub output_tokens: i64, + pub total_cost: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminUsageStatsResponse { + pub period: String, + pub since: String, + pub usage: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminUsageSummaryUsers { + pub total: i64, + pub active: i64, + pub suspended: i64, + pub admins: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminUsageSummaryJobs { + pub total: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminUsageSummaryWindow { + pub llm_calls: i64, + pub input_tokens: i64, + pub output_tokens: i64, + pub total_cost: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminUsageSummaryResponse { + pub users: AdminUsageSummaryUsers, + pub jobs: AdminUsageSummaryJobs, + pub usage_30d: AdminUsageSummaryWindow, + pub uptime_seconds: u64, +} + // --- Registry --- #[derive(Debug, Serialize)] diff --git a/src/db/libsql/users.rs b/src/db/libsql/users.rs index 2f93647104..936c1c8d59 100644 --- a/src/db/libsql/users.rs +++ b/src/db/libsql/users.rs @@ -7,7 +7,7 @@ use uuid::Uuid; use super::{fmt_opt_ts, fmt_ts, get_opt_text, get_opt_ts, get_text, get_ts, opt_text}; use crate::db::libsql::LibSqlBackend; -use crate::db::{ApiTokenRecord, DatabaseError, UserRecord, UserStore}; +use crate::db::{AdminUsageSummary, ApiTokenRecord, DatabaseError, UserRecord, UserStore}; use crate::workspace::GREETING_SEED; fn row_to_user(row: &libsql::Row) -> Result { @@ -850,6 +850,87 @@ impl UserStore for LibSqlBackend { } Ok(stats) } + + /// All LLM aggregates are scoped to `since` so the query is driven by + /// the `idx_llm_calls_created_at` index rather than a full table scan. + async fn admin_usage_summary( + &self, + since: DateTime, + ) -> Result { + let conn = self.connect().await?; + let since_str = fmt_ts(&since); + let mut rows = conn + .query( + r#" + SELECT + (SELECT COUNT(*) FROM users) AS total_users, + (SELECT COUNT(*) FROM users WHERE status = 'active') AS active_users, + (SELECT COUNT(*) FROM users WHERE status = 'suspended') AS suspended_users, + (SELECT COUNT(*) FROM users WHERE role = 'admin') AS admin_users, + (SELECT COUNT(*) FROM agent_jobs) AS total_jobs, + recent.llm_calls, + recent.input_tokens, + recent.output_tokens, + recent.usage_cost + FROM ( + SELECT + COUNT(*) AS llm_calls, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + CAST(COALESCE(SUM(cost), 0) AS TEXT) AS usage_cost + FROM llm_calls + WHERE created_at >= ?1 + ) recent + "#, + params![since_str], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + + let row = rows + .next() + .await + .map_err(|e| DatabaseError::Query(e.to_string()))? + .ok_or_else(|| { + DatabaseError::Query("admin usage summary query returned no rows".to_string()) + })?; + + let usage_cost_str = get_text(&row, 8); + let usage_cost = rust_decimal::Decimal::from_str_exact(&usage_cost_str).map_err(|e| { + DatabaseError::Query(format!( + "invalid usage_cost value '{}': {}", + usage_cost_str, e + )) + })?; + + Ok(AdminUsageSummary { + total_users: row + .get::(0) + .map_err(|e| DatabaseError::Query(e.to_string()))?, + active_users: row + .get::(1) + .map_err(|e| DatabaseError::Query(e.to_string()))?, + suspended_users: row + .get::(2) + .map_err(|e| DatabaseError::Query(e.to_string()))?, + admin_users: row + .get::(3) + .map_err(|e| DatabaseError::Query(e.to_string()))?, + total_jobs: row + .get::(4) + .map_err(|e| DatabaseError::Query(e.to_string()))?, + llm_calls: row + .get::(5) + .map_err(|e| DatabaseError::Query(e.to_string()))?, + input_tokens: row + .get::(6) + .map_err(|e| DatabaseError::Query(e.to_string()))?, + output_tokens: row + .get::(7) + .map_err(|e| DatabaseError::Query(e.to_string()))?, + usage_cost, + }) + } } #[cfg(test)] @@ -1231,4 +1312,48 @@ mod tests { let gpt35 = stats.iter().find(|s| s.model == "gpt-3.5").unwrap(); assert_eq!(gpt35.call_count, 1); } + + #[tokio::test] + async fn test_admin_usage_summary_aggregates_in_db() { + let (db, _dir) = setup().await; + db.create_user(&test_user("alice")).await.unwrap(); + db.create_user(&test_user("bob")).await.unwrap(); + db.update_user_role("alice", "admin").await.unwrap(); + db.update_user_status("bob", "suspended").await.unwrap(); + + insert_test_job(&db, "job-a1", "alice").await; + insert_test_job(&db, "job-a2", "alice").await; + insert_test_job(&db, "job-b1", "bob").await; + insert_test_llm_call(&db, "job-a1", "gpt-4", "0.05").await; + insert_test_llm_call(&db, "job-a2", "gpt-4", "0.10").await; + insert_test_llm_call(&db, "job-a2", "gpt-3.5", "0.01").await; + + let since = chrono::Utc::now() - chrono::Duration::hours(1); + let summary = db.admin_usage_summary(since).await.unwrap(); + + assert_eq!(summary.total_users, 2); + assert_eq!(summary.active_users, 1); + assert_eq!(summary.suspended_users, 1); + assert_eq!(summary.admin_users, 1); + assert_eq!(summary.total_jobs, 3); + assert_eq!(summary.llm_calls, 3); + assert_eq!(summary.input_tokens, 300); + assert_eq!(summary.output_tokens, 150); + assert_eq!( + summary.usage_cost, + rust_decimal::Decimal::from_str_exact("0.16").unwrap() + ); + + // Regression: `since` must actually bound the LLM aggregates. + // A `since` in the future should exclude every row we just inserted. + let future = chrono::Utc::now() + chrono::Duration::hours(1); + let bounded = db.admin_usage_summary(future).await.unwrap(); + assert_eq!(bounded.llm_calls, 0); + assert_eq!(bounded.input_tokens, 0); + assert_eq!(bounded.output_tokens, 0); + assert_eq!(bounded.usage_cost, rust_decimal::Decimal::ZERO); + // Non-windowed counts are unaffected. + assert_eq!(bounded.total_users, 2); + assert_eq!(bounded.total_jobs, 3); + } } diff --git a/src/db/libsql_migrations.rs b/src/db/libsql_migrations.rs index 2df0750374..354cfe50a8 100644 --- a/src/db/libsql_migrations.rs +++ b/src/db/libsql_migrations.rs @@ -983,6 +983,13 @@ WHERE source_channel IS NULL; // includes this column for fresh installs. r#" ALTER TABLE agent_jobs ADD COLUMN restart_params TEXT; +"#, + ), + ( + 24, + "llm_calls_created_at_index", + r#" +CREATE INDEX IF NOT EXISTS idx_llm_calls_created_at ON llm_calls(created_at); "#, ), ]; diff --git a/src/db/mod.rs b/src/db/mod.rs index 069defd3c8..b47a95bc52 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1046,6 +1046,12 @@ pub trait UserStore: Send + Sync { user_id: Option<&str>, ) -> Result, DatabaseError>; + /// Aggregated usage summary for the admin dashboard. + async fn admin_usage_summary( + &self, + since: DateTime, + ) -> Result; + /// Create a user and their initial API token atomically. /// If either operation fails, both are rolled back. async fn create_user_with_token( @@ -1081,6 +1087,24 @@ pub struct UserSummaryStats { pub last_active_at: Option>, } +/// Aggregated usage summary for the admin dashboard. +/// +/// LLM usage fields (`llm_calls`, `input_tokens`, `output_tokens`, `usage_cost`) +/// are scoped to the 30-day window passed as `since` — this keeps the query +/// index-driven and avoids full `llm_calls` scans on every dashboard refresh. +#[derive(Debug, Clone)] +pub struct AdminUsageSummary { + pub total_users: i64, + pub active_users: i64, + pub suspended_users: i64, + pub admin_users: i64, + pub total_jobs: i64, + pub llm_calls: i64, + pub input_tokens: i64, + pub output_tokens: i64, + pub usage_cost: Decimal, +} + /// A pending pairing request. #[derive(Debug, Clone)] pub struct PairingRequestRecord { diff --git a/src/db/postgres.rs b/src/db/postgres.rs index 8e0f27a139..59c0312fc6 100644 --- a/src/db/postgres.rs +++ b/src/db/postgres.rs @@ -1081,6 +1081,13 @@ impl UserStore for PgBackend { self.store.user_summary_stats(user_id).await } + async fn admin_usage_summary( + &self, + since: DateTime, + ) -> Result { + self.store.admin_usage_summary(since).await + } + async fn create_user_with_token( &self, user: &UserRecord, diff --git a/src/history/store.rs b/src/history/store.rs index c07dc394b4..828cb6c2fd 100644 --- a/src/history/store.rs +++ b/src/history/store.rs @@ -3063,6 +3063,53 @@ impl Store { } Ok(stats) } + + /// All LLM aggregates are scoped to `since` so the query is served by + /// `idx_llm_calls_created_at` rather than a full `llm_calls` scan. + pub async fn admin_usage_summary( + &self, + since: DateTime, + ) -> Result { + let conn = self.conn().await?; + let row = conn + .query_one( + r#" + SELECT + (SELECT COUNT(*) FROM users) AS total_users, + (SELECT COUNT(*) FROM users WHERE status = 'active') AS active_users, + (SELECT COUNT(*) FROM users WHERE status = 'suspended') AS suspended_users, + (SELECT COUNT(*) FROM users WHERE role = 'admin') AS admin_users, + (SELECT COUNT(*) FROM agent_jobs) AS total_jobs, + recent.llm_calls, + recent.input_tokens, + recent.output_tokens, + recent.usage_cost + FROM ( + SELECT + COUNT(*) AS llm_calls, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(cost), 0::numeric) AS usage_cost + FROM llm_calls + WHERE created_at >= $1 + ) recent + "#, + &[&since], + ) + .await?; + + Ok(crate::db::AdminUsageSummary { + total_users: row.get("total_users"), + active_users: row.get("active_users"), + suspended_users: row.get("suspended_users"), + admin_users: row.get("admin_users"), + total_jobs: row.get("total_jobs"), + llm_calls: row.get("llm_calls"), + input_tokens: row.get("input_tokens"), + output_tokens: row.get("output_tokens"), + usage_cost: row.get("usage_cost"), + }) + } } #[cfg(feature = "postgres")] @@ -3131,6 +3178,155 @@ mod tests { } } + /// PG integration test for admin_usage_summary. + /// Mirrors src/db/libsql/users.rs::test_admin_usage_summary_aggregates_in_db. + /// Requires a running PostgreSQL instance (integration tier). + #[cfg(feature = "postgres")] + #[tokio::test] + #[ignore] + async fn test_admin_usage_summary_pg() { + use crate::config::Config; + use crate::context::JobContext; + + let _ = dotenvy::dotenv(); + let config = Config::from_env().await.expect("Failed to load config"); + let store = Store::new(&config.database) + .await + .expect("Failed to connect to database"); + store + .run_migrations() + .await + .expect("Failed to run migrations"); + + // Use unique IDs to avoid collisions with other test runs. + let test_id = Uuid::new_v4().to_string(); + let alice_id = format!("alice-{test_id}"); + let bob_id = format!("bob-{test_id}"); + let now = chrono::Utc::now(); + + // Create two test users. + let alice = crate::db::UserRecord { + id: alice_id.clone(), + email: Some(format!("alice-{test_id}@test.local")), + display_name: "Alice".to_string(), + status: "active".to_string(), + role: "admin".to_string(), + created_at: now, + updated_at: now, + last_login_at: None, + created_by: None, + metadata: serde_json::json!({}), + }; + let bob = crate::db::UserRecord { + id: bob_id.clone(), + email: Some(format!("bob-{test_id}@test.local")), + display_name: "Bob".to_string(), + status: "suspended".to_string(), + role: "member".to_string(), + created_at: now, + updated_at: now, + last_login_at: None, + created_by: None, + metadata: serde_json::json!({}), + }; + store.create_user(&alice).await.unwrap(); + store.create_user(&bob).await.unwrap(); + + // Create jobs for each user. + let ctx_a1 = JobContext::with_user(&alice_id, "Job A1", "test"); + let ctx_a2 = JobContext::with_user(&alice_id, "Job A2", "test"); + let ctx_b1 = JobContext::with_user(&bob_id, "Job B1", "test"); + store.save_job(&ctx_a1).await.unwrap(); + store.save_job(&ctx_a2).await.unwrap(); + store.save_job(&ctx_b1).await.unwrap(); + + // Record LLM calls. + store + .record_llm_call(&LlmCallRecord { + job_id: Some(ctx_a1.job_id), + conversation_id: None, + provider: "openai", + model: "gpt-4", + input_tokens: 100, + output_tokens: 50, + cost: Decimal::from_str_exact("0.05").unwrap(), + purpose: None, + }) + .await + .unwrap(); + store + .record_llm_call(&LlmCallRecord { + job_id: Some(ctx_a2.job_id), + conversation_id: None, + provider: "openai", + model: "gpt-4", + input_tokens: 100, + output_tokens: 50, + cost: Decimal::from_str_exact("0.10").unwrap(), + purpose: None, + }) + .await + .unwrap(); + store + .record_llm_call(&LlmCallRecord { + job_id: Some(ctx_a2.job_id), + conversation_id: None, + provider: "openai", + model: "gpt-3.5", + input_tokens: 100, + output_tokens: 50, + cost: Decimal::from_str_exact("0.01").unwrap(), + purpose: None, + }) + .await + .unwrap(); + + let since = chrono::Utc::now() - chrono::Duration::hours(1); + let summary = store.admin_usage_summary(since).await.unwrap(); + + // Assertions on counts — the DB may contain rows from other runs, so + // assert >= for global counts; the test users we just inserted must be + // reflected. + assert!(summary.total_users >= 2, "expected at least 2 users"); + assert!(summary.active_users >= 1, "expected at least 1 active user"); + assert!( + summary.suspended_users >= 1, + "expected at least 1 suspended user" + ); + assert!(summary.admin_users >= 1, "expected at least 1 admin user"); + assert!(summary.total_jobs >= 3, "expected at least 3 jobs"); + assert!(summary.llm_calls >= 3, "expected at least 3 LLM calls"); + assert!( + summary.input_tokens >= 300, + "expected at least 300 input tokens" + ); + assert!( + summary.output_tokens >= 150, + "expected at least 150 output tokens" + ); + assert!( + summary.usage_cost >= Decimal::from_str_exact("0.16").unwrap(), + "expected usage_cost >= 0.16, got {}", + summary.usage_cost + ); + + // Clean up test data. + let conn = store.conn().await.unwrap(); + for job_id in [ctx_a1.job_id, ctx_a2.job_id, ctx_b1.job_id] { + conn.execute("DELETE FROM llm_calls WHERE job_id = $1", &[&job_id]) + .await + .unwrap(); + conn.execute("DELETE FROM agent_jobs WHERE id = $1", &[&job_id]) + .await + .unwrap(); + } + for uid in [&alice_id, &bob_id] { + conn.execute("DELETE FROM users WHERE id = $1", &[uid]) + .await + .unwrap(); + } + } + /// Regression test: save_job must persist user_id and get_job must return it. /// Requires a running PostgreSQL instance (integration tier). #[cfg(feature = "postgres")]