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.
This commit is contained in:
2026-08-14 20:40:37 -06:00
parent b724f8a33b
commit 91589ee647
32 changed files with 735 additions and 1052 deletions
+1 -1
View File
@@ -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 # 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 — 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 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 env file edit needed. Skipped silently (logged, not an error) until an
+40 -38
View File
@@ -1,16 +1,16 @@
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, emoji & reactions, user profiles, site invites & email) # 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 FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
CRUD (open and private), room roles (owner/admin/member) and invites, a CRUD (open and private), room roles (owner/admin/member) and direct
WebSocket chat endpoint that fans out across multiple app-server instances membership management, a WebSocket chat endpoint that fans out across
via Redis pub/sub, Web Push notifications for offline room members, a multiple app-server instances via Redis pub/sub, Web Push notifications for
site-admin portal (user/room/bot management + an audit log), a bot/ offline room members, a site-admin portal (user/room/bot management + an
extension layer (scoped API tokens, live bot WebSocket access, incoming and audit log), a bot/extension layer (scoped API tokens, live bot WebSocket
outgoing webhooks, message editing), image uploads in chat messages, emoji access, incoming and outgoing webhooks, message editing), image uploads in
reactions on messages, self-service user profiles (display name, avatar), chat messages, emoji reactions on messages, self-service user profiles
and admin-issued email invites for new accounts plus email notifications (display name, avatar), and admin-issued email invites for new accounts
for room invites. See `../ARCHITECTURE.md` for the full system design and plus email notifications when a user is added to a room. See
the phased build plan. `../ARCHITECTURE.md` for the full system design and the phased build plan.
This is an **invite-only site**: there is no public registration endpoint. This is an **invite-only site**: there is no public registration endpoint.
Accounts are created by an operator on the app server — see step 4 below. 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` cli.py `python -m app.cli create-user` / `generate-vapid-keys`
models/ SQLAlchemy models (users, rooms, room_memberships, models/ SQLAlchemy models (users, rooms, room_memberships,
messages, message_images, message_reactions, messages, message_images, message_reactions,
room_invites, site_invites, smtp_settings, site_invites, smtp_settings,
push_subscriptions, admin_audit_log, api_tokens, push_subscriptions, admin_audit_log, api_tokens,
webhooks_incoming, event_subscriptions) webhooks_incoming, event_subscriptions)
schemas/ Pydantic request/response models schemas/ Pydantic request/response models
routers/ auth, rooms, users, invites, signup, push, admin, routers/ auth, rooms, users, signup, push, admin,
bots, webhooks, health bots, webhooks, health
services/ business logic called by routers services/ business logic called by routers
ws/ connection_manager (local sockets), presence + ws/ connection_manager (local sockets), presence +
@@ -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 on. An expired/invalid subscription (pywebpush 404/410) is deleted
automatically. 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 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 `private` (`is_private: true` at creation — joinable only by being added).
roles are `owner` > `admin` > `member`: Room roles are `owner` > `admin` > `member`:
- **member**: post messages, leave the room - **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 - **owner**: everything admin can, plus delete the room, remove admins, change
member roles, and transfer ownership member roles, and transfer ownership
Invite flow: an admin+ calls `POST /api/rooms/{id}/invites` with an existing Adding to a private room: an admin+ calls `POST /api/rooms/{id}/members` with
`target_username`; the invited user sees it via `GET /api/invites/mine` and an existing user's `user_id` — this adds them straight to
calls `POST /api/invites/{id}/accept` (or `/decline`). `GET /api/rooms/mine` `room_memberships` (no accept/decline step) and fires a "you've been added"
lists every room (open + private) the current user belongs to, alongside notification email (see Site invites & email below; silently skipped if
their role. 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 ## Image uploads
@@ -423,10 +427,10 @@ change after the fact.
## Site invites & email ## Site invites & email
Two related gaps closed together: creating a new account was CLI-only, and 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 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 the Admin portal; being added directly to a room (see Room roles and
send an email. membership above) sends a "you've been added" email too.
**Email sending** (`app/services/email_service.py`, using `aiosmtplib`): **Email sending** (`app/services/email_service.py`, using `aiosmtplib`):
`send_email(db, to, subject, body)` is the fire-and-forget path used by `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`). 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`) — **Site invites** (`app/models/site_invite.py`, `app/services/site_invite_service.py`) —
distinct from `RoomInvite` (existing user, specific room): this targets an distinct from adding an existing user to a room: this targets an email
email address for the site, no room involved. The raw token exists only in address for the site, no room involved. The raw token exists only in the
the email link, stored hashed (`security.hash_token`, the same convention email link, stored hashed (`security.hash_token`, the same convention API
API tokens use — it's a bearer secret looked up by itself, not tokens use — it's a bearer secret looked up by itself). `POST /api/signup`
`RoomInvite.token`'s current unhashed/unused column). `POST /api/signup`
(`app/routers/signup.py`) is the first genuinely public, (`app/routers/signup.py`) is the first genuinely public,
unauthenticated endpoint in this app that creates a `User` row — it calls unauthenticated endpoint in this app that creates a `User` row — it calls
the existing `auth_service.register_user` directly for identical 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 expiring token is the actual protection, inheriting the same "no rate
limiting on human/bot traffic" gap already documented below, not a new one. limiting on human/bot traffic" gap already documented below, not a new one.
**Room-invite email**: `invite_service.create_invite` sends one email to **Room-membership email**: `room_service.add_member` sends one email to
the target user after creating the `RoomInvite`, using the live request's the target user after creating the `RoomMembership`, using the live
`base_url` for the link — no new "public URL" config needed. request's `base_url` for the link — no new "public URL" config needed.
Scope cuts: no outgoing-webhook event type for these (matching image Scope cuts: no outgoing-webhook event type for these (matching image
uploads/reactions), no resend for a site invite (revoke + re-invite covers uploads/reactions), no resend for a site invite (revoke + re-invite covers
@@ -475,18 +478,17 @@ it), no HTML email templates.
## Notes / scope decisions ## Notes / scope decisions
- Invite-only site registration: no `POST /api/auth/register`. Accounts are - 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 provisioned with `python -m app.cli create-user` (see step 4 above), or via
separate from *room* invites above — site accounts vs. room membership. a site invite (see Site invites & email below). This is separate from
- Room invites are by **username only**`room_invites.target_email` exists adding an existing user to a private room — site accounts vs. room
in the schema (per `ARCHITECTURE.md`) but is unused, since there's no membership.
email-delivery mechanism anywhere in the stack yet.
- Sessions are signed cookies (Starlette `SessionMiddleware`), not a server-side - Sessions are signed cookies (Starlette `SessionMiddleware`), not a server-side
session table — see `ARCHITECTURE.md`'s rationale (simplest way to carry auth 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- 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. 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 - 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. 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. (`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 - `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 flat newest-first list with `limit`/`offset` pagination, no filter by
@@ -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)
+1 -2
View File
@@ -11,7 +11,7 @@ from redis.asyncio import Redis
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from app.config import settings from app.config import settings
from app.routers import 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.broadcaster import RoomBroadcaster
from app.ws.chat import router as ws_router from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager from app.ws.connection_manager import ConnectionManager
@@ -75,7 +75,6 @@ def create_app() -> FastAPI:
app.include_router(signup.router) app.include_router(signup.router)
app.include_router(rooms.router) app.include_router(rooms.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(invites.router)
app.include_router(push.router) app.include_router(push.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(bots.router) app.include_router(bots.router)
+1 -2
View File
@@ -2,7 +2,7 @@ from app.models.admin_audit_log import AdminAuditLog
from app.models.api_token import ApiToken from app.models.api_token import ApiToken
from app.models.base import Base from app.models.base import Base
from app.models.event_subscription import EventSubscription 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.membership import RoomMembership, RoomRole
from app.models.message import Message from app.models.message import Message
from app.models.message_image import MessageImage from app.models.message_image import MessageImage
@@ -23,7 +23,6 @@ __all__ = [
"Message", "Message",
"MessageImage", "MessageImage",
"MessageReaction", "MessageReaction",
"RoomInvite",
"InviteStatus", "InviteStatus",
"SiteInvite", "SiteInvite",
"SmtpSettings", "SmtpSettings",
-46
View File
@@ -1,53 +1,7 @@
import enum 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): class InviteStatus(str, enum.Enum):
pending = "pending" pending = "pending"
accepted = "accepted" accepted = "accepted"
revoked = "revoked" 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])
+1 -1
View File
@@ -16,7 +16,7 @@ def _default_expires_at() -> datetime:
class SiteInvite(Base): class SiteInvite(Base):
"""An admin-issued invite for someone with no account yet -- distinct """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" __tablename__ = "site_invites"
-86
View File
@@ -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")
+21 -62
View File
@@ -12,29 +12,19 @@ from app.dependencies import (
require_scope, require_scope,
) )
from app.models import MessageImage, RoomRole, User from app.models import MessageImage, RoomRole, User
from app.schemas.invite import InviteCreate, InviteRead
from app.schemas.message import MessageRead from app.schemas.message import MessageRead
from app.schemas.message_image import MessageImageCreated from app.schemas.message_image import MessageImageCreated
from app.schemas.room import ( from app.schemas.room import (
MyRoomItem, MyRoomItem,
RoomCreate, RoomCreate,
RoomListItem, RoomListItem,
RoomMemberAdd,
RoomMemberRead, RoomMemberRead,
RoomMemberRoleUpdate, RoomMemberRoleUpdate,
RoomRead, RoomRead,
RoomUpdate, RoomUpdate,
TransferOwnershipRequest, TransferOwnershipRequest,
) )
from app.services.invite_service import (
AlreadyMemberError,
DuplicateInviteError,
InviteNotFoundError,
InviteNotPendingError,
TargetUserNotFoundError,
create_invite,
list_room_invites,
revoke_invite,
)
from app.schemas.webhook import ( from app.schemas.webhook import (
EventSubscriptionCreate, EventSubscriptionCreate,
EventSubscriptionCreated, 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.message_service import get_reactions_for_messages, list_recent_messages
from app.services.room_service import ( from app.services.room_service import (
AlreadyMemberError,
CannotRemoveOwnerError, CannotRemoveOwnerError,
DuplicateRoomError, DuplicateRoomError,
InsufficientRoleError, InsufficientRoleError,
@@ -51,6 +42,8 @@ from app.services.room_service import (
OwnerMustTransferError, OwnerMustTransferError,
RoomIsPrivateError, RoomIsPrivateError,
RoomNotFoundError, RoomNotFoundError,
TargetUserNotFoundError,
add_member,
change_member_role, change_member_role,
create_room, create_room,
delete_room, delete_room,
@@ -373,66 +366,32 @@ async def get_room_image_endpoint(
) )
def _to_invite_read(invite) -> InviteRead: @router.post("/{room_id}/members", response_model=RoomMemberRead, status_code=201)
return InviteRead( async def add_member_endpoint(
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(
room_id: uuid.UUID, room_id: uuid.UUID,
data: InviteCreate, data: RoomMemberAdd,
request: Request, request: Request,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
await require_room_role(room_id, current_user, db, RoomRole.admin)
try: try:
invite = await create_invite( room = await get_room(db, room_id)
db, room_id, current_user.id, data.target_username, str(request.base_url) 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: 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: except AlreadyMemberError:
raise HTTPException(status_code=409, detail="That user is already a member") raise HTTPException(status_code=409, detail="That user is already a member")
except DuplicateInviteError: return RoomMemberRead(
raise HTTPException(status_code=409, detail="That user already has a pending invite") user_id=membership.user_id,
return _to_invite_read(invite) username=membership.user.username,
display_name=membership.user.display_name,
avatar_filename=membership.user.avatar_filename,
@router.get("/{room_id}/invites", response_model=list[InviteRead]) role=membership.role,
async def list_room_invites_endpoint( joined_at=membership.joined_at,
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")
@router.post("/{room_id}/webhooks/incoming", response_model=WebhookIncomingRead, status_code=201) @router.post("/{room_id}/webhooks/incoming", response_model=WebhookIncomingRead, status_code=201)
+15
View File
@@ -2,16 +2,31 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.dependencies import get_current_user from app.dependencies import get_current_user
from app.models import User from app.models import User
from app.schemas.user import UserDirectoryRead
from app.storage import UPLOADS_DIR from app.storage import UPLOADS_DIR
router = APIRouter(prefix="/api/users", tags=["users"]) 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") @router.get("/{user_id}/avatar")
async def get_user_avatar_endpoint( async def get_user_avatar_endpoint(
user_id: uuid.UUID, user_id: uuid.UUID,
-31
View File
@@ -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
+4
View File
@@ -45,6 +45,10 @@ class RoomMemberRead(BaseModel):
joined_at: datetime joined_at: datetime
class RoomMemberAdd(BaseModel):
user_id: uuid.UUID
class RoomMemberRoleUpdate(BaseModel): class RoomMemberRoleUpdate(BaseModel):
role: RoomRole role: RoomRole
+14
View File
@@ -25,3 +25,17 @@ class UserRead(BaseModel):
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
display_name: str | None = Field(default=None, max_length=50) 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
-170
View File
@@ -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
+45 -2
View File
@@ -5,8 +5,9 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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.schemas.room import RoomCreate, RoomUpdate
from app.services.email_service import send_email
class DuplicateRoomError(Exception): class DuplicateRoomError(Exception):
@@ -37,6 +38,14 @@ class OwnerMustTransferError(Exception):
pass pass
class TargetUserNotFoundError(Exception):
pass
class AlreadyMemberError(Exception):
pass
async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room: async def create_room(db: AsyncSession, owner_id: uuid.UUID, data: RoomCreate) -> Room:
room = Room( room = Room(
name=data.name, name=data.name,
@@ -108,6 +117,41 @@ async def join_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) ->
return membership 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: async def update_room(db: AsyncSession, room: Room, data: RoomUpdate) -> Room:
if data.name is not None: if data.name is not None:
room.name = data.name 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 — # Explicit deletes rather than relying on ORM cascade + eager-loading —
# simpler and more predictable in async code. # simpler and more predictable in async code.
await db.execute(delete(Message).where(Message.room_id == room.id)) 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.execute(delete(RoomMembership).where(RoomMembership.room_id == room.id))
await db.delete(room) await db.delete(room)
await db.commit() await db.commit()
-233
View File
@@ -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
+76
View File
@@ -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 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): async def test_list_room_members(client, db_session):
await register_and_login(client, db_session, username="alice") await register_and_login(client, db_session, username="alice")
room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"] room_id = (await client.post("/api/rooms", json={"name": "general"})).json()["id"]
+58
View File
@@ -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
-29
View File
@@ -1,29 +0,0 @@
import { apiFetch } from './client'
import type { Invite, MyInvite, RoomMember } from '../types'
export function createInvite(roomId: string, targetUsername: string): Promise<Invite> {
return apiFetch<Invite>(`/api/rooms/${roomId}/invites`, {
method: 'POST',
body: JSON.stringify({ target_username: targetUsername }),
})
}
export function listRoomInvites(roomId: string): Promise<Invite[]> {
return apiFetch<Invite[]>(`/api/rooms/${roomId}/invites`)
}
export function revokeInvite(roomId: string, inviteId: string): Promise<void> {
return apiFetch<void>(`/api/rooms/${roomId}/invites/${inviteId}`, { method: 'DELETE' })
}
export function listMyInvites(): Promise<MyInvite[]> {
return apiFetch<MyInvite[]>('/api/invites/mine')
}
export function acceptInvite(inviteId: string): Promise<RoomMember> {
return apiFetch<RoomMember>(`/api/invites/${inviteId}/accept`, { method: 'POST' })
}
export function declineInvite(inviteId: string): Promise<Invite> {
return apiFetch<Invite>(`/api/invites/${inviteId}/decline`, { method: 'POST' })
}
+7
View File
@@ -46,6 +46,13 @@ export function listRoomMembers(roomId: string): Promise<RoomMember[]> {
return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`) return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`)
} }
export function addRoomMember(roomId: string, userId: string): Promise<RoomMember> {
return apiFetch<RoomMember>(`/api/rooms/${roomId}/members`, {
method: 'POST',
body: JSON.stringify({ user_id: userId }),
})
}
export function removeMember(roomId: string, userId: string): Promise<void> { export function removeMember(roomId: string, userId: string): Promise<void> {
return apiFetch<void>(`/api/rooms/${roomId}/members/${userId}`, { method: 'DELETE' }) return apiFetch<void>(`/api/rooms/${roomId}/members/${userId}`, { method: 'DELETE' })
} }
+7
View File
@@ -1,3 +1,10 @@
import { apiFetch } from './client'
import type { UserDirectoryEntry } from '../types'
export function getUserAvatarUrl(userId: string, avatarFilename?: string | null): string { export function getUserAvatarUrl(userId: string, avatarFilename?: string | null): string {
return `/api/users/${userId}/avatar${avatarFilename ? `?v=${avatarFilename}` : ''}` return `/api/users/${userId}/avatar${avatarFilename ? `?v=${avatarFilename}` : ''}`
} }
export function listUserDirectory(): Promise<UserDirectoryEntry[]> {
return apiFetch<UserDirectoryEntry[]>('/api/users')
}
-112
View File
@@ -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<MyInvite[]>([])
const [loading, setLoading] = useState(true)
const [busyId, setBusyId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="modal-scrim" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Your invites</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
&times;
</button>
</div>
{error && <p className="modal-error">{error}</p>}
{loading ? (
<p className="modal-empty">Loading...</p>
) : invites.length === 0 ? (
<p className="modal-empty">No pending invites.</p>
) : (
invites.map((invite) => (
<div key={invite.id} className="modal-list-row">
<div className="modal-list-row-body">
<div className="modal-list-row-title">{invite.room_name}</div>
<div className="modal-list-row-sub">Invited by {invite.invited_by_username}</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
type="button"
className="btn-secondary"
disabled={busyId === invite.id}
onClick={() => handleDecline(invite)}
>
Decline
</button>
<button
type="button"
className="btn-primary"
disabled={busyId === invite.id}
onClick={() => handleAccept(invite)}
>
Accept
</button>
</div>
</div>
))
)}
<div className="modal-actions" style={{ marginTop: '1rem' }}>
<button type="button" className="btn-secondary" onClick={onClose}>
Close
</button>
</div>
</div>
</div>
)
}
+39 -30
View File
@@ -1,14 +1,33 @@
.room-info-panel { .room-info-panel {
width: 260px; position: relative;
flex: none;
min-width: 260px; min-width: 260px;
max-width: 480px;
border-left: 1px solid var(--ds-border); border-left: 1px solid var(--ds-border);
background: var(--ds-void-2); background: var(--ds-void-2);
padding: var(--sp-4); padding: var(--sp-4);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
display: flex; display: flex;
flex-direction: column; 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 { .room-info-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -81,26 +100,27 @@
white-space: nowrap; white-space: nowrap;
} }
.room-info-member-actions { .room-info-role-select {
display: flex; appearance: none;
gap: 6px; -webkit-appearance: none;
width: 100%; font-family: inherit;
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;
cursor: pointer; 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 { .room-info-role-select:hover:not(:disabled) {
color: var(--ds-text); opacity: 1;
border-color: var(--ds-accent); }
.room-info-role-select:disabled {
cursor: not-allowed;
opacity: 0.5;
} }
.role-badge { .role-badge {
@@ -135,6 +155,7 @@
.room-info-invite-form { .room-info-invite-form {
display: flex; display: flex;
gap: var(--sp-2); gap: var(--sp-2);
flex-wrap: wrap;
} }
.room-info-invite-form input { .room-info-invite-form input {
@@ -147,18 +168,6 @@
font-size: 0.84rem; 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 { .room-info-error {
color: var(--ds-danger); color: var(--ds-danger);
+72 -104
View File
@@ -1,8 +1,8 @@
import { useEffect, useState, type FormEvent } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { createInvite, listRoomInvites, revokeInvite } from '../api/invites' import { getUserAvatarUrl, listUserDirectory } from '../api/users'
import { getUserAvatarUrl } from '../api/users'
import { import {
addRoomMember,
changeMemberRole, changeMemberRole,
deleteRoom, deleteRoom,
leaveRoom, leaveRoom,
@@ -19,17 +19,19 @@ import {
revokeIncomingWebhook, revokeIncomingWebhook,
} from '../api/webhooks' } from '../api/webhooks'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useResizableWidth } from '../hooks/useResizableWidth'
import type { import type {
EventSubscription, EventSubscription,
EventType, EventType,
Invite,
MyRoomItem, MyRoomItem,
RoomMember, RoomMember,
RoomRole, RoomRole,
UserDirectoryEntry,
WebhookIncoming, WebhookIncoming,
} from '../types' } from '../types'
import { RoomAvatar } from './RoomAvatar' import { RoomAvatar } from './RoomAvatar'
import { UserAvatar } from './UserAvatar' import { UserAvatar } from './UserAvatar'
import { UserPicker } from './UserPicker'
import './RoomInfoPanel.css' import './RoomInfoPanel.css'
const EVENT_TYPES: EventType[] = ['message.created', 'message.updated'] const EVENT_TYPES: EventType[] = ['message.created', 'message.updated']
@@ -55,10 +57,15 @@ export function RoomInfoPanel({
}: RoomInfoPanelProps) { }: RoomInfoPanelProps) {
const { user } = useAuth() const { user } = useAuth()
const myRole = room.role 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<string | null>(null) const [inviteError, setInviteError] = useState<string | null>(null)
const [pendingInvites, setPendingInvites] = useState<Invite[]>([]) const [directoryUsers, setDirectoryUsers] = useState<UserDirectoryEntry[]>([])
const [busyUserId, setBusyUserId] = useState<string | null>(null) const [busyUserId, setBusyUserId] = useState<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
const [nameDraft, setNameDraft] = useState(room.name) const [nameDraft, setNameDraft] = useState(room.name)
@@ -80,35 +87,26 @@ export function RoomInfoPanel({
setNameDraft(room.name) setNameDraft(room.name)
setDescDraft(room.description ?? '') setDescDraft(room.description ?? '')
if (canManage) { if (canManage) {
listRoomInvites(room.id).then(setPendingInvites).catch(() => setPendingInvites([]))
listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([])) listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([]))
listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([])) listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([]))
listUserDirectory().then(setDirectoryUsers).catch(() => setDirectoryUsers([]))
} else { } else {
setPendingInvites([])
setIncomingWebhooks([]) setIncomingWebhooks([])
setEventSubscriptions([]) setEventSubscriptions([])
setDirectoryUsers([])
} }
}, [room.id, room.name, room.description, canManage]) }, [room.id, room.name, room.description, canManage])
async function handleInvite(e: FormEvent) { async function handleAddMember(target: UserDirectoryEntry) {
e.preventDefault()
const username = inviteUsername.trim()
if (!username) return
setInviteError(null) setInviteError(null)
try { try {
await createInvite(room.id, username) await addRoomMember(room.id, target.id)
setInviteUsername('') onMembersChanged()
setPendingInvites(await listRoomInvites(room.id))
} catch (err) { } catch (err) {
setInviteError(err instanceof ApiError ? err.message : String(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) { async function handleCreateWebhook(e: FormEvent) {
e.preventDefault() e.preventDefault()
setIntegrationsError(null) 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() { async function handleLeave() {
if (myRole === 'owner') return if (myRole === 'owner') return
if (!confirm(`Leave #${room.name}?`)) return if (!confirm(`Leave #${room.name}?`)) return
@@ -210,7 +231,8 @@ export function RoomInfoPanel({
} }
return ( return (
<aside className="room-info-panel"> <aside className="room-info-panel" style={{ width }}>
<div className="room-info-resize-handle" onPointerDown={startResize} />
<div className="room-info-header"> <div className="room-info-header">
<span className="room-info-header-label">Details</span> <span className="room-info-header-label">Details</span>
<button type="button" className="room-info-close" onClick={onClose} aria-label="Close"> <button type="button" className="room-info-close" onClick={onClose} aria-label="Close">
@@ -229,7 +251,9 @@ export function RoomInfoPanel({
<div className="room-info-section"> <div className="room-info-section">
<div className="room-info-label">Members</div> <div className="room-info-label">Members</div>
{members.map((m, i) => ( {members.map((m, i) => {
const actions = memberActions(m)
return (
<div key={m.user_id} className="room-info-member-row"> <div key={m.user_id} className="room-info-member-row">
<UserAvatar <UserAvatar
username={m.username} username={m.username}
@@ -238,98 +262,42 @@ export function RoomInfoPanel({
avatarUrl={m.avatar_filename ? getUserAvatarUrl(m.user_id, m.avatar_filename) : null} avatarUrl={m.avatar_filename ? getUserAvatarUrl(m.user_id, m.avatar_filename) : null}
/> />
<span className="room-info-member-name">{m.display_name || m.username}</span> <span className="room-info-member-name">{m.display_name || m.username}</span>
<span className={`role-badge role-badge-${m.role}`}>{m.role}</span> {actions.length > 0 ? (
{myRole === 'owner' && m.user_id !== user?.id && ( <select
<div className="room-info-member-actions"> className={`role-badge role-badge-${m.role} room-info-role-select`}
{m.role === 'member' && ( value={m.role}
<button
type="button"
disabled={busyUserId === m.user_id} disabled={busyUserId === m.user_id}
onClick={() => handleRoleChange(m.user_id, 'admin')} onChange={(e) => {
title="Promote to admin" const action = e.target.value
if (action && action !== m.role) handleMemberAction(m.user_id, action)
}}
aria-label={`Role and actions for ${m.display_name || m.username}`}
> >
Promote <option value={m.role}>{m.role[0].toUpperCase() + m.role.slice(1)}</option>
</button> {actions.map((a) => (
)} <option key={a.value} value={a.value}>
{m.role === 'admin' && ( {a.label}
<button </option>
type="button"
disabled={busyUserId === m.user_id}
onClick={() => handleRoleChange(m.user_id, 'member')}
title="Demote to member"
>
Demote
</button>
)}
<button
type="button"
disabled={busyUserId === m.user_id}
onClick={() => handleTransfer(m.user_id)}
title="Transfer ownership"
>
Make owner
</button>
<button
type="button"
className="room-info-danger-link"
disabled={busyUserId === m.user_id}
onClick={() => handleRemove(m.user_id)}
title="Remove from room"
>
Remove
</button>
</div>
)}
{myRole === 'admin' && m.role === 'member' && m.user_id !== user?.id && (
<div className="room-info-member-actions">
<button
type="button"
className="room-info-danger-link"
disabled={busyUserId === m.user_id}
onClick={() => handleRemove(m.user_id)}
title="Remove from room"
>
Remove
</button>
</div>
)}
</div>
))} ))}
</select>
) : (
<span className={`role-badge role-badge-${m.role}`}>{m.role}</span>
)}
</div>
)
})}
</div> </div>
{canManage && ( {canManage && (
<div className="room-info-section"> <div className="room-info-section">
<div className="room-info-label">Invite someone</div> <div className="room-info-label">Add someone</div>
<form className="room-info-invite-form" onSubmit={handleInvite}> <UserPicker
<input users={directoryUsers}
type="text" excludeUserIds={members.map((m) => m.user_id)}
placeholder="Username" placeholder="Search users to add…"
value={inviteUsername} onSelect={handleAddMember}
onChange={(e) => setInviteUsername(e.target.value)}
/> />
<button type="submit" className="btn-secondary">
Invite
</button>
</form>
{inviteError && <p className="room-info-error">{inviteError}</p>} {inviteError && <p className="room-info-error">{inviteError}</p>}
{pendingInvites.length > 0 && (
<div className="room-info-pending">
{pendingInvites.map((inv) => (
<div key={inv.id} className="room-info-pending-row">
<span>{inv.target_username ?? 'Unknown user'}</span>
<button
type="button"
className="room-info-danger-link"
onClick={() => handleRevoke(inv.id)}
title="Revoke invite"
>
Revoke
</button>
</div>
))}
</div>
)}
</div> </div>
)} )}
-15
View File
@@ -80,21 +80,6 @@
background: color-mix(in srgb, var(--ds-text) 5%, transparent); background: color-mix(in srgb, var(--ds-text) 5%, transparent);
} }
.sidebar-badge {
margin-left: auto;
background: var(--ds-accent);
color: var(--ds-void);
font-size: 0.68rem;
font-weight: 800;
border-radius: var(--radius-pill);
min-width: 16px;
height: 16px;
padding: 0 5px;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-section-label { .sidebar-section-label {
padding: 10px 16px 6px; padding: 10px 16px 6px;
font-size: 0.72rem; font-size: 0.72rem;
-13
View File
@@ -9,8 +9,6 @@ interface SidebarProps {
onSearchChange: (value: string) => void onSearchChange: (value: string) => void
onOpenNewRoom: () => void onOpenNewRoom: () => void
onOpenBrowse: () => void onOpenBrowse: () => void
onOpenInvites: () => void
inviteCount: number
unavailableOffline?: boolean unavailableOffline?: boolean
} }
@@ -21,8 +19,6 @@ export function Sidebar({
onSearchChange, onSearchChange,
onOpenNewRoom, onOpenNewRoom,
onOpenBrowse, onOpenBrowse,
onOpenInvites,
inviteCount,
unavailableOffline, unavailableOffline,
}: SidebarProps) { }: SidebarProps) {
const query = searchQuery.trim().toLowerCase() const query = searchQuery.trim().toLowerCase()
@@ -52,15 +48,6 @@ export function Sidebar({
</div> </div>
<div className="sidebar-scroll"> <div className="sidebar-scroll">
<button type="button" className="sidebar-entry" onClick={onOpenInvites}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M22 6 12 13 2 6" />
<rect x="2" y="4" width="20" height="16" rx="2" />
</svg>
Invites
{inviteCount > 0 && <span className="sidebar-badge">{inviteCount}</span>}
</button>
<button type="button" className="sidebar-entry" onClick={onOpenBrowse}> <button type="button" className="sidebar-entry" onClick={onOpenBrowse}>
<svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true"> <svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<circle cx="9" cy="9" r="6" /> <circle cx="9" cy="9" r="6" />
+72
View File
@@ -0,0 +1,72 @@
.user-picker {
position: relative;
flex: 1;
min-width: 0;
}
.user-picker input {
width: 100%;
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
padding: 7px 10px;
color: var(--ds-text);
font-size: 0.84rem;
}
.user-picker-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 20;
max-height: 220px;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 4px;
}
.user-picker-row {
display: flex;
align-items: center;
gap: 8px;
background: transparent;
border: none;
border-radius: 6px;
padding: 6px 8px;
cursor: pointer;
text-align: left;
font-size: 0.82rem;
color: var(--ds-text);
width: 100%;
}
.user-picker-row-active,
.user-picker-row:hover {
background: color-mix(in srgb, var(--ds-accent) 16%, transparent);
}
.user-picker-row-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-picker-row-username {
color: var(--ds-muted);
font-size: 0.74rem;
margin-left: auto;
padding-left: 6px;
flex: none;
}
.user-picker-empty {
padding: 8px 10px;
font-size: 0.8rem;
color: var(--ds-muted);
}
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from 'react'
import { getUserAvatarUrl } from '../api/users'
import type { UserDirectoryEntry } from '../types'
import { UserAvatar } from './UserAvatar'
import './UserPicker.css'
interface UserPickerProps {
users: UserDirectoryEntry[]
excludeUserIds?: string[]
placeholder?: string
onSelect: (user: UserDirectoryEntry) => void
}
export function UserPicker({ users, excludeUserIds, placeholder = 'Search users…', onSelect }: UserPickerProps) {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [highlighted, setHighlighted] = useState(0)
const rootRef = useRef<HTMLDivElement>(null)
const excluded = new Set(excludeUserIds ?? [])
const q = query.trim().toLowerCase()
const matches = q
? users
.filter((u) => !excluded.has(u.id))
.filter((u) => u.username.toLowerCase().includes(q) || (u.display_name ?? '').toLowerCase().includes(q))
.slice(0, 8)
: []
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
function choose(u: UserDirectoryEntry) {
onSelect(u)
setQuery('')
setOpen(false)
setHighlighted(0)
}
function handleKeyDown(e: React.KeyboardEvent) {
if (!open || matches.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlighted((h) => Math.min(h + 1, matches.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlighted((h) => Math.max(h - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
choose(matches[highlighted])
} else if (e.key === 'Escape') {
setOpen(false)
}
}
return (
<div className="user-picker" ref={rootRef}>
<input
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => {
setQuery(e.target.value)
setOpen(true)
setHighlighted(0)
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
/>
{open && matches.length > 0 && (
<div className="user-picker-dropdown">
{matches.map((u, i) => (
<button
type="button"
key={u.id}
className={`user-picker-row${i === highlighted ? ' user-picker-row-active' : ''}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => choose(u)}
onMouseEnter={() => setHighlighted(i)}
>
<UserAvatar
username={u.username}
colorIndex={i}
size={22}
avatarUrl={u.avatar_filename ? getUserAvatarUrl(u.id, u.avatar_filename) : null}
/>
<span className="user-picker-row-name">{u.display_name || u.username}</span>
{u.display_name && <span className="user-picker-row-username">@{u.username}</span>}
</button>
))}
</div>
)}
{open && q && matches.length === 0 && <div className="user-picker-dropdown user-picker-empty">No matches</div>}
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { useCallback, useEffect, useRef, useState, type PointerEvent } from 'react'
interface UseResizableWidthOptions {
storageKey: string
defaultWidth: number
min: number
max: number
}
// Right-anchored resizable panel: width is the distance from the cursor to
// the viewport's right edge, so a handle on the panel's left edge drags
// naturally. Persists to localStorage so it survives a reload.
export function useResizableWidth({ storageKey, defaultWidth, min, max }: UseResizableWidthOptions) {
const [width, setWidth] = useState(() => {
const stored = Number(localStorage.getItem(storageKey))
return stored >= min && stored <= max ? stored : defaultWidth
})
const widthRef = useRef(width)
widthRef.current = width
const draggingRef = useRef(false)
useEffect(() => {
function handleMove(e: PointerEvent<Window> | globalThis.PointerEvent) {
if (!draggingRef.current) return
const next = Math.min(max, Math.max(min, window.innerWidth - e.clientX))
setWidth(next)
}
function handleUp() {
if (!draggingRef.current) return
draggingRef.current = false
localStorage.setItem(storageKey, String(widthRef.current))
}
window.addEventListener('pointermove', handleMove as (e: globalThis.PointerEvent) => void)
window.addEventListener('pointerup', handleUp)
return () => {
window.removeEventListener('pointermove', handleMove as (e: globalThis.PointerEvent) => void)
window.removeEventListener('pointerup', handleUp)
}
}, [max, min, storageKey])
const startResize = useCallback((e: PointerEvent) => {
e.preventDefault()
draggingRef.current = true
}, [])
return { width, startResize }
}
+25 -11
View File
@@ -24,6 +24,7 @@ import {
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots' import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots'
import { getUserAvatarUrl } from '../api/users' import { getUserAvatarUrl } from '../api/users'
import { UserPicker } from '../components/UserPicker'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar' import { hashIndex } from '../lib/avatar'
import type { import type {
@@ -52,6 +53,7 @@ export function AdminPage() {
const [tab, setTab] = useState<Tab>('users') const [tab, setTab] = useState<Tab>('users')
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([])
const [rooms, setRooms] = useState<AdminRoom[]>([]) const [rooms, setRooms] = useState<AdminRoom[]>([])
const [transferringRoomId, setTransferringRoomId] = useState<string | null>(null)
const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([]) const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([])
const [auditHasMore, setAuditHasMore] = useState(true) const [auditHasMore, setAuditHasMore] = useState(true)
const [busyId, setBusyId] = useState<string | null>(null) const [busyId, setBusyId] = useState<string | null>(null)
@@ -191,18 +193,16 @@ export function AdminPage() {
}) })
} }
async function handleTransferOwnership(r: AdminRoom) { function toggleTransfer(roomId: string) {
const username = prompt(`Transfer #${r.name} to which username? (must already be a member)`) setTransferringRoomId((prev) => (prev === roomId ? null : roomId))
if (!username) return
const target = users.find((u) => u.username === username.trim())
if (!target) {
setError(`No known user named "${username}"`)
return
} }
async function handleTransferOwnership(r: AdminRoom, targetId: string) {
await withBusy(r.id, async () => { await withBusy(r.id, async () => {
const updated = await transferOwnershipAdmin(r.id, target.id) const updated = await transferOwnershipAdmin(r.id, targetId)
setRooms((prev) => prev.map((x) => (x.id === updated.id ? updated : x))) setRooms((prev) => prev.map((x) => (x.id === updated.id ? updated : x)))
}) })
setTransferringRoomId(null)
} }
async function handleCreateBot() { async function handleCreateBot() {
@@ -465,7 +465,8 @@ export function AdminPage() {
</thead> </thead>
<tbody> <tbody>
{rooms.map((r) => ( {rooms.map((r) => (
<tr key={r.id}> <Fragment key={r.id}>
<tr>
<td>#{r.name}</td> <td>#{r.name}</td>
<td>{r.is_private ? 'Private' : 'Open'}</td> <td>{r.is_private ? 'Private' : 'Open'}</td>
<td> <td>
@@ -478,11 +479,24 @@ export function AdminPage() {
<button type="button" disabled={busyId === r.id} onClick={() => handleToggleArchive(r)}> <button type="button" disabled={busyId === r.id} onClick={() => handleToggleArchive(r)}>
{r.is_archived ? 'Unarchive' : 'Archive'} {r.is_archived ? 'Unarchive' : 'Archive'}
</button> </button>
<button type="button" disabled={busyId === r.id} onClick={() => handleTransferOwnership(r)}> <button type="button" disabled={busyId === r.id} onClick={() => toggleTransfer(r.id)}>
Transfer ownership {transferringRoomId === r.id ? 'Cancel' : 'Transfer ownership'}
</button> </button>
</td> </td>
</tr> </tr>
{transferringRoomId === r.id && (
<tr>
<td colSpan={5} className="admin-bot-detail">
<UserPicker
users={users}
excludeUserIds={[r.owner_id]}
placeholder="Search users to transfer ownership to…"
onSelect={(target) => handleTransferOwnership(r, target.id)}
/>
</td>
</tr>
)}
</Fragment>
))} ))}
</tbody> </tbody>
</table> </table>
+1 -22
View File
@@ -1,11 +1,9 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { NetworkError } from '../api/client' import { NetworkError } from '../api/client'
import { listMyInvites } from '../api/invites'
import { listMyRooms, listRoomMembers } from '../api/rooms' import { listMyRooms, listRoomMembers } from '../api/rooms'
import { BrowseRoomsModal } from '../components/BrowseRoomsModal' import { BrowseRoomsModal } from '../components/BrowseRoomsModal'
import { ChatPane } from '../components/ChatPane' import { ChatPane } from '../components/ChatPane'
import { InvitesModal } from '../components/InvitesModal'
import { NewRoomModal } from '../components/NewRoomModal' import { NewRoomModal } from '../components/NewRoomModal'
import { OfflineBanner } from '../components/OfflineBanner' import { OfflineBanner } from '../components/OfflineBanner'
import { RoomInfoPanel } from '../components/RoomInfoPanel' import { RoomInfoPanel } from '../components/RoomInfoPanel'
@@ -16,7 +14,7 @@ import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
import type { MyRoomItem, RoomMember } from '../types' import type { MyRoomItem, RoomMember } from '../types'
import './ChatShellPage.css' import './ChatShellPage.css'
type ModalKind = 'new' | 'browse' | 'invites' | null type ModalKind = 'new' | 'browse' | null
export function ChatShellPage() { export function ChatShellPage() {
const { roomId } = useParams<{ roomId?: string }>() const { roomId } = useParams<{ roomId?: string }>()
@@ -28,7 +26,6 @@ export function ChatShellPage() {
const [rooms, setRooms] = useState<MyRoomItem[]>([]) const [rooms, setRooms] = useState<MyRoomItem[]>([])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [members, setMembers] = useState<RoomMember[]>([]) const [members, setMembers] = useState<RoomMember[]>([])
const [inviteCount, setInviteCount] = useState(0)
const [infoOpen, setInfoOpen] = useState(false) const [infoOpen, setInfoOpen] = useState(false)
const [modal, setModal] = useState<ModalKind>(null) const [modal, setModal] = useState<ModalKind>(null)
const [roomsUnavailableOffline, setRoomsUnavailableOffline] = useState(false) const [roomsUnavailableOffline, setRoomsUnavailableOffline] = useState(false)
@@ -59,12 +56,6 @@ export function ChatShellPage() {
refreshRooms().catch(() => {}) refreshRooms().catch(() => {})
}, [refreshRooms]) }, [refreshRooms])
useEffect(() => {
listMyInvites()
.then((list) => setInviteCount(list.length))
.catch(() => {})
}, [])
useEffect(() => { useEffect(() => {
refreshMembers() refreshMembers()
// Also re-run when the logged-in user's own profile changes (display // Also re-run when the logged-in user's own profile changes (display
@@ -92,8 +83,6 @@ export function ChatShellPage() {
onSearchChange={setSearch} onSearchChange={setSearch}
onOpenNewRoom={() => setModal('new')} onOpenNewRoom={() => setModal('new')}
onOpenBrowse={() => setModal('browse')} onOpenBrowse={() => setModal('browse')}
onOpenInvites={() => setModal('invites')}
inviteCount={inviteCount}
unavailableOffline={roomsUnavailableOffline && rooms.length === 0} unavailableOffline={roomsUnavailableOffline && rooms.length === 0}
/> />
)} )}
@@ -156,16 +145,6 @@ export function ChatShellPage() {
}} }}
/> />
)} )}
{modal === 'invites' && (
<InvitesModal
onClose={() => setModal(null)}
onInvitesChanged={setInviteCount}
onAccepted={(id) => {
setModal(null)
refreshRooms().then(() => goToRoom(id))
}}
/>
)}
</div> </div>
) )
} }
+7 -16
View File
@@ -9,6 +9,13 @@ export interface User {
created_at: string created_at: string
} }
export interface UserDirectoryEntry {
id: string
username: string
display_name: string | null
avatar_filename: string | null
}
export type RoomRole = 'owner' | 'admin' | 'member' export type RoomRole = 'owner' | 'admin' | 'member'
export interface Room { export interface Room {
@@ -39,22 +46,6 @@ export interface RoomMember {
export type InviteStatus = 'pending' | 'accepted' | 'revoked' export type InviteStatus = 'pending' | 'accepted' | 'revoked'
export interface Invite {
id: string
room_id: string
invited_by: string
target_user_id: string | null
target_username: string | null
status: InviteStatus
expires_at: string
created_at: string
}
export interface MyInvite extends Invite {
room_name: string
invited_by_username: string
}
export interface ReactionSummary { export interface ReactionSummary {
emoji: string emoji: string
count: number count: number