Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,28 @@ backend/evals/personas/
# Python
__pycache__/
*.pyc

# Armada orchestration artifacts (not part of upstream/dev)
armada/
armada.yaml
.opencode/
.agents/
DEFECTS.md
ADVERSARIAL_REVIEW.md
antigravity_chat_history.md
screenshots/

# Backend eval artifacts (not part of the PR)
backend/eval_run_outputs.md
backend/evals/logs/
backend/evals/peer_reviews/
backend/evals/reports/

# SDD contract artifacts (armada)
REQUIREMENTS-*.md

# armada:start
/armada/
/.opencode/
/opencode.json
# armada:end
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Changelog

All notable changes to this project are documented in this file.

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).

## [Unreleased] - 2026-08-02

### Added

- /admin dashboard at /admin/{analytics,moderation,health}
- Backend: 4 analytics endpoints (users, chats, feedback, tokens) with date range filter
- Backend: 4 moderation endpoints (list users, disable toggle, list chats, soft delete)
- Backend: 2 health endpoints (status + metrics incl. token rate and cost)
- Token usage tracking via new ChatTokenUsage model
- ADMIN_EMAILS env var for admin allowlist
- canViewAdmin flag in /api/auth/me
- Disabled-user auth block on login, guest, refresh
- Soft delete for chats via deletedAt column
- Rate limit exemption for /api/admin/* paths
- Dev-only auto table creation for ChatTokenUsage

### Fixed

- Health endpoint now returns string db/mcp state and uptimeSeconds
- Moderation search strips null bytes; reversed date range returns 400
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<a href="#getting-started">Getting Started</a> ·
<a href="#configuration">Configuration</a> ·
<a href="#authentication">Authentication</a> ·
<a href="#admin-dashboard">Admin Dashboard</a> ·
<a href="#deployment">Deployment</a> ·
<a href="DEVELOPER.md">Developer Guide</a> ·
<a href="https://worldbank.github.io/data-ai-chatbot">Documentation</a>
Expand Down Expand Up @@ -307,6 +308,7 @@ pnpm dev # Start dev server (http://localhost:3001)
| `CORS_ORIGINS` | No | Comma-separated allowed origins |
| `ENVIRONMENT` | No | `development` / `production` |
| `AUTH_PROVIDER` | No | `guest` \| `user` \| `msal` (see [Authentication](#authentication)) |
| `ADMIN_EMAILS` | No | Comma-separated email allowlist for the admin dashboard (empty = no admin access) |
| `RATE_LIMIT_ENABLED` | No | Enable per-user/IP rate limiting |
| `LOG_FILE` | No | Log output file path |

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

---

## Admin Dashboard

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.

### Enabling admin access

Set `ADMIN_EMAILS` on the backend with a comma-separated list of allowed email addresses:

```bash
# backend/.env
ADMIN_EMAILS=admin@org.com,ops@org.com
```

When `ADMIN_EMAILS` is empty, no one has admin access — every `/api/admin/*` request returns 403 and `canViewAdmin` is `false`.

### Accessing the dashboard

1. Log in with an account whose email is listed in `ADMIN_EMAILS`.
2. Visit `/admin`.

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:

| Page | URL | What it shows |
| --------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| Analytics | `/admin/analytics` | Stat cards and charts for users, chats, feedback, and token usage; date-range filter (Last 7d / 30d / 90d) |
| Moderation | `/admin/moderation` | User management (search, disable/enable) and chat browser (search by title, soft delete), with pagination |
| Health | `/admin/health` | API / database / MCP status cards and operational metrics; auto-refreshes every 30 seconds |

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).

Full endpoint reference: [`docs/admin-api.md`](docs/admin-api.md).
Feature contract: [`REQUIREMENTS-admin-dashboard.md`](REQUIREMENTS-admin-dashboard.md).

---

## Deployment

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

---

Expand Down
34 changes: 34 additions & 0 deletions backend/alembic/versions/9bab245e8167_add_user_disabled_column.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""add_user_disabled_column

Revision ID: 9bab245e8167
Revises: k2m3n4o5p6q7
Create Date: 2026-08-01 19:57:15.693715

"""

from typing import Sequence, Union

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "9bab245e8167"
down_revision: Union[str, None] = "k2m3n4o5p6q7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"User",
sa.Column("disabled", sa.Boolean(), nullable=False, server_default=sa.false()),
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("User", "disabled")
# ### end Alembic commands ###
30 changes: 30 additions & 0 deletions backend/alembic/versions/l3m4n5o6p7q8_add_deleted_at_to_chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""add_deleted_at_to_chat

Revision ID: l3m4n5o6p7q8
Revises: 9bab245e8167
Create Date: 2026-08-02 12:00:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "l3m4n5o6p7q8" # pragma: allowlist secret
down_revision: Union[str, None] = "9bab245e8167" # pragma: allowlist secret
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"Chat",
sa.Column("deletedAt", sa.DateTime(), nullable=True),
)


def downgrade() -> None:
op.drop_column("Chat", "deletedAt")
39 changes: 39 additions & 0 deletions backend/alembic/versions/m4n5o6p7q8r9_add_chattokenusage_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""add_chattokenusage_table

Revision ID: m4n5o6p7q8r9
Revises: l3m4n5o6p7q8
Create Date: 2026-08-02 14:00:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

from alembic import op
from app.db.migration_utils import grant_table_to_app_user

revision: str = "m4n5o6p7q8r9" # pragma: allowlist secret
down_revision: Union[str, None] = "l3m4n5o6p7q8" # pragma: allowlist secret
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"ChatTokenUsage",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("chatId", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("messageId", sa.String(length=64), nullable=False),
sa.Column("totalTokens", sa.Integer(), nullable=False, server_default="0"),
sa.Column("costUSD", sa.Float(), nullable=False, server_default="0.0"),
sa.Column("createdAt", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_chattokenusage_createdat", "ChatTokenUsage", ["createdAt"])
grant_table_to_app_user(op, "ChatTokenUsage")


def downgrade() -> None:
op.drop_table("ChatTokenUsage")
30 changes: 30 additions & 0 deletions backend/alembic/versions/n5o6p7q8r9s0_add_deleted_at_to_vote_v2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""add_deleted_at_to_vote_v2

Revision ID: n5o6p7q8r9s0
Revises: m4n5o6p7q8r9
Create Date: 2026-08-04 12:00:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "n5o6p7q8r9s0" # pragma: allowlist secret
down_revision: Union[str, None] = "m4n5o6p7q8r9" # pragma: allowlist secret
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"Vote_v2",
sa.Column("deletedAt", sa.DateTime(), nullable=True),
)


def downgrade() -> None:
op.drop_column("Vote_v2", "deletedAt")
Loading
Loading