From 91589ee6478c793e60f66f7397d0f5409fd3fe22 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Fri, 14 Aug 2026 20:40:37 -0600 Subject: [PATCH] Add resizable room panel, searchable user picker, and direct room membership Room info panel is now user-resizable (fixing a layout clip at narrow widths), and every user-selection spot (room membership, admin ownership transfer) uses a new searchable UserPicker instead of raw text input or prompt(). Member rows fold role + actions into a single inline dropdown instead of a row of buttons, so the member list stays usable as rooms grow. Room invites (the accept/decline flow) are replaced by adding a user to a room directly -- an admin/owner picks someone and they're a member immediately, with a "you've been added" notification email instead of an invite email. Drops the now-unused room_invites table. --- DEPLOYMENT.md | 2 +- backend/README.md | 78 +++--- .../a3f7c2e91b4d_drop_room_invites.py | 53 ++++ backend/app/main.py | 3 +- backend/app/models/__init__.py | 3 +- backend/app/models/invite.py | 46 ---- backend/app/models/site_invite.py | 2 +- backend/app/routers/invites.py | 86 ------- backend/app/routers/rooms.py | 83 ++----- backend/app/routers/users.py | 15 ++ backend/app/schemas/invite.py | 31 --- backend/app/schemas/room.py | 4 + backend/app/schemas/user.py | 14 ++ backend/app/services/invite_service.py | 170 ------------- backend/app/services/room_service.py | 47 +++- backend/tests/test_invites.py | 233 ------------------ backend/tests/test_rooms.py | 76 ++++++ backend/tests/test_user_directory.py | 58 +++++ frontend/src/api/invites.ts | 29 --- frontend/src/api/rooms.ts | 7 + frontend/src/api/users.ts | 7 + frontend/src/components/InvitesModal.tsx | 112 --------- frontend/src/components/RoomInfoPanel.css | 69 +++--- frontend/src/components/RoomInfoPanel.tsx | 196 ++++++--------- frontend/src/components/Sidebar.css | 15 -- frontend/src/components/Sidebar.tsx | 13 - frontend/src/components/UserPicker.css | 72 ++++++ frontend/src/components/UserPicker.tsx | 102 ++++++++ frontend/src/hooks/useResizableWidth.ts | 47 ++++ frontend/src/pages/AdminPage.tsx | 68 +++-- frontend/src/pages/ChatShellPage.tsx | 23 +- frontend/src/types.ts | 23 +- 32 files changed, 735 insertions(+), 1052 deletions(-) create mode 100644 backend/alembic/versions/a3f7c2e91b4d_drop_room_invites.py delete mode 100644 backend/app/routers/invites.py delete mode 100644 backend/app/schemas/invite.py delete mode 100644 backend/app/services/invite_service.py delete mode 100644 backend/tests/test_invites.py create mode 100644 backend/tests/test_user_directory.py delete mode 100644 frontend/src/api/invites.ts delete mode 100644 frontend/src/components/InvitesModal.tsx create mode 100644 frontend/src/components/UserPicker.css create mode 100644 frontend/src/components/UserPicker.tsx create mode 100644 frontend/src/hooks/useResizableWidth.ts diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index eeb0fa7..3b37d5c 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -169,7 +169,7 @@ sudo -u chatapp /srv/chatapp/backend/.venv/bin/python -m app.cli generate-vapid- # paste the three printed lines into /etc/chatapp/env ``` -Optional: outgoing email (admin-invited signups, room-invite notifications). +Optional: outgoing email (admin-invited signups, room membership notifications). Unlike everything else on this page, SMTP is **not** configured here — it's set through the Admin portal's Settings tab at runtime, no redeploy or env file edit needed. Skipped silently (logged, not an error) until an diff --git a/backend/README.md b/backend/README.md index ff874e0..50969f7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,16 +1,16 @@ # KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, emoji & reactions, user profiles, site invites & email) 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, Web Push notifications for offline room members, a -site-admin portal (user/room/bot management + an audit log), a bot/ -extension layer (scoped API tokens, live bot WebSocket access, incoming and -outgoing webhooks, message editing), image uploads in chat messages, emoji -reactions on messages, self-service user profiles (display name, avatar), -and admin-issued email invites for new accounts plus email notifications -for room invites. See `../ARCHITECTURE.md` for the full system design and -the phased build plan. +CRUD (open and private), room roles (owner/admin/member) and direct +membership management, a WebSocket chat endpoint that fans out across +multiple app-server instances via Redis pub/sub, Web Push notifications for +offline room members, a site-admin portal (user/room/bot management + an +audit log), a bot/extension layer (scoped API tokens, live bot WebSocket +access, incoming and outgoing webhooks, message editing), image uploads in +chat messages, emoji reactions on messages, self-service user profiles +(display name, avatar), and admin-issued email invites for new accounts +plus email notifications when a user is added to a room. 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. Accounts are created by an operator on the app server — see step 4 below. @@ -128,11 +128,11 @@ app/ cli.py `python -m app.cli create-user` / `generate-vapid-keys` models/ SQLAlchemy models (users, rooms, room_memberships, messages, message_images, message_reactions, - room_invites, site_invites, smtp_settings, + site_invites, smtp_settings, push_subscriptions, admin_audit_log, api_tokens, webhooks_incoming, event_subscriptions) schemas/ Pydantic request/response models - routers/ auth, rooms, users, invites, signup, push, admin, + routers/ auth, rooms, users, signup, push, admin, bots, webhooks, health services/ business logic called by routers ws/ connection_manager (local sockets), presence + @@ -309,21 +309,25 @@ that point, so nothing online-facing is delayed, and it sidesteps on. An expired/invalid subscription (pywebpush 404/410) is deleted automatically. -## Room roles and invites (Phase 2) +## Room roles and membership (Phase 2) Rooms can be `open` (anyone can join via `POST /api/rooms/{id}/join`) or -`private` (`is_private: true` at creation — joinable only via invite). Room -roles are `owner` > `admin` > `member`: +`private` (`is_private: true` at creation — joinable only by being added). +Room roles are `owner` > `admin` > `member`: - **member**: post messages, leave the room -- **admin**: edit room settings, create/list/revoke invites, remove plain members +- **admin**: edit room settings, add/remove plain members - **owner**: everything admin can, plus delete the room, remove admins, change member roles, and transfer ownership -Invite flow: an admin+ calls `POST /api/rooms/{id}/invites` with an existing -`target_username`; the invited user sees it via `GET /api/invites/mine` and -calls `POST /api/invites/{id}/accept` (or `/decline`). `GET /api/rooms/mine` -lists every room (open + private) the current user belongs to, alongside -their role. +Adding to a private room: an admin+ calls `POST /api/rooms/{id}/members` with +an existing user's `user_id` — this adds them straight to +`room_memberships` (no accept/decline step) and fires a "you've been added" +notification email (see Site invites & email below; silently skipped if +SMTP isn't configured). There used to be a separate accept/decline +`RoomInvite` flow here; it was removed in favor of direct add + notify, +since nothing meaningful was gained by making the target confirm first. +`GET /api/rooms/mine` lists every room (open + private) the current user +belongs to, alongside their role. ## Image uploads @@ -423,10 +427,10 @@ change after the fact. ## Site invites & email Two related gaps closed together: creating a new account was CLI-only, and -neither a brand-new invitee nor an existing user invited to a room got any +neither a brand-new invitee nor an existing user added to a room got any notification. Site admins (only) invite a brand-new person by email from -the Admin portal; both that signup-invite and the existing room-invite flow -send an email. +the Admin portal; being added directly to a room (see Room roles and +membership above) sends a "you've been added" email too. **Email sending** (`app/services/email_service.py`, using `aiosmtplib`): `send_email(db, to, subject, body)` is the fire-and-forget path used by @@ -450,11 +454,10 @@ password on update means "keep the current one" — the frontend never has the plaintext to send back, only whether one is set (`has_password`). **Site invites** (`app/models/site_invite.py`, `app/services/site_invite_service.py`) — -distinct from `RoomInvite` (existing user, specific room): this targets an -email address for the site, no room involved. The raw token exists only in -the email link, stored hashed (`security.hash_token`, the same convention -API tokens use — it's a bearer secret looked up by itself, not -`RoomInvite.token`'s current unhashed/unused column). `POST /api/signup` +distinct from adding an existing user to a room: this targets an email +address for the site, no room involved. The raw token exists only in the +email link, stored hashed (`security.hash_token`, the same convention API +tokens use — it's a bearer secret looked up by itself). `POST /api/signup` (`app/routers/signup.py`) is the first genuinely public, unauthenticated endpoint in this app that creates a `User` row — it calls the existing `auth_service.register_user` directly for identical @@ -464,9 +467,9 @@ already signed in. No new rate limiting on it — the unguessable, single-use, expiring token is the actual protection, inheriting the same "no rate limiting on human/bot traffic" gap already documented below, not a new one. -**Room-invite email**: `invite_service.create_invite` sends one email to -the target user after creating the `RoomInvite`, using the live request's -`base_url` for the link — no new "public URL" config needed. +**Room-membership email**: `room_service.add_member` sends one email to +the target user after creating the `RoomMembership`, using the live +request's `base_url` for the link — no new "public URL" config needed. Scope cuts: no outgoing-webhook event type for these (matching image uploads/reactions), no resend for a site invite (revoke + re-invite covers @@ -475,18 +478,17 @@ it), no HTML email templates. ## Notes / scope decisions - Invite-only site registration: no `POST /api/auth/register`. Accounts are - provisioned with `python -m app.cli create-user` (see step 4 above). This is - separate from *room* invites above — site accounts vs. room membership. -- Room invites are by **username only** — `room_invites.target_email` exists - in the schema (per `ARCHITECTURE.md`) but is unused, since there's no - email-delivery mechanism anywhere in the stack yet. + provisioned with `python -m app.cli create-user` (see step 4 above), or via + a site invite (see Site invites & email below). This is separate from + adding an existing user to a private room — site accounts vs. room + membership. - Sessions are signed cookies (Starlette `SessionMiddleware`), not a server-side session table — see `ARCHITECTURE.md`'s rationale (simplest way to carry auth through a WebSocket handshake). This means there's currently no way to force- revoke a session server-side; that needs a real session table later. - No CSRF token yet — `SameSite=Lax` cookies plus a same-origin frontend dev 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 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 diff --git a/backend/alembic/versions/a3f7c2e91b4d_drop_room_invites.py b/backend/alembic/versions/a3f7c2e91b4d_drop_room_invites.py new file mode 100644 index 0000000..c775c73 --- /dev/null +++ b/backend/alembic/versions/a3f7c2e91b4d_drop_room_invites.py @@ -0,0 +1,53 @@ +"""drop room_invites (replaced by direct add-to-room) + +Revision ID: a3f7c2e91b4d +Revises: 41139ce908df +Create Date: 2026-08-14 00:00:00.000000 + +""" +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 = 'a3f7c2e91b4d' +down_revision: Union[str, Sequence[str], None] = '41139ce908df' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Room invites are replaced by adding an existing user to a room + # directly (RoomMembership row + notification email, no accept step). + # The invite_status enum type stays -- site_invites still uses it. + op.drop_index(op.f('ix_room_invites_token'), table_name='room_invites') + op.drop_index(op.f('ix_room_invites_target_user_id'), table_name='room_invites') + op.drop_index(op.f('ix_room_invites_room_id'), table_name='room_invites') + op.drop_table('room_invites') + + +def downgrade() -> None: + """Downgrade schema.""" + op.create_table('room_invites', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('room_id', sa.Uuid(), nullable=False), + sa.Column('invited_by', sa.Uuid(), nullable=False), + sa.Column('token', sa.String(length=64), nullable=False), + sa.Column('target_user_id', sa.Uuid(), nullable=True), + sa.Column('target_email', sa.String(length=255), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('status', postgresql.ENUM('pending', 'accepted', 'revoked', name='invite_status', create_type=False), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint('target_user_id IS NOT NULL OR target_email IS NOT NULL', name='room_invites_target_required'), + sa.ForeignKeyConstraint(['invited_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ), + sa.ForeignKeyConstraint(['target_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_room_invites_room_id'), 'room_invites', ['room_id'], unique=False) + op.create_index(op.f('ix_room_invites_target_user_id'), 'room_invites', ['target_user_id'], unique=False) + op.create_index(op.f('ix_room_invites_token'), 'room_invites', ['token'], unique=True) diff --git a/backend/app/main.py b/backend/app/main.py index fa4f89f..f3365f7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,7 @@ from redis.asyncio import Redis from starlette.middleware.sessions import SessionMiddleware from app.config import settings -from app.routers import admin, auth, bots, health, invites, push, rooms, signup, users, webhooks +from app.routers import admin, auth, bots, health, push, rooms, signup, users, webhooks from app.ws.broadcaster import RoomBroadcaster from app.ws.chat import router as ws_router from app.ws.connection_manager import ConnectionManager @@ -75,7 +75,6 @@ def create_app() -> FastAPI: app.include_router(signup.router) app.include_router(rooms.router) app.include_router(users.router) - app.include_router(invites.router) app.include_router(push.router) app.include_router(admin.router) app.include_router(bots.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1457f92..8aa7884 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,7 +2,7 @@ from app.models.admin_audit_log import AdminAuditLog from app.models.api_token import ApiToken from app.models.base import Base from app.models.event_subscription import EventSubscription -from app.models.invite import InviteStatus, RoomInvite +from app.models.invite import InviteStatus from app.models.membership import RoomMembership, RoomRole from app.models.message import Message from app.models.message_image import MessageImage @@ -23,7 +23,6 @@ __all__ = [ "Message", "MessageImage", "MessageReaction", - "RoomInvite", "InviteStatus", "SiteInvite", "SmtpSettings", diff --git a/backend/app/models/invite.py b/backend/app/models/invite.py index 61d4c42..d209f86 100644 --- a/backend/app/models/invite.py +++ b/backend/app/models/invite.py @@ -1,53 +1,7 @@ import enum -import uuid -from datetime import datetime, timedelta, timezone - -from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, String, func -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.models.base import Base - -DEFAULT_INVITE_LIFETIME = timedelta(days=7) - - -def _default_expires_at() -> datetime: - return datetime.now(timezone.utc) + DEFAULT_INVITE_LIFETIME class InviteStatus(str, enum.Enum): pending = "pending" accepted = "accepted" revoked = "revoked" - - -class RoomInvite(Base): - __tablename__ = "room_invites" - __table_args__ = ( - CheckConstraint( - "target_user_id IS NOT NULL OR target_email IS NOT NULL", - name="room_invites_target_required", - ), - ) - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - room_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rooms.id"), index=True, nullable=False) - invited_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False) - token: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) - target_user_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("users.id"), index=True) - # Stored per the documented schema but not actionable yet: there's no - # email-delivery mechanism anywhere in the stack. Phase 2 only creates - # invites via target_user_id (existing users, looked up by username). - target_email: Mapped[str | None] = mapped_column(String(255)) - expires_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=_default_expires_at, nullable=False - ) - status: Mapped[InviteStatus] = mapped_column( - Enum(InviteStatus, name="invite_status"), default=InviteStatus.pending, nullable=False - ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - - room = relationship("Room") - inviter = relationship("User", foreign_keys=[invited_by]) - target_user = relationship("User", foreign_keys=[target_user_id]) diff --git a/backend/app/models/site_invite.py b/backend/app/models/site_invite.py index d56740f..5ed933b 100644 --- a/backend/app/models/site_invite.py +++ b/backend/app/models/site_invite.py @@ -16,7 +16,7 @@ def _default_expires_at() -> datetime: class SiteInvite(Base): """An admin-issued invite for someone with no account yet -- distinct - from RoomInvite, which targets an existing user for a specific room.""" + from adding an existing user directly to a room.""" __tablename__ = "site_invites" diff --git a/backend/app/routers/invites.py b/backend/app/routers/invites.py deleted file mode 100644 index 8e8b656..0000000 --- a/backend/app/routers/invites.py +++ /dev/null @@ -1,86 +0,0 @@ -import uuid - -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.dependencies import get_current_user -from app.models import User -from app.schemas.invite import InviteRead, MyInviteRead -from app.schemas.room import RoomMemberRead -from app.services.invite_service import ( - InviteExpiredError, - InviteNotFoundError, - InviteNotPendingError, - WrongInviteTargetError, - accept_invite, - decline_invite, - list_my_invites, -) - -router = APIRouter(prefix="/api/invites", tags=["invites"]) - - -@router.get("/mine", response_model=list[MyInviteRead]) -async def list_my_invites_endpoint( - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - invites = await list_my_invites(db, current_user.id) - return [ - MyInviteRead( - id=i.id, - room_id=i.room_id, - invited_by=i.invited_by, - target_user_id=i.target_user_id, - status=i.status, - expires_at=i.expires_at, - created_at=i.created_at, - room_name=i.room.name, - invited_by_username=i.inviter.username, - ) - for i in invites - ] - - -@router.post("/{invite_id}/accept", response_model=RoomMemberRead) -async def accept_invite_endpoint( - invite_id: uuid.UUID, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - try: - membership = await accept_invite(db, invite_id, current_user.id) - except InviteNotFoundError: - raise HTTPException(status_code=404, detail="Invite not found") - except WrongInviteTargetError: - raise HTTPException(status_code=403, detail="This invite is not addressed to you") - except InviteNotPendingError: - raise HTTPException(status_code=400, detail="Invite is no longer pending") - except InviteExpiredError: - raise HTTPException(status_code=400, detail="Invite has expired") - - return RoomMemberRead( - user_id=membership.user_id, - username=current_user.username, - display_name=current_user.display_name, - avatar_filename=current_user.avatar_filename, - role=membership.role, - joined_at=membership.joined_at, - ) - - -@router.post("/{invite_id}/decline", response_model=InviteRead) -async def decline_invite_endpoint( - invite_id: uuid.UUID, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - try: - return await decline_invite(db, invite_id, current_user.id) - except InviteNotFoundError: - raise HTTPException(status_code=404, detail="Invite not found") - except WrongInviteTargetError: - raise HTTPException(status_code=403, detail="This invite is not addressed to you") - except InviteNotPendingError: - raise HTTPException(status_code=400, detail="Invite is no longer pending") diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py index b38c490..b0fcf25 100644 --- a/backend/app/routers/rooms.py +++ b/backend/app/routers/rooms.py @@ -12,29 +12,19 @@ from app.dependencies import ( require_scope, ) from app.models import MessageImage, RoomRole, User -from app.schemas.invite import InviteCreate, InviteRead from app.schemas.message import MessageRead from app.schemas.message_image import MessageImageCreated from app.schemas.room import ( MyRoomItem, RoomCreate, RoomListItem, + RoomMemberAdd, RoomMemberRead, RoomMemberRoleUpdate, RoomRead, RoomUpdate, TransferOwnershipRequest, ) -from app.services.invite_service import ( - AlreadyMemberError, - DuplicateInviteError, - InviteNotFoundError, - InviteNotPendingError, - TargetUserNotFoundError, - create_invite, - list_room_invites, - revoke_invite, -) from app.schemas.webhook import ( EventSubscriptionCreate, EventSubscriptionCreated, @@ -44,6 +34,7 @@ from app.schemas.webhook import ( ) from app.services.message_service import get_reactions_for_messages, list_recent_messages from app.services.room_service import ( + AlreadyMemberError, CannotRemoveOwnerError, DuplicateRoomError, InsufficientRoleError, @@ -51,6 +42,8 @@ from app.services.room_service import ( OwnerMustTransferError, RoomIsPrivateError, RoomNotFoundError, + TargetUserNotFoundError, + add_member, change_member_role, create_room, delete_room, @@ -373,66 +366,32 @@ async def get_room_image_endpoint( ) -def _to_invite_read(invite) -> InviteRead: - return InviteRead( - id=invite.id, - room_id=invite.room_id, - invited_by=invite.invited_by, - target_user_id=invite.target_user_id, - target_username=invite.target_user.username if invite.target_user else None, - status=invite.status, - expires_at=invite.expires_at, - created_at=invite.created_at, - ) - - -@router.post("/{room_id}/invites", response_model=InviteRead, status_code=201) -async def create_invite_endpoint( +@router.post("/{room_id}/members", response_model=RoomMemberRead, status_code=201) +async def add_member_endpoint( room_id: uuid.UUID, - data: InviteCreate, + data: RoomMemberAdd, request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - await require_room_role(room_id, current_user, db, RoomRole.admin) try: - invite = await create_invite( - db, room_id, current_user.id, data.target_username, str(request.base_url) - ) + room = await get_room(db, room_id) + await require_room_role(room_id, current_user, db, RoomRole.admin) + membership = await add_member(db, room, data.user_id, str(request.base_url)) + except RoomNotFoundError: + raise HTTPException(status_code=404, detail="Room not found") except TargetUserNotFoundError: - raise HTTPException(status_code=404, detail="No user with that username") + raise HTTPException(status_code=404, detail="No user with that ID") except AlreadyMemberError: raise HTTPException(status_code=409, detail="That user is already a member") - except DuplicateInviteError: - raise HTTPException(status_code=409, detail="That user already has a pending invite") - return _to_invite_read(invite) - - -@router.get("/{room_id}/invites", response_model=list[InviteRead]) -async def list_room_invites_endpoint( - room_id: uuid.UUID, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - await require_room_role(room_id, current_user, db, RoomRole.admin) - invites = await list_room_invites(db, room_id) - return [_to_invite_read(i) for i in invites] - - -@router.delete("/{room_id}/invites/{invite_id}", status_code=204) -async def revoke_invite_endpoint( - room_id: uuid.UUID, - invite_id: uuid.UUID, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - await require_room_role(room_id, current_user, db, RoomRole.admin) - try: - await revoke_invite(db, room_id, invite_id) - except InviteNotFoundError: - raise HTTPException(status_code=404, detail="Invite not found") - except InviteNotPendingError: - raise HTTPException(status_code=400, detail="Invite is no longer pending") + return RoomMemberRead( + user_id=membership.user_id, + username=membership.user.username, + display_name=membership.user.display_name, + avatar_filename=membership.user.avatar_filename, + role=membership.role, + joined_at=membership.joined_at, + ) @router.post("/{room_id}/webhooks/incoming", response_model=WebhookIncomingRead, status_code=201) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 5cd0499..22e0b29 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -2,16 +2,31 @@ import uuid from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.dependencies import get_current_user from app.models import User +from app.schemas.user import UserDirectoryRead from app.storage import UPLOADS_DIR router = APIRouter(prefix="/api/users", tags=["users"]) +@router.get("", response_model=list[UserDirectoryRead]) +async def list_users_directory_endpoint( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(User) + .where(User.is_active.is_(True), User.is_bot.is_(False)) + .order_by(User.username) + ) + return list(result.scalars().all()) + + @router.get("/{user_id}/avatar") async def get_user_avatar_endpoint( user_id: uuid.UUID, diff --git a/backend/app/schemas/invite.py b/backend/app/schemas/invite.py deleted file mode 100644 index fdc56f4..0000000 --- a/backend/app/schemas/invite.py +++ /dev/null @@ -1,31 +0,0 @@ -import uuid -from datetime import datetime - -from pydantic import BaseModel, ConfigDict, Field - -from app.models import InviteStatus - - -class InviteCreate(BaseModel): - target_username: str = Field(min_length=1) - - -class InviteRead(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: uuid.UUID - room_id: uuid.UUID - invited_by: uuid.UUID - target_user_id: uuid.UUID | None - target_username: str | None = None - status: InviteStatus - expires_at: datetime - created_at: datetime - - -class MyInviteRead(InviteRead): - """InviteRead plus context the recipient can't otherwise resolve client-side -- - GET /api/invites/mine is for rooms the user isn't a member of yet.""" - - room_name: str - invited_by_username: str diff --git a/backend/app/schemas/room.py b/backend/app/schemas/room.py index 083d4cc..166efb6 100644 --- a/backend/app/schemas/room.py +++ b/backend/app/schemas/room.py @@ -45,6 +45,10 @@ class RoomMemberRead(BaseModel): joined_at: datetime +class RoomMemberAdd(BaseModel): + user_id: uuid.UUID + + class RoomMemberRoleUpdate(BaseModel): role: RoomRole diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 8e0d7aa..a6d3ba8 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -25,3 +25,17 @@ class UserRead(BaseModel): class ProfileUpdate(BaseModel): display_name: str | None = Field(default=None, max_length=50) + + +class UserDirectoryRead(BaseModel): + """Lightweight entry for user-picker UIs (room invites, admin ownership + transfer) -- same visibility level as an avatar: any authenticated user + can see this much about anyone (excludes bots, which aren't invited + through these flows).""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + username: str + display_name: str | None + avatar_filename: str | None diff --git a/backend/app/services/invite_service.py b/backend/app/services/invite_service.py deleted file mode 100644 index 831596a..0000000 --- a/backend/app/services/invite_service.py +++ /dev/null @@ -1,170 +0,0 @@ -import secrets -import uuid -from datetime import datetime, timezone - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from app.models import InviteStatus, Room, RoomInvite, RoomMembership, RoomRole, User -from app.services.email_service import send_email - - -class TargetUserNotFoundError(Exception): - pass - - -class AlreadyMemberError(Exception): - pass - - -class DuplicateInviteError(Exception): - pass - - -class InviteNotFoundError(Exception): - pass - - -class WrongInviteTargetError(Exception): - pass - - -class InviteNotPendingError(Exception): - pass - - -class InviteExpiredError(Exception): - pass - - -async def create_invite( - db: AsyncSession, - room_id: uuid.UUID, - invited_by: uuid.UUID, - target_username: str, - base_url: str, -) -> RoomInvite: - result = await db.execute(select(User).where(User.username == target_username)) - target = result.scalar_one_or_none() - if target is None: - raise TargetUserNotFoundError() - - existing_membership = await db.execute( - select(RoomMembership).where( - RoomMembership.room_id == room_id, RoomMembership.user_id == target.id - ) - ) - if existing_membership.scalar_one_or_none() is not None: - raise AlreadyMemberError() - - existing_invite = await db.execute( - select(RoomInvite).where( - RoomInvite.room_id == room_id, - RoomInvite.target_user_id == target.id, - RoomInvite.status == InviteStatus.pending, - ) - ) - if existing_invite.scalar_one_or_none() is not None: - raise DuplicateInviteError() - - invite = RoomInvite( - room_id=room_id, - invited_by=invited_by, - token=secrets.token_urlsafe(32), - target_user_id=target.id, - ) - db.add(invite) - await db.commit() - await db.refresh(invite) - invite.target_user = target - - room = await db.get(Room, room_id) - await send_email( - db, - target.email, - f"You've been invited to #{room.name}" if room else "You've been invited to a room", - f"You've been invited to join a room on KeepItTalking.\n\n" - f"Open the app to accept: {base_url.rstrip('/')}", - ) - return invite - - -async def list_room_invites(db: AsyncSession, room_id: uuid.UUID) -> list[RoomInvite]: - result = await db.execute( - select(RoomInvite) - .where(RoomInvite.room_id == room_id, RoomInvite.status == InviteStatus.pending) - .options(selectinload(RoomInvite.target_user)) - ) - return list(result.scalars().all()) - - -async def list_my_invites(db: AsyncSession, user_id: uuid.UUID) -> list[RoomInvite]: - result = await db.execute( - select(RoomInvite) - .where( - RoomInvite.target_user_id == user_id, - RoomInvite.status == InviteStatus.pending, - RoomInvite.expires_at > datetime.now(timezone.utc), - ) - .options(selectinload(RoomInvite.room), selectinload(RoomInvite.inviter)) - ) - return list(result.scalars().all()) - - -async def _get_invite(db: AsyncSession, invite_id: uuid.UUID) -> RoomInvite: - invite = await db.get(RoomInvite, invite_id) - if invite is None: - raise InviteNotFoundError() - return invite - - -async def accept_invite(db: AsyncSession, invite_id: uuid.UUID, user_id: uuid.UUID) -> RoomMembership: - invite = await _get_invite(db, invite_id) - if invite.target_user_id != user_id: - raise WrongInviteTargetError() - if invite.status != InviteStatus.pending: - raise InviteNotPendingError() - if invite.expires_at <= datetime.now(timezone.utc): - raise InviteExpiredError() - - result = await db.execute( - select(RoomMembership).where( - RoomMembership.room_id == invite.room_id, RoomMembership.user_id == user_id - ) - ) - membership = result.scalar_one_or_none() - if membership is None: - membership = RoomMembership(room_id=invite.room_id, user_id=user_id, role=RoomRole.member) - db.add(membership) - - invite.status = InviteStatus.accepted - await db.commit() - await db.refresh(membership) - return membership - - -async def decline_invite(db: AsyncSession, invite_id: uuid.UUID, user_id: uuid.UUID) -> RoomInvite: - invite = await _get_invite(db, invite_id) - if invite.target_user_id != user_id: - raise WrongInviteTargetError() - if invite.status != InviteStatus.pending: - raise InviteNotPendingError() - - invite.status = InviteStatus.revoked - await db.commit() - await db.refresh(invite) - return invite - - -async def revoke_invite(db: AsyncSession, room_id: uuid.UUID, invite_id: uuid.UUID) -> RoomInvite: - invite = await _get_invite(db, invite_id) - if invite.room_id != room_id: - raise InviteNotFoundError() - if invite.status != InviteStatus.pending: - raise InviteNotPendingError() - - invite.status = InviteStatus.revoked - await db.commit() - await db.refresh(invite) - return invite diff --git a/backend/app/services/room_service.py b/backend/app/services/room_service.py index 1b61d3c..3719abb 100644 --- a/backend/app/services/room_service.py +++ b/backend/app/services/room_service.py @@ -5,8 +5,9 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.models import Message, Room, RoomInvite, RoomMembership, RoomRole +from app.models import Message, Room, RoomMembership, RoomRole, User from app.schemas.room import RoomCreate, RoomUpdate +from app.services.email_service import send_email class DuplicateRoomError(Exception): @@ -37,6 +38,14 @@ class OwnerMustTransferError(Exception): pass +class TargetUserNotFoundError(Exception): + pass + + +class AlreadyMemberError(Exception): + pass + + async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room: room = Room( name=data.name, @@ -108,6 +117,41 @@ async def join_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> return membership +async def add_member( + db: AsyncSession, room: Room, target_user_id: uuid.UUID, base_url: str +) -> RoomMembership: + target = await db.get(User, target_user_id) + if target is None: + raise TargetUserNotFoundError() + + existing = await db.execute( + select(RoomMembership).where( + RoomMembership.room_id == room.id, RoomMembership.user_id == target_user_id + ) + ) + if existing.scalar_one_or_none() is not None: + raise AlreadyMemberError() + + membership = RoomMembership(room_id=room.id, user_id=target_user_id, role=RoomRole.member) + db.add(membership) + await db.commit() + + await send_email( + db, + target.email, + f"You've been added to #{room.name}", + f"You've been added to the #{room.name} room on KeepItTalking.\n\n" + f"Open the app: {base_url.rstrip('/')}", + ) + + result = await db.execute( + select(RoomMembership) + .where(RoomMembership.room_id == room.id, RoomMembership.user_id == target_user_id) + .options(selectinload(RoomMembership.user)) + ) + return result.scalar_one() + + async def update_room(db: AsyncSession, room: Room, data: RoomUpdate) -> Room: if data.name is not None: room.name = data.name @@ -126,7 +170,6 @@ async def delete_room(db: AsyncSession, room: Room) -> None: # Explicit deletes rather than relying on ORM cascade + eager-loading — # simpler and more predictable in async code. await db.execute(delete(Message).where(Message.room_id == room.id)) - await db.execute(delete(RoomInvite).where(RoomInvite.room_id == room.id)) await db.execute(delete(RoomMembership).where(RoomMembership.room_id == room.id)) await db.delete(room) await db.commit() diff --git a/backend/tests/test_invites.py b/backend/tests/test_invites.py deleted file mode 100644 index e71a45f..0000000 --- a/backend/tests/test_invites.py +++ /dev/null @@ -1,233 +0,0 @@ -import uuid -from datetime import datetime, timedelta, timezone - -from app.models import RoomInvite, 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 _configure_smtp(client): - resp = await client.put( - "/api/admin/settings/smtp", - json={ - "host": "smtp.example.com", - "port": 587, - "from_address": "noreply@example.com", - }, - ) - assert resp.status_code == 200, resp.text - - -async def _create_private_room(client, name="secret"): - resp = await client.post("/api/rooms", json={"name": name, "is_private": True}) - assert resp.status_code == 201, resp.text - return resp.json() - - -async def test_create_invite_requires_admin(client, db_session): - await register_and_login(client, db_session, username="alice") - room = await _create_private_room(client) - - await client.post("/api/auth/logout") - await register_and_login(client, db_session, username="bob") - await register_and_login(client, db_session, username="carol") - - # bob has no membership in the room at all, so he's blocked by the - # membership check before role is even considered. - resp = await client.post( - f"/api/rooms/{room['id']}/invites", json={"target_username": "carol"} - ) - assert resp.status_code == 403 - - -async def test_invite_unknown_username_404(client, db_session): - await register_and_login(client, db_session, username="alice") - room = await _create_private_room(client) - - resp = await client.post( - f"/api/rooms/{room['id']}/invites", json={"target_username": "nobody"} - ) - assert resp.status_code == 404 - - -async def test_invite_accept_flow(client, db_session): - await register_and_login(client, db_session, username="alice") - room = await _create_private_room(client) - await register_and_login(client, db_session, username="bob") # seed bob's account only - - await login_as(client, "alice") - resp = await client.post( - f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"} - ) - assert resp.status_code == 201 - invite = resp.json() - assert invite["status"] == "pending" - assert invite["target_username"] == "bob" - - resp = await client.get(f"/api/rooms/{room['id']}/invites") - assert resp.status_code == 200 - assert resp.json()[0]["target_username"] == "bob" - - await client.post("/api/auth/logout") - await login_as(client, "bob") - - resp = await client.get("/api/invites/mine") - assert resp.status_code == 200 - mine = resp.json() - assert len(mine) == 1 - assert mine[0]["id"] == invite["id"] - assert mine[0]["room_name"] == room["name"] - assert mine[0]["invited_by_username"] == "alice" - - resp = await client.post(f"/api/invites/{invite['id']}/accept") - assert resp.status_code == 200 - assert resp.json()["role"] == "member" - - resp = await client.get(f"/api/rooms/{room['id']}/messages") - assert resp.status_code == 200 # now a member - - resp = await client.get("/api/rooms/mine") - assert any(r["name"] == room["name"] for r in resp.json()) - - -async def test_accept_invite_wrong_user_403(client, db_session): - await register_and_login(client, db_session, username="alice") - room = await _create_private_room(client) - await register_and_login(client, db_session, username="bob") - await login_as(client, "alice") - invite = ( - await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}) - ).json() - - await client.post("/api/auth/logout") - await register_and_login(client, db_session, username="carol") - resp = await client.post(f"/api/invites/{invite['id']}/accept") - assert resp.status_code == 403 - - -async def test_decline_invite(client, db_session): - await register_and_login(client, db_session, username="alice") - room = await _create_private_room(client) - await register_and_login(client, db_session, username="bob") - await login_as(client, "alice") - invite = ( - await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}) - ).json() - - await client.post("/api/auth/logout") - await login_as(client, "bob") - resp = await client.post(f"/api/invites/{invite['id']}/decline") - assert resp.status_code == 200 - assert resp.json()["status"] == "revoked" - - resp = await client.post(f"/api/invites/{invite['id']}/accept") - assert resp.status_code == 400 # no longer pending - - -async def test_revoke_invite(client, db_session): - await register_and_login(client, db_session, username="alice") - room = await _create_private_room(client) - await register_and_login(client, db_session, username="bob") - await login_as(client, "alice") - invite = ( - await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}) - ).json() - - resp = await client.delete(f"/api/rooms/{room['id']}/invites/{invite['id']}") - assert resp.status_code == 204 - - await client.post("/api/auth/logout") - await login_as(client, "bob") - resp = await client.post(f"/api/invites/{invite['id']}/accept") - assert resp.status_code == 400 - - -async def test_duplicate_pending_invite_rejected(client, db_session): - await register_and_login(client, db_session, username="alice") - room = await _create_private_room(client) - await register_and_login(client, db_session, username="bob") - await login_as(client, "alice") - - resp1 = await client.post( - f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"} - ) - assert resp1.status_code == 201 - resp2 = await client.post( - f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"} - ) - assert resp2.status_code == 409 - - -async def test_invite_already_member_rejected(client, db_session): - await register_and_login(client, db_session, username="alice") - room = await _create_private_room(client, name="open-ish") - resp = await client.post( - f"/api/rooms/{room['id']}/invites", json={"target_username": "alice"} - ) - assert resp.status_code == 409 - - -async def test_expired_invite_rejected_on_accept(client, db_session): - await register_and_login(client, db_session, username="alice") - room = await _create_private_room(client) - await register_and_login(client, db_session, username="bob") - await login_as(client, "alice") - invite = ( - await client.post(f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}) - ).json() - - db_invite = await db_session.get(RoomInvite, uuid.UUID(invite["id"])) - db_invite.expires_at = datetime.now(timezone.utc) - timedelta(days=1) - await db_session.commit() - - await client.post("/api/auth/logout") - await login_as(client, "bob") - resp = await client.post(f"/api/invites/{invite['id']}/accept") - assert resp.status_code == 400 - - -async def test_create_invite_sends_email_to_target(client, db_session, monkeypatch): - calls = [] - - async def fake_send(message, **kwargs): - calls.append(kwargs) - - monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send) - - alice = await register_and_login(client, db_session, username="alice") - await _make_admin(db_session, alice["id"]) - await _configure_smtp(client) - room = await _create_private_room(client) - await register_and_login(client, db_session, username="bob") - await login_as(client, "alice") - - resp = await client.post( - f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"} - ) - assert resp.status_code == 201 - assert len(calls) == 1 - assert calls[0]["hostname"] == "smtp.example.com" - - -async def test_create_invite_succeeds_even_if_email_delivery_fails(client, db_session, monkeypatch): - async def fake_send(message, **kwargs): - raise ConnectionRefusedError("boom") - - monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send) - - alice = await register_and_login(client, db_session, username="alice") - await _make_admin(db_session, alice["id"]) - await _configure_smtp(client) - room = await _create_private_room(client) - await register_and_login(client, db_session, username="bob") - await login_as(client, "alice") - - resp = await client.post( - f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"} - ) - assert resp.status_code == 201 diff --git a/backend/tests/test_rooms.py b/backend/tests/test_rooms.py index 5013b95..817c7d0 100644 --- a/backend/tests/test_rooms.py +++ b/backend/tests/test_rooms.py @@ -245,6 +245,82 @@ async def test_change_member_role_owner_only(client, db_session): assert resp.status_code == 403 # bob is a plain member, not owner +def _fake_send_email(monkeypatch): + calls = [] + + async def fake(db, to, subject, body): + calls.append({"to": to, "subject": subject, "body": body}) + + monkeypatch.setattr("app.services.room_service.send_email", fake) + return calls + + +async def test_add_member_directly(client, db_session, monkeypatch): + calls = _fake_send_email(monkeypatch) + await register_and_login(client, db_session, username="alice") + room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"] + + await client.post("/api/auth/logout") + bob = await register_and_login(client, db_session, username="bob") + + await client.post("/api/auth/logout") + await login_as(client, "alice") + resp = await client.post(f"/api/rooms/{room_id}/members", json={"user_id": bob["id"]}) + assert resp.status_code == 201, resp.text + assert resp.json()["username"] == "bob" + assert resp.json()["role"] == "member" + + result = await db_session.execute( + select(RoomMembership).where( + RoomMembership.room_id == uuid.UUID(room_id), RoomMembership.user_id == uuid.UUID(bob["id"]) + ) + ) + assert result.scalar_one().role == RoomRole.member + + assert len(calls) == 1 + assert calls[0]["to"] == bob["email"] + assert "added" in calls[0]["subject"].lower() + + +async def test_add_member_requires_admin_role(client, db_session, monkeypatch): + _fake_send_email(monkeypatch) + await register_and_login(client, db_session, username="alice") + room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"] + + await client.post("/api/auth/logout") + bob = await register_and_login(client, db_session, username="bob") + await client.post(f"/api/rooms/{room_id}/join") + + carol = await register_and_login(client, db_session, username="carol") + + resp = await client.post(f"/api/rooms/{room_id}/members", json={"user_id": carol["id"]}) + assert resp.status_code == 403 + + +async def test_add_member_already_member_conflict(client, db_session, monkeypatch): + _fake_send_email(monkeypatch) + await register_and_login(client, db_session, username="alice") + room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"] + + await client.post("/api/auth/logout") + bob = await register_and_login(client, db_session, username="bob") + await client.post(f"/api/rooms/{room_id}/join") + + await client.post("/api/auth/logout") + await login_as(client, "alice") + resp = await client.post(f"/api/rooms/{room_id}/members", json={"user_id": bob["id"]}) + assert resp.status_code == 409 + + +async def test_add_member_unknown_user_404(client, db_session, monkeypatch): + _fake_send_email(monkeypatch) + await register_and_login(client, db_session, username="alice") + room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"] + + resp = await client.post(f"/api/rooms/{room_id}/members", json={"user_id": str(uuid.uuid4())}) + assert resp.status_code == 404 + + async def test_list_room_members(client, db_session): await register_and_login(client, db_session, username="alice") room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"] diff --git a/backend/tests/test_user_directory.py b/backend/tests/test_user_directory.py new file mode 100644 index 0000000..c2894e9 --- /dev/null +++ b/backend/tests/test_user_directory.py @@ -0,0 +1,58 @@ +import uuid + +from app.models import User +from app.services.bot_service import create_bot +from tests.conftest import register_and_login + + +def _unique(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +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 test_user_directory_requires_auth(client): + resp = await client.get("/api/users") + assert resp.status_code == 401 + + +async def test_user_directory_lists_active_users(client, db_session): + alice = await register_and_login(client, db_session, username=_unique("alice")) + await register_and_login(client, db_session, username=_unique("bob")) + + resp = await client.get("/api/users") + assert resp.status_code == 200 + usernames = {u["username"] for u in resp.json()} + assert alice["username"] in usernames + entry = next(u for u in resp.json() if u["id"] == alice["id"]) + assert entry["display_name"] is None + assert entry["avatar_filename"] is None + + +async def test_user_directory_excludes_bots(client, db_session): + admin = await register_and_login(client, db_session, username=_unique("admin")) + await _make_admin(db_session, admin["id"]) + admin_user = await db_session.get(User, uuid.UUID(admin["id"])) + bot_username = _unique("bot") + await create_bot(db_session, admin_user, bot_username) + + resp = await client.get("/api/users") + usernames = {u["username"] for u in resp.json()} + assert bot_username not in usernames + + +async def test_user_directory_excludes_deactivated_users(client, db_session): + admin = await register_and_login(client, db_session, username=_unique("admin")) + await _make_admin(db_session, admin["id"]) + bob = await register_and_login(client, db_session, username=_unique("bob")) + + await client.post("/api/auth/login", json={"username_or_email": admin["username"], "password": "password123"}) + await client.post(f"/api/admin/users/{bob['id']}/deactivate") + + resp = await client.get("/api/users") + usernames = {u["username"] for u in resp.json()} + assert bob["username"] not in usernames diff --git a/frontend/src/api/invites.ts b/frontend/src/api/invites.ts deleted file mode 100644 index fde8513..0000000 --- a/frontend/src/api/invites.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { apiFetch } from './client' -import type { Invite, MyInvite, RoomMember } from '../types' - -export function createInvite(roomId: string, targetUsername: string): Promise { - return apiFetch(`/api/rooms/${roomId}/invites`, { - method: 'POST', - body: JSON.stringify({ target_username: targetUsername }), - }) -} - -export function listRoomInvites(roomId: string): Promise { - return apiFetch(`/api/rooms/${roomId}/invites`) -} - -export function revokeInvite(roomId: string, inviteId: string): Promise { - return apiFetch(`/api/rooms/${roomId}/invites/${inviteId}`, { method: 'DELETE' }) -} - -export function listMyInvites(): Promise { - return apiFetch('/api/invites/mine') -} - -export function acceptInvite(inviteId: string): Promise { - return apiFetch(`/api/invites/${inviteId}/accept`, { method: 'POST' }) -} - -export function declineInvite(inviteId: string): Promise { - return apiFetch(`/api/invites/${inviteId}/decline`, { method: 'POST' }) -} diff --git a/frontend/src/api/rooms.ts b/frontend/src/api/rooms.ts index 0ce03e0..dbbd400 100644 --- a/frontend/src/api/rooms.ts +++ b/frontend/src/api/rooms.ts @@ -46,6 +46,13 @@ export function listRoomMembers(roomId: string): Promise { return apiFetch(`/api/rooms/${roomId}/members`) } +export function addRoomMember(roomId: string, userId: string): Promise { + return apiFetch(`/api/rooms/${roomId}/members`, { + method: 'POST', + body: JSON.stringify({ user_id: userId }), + }) +} + export function removeMember(roomId: string, userId: string): Promise { return apiFetch(`/api/rooms/${roomId}/members/${userId}`, { method: 'DELETE' }) } diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index dd0df15..440c4a7 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -1,3 +1,10 @@ +import { apiFetch } from './client' +import type { UserDirectoryEntry } from '../types' + export function getUserAvatarUrl(userId: string, avatarFilename?: string | null): string { return `/api/users/${userId}/avatar${avatarFilename ? `?v=${avatarFilename}` : ''}` } + +export function listUserDirectory(): Promise { + return apiFetch('/api/users') +} diff --git a/frontend/src/components/InvitesModal.tsx b/frontend/src/components/InvitesModal.tsx deleted file mode 100644 index e4f3781..0000000 --- a/frontend/src/components/InvitesModal.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useEffect, useState } from 'react' -import { acceptInvite, declineInvite, listMyInvites } from '../api/invites' -import { ApiError } from '../api/client' -import type { MyInvite } from '../types' -import './Modal.css' - -interface InvitesModalProps { - onClose: () => void - onAccepted: (roomId: string) => void - onInvitesChanged: (count: number) => void -} - -export function InvitesModal({ onClose, onAccepted, onInvitesChanged }: InvitesModalProps) { - const [invites, setInvites] = useState([]) - const [loading, setLoading] = useState(true) - const [busyId, setBusyId] = useState(null) - const [error, setError] = useState(null) - - async function refresh() { - const list = await listMyInvites() - setInvites(list) - onInvitesChanged(list.length) - } - - useEffect(() => { - refresh() - .catch((err) => setError(err instanceof ApiError ? err.message : String(err))) - .finally(() => setLoading(false)) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - async function handleAccept(invite: MyInvite) { - setBusyId(invite.id) - setError(null) - try { - await acceptInvite(invite.id) - await refresh() - onAccepted(invite.room_id) - } catch (err) { - setError(err instanceof ApiError ? err.message : String(err)) - } finally { - setBusyId(null) - } - } - - async function handleDecline(invite: MyInvite) { - setBusyId(invite.id) - setError(null) - try { - await declineInvite(invite.id) - await refresh() - } catch (err) { - setError(err instanceof ApiError ? err.message : String(err)) - } finally { - setBusyId(null) - } - } - - return ( -
-
e.stopPropagation()}> -
-

Your invites

- -
- - {error &&

{error}

} - - {loading ? ( -

Loading...

- ) : invites.length === 0 ? ( -

No pending invites.

- ) : ( - invites.map((invite) => ( -
-
-
{invite.room_name}
-
Invited by {invite.invited_by_username}
-
-
- - -
-
- )) - )} - -
- -
-
-
- ) -} diff --git a/frontend/src/components/RoomInfoPanel.css b/frontend/src/components/RoomInfoPanel.css index 1a47bfe..2085480 100644 --- a/frontend/src/components/RoomInfoPanel.css +++ b/frontend/src/components/RoomInfoPanel.css @@ -1,14 +1,33 @@ .room-info-panel { - width: 260px; + position: relative; + flex: none; min-width: 260px; + max-width: 480px; border-left: 1px solid var(--ds-border); background: var(--ds-void-2); padding: var(--sp-4); overflow-y: auto; + overflow-x: hidden; display: flex; flex-direction: column; } +.room-info-resize-handle { + position: absolute; + top: 0; + bottom: 0; + left: -3px; + width: 6px; + cursor: col-resize; + z-index: 5; + touch-action: none; +} + +.room-info-resize-handle:hover, +.room-info-resize-handle:active { + background: color-mix(in srgb, var(--ds-accent) 40%, transparent); +} + .room-info-header { display: flex; justify-content: space-between; @@ -81,26 +100,27 @@ white-space: nowrap; } -.room-info-member-actions { - display: flex; - gap: 6px; - width: 100%; - padding-left: 32px; -} - -.room-info-member-actions button { - background: transparent; - border: 1px solid var(--ds-border); - color: var(--ds-muted); - font-size: 0.7rem; - padding: 3px 7px; - border-radius: 6px; +.room-info-role-select { + appearance: none; + -webkit-appearance: none; + font-family: inherit; cursor: pointer; + padding-right: 20px; + background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: calc(100% - 11px) 55%, calc(100% - 6px) 55%; + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; + opacity: 0.85; } -.room-info-member-actions button:hover { - color: var(--ds-text); - border-color: var(--ds-accent); +.room-info-role-select:hover:not(:disabled) { + opacity: 1; +} + +.room-info-role-select:disabled { + cursor: not-allowed; + opacity: 0.5; } .role-badge { @@ -135,6 +155,7 @@ .room-info-invite-form { display: flex; gap: var(--sp-2); + flex-wrap: wrap; } .room-info-invite-form input { @@ -147,18 +168,6 @@ font-size: 0.84rem; } -.room-info-pending { - margin-top: var(--sp-2); -} - -.room-info-pending-row { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 0.82rem; - color: var(--ds-text); - padding: 5px 0; -} .room-info-error { color: var(--ds-danger); diff --git a/frontend/src/components/RoomInfoPanel.tsx b/frontend/src/components/RoomInfoPanel.tsx index c9b84dc..0bb7b81 100644 --- a/frontend/src/components/RoomInfoPanel.tsx +++ b/frontend/src/components/RoomInfoPanel.tsx @@ -1,8 +1,8 @@ import { useEffect, useState, type FormEvent } from 'react' import { ApiError } from '../api/client' -import { createInvite, listRoomInvites, revokeInvite } from '../api/invites' -import { getUserAvatarUrl } from '../api/users' +import { getUserAvatarUrl, listUserDirectory } from '../api/users' import { + addRoomMember, changeMemberRole, deleteRoom, leaveRoom, @@ -19,17 +19,19 @@ import { revokeIncomingWebhook, } from '../api/webhooks' import { useAuth } from '../context/AuthContext' +import { useResizableWidth } from '../hooks/useResizableWidth' import type { EventSubscription, EventType, - Invite, MyRoomItem, RoomMember, RoomRole, + UserDirectoryEntry, WebhookIncoming, } from '../types' import { RoomAvatar } from './RoomAvatar' import { UserAvatar } from './UserAvatar' +import { UserPicker } from './UserPicker' import './RoomInfoPanel.css' const EVENT_TYPES: EventType[] = ['message.created', 'message.updated'] @@ -55,10 +57,15 @@ export function RoomInfoPanel({ }: RoomInfoPanelProps) { const { user } = useAuth() const myRole = room.role + const { width, startResize } = useResizableWidth({ + storageKey: 'room-info-panel-width', + defaultWidth: 260, + min: 260, + max: 480, + }) - const [inviteUsername, setInviteUsername] = useState('') const [inviteError, setInviteError] = useState(null) - const [pendingInvites, setPendingInvites] = useState([]) + const [directoryUsers, setDirectoryUsers] = useState([]) const [busyUserId, setBusyUserId] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) const [nameDraft, setNameDraft] = useState(room.name) @@ -80,35 +87,26 @@ export function RoomInfoPanel({ setNameDraft(room.name) setDescDraft(room.description ?? '') if (canManage) { - listRoomInvites(room.id).then(setPendingInvites).catch(() => setPendingInvites([])) listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([])) listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([])) + listUserDirectory().then(setDirectoryUsers).catch(() => setDirectoryUsers([])) } else { - setPendingInvites([]) setIncomingWebhooks([]) setEventSubscriptions([]) + setDirectoryUsers([]) } }, [room.id, room.name, room.description, canManage]) - async function handleInvite(e: FormEvent) { - e.preventDefault() - const username = inviteUsername.trim() - if (!username) return + async function handleAddMember(target: UserDirectoryEntry) { setInviteError(null) try { - await createInvite(room.id, username) - setInviteUsername('') - setPendingInvites(await listRoomInvites(room.id)) + await addRoomMember(room.id, target.id) + onMembersChanged() } catch (err) { setInviteError(err instanceof ApiError ? err.message : String(err)) } } - async function handleRevoke(inviteId: string) { - await revokeInvite(room.id, inviteId) - setPendingInvites(await listRoomInvites(room.id)) - } - async function handleCreateWebhook(e: FormEvent) { e.preventDefault() setIntegrationsError(null) @@ -185,6 +183,29 @@ export function RoomInfoPanel({ } } + function memberActions(m: RoomMember): { value: string; label: string }[] { + if (m.user_id === user?.id) return [] + if (myRole === 'owner') { + const actions: { value: string; label: string }[] = [] + if (m.role === 'member') actions.push({ value: 'promote', label: 'Promote to admin' }) + if (m.role === 'admin') actions.push({ value: 'demote', label: 'Demote to member' }) + actions.push({ value: 'transfer', label: 'Make owner' }) + actions.push({ value: 'remove', label: 'Remove from room' }) + return actions + } + if (myRole === 'admin' && m.role === 'member') { + return [{ value: 'remove', label: 'Remove from room' }] + } + return [] + } + + function handleMemberAction(userId: string, action: string) { + if (action === 'promote') handleRoleChange(userId, 'admin') + else if (action === 'demote') handleRoleChange(userId, 'member') + else if (action === 'transfer') handleTransfer(userId) + else if (action === 'remove') handleRemove(userId) + } + async function handleLeave() { if (myRole === 'owner') return if (!confirm(`Leave #${room.name}?`)) return @@ -210,7 +231,8 @@ export function RoomInfoPanel({ } return ( -