diff --git a/application/api/admin/routes.py b/application/api/admin/routes.py index dca86fa6..5676679f 100644 --- a/application/api/admin/routes.py +++ b/application/api/admin/routes.py @@ -3,18 +3,30 @@ Every resource here is behind ``@admin_required``. The frontend route guard is cosmetic — this server-side decorator is the actual security boundary, so any new endpoint added to this namespace MUST carry it. + +Reads are cross-user aggregates/feeds (deliberately *not* scoped to the +caller's ``sub``, unlike the rest of the API). Mutations (role grant/revoke, +deactivate, force-logout) are written through the existing repositories and +audited to ``auth_events`` with the acting admin recorded. """ from __future__ import annotations import logging +from datetime import datetime, timedelta, timezone from flask import jsonify, make_response, request from flask_restx import Namespace, Resource -from application.api.user.authz import admin_required +from application.api.oidc import denylist +from application.api.user.authz import ROLE_ADMIN, admin_required +from application.storage.db.repositories.admin_stats import AdminStatsRepository +from application.storage.db.repositories.auth_events import AuthEventsRepository +from application.storage.db.repositories.device_audit_log import DeviceAuditLogRepository +from application.storage.db.repositories.token_usage import TokenUsageRepository +from application.storage.db.repositories.user_roles import UserRolesRepository from application.storage.db.repositories.users import UsersRepository -from application.storage.db.session import db_readonly +from application.storage.db.session import db_readonly, db_session logger = logging.getLogger(__name__) @@ -31,22 +43,57 @@ def _int_arg(name: str, default: int) -> int: return default +def _page() -> tuple[int, int, int]: + page = max(1, _int_arg("page", 1)) + page_size = max(1, min(_MAX_PAGE_SIZE, _int_arg("page_size", _DEFAULT_PAGE_SIZE))) + return page, page_size, (page - 1) * page_size + + +def _actor() -> str | None: + token = getattr(request, "decoded_token", None) + return token.get("sub") if isinstance(token, dict) else None + + +def _since_arg(default_days: int) -> datetime | None: + """``?since_days=N`` → a UTC cutoff datetime, or None for 'all time'.""" + days = _int_arg("since_days", default_days) + if days <= 0: + return None + return datetime.now(timezone.utc) - timedelta(days=days) + + +@admin_ns.route("/admin/overview") +class AdminOverviewResource(Resource): + @admin_required + def get(self): + """Top-line KPIs for the dashboard home.""" + with db_readonly() as conn: + stats = AdminStatsRepository(conn).overview() + return make_response(jsonify({"success": True, **stats}), 200) + + @admin_ns.route("/admin/users") class AdminUsersResource(Resource): @admin_required def get(self): - """List users, paginated. Optional exact ``user_id`` filter.""" - page = max(1, _int_arg("page", 1)) - page_size = max(1, min(_MAX_PAGE_SIZE, _int_arg("page_size", _DEFAULT_PAGE_SIZE))) - offset = (page - 1) * page_size + """List users, paginated, most-recently-seen first. + + Each row carries ``last_seen`` (max auth-event time). Admin status is + intentionally not joined here; the dashboard cross-references + GET /api/admin/admins for badges. + """ + page, page_size, offset = _page() user_id_filter = request.args.get("user_id") or None with db_readonly() as conn: - total, rows = UsersRepository(conn).list_paginated(user_id_filter, offset, page_size) + total, rows = AdminStatsRepository(conn).list_users( + user_id_filter, offset, page_size + ) users = [ { "user_id": row.get("user_id"), "active": bool(row.get("active", True)), "created_at": row.get("created_at"), + "last_seen": row.get("last_seen"), } for row in rows ] @@ -63,3 +110,266 @@ class AdminUsersResource(Resource): ), 200, ) + + +@admin_ns.route("/admin/users/") +class AdminUserResource(Resource): + @admin_required + def get(self, user_id): + """Per-user drill-down: profile, roles, recent auth events, counts.""" + with db_readonly() as conn: + user = UsersRepository(conn).get(user_id) + if user is None: + return make_response(jsonify({"success": False}), 404) + roles_repo = UserRolesRepository(conn) + body = { + "success": True, + "user": { + "user_id": user.get("user_id"), + "active": bool(user.get("active", True)), + "created_at": user.get("created_at"), + "updated_at": user.get("updated_at"), + }, + "roles": sorted({"user", *roles_repo.role_names_for(user_id)}), + "grants": roles_repo.list_for(user_id), + "recent_events": AuthEventsRepository(conn).list_recent( + user_id, limit=20 + ), + "counts": AdminStatsRepository(conn).user_counts(user_id), + } + return make_response(jsonify(body), 200) + + @admin_required + def patch(self, user_id): + """Activate/deactivate a user. Deactivation also revokes live sessions.""" + actor = _actor() + data = request.get_json(silent=True) or {} + if "active" not in data or not isinstance(data["active"], bool): + return make_response( + jsonify({"success": False, "message": "Body requires boolean 'active'"}), + 400, + ) + active = data["active"] + if not active and user_id == actor: + return make_response( + jsonify( + {"success": False, "message": "You cannot deactivate yourself"} + ), + 409, + ) + with db_session() as conn: + user = UsersRepository(conn).get(user_id) + if user is None: + return make_response(jsonify({"success": False}), 404) + updated = UsersRepository(conn).set_active(str(user["id"]), active) + AuthEventsRepository(conn).insert( + user_id, + "admin_user_activated" if active else "admin_user_deactivated", + ip=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + metadata={"by": actor, "via": "admin_api"}, + ) + if not active: + # Best-effort live-session revocation (mirrors SCIM deactivation). + denylist.deny_user(user_id) + return make_response( + jsonify({"success": True, "active": bool(updated.get("active", active))}), + 200, + ) + + +@admin_ns.route("/admin/users//role") +class AdminUserRoleResource(Resource): + @admin_required + def post(self, user_id): + """Grant the admin role to ``user_id`` (manual source). Idempotent.""" + actor = _actor() + with db_session() as conn: + inserted = UserRolesRepository(conn).grant( + user_id, ROLE_ADMIN, source="manual", granted_by=actor + ) + if inserted: + AuthEventsRepository(conn).insert( + user_id, + "role_granted", + ip=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + metadata={ + "role": ROLE_ADMIN, + "source": "manual", + "granted_by": actor, + "via": "admin_api", + }, + ) + return make_response( + jsonify({"success": True, "granted": inserted, "role": ROLE_ADMIN}), 200 + ) + + @admin_required + def delete(self, user_id): + """Revoke the manual admin grant. Refuses to remove the last admin.""" + actor = _actor() + with db_session() as conn: + roles_repo = UserRolesRepository(conn) + admins = roles_repo.list_admins() + if len(admins) <= 1 and any(a["user_id"] == user_id for a in admins): + return make_response( + jsonify( + { + "success": False, + "message": "Cannot remove the last admin", + } + ), + 409, + ) + removed = roles_repo.revoke(user_id, ROLE_ADMIN, source="manual") + if removed: + AuthEventsRepository(conn).insert( + user_id, + "role_revoked", + ip=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + metadata={ + "role": ROLE_ADMIN, + "source": "manual", + "revoked_by": actor, + "via": "admin_api", + }, + ) + return make_response(jsonify({"success": True, "revoked": removed}), 200) + + +@admin_ns.route("/admin/users//revoke-sessions") +class AdminUserSessionsResource(Resource): + @admin_required + def post(self, user_id): + """Force-logout: revoke the user's live OIDC sessions (best-effort).""" + ok = denylist.deny_user(user_id) + with db_session() as conn: + AuthEventsRepository(conn).insert( + user_id, + "admin_sessions_revoked", + ip=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + metadata={"by": _actor(), "via": "admin_api", "persisted": ok}, + ) + return make_response(jsonify({"success": True, "revoked": ok}), 200) + + +@admin_ns.route("/admin/admins") +class AdminAdminsResource(Resource): + @admin_required + def get(self): + """List all admins with earliest grant time + grant sources.""" + with db_readonly() as conn: + admins = UserRolesRepository(conn).list_admins() + return make_response(jsonify({"success": True, "admins": admins}), 200) + + +@admin_ns.route("/admin/usage") +class AdminUsageResource(Resource): + _GROUP_BY = ("none", "model", "agent", "source") + _BUCKETS = ("day", "hour") + + @admin_required + def get(self): + """Global token usage: time-bucketed series + total + top users.""" + days = max(1, min(365, _int_arg("days", 30))) + bucket = request.args.get("bucket", "day") + group_by = request.args.get("group_by", "none") + if bucket not in self._BUCKETS or group_by not in self._GROUP_BY: + return make_response(jsonify({"success": False, "message": "Invalid option"}), 400) + start = datetime.now(timezone.utc) - timedelta(days=days) + with db_readonly() as conn: + usage_repo = TokenUsageRepository(conn) + series = usage_repo.bucketed_totals( + bucket_unit=bucket, + timestamp_gte=start, + group_by=None if group_by == "none" else group_by, + ) + total = usage_repo.sum_tokens_in_range( + start=start, end=datetime.now(timezone.utc) + ) + top_users = AdminStatsRepository(conn).top_token_users(since=start, limit=10) + return make_response( + jsonify( + { + "success": True, + "days": days, + "bucket": bucket, + "group_by": group_by, + "series": series, + "total_tokens": int(total), + "top_users": top_users, + } + ), + 200, + ) + + +@admin_ns.route("/admin/audit") +class AdminAuditResource(Resource): + @admin_required + def get(self): + """Global auth-events feed, newest first. Filter by event/user/since.""" + page, page_size, offset = _page() + event = request.args.get("event") or None + user_id = request.args.get("user_id") or None + since = _since_arg(default_days=0) # 0 = all time + with db_readonly() as conn: + repo = AuthEventsRepository(conn) + total = repo.count_all(event=event, user_id=user_id, since=since) + events = repo.list_all( + event=event, + user_id=user_id, + since=since, + limit=page_size, + offset=offset, + ) + return make_response( + jsonify( + { + "success": True, + "events": events, + "page": page, + "page_size": page_size, + "total": total, + "has_more": offset + len(events) < total, + } + ), + 200, + ) + + +@admin_ns.route("/admin/devices/audit") +class AdminDeviceAuditResource(Resource): + @admin_required + def get(self): + """Global remote-device command audit feed. Filter by decision/user/since.""" + page, page_size, offset = _page() + decision = request.args.get("decision") or None + user_id = request.args.get("user_id") or None + since = _since_arg(default_days=0) + with db_readonly() as conn: + repo = DeviceAuditLogRepository(conn) + total = repo.count_global(decision=decision, user_id=user_id, since=since) + rows = repo.list_global( + decision=decision, + user_id=user_id, + since=since, + limit=page_size, + offset=offset, + ) + return make_response( + jsonify( + { + "success": True, + "invocations": rows, + "page": page, + "page_size": page_size, + "total": total, + "has_more": offset + len(rows) < total, + } + ), + 200, + ) diff --git a/application/api/user/routes.py b/application/api/user/routes.py index 6e761cc3..aa792c93 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -5,7 +5,6 @@ Main user API routes - registers all namespace modules. from flask import Blueprint from application.api import api -from application.api.admin import admin_ns from .agents import ( agents_folders_ns, agents_ns, @@ -70,9 +69,3 @@ api.add_namespace(tools_mcp_ns) # Workflows api.add_namespace(workflows_ns) - -# Admin (admin-gated management endpoints). Registered here, in this -# import-cached module, rather than in app.py's body so a re-import of -# application.app (e.g. coverage tests that reload the module) doesn't re-fire -# add_namespace against the already-initialized Api. -api.add_namespace(admin_ns) diff --git a/application/app.py b/application/app.py index 88ea6d78..ad9f0140 100644 --- a/application/app.py +++ b/application/app.py @@ -15,6 +15,7 @@ from application.core.logging_config import setup_logging setup_logging() from application.api import api # noqa: E402 +from application.api.admin import admin_ns # noqa: E402 from application.api.answer import answer # noqa: E402 from application.api.devices import devices_bp # noqa: E402 from application.api.events.routes import events # noqa: E402 @@ -68,6 +69,12 @@ app.register_blueprint(devices_bp) app.register_blueprint(oidc_bp) app.register_blueprint(scim_bp) app.register_blueprint(v1_bp) +# Register the admin namespace once. The membership guard makes this idempotent +# if application.app is re-imported (coverage tests reload the module): without +# it, re-running add_namespace would re-register routes on the already-served +# first app and raise "add_url_rule can no longer be called". +if admin_ns not in api.namespaces: + api.add_namespace(admin_ns) app.config.update( UPLOAD_FOLDER="inputs", CELERY_BROKER_URL=settings.CELERY_BROKER_URL, diff --git a/application/storage/db/repositories/admin_stats.py b/application/storage/db/repositories/admin_stats.py new file mode 100644 index 00000000..a78fa316 --- /dev/null +++ b/application/storage/db/repositories/admin_stats.py @@ -0,0 +1,137 @@ +"""Read-only cross-table aggregations for the admin dashboard. + +Unlike the per-table repositories, this one answers operator-level questions +that span tables (counts, engagement, spend). All methods are read-only and +time-bounded where they touch large tables, so they are safe to call from an +admin-gated dashboard but should not be put on any hot path. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Optional + +from sqlalchemy import Connection, text + +from application.storage.db.base_repository import row_to_dict + + +class AdminStatsRepository: + """Global aggregates for the admin overview and per-user drill-down.""" + + def __init__(self, conn: Connection) -> None: + self._conn = conn + + def _scalar(self, sql: str, params: dict | None = None) -> int: + return int(self._conn.execute(text(sql), params or {}).scalar() or 0) + + def overview(self) -> dict: + """Top-line counts + recent-window engagement for the dashboard home.""" + now = datetime.now(timezone.utc) + cutoff_7d = now - timedelta(days=7) + cutoff_30d = now - timedelta(days=30) + + users_total = self._scalar("SELECT count(*) FROM users") + users_active = self._scalar("SELECT count(*) FROM users WHERE active") + return { + "users": { + "total": users_total, + "active": users_active, + "inactive": users_total - users_active, + }, + "admins": self._scalar( + "SELECT count(DISTINCT user_id) FROM user_roles WHERE role = 'admin'" + ), + "agents": self._scalar("SELECT count(*) FROM agents"), + "sources": self._scalar("SELECT count(*) FROM sources"), + "conversations": self._scalar("SELECT count(*) FROM conversations"), + "new_users_7d": self._scalar( + "SELECT count(*) FROM users WHERE created_at >= :c", {"c": cutoff_7d} + ), + "active_users_30d": self._scalar( + "SELECT count(DISTINCT user_id) FROM token_usage " + "WHERE timestamp >= :c AND user_id IS NOT NULL", + {"c": cutoff_30d}, + ), + "failed_logins_7d": self._scalar( + "SELECT count(*) FROM auth_events " + "WHERE event = 'oidc_login_denied' AND created_at >= :c", + {"c": cutoff_7d}, + ), + "tokens_30d": self._scalar( + "SELECT COALESCE(SUM(prompt_tokens + generated_tokens), 0) " + "FROM token_usage WHERE timestamp >= :c", + {"c": cutoff_30d}, + ), + } + + def top_token_users(self, *, since: datetime, limit: int = 10) -> list[dict]: + """Highest token consumers since ``since`` (admin usage view).""" + result = self._conn.execute( + text( + """ + SELECT user_id, SUM(prompt_tokens + generated_tokens) AS tokens + FROM token_usage + WHERE timestamp >= :since AND user_id IS NOT NULL + GROUP BY user_id + ORDER BY tokens DESC + LIMIT :limit + """ + ), + {"since": since, "limit": int(limit)}, + ) + return [{"user_id": r[0], "tokens": int(r[1])} for r in result.fetchall()] + + def list_users( + self, user_id_filter: Optional[str], offset: int, limit: int + ) -> tuple[int, list[dict]]: + """Paginated users with their last auth-event time, most-recently-seen first. + + ``last_seen`` is max(auth_events.created_at) for the user (NULL = never). + Ordering surfaces active accounts first, dormant ones last — the natural + triage order for an operator. + """ + where = "" + count_params: dict = {} + if user_id_filter is not None: + where = "WHERE lower(u.user_id) = lower(:uid)" + count_params["uid"] = user_id_filter + total = self._conn.execute( + text(f"SELECT count(*) FROM users u {where}"), count_params + ).scalar_one() + result = self._conn.execute( + text( + f""" + SELECT u.user_id, u.active, u.created_at, + max(ae.created_at) AS last_seen + FROM users u + LEFT JOIN auth_events ae ON ae.user_id = u.user_id + {where} + GROUP BY u.user_id, u.active, u.created_at + ORDER BY max(ae.created_at) DESC NULLS LAST, u.created_at DESC + LIMIT :limit OFFSET :offset + """ + ), + {**count_params, "limit": int(limit), "offset": int(offset)}, + ) + return int(total), [row_to_dict(row) for row in result.fetchall()] + + def user_counts(self, user_id: str) -> dict: + """Per-user resource counts for the drill-down view.""" + cutoff_30d = datetime.now(timezone.utc) - timedelta(days=30) + return { + "agents": self._scalar( + "SELECT count(*) FROM agents WHERE user_id = :u", {"u": user_id} + ), + "sources": self._scalar( + "SELECT count(*) FROM sources WHERE user_id = :u", {"u": user_id} + ), + "conversations": self._scalar( + "SELECT count(*) FROM conversations WHERE user_id = :u", {"u": user_id} + ), + "tokens_30d": self._scalar( + "SELECT COALESCE(SUM(prompt_tokens + generated_tokens), 0) " + "FROM token_usage WHERE user_id = :u AND timestamp >= :c", + {"u": user_id, "c": cutoff_30d}, + ), + } diff --git a/application/storage/db/repositories/auth_events.py b/application/storage/db/repositories/auth_events.py index 299e78b0..e060a36b 100644 --- a/application/storage/db/repositories/auth_events.py +++ b/application/storage/db/repositories/auth_events.py @@ -57,3 +57,52 @@ class AuthEventsRepository: {"user_id": user_id, "limit": limit}, ) return [row_to_dict(row) for row in result.fetchall()] + + @staticmethod + def _filter_clauses(event, user_id, since) -> tuple[str, dict]: + clauses: list[str] = [] + params: dict = {} + if event: + clauses.append("event = :event") + params["event"] = event + if user_id: + clauses.append("user_id = :user_id") + params["user_id"] = user_id + if since is not None: + clauses.append("created_at >= :since") + params["since"] = since + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + return where, params + + def list_all( + self, + *, + event: Optional[str] = None, + user_id: Optional[str] = None, + since=None, + limit: int = 50, + offset: int = 0, + ) -> list[dict]: + """Global audit feed (admin), newest first; optional event/user/since filters.""" + where, params = self._filter_clauses(event, user_id, since) + params.update({"limit": int(limit), "offset": int(offset)}) + result = self._conn.execute( + text( + f"SELECT * FROM auth_events {where} " + "ORDER BY created_at DESC LIMIT :limit OFFSET :offset" + ), + params, + ) + return [row_to_dict(row) for row in result.fetchall()] + + def count_all( + self, *, event: Optional[str] = None, user_id: Optional[str] = None, since=None + ) -> int: + """Total matching the same filters as :meth:`list_all` (for pagination).""" + where, params = self._filter_clauses(event, user_id, since) + return int( + self._conn.execute( + text(f"SELECT count(*) FROM auth_events {where}"), params + ).scalar() + or 0 + ) diff --git a/application/storage/db/repositories/device_audit_log.py b/application/storage/db/repositories/device_audit_log.py index 93b6dd8d..01187b25 100644 --- a/application/storage/db/repositories/device_audit_log.py +++ b/application/storage/db/repositories/device_audit_log.py @@ -109,6 +109,63 @@ class DeviceAuditLogRepository: ) return result.rowcount > 0 + @staticmethod + def _global_filters(decision, user_id, device_id, since) -> tuple[str, dict]: + clauses: list[str] = [] + params: dict = {} + if decision: + clauses.append("decision = :decision") + params["decision"] = decision + if user_id: + clauses.append("user_id = :user_id") + params["user_id"] = user_id + if device_id: + clauses.append("device_id = CAST(:device_id AS uuid)") + params["device_id"] = device_id + if since is not None: + clauses.append("created_at >= :since") + params["since"] = since + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + return where, params + + def list_global( + self, + *, + decision: Optional[str] = None, + user_id: Optional[str] = None, + device_id: Optional[str] = None, + since=None, + limit: int = 50, + offset: int = 0, + ) -> list[dict]: + """Cross-device admin feed of remote-command invocations, newest first.""" + where, params = self._global_filters(decision, user_id, device_id, since) + params.update({"limit": int(limit), "offset": int(offset)}) + result = self._conn.execute( + text( + f"SELECT * FROM device_audit_log {where} " + "ORDER BY created_at DESC LIMIT :limit OFFSET :offset" + ), + params, + ) + return [row_to_dict(r) for r in result.fetchall()] + + def count_global( + self, + *, + decision: Optional[str] = None, + user_id: Optional[str] = None, + device_id: Optional[str] = None, + since=None, + ) -> int: + where, params = self._global_filters(decision, user_id, device_id, since) + return int( + self._conn.execute( + text(f"SELECT count(*) FROM device_audit_log {where}"), params + ).scalar() + or 0 + ) + def list_for_device( self, device_id: str, user_id: str, *, limit: int = 100 ) -> list[dict]: diff --git a/frontend/src/admin/AdminUI.tsx b/frontend/src/admin/AdminUI.tsx new file mode 100644 index 00000000..c83055f5 --- /dev/null +++ b/frontend/src/admin/AdminUI.tsx @@ -0,0 +1,157 @@ +import Spinner from '../components/Spinner'; + +export function Loading() { + return ( +
+ +
+ ); +} + +export function LoadError({ + message = 'Failed to load.', +}: { + message?: string; +}) { + return

{message}

; +} + +export function StatCard({ + label, + value, + sub, +}: { + label: string; + value: React.ReactNode; + sub?: string; +}) { + return ( +
+

{label}

+

+ {value} +

+ {sub ?

{sub}

: null} +
+ ); +} + +type Tone = 'default' | 'success' | 'danger' | 'muted' | 'brand' | 'warning'; + +const TONES: Record = { + default: 'bg-muted text-foreground', + success: + 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + danger: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + muted: 'bg-muted text-muted-foreground', + // brand violet — matches the chart's --primary; used for the Admin role. + brand: + 'bg-[#7D54D1]/15 text-[#7D54D1] dark:bg-violet-900/40 dark:text-violet-300', + warning: + 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', +}; + +export function Pill({ + tone = 'default', + children, +}: { + tone?: Tone; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function fmtDate(value?: string | null): string { + if (!value) return '—'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString(); +} + +export function fmtDateShort(value?: string | null): string { + if (!value) return '—'; + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? String(value) + : date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +export function fmtRelative(value?: string | null): string { + if (!value) return 'never'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + const sec = Math.round((Date.now() - date.getTime()) / 1000); + if (sec < 60) return 'just now'; + const min = Math.round(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.round(min / 60); + if (hr < 24) return `${hr}h ago`; + const day = Math.round(hr / 24); + if (day < 7) return `${day}d ago`; + if (day < 30) return `${Math.round(day / 7)}w ago`; + if (day < 365) return `${Math.round(day / 30)}mo ago`; + return `${Math.round(day / 365)}y ago`; +} + +export function fmtNumber(n?: number | null): string { + return new Intl.NumberFormat().format(n ?? 0); +} + +export function fmtCompact(n?: number | null): string { + return new Intl.NumberFormat(undefined, { + notation: 'compact', + maximumFractionDigits: 1, + }).format(n ?? 0); +} + +// Humanized auth-event labels + semantic color tier, shared by the Audit feed +// and the user-detail modal so the same event reads identically everywhere. +const EVENT_LABELS: Record = { + oidc_login: 'Login', + oidc_login_denied: 'Login denied', + oidc_refresh: 'Token refresh', + backchannel_logout: 'Logout (SSO)', + role_granted: 'Admin granted', + role_revoked: 'Admin revoked', + admin_user_deactivated: 'Deactivated', + admin_user_activated: 'Activated', + admin_sessions_revoked: 'Sessions revoked', + scim_created: 'Provisioned', + scim_deactivated: 'Deactivated (SCIM)', + scim_activated: 'Activated (SCIM)', +}; + +export function eventLabel(event: string): string { + return ( + EVENT_LABELS[event] ?? + event.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase()) + ); +} + +export function eventTone(event: string): Tone { + if ( + event === 'oidc_login_denied' || + event === 'admin_user_deactivated' || + event === 'scim_deactivated' + ) + return 'danger'; + if (event === 'role_granted') return 'brand'; + if (event === 'role_revoked' || event === 'admin_sessions_revoked') + return 'warning'; + return 'muted'; +} + +// 127.0.0.1 / ::1 are noise in dev — collapse to a muted "local" chip so real +// external IPs stand out in the audit feed. +export function isLoopback(ip?: string | null): boolean { + return ip === '127.0.0.1' || ip === '::1' || ip === 'localhost'; +} diff --git a/frontend/src/admin/Admins.tsx b/frontend/src/admin/Admins.tsx new file mode 100644 index 00000000..5a0b3361 --- /dev/null +++ b/frontend/src/admin/Admins.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; + +import adminService from '../api/services/adminService'; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from '../components/ui/table'; +import { selectToken } from '../preferences/preferenceSlice'; +import { Loading, Pill, fmtDate } from './AdminUI'; + +type Admin = { user_id: string; granted_at?: string; sources?: string[] }; + +export default function Admins() { + const token = useSelector(selectToken); + const [admins, setAdmins] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setLoading(true); + adminService + .getAdmins(token) + .then((res) => res.json()) + .then((json) => { + if (!cancelled) { + setAdmins(json.admins ?? []); + setLoading(false); + } + }) + .catch(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [token]); + + if (loading) return ; + + return ( +
+

+ Admin access is the deployment super-admin role. Grant or revoke it from + the Users tab; OIDC-group-derived grants reconcile automatically at + login. +

+ {admins.length === 0 ? ( +

No admins.

+ ) : ( + + + + + User + Sources + Granted + + + + {admins.map((a) => ( + + + {a.user_id} + + +
+ {(a.sources ?? []).map((s) => ( + + {s} + + ))} +
+
+ + {fmtDate(a.granted_at)} + +
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/admin/Audit.tsx b/frontend/src/admin/Audit.tsx new file mode 100644 index 00000000..02c5e492 --- /dev/null +++ b/frontend/src/admin/Audit.tsx @@ -0,0 +1,202 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useSearchParams } from 'react-router-dom'; + +import adminService from '../api/services/adminService'; +import { Button } from '../components/ui/button'; +import { Input } from '../components/ui/input'; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from '../components/ui/table'; +import { selectToken } from '../preferences/preferenceSlice'; +import { + Loading, + Pill, + eventLabel, + eventTone, + fmtDate, + fmtNumber, + fmtRelative, + isLoopback, +} from './AdminUI'; + +type Event = { + id: string; + user_id: string; + event: string; + ip?: string; + created_at?: string; +}; + +const PAGE_SIZE = 50; + +export default function Audit() { + const token = useSelector(selectToken); + const [searchParams] = useSearchParams(); + const initialEvent = searchParams.get('event') ?? ''; + const [events, setEvents] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [eventFilter, setEventFilter] = useState(initialEvent); + const [userFilter, setUserFilter] = useState(''); + const [search, setSearch] = useState({ event: initialEvent, user_id: '' }); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await adminService.getAudit( + { + page, + page_size: PAGE_SIZE, + event: search.event || undefined, + user_id: search.user_id || undefined, + }, + token, + ); + const json = await res.json().catch(() => ({})); + setEvents(json.events ?? []); + setTotal(json.total ?? 0); + } finally { + setLoading(false); + } + }, [token, page, search]); + + useEffect(() => { + load(); + }, [load]); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const applyFilters = () => { + setPage(1); + setSearch({ event: eventFilter.trim(), user_id: userFilter.trim() }); + }; + + return ( +
+
+ setEventFilter(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && applyFilters()} + className="max-w-xs" + /> + setUserFilter(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && applyFilters()} + className="max-w-xs" + /> + + {(search.event || search.user_id) && ( + + )} +
+ + {loading ? ( + + ) : events.length === 0 ? ( +

No events.

+ ) : ( + <> + + + + + Event + User + IP + When + + + + {events.map((ev, idx) => { + // De-emphasize a user id repeated from the row above (ditto), + // so distinct users stand out in a long single-user stream. + const repeat = + idx > 0 && events[idx - 1].user_id === ev.user_id; + return ( + + + + {eventLabel(ev.event)} + + + + {repeat ? '〃' : ev.user_id} + + + {isLoopback(ev.ip) ? ( + + local + + ) : ( + + {ev.ip ?? '—'} + + )} + + + + {fmtRelative(ev.created_at)} + + + + ); + })} + +
+
+
+

+ {fmtNumber(total)} events · page {page} of {totalPages} +

+
+ + +
+
+ + )} +
+ ); +} diff --git a/frontend/src/admin/Overview.tsx b/frontend/src/admin/Overview.tsx new file mode 100644 index 00000000..1ed25f4c --- /dev/null +++ b/frontend/src/admin/Overview.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; + +import adminService from '../api/services/adminService'; +import { selectToken } from '../preferences/preferenceSlice'; +import { Loading, LoadError, StatCard, fmtNumber } from './AdminUI'; + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export default function Overview() { + const token = useSelector(selectToken); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setLoading(true); + adminService + .getOverview(token) + .then((res) => res.json()) + .then((json) => { + if (!cancelled) { + setData(json); + setLoading(false); + } + }) + .catch(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [token]); + + if (loading) return ; + if (!data?.success) return ; + + const users = data.users ?? {}; + const failed = data.failed_logins_7d ?? 0; + + return ( +
+
+
+ + + + +
+
+ +
+
+ + + + +
+
+ +
+
+
0 + ? 'border-red-300 bg-red-50 dark:border-red-900/50 dark:bg-red-950/30' + : 'border-border' + }`} + > +

Failed logins (7d)

+

0 + ? 'text-red-600 dark:text-red-400' + : 'text-foreground' + }`} + > + {fmtNumber(failed)} +

+ {failed > 0 ? ( + + View in Audit → + + ) : ( +

+ No denied logins +

+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/admin/Usage.tsx b/frontend/src/admin/Usage.tsx new file mode 100644 index 00000000..edd78743 --- /dev/null +++ b/frontend/src/admin/Usage.tsx @@ -0,0 +1,163 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; + +import adminService from '../api/services/adminService'; +import SkeletonLoader from '../components/SkeletonLoader'; +import { Button } from '../components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from '../components/ui/table'; +import { useDarkTheme } from '../hooks'; +import { selectToken } from '../preferences/preferenceSlice'; +import { formatDate } from '../utils/dateTimeUtils'; +import { Loading, LoadError, StatCard, fmtNumber } from './AdminUI'; +import UsageChart, { usageColors } from './UsageChart'; + +type Bucket = { + bucket: string; + prompt_tokens: number; + generated_tokens: number; +}; +type TopUser = { user_id: string; tokens: number }; + +const RANGES = [7, 30, 90]; + +export default function Usage() { + const token = useSelector(selectToken); + const [isDarkTheme] = useDarkTheme(); + const [days, setDays] = useState(30); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await adminService.getUsage({ days, bucket: 'day' }, token); + setData(await res.json().catch(() => ({}))); + } finally { + setLoading(false); + } + }, [token, days]); + + useEffect(() => { + load(); + }, [load]); + + const series: Bucket[] = data?.series ?? []; + const topUsers: TopUser[] = data?.top_users ?? []; + const promptTotal = series.reduce((sum, b) => sum + b.prompt_tokens, 0); + const generatedTotal = series.reduce((sum, b) => sum + b.generated_tokens, 0); + + const chartData = useMemo(() => { + const colors = usageColors(); + return { + labels: series.map((b) => formatDate(b.bucket)), + datasets: [ + { + label: 'Prompt', + data: series.map((b) => b.prompt_tokens), + backgroundColor: colors.prompt, + }, + { + label: 'Generated', + data: series.map((b) => b.generated_tokens), + backgroundColor: colors.generated, + }, + ], + }; + // isDarkTheme re-resolves the canvas colors when the theme toggles. + }, [series, isDarkTheme]); + + if (data === null && loading) return ; + if (data && !data.success) + return ; + + return ( +
+
+ {RANGES.map((r) => ( + + ))} +
+ +
+ + + + +
+ +
+
+

+ Token usage +

+
+
+
+ {loading ? ( + + ) : series.length === 0 ? ( +

+ No usage in this period. +

+ ) : ( + + )} +
+
+ +
+

+ Top users by tokens +

+ {topUsers.length === 0 ? ( +

No usage.

+ ) : ( + + + + + User + Tokens + + + + {topUsers.map((u) => ( + + + {u.user_id} + + + {fmtNumber(u.tokens)} + + + ))} + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/admin/UsageChart.tsx b/frontend/src/admin/UsageChart.tsx new file mode 100644 index 00000000..d9c27f4d --- /dev/null +++ b/frontend/src/admin/UsageChart.tsx @@ -0,0 +1,97 @@ +import { + BarElement, + CategoryScale, + Chart as ChartJS, + Legend, + LinearScale, + Title, + Tooltip, +} from 'chart.js'; +import { Bar } from 'react-chartjs-2'; + +import { htmlLegendPlugin } from '../utils/chartUtils'; + +import type { ChartData } from 'chart.js'; + +ChartJS.register( + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, +); + +// Chart.js renders to a canvas and can't read CSS variables, so resolve the +// theme color to a concrete string at render time (mirrors settings/Analytics). +function readCssVar(name: string, fallback: string): string { + if (typeof window === 'undefined') return fallback; + const value = getComputedStyle(document.body).getPropertyValue(name).trim(); + return value || fallback; +} + +export function usageColors(): { prompt: string; generated: string } { + return { + // Violet + pink is the established DocsGPT 2-series pairing (Analytics uses + // --primary + SERIES_COLORS[0]); far more colorblind-separable than blue. + prompt: readCssVar('--primary', '#7D54D1'), + generated: '#FF6384', + }; +} + +function compactTick(value: number | string): string { + const n = typeof value === 'number' ? value : Number(value); + if (Number.isNaN(n)) return String(value); + return new Intl.NumberFormat(undefined, { + notation: 'compact', + maximumFractionDigits: 1, + }).format(n); +} + +type UsageChartProps = { + data: ChartData<'bar'>; + legendID: string; + maxTicksLimitInX?: number; +}; + +export default function UsageChart({ + data, + legendID, + maxTicksLimitInX = 8, +}: UsageChartProps) { + const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + htmlLegend: { containerID: legendID }, + }, + scales: { + x: { + grid: { lineWidth: 0.2, color: '#C4C4C4' }, + border: { width: 0.2, color: '#C4C4C4' }, + ticks: { maxTicksLimit: maxTicksLimitInX }, + stacked: true, + }, + y: { + grid: { lineWidth: 0.2, color: '#C4C4C4' }, + border: { width: 0.2, color: '#C4C4C4' }, + ticks: { callback: compactTick }, + stacked: true, + }, + }, + }; + return ( + ({ + ...dataset, + hoverBackgroundColor: `${dataset.backgroundColor}CC`, + })), + }} + /> + ); +} diff --git a/frontend/src/admin/Users.tsx b/frontend/src/admin/Users.tsx new file mode 100644 index 00000000..e12f0015 --- /dev/null +++ b/frontend/src/admin/Users.tsx @@ -0,0 +1,543 @@ +import { + Eye, + LogOut, + ShieldCheck, + ShieldOff, + UserCheck, + UserX, +} from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; + +import adminService from '../api/services/adminService'; +import ThreeDots from '../assets/three-dots.svg'; +import { Button } from '../components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../components/ui/dropdown-menu'; +import { Input } from '../components/ui/input'; +import { Modal } from '../components/ui/modal'; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from '../components/ui/table'; +import ConfirmationModal from '../modals/ConfirmationModal'; +import { ActiveState } from '../models/misc'; +import { selectToken } from '../preferences/preferenceSlice'; +import { + Loading, + Pill, + eventLabel, + fmtDateShort, + fmtNumber, + fmtRelative, +} from './AdminUI'; + +type AdminUser = { + user_id: string; + active: boolean; + created_at?: string; + last_seen?: string | null; +}; + +type Action = { + key: string; + label: string; + icon: typeof Eye; + destructive?: boolean; + perform: () => void; +}; + +const PAGE_SIZE = 25; + +export default function Users() { + const token = useSelector(selectToken); + const [users, setUsers] = useState([]); + const [adminIds, setAdminIds] = useState>(new Set()); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [query, setQuery] = useState(''); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const [menuUserId, setMenuUserId] = useState(null); + const [detail, setDetail] = useState(null); + const [feedback, setFeedback] = useState<{ + ok: boolean; + message: string; + } | null>(null); + const [confirm, setConfirm] = useState<{ + message: string; + submitLabel: string; + run: () => void; + } | null>(null); + const [confirmState, setConfirmState] = useState('INACTIVE'); + + const load = useCallback(async () => { + setLoading(true); + try { + const [usersRes, adminsRes] = await Promise.all([ + adminService.getUsers( + { page, page_size: PAGE_SIZE, user_id: query || undefined }, + token, + ), + adminService.getAdmins(token), + ]); + const usersJson = await usersRes.json().catch(() => ({})); + const adminsJson = await adminsRes.json().catch(() => ({})); + setUsers(usersJson.users ?? []); + setTotal(usersJson.total ?? 0); + setAdminIds( + new Set((adminsJson.admins ?? []).map((a: any) => a.user_id)), + ); + } finally { + setLoading(false); + } + }, [token, page, query]); + + useEffect(() => { + load(); + }, [load]); + + // Auto-dismiss the inline feedback banner. + useEffect(() => { + if (!feedback) return; + const id = setTimeout(() => setFeedback(null), 4500); + return () => clearTimeout(id); + }, [feedback]); + + const run = + (fn: () => Promise, userId: string, successMsg: string) => + async () => { + setBusy(userId); + try { + const res = await fn(); + const json = await res.json().catch(() => ({})); + if (res.ok && json.success !== false) { + setFeedback({ ok: true, message: successMsg }); + await load(); + } else { + setFeedback({ + ok: false, + message: json.message || `Action failed for ${userId}`, + }); + } + } catch { + setFeedback({ ok: false, message: `Action failed for ${userId}` }); + } finally { + setBusy(null); + } + }; + + const askConfirm = ( + message: string, + submitLabel: string, + action: () => void, + ) => { + setConfirm({ message, submitLabel, run: action }); + setConfirmState('ACTIVE'); + }; + + const openDetail = async (userId: string) => { + const res = await adminService.getUser(userId, token); + const json = await res.json().catch(() => ({})); + if (json.success) setDetail(json); + }; + + const buildActions = ( + userId: string, + isAdmin: boolean, + active: boolean, + ): Action[] => { + const acts: Action[] = []; + if (isAdmin) { + acts.push({ + key: 'revoke', + label: 'Revoke admin', + icon: ShieldOff, + destructive: true, + perform: () => + askConfirm( + `Revoke admin from ${userId}?`, + 'Revoke', + run( + () => adminService.revokeAdmin(userId, token), + userId, + `Removed admin from ${userId}`, + ), + ), + }); + } else { + acts.push({ + key: 'grant', + label: 'Make admin', + icon: ShieldCheck, + perform: run( + () => adminService.grantAdmin(userId, token), + userId, + `${userId} is now an admin`, + ), + }); + } + if (active) { + acts.push({ + key: 'deactivate', + label: 'Deactivate', + icon: UserX, + destructive: true, + perform: () => + askConfirm( + `Deactivate ${userId}? This revokes their live sessions.`, + 'Deactivate', + run( + () => adminService.setUserActive(userId, false, token), + userId, + `${userId} deactivated`, + ), + ), + }); + } else { + acts.push({ + key: 'activate', + label: 'Activate', + icon: UserCheck, + perform: run( + () => adminService.setUserActive(userId, true, token), + userId, + `${userId} reactivated`, + ), + }); + } + acts.push({ + key: 'logout', + label: 'Force logout', + icon: LogOut, + perform: run( + () => adminService.revokeSessions(userId, token), + userId, + `Sessions revoked for ${userId}`, + ), + }); + return acts; + }; + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const applySearch = () => { + setPage(1); + setQuery(search.trim()); + }; + + return ( +
+ {feedback ? ( +
+ {feedback.message} +
+ ) : null} + +
+ setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') applySearch(); + }} + className="max-w-xs" + /> + + {query ? ( + + ) : null} +
+ + {loading ? ( + + ) : users.length === 0 ? ( +

No users found.

+ ) : ( + <> + + + + + User + Tags + Last seen + Created + Actions + + + + {users.map((u) => { + const isAdmin = adminIds.has(u.user_id); + const disabled = busy === u.user_id; + return ( + openDetail(u.user_id)} + > + + + {u.user_id} + + + +
+ {isAdmin ? Admin : null} + {!u.active ? ( + Inactive + ) : null} +
+
+ + + {fmtRelative(u.last_seen)} + + + + + {fmtDateShort(u.created_at)} + + + +
e.stopPropagation()} + > + + setMenuUserId(open ? u.user_id : null) + } + > + + + + + openDetail(u.user_id)} + > + + View details + + {buildActions(u.user_id, isAdmin, u.active).map( + (act) => ( + + + {act.label} + + ), + )} + + +
+
+
+ ); + })} +
+
+
+
+

+ {fmtNumber(total)} users · page {page} of {totalPages} +

+
+ + +
+
+ + )} + + {confirm ? ( + { + confirm.run(); + setConfirm(null); + }} + /> + ) : null} + + { + if (!open) setDetail(null); + }} + title={detail?.user?.user_id ?? 'User'} + size="lg" + footer={ + detail ? ( +
+ {buildActions( + detail.user.user_id, + (detail.roles ?? []).includes('admin'), + detail.user?.active ?? true, + ).map((act) => ( + + ))} +
+ ) : undefined + } + > + {detail ? ( +
+
+

+ Roles & status +

+
+ {(detail.roles ?? []).map((r: string) => ( + + {r} + + ))} + {detail.user?.active ? ( + Active + ) : ( + Inactive + )} +
+
+
+
+ Agents + + {fmtNumber(detail.counts?.agents)} + +
+
+ Sources + + {fmtNumber(detail.counts?.sources)} + +
+
+ Conversations + + {fmtNumber(detail.counts?.conversations)} + +
+
+ Tokens (30d) + + {fmtNumber(detail.counts?.tokens_30d)} + +
+
+
+

+ Recent auth events +

+
+ {(detail.recent_events ?? []).length === 0 ? ( +

None

+ ) : ( + (detail.recent_events ?? []).map((ev: any) => ( +
+ {eventLabel(ev.event)} + + {fmtRelative(ev.created_at)} + +
+ )) + )} +
+
+
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/admin/index.tsx b/frontend/src/admin/index.tsx index dcbe889a..ce73070c 100644 --- a/frontend/src/admin/index.tsx +++ b/frontend/src/admin/index.tsx @@ -1,17 +1,72 @@ +import { useTranslation } from 'react-i18next'; +import { + Navigate, + Route, + Routes, + useLocation, + useNavigate, +} from 'react-router-dom'; + +import { Tabs, TabsList, TabsTrigger } from '../components/ui/tabs'; +import Admins from './Admins'; +import Audit from './Audit'; +import Overview from './Overview'; +import Usage from './Usage'; +import Users from './Users'; + +const TABS = [ + { key: 'overview', label: 'Overview', path: '/admin' }, + { key: 'users', label: 'Users', path: '/admin/users' }, + { key: 'admins', label: 'Admins', path: '/admin/admins' }, + { key: 'usage', label: 'Usage', path: '/admin/usage' }, + { key: 'audit', label: 'Audit', path: '/admin/audit' }, +]; + /** - * Placeholder admin dashboard. Reached only via (cosmetic guard); - * the real dashboard is built out here in a later PR. Server-side - * @admin_required on /api/admin/* is the actual access boundary. + * Admin dashboard (operator-level, global admin). Reached only via + * (cosmetic guard); every endpoint it calls is independently @admin_required on + * the server. */ export default function Admin() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + + const active = + TABS.slice(1).find((tab) => location.pathname.startsWith(tab.path)) + ?.label ?? 'Overview'; + return ( -
-

- Admin -

-

- Admin dashboard coming soon. -

+
+
+

+ {t('admin.label', 'Admin')} +

+ { + const tab = TABS.find((tb) => tb.label === label); + if (tab) navigate(tab.path); + }} + className="relative mt-6 flex flex-row items-center space-x-1 overflow-auto md:space-x-0" + > + + {TABS.map((tab) => ( + + {t(`admin.tabs.${tab.key}`, tab.label)} + + ))} + + + + } /> + } /> + } /> + } /> + } /> + } /> + +
); } diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index ca6b3b30..82a6910f 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -113,6 +113,19 @@ const endpoints = { CHAT_COMPLETIONS: '/v1/chat/completions', MODELS: '/v1/models', }, + ADMIN: { + OVERVIEW: '/api/admin/overview', + USERS: '/api/admin/users', + USER: (id: string) => `/api/admin/users/${encodeURIComponent(id)}`, + USER_ROLE: (id: string) => + `/api/admin/users/${encodeURIComponent(id)}/role`, + USER_REVOKE_SESSIONS: (id: string) => + `/api/admin/users/${encodeURIComponent(id)}/revoke-sessions`, + ADMINS: '/api/admin/admins', + USAGE: '/api/admin/usage', + AUDIT: '/api/admin/audit', + DEVICE_AUDIT: '/api/admin/devices/audit', + }, CONVERSATION: { ANSWER: '/api/answer', ANSWER_STREAMING: '/stream', diff --git a/frontend/src/api/services/adminService.ts b/frontend/src/api/services/adminService.ts new file mode 100644 index 00000000..7359aed4 --- /dev/null +++ b/frontend/src/api/services/adminService.ts @@ -0,0 +1,59 @@ +import apiClient from '../client'; +import endpoints from '../endpoints'; + +const qs = (params: Record): string => { + const search = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== '') search.append(key, String(value)); + }); + const str = search.toString(); + return str ? `?${str}` : ''; +}; + +const adminService = { + getOverview: (token: string | null): Promise => + apiClient.get(endpoints.ADMIN.OVERVIEW, token), + getUsers: ( + params: { page?: number; page_size?: number; user_id?: string }, + token: string | null, + ): Promise => + apiClient.get(`${endpoints.ADMIN.USERS}${qs(params)}`, token), + getUser: (userId: string, token: string | null): Promise => + apiClient.get(endpoints.ADMIN.USER(userId), token), + grantAdmin: (userId: string, token: string | null): Promise => + apiClient.post(endpoints.ADMIN.USER_ROLE(userId), {}, token), + revokeAdmin: (userId: string, token: string | null): Promise => + apiClient.delete(endpoints.ADMIN.USER_ROLE(userId), token), + setUserActive: ( + userId: string, + active: boolean, + token: string | null, + ): Promise => + apiClient.patch(endpoints.ADMIN.USER(userId), { active }, token), + revokeSessions: (userId: string, token: string | null): Promise => + apiClient.post(endpoints.ADMIN.USER_REVOKE_SESSIONS(userId), {}, token), + getAdmins: (token: string | null): Promise => + apiClient.get(endpoints.ADMIN.ADMINS, token), + getUsage: ( + params: { days?: number; group_by?: string; bucket?: string }, + token: string | null, + ): Promise => + apiClient.get(`${endpoints.ADMIN.USAGE}${qs(params)}`, token), + getAudit: ( + params: { + page?: number; + page_size?: number; + event?: string; + user_id?: string; + }, + token: string | null, + ): Promise => + apiClient.get(`${endpoints.ADMIN.AUDIT}${qs(params)}`, token), + getDeviceAudit: ( + params: { page?: number; page_size?: number; decision?: string }, + token: string | null, + ): Promise => + apiClient.get(`${endpoints.ADMIN.DEVICE_AUDIT}${qs(params)}`, token), +}; + +export default adminService; diff --git a/tests/api/test_admin_dashboard.py b/tests/api/test_admin_dashboard.py new file mode 100644 index 00000000..b0213f98 --- /dev/null +++ b/tests/api/test_admin_dashboard.py @@ -0,0 +1,267 @@ +"""Endpoint tests for the admin dashboard (Phase 0 + Phase 1). + +Repos are mocked at the route layer (the real SQL is covered by +tests/storage/db/repositories/test_admin_stats.py); these pin the route wiring, +the @admin_required boundary, the audited mutations, and the safety guards. +""" + +from __future__ import annotations + +import json +from contextlib import ExitStack, contextmanager +from unittest.mock import Mock, patch + +import pytest + + +@pytest.fixture +def client(): + from application.app import app as flask_app + + flask_app.config["TESTING"] = True + return flask_app.test_client() + + +@contextmanager +def _fake_conn(): + yield Mock() + + +@contextmanager +def _admin(**route_patches): + """Authenticate as admin and patch application.api.admin.routes.* members.""" + with ExitStack() as stack: + stack.enter_context( + patch("application.app.handle_auth", return_value={"sub": "admin1"}) + ) + stack.enter_context( + patch("application.app.resolve_roles", return_value=["admin", "user"]) + ) + stack.enter_context(patch("application.api.admin.routes.db_readonly", _fake_conn)) + stack.enter_context(patch("application.api.admin.routes.db_session", _fake_conn)) + for name, value in route_patches.items(): + stack.enter_context(patch(f"application.api.admin.routes.{name}", value)) + yield + + +def _body(resp): + return json.loads(resp.data) + + +@pytest.mark.unit +class TestGuard: + def test_non_admin_forbidden(self, client): + with patch("application.app.handle_auth", return_value={"sub": "u"}), patch( + "application.app.resolve_roles", return_value=["user"] + ): + assert client.get("/api/admin/overview").status_code == 403 + + def test_unauthenticated(self, client): + with patch("application.app.handle_auth", return_value=None): + assert client.get("/api/admin/overview").status_code == 401 + + +@pytest.mark.unit +class TestOverview: + def test_ok(self, client): + stats = Mock() + stats.overview.return_value = { + "users": {"total": 3, "active": 3, "inactive": 0}, + "admins": 1, + "agents": 0, + "sources": 0, + "conversations": 0, + "new_users_7d": 1, + "active_users_30d": 2, + "failed_logins_7d": 0, + "tokens_30d": 42, + } + with _admin(AdminStatsRepository=Mock(return_value=stats)): + resp = client.get("/api/admin/overview") + assert resp.status_code == 200 + data = _body(resp) + assert data["users"]["total"] == 3 + assert data["tokens_30d"] == 42 + + +@pytest.mark.unit +class TestAdminsAndRoles: + def test_list_admins(self, client): + roles = Mock() + roles.list_admins.return_value = [ + {"user_id": "a", "granted_at": None, "sources": ["manual"]} + ] + with _admin(UserRolesRepository=Mock(return_value=roles)): + resp = client.get("/api/admin/admins") + assert resp.status_code == 200 + assert _body(resp)["admins"][0]["user_id"] == "a" + + def test_grant_admin_audited(self, client): + roles = Mock() + roles.grant.return_value = True + events = Mock() + with _admin( + UserRolesRepository=Mock(return_value=roles), + AuthEventsRepository=Mock(return_value=events), + ): + resp = client.post("/api/admin/users/alice/role") + assert resp.status_code == 200 + assert _body(resp)["granted"] is True + roles.grant.assert_called_once() + # audited as role_granted + assert events.insert.call_args.args[1] == "role_granted" + + def test_revoke_blocks_last_admin(self, client): + roles = Mock() + roles.list_admins.return_value = [{"user_id": "alice"}] + with _admin( + UserRolesRepository=Mock(return_value=roles), + AuthEventsRepository=Mock(return_value=Mock()), + ): + resp = client.delete("/api/admin/users/alice/role") + assert resp.status_code == 409 + roles.revoke.assert_not_called() + + def test_revoke_ok_when_other_admins_exist(self, client): + roles = Mock() + roles.list_admins.return_value = [{"user_id": "alice"}, {"user_id": "bob"}] + roles.revoke.return_value = True + events = Mock() + with _admin( + UserRolesRepository=Mock(return_value=roles), + AuthEventsRepository=Mock(return_value=events), + ): + resp = client.delete("/api/admin/users/alice/role") + assert resp.status_code == 200 + roles.revoke.assert_called_once() + assert events.insert.call_args.args[1] == "role_revoked" + + +@pytest.mark.unit +class TestUserLifecycle: + def test_self_deactivation_blocked(self, client): + with _admin(UsersRepository=Mock()): + resp = client.patch("/api/admin/users/admin1", json={"active": False}) + assert resp.status_code == 409 + + def test_bad_body(self, client): + with _admin(UsersRepository=Mock()): + resp = client.patch("/api/admin/users/bob", json={}) + assert resp.status_code == 400 + + def test_user_not_found(self, client): + users = Mock() + users.get.return_value = None + with _admin( + UsersRepository=Mock(return_value=users), + AuthEventsRepository=Mock(return_value=Mock()), + ): + resp = client.patch("/api/admin/users/ghost", json={"active": True}) + assert resp.status_code == 404 + + def test_deactivate_revokes_sessions_and_audits(self, client): + users = Mock() + users.get.return_value = { + "id": "00000000-0000-0000-0000-000000000001", + "user_id": "bob", + } + users.set_active.return_value = {"active": False} + events = Mock() + with _admin( + UsersRepository=Mock(return_value=users), + AuthEventsRepository=Mock(return_value=events), + ), patch("application.api.admin.routes.denylist") as dl: + resp = client.patch("/api/admin/users/bob", json={"active": False}) + assert resp.status_code == 200 + dl.deny_user.assert_called_once_with("bob") + assert events.insert.call_args.args[1] == "admin_user_deactivated" + + def test_force_logout(self, client): + events = Mock() + with _admin(AuthEventsRepository=Mock(return_value=events)), patch( + "application.api.admin.routes.denylist" + ) as dl: + dl.deny_user.return_value = True + resp = client.post("/api/admin/users/bob/revoke-sessions") + assert resp.status_code == 200 + dl.deny_user.assert_called_once_with("bob") + + def test_user_detail(self, client): + users = Mock() + users.get.return_value = { + "user_id": "bob", + "active": True, + "created_at": None, + "updated_at": None, + } + roles = Mock() + roles.role_names_for.return_value = ["admin"] + roles.list_for.return_value = [] + events = Mock() + events.list_recent.return_value = [] + stats = Mock() + stats.user_counts.return_value = { + "agents": 0, + "sources": 0, + "conversations": 0, + "tokens_30d": 0, + } + with _admin( + UsersRepository=Mock(return_value=users), + UserRolesRepository=Mock(return_value=roles), + AuthEventsRepository=Mock(return_value=events), + AdminStatsRepository=Mock(return_value=stats), + ): + resp = client.get("/api/admin/users/bob") + assert resp.status_code == 200 + data = _body(resp) + assert data["user"]["user_id"] == "bob" + assert "admin" in data["roles"] and "user" in data["roles"] + + +@pytest.mark.unit +class TestUsageAndAudit: + def test_usage(self, client): + usage = Mock() + usage.bucketed_totals.return_value = [ + {"bucket": "2026-06-14", "prompt_tokens": 5, "generated_tokens": 3} + ] + usage.sum_tokens_in_range.return_value = 8 + stats = Mock() + stats.top_token_users.return_value = [{"user_id": "a", "tokens": 8}] + with _admin( + TokenUsageRepository=Mock(return_value=usage), + AdminStatsRepository=Mock(return_value=stats), + ): + resp = client.get("/api/admin/usage?days=7&bucket=day") + assert resp.status_code == 200 + data = _body(resp) + assert data["total_tokens"] == 8 + assert len(data["series"]) == 1 + assert data["top_users"][0]["user_id"] == "a" + + def test_usage_invalid_bucket(self, client): + with _admin(): + resp = client.get("/api/admin/usage?bucket=year") + assert resp.status_code == 400 + + def test_audit_feed(self, client): + repo = Mock() + repo.count_all.return_value = 1 + repo.list_all.return_value = [{"user_id": "a", "event": "oidc_login"}] + with _admin(AuthEventsRepository=Mock(return_value=repo)): + resp = client.get("/api/admin/audit?event=oidc_login&page=1&page_size=10") + assert resp.status_code == 200 + data = _body(resp) + assert data["total"] == 1 + assert data["events"][0]["event"] == "oidc_login" + repo.list_all.assert_called_once() + + def test_device_audit_feed(self, client): + repo = Mock() + repo.count_global.return_value = 0 + repo.list_global.return_value = [] + with _admin(DeviceAuditLogRepository=Mock(return_value=repo)): + resp = client.get("/api/admin/devices/audit?decision=denied") + assert resp.status_code == 200 + assert _body(resp)["invocations"] == [] diff --git a/tests/api/test_rbac_endpoints.py b/tests/api/test_rbac_endpoints.py index 887c8239..9f8d5a2e 100644 --- a/tests/api/test_rbac_endpoints.py +++ b/tests/api/test_rbac_endpoints.py @@ -64,66 +64,70 @@ class TestAdminUsersEndpoint: @staticmethod def _admin_get(client, repo, query=""): + # The list endpoint resolves users (with last_seen) via AdminStatsRepository. with patch("application.app.handle_auth", return_value={"sub": "a1"}), patch( "application.app.resolve_roles", return_value=["admin", "user"] ), patch("application.api.admin.routes.db_readonly", _fake_readonly), patch( - "application.api.admin.routes.UsersRepository", return_value=repo + "application.api.admin.routes.AdminStatsRepository", return_value=repo ): return client.get(f"/api/admin/users{query}") @staticmethod def _rows(n): return [ - {"user_id": f"u{i}", "active": True, "created_at": "2026-01-01"} + { + "user_id": f"u{i}", + "active": True, + "created_at": "2026-01-01", + "last_seen": "2026-02-01", + } for i in range(n) ] def test_admin_gets_user_list(self, client): repo = Mock() - repo.list_paginated.return_value = ( - 1, - [{"user_id": "u1", "active": True, "created_at": "2026-01-01"}], - ) + repo.list_users.return_value = (1, self._rows(1)) resp = self._admin_get(client, repo) assert resp.status_code == 200 data = json.loads(resp.data) assert data["success"] is True assert data["total"] == 1 assert data["users"][0] == { - "user_id": "u1", + "user_id": "u0", "active": True, "created_at": "2026-01-01", + "last_seen": "2026-02-01", } - repo.list_paginated.assert_called_once_with(None, 0, 25) + repo.list_users.assert_called_once_with(None, 0, 25) def test_page_size_clamped_and_offset_computed(self, client): repo = Mock() - repo.list_paginated.return_value = (0, []) + repo.list_users.return_value = (0, []) self._admin_get(client, repo, "?page=2&page_size=999") # page_size clamps to _MAX_PAGE_SIZE=100, offset=(2-1)*100 - repo.list_paginated.assert_called_once_with(None, 100, 100) + repo.list_users.assert_called_once_with(None, 100, 100) def test_non_int_params_fall_back_to_defaults(self, client): repo = Mock() - repo.list_paginated.return_value = (0, []) + repo.list_users.return_value = (0, []) self._admin_get(client, repo, "?page=abc&page_size=xyz") - repo.list_paginated.assert_called_once_with(None, 0, 25) + repo.list_users.assert_called_once_with(None, 0, 25) def test_user_id_filter_passed_first_positional(self, client): repo = Mock() - repo.list_paginated.return_value = (0, []) + repo.list_users.return_value = (0, []) self._admin_get(client, repo, "?user_id=foo") - repo.list_paginated.assert_called_once_with("foo", 0, 25) + repo.list_users.assert_called_once_with("foo", 0, 25) def test_has_more_true_when_more_pages_remain(self, client): repo = Mock() - repo.list_paginated.return_value = (5, self._rows(3)) + repo.list_users.return_value = (5, self._rows(3)) resp = self._admin_get(client, repo, "?page=1&page_size=3") assert json.loads(resp.data)["has_more"] is True def test_has_more_false_at_last_page(self, client): repo = Mock() - repo.list_paginated.return_value = (3, self._rows(3)) + repo.list_users.return_value = (3, self._rows(3)) resp = self._admin_get(client, repo, "?page=1&page_size=3") assert json.loads(resp.data)["has_more"] is False @@ -188,7 +192,7 @@ class TestChokepointOverwritesForgedRoles: authz_repo = Mock() authz_repo.role_names_for.return_value = ["admin"] admin_repo = Mock() - admin_repo.list_paginated.return_value = (0, []) + admin_repo.list_users.return_value = (0, []) with patch( "application.app.handle_auth", return_value={"sub": "alice"} ), patch("application.app.oidc_session_denied", return_value=False), patch.object( @@ -196,7 +200,7 @@ class TestChokepointOverwritesForgedRoles: ), patch.object(authz, "db_readonly", _fake_readonly), patch.object( authz, "UserRolesRepository", return_value=authz_repo ), patch("application.api.admin.routes.db_readonly", _fake_readonly), patch( - "application.api.admin.routes.UsersRepository", return_value=admin_repo + "application.api.admin.routes.AdminStatsRepository", return_value=admin_repo ): admin_resp = client.get("/api/admin/users") me_resp = client.get("/api/user/me") diff --git a/tests/storage/db/repositories/test_admin_stats.py b/tests/storage/db/repositories/test_admin_stats.py new file mode 100644 index 00000000..0fb47bcd --- /dev/null +++ b/tests/storage/db/repositories/test_admin_stats.py @@ -0,0 +1,145 @@ +"""Real-SQL tests for the admin dashboard aggregates. + +The migrated schema seeds a ``'__system__'`` user (0001_initial), so global +counts carry a baseline — these tests assert deltas / user-scoped filters rather +than absolute totals. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from application.storage.db.repositories.admin_stats import AdminStatsRepository +from application.storage.db.repositories.auth_events import AuthEventsRepository +from application.storage.db.repositories.token_usage import TokenUsageRepository +from application.storage.db.repositories.user_roles import UserRolesRepository +from application.storage.db.repositories.users import UsersRepository + + +class TestOverview: + def test_count_deltas_reflect_seeded_data(self, pg_conn): + repo = AdminStatsRepository(pg_conn) + before = repo.overview() + + UsersRepository(pg_conn).upsert("ov_alice") + UsersRepository(pg_conn).upsert("ov_bob") + UserRolesRepository(pg_conn).grant("ov_alice", "admin", source="manual") + TokenUsageRepository(pg_conn).insert( + user_id="ov_alice", prompt_tokens=10, generated_tokens=5 + ) + TokenUsageRepository(pg_conn).insert( + user_id="ov_bob", prompt_tokens=3, generated_tokens=2 + ) + AuthEventsRepository(pg_conn).insert("ov_bob", "oidc_login_denied") + + after = repo.overview() + assert after["users"]["total"] - before["users"]["total"] == 2 + assert after["users"]["active"] - before["users"]["active"] == 2 + assert after["admins"] - before["admins"] == 1 + assert after["new_users_7d"] - before["new_users_7d"] == 2 + assert after["active_users_30d"] - before["active_users_30d"] == 2 + assert after["failed_logins_7d"] - before["failed_logins_7d"] == 1 + assert after["tokens_30d"] - before["tokens_30d"] == 20 + + def test_inactive_user_counted(self, pg_conn): + repo = AdminStatsRepository(pg_conn) + before = repo.overview() + UsersRepository(pg_conn).upsert("ov_inact") + row = UsersRepository(pg_conn).get("ov_inact") + UsersRepository(pg_conn).set_active(str(row["id"]), False) + after = repo.overview() + assert after["users"]["total"] - before["users"]["total"] == 1 + assert after["users"]["active"] - before["users"]["active"] == 0 + assert after["users"]["inactive"] - before["users"]["inactive"] == 1 + + def test_overview_shape(self, pg_conn): + ov = AdminStatsRepository(pg_conn).overview() + assert set(ov["users"]) == {"total", "active", "inactive"} + for key in ( + "admins", + "agents", + "sources", + "conversations", + "new_users_7d", + "active_users_30d", + "failed_logins_7d", + "tokens_30d", + ): + assert isinstance(ov[key], int) + + +class TestListUsers: + def test_last_seen_and_recent_first_ordering(self, pg_conn): + UsersRepository(pg_conn).upsert("lu_dormant") + UsersRepository(pg_conn).upsert("lu_recent") + AuthEventsRepository(pg_conn).insert("lu_recent", "oidc_login") + + total, rows = AdminStatsRepository(pg_conn).list_users(None, 0, 200) + by_user = {r["user_id"]: r for r in rows} + assert total >= 2 + assert by_user["lu_recent"]["last_seen"] is not None + assert by_user["lu_dormant"]["last_seen"] is None + ids = [r["user_id"] for r in rows] + # the user with auth activity sorts ahead of the dormant one + assert ids.index("lu_recent") < ids.index("lu_dormant") + + def test_exact_filter(self, pg_conn): + UsersRepository(pg_conn).upsert("lu_a") + UsersRepository(pg_conn).upsert("lu_b") + total, rows = AdminStatsRepository(pg_conn).list_users("lu_a", 0, 200) + assert total == 1 + assert [r["user_id"] for r in rows] == ["lu_a"] + + +class TestTopTokenUsers: + def test_ordered_by_spend_desc(self, pg_conn): + since = datetime.now(timezone.utc) - timedelta(days=30) + TokenUsageRepository(pg_conn).insert( + user_id="tt_big", prompt_tokens=100000, generated_tokens=0 + ) + TokenUsageRepository(pg_conn).insert( + user_id="tt_small", prompt_tokens=1, generated_tokens=0 + ) + top = AdminStatsRepository(pg_conn).top_token_users(since=since, limit=100) + by_user = {t["user_id"]: t["tokens"] for t in top} + assert by_user["tt_big"] == 100000 + assert by_user["tt_small"] == 1 + ids = [t["user_id"] for t in top] + assert ids.index("tt_big") < ids.index("tt_small") + + +class TestUserCounts: + def test_tokens_and_zero_resources(self, pg_conn): + TokenUsageRepository(pg_conn).insert( + user_id="uc_alice", prompt_tokens=7, generated_tokens=3 + ) + counts = AdminStatsRepository(pg_conn).user_counts("uc_alice") + assert counts["tokens_30d"] == 10 + assert counts["agents"] == 0 + assert counts["sources"] == 0 + assert counts["conversations"] == 0 + + +class TestAuthEventsGlobalFeed: + def test_filter_count_and_paginate(self, pg_conn): + repo = AuthEventsRepository(pg_conn) + repo.insert("ae_alice", "oidc_login") + repo.insert("ae_alice", "oidc_login_denied") + repo.insert("ae_bob", "oidc_login") + + assert repo.count_all(user_id="ae_alice") == 2 + assert repo.count_all(user_id="ae_bob") == 1 + assert repo.count_all(user_id="ae_alice", event="oidc_login") == 1 + + denied = repo.list_all(user_id="ae_alice", event="oidc_login_denied") + assert len(denied) == 1 and denied[0]["user_id"] == "ae_alice" + + assert len(repo.list_all(user_id="ae_alice", limit=1, offset=0)) == 1 + assert len(repo.list_all(user_id="ae_alice", limit=5, offset=2)) == 0 + + def test_since_excludes_older(self, pg_conn): + repo = AuthEventsRepository(pg_conn) + repo.insert("ae_since", "oidc_login") + future = datetime.now(timezone.utc) + timedelta(days=1) + assert repo.count_all(user_id="ae_since", since=future) == 0 + assert repo.list_all(user_id="ae_since", since=future) == []