mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 22:14:05 +00:00
Merge pull request #2539 from arc53/admin-dashboard
feat: admin dashboard
This commit is contained in:
19 files changed
+2511
-42
No files matched your search
@@ -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/<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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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"] == []
|
||||
@@ -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) == []
|
||||
Reference in new issue
Block a user