feat(admin): add admin dashboard with user and session management APIs

This commit is contained in:
2026-06-12 21:55:37 +08:00
parent 58a41093c6
commit 0fde3e9490
7 changed files with 837 additions and 9 deletions
+16 -9
View File
@@ -12,15 +12,20 @@ agent_alpha/
│ ├── core/
│ │ ├── agent.py # AgentService (lazy init, capabilities)
│ │ ├── config.py # pydantic-settings from .env
│ │ ├── database.py# SQLAlchemy async engine + Valkey client
│ │ ── dependencies.py # FastAPI DI providers
│ │ ├── database.py# SQLAlchemy async engine + Valkey client + migrations
│ │ ── dependencies.py # FastAPI DI providers
│ │ └── models.py # SQLAlchemy ORM models (User)
│ └── routes/
│ ├── chat.py # POST /api/chat
── health.py # GET /api/health
│ ├── admin.py # GET /api/admin/* (admin dashboard — stats, users, sessions)
── auth.py # POST /api/auth/{login,register,logout}, GET /api/auth/me
│ ├── chat.py # POST /api/chat, GET /api/chat/{history,sessions}
│ ├── health.py # GET /api/health
│ └── users.py # CRUD /api/users
├── frontend/ # React + Vite SPA
│ ├── src/
│ │ ├── App.tsx # Chat UI component
│ │ ── api.ts # Fetch client
│ │ ├── Admin.tsx # Admin dashboard (overview, users, sessions tabs)
│ │ ── App.tsx # Auth + Chat UI + admin routing
│ │ └── api.ts # Fetch client (chat, auth, users, admin)
│ └── nginx.conf # Production SPA + API proxy
├── skills/ # pydantic-ai skill definitions
└── docker-compose.yml # Full stack orchestration
@@ -72,12 +77,12 @@ make compose-down # Stop everything
- **Functional components** only (hooks, no classes).
- **Interfaces** at file top, exported when reused.
- **Tailwind utility classes** inline; no CSS modules or styled-components.
- **API client** isolated in `api.ts`one `sendMessage()` export.
- **API client** isolated in `api.ts`exports for chat (`sendMessage`, `getHistory`), auth (`login`, `register`, `getMe`, `logout`), users (`listUsers`, `createUser`, `getUserSessions`), and admin (`getAdminStats`, `adminListUsers`, `adminUpdateUser`, `adminListSessions`, `adminDeleteSession`). Auth token managed via `localStorage` + `Authorization: Bearer` header helpers.
## Key Architecture Decisions
### Dependency Injection
`dependencies.py` provides `get_settings()` and `get_agent_service()` as FastAPI `Depends()` callables. `AgentService` is lazily initialized (not built at import time). `AppBuilder` accepts optional `Settings` for test overrides.
`dependencies.py` provides `get_settings()`, `get_agent_service()`, and `get_db_session()` as FastAPI `Depends()` callables. `AgentService` is lazily initialized (not built at import time). `AppBuilder` accepts optional `Settings` for test overrides.
### Capabilities Stack
Agent capabilities are assembled in `AgentService._build_capabilities()` — a list that includes code execution, web search, MCP, sub-agents, cost tracking, input/tool guards, secret redaction, and stuck-loop detection.
@@ -90,10 +95,12 @@ Environment variables loaded from `.env` via `pydantic-settings` (`backend/core/
## Common Pitfalls
1. **Container DNS caching**: nginx caches DNS at startup. Use `resolver` directive + variable in `proxy_pass` for runtime resolution (see `frontend/nginx.conf`).
1. **Container DNS caching**: nginx caches DNS at startup. Podman does not use Docker's `127.0.0.11` resolver, so the `resolver` + variable trick doesn't work. If the backend restarts, also restart the frontend container (`podman compose restart frontend`).
2. **Slow LLM responses**: The LLM may take minutes to respond. Nginx `proxy_read_timeout` is set to 600s. Backend timeouts should match.
3. **Logfire is optional**: `logfire.configure()` is wrapped in `try/except` — omit `LOGFIRE_TOKEN` to disable.
4. **API key for local LLMs**: Set `LLM_API_KEY=no-key-required` (or leave empty) for Ollama/local endpoints; the OpenAI client requires a non-None value.
5. **uv vs pip**: Always use `uv sync` / `uv add`, never `pip install`. The lock file is `uv.lock`.
6. **bun for frontend**: Use `bun install` / `bun run dev` for frontend, not `npm`.
7. **Container orchestration**: Backend depends on postgres + valkey being healthy. Use `depends_on` with `condition: service_healthy`.
8. **Schema migrations**: `init_db()` in `database.py` runs `Base.metadata.create_all` followed by `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for new columns. Existing tables are never rebuilt — new columns are added in-place.
9. **bcrypt on container restart**: `bcrypt` is a native dependency. If the container fails to start with an import error, ensure `bcrypt` is in `pyproject.toml` and `uv sync` was run during the Docker build.
+2
View File
@@ -12,6 +12,7 @@ from uvicorn.logging import DefaultFormatter
from backend.core.config import Settings
from backend.core.database import close_engine, close_valkey, init_db
from backend.core.dependencies import get_agent_service
from backend.routes.admin import router as admin_router
from backend.routes.auth import router as auth_router
from backend.routes.chat import router as chat_router
from backend.routes.health import router as health_router
@@ -64,6 +65,7 @@ class AppBuilder:
allow_headers=["*"],
)
app.include_router(admin_router)
app.include_router(auth_router)
app.include_router(health_router)
app.include_router(chat_router)
+58
View File
@@ -448,3 +448,61 @@ async def delete_session(
if user_sessions_key is not None:
pipe.srem(user_sessions_key, session_id)
await pipe.execute()
# ── Admin stats ────────────────────────────────────────────────────────────
async def get_admin_stats(
session: AsyncSession | None = None,
valkey: Redis | None = None,
) -> dict[str, object]:
"""Return high-level system statistics for the admin dashboard."""
if session is None:
async with _session_factory() as session:
return await _gather_stats(session, valkey)
return await _gather_stats(session, valkey)
async def _gather_stats(
session: AsyncSession,
valkey: Redis | None = None,
) -> dict[str, object]:
"""Internal: gather all stats in one place."""
from sqlalchemy import func as sa_func
# Total users
result = await session.execute(sa_func.count(User.id))
total_users: int = result.scalar() or 0
# Users by role
result = await session.execute(
select(User.role, sa_func.count(User.id)).group_by(User.role)
)
users_by_role: dict[str, int] = {
row[0]: row[1] for row in result
}
# Active vs inactive
result = await session.execute(
select(User.is_active, sa_func.count(User.id)).group_by(User.is_active)
)
users_by_active: dict[str, int] = {
"active": 0,
"inactive": 0,
}
for row in result:
key = "active" if row[0] else "inactive"
users_by_active[key] = row[1]
# Sessions from Valkey
if valkey is None:
valkey = await get_valkey()
total_sessions = await valkey.scard(_SESSIONS_SET)
return {
"total_users": total_users,
"users_by_role": users_by_role,
"users_by_active": users_by_active,
"total_sessions": total_sessions,
}
+200
View File
@@ -0,0 +1,200 @@
"""Admin dashboard API routes — system stats, user management, session overview.
All endpoints require ``Authorization: Bearer <token>`` with an ``admin`` role.
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from backend.core.database import (
delete_session,
get_admin_stats,
get_session_messages,
get_session_user_id,
get_user,
get_user_by_username,
list_sessions,
list_user_sessions,
list_users,
resolve_auth_token,
update_user,
)
from backend.core.dependencies import get_db_session
router = APIRouter(prefix="/api/admin", tags=["admin"])
# ── Admin auth dependency ──────────────────────────────────────────────────
async def require_admin(
authorization: str | None = Header(None),
session: AsyncSession = Depends(get_db_session),
) -> str:
"""Verify the request comes from an authenticated admin user.
Returns the ``user_id`` string on success, raises 401/403 otherwise.
"""
if authorization is None:
raise HTTPException(status_code=401, detail="Missing Authorization header")
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token:
raise HTTPException(status_code=401, detail="Invalid Authorization header")
user_id = await resolve_auth_token(token)
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid or expired token")
user = await get_user(uuid.UUID(user_id), session=session)
if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="User not found or inactive")
if user.role != "admin":
raise HTTPException(
status_code=403,
detail="Admin role required",
)
return user_id
# ── Schemas ────────────────────────────────────────────────────────────────
class AdminStatsOut(BaseModel):
total_users: int
users_by_role: dict[str, int]
users_by_active: dict[str, int]
total_sessions: int
class AdminUserOut(BaseModel):
id: str
username: str
display_name: str
role: str
team: str | None = None
is_active: bool
created_at: str
updated_at: str
session_count: int = 0
class AdminUserUpdate(BaseModel):
role: str | None = None
is_active: bool | None = None
display_name: str | None = None
team: str | None = None
class AdminSessionOut(BaseModel):
session_id: str
message_count: int
user_id: str | None = None
# ── Helpers ────────────────────────────────────────────────────────────────
def _user_to_admin_out(user: object, session_count: int = 0) -> AdminUserOut:
return AdminUserOut(
id=str(user.id),
username=user.username,
display_name=user.display_name,
role=user.role,
team=user.team,
is_active=user.is_active,
created_at=user.created_at.isoformat(),
updated_at=user.updated_at.isoformat(),
session_count=session_count,
)
# ── Routes ─────────────────────────────────────────────────────────────────
@router.get("/stats", response_model=AdminStatsOut)
async def admin_stats(
_admin_id: str = Depends(require_admin),
session: AsyncSession = Depends(get_db_session),
) -> AdminStatsOut:
"""Return system-wide statistics (users, sessions, etc.)."""
stats = await get_admin_stats(session=session)
return AdminStatsOut(**stats)
@router.get("/users", response_model=list[AdminUserOut])
async def admin_list_users(
_admin_id: str = Depends(require_admin),
session: AsyncSession = Depends(get_db_session),
) -> list[AdminUserOut]:
"""List **all** users (including inactive) with session counts."""
# Fetch all users, not just active ones.
from sqlalchemy import select as sa_select
from backend.core.models import User as UserModel
result = await session.execute(
sa_select(UserModel).order_by(UserModel.created_at.desc())
)
users = list(result.scalars().all())
out: list[AdminUserOut] = []
for u in users:
sids = await list_user_sessions(str(u.id))
out.append(_user_to_admin_out(u, session_count=len(sids)))
return out
@router.patch("/users/{user_id}", response_model=AdminUserOut)
async def admin_update_user(
user_id: uuid.UUID,
body: AdminUserUpdate,
_admin_id: str = Depends(require_admin),
session: AsyncSession = Depends(get_db_session),
) -> AdminUserOut:
"""Update any user's role, active status, display_name, or team."""
kwargs = body.model_dump(exclude_none=True)
if not kwargs:
raise HTTPException(status_code=400, detail="No fields to update")
user = await update_user(user_id, session=session, **kwargs)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
sids = await list_user_sessions(str(user.id))
return _user_to_admin_out(user, session_count=len(sids))
@router.get("/sessions", response_model=list[AdminSessionOut])
async def admin_list_sessions(
_admin_id: str = Depends(require_admin),
) -> list[AdminSessionOut]:
"""List all chat sessions with their message count and owning user."""
ids = await list_sessions()
result: list[AdminSessionOut] = []
for sid in ids:
msgs = await get_session_messages(sid)
uid = await get_session_user_id(sid)
result.append(
AdminSessionOut(
session_id=sid,
message_count=len(msgs),
user_id=uid,
)
)
return result
@router.delete("/sessions/{session_id}", status_code=204)
async def admin_delete_session(
session_id: str,
_admin_id: str = Depends(require_admin),
) -> None:
"""Delete a chat session and all its messages."""
await delete_session(session_id)
+450
View File
@@ -0,0 +1,450 @@
import { useState, useEffect } from "react";
import {
getAdminStats,
adminListUsers,
adminUpdateUser,
adminListSessions,
adminDeleteSession,
type AdminStats,
type AdminUserData,
type AdminSessionData,
} from "./api";
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
interface AdminProps {
onBack: () => void;
}
const ROLE_COLORS: Record<string, string> = {
admin: "text-purple-400",
user: "text-indigo-400",
viewer: "text-gray-400",
};
/* ------------------------------------------------------------------ */
/* Admin Dashboard */
/* ------------------------------------------------------------------ */
export default function AdminDashboard({ onBack }: AdminProps) {
const [tab, setTab] = useState<"overview" | "users" | "sessions">("overview");
const [stats, setStats] = useState<AdminStats | null>(null);
const [users, setUsers] = useState<AdminUserData[]>([]);
const [sessions, setSessions] = useState<AdminSessionData[]>([]);
const [busy, setBusy] = useState(true);
const [error, setError] = useState("");
/* ── Load data ────────────────────────────────────────────────────── */
const loadAll = async () => {
setBusy(true);
setError("");
try {
const [s, u, ses] = await Promise.all([
getAdminStats(),
adminListUsers(),
adminListSessions(),
]);
setStats(s);
setUsers(u);
setSessions(ses);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load data");
} finally {
setBusy(false);
}
};
useEffect(() => {
loadAll();
}, []);
/* ── User role toggle ─────────────────────────────────────────────── */
const handleRoleChange = async (userId: string, newRole: string) => {
try {
await adminUpdateUser(userId, { role: newRole });
await loadAll();
} catch (err: unknown) {
alert(err instanceof Error ? err.message : "Update failed");
}
};
const handleToggleActive = async (userId: string, current: boolean) => {
try {
await adminUpdateUser(userId, { is_active: !current });
await loadAll();
} catch (err: unknown) {
alert(err instanceof Error ? err.message : "Update failed");
}
};
const handleDeleteSession = async (sessionId: string) => {
if (!confirm("Delete this session and all its messages?")) return;
try {
await adminDeleteSession(sessionId);
await loadAll();
} catch (err: unknown) {
alert(err instanceof Error ? err.message : "Delete failed");
}
};
/* ── Render ───────────────────────────────────────────────────────── */
return (
<div className="mx-auto flex h-dvh max-w-6xl flex-col">
{/* Header */}
<header className="flex items-center gap-3 border-b border-gray-800 px-6 py-4">
<button
onClick={onBack}
className="rounded-lg border border-gray-700 p-2 text-gray-400 transition hover:border-indigo-500 hover:text-indigo-400"
title="Back to chat"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-purple-600 to-pink-500 text-sm font-bold text-white shadow-lg shadow-purple-500/25">
A
</div>
<div className="flex-1">
<h1 className="text-lg font-semibold leading-tight tracking-tight">
Admin Dashboard
</h1>
<p className="text-xs text-gray-500">System management</p>
</div>
<button
onClick={loadAll}
disabled={busy}
className="rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 transition hover:border-indigo-500 hover:text-indigo-400 disabled:opacity-40"
>
Refresh
</button>
</header>
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-800 px-6 pt-3">
{(["overview", "users", "sessions"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`rounded-t-lg px-4 py-2 text-sm font-medium capitalize transition ${
tab === t
? "border border-b-0 border-gray-700 bg-gray-900 text-gray-100"
: "text-gray-500 hover:text-gray-300"
}`}
>
{t}
{t === "users" && stats && (
<span className="ml-1.5 rounded-full bg-gray-800 px-1.5 py-0.5 text-xs">
{stats.total_users}
</span>
)}
{t === "sessions" && stats && (
<span className="ml-1.5 rounded-full bg-gray-800 px-1.5 py-0.5 text-xs">
{stats.total_sessions}
</span>
)}
</button>
))}
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-6">
{error && (
<div className="mb-4 rounded-lg bg-red-900/40 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
{busy && !stats ? (
<div className="flex items-center justify-center py-20">
<p className="text-sm text-gray-500">Loading</p>
</div>
) : tab === "overview" ? (
<OverviewTab stats={stats} />
) : tab === "users" ? (
<UsersTab
users={users}
onRoleChange={handleRoleChange}
onToggleActive={handleToggleActive}
/>
) : (
<SessionsTab sessions={sessions} onDelete={handleDeleteSession} />
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Overview Tab */
/* ------------------------------------------------------------------ */
function OverviewTab({ stats }: { stats: AdminStats | null }) {
if (!stats) return null;
const cards = [
{
label: "Total Users",
value: stats.total_users,
color: "from-indigo-500 to-purple-600",
},
{
label: "Active Users",
value: stats.users_by_active?.active ?? 0,
color: "from-emerald-500 to-teal-600",
},
{
label: "Chat Sessions",
value: stats.total_sessions,
color: "from-amber-500 to-orange-600",
},
{
label: "Admins",
value: stats.users_by_role?.admin ?? 0,
color: "from-purple-500 to-pink-600",
},
];
return (
<div className="space-y-8">
{/* Stat cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{cards.map((card) => (
<div
key={card.label}
className="rounded-xl border border-gray-800 bg-gray-900/60 p-5"
>
<p className="text-sm text-gray-500">{card.label}</p>
<p className="mt-1 text-3xl font-bold text-gray-100">
{card.value}
</p>
<div
className={`mt-3 h-1 w-full rounded-full bg-gradient-to-r ${card.color}`}
/>
</div>
))}
</div>
{/* Breakdowns */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Users by role */}
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-5">
<h3 className="mb-3 text-sm font-semibold text-gray-300">
Users by Role
</h3>
<div className="space-y-2">
{Object.entries(stats.users_by_role).map(([role, count]) => (
<div key={role} className="flex items-center gap-3">
<span className={`w-20 text-sm capitalize ${ROLE_COLORS[role] ?? "text-gray-400"}`}>
{role}
</span>
<div className="flex-1">
<div className="h-2 rounded-full bg-gray-800">
<div
className="h-2 rounded-full bg-gradient-to-r from-indigo-500 to-purple-600"
style={{
width: `${stats.total_users > 0 ? (count / stats.total_users) * 100 : 0}%`,
}}
/>
</div>
</div>
<span className="w-8 text-right text-sm text-gray-400">
{count}
</span>
</div>
))}
</div>
</div>
{/* Active vs inactive */}
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-5">
<h3 className="mb-3 text-sm font-semibold text-gray-300">
User Status
</h3>
<div className="space-y-2">
{Object.entries(stats.users_by_active).map(([status, count]) => (
<div key={status} className="flex items-center gap-3">
<span className="w-20 text-sm capitalize text-gray-400">
{status}
</span>
<div className="flex-1">
<div className="h-2 rounded-full bg-gray-800">
<div
className={`h-2 rounded-full ${
status === "active"
? "bg-gradient-to-r from-emerald-500 to-teal-500"
: "bg-gradient-to-r from-red-500 to-rose-500"
}`}
style={{
width: `${stats.total_users > 0 ? (count / stats.total_users) * 100 : 0}%`,
}}
/>
</div>
</div>
<span className="w-8 text-right text-sm text-gray-400">
{count}
</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Users Tab */
/* ------------------------------------------------------------------ */
function UsersTab({
users,
onRoleChange,
onToggleActive,
}: {
users: AdminUserData[];
onRoleChange: (id: string, role: string) => void;
onToggleActive: (id: string, current: boolean) => void;
}) {
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800 text-xs uppercase tracking-wider text-gray-500">
<th className="pb-3 pr-4">User</th>
<th className="pb-3 pr-4">Role</th>
<th className="pb-3 pr-4">Team</th>
<th className="pb-3 pr-4">Status</th>
<th className="pb-3 pr-4">Sessions</th>
<th className="pb-3 pr-4">Created</th>
<th className="pb-3" />
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr
key={u.id}
className="border-b border-gray-800/50 transition hover:bg-gray-800/30"
>
<td className="py-3 pr-4">
<div>
<p className="font-medium text-gray-200">{u.display_name}</p>
<p className="text-xs text-gray-500">@{u.username}</p>
</div>
</td>
<td className="py-3 pr-4">
<select
value={u.role}
onChange={(e) => onRoleChange(u.id, e.target.value)}
className="rounded-lg border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-gray-200 outline-none focus:border-indigo-500"
>
<option value="admin">admin</option>
<option value="user">user</option>
<option value="viewer">viewer</option>
</select>
</td>
<td className="py-3 pr-4 text-gray-400">{u.team ?? "—"}</td>
<td className="py-3 pr-4">
<button
onClick={() => onToggleActive(u.id, u.is_active)}
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
u.is_active
? "bg-emerald-900/50 text-emerald-400"
: "bg-red-900/50 text-red-400"
}`}
>
{u.is_active ? "Active" : "Inactive"}
</button>
</td>
<td className="py-3 pr-4 text-gray-400">{u.session_count}</td>
<td className="py-3 pr-4 text-xs text-gray-500">
{new Date(u.created_at).toLocaleDateString()}
</td>
<td className="py-3">
<span
className={`inline-block h-2 w-2 rounded-full ${
ROLE_COLORS[u.role]?.replace("text-", "bg-") ?? "bg-gray-500"
}`}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Sessions Tab */
/* ------------------------------------------------------------------ */
function SessionsTab({
sessions,
onDelete,
}: {
sessions: AdminSessionData[];
onDelete: (id: string) => void;
}) {
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800 text-xs uppercase tracking-wider text-gray-500">
<th className="pb-3 pr-4">Session ID</th>
<th className="pb-3 pr-4">Messages</th>
<th className="pb-3 pr-4">User ID</th>
<th className="pb-3" />
</tr>
</thead>
<tbody>
{sessions.length === 0 ? (
<tr>
<td colSpan={4} className="py-8 text-center text-gray-500">
No sessions yet.
</td>
</tr>
) : (
sessions.map((s) => (
<tr
key={s.session_id}
className="border-b border-gray-800/50 transition hover:bg-gray-800/30"
>
<td className="py-3 pr-4">
<code className="text-xs text-gray-300">
{s.session_id.slice(0, 16)}
</code>
</td>
<td className="py-3 pr-4 text-gray-400">{s.message_count}</td>
<td className="py-3 pr-4">
{s.user_id ? (
<code className="text-xs text-gray-500">
{s.user_id.slice(0, 12)}
</code>
) : (
<span className="text-gray-600"></span>
)}
</td>
<td className="py-3">
<button
onClick={() => onDelete(s.session_id)}
className="rounded-lg px-2 py-1 text-xs text-red-400 transition hover:bg-red-900/30"
title="Delete session"
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
}
+24
View File
@@ -13,6 +13,7 @@ import {
type AuthResponse,
type UserSessionData,
} from "./api";
import AdminDashboard from "./Admin";
/* ------------------------------------------------------------------ */
/* Types */
@@ -219,6 +220,9 @@ export default function App() {
);
const [authReady, setAuthReady] = useState(false);
/* ── Admin state ──────────────────────────────────────────────────── */
const [showAdmin, setShowAdmin] = useState(false);
/* ── Session sidebar state ───────────────────────────────────────── */
const [userSessions, setUserSessions] = useState<UserSessionData[]>([]);
const [showSessions, setShowSessions] = useState(false);
@@ -390,6 +394,12 @@ export default function App() {
return <AuthPage onAuth={handleAuth} />;
}
/* ── Admin UI ────────────────────────────────────────────────────── */
if (showAdmin && authenticated.role === "admin") {
return <AdminDashboard onBack={() => setShowAdmin(false)} />;
}
/* ── Chat UI ─────────────────────────────────────────────────────── */
return (
@@ -429,6 +439,20 @@ export default function App() {
Sessions
</button>
{/* Admin button — only for admin role */}
{authenticated.role === "admin" && (
<button
onClick={() => setShowAdmin(true)}
className="rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-purple-400 transition hover:border-purple-500 hover:text-purple-300"
title="Admin dashboard"
>
<svg className="mr-1 inline-block h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Admin
</button>
)}
{/* Logout button */}
<button
onClick={handleLogout}
+87
View File
@@ -217,3 +217,90 @@ export async function getUserSessions(
}
return res.json();
}
/* ── Admin API ─────────────────────────────────────────────────── */
export interface AdminStats {
total_users: number;
users_by_role: Record<string, number>;
users_by_active: Record<string, number>;
total_sessions: number;
}
export interface AdminUserData {
id: string;
username: string;
display_name: string;
role: string;
team: string | null;
is_active: boolean;
created_at: string;
updated_at: string;
session_count: number;
}
export interface AdminSessionData {
session_id: string;
message_count: number;
user_id: string | null;
}
export async function getAdminStats(): Promise<AdminStats> {
const res = await fetch(`${API_BASE}/admin/stats`, {
headers: { ...authHeaders() },
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.detail ?? `HTTP ${res.status}`);
}
return res.json();
}
export async function adminListUsers(): Promise<AdminUserData[]> {
const res = await fetch(`${API_BASE}/admin/users`, {
headers: { ...authHeaders() },
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.detail ?? `HTTP ${res.status}`);
}
return res.json();
}
export async function adminUpdateUser(
userId: string,
data: { role?: string; is_active?: boolean; display_name?: string; team?: string | null },
): Promise<AdminUserData> {
const res = await fetch(`${API_BASE}/admin/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.detail ?? `HTTP ${res.status}`);
}
return res.json();
}
export async function adminListSessions(): Promise<AdminSessionData[]> {
const res = await fetch(`${API_BASE}/admin/sessions`, {
headers: { ...authHeaders() },
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.detail ?? `HTTP ${res.status}`);
}
return res.json();
}
export async function adminDeleteSession(sessionId: string): Promise<void> {
const res = await fetch(`${API_BASE}/admin/sessions/${sessionId}`, {
method: "DELETE",
headers: { ...authHeaders() },
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.detail ?? `HTTP ${res.status}`);
}
}