aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py273
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/deps.py13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js369
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js60
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css203
-rw-r--r--packages/meshbay-hub/tests/test_admin.py259
10 files changed, 1205 insertions, 17 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
new file mode 100644
index 0000000..c3fa8c2
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -0,0 +1,273 @@
+"""
+MeshBay Hub — admin/moderation panel endpoints.
+
+All endpoints require moderator or admin role.
+Separate from moderation.py (which handles public reporting and content blocklist).
+"""
+
+import logging
+from datetime import datetime, timezone
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from meshbay_hub.auth import decrypt_email
+from meshbay_hub.api.deps import require_moderator
+from meshbay_hub.api.revocation import get_connected_node_count
+from meshbay_hub.db.engine import get_db
+from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User
+
+log = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/v1/admin", tags=["admin"])
+
+
+# ── Request models ───────────────────────────────────────────────────────────
+
+class UserPatchRequest(BaseModel):
+ role: str | None = None
+ status: str | None = None
+
+
+class GroupPatchRequest(BaseModel):
+ status: str | None = None
+
+
+# ── Stats ────────────────────────────────────────────────────────────────────
+
+@router.get("/stats")
+async def admin_stats(
+ current_user: User = Depends(require_moderator),
+ db: AsyncSession = Depends(get_db),
+):
+ user_count = (await db.execute(select(func.count()).select_from(User))).scalar_one()
+ group_count = (await db.execute(select(func.count()).select_from(Group))).scalar_one()
+ node_count = (await db.execute(select(func.count()).select_from(Node))).scalar_one()
+ return {
+ "users": user_count,
+ "groups": group_count,
+ "nodes": node_count,
+ "online_nodes": get_connected_node_count(),
+ }
+
+
+# ── Users ────────────────────────────────────────────────────────────────────
+
+@router.get("/users")
+async def admin_list_users(
+ current_user: User = Depends(require_moderator),
+ db: AsyncSession = Depends(get_db),
+ q: str = "",
+ offset: int = 0,
+ limit: int = Query(default=50, le=200),
+):
+ query = select(User).order_by(User.created_at.desc())
+ if q:
+ query = query.where(User.username.ilike(f"%{q}%"))
+ query = query.offset(offset).limit(limit)
+ result = await db.execute(query)
+ users = result.scalars().all()
+
+ total_query = select(func.count()).select_from(User)
+ if q:
+ total_query = total_query.where(User.username.ilike(f"%{q}%"))
+ total = (await db.execute(total_query)).scalar_one()
+
+ return {
+ "users": [
+ {
+ "id": u.id,
+ "username": u.username,
+ "role": u.role,
+ "status": u.status,
+ "created_at": u.created_at.isoformat(),
+ }
+ for u in users
+ ],
+ "total": total,
+ }
+
+
+@router.get("/users/{user_id}")
+async def admin_get_user(
+ user_id: str,
+ current_user: User = Depends(require_moderator),
+ db: AsyncSession = Depends(get_db),
+):
+ user = await db.get(User, user_id)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ group_count = (await db.execute(
+ select(func.count()).where(GroupMember.user_id == user_id)
+ )).scalar_one()
+
+ try:
+ email = decrypt_email(user.email)
+ except Exception:
+ email = "(encrypted)"
+
+ return {
+ "id": user.id,
+ "username": user.username,
+ "email": email,
+ "role": user.role,
+ "status": user.status,
+ "created_at": user.created_at.isoformat(),
+ "group_count": group_count,
+ }
+
+
+@router.patch("/users/{user_id}")
+async def admin_patch_user(
+ user_id: str,
+ body: UserPatchRequest,
+ current_user: User = Depends(require_moderator),
+ db: AsyncSession = Depends(get_db),
+):
+ user = await db.get(User, user_id)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ if user.id == current_user.id:
+ raise HTTPException(status_code=400, detail="Cannot modify your own account")
+
+ if body.role is not None:
+ if body.role not in ("user", "moderator", "admin"):
+ raise HTTPException(status_code=422, detail="role must be user, moderator, or admin")
+ user.role = body.role
+ log.info("User %s role changed to %s by %s", user.username, body.role, current_user.username)
+
+ if body.status is not None:
+ if body.status not in ("active", "suspended", "revoked"):
+ raise HTTPException(status_code=422, detail="status must be active, suspended, or revoked")
+ user.status = body.status
+ log.info("User %s status changed to %s by %s",
+ user.username, body.status, current_user.username)
+
+ db.add(IPLog(
+ user_id=current_user.id,
+ event="admin_user_update",
+ ip_address="admin",
+ detail=f"{user.username}: role={user.role} status={user.status}",
+ ))
+ await db.commit()
+
+ return {
+ "id": user.id,
+ "username": user.username,
+ "role": user.role,
+ "status": user.status,
+ }
+
+
+# ── Groups ───────────────────────────────────────────────────────────────────
+
+@router.get("/groups")
+async def admin_list_groups(
+ current_user: User = Depends(require_moderator),
+ db: AsyncSession = Depends(get_db),
+ offset: int = 0,
+ limit: int = Query(default=50, le=200),
+):
+ query = (
+ select(
+ Group,
+ func.count(GroupMember.user_id).label("member_count"),
+ )
+ .outerjoin(GroupMember, Group.id == GroupMember.group_id)
+ .group_by(Group.id)
+ .order_by(Group.created_at.desc())
+ .offset(offset)
+ .limit(limit)
+ )
+ result = await db.execute(query)
+ rows = result.all()
+
+ total = (await db.execute(select(func.count()).select_from(Group))).scalar_one()
+
+ return {
+ "groups": [
+ {
+ "id": g.id,
+ "name": g.name,
+ "admin_id": g.admin_id,
+ "visibility": g.visibility,
+ "status": g.status,
+ "created_at": g.created_at.isoformat(),
+ "member_count": mc,
+ }
+ for g, mc in rows
+ ],
+ "total": total,
+ }
+
+
+@router.patch("/groups/{group_id}")
+async def admin_patch_group(
+ group_id: str,
+ body: GroupPatchRequest,
+ current_user: User = Depends(require_moderator),
+ db: AsyncSession = Depends(get_db),
+):
+ group = await db.get(Group, group_id)
+ if not group:
+ raise HTTPException(status_code=404, detail="Group not found")
+
+ if body.status is not None:
+ if body.status not in ("active", "suspended", "revoked"):
+ raise HTTPException(status_code=422, detail="status must be active, suspended, or revoked")
+ group.status = body.status
+ log.info("Group %s status changed to %s by %s",
+ group.name, body.status, current_user.username)
+
+ db.add(IPLog(
+ user_id=current_user.id,
+ event="admin_group_update",
+ ip_address="admin",
+ detail=f"{group.name}: status={group.status}",
+ ))
+ await db.commit()
+
+ return {
+ "id": group.id,
+ "name": group.name,
+ "status": group.status,
+ }
+
+
+# ── IP Audit Logs ────────────────────────────────────────────────────────────
+
+@router.get("/logs")
+async def admin_list_logs(
+ current_user: User = Depends(require_moderator),
+ db: AsyncSession = Depends(get_db),
+ user_id: str | None = None,
+ event: str | None = None,
+ offset: int = 0,
+ limit: int = Query(default=50, le=200),
+):
+ query = select(IPLog).order_by(IPLog.timestamp.desc())
+ if user_id:
+ query = query.where(IPLog.user_id == user_id)
+ if event:
+ query = query.where(IPLog.event == event)
+ query = query.offset(offset).limit(limit)
+ result = await db.execute(query)
+ logs = result.scalars().all()
+
+ return {
+ "logs": [
+ {
+ "id": lg.id,
+ "user_id": lg.user_id,
+ "event": lg.event,
+ "ip_address": lg.ip_address,
+ "detail": lg.detail,
+ "timestamp": lg.timestamp.isoformat(),
+ }
+ for lg in logs
+ ],
+ }
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
index 7a580ad..addba30 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
@@ -52,10 +52,21 @@ async def get_current_user(
return user
+async def require_moderator(
+ current_user: User = Depends(get_current_user),
+) -> User:
+ if current_user.role not in ("moderator", "admin") \
+ and current_user.username not in _admin_usernames:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
+ detail="Moderator access required")
+ return current_user
+
+
async def require_admin(
current_user: User = Depends(get_current_user),
) -> User:
- if current_user.username not in _admin_usernames:
+ if current_user.role != "admin" \
+ and current_user.username not in _admin_usernames:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required")
return current_user
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index f91b381..2c2eede 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -236,6 +236,18 @@ async def token_refresh(
}
+@router.get("/me")
+async def get_current_user_info(
+ current_user: User = Depends(get_current_user),
+):
+ return {
+ "user_id": current_user.id,
+ "username": current_user.username,
+ "role": current_user.role,
+ "status": current_user.status,
+ }
+
+
@router.get("/{username}/pubkeys")
async def get_user_pubkeys(
username: str,
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 62917f4..5ad7329 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -21,6 +21,16 @@ STATIC_DIR = Path(__file__).parent.parent / "static"
router = APIRouter(tags=["webapp"])
+@router.get("/app", response_class=HTMLResponse)
+async def app_root():
+ return HTMLResponse(_HTML)
+
+
+@router.get("/app/{path:path}", response_class=HTMLResponse)
+async def app_catchall(path: str):
+ return HTMLResponse(_HTML)
+
+
@router.get("/", response_class=HTMLResponse)
async def index():
return HTMLResponse(_HTML)
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 1b12a37..d7f95cf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -33,10 +33,28 @@ from meshbay_hub.csam import csam_router
from meshbay_hub.api.health import router as health_router
from meshbay_hub.api.relay import router as relay_router
from meshbay_hub.api.signaling import router as signaling_router
+from meshbay_hub.api.admin import router as admin_router
from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR
from meshbay_hub.api.middleware import limiter
+async def _sync_admin_roles(admin_usernames: list[str]) -> None:
+ """Ensure config-listed admin usernames have role='admin' in the DB."""
+ from sqlalchemy import select, update
+ from meshbay_hub.db.engine import get_session_factory
+ from meshbay_hub.db.models import User
+
+ factory = get_session_factory()
+ async with factory() as session:
+ result = await session.execute(
+ select(User).where(User.username.in_(admin_usernames))
+ )
+ for user in result.scalars().all():
+ if user.role != "admin":
+ user.role = "admin"
+ await session.commit()
+
+
def create_app(cfg: HubConfig | None = None) -> FastAPI:
from meshbay_hub.config import load_config
if cfg is None:
@@ -54,6 +72,9 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
users_set_config(cfg)
set_admin_usernames(cfg.identity.admin_usernames)
+ if cfg.identity.admin_usernames:
+ await _sync_admin_roles(cfg.identity.admin_usernames)
+
from meshbay_hub.csam import get_csam_checker
get_csam_checker().load()
@@ -95,6 +116,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
app.include_router(health_router)
app.include_router(relay_router)
app.include_router(signaling_router)
+ app.include_router(admin_router)
app.include_router(webapp_router)
from starlette.staticfiles import StaticFiles
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index c0b0491..d06bcda 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -47,6 +47,7 @@ class User(Base):
pk_x25519: Mapped[str] = mapped_column(String(64), nullable=False) # base64 raw 32B
hub_id: Mapped[str] = mapped_column(String(128), nullable=False)
keypair_bundle: Mapped[str | None] = mapped_column(Text) # AES-GCM encrypted, web clients only
+ role: Mapped[str] = mapped_column(String(16), default="user") # user|moderator|admin
status: Mapped[str] = mapped_column(String(16), default="active") # active|suspended|revoked
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 2179e99..d36a0a1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -113,7 +113,8 @@ function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle }) {
// ── Sidebar ──────────────────────────────────────────────────────────────────
-function Sidebar({ groups, route, menuOpen }) {
+function Sidebar({ groups, route, menuOpen, role }) {
+ const isStaff = role === 'moderator' || role === 'admin';
return html`
<aside class="sidebar ${menuOpen ? 'open' : ''}">
<div class="sidebar-section">
@@ -135,6 +136,10 @@ function Sidebar({ groups, route, menuOpen }) {
href="#/explore">${t('sidebar.public_groups')}</a>
<a class="sidebar-item ${route === '/settings' ? 'active' : ''}"
href="#/settings">${t('sidebar.settings')}</a>
+ ${isStaff && html`
+ <a class="sidebar-item ${route === '/admin' ? 'active' : ''}"
+ href="#/admin">${t('sidebar.admin')}</a>
+ `}
</div>
</aside>
`;
@@ -994,6 +999,338 @@ function SettingsPage({ user, theme, onThemeChange }) {
`;
}
+// ── Admin Panel ─────────────────────────────────────────────────────────────
+
+function AdminPage({ token }) {
+ const [tab, setTab] = useState('stats');
+ const [stats, setStats] = useState(null);
+ const [users, setUsers] = useState([]);
+ const [usersTotal, setUsersTotal] = useState(0);
+ const [userSearch, setUserSearch] = useState('');
+ const [groups, setGroups] = useState([]);
+ const [groupsTotal, setGroupsTotal] = useState(0);
+ const [logs, setLogs] = useState([]);
+ const [logEvent, setLogEvent] = useState('');
+ const [logOffset, setLogOffset] = useState(0);
+ const [blocklist, setBlocklist] = useState([]);
+ const [detailUser, setDetailUser] = useState(null);
+ const [error, setError] = useState('');
+
+ const headers = { Authorization: `Bearer ${token}` };
+
+ const loadStats = useCallback(async () => {
+ try {
+ const data = await hubFetch('/v1/admin/stats', { token });
+ setStats(data);
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const loadUsers = useCallback(async (q = '') => {
+ try {
+ const data = await hubFetch(`/v1/admin/users?q=${encodeURIComponent(q)}&limit=100`, { token });
+ setUsers(data.users);
+ setUsersTotal(data.total);
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const loadGroups = useCallback(async () => {
+ try {
+ const data = await hubFetch('/v1/admin/groups?limit=100', { token });
+ setGroups(data.groups);
+ setGroupsTotal(data.total);
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const loadLogs = useCallback(async (event = '', offset = 0, append = false) => {
+ try {
+ let url = `/v1/admin/logs?limit=50&offset=${offset}`;
+ if (event) url += `&event=${encodeURIComponent(event)}`;
+ const data = await hubFetch(url, { token });
+ setLogs(prev => append ? [...prev, ...data.logs] : data.logs);
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const loadBlocklist = useCallback(async () => {
+ try {
+ const data = await hubFetch('/v1/admin/blocklist', { token });
+ setBlocklist(data.entries);
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ useEffect(() => {
+ setError('');
+ if (tab === 'stats') loadStats();
+ else if (tab === 'users') loadUsers(userSearch);
+ else if (tab === 'groups') loadGroups();
+ else if (tab === 'logs') { setLogOffset(0); loadLogs(logEvent, 0); }
+ else if (tab === 'blocklist') loadBlocklist();
+ }, [tab]);
+
+ const patchUser = useCallback(async (userId, patch) => {
+ try {
+ await hubFetch(`/v1/admin/users/${userId}`, { method: 'PATCH', body: patch, token });
+ loadUsers(userSearch);
+ if (detailUser && detailUser.id === userId) setDetailUser(null);
+ } catch (e) { setError(e.message); }
+ }, [token, userSearch, detailUser]);
+
+ const patchGroup = useCallback(async (groupId, patch) => {
+ try {
+ await hubFetch(`/v1/admin/groups/${groupId}`, { method: 'PATCH', body: patch, token });
+ loadGroups();
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const showUserDetail = useCallback(async (userId) => {
+ try {
+ const data = await hubFetch(`/v1/admin/users/${userId}`, { token });
+ setDetailUser(data);
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const addToBlocklist = useCallback(async (hash, reason) => {
+ try {
+ await hubFetch('/v1/admin/blocklist', { method: 'POST', body: { content_hash: hash, reason }, token });
+ loadBlocklist();
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const removeFromBlocklist = useCallback(async (hash) => {
+ try {
+ await hubFetch(`/v1/admin/blocklist/${hash}`, { method: 'DELETE', token });
+ loadBlocklist();
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ const TABS = ['stats', 'users', 'groups', 'logs', 'blocklist'];
+
+ return html`
+ <div>
+ <h2>${t('admin.title')}</h2>
+ ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
+
+ <div class="admin-tabs">
+ ${TABS.map(k => html`
+ <button key=${k} class="admin-tab ${tab === k ? 'active' : ''}"
+ onClick=${() => setTab(k)}>${t('admin.tab_' + k)}</button>
+ `)}
+ </div>
+
+ ${tab === 'stats' && stats && html`
+ <div class="admin-stats">
+ ${[['users', 'stat_users'], ['groups', 'stat_groups'],
+ ['nodes', 'stat_nodes'], ['online_nodes', 'stat_online']].map(([k, label]) => html`
+ <div class="stat-card" key=${k}>
+ <div class="stat-value">${stats[k]}</div>
+ <div class="stat-label">${t('admin.' + label)}</div>
+ </div>
+ `)}
+ </div>
+ `}
+
+ ${tab === 'users' && html`
+ <div class="admin-toolbar">
+ <input class="admin-search" type="text" placeholder="${t('admin.users_search')}"
+ value=${userSearch} onInput=${e => { setUserSearch(e.target.value); loadUsers(e.target.value); }} />
+ <span class="settings-value">${usersTotal} total</span>
+ </div>
+ <table class="admin-table">
+ <thead><tr>
+ <th>${t('admin.col_username')}</th>
+ <th>${t('admin.col_role')}</th>
+ <th>${t('admin.col_status')}</th>
+ <th>${t('admin.col_created')}</th>
+ <th>${t('admin.col_actions')}</th>
+ </tr></thead>
+ <tbody>
+ ${users.length === 0 && html`<tr><td colspan="5" class="admin-empty">${t('admin.no_users')}</td></tr>`}
+ ${users.map(u => html`
+ <tr key=${u.id}>
+ <td>${u.username}</td>
+ <td>
+ <select class="admin-role-select" value=${u.role}
+ onChange=${e => patchUser(u.id, { role: e.target.value })}>
+ <option value="user">user</option>
+ <option value="moderator">moderator</option>
+ <option value="admin">admin</option>
+ </select>
+ </td>
+ <td><span class="badge">${u.status}</span></td>
+ <td>${new Date(u.created_at).toLocaleDateString()}</td>
+ <td class="admin-actions">
+ <button class="admin-btn" onClick=${() => showUserDetail(u.id)}>${t('admin.btn_details')}</button>
+ ${u.status === 'active'
+ ? html`<button class="admin-btn danger" onClick=${() => patchUser(u.id, { status: 'suspended' })}>${t('admin.btn_suspend')}</button>`
+ : u.status === 'suspended'
+ ? html`<button class="admin-btn" onClick=${() => patchUser(u.id, { status: 'active' })}>${t('admin.btn_unsuspend')}</button>`
+ : null
+ }
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
+ ${tab === 'groups' && html`
+ <div class="admin-toolbar">
+ <span class="settings-value">${groupsTotal} total</span>
+ </div>
+ <table class="admin-table">
+ <thead><tr>
+ <th>${t('admin.col_name')}</th>
+ <th>${t('admin.col_visibility')}</th>
+ <th>${t('admin.col_members')}</th>
+ <th>${t('admin.col_status')}</th>
+ <th>${t('admin.col_created')}</th>
+ <th>${t('admin.col_actions')}</th>
+ </tr></thead>
+ <tbody>
+ ${groups.length === 0 && html`<tr><td colspan="6" class="admin-empty">${t('admin.no_groups')}</td></tr>`}
+ ${groups.map(g => html`
+ <tr key=${g.id}>
+ <td>${g.name}</td>
+ <td><span class="badge">${g.visibility}</span></td>
+ <td>${g.member_count}</td>
+ <td><span class="badge">${g.status}</span></td>
+ <td>${new Date(g.created_at).toLocaleDateString()}</td>
+ <td class="admin-actions">
+ ${g.status === 'active'
+ ? html`<button class="admin-btn danger" onClick=${() => patchGroup(g.id, { status: 'suspended' })}>${t('admin.btn_suspend')}</button>`
+ : g.status === 'suspended'
+ ? html`<button class="admin-btn" onClick=${() => patchGroup(g.id, { status: 'active' })}>${t('admin.btn_unsuspend')}</button>`
+ : null
+ }
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
+ ${tab === 'logs' && html`
+ <div class="admin-toolbar">
+ <select class="admin-select" value=${logEvent} onChange=${e => {
+ setLogEvent(e.target.value);
+ setLogOffset(0);
+ loadLogs(e.target.value, 0);
+ }}>
+ <option value="">${t('admin.filter_all')}</option>
+ ${['login', 'login_fail', 'account_create', 'token_refresh', 'group_create',
+ 'group_join', 'group_leave', 'node_announce', 'revoke_user', 'revoke_group',
+ 'admin_user_update', 'admin_group_update'].map(ev => html`
+ <option key=${ev} value=${ev}>${ev}</option>
+ `)}
+ </select>
+ </div>
+ <table class="admin-table">
+ <thead><tr>
+ <th>${t('admin.col_time')}</th>
+ <th>${t('admin.col_event')}</th>
+ <th>${t('admin.col_ip')}</th>
+ <th>${t('admin.col_detail')}</th>
+ </tr></thead>
+ <tbody>
+ ${logs.length === 0 && html`<tr><td colspan="4" class="admin-empty">${t('admin.no_logs')}</td></tr>`}
+ ${logs.map(lg => html`
+ <tr key=${lg.id}>
+ <td style="white-space:nowrap">${new Date(lg.timestamp).toLocaleString()}</td>
+ <td><span class="badge">${lg.event}</span></td>
+ <td>${lg.ip_address}</td>
+ <td>${lg.detail || ''}</td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ ${logs.length > 0 && logs.length % 50 === 0 && html`
+ <button class="admin-btn admin-load-more" onClick=${() => {
+ const next = logOffset + 50;
+ setLogOffset(next);
+ loadLogs(logEvent, next, true);
+ }}>${t('admin.btn_load_more')}</button>
+ `}
+ `}
+
+ ${tab === 'blocklist' && html`
+ <${BlocklistForm} onAdd=${addToBlocklist} />
+ <table class="admin-table">
+ <thead><tr>
+ <th>${t('admin.col_hash')}</th>
+ <th>${t('admin.col_reason')}</th>
+ <th>${t('admin.col_date')}</th>
+ <th>${t('admin.col_added_by')}</th>
+ <th>${t('admin.col_actions')}</th>
+ </tr></thead>
+ <tbody>
+ ${blocklist.length === 0 && html`<tr><td colspan="5" class="admin-empty">${t('admin.no_blocked')}</td></tr>`}
+ ${blocklist.map(b => html`
+ <tr key=${b.hash}>
+ <td style="font-family:monospace;font-size:0.8em">${b.hash.slice(0, 16)}...</td>
+ <td>${b.reason}</td>
+ <td>${new Date(b.added_at).toLocaleDateString()}</td>
+ <td>${b.added_by || ''}</td>
+ <td>
+ <button class="admin-btn" onClick=${() => removeFromBlocklist(b.hash)}>${t('admin.btn_unblock')}</button>
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
+ ${detailUser && html`
+ <div class="admin-detail-overlay" onClick=${e => {
+ if (e.target.classList.contains('admin-detail-overlay')) setDetailUser(null);
+ }}>
+ <div class="admin-detail-card">
+ <h3>${t('admin.user_detail')}</h3>
+ ${[
+ ['admin.col_username', detailUser.username],
+ ['admin.col_email', detailUser.email],
+ ['admin.col_role', detailUser.role],
+ ['admin.col_status', detailUser.status],
+ ['admin.col_created', new Date(detailUser.created_at).toLocaleString()],
+ ['admin.col_groups', detailUser.group_count],
+ ].map(([label, val]) => html`
+ <div class="admin-detail-row" key=${label}>
+ <span class="admin-detail-label">${t(label)}</span>
+ <span class="admin-detail-value">${val}</span>
+ </div>
+ `)}
+ <button class="admin-btn" style="margin-top:16px;width:100%"
+ onClick=${() => setDetailUser(null)}>${t('admin.btn_close')}</button>
+ </div>
+ </div>
+ `}
+ </div>
+ `;
+}
+
+function BlocklistForm({ onAdd }) {
+ const [hash, setHash] = useState('');
+ const [reason, setReason] = useState('');
+
+ const submit = (e) => {
+ e.preventDefault();
+ if (hash.length === 64 && reason) {
+ onAdd(hash, reason);
+ setHash('');
+ setReason('');
+ }
+ };
+
+ return html`
+ <form class="blocklist-form" onSubmit=${submit}>
+ <input type="text" placeholder="${t('admin.hash_placeholder')}"
+ value=${hash} onInput=${e => setHash(e.target.value)}
+ pattern="[0-9a-f]{64}" required />
+ <input type="text" placeholder="${t('admin.reason_placeholder')}"
+ value=${reason} onInput=${e => setReason(e.target.value)} required />
+ <button class="admin-btn" type="submit">${t('admin.btn_block')}</button>
+ </form>
+ `;
+}
+
// ── App ──────────────────────────────────────────────────────────────────────
function App() {
@@ -1026,29 +1363,24 @@ function App() {
const authCtx = {
user,
login: async (username, password) => {
+ let token, refreshToken;
if (window.MeshBayKeys) {
const data = await window.MeshBayKeys.loginAndRecover(username, password);
_sessionKeys = { skXB64: data.skXB64, skEdB64: data.skEdB64 };
- const u = {
- username,
- token: data.accessToken,
- refreshToken: data.refreshToken,
- };
- setUser(u);
- saveAuth(u);
+ token = data.accessToken;
+ refreshToken = data.refreshToken;
} else {
const data = await hubFetch('/v1/users/login', {
method: 'POST',
body: { username, password },
});
- const u = {
- username,
- token: data.access_token,
- refreshToken: data.refresh_token,
- };
- setUser(u);
- saveAuth(u);
+ token = data.access_token;
+ refreshToken = data.refresh_token;
}
+ const me = await hubFetch('/v1/users/me', { token });
+ const u = { username, token, refreshToken, role: me.role };
+ setUser(u);
+ saveAuth(u);
},
logout: () => {
setUser(null);
@@ -1073,6 +1405,10 @@ function App() {
page = html`<${GroupPage}
groupId=${groupId} group=${group} token=${user.token}
username=${user.username} />`;
+ } else if (route === '/admin') {
+ page = (user.role === 'moderator' || user.role === 'admin')
+ ? html`<${AdminPage} token=${user.token} />`
+ : html`<${HomePage} groups=${groups} />`;
} else if (route === '/settings') {
page = html`<${SettingsPage} user=${user} theme=${theme}
onThemeChange=${setTheme} />`;
@@ -1092,7 +1428,8 @@ function App() {
${user && html`<${Sidebar}
groups=${groups}
route=${route}
- menuOpen=${menuOpen} />`}
+ menuOpen=${menuOpen}
+ role=${user.role} />`}
${menuOpen && html`<div class="overlay visible"
onClick=${() => setMenuOpen(false)} />`}
<main class="main">
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index e0242cf..cb5133e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -117,6 +117,66 @@ const en = {
// Sidebar
'sidebar.settings': 'Settings',
+ 'sidebar.admin': 'Admin',
+
+ // Admin panel
+ 'admin.title': 'Administration',
+ 'admin.tab_stats': 'Stats',
+ 'admin.tab_users': 'Users',
+ 'admin.tab_groups': 'Groups',
+ 'admin.tab_logs': 'Logs',
+ 'admin.tab_blocklist': 'Blocklist',
+
+ // Admin stats
+ 'admin.stat_users': 'Users',
+ 'admin.stat_groups': 'Groups',
+ 'admin.stat_nodes': 'Nodes',
+ 'admin.stat_online': 'Online Nodes',
+
+ // Admin users
+ 'admin.users_search': 'Search users...',
+ 'admin.col_username': 'Username',
+ 'admin.col_role': 'Role',
+ 'admin.col_status': 'Status',
+ 'admin.col_created': 'Created',
+ 'admin.col_actions': 'Actions',
+ 'admin.col_email': 'Email',
+ 'admin.col_groups': 'Groups',
+ 'admin.no_users': 'No users found',
+ 'admin.btn_suspend': 'Suspend',
+ 'admin.btn_unsuspend': 'Unsuspend',
+ 'admin.btn_details': 'Details',
+ 'admin.user_detail': 'User details',
+ 'admin.btn_close': 'Close',
+ 'admin.self_note': '(you)',
+
+ // Admin groups
+ 'admin.col_name': 'Name',
+ 'admin.col_visibility': 'Visibility',
+ 'admin.col_members': 'Members',
+ 'admin.no_groups': 'No groups found',
+
+ // Admin logs
+ 'admin.col_time': 'Time',
+ 'admin.col_event': 'Event',
+ 'admin.col_user': 'User',
+ 'admin.col_ip': 'IP',
+ 'admin.col_detail': 'Detail',
+ 'admin.filter_all': 'All events',
+ 'admin.no_logs': 'No logs found',
+ 'admin.btn_load_more': 'Load more',
+
+ // Admin blocklist
+ 'admin.col_hash': 'Hash',
+ 'admin.col_reason': 'Reason',
+ 'admin.col_date': 'Date',
+ 'admin.col_added_by': 'Added by',
+ 'admin.no_blocked': 'No blocked hashes',
+ 'admin.add_hash': 'Add hash to blocklist',
+ 'admin.hash_placeholder': 'blake3 hash (64 hex chars)',
+ 'admin.reason_placeholder': 'Reason',
+ 'admin.btn_block': 'Block',
+ 'admin.btn_unblock': 'Unblock',
};
// ── Locale registry ─────────────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 27e645e..b1b1a7e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -767,6 +767,209 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.play-btn:hover { background: var(--bg-raised); border-color: var(--success); }
.play-btn:disabled { opacity: 0.3; cursor: not-allowed; }
+/* ── Admin panel ─────────────────────────────────────────────────────────── */
+
+.admin-tabs {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 20px;
+ border-bottom: 2px solid var(--border);
+ flex-wrap: wrap;
+}
+
+.admin-tab {
+ padding: 8px 16px;
+ border: none;
+ background: none;
+ color: var(--text-secondary);
+ font-size: 0.9em;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -2px;
+ transition: color 0.15s, border-color 0.15s;
+}
+.admin-tab:hover { color: var(--text); }
+.admin-tab.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+ font-weight: 600;
+}
+
+.admin-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 16px;
+ margin-bottom: 20px;
+}
+
+.stat-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 20px;
+ text-align: center;
+}
+.stat-card .stat-value {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--accent);
+}
+.stat-card .stat-label {
+ font-size: 0.8em;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-dim);
+ margin-top: 4px;
+}
+
+.admin-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+}
+
+.admin-search {
+ flex: 1;
+ min-width: 180px;
+ max-width: 300px;
+ padding: 7px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-base);
+ color: var(--text);
+ font-size: 0.9em;
+}
+.admin-search:focus { outline: none; border-color: var(--border-focus); }
+
+.admin-select {
+ padding: 7px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-base);
+ color: var(--text);
+ font-size: 0.85em;
+ cursor: pointer;
+}
+
+.admin-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.88em;
+}
+.admin-table th {
+ text-align: left;
+ padding: 8px 10px;
+ border-bottom: 2px solid var(--border);
+ color: var(--text-secondary);
+ font-size: 0.8em;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+}
+.admin-table td {
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--border);
+ vertical-align: middle;
+}
+.admin-table tr:hover { background: var(--bg-raised); }
+.admin-table .admin-empty {
+ text-align: center;
+ padding: 24px;
+ color: var(--text-dim);
+ font-style: italic;
+}
+
+.admin-actions { display: flex; gap: 6px; }
+
+.admin-btn {
+ padding: 4px 10px;
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ background: var(--bg-base);
+ color: var(--text);
+ font-size: 0.82em;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.admin-btn:hover { border-color: var(--accent); color: var(--accent); }
+.admin-btn.danger { color: var(--error); }
+.admin-btn.danger:hover { border-color: var(--error); }
+.admin-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+
+.admin-role-select {
+ padding: 3px 6px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--bg-base);
+ color: var(--text);
+ font-size: 0.85em;
+ cursor: pointer;
+}
+
+.admin-detail-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 150;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.admin-detail-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 24px;
+ min-width: 340px;
+ max-width: 90vw;
+ box-shadow: var(--shadow-lg);
+}
+
+.admin-detail-card h3 {
+ margin-bottom: 16px;
+ font-size: 1.1em;
+}
+
+.admin-detail-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 6px 0;
+ font-size: 0.9em;
+}
+.admin-detail-row + .admin-detail-row {
+ border-top: 1px solid var(--border);
+}
+.admin-detail-label { color: var(--text-secondary); }
+.admin-detail-value { font-weight: 500; }
+
+.admin-load-more {
+ display: block;
+ margin: 16px auto;
+ padding: 8px 24px;
+}
+
+.blocklist-form {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+}
+.blocklist-form input {
+ flex: 1;
+ min-width: 180px;
+ padding: 7px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-base);
+ color: var(--text);
+ font-size: 0.9em;
+}
+.blocklist-form input:focus { outline: none; border-color: var(--border-focus); }
+
/* ── Overlay (mobile sidebar backdrop) ────────────────────────────────────── */
.overlay {
diff --git a/packages/meshbay-hub/tests/test_admin.py b/packages/meshbay-hub/tests/test_admin.py
new file mode 100644
index 0000000..51b233a
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_admin.py
@@ -0,0 +1,259 @@
+"""
+Integration tests for the admin/moderation panel API.
+"""
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+
+from meshbay_common.crypto import pk_to_b64
+from meshbay_hub.api.deps import set_admin_usernames
+
+
+def _gen_user_keys():
+ sk_ed = Ed25519PrivateKey.generate()
+ sk_x = X25519PrivateKey.generate()
+ return pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key())
+
+
+async def _register(client, username, email="test@x.com", password="testpass99"):
+ pk_ed, pk_x = _gen_user_keys()
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": email, "password": password,
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x,
+ })
+ assert r.status_code == 201
+ return r.json()["user_id"]
+
+
+async def _login(client, username, password="testpass99"):
+ r = await client.post("/v1/users/login", json={
+ "username": username, "password": password,
+ })
+ assert r.status_code == 200
+ return r.json()["access_token"]
+
+
+async def _setup_admin(client, admin_name="admin"):
+ user_id = await _register(client, admin_name, email=f"{admin_name}@x.com")
+ set_admin_usernames([admin_name])
+ token = await _login(client, admin_name)
+ return user_id, token
+
+
+# ── require_moderator ────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_admin_stats_requires_moderator(client):
+ await _register(client, "normie")
+ token = await _login(client, "normie")
+ r = await client.get("/v1/admin/stats", headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_admin_stats_allowed_for_config_admin(client):
+ _, token = await _setup_admin(client)
+ r = await client.get("/v1/admin/stats", headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ data = r.json()
+ assert "users" in data
+ assert "groups" in data
+ assert "nodes" in data
+ assert "online_nodes" in data
+
+
+@pytest.mark.asyncio
+async def test_admin_stats_allowed_for_db_moderator(client):
+ _, admin_token = await _setup_admin(client, "boss")
+ mod_id = await _register(client, "moduser")
+ r = await client.patch(f"/v1/admin/users/{mod_id}",
+ json={"role": "moderator"},
+ headers={"Authorization": f"Bearer {admin_token}"})
+ assert r.status_code == 200
+
+ mod_token = await _login(client, "moduser")
+ r = await client.get("/v1/admin/stats", headers={"Authorization": f"Bearer {mod_token}"})
+ assert r.status_code == 200
+
+
+# ── User management ─────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_admin_list_users(client):
+ _, token = await _setup_admin(client)
+ await _register(client, "alice", email="a@x.com")
+ await _register(client, "bob", email="b@x.com")
+
+ r = await client.get("/v1/admin/users", headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ data = r.json()
+ assert data["total"] == 3
+ assert len(data["users"]) == 3
+
+
+@pytest.mark.asyncio
+async def test_admin_list_users_search(client):
+ _, token = await _setup_admin(client)
+ await _register(client, "alice", email="a@x.com")
+ await _register(client, "bob", email="b@x.com")
+
+ r = await client.get("/v1/admin/users?q=ali",
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ data = r.json()
+ assert data["total"] == 1
+ assert data["users"][0]["username"] == "alice"
+
+
+@pytest.mark.asyncio
+async def test_admin_get_user_detail(client):
+ _, token = await _setup_admin(client)
+ uid = await _register(client, "alice", email="alice@example.com")
+
+ r = await client.get(f"/v1/admin/users/{uid}",
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ data = r.json()
+ assert data["username"] == "alice"
+ assert data["role"] == "user"
+ assert data["status"] == "active"
+ assert data["group_count"] == 0
+ assert "email" in data
+
+
+@pytest.mark.asyncio
+async def test_admin_suspend_unsuspend_user(client):
+ _, token = await _setup_admin(client)
+ uid = await _register(client, "alice", email="a@x.com")
+ alice_token = await _login(client, "alice")
+
+ r = await client.patch(f"/v1/admin/users/{uid}",
+ json={"status": "suspended"},
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ assert r.json()["status"] == "suspended"
+
+ r = await client.get("/v1/users/me",
+ headers={"Authorization": f"Bearer {alice_token}"})
+ assert r.status_code == 403
+
+ r = await client.patch(f"/v1/admin/users/{uid}",
+ json={"status": "active"},
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ assert r.json()["status"] == "active"
+
+ r = await client.get("/v1/users/me",
+ headers={"Authorization": f"Bearer {alice_token}"})
+ assert r.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_admin_change_role(client):
+ _, token = await _setup_admin(client)
+ uid = await _register(client, "alice", email="a@x.com")
+
+ r = await client.patch(f"/v1/admin/users/{uid}",
+ json={"role": "moderator"},
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ assert r.json()["role"] == "moderator"
+
+
+@pytest.mark.asyncio
+async def test_admin_cannot_modify_self(client):
+ admin_id, token = await _setup_admin(client)
+ r = await client.patch(f"/v1/admin/users/{admin_id}",
+ json={"role": "user"},
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_admin_invalid_role_rejected(client):
+ _, token = await _setup_admin(client)
+ uid = await _register(client, "alice", email="a@x.com")
+ r = await client.patch(f"/v1/admin/users/{uid}",
+ json={"role": "superuser"},
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 422
+
+
+# ── Group management ─────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_admin_list_groups(client):
+ _, token = await _setup_admin(client)
+
+ await client.post("/v1/groups", json={
+ "name": "test-group", "visibility": "public", "join_policy": "open",
+ }, headers={"Authorization": f"Bearer {token}"})
+
+ r = await client.get("/v1/admin/groups",
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ data = r.json()
+ assert data["total"] >= 1
+ assert data["groups"][0]["name"] == "test-group"
+ assert "member_count" in data["groups"][0]
+
+
+@pytest.mark.asyncio
+async def test_admin_suspend_group(client):
+ _, token = await _setup_admin(client)
+ r = await client.post("/v1/groups", json={
+ "name": "bad-group", "visibility": "public", "join_policy": "open",
+ }, headers={"Authorization": f"Bearer {token}"})
+ gid = r.json()["group_id"]
+
+ r = await client.patch(f"/v1/admin/groups/{gid}",
+ json={"status": "suspended"},
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ assert r.json()["status"] == "suspended"
+
+
+# ── Audit logs ───────────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_admin_logs(client):
+ _, token = await _setup_admin(client)
+ await _register(client, "alice", email="a@x.com")
+
+ r = await client.get("/v1/admin/logs",
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ data = r.json()
+ assert len(data["logs"]) > 0
+ log = data["logs"][0]
+ assert "event" in log
+ assert "timestamp" in log
+
+
+@pytest.mark.asyncio
+async def test_admin_logs_filter_by_event(client):
+ _, token = await _setup_admin(client)
+ await _register(client, "alice", email="a@x.com")
+ await _login(client, "alice")
+
+ r = await client.get("/v1/admin/logs?event=login",
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ for log in r.json()["logs"]:
+ assert log["event"] == "login"
+
+
+# ── /v1/users/me ─────────────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_users_me(client):
+ await _register(client, "alice", email="a@x.com")
+ token = await _login(client, "alice")
+ r = await client.get("/v1/users/me",
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200
+ data = r.json()
+ assert data["username"] == "alice"
+ assert data["role"] == "user"
+ assert data["status"] == "active"