Phase 6: Admin portal

Adds is_site_admin-gated site administration: user management (list,
deactivate/reactivate, reset password, promote/demote), room management
(list all rooms including private ones, archive/unarchive, force-transfer
ownership), and an audit log of every admin action.

Backend: User.is_active (deactivation) and Room.is_archived (archive) are
new columns; AdminAuditLog is a new table matching ARCHITECTURE.md's
admin_audit_log design, written to in the same transaction as every
mutating admin action. require_site_admin (dependencies.py) gates all
/api/admin/* routes. get_current_user now rechecks is_active on every
request, so deactivating a user kills their already-open session
immediately, not just future logins. An admin can't deactivate or demote
their own account (the one self-lockout guard included). Archived rooms
drop out of the open-room browse list but stay readable for existing
members.

Frontend: new /admin route (AdminRoute guard, redirects non-admins to
/rooms) with a tabbed Users / Rooms / Audit log / Settings page, plus an
"Admin" link in the account menu for site admins.

Bot/integration management and system settings -- both listed in the
original issue -- are intentionally not here: bot management has nothing
to manage until Phase 7 builds the actual bot data model, and there's no
settings storage or concrete setting to configure yet. Settings has an
empty placeholder tab; bot management is deferred entirely to Phase 7.
Confirmed this scope cut with the repo owner before implementing.

New tests/test_admin.py (14 tests, full suite now 58/58) covers every
admin endpoint's permission gate, the self-action guards, deactivation's
immediate effect on an already-open session, and that every mutating
action produces exactly one audit log row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 07:37:29 -06:00
co-authored by Claude Sonnet 5
parent 0b995ef75f
commit 4aa8ef89c5
22 changed files with 1394 additions and 10 deletions
+45 -6
View File
@@ -1,9 +1,10 @@
# KeepItTalking backend (Phase 1 + 2 + 4 + 5) # KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6)
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
CRUD (open and private), room roles (owner/admin/member) and invites, a CRUD (open and private), room roles (owner/admin/member) and invites, a
WebSocket chat endpoint that fans out across multiple app-server instances WebSocket chat endpoint that fans out across multiple app-server instances
via Redis pub/sub, and Web Push notifications for offline room members. See via Redis pub/sub, Web Push notifications for offline room members, and a
site-admin portal (user/room management + an audit log). See
`../ARCHITECTURE.md` for the full system design and the phased build plan. `../ARCHITECTURE.md` for the full system design and the phased build plan.
This is an **invite-only site**: there is no public registration endpoint. This is an **invite-only site**: there is no public registration endpoint.
@@ -59,7 +60,8 @@ cp .env.example .env
### 5. Create a user ### 5. Create a user
There's no public sign-up. Create accounts directly with the CLI (add There's no public sign-up. Create accounts directly with the CLI (add
`--admin` to grant `is_site_admin`, useful ahead of the phase-6 admin portal): `--admin` to grant `is_site_admin`, which unlocks the admin portal at
`/admin` on the frontend and the `/api/admin/*` routes below):
```bash ```bash
.venv/bin/python -m app.cli create-user alice alice@example.com "some-password" .venv/bin/python -m app.cli create-user alice alice@example.com "some-password"
@@ -106,13 +108,15 @@ app/
main.py create_app(), session middleware, router/WS mounting main.py create_app(), session middleware, router/WS mounting
config.py environment-driven settings (pydantic-settings) config.py environment-driven settings (pydantic-settings)
database.py async engine/session, get_db() dependency database.py async engine/session, get_db() dependency
dependencies.py get_current_user, require_room_member, require_room_role dependencies.py get_current_user, require_room_member, require_room_role,
require_site_admin
security.py argon2 password hashing security.py argon2 password hashing
cli.py `python -m app.cli create-user` / `generate-vapid-keys` cli.py `python -m app.cli create-user` / `generate-vapid-keys`
models/ SQLAlchemy models (users, rooms, room_memberships, models/ SQLAlchemy models (users, rooms, room_memberships,
messages, room_invites, push_subscriptions) messages, room_invites, push_subscriptions,
admin_audit_log)
schemas/ Pydantic request/response models schemas/ Pydantic request/response models
routers/ auth, rooms, invites, push, health routers/ auth, rooms, invites, push, admin, health
services/ business logic called by routers services/ business logic called by routers
ws/ connection_manager (local sockets), presence + ws/ connection_manager (local sockets), presence +
broadcaster (Redis), /ws/chat handler broadcaster (Redis), /ws/chat handler
@@ -120,6 +124,38 @@ alembic/ migrations
tests/ pytest + httpx/TestClient tests tests/ pytest + httpx/TestClient tests
``` ```
## Admin portal (Phase 6)
Every `/api/admin/*` route (`app/routers/admin.py`) requires
`current_user.is_site_admin` (checked via `require_site_admin`,
`app/dependencies.py`) and is backed by `app/services/admin_service.py`:
- **Users**: list, deactivate/reactivate (`User.is_active`), reset password,
promote/demote `is_site_admin`. An admin can't deactivate or demote their
own account (`CannotActOnSelfError` → 400) — the one guard against an
admin locking themselves out. Deactivation takes effect immediately, even
for an already-open session: `get_current_user` re-checks `is_active` on
every request since it already loads the user row.
- **Rooms**: list every room including private ones (unlike the
member-facing `GET /api/rooms`, which is open-rooms-only), archive/
unarchive (`Room.is_archived` — archived rooms drop out of the open-room
browse list but stay readable for existing members, matching how
Mattermost archive works), and force a transfer of ownership to any
existing member without needing to already be the owner (the "admin
override" of the member-initiated transfer in `room_service.py`, which
otherwise requires exactly that).
- **Audit log**: every mutating admin action writes one `AdminAuditLog` row
(actor, action, target type/id, JSON metadata) in the same transaction as
the change, listed newest-first via `GET /api/admin/audit-log`.
Two items from the original phase scope are deliberately not here yet:
- **Bot/integration management** — nothing to manage until Phase 7 builds
the actual bot data model (`api_tokens`, `webhooks_incoming`,
`event_subscriptions` per `ARCHITECTURE.md` §4); it'll be built alongside
that data model instead of as an empty panel now.
- **System settings** — no settings storage or concrete setting exists yet.
The frontend has an empty "Settings" tab as a placeholder for when one
does.
## Cross-instance broadcast (Phase 5) ## Cross-instance broadcast (Phase 5)
The WebSocket layer is split into three pieces so that running one app The WebSocket layer is split into three pieces so that running one app
@@ -192,3 +228,6 @@ their role.
proxy (see `../frontend/vite.config.ts`) is the accepted phase-1 mitigation. proxy (see `../frontend/vite.config.ts`) is the accepted phase-1 mitigation.
- Deleting a room explicitly deletes its messages/memberships/invites first - Deleting a room explicitly deletes its messages/memberships/invites first
(`room_service.delete_room`) rather than relying on DB-level cascades. (`room_service.delete_room`) rather than relying on DB-level cascades.
- `admin_audit_log` has no admin UI for filtering/searching yet — it's a
flat newest-first list with `limit`/`offset` pagination, no filter by
actor/action/target. Fine at current scale; revisit if the log grows.
@@ -0,0 +1,63 @@
"""admin portal
Revision ID: 6ee71c8d5e07
Revises: 8a55c5254edb
Create Date: 2026-08-14 07:25:19.603206
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '6ee71c8d5e07'
down_revision: Union[str, Sequence[str], None] = '8a55c5254edb'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('admin_audit_log',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('actor_id', sa.Uuid(), nullable=False),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('target_type', sa.String(length=50), nullable=False),
sa.Column('target_id', sa.Uuid(), nullable=False),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['actor_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_admin_audit_log_actor_id'), 'admin_audit_log', ['actor_id'], unique=False)
op.create_index(op.f('ix_admin_audit_log_created_at'), 'admin_audit_log', ['created_at'], unique=False)
# server_default backfills existing rows; dropped right after since the
# ORM already sends an explicit value on every insert (matches the
# client-side-default style used for is_private/is_bot in the initial
# migration, which didn't need a backfill because those tables were
# still empty at the time).
op.add_column(
'rooms',
sa.Column('is_archived', sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.alter_column('rooms', 'is_archived', server_default=None)
op.add_column(
'users',
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.alter_column('users', 'is_active', server_default=None)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'is_active')
op.drop_column('rooms', 'is_archived')
op.drop_index(op.f('ix_admin_audit_log_created_at'), table_name='admin_audit_log')
op.drop_index(op.f('ix_admin_audit_log_actor_id'), table_name='admin_audit_log')
op.drop_table('admin_audit_log')
# ### end Alembic commands ###
+6 -1
View File
@@ -18,7 +18,7 @@ async def get_current_user(
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
user = await db.get(User, uuid.UUID(user_id)) user = await db.get(User, uuid.UUID(user_id))
if user is None: if user is None or not user.is_active:
request.session.clear() request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
@@ -46,3 +46,8 @@ async def require_room_role(
if _ROLE_RANK[membership.role] < _ROLE_RANK[minimum]: if _ROLE_RANK[membership.role] < _ROLE_RANK[minimum]:
raise HTTPException(status_code=403, detail="Insufficient room role") raise HTTPException(status_code=403, detail="Insufficient room role")
return membership return membership
def require_site_admin(user: User) -> None:
if not user.is_site_admin:
raise HTTPException(status_code=403, detail="Site admin required")
+2 -1
View File
@@ -8,7 +8,7 @@ from redis.asyncio import Redis
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from app.config import settings from app.config import settings
from app.routers import auth, health, invites, push, rooms from app.routers import admin, auth, health, invites, push, rooms
from app.ws.broadcaster import RoomBroadcaster from app.ws.broadcaster import RoomBroadcaster
from app.ws.chat import router as ws_router from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager from app.ws.connection_manager import ConnectionManager
@@ -49,6 +49,7 @@ def create_app() -> FastAPI:
app.include_router(rooms.router) app.include_router(rooms.router)
app.include_router(invites.router) app.include_router(invites.router)
app.include_router(push.router) app.include_router(push.router)
app.include_router(admin.router)
app.include_router(ws_router) app.include_router(ws_router)
return app return app
+2
View File
@@ -1,3 +1,4 @@
from app.models.admin_audit_log import AdminAuditLog
from app.models.base import Base from app.models.base import Base
from app.models.invite import InviteStatus, RoomInvite from app.models.invite import InviteStatus, RoomInvite
from app.models.membership import RoomMembership, RoomRole from app.models.membership import RoomMembership, RoomRole
@@ -16,4 +17,5 @@ __all__ = [
"RoomInvite", "RoomInvite",
"InviteStatus", "InviteStatus",
"PushSubscription", "PushSubscription",
"AdminAuditLog",
] ]
+24
View File
@@ -0,0 +1,24 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class AdminAuditLog(Base):
__tablename__ = "admin_audit_log"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
actor_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
action: Mapped[str] = mapped_column(String(100), nullable=False)
target_type: Mapped[str] = mapped_column(String(50), nullable=False)
target_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
)
actor = relationship("User")
+1
View File
@@ -14,6 +14,7 @@ class Room(Base):
name: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False) name: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
is_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False) owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
+1
View File
@@ -16,6 +16,7 @@ class User(Base):
password_hash: Mapped[str] = mapped_column(String(255), nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
is_bot: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_bot: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_site_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_site_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )
+225
View File
@@ -0,0 +1,225 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user, require_site_admin
from app.models import Room, RoomMembership, User
from app.schemas.admin import (
AdminRoomRead,
AdminUserRead,
AuditLogEntryRead,
ResetPasswordRequest,
TransferOwnershipRequest,
)
from app.services.admin_service import (
CannotActOnSelfError,
RoomNotFoundError,
TargetNotRoomMemberError,
UserNotFoundError,
list_audit_log,
list_rooms_admin,
list_users,
reset_user_password,
set_room_archived,
set_user_active,
set_user_site_admin,
transfer_ownership_admin,
)
router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.get("/users", response_model=list[AdminUserRead])
async def list_users_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
return await list_users(db)
@router.post("/users/{user_id}/deactivate", response_model=AdminUserRead)
async def deactivate_user_endpoint(
user_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
try:
return await set_user_active(db, current_user, user_id, active=False)
except CannotActOnSelfError:
raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
except UserNotFoundError:
raise HTTPException(status_code=404, detail="User not found")
@router.post("/users/{user_id}/reactivate", response_model=AdminUserRead)
async def reactivate_user_endpoint(
user_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
try:
return await set_user_active(db, current_user, user_id, active=True)
except CannotActOnSelfError:
raise HTTPException(status_code=400, detail="Cannot reactivate your own account")
except UserNotFoundError:
raise HTTPException(status_code=404, detail="User not found")
@router.post("/users/{user_id}/reset-password", status_code=204)
async def reset_user_password_endpoint(
user_id: uuid.UUID,
data: ResetPasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
try:
await reset_user_password(db, current_user, user_id, data.new_password)
except UserNotFoundError:
raise HTTPException(status_code=404, detail="User not found")
@router.post("/users/{user_id}/promote", response_model=AdminUserRead)
async def promote_user_endpoint(
user_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
try:
return await set_user_site_admin(db, current_user, user_id, is_admin=True)
except CannotActOnSelfError:
raise HTTPException(status_code=400, detail="Cannot promote your own account")
except UserNotFoundError:
raise HTTPException(status_code=404, detail="User not found")
@router.post("/users/{user_id}/demote", response_model=AdminUserRead)
async def demote_user_endpoint(
user_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
try:
return await set_user_site_admin(db, current_user, user_id, is_admin=False)
except CannotActOnSelfError:
raise HTTPException(status_code=400, detail="Cannot demote your own account")
except UserNotFoundError:
raise HTTPException(status_code=404, detail="User not found")
@router.get("/rooms", response_model=list[AdminRoomRead])
async def list_rooms_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
rooms = await list_rooms_admin(db)
return [
AdminRoomRead(
id=room.id,
name=room.name,
description=room.description,
is_private=room.is_private,
is_archived=room.is_archived,
owner_id=room.owner_id,
created_at=room.created_at,
member_count=member_count,
)
for room, member_count in rooms
]
@router.post("/rooms/{room_id}/archive", response_model=AdminRoomRead)
async def archive_room_endpoint(
room_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
try:
room = await set_room_archived(db, current_user, room_id, archived=True)
except RoomNotFoundError:
raise HTTPException(status_code=404, detail="Room not found")
return await _to_admin_room_read(db, room)
@router.post("/rooms/{room_id}/unarchive", response_model=AdminRoomRead)
async def unarchive_room_endpoint(
room_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
try:
room = await set_room_archived(db, current_user, room_id, archived=False)
except RoomNotFoundError:
raise HTTPException(status_code=404, detail="Room not found")
return await _to_admin_room_read(db, room)
@router.post("/rooms/{room_id}/transfer-ownership", response_model=AdminRoomRead)
async def transfer_ownership_endpoint(
room_id: uuid.UUID,
data: TransferOwnershipRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
try:
room = await transfer_ownership_admin(db, current_user, room_id, data.new_owner_id)
except RoomNotFoundError:
raise HTTPException(status_code=404, detail="Room not found")
except TargetNotRoomMemberError:
raise HTTPException(
status_code=400, detail="New owner must already be a member of the room"
)
return await _to_admin_room_read(db, room)
async def _to_admin_room_read(db: AsyncSession, room: Room) -> AdminRoomRead:
result = await db.execute(
select(func.count()).select_from(RoomMembership).where(RoomMembership.room_id == room.id)
)
member_count = result.scalar_one()
return AdminRoomRead(
id=room.id,
name=room.name,
description=room.description,
is_private=room.is_private,
is_archived=room.is_archived,
owner_id=room.owner_id,
created_at=room.created_at,
member_count=member_count,
)
@router.get("/audit-log", response_model=list[AuditLogEntryRead])
async def list_audit_log_endpoint(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
entries = await list_audit_log(db, limit=limit, offset=offset)
return [
AuditLogEntryRead(
id=e.id,
actor_id=e.actor_id,
actor_username=e.actor.username,
action=e.action,
target_type=e.target_type,
target_id=e.target_id,
metadata=e.metadata_,
created_at=e.created_at,
)
for e in entries
]
+7 -1
View File
@@ -6,7 +6,11 @@ from app.dependencies import get_current_user
from app.models import User from app.models import User
from app.schemas.auth import LoginRequest from app.schemas.auth import LoginRequest
from app.schemas.user import UserRead from app.schemas.user import UserRead
from app.services.auth_service import InvalidCredentialsError, authenticate_user from app.services.auth_service import (
AccountDeactivatedError,
InvalidCredentialsError,
authenticate_user,
)
# No POST /register here: this is an invite-only site. Accounts are created # No POST /register here: this is an invite-only site. Accounts are created
# by an operator via `python -m app.cli create-user` (see app/cli.py), not # by an operator via `python -m app.cli create-user` (see app/cli.py), not
@@ -25,6 +29,8 @@ async def login(
) )
except InvalidCredentialsError: except InvalidCredentialsError:
raise HTTPException(status_code=401, detail="Invalid username/email or password") raise HTTPException(status_code=401, detail="Invalid username/email or password")
except AccountDeactivatedError:
raise HTTPException(status_code=401, detail="Account is deactivated")
request.session["user_id"] = str(user.id) request.session["user_id"] = str(user.id)
return user return user
+48
View File
@@ -0,0 +1,48 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class AdminUserRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
username: str
email: EmailStr
is_bot: bool
is_site_admin: bool
is_active: bool
created_at: datetime
class AdminRoomRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
is_private: bool
is_archived: bool
owner_id: uuid.UUID
created_at: datetime
member_count: int
class AuditLogEntryRead(BaseModel):
id: uuid.UUID
actor_id: uuid.UUID
actor_username: str
action: str
target_type: str
target_id: uuid.UUID
metadata: dict | None
created_at: datetime
class ResetPasswordRequest(BaseModel):
new_password: str = Field(min_length=8, max_length=200)
class TransferOwnershipRequest(BaseModel):
new_owner_id: uuid.UUID
+171
View File
@@ -0,0 +1,171 @@
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import AdminAuditLog, Room, RoomMembership, RoomRole, User
from app.security import hash_password
class UserNotFoundError(Exception):
pass
class RoomNotFoundError(Exception):
pass
class CannotActOnSelfError(Exception):
pass
class TargetNotRoomMemberError(Exception):
pass
async def _get_user(db: AsyncSession, user_id: uuid.UUID) -> User:
user = await db.get(User, user_id)
if user is None:
raise UserNotFoundError()
return user
async def _get_room(db: AsyncSession, room_id: uuid.UUID) -> Room:
room = await db.get(Room, room_id)
if room is None:
raise RoomNotFoundError()
return room
async def _get_membership(
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID
) -> RoomMembership:
result = await db.execute(
select(RoomMembership).where(
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
)
)
membership = result.scalar_one_or_none()
if membership is None:
raise TargetNotRoomMemberError()
return membership
def _log(
db: AsyncSession,
actor: User,
action: str,
target_type: str,
target_id: uuid.UUID,
metadata: dict | None = None,
) -> None:
db.add(
AdminAuditLog(
actor_id=actor.id,
action=action,
target_type=target_type,
target_id=target_id,
metadata_=metadata,
)
)
async def list_users(db: AsyncSession) -> list[User]:
result = await db.execute(select(User).order_by(User.created_at))
return list(result.scalars().all())
async def set_user_active(
db: AsyncSession, actor: User, target_user_id: uuid.UUID, active: bool
) -> User:
if target_user_id == actor.id:
raise CannotActOnSelfError()
user = await _get_user(db, target_user_id)
user.is_active = active
_log(db, actor, "user.activate" if active else "user.deactivate", "user", user.id)
await db.commit()
await db.refresh(user)
return user
async def reset_user_password(
db: AsyncSession, actor: User, target_user_id: uuid.UUID, new_password: str
) -> None:
user = await _get_user(db, target_user_id)
user.password_hash = hash_password(new_password)
_log(db, actor, "user.reset_password", "user", user.id)
await db.commit()
async def set_user_site_admin(
db: AsyncSession, actor: User, target_user_id: uuid.UUID, is_admin: bool
) -> User:
if target_user_id == actor.id:
raise CannotActOnSelfError()
user = await _get_user(db, target_user_id)
user.is_site_admin = is_admin
_log(db, actor, "user.promote" if is_admin else "user.demote", "user", user.id)
await db.commit()
await db.refresh(user)
return user
async def list_rooms_admin(db: AsyncSession) -> list[tuple[Room, int]]:
result = await db.execute(
select(Room, func.count(RoomMembership.user_id))
.outerjoin(RoomMembership, RoomMembership.room_id == Room.id)
.group_by(Room.id)
.order_by(Room.created_at)
)
return [(room, count) for room, count in result.all()]
async def set_room_archived(
db: AsyncSession, actor: User, room_id: uuid.UUID, archived: bool
) -> Room:
room = await _get_room(db, room_id)
room.is_archived = archived
_log(db, actor, "room.archive" if archived else "room.unarchive", "room", room.id)
await db.commit()
await db.refresh(room)
return room
async def transfer_ownership_admin(
db: AsyncSession, actor: User, room_id: uuid.UUID, new_owner_id: uuid.UUID
) -> Room:
room = await _get_room(db, room_id)
# Same invariant as the member-initiated room_service.transfer_ownership
# (new owner must already be a member) -- this is the admin override for
# the "acting user must currently be the owner" gate, not for that one.
new_owner_membership = await _get_membership(db, room_id, new_owner_id)
current_owner_membership = await _get_membership(db, room_id, room.owner_id)
new_owner_membership.role = RoomRole.owner
current_owner_membership.role = RoomRole.admin
room.owner_id = new_owner_id
_log(
db,
actor,
"room.transfer_ownership",
"room",
room.id,
{"new_owner_id": str(new_owner_id)},
)
await db.commit()
await db.refresh(room)
return room
async def list_audit_log(
db: AsyncSession, limit: int = 50, offset: int = 0
) -> list[AdminAuditLog]:
result = await db.execute(
select(AdminAuditLog)
.options(selectinload(AdminAuditLog.actor))
.order_by(AdminAuditLog.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
+6
View File
@@ -15,6 +15,10 @@ class InvalidCredentialsError(Exception):
pass pass
class AccountDeactivatedError(Exception):
pass
async def register_user(db: AsyncSession, data: UserCreate) -> User: async def register_user(db: AsyncSession, data: UserCreate) -> User:
user = User( user = User(
username=data.username, username=data.username,
@@ -43,4 +47,6 @@ async def authenticate_user(
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None or not verify_password(password, user.password_hash): if user is None or not verify_password(password, user.password_hash):
raise InvalidCredentialsError() raise InvalidCredentialsError()
if not user.is_active:
raise AccountDeactivatedError()
return user return user
+1 -1
View File
@@ -60,7 +60,7 @@ async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -
async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Room, bool]]: async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Room, bool]]:
result = await db.execute( result = await db.execute(
select(Room) select(Room)
.where(Room.is_private.is_(False)) .where(Room.is_private.is_(False), Room.is_archived.is_(False))
.options(selectinload(Room.memberships)) .options(selectinload(Room.memberships))
.order_by(Room.created_at) .order_by(Room.created_at)
) )
+237
View File
@@ -0,0 +1,237 @@
import uuid
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.models import AdminAuditLog, User
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
from tests.conftest import login_as, register_and_login
async def _make_admin(db_session, user_id: str) -> None:
user = await db_session.get(User, uuid.UUID(user_id))
user.is_site_admin = True
await db_session.commit()
async def _create_user_direct(db_session, username: str) -> User:
# Seed a target user without disturbing `client`'s active session --
# same technique register_and_login uses under the hood, just without
# the login step.
return await register_user(
db_session,
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
)
async def test_admin_endpoints_require_site_admin(client, db_session):
await register_and_login(client, db_session, username="alice")
fake_id = uuid.uuid4()
assert (await client.get("/api/admin/users")).status_code == 403
assert (await client.get("/api/admin/rooms")).status_code == 403
assert (await client.get("/api/admin/audit-log")).status_code == 403
assert (await client.post(f"/api/admin/users/{fake_id}/deactivate")).status_code == 403
async def test_list_users_includes_target(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
bob = await _create_user_direct(db_session, "bob")
resp = await client.get("/api/admin/users")
assert resp.status_code == 200
usernames = {u["username"] for u in resp.json()}
assert {"admin1", "bob"} <= usernames
bob_entry = next(u for u in resp.json() if u["username"] == "bob")
assert bob_entry["is_active"] is True
async def test_deactivate_and_reactivate_user(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
bob = await _create_user_direct(db_session, "bob")
resp = await client.post(f"/api/admin/users/{bob.id}/deactivate")
assert resp.status_code == 200
assert resp.json()["is_active"] is False
resp = await client.post(f"/api/admin/users/{bob.id}/reactivate")
assert resp.status_code == 200
assert resp.json()["is_active"] is True
async def test_deactivated_user_loses_access(client, db_session, app):
alice = await register_and_login(client, db_session, username="alice")
assert (await client.get("/api/auth/me")).status_code == 200
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as bob_client:
bob = await register_and_login(bob_client, db_session, username="bob")
await _make_admin(db_session, bob["id"])
resp = await bob_client.post(f"/api/admin/users/{alice['id']}/deactivate")
assert resp.status_code == 200
assert resp.json()["is_active"] is False
# An already-established session dies immediately -- get_current_user
# rereads is_active on every request.
assert (await client.get("/api/auth/me")).status_code == 401
# A fresh login attempt is also rejected.
login_resp = await client.post(
"/api/auth/login", json={"username_or_email": "alice", "password": "password123"}
)
assert login_resp.status_code == 401
async def test_reset_password(client, db_session, app):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
bob = await _create_user_direct(db_session, "bob")
resp = await client.post(
f"/api/admin/users/{bob.id}/reset-password", json={"new_password": "new-password-1"}
)
assert resp.status_code == 204
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as bob_client:
old_login = await bob_client.post(
"/api/auth/login", json={"username_or_email": "bob", "password": "password123"}
)
assert old_login.status_code == 401
new_login = await bob_client.post(
"/api/auth/login", json={"username_or_email": "bob", "password": "new-password-1"}
)
assert new_login.status_code == 200
async def test_promote_and_demote_user(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
bob = await _create_user_direct(db_session, "bob")
resp = await client.post(f"/api/admin/users/{bob.id}/promote")
assert resp.status_code == 200
assert resp.json()["is_site_admin"] is True
resp = await client.post(f"/api/admin/users/{bob.id}/demote")
assert resp.status_code == 200
assert resp.json()["is_site_admin"] is False
async def test_cannot_deactivate_own_account(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
resp = await client.post(f"/api/admin/users/{admin['id']}/deactivate")
assert resp.status_code == 400
async def test_cannot_demote_own_account(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
resp = await client.post(f"/api/admin/users/{admin['id']}/demote")
assert resp.status_code == 400
async def test_list_rooms_includes_private_with_member_count(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await client.post("/api/rooms", json={"name": "open-room"})
await client.post("/api/rooms", json={"name": "secret-room", "is_private": True})
resp = await client.get("/api/admin/rooms")
assert resp.status_code == 200
rooms_by_name = {r["name"]: r for r in resp.json()}
assert "secret-room" in rooms_by_name
assert rooms_by_name["secret-room"]["is_private"] is True
assert rooms_by_name["open-room"]["member_count"] == 1
async def test_archive_hides_room_from_open_browse_but_not_members(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
room = (await client.post("/api/rooms", json={"name": "general"})).json()
resp = await client.post(f"/api/admin/rooms/{room['id']}/archive")
assert resp.status_code == 200
assert resp.json()["is_archived"] is True
open_rooms = (await client.get("/api/rooms")).json()
assert not any(r["id"] == room["id"] for r in open_rooms)
# existing member (the admin, as owner) can still read history
messages_resp = await client.get(f"/api/rooms/{room['id']}/messages")
assert messages_resp.status_code == 200
resp = await client.post(f"/api/admin/rooms/{room['id']}/unarchive")
assert resp.status_code == 200
assert resp.json()["is_archived"] is False
open_rooms = (await client.get("/api/rooms")).json()
assert any(r["id"] == room["id"] for r in open_rooms)
async def test_transfer_ownership_admin(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
room = (await client.post("/api/rooms", json={"name": "general"})).json()
bob = await _create_user_direct(db_session, "bob")
from app.services.room_service import join_room
await join_room(db_session, uuid.UUID(room["id"]), bob.id)
resp = await client.post(
f"/api/admin/rooms/{room['id']}/transfer-ownership", json={"new_owner_id": str(bob.id)}
)
assert resp.status_code == 200
assert resp.json()["owner_id"] == str(bob.id)
async def test_transfer_ownership_requires_existing_membership(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
room = (await client.post("/api/rooms", json={"name": "general"})).json()
bob = await _create_user_direct(db_session, "bob")
resp = await client.post(
f"/api/admin/rooms/{room['id']}/transfer-ownership", json={"new_owner_id": str(bob.id)}
)
assert resp.status_code == 400
async def test_admin_action_writes_audit_log(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
bob = await _create_user_direct(db_session, "bob")
resp = await client.post(f"/api/admin/users/{bob.id}/deactivate")
assert resp.status_code == 200
result = await db_session.execute(
select(AdminAuditLog).where(AdminAuditLog.target_id == bob.id)
)
entries = result.scalars().all()
assert len(entries) == 1
assert entries[0].action == "user.deactivate"
assert entries[0].actor_id == uuid.UUID(admin["id"])
assert entries[0].target_type == "user"
async def test_audit_log_endpoint_lists_entries(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
bob = await _create_user_direct(db_session, "bob")
await client.post(f"/api/admin/users/{bob.id}/deactivate")
resp = await client.get("/api/admin/audit-log")
assert resp.status_code == 200
entries = resp.json()
assert any(
e["action"] == "user.deactivate" and e["target_id"] == str(bob.id) for e in entries
)
assert entries[0]["actor_username"] == "admin1"
+10
View File
@@ -1,8 +1,10 @@
import { Navigate, Route, Routes } from 'react-router-dom' import { Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext' import { AuthProvider } from './context/AuthContext'
import { AdminRoute } from './components/AdminRoute'
import { ProtectedRoute } from './components/ProtectedRoute' import { ProtectedRoute } from './components/ProtectedRoute'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
import { ChatShellPage } from './pages/ChatShellPage' import { ChatShellPage } from './pages/ChatShellPage'
import { AdminPage } from './pages/AdminPage'
function App() { function App() {
return ( return (
@@ -25,6 +27,14 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/admin"
element={
<AdminRoute>
<AdminPage />
</AdminRoute>
}
/>
<Route path="*" element={<Navigate to="/rooms" replace />} /> <Route path="*" element={<Navigate to="/rooms" replace />} />
</Routes> </Routes>
</AuthProvider> </AuthProvider>
+52
View File
@@ -0,0 +1,52 @@
import { apiFetch } from './client'
import type { AdminRoom, AdminUser, AuditLogEntry } from '../types'
export function listAdminUsers(): Promise<AdminUser[]> {
return apiFetch<AdminUser[]>('/api/admin/users')
}
export function deactivateUser(userId: string): Promise<AdminUser> {
return apiFetch<AdminUser>(`/api/admin/users/${userId}/deactivate`, { method: 'POST' })
}
export function reactivateUser(userId: string): Promise<AdminUser> {
return apiFetch<AdminUser>(`/api/admin/users/${userId}/reactivate`, { method: 'POST' })
}
export function resetUserPassword(userId: string, newPassword: string): Promise<void> {
return apiFetch<void>(`/api/admin/users/${userId}/reset-password`, {
method: 'POST',
body: JSON.stringify({ new_password: newPassword }),
})
}
export function promoteUser(userId: string): Promise<AdminUser> {
return apiFetch<AdminUser>(`/api/admin/users/${userId}/promote`, { method: 'POST' })
}
export function demoteUser(userId: string): Promise<AdminUser> {
return apiFetch<AdminUser>(`/api/admin/users/${userId}/demote`, { method: 'POST' })
}
export function listAdminRooms(): Promise<AdminRoom[]> {
return apiFetch<AdminRoom[]>('/api/admin/rooms')
}
export function archiveRoom(roomId: string): Promise<AdminRoom> {
return apiFetch<AdminRoom>(`/api/admin/rooms/${roomId}/archive`, { method: 'POST' })
}
export function unarchiveRoom(roomId: string): Promise<AdminRoom> {
return apiFetch<AdminRoom>(`/api/admin/rooms/${roomId}/unarchive`, { method: 'POST' })
}
export function transferOwnershipAdmin(roomId: string, newOwnerId: string): Promise<AdminRoom> {
return apiFetch<AdminRoom>(`/api/admin/rooms/${roomId}/transfer-ownership`, {
method: 'POST',
body: JSON.stringify({ new_owner_id: newOwnerId }),
})
}
export function listAuditLog(limit = 50, offset = 0): Promise<AuditLogEntry[]> {
return apiFetch<AuditLogEntry[]>(`/api/admin/audit-log?limit=${limit}&offset=${offset}`)
}
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export function AdminRoute({ children }: { children: ReactNode }) {
const { user, loading } = useAuth()
if (loading) return <p>Loading...</p>
if (!user) return <Navigate to="/login" replace />
if (!user.is_site_admin) return <Navigate to="/rooms" replace />
return <>{children}</>
}
+14
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import logo from '../assets/logo.png' import logo from '../assets/logo.png'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { initials } from '../lib/avatar' import { initials } from '../lib/avatar'
@@ -7,6 +8,7 @@ import './TopBar.css'
export function TopBar() { export function TopBar() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const navigate = useNavigate()
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [pushSubscribed, setPushSubscribed] = useState(false) const [pushSubscribed, setPushSubscribed] = useState(false)
const [pushBusy, setPushBusy] = useState(false) const [pushBusy, setPushBusy] = useState(false)
@@ -58,6 +60,18 @@ export function TopBar() {
<div className="top-bar-menu-scrim" onClick={() => setMenuOpen(false)} /> <div className="top-bar-menu-scrim" onClick={() => setMenuOpen(false)} />
<div className="top-bar-menu" role="menu"> <div className="top-bar-menu" role="menu">
<div className="top-bar-menu-username">{user.username}</div> <div className="top-bar-menu-username">{user.username}</div>
{user.is_site_admin && (
<button
type="button"
role="menuitem"
onClick={() => {
setMenuOpen(false)
navigate('/admin')
}}
>
Admin
</button>
)}
{isPushSupported() && ( {isPushSupported() && (
<button <button
type="button" type="button"
+143
View File
@@ -0,0 +1,143 @@
.admin-page {
height: 100%;
display: flex;
flex-direction: column;
background: var(--ds-void);
}
.admin-body {
flex: 1;
overflow-y: auto;
padding: var(--sp-6) var(--sp-8);
max-width: 960px;
width: 100%;
margin: 0 auto;
}
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-6);
}
.admin-header h1 {
font-size: 1.3rem;
margin: 0;
}
.admin-tabs {
display: flex;
gap: var(--sp-2);
border-bottom: 1px solid var(--ds-border);
margin-bottom: var(--sp-4);
}
.admin-tab {
background: transparent;
border: none;
color: var(--ds-muted);
font-size: 0.86rem;
font-weight: 700;
padding: 10px 4px;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.admin-tab:hover {
color: var(--ds-text);
}
.admin-tab.active {
color: var(--ds-accent);
border-bottom-color: var(--ds-accent);
}
.admin-error {
color: var(--ds-danger);
font-size: 0.84rem;
margin: 0 0 var(--sp-4);
}
.admin-placeholder {
color: var(--ds-muted);
font-size: 0.9rem;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
.admin-table th {
text-align: left;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ds-muted);
padding: 8px 10px;
border-bottom: 1px solid var(--ds-border);
}
.admin-table td {
padding: 10px;
border-bottom: 1px solid var(--ds-border);
vertical-align: middle;
}
.admin-table tbody tr:hover {
background: var(--ds-surface);
}
.status-badge {
display: inline-flex;
align-items: center;
border-radius: var(--radius-pill);
font-size: 0.68rem;
font-weight: 800;
padding: 2px 8px;
}
.status-badge.active {
border: 1px solid color-mix(in srgb, var(--ds-accent) 50%, transparent);
background: color-mix(in srgb, var(--ds-accent) 14%, transparent);
color: var(--ds-accent);
}
.status-badge.inactive {
border: 1px solid color-mix(in srgb, var(--ds-danger) 50%, transparent);
background: color-mix(in srgb, var(--ds-danger) 14%, transparent);
color: var(--ds-danger);
}
.admin-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.admin-actions button {
background: transparent;
border: 1px solid var(--ds-border);
color: var(--ds-muted);
font-size: 0.72rem;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
}
.admin-actions button:hover:not(:disabled) {
color: var(--ds-text);
border-color: var(--ds-accent);
}
.admin-actions button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.admin-load-more {
margin-top: var(--sp-4);
}
+291
View File
@@ -0,0 +1,291 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import {
archiveRoom,
deactivateUser,
demoteUser,
listAdminRooms,
listAdminUsers,
listAuditLog,
promoteUser,
reactivateUser,
resetUserPassword,
transferOwnershipAdmin,
unarchiveRoom,
} from '../api/admin'
import { ApiError } from '../api/client'
import { useAuth } from '../context/AuthContext'
import type { AdminRoom, AdminUser, AuditLogEntry } from '../types'
import { TopBar } from '../components/TopBar'
import './AdminPage.css'
type Tab = 'users' | 'rooms' | 'audit' | 'settings'
const AUDIT_PAGE_SIZE = 50
export function AdminPage() {
const { user: currentUser } = useAuth()
const [tab, setTab] = useState<Tab>('users')
const [users, setUsers] = useState<AdminUser[]>([])
const [rooms, setRooms] = useState<AdminRoom[]>([])
const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([])
const [auditHasMore, setAuditHasMore] = useState(true)
const [busyId, setBusyId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
function reportError(err: unknown) {
setError(err instanceof ApiError ? err.message : String(err))
}
function loadUsers() {
listAdminUsers().then(setUsers).catch(reportError)
}
function loadRooms() {
listAdminRooms().then(setRooms).catch(reportError)
}
function loadAuditLog() {
listAuditLog(AUDIT_PAGE_SIZE, 0)
.then((entries) => {
setAuditLog(entries)
setAuditHasMore(entries.length === AUDIT_PAGE_SIZE)
})
.catch(reportError)
}
useEffect(() => {
if (tab === 'users') loadUsers()
if (tab === 'rooms') {
loadRooms()
if (users.length === 0) loadUsers() // needed to resolve usernames for ownership transfer
}
if (tab === 'audit') loadAuditLog()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab])
async function withBusy(id: string, action: () => Promise<void>) {
setBusyId(id)
setError(null)
try {
await action()
} catch (err) {
reportError(err)
} finally {
setBusyId(null)
}
}
async function handleToggleActive(u: AdminUser) {
await withBusy(u.id, async () => {
const updated = u.is_active ? await deactivateUser(u.id) : await reactivateUser(u.id)
setUsers((prev) => prev.map((x) => (x.id === updated.id ? updated : x)))
})
}
async function handleTogglePromote(u: AdminUser) {
await withBusy(u.id, async () => {
const updated = u.is_site_admin ? await demoteUser(u.id) : await promoteUser(u.id)
setUsers((prev) => prev.map((x) => (x.id === updated.id ? updated : x)))
})
}
async function handleResetPassword(u: AdminUser) {
const newPassword = prompt(`New password for ${u.username} (min 8 characters):`)
if (!newPassword) return
await withBusy(u.id, async () => {
await resetUserPassword(u.id, newPassword)
})
}
async function handleToggleArchive(r: AdminRoom) {
await withBusy(r.id, async () => {
const updated = r.is_archived ? await unarchiveRoom(r.id) : await archiveRoom(r.id)
setRooms((prev) => prev.map((x) => (x.id === updated.id ? updated : x)))
})
}
async function handleTransferOwnership(r: AdminRoom) {
const username = prompt(`Transfer #${r.name} to which username? (must already be a member)`)
if (!username) return
const target = users.find((u) => u.username === username.trim())
if (!target) {
setError(`No known user named "${username}"`)
return
}
await withBusy(r.id, async () => {
const updated = await transferOwnershipAdmin(r.id, target.id)
setRooms((prev) => prev.map((x) => (x.id === updated.id ? updated : x)))
})
}
return (
<div className="admin-page">
<TopBar />
<div className="admin-body">
<div className="admin-header">
<h1>Admin</h1>
<Link to="/rooms" className="btn-secondary">
Back to chat
</Link>
</div>
<div className="admin-tabs" role="tablist">
{(['users', 'rooms', 'audit', 'settings'] as const).map((t) => (
<button
key={t}
type="button"
role="tab"
aria-selected={tab === t}
className={`admin-tab${tab === t ? ' active' : ''}`}
onClick={() => setTab(t)}
>
{t === 'users' && 'Users'}
{t === 'rooms' && 'Rooms'}
{t === 'audit' && 'Audit log'}
{t === 'settings' && 'Settings'}
</button>
))}
</div>
{error && <p className="admin-error">{error}</p>}
{tab === 'users' && (
<table className="admin-table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Status</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.username}</td>
<td>{u.email}</td>
<td>
<span className={`status-badge ${u.is_active ? 'active' : 'inactive'}`}>
{u.is_active ? 'Active' : 'Deactivated'}
</span>
</td>
<td>
<span className={`role-badge role-badge-${u.is_site_admin ? 'owner' : 'member'}`}>
{u.is_site_admin ? 'Site admin' : 'Member'}
</span>
</td>
<td className="admin-actions">
<button
type="button"
disabled={busyId === u.id || u.id === currentUser?.id}
onClick={() => handleToggleActive(u)}
>
{u.is_active ? 'Deactivate' : 'Reactivate'}
</button>
<button
type="button"
disabled={busyId === u.id || u.id === currentUser?.id}
onClick={() => handleTogglePromote(u)}
>
{u.is_site_admin ? 'Demote' : 'Promote'}
</button>
<button type="button" disabled={busyId === u.id} onClick={() => handleResetPassword(u)}>
Reset password
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{tab === 'rooms' && (
<table className="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Visibility</th>
<th>Status</th>
<th>Members</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{rooms.map((r) => (
<tr key={r.id}>
<td>#{r.name}</td>
<td>{r.is_private ? 'Private' : 'Open'}</td>
<td>
<span className={`status-badge ${r.is_archived ? 'inactive' : 'active'}`}>
{r.is_archived ? 'Archived' : 'Active'}
</span>
</td>
<td>{r.member_count}</td>
<td className="admin-actions">
<button type="button" disabled={busyId === r.id} onClick={() => handleToggleArchive(r)}>
{r.is_archived ? 'Unarchive' : 'Archive'}
</button>
<button type="button" disabled={busyId === r.id} onClick={() => handleTransferOwnership(r)}>
Transfer ownership
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{tab === 'audit' && (
<>
<table className="admin-table">
<thead>
<tr>
<th>When</th>
<th>Actor</th>
<th>Action</th>
<th>Target</th>
</tr>
</thead>
<tbody>
{auditLog.map((e) => (
<tr key={e.id}>
<td>{new Date(e.created_at).toLocaleString()}</td>
<td>{e.actor_username}</td>
<td>{e.action}</td>
<td>
{e.target_type} {e.target_id.slice(0, 8)}
</td>
</tr>
))}
</tbody>
</table>
{auditHasMore && (
<button
type="button"
className="btn-secondary admin-load-more"
onClick={() =>
listAuditLog(AUDIT_PAGE_SIZE, auditLog.length)
.then((more) => {
setAuditLog((prev) => [...prev, ...more])
setAuditHasMore(more.length === AUDIT_PAGE_SIZE)
})
.catch(reportError)
}
>
Load more
</button>
)}
</>
)}
{tab === 'settings' && (
<p className="admin-placeholder">
System settings are coming in a future phase there's nothing configurable yet.
</p>
)}
</div>
</div>
)
}
+32
View File
@@ -81,3 +81,35 @@ export interface ChatErrorEnvelope {
} }
export type ServerEnvelope = ChatMessageEnvelope | ChatJoinedEnvelope | ChatErrorEnvelope export type ServerEnvelope = ChatMessageEnvelope | ChatJoinedEnvelope | ChatErrorEnvelope
export interface AdminUser {
id: string
username: string
email: string
is_bot: boolean
is_site_admin: boolean
is_active: boolean
created_at: string
}
export interface AdminRoom {
id: string
name: string
description: string | null
is_private: boolean
is_archived: boolean
owner_id: string
created_at: string
member_count: number
}
export interface AuditLogEntry {
id: string
actor_id: string
actor_username: string
action: string
target_type: string
target_id: string
metadata: Record<string, unknown> | null
created_at: string
}