Skip to content

Commit a947f2f

Browse files
committed
feat(admin): add /admin dashboard with analytics, moderation, health
Phases 0-3 per REQUIREMENTS-admin-dashboard.md: Phase 0 - Admin Auth Foundation - ADMIN_EMAILS env var, require_admin dep, canViewAdmin flag in /api/auth/me - Disabled-user block on login, guest, refresh - /admin/* layout with auth gate, sidebar (Analytics/Moderation/Health/Review feedback) - Dashboard menu item in user nav for admins Phase 1 - Analytics Dashboard - GET /api/admin/analytics/{users,chats,feedback,tokens} with date range filter - Frontend: stat cards, 5 chart families, date filter, /admin/analytics page - Token usage block (totalTokens, promptTokens, completionTokens, tokensByModel, cost) Phase 2 - Moderation Panel - GET /api/admin/moderation/{users,chats} paginated, with search - POST /disable toggle, DELETE /chats/{id} soft delete (deletedAt) - Frontend: Users tab + Chats tab with search, pagination, actions Phase 3 - System Health Dashboard - GET /api/admin/health (status, db, mcp, uptimeSeconds) with MCP async-with fix - GET /api/admin/health/metrics (8 fields incl. totalTokens24h, tokenRate24h, costEstimate24h) - Frontend: status cards, metrics grid, 30s auto-refresh Cross-cutting - Rate-limit exemption for /api/admin/* - ChatTokenUsage model + 2 alembic migrations (deletedAt + ChatTokenUsage) - Admin canViewTokenUsage flag (admin supersedes reviewer) - require_feedback_reviewer accepts admin OR reviewer emails - /review/* auth-gated shell (ReviewShell) for sole reviewers - /register + /login: bounce fix + optional 'Continue as guest' path (MSAL untouched) - 90 backend tests + 33 frontend unit tests + 18 e2e tests - docs/admin-api.md, CHANGELOG.md, README.md updated - .gitignore: ignore armada/, .opencode/, .agents/, DEFECTS.md, ADVERSARIAL_REVIEW.md, screenshots/, eval artifacts
1 parent 814c4fe commit a947f2f

50 files changed

Lines changed: 6263 additions & 156 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,22 @@ backend/evals/personas/
8484
# Python
8585
__pycache__/
8686
*.pyc
87+
88+
# Armada orchestration artifacts (not part of upstream/dev)
89+
armada/
90+
armada.yaml
91+
.opencode/
92+
.agents/
93+
DEFECTS.md
94+
ADVERSARIAL_REVIEW.md
95+
antigravity_chat_history.md
96+
screenshots/
97+
98+
# Backend eval artifacts (not part of the PR)
99+
backend/eval_run_outputs.md
100+
backend/evals/logs/
101+
backend/evals/peer_reviews/
102+
backend/evals/reports/
103+
104+
# SDD contract artifacts (armada)
105+
REQUIREMENTS-*.md

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Changelog
2+
3+
All notable changes to this project are documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6+
7+
## [Unreleased] - 2026-08-02
8+
9+
### Added
10+
11+
- /admin dashboard at /admin/{analytics,moderation,health}
12+
- Backend: 4 analytics endpoints (users, chats, feedback, tokens) with date range filter
13+
- Backend: 4 moderation endpoints (list users, disable toggle, list chats, soft delete)
14+
- Backend: 2 health endpoints (status + metrics incl. token rate and cost)
15+
- Token usage tracking via new ChatTokenUsage model
16+
- ADMIN_EMAILS env var for admin allowlist
17+
- canViewAdmin flag in /api/auth/me
18+
- Disabled-user auth block on login, guest, refresh
19+
- Soft delete for chats via deletedAt column
20+
- Rate limit exemption for /api/admin/* paths
21+
- Dev-only auto table creation for ChatTokenUsage
22+
23+
### Fixed
24+
25+
- Health endpoint now returns string db/mcp state and uptimeSeconds
26+
- Moderation search strips null bytes; reversed date range returns 400

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<a href="#getting-started">Getting Started</a> ·
1111
<a href="#configuration">Configuration</a> ·
1212
<a href="#authentication">Authentication</a> ·
13+
<a href="#admin-dashboard">Admin Dashboard</a> ·
1314
<a href="#deployment">Deployment</a> ·
1415
<a href="DEVELOPER.md">Developer Guide</a> ·
1516
<a href="https://worldbank.github.io/data-ai-chatbot">Documentation</a>
@@ -307,6 +308,7 @@ pnpm dev # Start dev server (http://localhost:3001)
307308
| `CORS_ORIGINS` | No | Comma-separated allowed origins |
308309
| `ENVIRONMENT` | No | `development` / `production` |
309310
| `AUTH_PROVIDER` | No | `guest` \| `user` \| `msal` (see [Authentication](#authentication)) |
311+
| `ADMIN_EMAILS` | No | Comma-separated email allowlist for the admin dashboard (empty = no admin access) |
310312
| `RATE_LIMIT_ENABLED` | No | Enable per-user/IP rate limiting |
311313
| `LOG_FILE` | No | Log output file path |
312314

@@ -355,6 +357,41 @@ See [`frontend/docs/env-variables.md`](frontend/docs/env-variables.md) for the f
355357

356358
---
357359

360+
## Admin Dashboard
361+
362+
The application ships with an admin dashboard at `/admin` for usage analytics, content moderation, and system health. All `/admin/*` pages and `/api/admin/*` endpoints are gated by the `ADMIN_EMAILS` allowlist; non-admin users get a 403 both in the UI and at the API layer.
363+
364+
### Enabling admin access
365+
366+
Set `ADMIN_EMAILS` on the backend with a comma-separated list of allowed email addresses:
367+
368+
```bash
369+
# backend/.env
370+
ADMIN_EMAILS=admin@org.com,ops@org.com
371+
```
372+
373+
When `ADMIN_EMAILS` is empty, no one has admin access — every `/api/admin/*` request returns 403 and `canViewAdmin` is `false`.
374+
375+
### Accessing the dashboard
376+
377+
1. Log in with an account whose email is listed in `ADMIN_EMAILS`.
378+
2. Visit `/admin`.
379+
380+
The admin shell checks the `canViewAdmin` flag returned by `GET /api/auth/me` and shows a 403 page to non-admins. A sidebar links to the three pages:
381+
382+
| Page | URL | What it shows |
383+
| --------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------- |
384+
| Analytics | `/admin/analytics` | Stat cards and charts for users, chats, feedback, and token usage; date-range filter (Last 7d / 30d / 90d) |
385+
| Moderation | `/admin/moderation` | User management (search, disable/enable) and chat browser (search by title, soft delete), with pagination |
386+
| Health | `/admin/health` | API / database / MCP status cards and operational metrics; auto-refreshes every 30 seconds |
387+
388+
Token usage is tracked per message via the `ChatTokenUsage` model and surfaces on **Analytics** (totals, per-model, cost estimates) and **Health** (24-hour totals and token rate).
389+
390+
Full endpoint reference: [`docs/admin-api.md`](docs/admin-api.md).
391+
Feature contract: [`REQUIREMENTS-admin-dashboard.md`](REQUIREMENTS-admin-dashboard.md).
392+
393+
---
394+
358395
## Deployment
359396

360397
### Docker Compose (development / testing)
@@ -503,6 +540,8 @@ Additional reference docs in this repository:
503540
- [frontend/docs/env-variables.md](frontend/docs/env-variables.md) — full frontend env var reference
504541
- [docs/docker-setup.md](docs/docker-setup.md) — Docker setup details
505542
- [docs/security-guardrails-audit.md](docs/security-guardrails-audit.md) — security audit summary
543+
- [docs/admin-api.md](docs/admin-api.md) — admin dashboard API reference
544+
- [REQUIREMENTS-admin-dashboard.md](REQUIREMENTS-admin-dashboard.md) — admin dashboard feature contract
506545

507546
---
508547

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""add_user_disabled_column
2+
3+
Revision ID: 9bab245e8167
4+
Revises: k2m3n4o5p6q7
5+
Create Date: 2026-08-01 19:57:15.693715
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
13+
from alembic import op
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "9bab245e8167"
17+
down_revision: Union[str, None] = "k2m3n4o5p6q7"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.add_column(
25+
"User",
26+
sa.Column("disabled", sa.Boolean(), nullable=False, server_default=sa.false()),
27+
)
28+
# ### end Alembic commands ###
29+
30+
31+
def downgrade() -> None:
32+
# ### commands auto generated by Alembic - please adjust! ###
33+
op.drop_column("User", "disabled")
34+
# ### end Alembic commands ###
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""add_deleted_at_to_chat
2+
3+
Revision ID: l3m4n5o6p7q8
4+
Revises: 9bab245e8167
5+
Create Date: 2026-08-02 12:00:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
13+
from alembic import op
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "l3m4n5o6p7q8" # pragma: allowlist secret
17+
down_revision: Union[str, None] = "9bab245e8167" # pragma: allowlist secret
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
op.add_column(
24+
"Chat",
25+
sa.Column("deletedAt", sa.DateTime(), nullable=True),
26+
)
27+
28+
29+
def downgrade() -> None:
30+
op.drop_column("Chat", "deletedAt")
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""add_chattokenusage_table
2+
3+
Revision ID: m4n5o6p7q8r9
4+
Revises: l3m4n5o6p7q8
5+
Create Date: 2026-08-02 14:00:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from sqlalchemy.dialects import postgresql
13+
14+
from alembic import op
15+
from app.db.migration_utils import grant_table_to_app_user
16+
17+
revision: str = "m4n5o6p7q8r9" # pragma: allowlist secret
18+
down_revision: Union[str, None] = "l3m4n5o6p7q8" # pragma: allowlist secret
19+
branch_labels: Union[str, Sequence[str], None] = None
20+
depends_on: Union[str, Sequence[str], None] = None
21+
22+
23+
def upgrade() -> None:
24+
op.create_table(
25+
"ChatTokenUsage",
26+
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
27+
sa.Column("chatId", postgresql.UUID(as_uuid=True), nullable=False),
28+
sa.Column("messageId", sa.String(length=64), nullable=False),
29+
sa.Column("totalTokens", sa.Integer(), nullable=False, server_default="0"),
30+
sa.Column("costUSD", sa.Float(), nullable=False, server_default="0.0"),
31+
sa.Column("createdAt", sa.DateTime(), nullable=False),
32+
sa.PrimaryKeyConstraint("id"),
33+
)
34+
op.create_index("ix_chattokenusage_createdat", "ChatTokenUsage", ["createdAt"])
35+
grant_table_to_app_user(op, "ChatTokenUsage")
36+
37+
38+
def downgrade() -> None:
39+
op.drop_table("ChatTokenUsage")

backend/app/api/deps.py

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@
3333
logger = logging.getLogger(__name__)
3434

3535

36+
def _raise_if_disabled(user) -> None:
37+
"""Raise 403 when the resolved user has been disabled by an admin."""
38+
if getattr(user, "disabled", False):
39+
logger.warning("Account disabled, rejecting authentication: user_id=%s", user.id)
40+
raise HTTPException(
41+
status_code=status.HTTP_403_FORBIDDEN,
42+
detail="Account has been disabled",
43+
)
44+
45+
46+
async def invalidate_user_cache(user_id: str) -> None:
47+
"""Drop a user's cached auth entry so the next request re-reads from the DB."""
48+
async with _user_cache_lock:
49+
_user_cache.pop(user_id, None)
50+
51+
3652
async def get_current_user(
3753
request: Request,
3854
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
@@ -103,6 +119,7 @@ async def get_current_user(
103119
user = await get_or_create_user_from_azure_claims(
104120
db, azure_oid=oid, email=email, name=name
105121
)
122+
_raise_if_disabled(user)
106123
user_dict = {"id": str(user.id), "type": user.type or "regular"}
107124
async with _user_cache_lock:
108125
_user_cache[msal_cache_key] = (time.monotonic(), user_dict)
@@ -154,6 +171,7 @@ async def get_current_user(
154171
)
155172
payload = None
156173
else:
174+
_raise_if_disabled(user)
157175
# Check if password was changed after token was issued (session invalidation)
158176
if jti and hasattr(user, "password_changed_at") and user.password_changed_at:
159177
token_issued_at = payload.get("iat")
@@ -210,6 +228,7 @@ async def get_current_user(
210228
try:
211229
user_id = UUID(validated_user_id)
212230
user = await get_user_by_id(db, user_id)
231+
_raise_if_disabled(user)
213232
logger.debug(
214233
"Guest user lookup: user_id=%s, found=%s, email=%s",
215234
user_id,
@@ -285,6 +304,7 @@ async def get_current_user(
285304
try:
286305
user_id = UUID(validated_user_id)
287306
user = await get_user_by_id(db, user_id)
307+
_raise_if_disabled(user)
288308
logger.info(
289309
"Regular user lookup: user_id=%s, found=%s, email=%s",
290310
user_id,
@@ -338,6 +358,7 @@ async def get_current_user(
338358
return {**cached, "_restore_user": True}
339359

340360
user = await get_user_by_id(db, user_id)
361+
_raise_if_disabled(user)
341362
logger.debug(
342363
"Regular user lookup (raw UUID fallback): user_id=%s, found=%s, email=%s",
343364
user_id,
@@ -406,7 +427,9 @@ def get_reviewer_emails() -> set[str]:
406427
Parse FEEDBACK_REVIEWER_EMAILS into a lower-cased set of email addresses.
407428
408429
Centralised here so that both require_feedback_reviewer (access control) and
409-
/api/auth/me (canViewTokenUsage flag) use identical parsing logic.
430+
/api/auth/me (canViewTokenUsage flag) use identical parsing logic. Admins
431+
(in ADMIN_EMAILS) are implicitly allowed too — see require_feedback_reviewer
432+
for the union logic.
410433
"""
411434
raw = getattr(settings, "FEEDBACK_REVIEWER_EMAILS", "") or ""
412435
return {e.strip().lower() for e in raw.split(",") if e.strip()}
@@ -417,12 +440,17 @@ async def require_feedback_reviewer(
417440
db: AsyncSession = Depends(get_db),
418441
) -> dict:
419442
"""
420-
Require authenticated user whose email is in FEEDBACK_REVIEWER_EMAILS.
443+
Require authenticated user whose email is in FEEDBACK_REVIEWER_EMAILS
444+
or ADMIN_EMAILS (admin supersedes reviewer).
445+
421446
Use for feedback review/list endpoints. Raises 403 if not allowed.
422447
"""
423-
allowed = get_reviewer_emails()
424-
if not allowed:
425-
logger.warning("require_feedback_reviewer: FEEDBACK_REVIEWER_EMAILS is empty")
448+
reviewer_emails = get_reviewer_emails()
449+
admin_emails = get_admin_emails()
450+
if not reviewer_emails and not admin_emails:
451+
logger.warning(
452+
"require_feedback_reviewer: both FEEDBACK_REVIEWER_EMAILS and ADMIN_EMAILS are empty"
453+
)
426454
raise HTTPException(
427455
status_code=status.HTTP_403_FORBIDDEN,
428456
detail="Feedback review is not configured or access is disabled",
@@ -449,7 +477,8 @@ async def require_feedback_reviewer(
449477
status_code=status.HTTP_403_FORBIDDEN,
450478
detail="You do not have permission to view feedback",
451479
)
452-
if user.email.strip().lower() not in allowed:
480+
email_lower = user.email.strip().lower()
481+
if email_lower not in reviewer_emails and email_lower not in admin_emails:
453482
logger.info(
454483
"require_feedback_reviewer: email not in allowlist, user_id=%s",
455484
user_id_str,
@@ -459,3 +488,50 @@ async def require_feedback_reviewer(
459488
detail="You do not have permission to view feedback",
460489
)
461490
return current_user
491+
492+
493+
def get_admin_emails() -> set[str]:
494+
"""Parse ADMIN_EMAILS into a lower-cased set of email addresses."""
495+
raw = getattr(settings, "ADMIN_EMAILS", "") or ""
496+
return {e.strip().lower() for e in raw.split(",") if e.strip()}
497+
498+
499+
async def require_admin(
500+
current_user: dict = Depends(get_current_user),
501+
db: AsyncSession = Depends(get_db),
502+
) -> dict:
503+
"""
504+
Require authenticated user whose email is in ADMIN_EMAILS.
505+
Use for admin dashboard endpoints. Raises 403 if not allowed.
506+
"""
507+
allowed = get_admin_emails()
508+
if not allowed:
509+
raise HTTPException(
510+
status_code=status.HTTP_403_FORBIDDEN,
511+
detail="Admin access is not configured",
512+
)
513+
user_id_str = current_user.get("id")
514+
if not user_id_str:
515+
raise HTTPException(
516+
status_code=status.HTTP_403_FORBIDDEN,
517+
detail="User ID not found",
518+
)
519+
try:
520+
user_uuid = UUID(user_id_str)
521+
except (ValueError, TypeError):
522+
raise HTTPException(
523+
status_code=status.HTTP_403_FORBIDDEN,
524+
detail="Invalid user ID",
525+
)
526+
user = await get_user_by_id(db, user_uuid)
527+
if not user or not getattr(user, "email", None):
528+
raise HTTPException(
529+
status_code=status.HTTP_403_FORBIDDEN,
530+
detail="You do not have admin access",
531+
)
532+
if user.email.strip().lower() not in allowed:
533+
raise HTTPException(
534+
status_code=status.HTTP_403_FORBIDDEN,
535+
detail="You do not have admin access",
536+
)
537+
return current_user
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Admin dashboard API routes."""

0 commit comments

Comments
 (0)