diff options
Diffstat (limited to 'packages/meshbay-hub/src')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/admin.py | 273 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/deps.py | 13 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 12 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 10 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/app.py | 22 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/db/models.py | 1 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 369 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 60 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 203 |
9 files changed, 946 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 { |