diff --git a/README.md b/README.md
index decbb15e..c7b90fea 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@
- [x] Postgres migration for user data ( April 2026 )
- [x] OpenTelemetry observability ( April 2026 )
- [x] Bring Your Own Model (BYOM) ( April 2026 )
-- [ ] Agent scheduling (RedBeat-backed) ( Q2 2026 )
+- [x] Agent scheduling (RedBeat-backed) ( Q2 2026 )
You can find our full roadmap [here](https://github.com/orgs/arc53/projects/2). Please don't hesitate to contribute or create issues, it helps us improve DocsGPT!
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 e49be562..2d8ed6c7 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,
@@ -74,9 +73,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..e62ab82b
--- /dev/null
+++ b/frontend/src/admin/AdminUI.tsx
@@ -0,0 +1,148 @@
+import Spinner from '../components/Spinner';
+import { formatDateOnly, formatDateTime } from '../utils/dateTimeUtils';
+
+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 {
+ return value ? formatDateTime(value) : '—';
+}
+
+export function fmtDateShort(value?: string | null): string {
+ return value ? formatDateOnly(value) : '—';
+}
+
+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"
+ />
+
+ Filter
+
+ {(search.event || search.user_id) && (
+ {
+ setEventFilter('');
+ setUserFilter('');
+ setPage(1);
+ setSearch({ event: '', user_id: '' });
+ }}
+ >
+ Clear
+
+ )}
+
+
+ {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}
+
+
+ setPage((p) => p - 1)}
+ >
+ Previous
+
+ = totalPages}
+ onClick={() => setPage((p) => p + 1)}
+ >
+ Next
+
+
+
+ >
+ )}
+
+ );
+}
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 (
+
+ );
+}
+
+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) => (
+ setDays(r)}
+ >
+ {r}d
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ {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"
+ />
+
+ Search
+
+ {query ? (
+ {
+ setSearch('');
+ setQuery('');
+ setPage(1);
+ }}
+ >
+ Clear
+
+ ) : 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}
+
+
+ setPage((p) => p - 1)}
+ >
+ Previous
+
+ = totalPages}
+ onClick={() => setPage((p) => p + 1)}
+ >
+ Next
+
+
+
+ >
+ )}
+
+ {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) => (
+
{
+ // Close the detail dialog before any confirm dialog opens
+ // (avoids stacked modals); the list + banner reflect the result.
+ setDetail(null);
+ act.perform();
+ }}
+ >
+
+ {act.label}
+
+ ))}
+
+ ) : 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/agents/AgentLogs.tsx b/frontend/src/agents/AgentLogs.tsx
index ec9fc53e..59994701 100644
--- a/frontend/src/agents/AgentLogs.tsx
+++ b/frontend/src/agents/AgentLogs.tsx
@@ -8,6 +8,7 @@ import Spinner from '../components/Spinner';
import { selectToken } from '../preferences/preferenceSlice';
import Analytics from '../settings/Analytics';
import Logs from '../settings/Logs';
+import { formatDateTime } from '../utils/dateTimeUtils';
import AgentPageHeader from './AgentPageHeader';
import { Agent } from './types';
@@ -59,7 +60,7 @@ export default function AgentLogs() {
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
- new Date(agent.last_used_at).toLocaleString()
+ formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
diff --git a/frontend/src/agents/SharedAgentCard.tsx b/frontend/src/agents/SharedAgentCard.tsx
index f7745fe0..d5fb8e42 100644
--- a/frontend/src/agents/SharedAgentCard.tsx
+++ b/frontend/src/agents/SharedAgentCard.tsx
@@ -4,6 +4,7 @@ import EditIcon from '../assets/edit.svg';
import ToolIcon from '../components/ToolIcon';
import { Avatar } from '../components/ui/avatar';
import { Button } from '../components/ui/button';
+import { formatDateTime } from '../utils/dateTimeUtils';
import { getToolDisplayName } from '../utils/toolUtils';
import { Agent } from './types';
@@ -61,18 +62,7 @@ export default function SharedAgentCard({
)}
{agent.shared_metadata?.shared_at && (
- Shared on{' '}
- {new Date(agent.shared_metadata.shared_at).toLocaleString(
- 'en-US',
- {
- month: 'long',
- day: 'numeric',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- hour12: true,
- },
- )}
+ Shared on {formatDateTime(agent.shared_metadata.shared_at)}
)}
diff --git a/frontend/src/agents/schedules/RunDetailDrawer.tsx b/frontend/src/agents/schedules/RunDetailDrawer.tsx
index 7cd85876..4c1359bd 100644
--- a/frontend/src/agents/schedules/RunDetailDrawer.tsx
+++ b/frontend/src/agents/schedules/RunDetailDrawer.tsx
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { Button } from '../../components/ui/button';
+import { formatDateTime } from '../../utils/dateTimeUtils';
import type { ScheduleRun } from '../types/schedule';
import ScheduleStatusBadge from './StatusBadge';
@@ -10,10 +11,7 @@ export type RunDetailDrawerProps = {
};
const formatTimestamp = (value?: string | null): string => {
- if (!value) return '—';
- const d = new Date(value);
- if (Number.isNaN(d.getTime())) return value;
- return d.toLocaleString();
+ return value ? formatDateTime(value) : '—';
};
/** Side drawer with a single run's output / error (terminal-state only). */
diff --git a/frontend/src/agents/schedules/RunLog.tsx b/frontend/src/agents/schedules/RunLog.tsx
index 364237b0..83b0913c 100644
--- a/frontend/src/agents/schedules/RunLog.tsx
+++ b/frontend/src/agents/schedules/RunLog.tsx
@@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { Button } from '../../components/ui/button';
import { selectToken } from '../../preferences/preferenceSlice';
import type { AppDispatch, RootState } from '../../store';
+import { formatDateTime } from '../../utils/dateTimeUtils';
import type { ScheduleRun } from '../types/schedule';
import ScheduleStatusBadge from './StatusBadge';
import { loadRunsForSchedule, selectRunsForSchedule } from './schedulesSlice';
@@ -14,10 +15,7 @@ export type RunLogProps = {
};
const formatTimestamp = (value?: string | null): string => {
- if (!value) return '—';
- const d = new Date(value);
- if (Number.isNaN(d.getTime())) return value;
- return d.toLocaleString();
+ return value ? formatDateTime(value) : '—';
};
/** Paginated run log for a schedule (SSE updates merge via schedulesSlice). */
diff --git a/frontend/src/agents/schedules/ScheduleFormModal.tsx b/frontend/src/agents/schedules/ScheduleFormModal.tsx
index 1b7c48c4..d94d224d 100644
--- a/frontend/src/agents/schedules/ScheduleFormModal.tsx
+++ b/frontend/src/agents/schedules/ScheduleFormModal.tsx
@@ -20,6 +20,7 @@ import { TimePicker } from '@/components/ui/time-picker';
import { cn } from '@/lib/utils';
import { Modal } from '../../components/ui/modal';
+import { formatDateOnly } from '../../utils/dateTimeUtils';
import type { Schedule, ScheduleCreatePayload } from '../types/schedule';
import {
browserTimezone,
@@ -91,13 +92,7 @@ const dateToDateString = (d: Date): string => {
};
const formatDateLabel = (value: string): string => {
- const d = dateStringToDate(value);
- if (!d) return '';
- return d.toLocaleDateString(undefined, {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- });
+ return formatDateOnly(value);
};
/** Create/edit a Schedule via a modal dialog. */
diff --git a/frontend/src/agents/schedules/SchedulerToolCallCard.tsx b/frontend/src/agents/schedules/SchedulerToolCallCard.tsx
index 0597b1c6..bbfc616b 100644
--- a/frontend/src/agents/schedules/SchedulerToolCallCard.tsx
+++ b/frontend/src/agents/schedules/SchedulerToolCallCard.tsx
@@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { Button } from '../../components/ui/button';
import { selectToken } from '../../preferences/preferenceSlice';
import type { AppDispatch } from '../../store';
+import { formatDateTime } from '../../utils/dateTimeUtils';
import { deleteSchedule, loadSchedulesForAgent } from './schedulesSlice';
export type SchedulerToolCallCardProps = {
@@ -18,10 +19,7 @@ export type SchedulerToolCallCardProps = {
};
const formatTimestamp = (value?: string | null): string => {
- if (!value) return '—';
- const d = new Date(value);
- if (Number.isNaN(d.getTime())) return value;
- return d.toLocaleString();
+ return value ? formatDateTime(value) : '—';
};
const parseResult = (result: unknown): Record | null => {
diff --git a/frontend/src/agents/schedules/SchedulesView.tsx b/frontend/src/agents/schedules/SchedulesView.tsx
index 56d1b6fb..bc755f22 100644
--- a/frontend/src/agents/schedules/SchedulesView.tsx
+++ b/frontend/src/agents/schedules/SchedulesView.tsx
@@ -10,6 +10,7 @@ import ConfirmationModal from '../../modals/ConfirmationModal';
import { ActiveState } from '../../models/misc';
import { selectToken } from '../../preferences/preferenceSlice';
import type { AppDispatch, RootState } from '../../store';
+import { formatDateTime } from '../../utils/dateTimeUtils';
import AgentPageHeader from '../AgentPageHeader';
import type { Agent } from '../types';
import type {
@@ -33,10 +34,7 @@ import {
} from './schedulesSlice';
const formatTimestamp = (value?: string | null): string => {
- if (!value) return '—';
- const d = new Date(value);
- if (Number.isNaN(d.getTime())) return value;
- return d.toLocaleString();
+ return value ? formatDateTime(value) : '—';
};
/** Standalone Schedules page for an agent: list, create, edit, pause, run, delete. */
@@ -170,7 +168,7 @@ export default function SchedulesView() {
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
- new Date(agent.last_used_at).toLocaleString()
+ formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts
index 30bcdfc2..b367ce17 100644
--- a/frontend/src/api/endpoints.ts
+++ b/frontend/src/api/endpoints.ts
@@ -123,6 +123,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/frontend/src/components/FilePicker.tsx b/frontend/src/components/FilePicker.tsx
index ba0b4ffb..d8437d42 100644
--- a/frontend/src/components/FilePicker.tsx
+++ b/frontend/src/components/FilePicker.tsx
@@ -3,7 +3,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import userService from '../api/services/userService';
import { formatBytes } from '../utils/stringUtils';
-import { formatDate } from '../utils/dateTimeUtils';
+import { formatDateTime } from '../utils/dateTimeUtils';
import {
getSessionToken,
setSessionToken,
@@ -599,7 +599,7 @@ export const FilePicker: React.FC = ({
- {formatDate(file.modifiedTime)}
+ {formatDateTime(file.modifiedTime)}
{file.size ? formatBytes(file.size) : '-'}
diff --git a/frontend/src/conversation/SharedConversation.tsx b/frontend/src/conversation/SharedConversation.tsx
index 133c51ea..3a732467 100644
--- a/frontend/src/conversation/SharedConversation.tsx
+++ b/frontend/src/conversation/SharedConversation.tsx
@@ -8,7 +8,7 @@ import MessageInput from '../components/MessageInput';
import { Button } from '../components/ui/button';
import { selectToken } from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
-import { formatDate } from '../utils/dateTimeUtils';
+import { formatDateTime } from '../utils/dateTimeUtils';
import ConversationMessages from './ConversationMessages';
import {
addQuery,
@@ -69,7 +69,7 @@ export const SharedConversation = () => {
setFetchedData({
queries: data.queries,
title: data.title,
- date: formatDate(data.timestamp),
+ date: formatDateTime(data.timestamp),
identifier,
}),
);
diff --git a/frontend/src/settings/RemoteDeviceConfig.tsx b/frontend/src/settings/RemoteDeviceConfig.tsx
index a037aa42..f8426410 100644
--- a/frontend/src/settings/RemoteDeviceConfig.tsx
+++ b/frontend/src/settings/RemoteDeviceConfig.tsx
@@ -30,6 +30,7 @@ import {
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
+import { formatDateTime } from '../utils/dateTimeUtils';
import { UserToolType } from './types';
/** ms-since-last-seen threshold for the online pill. */
@@ -43,12 +44,7 @@ function isOnline(device: Device | null): boolean {
}
function formatTimestamp(value: string | null | undefined): string {
- if (!value) return '-';
- try {
- return new Date(value).toLocaleString();
- } catch {
- return value;
- }
+ return value ? formatDateTime(value) : '-';
}
/** Compact relative span (e.g. "12s", "5m", "3h", "2d") since `value`. */
diff --git a/frontend/src/utils/dateTimeUtils.test.ts b/frontend/src/utils/dateTimeUtils.test.ts
new file mode 100644
index 00000000..36184377
--- /dev/null
+++ b/frontend/src/utils/dateTimeUtils.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+
+import { formatDate, formatDateOnly, formatDateTime } from './dateTimeUtils';
+
+describe('dateTimeUtils', () => {
+ it('formats date-only values as DD/MM/YYYY', () => {
+ expect(formatDate('2026-05-21')).toBe('21/05/2026');
+ expect(formatDateOnly('2026-05-21')).toBe('21/05/2026');
+ });
+
+ it('formats local datetime strings with a 24-hour time', () => {
+ expect(formatDate('2026-05-21 14:30:00')).toBe('21/05/2026, 14:30');
+ expect(formatDateTime('2026-05-21T14:30:00')).toBe('21/05/2026, 14:30');
+ });
+
+ it('does not invent a midnight time for date-only values', () => {
+ expect(formatDateTime('2026-05-21')).toBe('21/05/2026');
+ });
+
+ it('supports ISO timestamps with fractional seconds and offsets', () => {
+ const value = '2026-06-09T00:51:20.427827+00:00';
+ const expected = new Intl.DateTimeFormat('en-GB', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ }).format(new Date(value));
+
+ expect(formatDate(value)).toBe(expected);
+ expect(formatDateTime(value)).toBe(expected);
+ });
+
+ it('parses natural-language dates into DD/MM/YYYY', () => {
+ expect(formatDate('May 21, 2026')).toBe('21/05/2026');
+ });
+
+ it('returns the original value when parsing fails', () => {
+ expect(formatDate('not a date')).toBe('not a date');
+ expect(formatDateTime('still not a date')).toBe('still not a date');
+ });
+});
diff --git a/frontend/src/utils/dateTimeUtils.ts b/frontend/src/utils/dateTimeUtils.ts
index ce7a2c39..1f287cab 100644
--- a/frontend/src/utils/dateTimeUtils.ts
+++ b/frontend/src/utils/dateTimeUtils.ts
@@ -1,59 +1,70 @@
-export function formatDate(dateString: string): string {
- if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/.test(dateString)) {
- // ISO 8601 format
- const dateTime = new Date(dateString);
- return dateTime.toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- });
- } else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(dateString)) {
- const dateTime = new Date(dateString);
- return dateTime.toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- });
- } else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(dateString)) {
- const dateTime = new Date(dateString);
- return dateTime.toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- });
- } else if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
- // `new Date('YYYY-MM-DD')` parses as UTC midnight, so rendering it
- // in a timezone west of UTC shows the previous day. Construct from
- // parts so the date is interpreted in local time (matching the
- // space-separated hourly buckets, which already parse as local).
- const [year, month, day] = dateString.split('-').map(Number);
- const date = new Date(year, month - 1, day);
- return date.toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- });
- } else if (
- /^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(
- dateString,
- )
- ) {
- // Format: "Fri, 08 Jul 2025 06:00:00 GMT"
- const dateTime = new Date(dateString);
- return dateTime.toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- });
- } else {
- return dateString;
+type FormatMode = 'auto' | 'date' | 'dateTime';
+
+const DATE_ONLY_REGEX = /^(\d{4})-(\d{2})-(\d{2})$/;
+const LOCAL_DATE_TIME_REGEX =
+ /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?$/;
+const HAS_TIME_REGEX = /\d{1,2}:\d{2}/;
+
+const DATE_FORMATTER = new Intl.DateTimeFormat('en-GB', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+});
+
+const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('en-GB', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+});
+
+function parseDateValue(value: string): Date | null {
+ const dateOnlyMatch = DATE_ONLY_REGEX.exec(value);
+ if (dateOnlyMatch) {
+ const [, year, month, day] = dateOnlyMatch;
+ return new Date(Number(year), Number(month) - 1, Number(day));
}
+
+ const localDateTimeMatch = LOCAL_DATE_TIME_REGEX.exec(value);
+ if (localDateTimeMatch) {
+ const [, year, month, day, hour, minute, second = '0'] = localDateTimeMatch;
+ return new Date(
+ Number(year),
+ Number(month) - 1,
+ Number(day),
+ Number(hour),
+ Number(minute),
+ Number(second),
+ );
+ }
+
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+}
+
+function formatDateValue(value: string, mode: FormatMode): string {
+ const parsed = parseDateValue(value);
+ if (!parsed) return value;
+
+ const hasTime = HAS_TIME_REGEX.test(value);
+ const shouldIncludeTime =
+ mode === 'dateTime' ? hasTime : mode === 'auto' && hasTime;
+
+ return shouldIncludeTime
+ ? DATE_TIME_FORMATTER.format(parsed)
+ : DATE_FORMATTER.format(parsed);
+}
+
+export function formatDate(dateString: string): string {
+ return formatDateValue(dateString, 'auto');
+}
+
+export function formatDateOnly(dateString: string): string {
+ return formatDateValue(dateString, 'date');
+}
+
+export function formatDateTime(dateString: string): string {
+ return formatDateValue(dateString, 'dateTime');
}
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) == []