feat: admin dashboard

This commit is contained in:
Alex committed 2026-06-15 11:30:02 +01:00
1 parent 49fb28acd3
commit 9a349401f4
19 files changed
+2513 -42

No files matched your search

+325 -7
View File
@@ -3,18 +3,31 @@
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 import api
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 +44,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 +111,273 @@ class AdminUsersResource(Resource):
),
200,
)
@admin_ns.route("/admin/users/<string:user_id>")
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/<string:user_id>/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/<string:user_id>/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,
)
# Register here, in this import-cached module, rather than in app.py's body so a
# re-import of application.app (coverage tests reload the module) doesn't re-fire
# add_namespace against the already-initialized Api. app.py imports this module
# for the side effect.
api.add_namespace(admin_ns)
-7
View File
@@ -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)
+1
View File
@@ -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 routes as _admin_routes # noqa: E402,F401 (registers admin_ns)
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
@@ -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},
),
}
@@ -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
)
@@ -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]:
+157
View File
@@ -0,0 +1,157 @@
import Spinner from '../components/Spinner';
export function Loading() {
return (
<div className="flex h-40 items-center justify-center">
<Spinner />
</div>
);
}
export function LoadError({
message = 'Failed to load.',
}: {
message?: string;
}) {
return <p className="text-muted-foreground mt-8 text-sm">{message}</p>;
}
export function StatCard({
label,
value,
sub,
}: {
label: string;
value: React.ReactNode;
sub?: string;
}) {
return (
<div className="border-border dark:border-border rounded-2xl border px-6 py-5">
<p className="text-muted-foreground text-sm">{label}</p>
<p className="text-foreground dark:text-foreground mt-1 text-2xl font-bold tabular-nums">
{value}
</p>
{sub ? <p className="text-muted-foreground mt-1 text-xs">{sub}</p> : null}
</div>
);
}
type Tone = 'default' | 'success' | 'danger' | 'muted' | 'brand' | 'warning';
const TONES: Record<Tone, string> = {
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 (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${TONES[tone]}`}
>
{children}
</span>
);
}
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<string, string> = {
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';
}
+89
View File
@@ -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<Admin[]>([]);
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 <Loading />;
return (
<div className="mt-6">
<p className="text-muted-foreground mb-4 text-sm">
Admin access is the deployment super-admin role. Grant or revoke it from
the Users tab; OIDC-group-derived grants reconcile automatically at
login.
</p>
{admins.length === 0 ? (
<p className="text-muted-foreground text-sm">No admins.</p>
) : (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableHeader>User</TableHeader>
<TableHeader>Sources</TableHeader>
<TableHeader>Granted</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{admins.map((a) => (
<TableRow key={a.user_id}>
<TableCell className="font-medium break-all">
{a.user_id}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{(a.sources ?? []).map((s) => (
<Pill key={s} tone="muted">
{s}
</Pill>
))}
</div>
</TableCell>
<TableCell className="text-muted-foreground whitespace-nowrap">
{fmtDate(a.granted_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</div>
);
}
+202
View File
@@ -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<Event[]>([]);
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 (
<div className="mt-6">
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
placeholder="Event (e.g. oidc_login_denied)"
value={eventFilter}
onChange={(e) => setEventFilter(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
className="max-w-xs"
/>
<Input
placeholder="User id"
value={userFilter}
onChange={(e) => setUserFilter(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
className="max-w-xs"
/>
<Button variant="outline" size="sm" onClick={applyFilters}>
Filter
</Button>
{(search.event || search.user_id) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setEventFilter('');
setUserFilter('');
setPage(1);
setSearch({ event: '', user_id: '' });
}}
>
Clear
</Button>
)}
</div>
{loading ? (
<Loading />
) : events.length === 0 ? (
<p className="text-muted-foreground mt-8 text-sm">No events.</p>
) : (
<>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableHeader>Event</TableHeader>
<TableHeader>User</TableHeader>
<TableHeader>IP</TableHeader>
<TableHeader>When</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{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 (
<TableRow key={ev.id}>
<TableCell>
<Pill tone={eventTone(ev.event)}>
{eventLabel(ev.event)}
</Pill>
</TableCell>
<TableCell
className={`font-mono text-[13px] ${
repeat ? 'text-muted-foreground/50' : ''
}`}
>
{repeat ? '〃' : ev.user_id}
</TableCell>
<TableCell className="whitespace-nowrap">
{isLoopback(ev.ip) ? (
<span className="text-muted-foreground text-xs">
local
</span>
) : (
<span className="font-mono text-[13px]">
{ev.ip ?? '—'}
</span>
)}
</TableCell>
<TableCell className="text-muted-foreground whitespace-nowrap">
<span title={fmtDate(ev.created_at)}>
{fmtRelative(ev.created_at)}
</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<div className="mt-4 flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{fmtNumber(total)} events · page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
</>
)}
</div>
);
}
+122
View File
@@ -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 (
<div className="mt-8">
<p className="text-muted-foreground mb-3 text-sm font-medium">{title}</p>
{children}
</div>
);
}
export default function Overview() {
const token = useSelector(selectToken);
const [data, setData] = useState<any | null>(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 <Loading />;
if (!data?.success) return <LoadError message="Failed to load overview." />;
const users = data.users ?? {};
const failed = data.failed_logins_7d ?? 0;
return (
<div>
<Section title="Users">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
label="Total users"
value={fmtNumber(users.total)}
sub={`${fmtNumber(users.active)} active · ${fmtNumber(users.inactive)} inactive`}
/>
<StatCard label="Admins" value={fmtNumber(data.admins)} />
<StatCard
label="New users (7d)"
value={fmtNumber(data.new_users_7d)}
/>
<StatCard
label="Active users (30d)"
value={fmtNumber(data.active_users_30d)}
/>
</div>
</Section>
<Section title="Activity">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard label="Agents" value={fmtNumber(data.agents)} />
<StatCard label="Sources" value={fmtNumber(data.sources)} />
<StatCard
label="Conversations"
value={fmtNumber(data.conversations)}
/>
<StatCard label="Tokens (30d)" value={fmtNumber(data.tokens_30d)} />
</div>
</Section>
<Section title="Security & access">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div
className={`rounded-2xl border px-6 py-5 ${
failed > 0
? 'border-red-300 bg-red-50 dark:border-red-900/50 dark:bg-red-950/30'
: 'border-border'
}`}
>
<p className="text-muted-foreground text-sm">Failed logins (7d)</p>
<p
className={`mt-1 text-2xl font-bold tabular-nums ${
failed > 0
? 'text-red-600 dark:text-red-400'
: 'text-foreground'
}`}
>
{fmtNumber(failed)}
</p>
{failed > 0 ? (
<Link
to="/admin/audit?event=oidc_login_denied"
className="mt-2 inline-block text-xs text-red-600 hover:underline dark:text-red-400"
>
View in Audit →
</Link>
) : (
<p className="text-muted-foreground mt-2 text-xs">
No denied logins
</p>
)}
</div>
</div>
</Section>
</div>
);
}
+163
View File
@@ -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<any | null>(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 <Loading />;
if (data && !data.success)
return <LoadError message="Failed to load usage." />;
return (
<div className="mt-6">
<div className="mb-4 flex items-center gap-1">
{RANGES.map((r) => (
<Button
key={r}
variant={r === days ? 'default' : 'outline'}
size="sm"
className="rounded-3xl"
onClick={() => setDays(r)}
>
{r}d
</Button>
))}
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatCard
label={`Total tokens (${days}d)`}
value={fmtNumber(data?.total_tokens)}
/>
<StatCard label="Prompt tokens" value={fmtNumber(promptTotal)} />
<StatCard label="Generated tokens" value={fmtNumber(generatedTotal)} />
<StatCard label="Days with usage" value={fmtNumber(series.length)} />
</div>
<div className="border-border dark:border-border mt-4 h-[345px] w-full overflow-hidden rounded-2xl border px-6 py-5">
<div className="flex flex-row items-center justify-between gap-3">
<p className="text-foreground dark:text-foreground font-bold">
Token usage
</p>
<div
id="admin-usage-legend"
className="flex flex-row items-center justify-end"
></div>
</div>
<div className="relative mt-px h-[260px] w-full">
{loading ? (
<SkeletonLoader count={1} component={'analysis'} />
) : series.length === 0 ? (
<p className="text-muted-foreground mt-8 text-sm">
No usage in this period.
</p>
) : (
<UsageChart data={chartData} legendID="admin-usage-legend" />
)}
</div>
</div>
<div className="border-border dark:border-border mt-4 w-full overflow-hidden rounded-2xl border px-6 py-5">
<p className="text-foreground dark:text-foreground mb-3 font-bold">
Top users by tokens
</p>
{topUsers.length === 0 ? (
<p className="text-muted-foreground text-sm">No usage.</p>
) : (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableHeader>User</TableHeader>
<TableHeader className="text-right">Tokens</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{topUsers.map((u) => (
<TableRow key={u.user_id}>
<TableCell className="font-mono text-[13px] break-all">
{u.user_id}
</TableCell>
<TableCell className="text-right whitespace-nowrap tabular-nums">
{fmtNumber(u.tokens)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</div>
</div>
);
}
+97
View File
@@ -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 (
<Bar
options={options}
plugins={[htmlLegendPlugin]}
data={{
...data,
datasets: data.datasets.map((dataset) => ({
...dataset,
hoverBackgroundColor: `${dataset.backgroundColor}CC`,
})),
}}
/>
);
}
+543
View File
@@ -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<AdminUser[]>([]);
const [adminIds, setAdminIds] = useState<Set<string>>(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<string | null>(null);
const [menuUserId, setMenuUserId] = useState<string | null>(null);
const [detail, setDetail] = useState<any | null>(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<ActiveState>('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<Response>, 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 (
<div className="mt-6">
{feedback ? (
<div
className={`mb-4 rounded-xl border px-4 py-2 text-sm ${
feedback.ok
? 'border-green-300 bg-green-50 text-green-700 dark:border-green-900/50 dark:bg-green-950/30 dark:text-green-300'
: 'border-red-300 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300'
}`}
>
{feedback.message}
</div>
) : null}
<div className="mb-4 flex flex-wrap items-center gap-2">
<Input
placeholder="Filter by exact user id"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') applySearch();
}}
className="max-w-xs"
/>
<Button variant="outline" size="sm" onClick={applySearch}>
Search
</Button>
{query ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearch('');
setQuery('');
setPage(1);
}}
>
Clear
</Button>
) : null}
</div>
{loading ? (
<Loading />
) : users.length === 0 ? (
<p className="text-muted-foreground mt-8 text-sm">No users found.</p>
) : (
<>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableHeader>User</TableHeader>
<TableHeader>Tags</TableHeader>
<TableHeader>Last seen</TableHeader>
<TableHeader>Created</TableHeader>
<TableHeader className="text-right">Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{users.map((u) => {
const isAdmin = adminIds.has(u.user_id);
const disabled = busy === u.user_id;
return (
<TableRow
key={u.user_id}
className="hover:bg-muted/40 cursor-pointer"
onClick={() => openDetail(u.user_id)}
>
<TableCell className="max-w-[280px]">
<span
className="block truncate font-mono text-[13px]"
title={u.user_id}
>
{u.user_id}
</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{isAdmin ? <Pill tone="brand">Admin</Pill> : null}
{!u.active ? (
<Pill tone="danger">Inactive</Pill>
) : null}
</div>
</TableCell>
<TableCell className="text-muted-foreground whitespace-nowrap">
<span title={u.last_seen ?? 'never'}>
{fmtRelative(u.last_seen)}
</span>
</TableCell>
<TableCell className="text-muted-foreground whitespace-nowrap">
<span title={u.created_at ?? ''}>
{fmtDateShort(u.created_at)}
</span>
</TableCell>
<TableCell className="text-right">
<div
className="flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu
open={menuUserId === u.user_id}
onOpenChange={(open) =>
setMenuUserId(open ? u.user_id : null)
}
>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
className="text-muted-foreground hover:text-foreground h-[35px] w-7"
aria-label="User actions"
>
<img
src={ThreeDots}
alt="User actions"
className="filter dark:invert"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-[176px]"
>
<DropdownMenuItem
onSelect={() => openDetail(u.user_id)}
>
<Eye size={16} />
<span>View details</span>
</DropdownMenuItem>
{buildActions(u.user_id, isAdmin, u.active).map(
(act) => (
<DropdownMenuItem
key={act.key}
variant={
act.destructive
? 'destructive'
: 'default'
}
onSelect={act.perform}
>
<act.icon size={16} />
<span>{act.label}</span>
</DropdownMenuItem>
),
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<div className="mt-4 flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{fmtNumber(total)} users · page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
</>
)}
{confirm ? (
<ConfirmationModal
message={confirm.message}
modalState={confirmState}
setModalState={setConfirmState}
submitLabel={confirm.submitLabel}
variant="danger"
handleSubmit={() => {
confirm.run();
setConfirm(null);
}}
/>
) : null}
<Modal
open={detail !== null}
onOpenChange={(open) => {
if (!open) setDetail(null);
}}
title={detail?.user?.user_id ?? 'User'}
size="lg"
footer={
detail ? (
<div className="flex flex-wrap justify-end gap-2">
{buildActions(
detail.user.user_id,
(detail.roles ?? []).includes('admin'),
detail.user?.active ?? true,
).map((act) => (
<Button
key={act.key}
type="button"
variant={act.destructive ? 'destructive-outline' : 'outline'}
size="sm"
onClick={() => {
// Close the detail dialog before any confirm dialog opens
// (avoids stacked modals); the list + banner reflect the result.
setDetail(null);
act.perform();
}}
>
<act.icon size={16} />
{act.label}
</Button>
))}
</div>
) : undefined
}
>
{detail ? (
<div className="space-y-4 text-sm">
<div>
<p className="text-muted-foreground mb-1 text-xs">
Roles & status
</p>
<div className="flex flex-wrap gap-2">
{(detail.roles ?? []).map((r: string) => (
<Pill key={r} tone={r === 'admin' ? 'brand' : 'muted'}>
{r}
</Pill>
))}
{detail.user?.active ? (
<Pill tone="success">Active</Pill>
) : (
<Pill tone="danger">Inactive</Pill>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Agents</span>
<span className="tabular-nums">
{fmtNumber(detail.counts?.agents)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Sources</span>
<span className="tabular-nums">
{fmtNumber(detail.counts?.sources)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Conversations</span>
<span className="tabular-nums">
{fmtNumber(detail.counts?.conversations)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tokens (30d)</span>
<span className="tabular-nums">
{fmtNumber(detail.counts?.tokens_30d)}
</span>
</div>
</div>
<div>
<p className="text-muted-foreground mb-1 text-xs">
Recent auth events
</p>
<div className="max-h-64 space-y-1 overflow-auto">
{(detail.recent_events ?? []).length === 0 ? (
<p className="text-muted-foreground">None</p>
) : (
(detail.recent_events ?? []).map((ev: any) => (
<div
key={ev.id}
className="flex items-center justify-between gap-2"
>
<span>{eventLabel(ev.event)}</span>
<span className="text-muted-foreground whitespace-nowrap">
{fmtRelative(ev.created_at)}
</span>
</div>
))
)}
</div>
</div>
</div>
) : null}
</Modal>
</div>
);
}
+65 -10
View File
@@ -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 <AdminRoute> (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 <AdminRoute>
* (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 (
<div className="p-6">
<h1 className="text-foreground text-2xl font-semibold dark:text-white">
Admin
</h1>
<p className="text-muted-foreground mt-2 text-sm">
Admin dashboard coming soon.
</p>
<div className="h-full overflow-auto p-4 md:p-12">
<div className="mx-auto w-full max-w-7xl">
<p className="text-foreground text-2xl font-bold">
{t('admin.label', 'Admin')}
</p>
<Tabs
value={active}
onValueChange={(label) => {
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"
>
<TabsList aria-label={t('admin.tabsAriaLabel', 'Admin sections')}>
{TABS.map((tab) => (
<TabsTrigger key={tab.key} value={tab.label}>
{t(`admin.tabs.${tab.key}`, tab.label)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Routes>
<Route index element={<Overview />} />
<Route path="users" element={<Users />} />
<Route path="admins" element={<Admins />} />
<Route path="usage" element={<Usage />} />
<Route path="audit" element={<Audit />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
</div>
</div>
);
}
+13
View File
@@ -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',
+59
View File
@@ -0,0 +1,59 @@
import apiClient from '../client';
import endpoints from '../endpoints';
const qs = (params: Record<string, string | number | undefined>): 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<any> =>
apiClient.get(endpoints.ADMIN.OVERVIEW, token),
getUsers: (
params: { page?: number; page_size?: number; user_id?: string },
token: string | null,
): Promise<any> =>
apiClient.get(`${endpoints.ADMIN.USERS}${qs(params)}`, token),
getUser: (userId: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.ADMIN.USER(userId), token),
grantAdmin: (userId: string, token: string | null): Promise<any> =>
apiClient.post(endpoints.ADMIN.USER_ROLE(userId), {}, token),
revokeAdmin: (userId: string, token: string | null): Promise<any> =>
apiClient.delete(endpoints.ADMIN.USER_ROLE(userId), token),
setUserActive: (
userId: string,
active: boolean,
token: string | null,
): Promise<any> =>
apiClient.patch(endpoints.ADMIN.USER(userId), { active }, token),
revokeSessions: (userId: string, token: string | null): Promise<any> =>
apiClient.post(endpoints.ADMIN.USER_REVOKE_SESSIONS(userId), {}, token),
getAdmins: (token: string | null): Promise<any> =>
apiClient.get(endpoints.ADMIN.ADMINS, token),
getUsage: (
params: { days?: number; group_by?: string; bucket?: string },
token: string | null,
): Promise<any> =>
apiClient.get(`${endpoints.ADMIN.USAGE}${qs(params)}`, token),
getAudit: (
params: {
page?: number;
page_size?: number;
event?: string;
user_id?: string;
},
token: string | null,
): Promise<any> =>
apiClient.get(`${endpoints.ADMIN.AUDIT}${qs(params)}`, token),
getDeviceAudit: (
params: { page?: number; page_size?: number; decision?: string },
token: string | null,
): Promise<any> =>
apiClient.get(`${endpoints.ADMIN.DEVICE_AUDIT}${qs(params)}`, token),
};
export default adminService;
+267
View File
@@ -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"] == []
+22 -18
View File
@@ -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")
@@ -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) == []