mirror of
https://github.com/furyhawk/agent_alpha.git
synced 2026-07-21 02:25:34 +00:00
feat(admin): add admin dashboard with user and session management APIs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user