diff --git a/backend/README.md b/backend/README.md index 8666923..9eb0eb5 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 CRUD (open and private), room roles (owner/admin/member) and invites, a 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. This is an **invite-only site**: there is no public registration endpoint. @@ -59,7 +60,8 @@ cp .env.example .env ### 5. Create a user 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 .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 config.py environment-driven settings (pydantic-settings) 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 cli.py `python -m app.cli create-user` / `generate-vapid-keys` 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 - routers/ auth, rooms, invites, push, health + routers/ auth, rooms, invites, push, admin, health services/ business logic called by routers ws/ connection_manager (local sockets), presence + broadcaster (Redis), /ws/chat handler @@ -120,6 +124,38 @@ alembic/ migrations 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) 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. - Deleting a room explicitly deletes its messages/memberships/invites first (`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. diff --git a/backend/alembic/versions/6ee71c8d5e07_admin_portal.py b/backend/alembic/versions/6ee71c8d5e07_admin_portal.py new file mode 100644 index 0000000..2015f80 --- /dev/null +++ b/backend/alembic/versions/6ee71c8d5e07_admin_portal.py @@ -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 ### diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index a722f4c..c882077 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -18,7 +18,7 @@ async def get_current_user( raise HTTPException(status_code=401, detail="Not authenticated") 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() 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]: raise HTTPException(status_code=403, detail="Insufficient room role") return membership + + +def require_site_admin(user: User) -> None: + if not user.is_site_admin: + raise HTTPException(status_code=403, detail="Site admin required") diff --git a/backend/app/main.py b/backend/app/main.py index b24f2e1..e3e83ce 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,7 @@ from redis.asyncio import Redis from starlette.middleware.sessions import SessionMiddleware 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.chat import router as ws_router from app.ws.connection_manager import ConnectionManager @@ -49,6 +49,7 @@ def create_app() -> FastAPI: app.include_router(rooms.router) app.include_router(invites.router) app.include_router(push.router) + app.include_router(admin.router) app.include_router(ws_router) return app diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 475d351..e5954fb 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,3 +1,4 @@ +from app.models.admin_audit_log import AdminAuditLog from app.models.base import Base from app.models.invite import InviteStatus, RoomInvite from app.models.membership import RoomMembership, RoomRole @@ -16,4 +17,5 @@ __all__ = [ "RoomInvite", "InviteStatus", "PushSubscription", + "AdminAuditLog", ] diff --git a/backend/app/models/admin_audit_log.py b/backend/app/models/admin_audit_log.py new file mode 100644 index 0000000..89c3708 --- /dev/null +++ b/backend/app/models/admin_audit_log.py @@ -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") diff --git a/backend/app/models/room.py b/backend/app/models/room.py index e3d4ba7..e91aab4 100644 --- a/backend/app/models/room.py +++ b/backend/app/models/room.py @@ -14,6 +14,7 @@ class Room(Base): name: Mapped[str] = mapped_column(String(100), unique=True, index=True, nullable=False) description: Mapped[str | None] = mapped_column(Text) 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) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False diff --git a/backend/app/models/user.py b/backend/app/models/user.py index a4c81f9..395f1b7 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -16,6 +16,7 @@ class User(Base): password_hash: Mapped[str] = mapped_column(String(255), 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_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..f451626 --- /dev/null +++ b/backend/app/routers/admin.py @@ -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 + ] diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 9434641..93d62a9 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -6,7 +6,11 @@ from app.dependencies import get_current_user from app.models import User from app.schemas.auth import LoginRequest 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 # by an operator via `python -m app.cli create-user` (see app/cli.py), not @@ -25,6 +29,8 @@ async def login( ) except InvalidCredentialsError: 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) return user diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..7cd16a6 --- /dev/null +++ b/backend/app/schemas/admin.py @@ -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 diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py new file mode 100644 index 0000000..7df6e77 --- /dev/null +++ b/backend/app/services/admin_service.py @@ -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()) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 4b007d6..3be156b 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -15,6 +15,10 @@ class InvalidCredentialsError(Exception): pass +class AccountDeactivatedError(Exception): + pass + + async def register_user(db: AsyncSession, data: UserCreate) -> User: user = User( username=data.username, @@ -43,4 +47,6 @@ async def authenticate_user( user = result.scalar_one_or_none() if user is None or not verify_password(password, user.password_hash): raise InvalidCredentialsError() + if not user.is_active: + raise AccountDeactivatedError() return user diff --git a/backend/app/services/room_service.py b/backend/app/services/room_service.py index 2eb639b..1b61d3c 100644 --- a/backend/app/services/room_service.py +++ b/backend/app/services/room_service.py @@ -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]]: result = await db.execute( select(Room) - .where(Room.is_private.is_(False)) + .where(Room.is_private.is_(False), Room.is_archived.is_(False)) .options(selectinload(Room.memberships)) .order_by(Room.created_at) ) diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py new file mode 100644 index 0000000..514ba7e --- /dev/null +++ b/backend/tests/test_admin.py @@ -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" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7fc4b57..54b7943 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,10 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { AuthProvider } from './context/AuthContext' +import { AdminRoute } from './components/AdminRoute' import { ProtectedRoute } from './components/ProtectedRoute' import { LoginPage } from './pages/LoginPage' import { ChatShellPage } from './pages/ChatShellPage' +import { AdminPage } from './pages/AdminPage' function App() { return ( @@ -25,6 +27,14 @@ function App() { } /> + + + + } + /> } /> diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 0000000..c204ee7 --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,52 @@ +import { apiFetch } from './client' +import type { AdminRoom, AdminUser, AuditLogEntry } from '../types' + +export function listAdminUsers(): Promise { + return apiFetch('/api/admin/users') +} + +export function deactivateUser(userId: string): Promise { + return apiFetch(`/api/admin/users/${userId}/deactivate`, { method: 'POST' }) +} + +export function reactivateUser(userId: string): Promise { + return apiFetch(`/api/admin/users/${userId}/reactivate`, { method: 'POST' }) +} + +export function resetUserPassword(userId: string, newPassword: string): Promise { + return apiFetch(`/api/admin/users/${userId}/reset-password`, { + method: 'POST', + body: JSON.stringify({ new_password: newPassword }), + }) +} + +export function promoteUser(userId: string): Promise { + return apiFetch(`/api/admin/users/${userId}/promote`, { method: 'POST' }) +} + +export function demoteUser(userId: string): Promise { + return apiFetch(`/api/admin/users/${userId}/demote`, { method: 'POST' }) +} + +export function listAdminRooms(): Promise { + return apiFetch('/api/admin/rooms') +} + +export function archiveRoom(roomId: string): Promise { + return apiFetch(`/api/admin/rooms/${roomId}/archive`, { method: 'POST' }) +} + +export function unarchiveRoom(roomId: string): Promise { + return apiFetch(`/api/admin/rooms/${roomId}/unarchive`, { method: 'POST' }) +} + +export function transferOwnershipAdmin(roomId: string, newOwnerId: string): Promise { + return apiFetch(`/api/admin/rooms/${roomId}/transfer-ownership`, { + method: 'POST', + body: JSON.stringify({ new_owner_id: newOwnerId }), + }) +} + +export function listAuditLog(limit = 50, offset = 0): Promise { + return apiFetch(`/api/admin/audit-log?limit=${limit}&offset=${offset}`) +} diff --git a/frontend/src/components/AdminRoute.tsx b/frontend/src/components/AdminRoute.tsx new file mode 100644 index 0000000..53a841e --- /dev/null +++ b/frontend/src/components/AdminRoute.tsx @@ -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

Loading...

+ if (!user) return + if (!user.is_site_admin) return + + return <>{children} +} diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 3607309..56d0851 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' import logo from '../assets/logo.png' import { useAuth } from '../context/AuthContext' import { initials } from '../lib/avatar' @@ -7,6 +8,7 @@ import './TopBar.css' export function TopBar() { const { user, logout } = useAuth() + const navigate = useNavigate() const [menuOpen, setMenuOpen] = useState(false) const [pushSubscribed, setPushSubscribed] = useState(false) const [pushBusy, setPushBusy] = useState(false) @@ -58,6 +60,18 @@ export function TopBar() {
setMenuOpen(false)} />
{user.username}
+ {user.is_site_admin && ( + + )} {isPushSupported() && ( + ))} +
+ + {error &&

{error}

} + + {tab === 'users' && ( + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + +
UsernameEmailStatusRoleActions
{u.username}{u.email} + + {u.is_active ? 'Active' : 'Deactivated'} + + + + {u.is_site_admin ? 'Site admin' : 'Member'} + + + + + +
+ )} + + {tab === 'rooms' && ( + + + + + + + + + + + + {rooms.map((r) => ( + + + + + + + + ))} + +
NameVisibilityStatusMembersActions
#{r.name}{r.is_private ? 'Private' : 'Open'} + + {r.is_archived ? 'Archived' : 'Active'} + + {r.member_count} + + +
+ )} + + {tab === 'audit' && ( + <> + + + + + + + + + + + {auditLog.map((e) => ( + + + + + + + ))} + +
WhenActorActionTarget
{new Date(e.created_at).toLocaleString()}{e.actor_username}{e.action} + {e.target_type} {e.target_id.slice(0, 8)} +
+ {auditHasMore && ( + + )} + + )} + + {tab === 'settings' && ( +

+ System settings are coming in a future phase — there's nothing configurable yet. +

+ )} +
+ + ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bd051b1..4c4ba55 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -81,3 +81,35 @@ export interface 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 | null + created_at: string +}