Private
Public Access
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:
+1
-1
@@ -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
|
||||
|
||||
+40
-38
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -45,6 +45,10 @@ class RoomMemberRead(BaseModel):
|
||||
joined_at: datetime
|
||||
|
||||
|
||||
class RoomMemberAdd(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
|
||||
|
||||
class RoomMemberRoleUpdate(BaseModel):
|
||||
role: RoomRole
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -46,6 +46,13 @@ export function listRoomMembers(roomId: string): Promise<RoomMember[]> {
|
||||
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> {
|
||||
return apiFetch<void>(`/api/rooms/${roomId}/members/${userId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
@@ -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<UserDirectoryEntry[]> {
|
||||
return apiFetch<UserDirectoryEntry[]>('/api/users')
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
×
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [pendingInvites, setPendingInvites] = useState<Invite[]>([])
|
||||
const [directoryUsers, setDirectoryUsers] = useState<UserDirectoryEntry[]>([])
|
||||
const [busyUserId, setBusyUserId] = useState<string | null>(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 (
|
||||
<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">
|
||||
<span className="room-info-header-label">Details</span>
|
||||
<button type="button" className="room-info-close" onClick={onClose} aria-label="Close">
|
||||
@@ -229,107 +251,53 @@ export function RoomInfoPanel({
|
||||
|
||||
<div className="room-info-section">
|
||||
<div className="room-info-label">Members</div>
|
||||
{members.map((m, i) => (
|
||||
<div key={m.user_id} className="room-info-member-row">
|
||||
<UserAvatar
|
||||
username={m.username}
|
||||
colorIndex={i}
|
||||
size={24}
|
||||
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={`role-badge role-badge-${m.role}`}>{m.role}</span>
|
||||
{myRole === 'owner' && m.user_id !== user?.id && (
|
||||
<div className="room-info-member-actions">
|
||||
{m.role === 'member' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyUserId === m.user_id}
|
||||
onClick={() => handleRoleChange(m.user_id, 'admin')}
|
||||
title="Promote to admin"
|
||||
>
|
||||
Promote
|
||||
</button>
|
||||
)}
|
||||
{m.role === 'admin' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyUserId === m.user_id}
|
||||
onClick={() => handleRoleChange(m.user_id, 'member')}
|
||||
title="Demote to member"
|
||||
>
|
||||
Demote
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
{members.map((m, i) => {
|
||||
const actions = memberActions(m)
|
||||
return (
|
||||
<div key={m.user_id} className="room-info-member-row">
|
||||
<UserAvatar
|
||||
username={m.username}
|
||||
colorIndex={i}
|
||||
size={24}
|
||||
avatarUrl={m.avatar_filename ? getUserAvatarUrl(m.user_id, m.avatar_filename) : null}
|
||||
/>
|
||||
<span className="room-info-member-name">{m.display_name || m.username}</span>
|
||||
{actions.length > 0 ? (
|
||||
<select
|
||||
className={`role-badge role-badge-${m.role} room-info-role-select`}
|
||||
value={m.role}
|
||||
disabled={busyUserId === m.user_id}
|
||||
onClick={() => handleTransfer(m.user_id)}
|
||||
title="Transfer ownership"
|
||||
onChange={(e) => {
|
||||
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}`}
|
||||
>
|
||||
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>
|
||||
))}
|
||||
<option value={m.role}>{m.role[0].toUpperCase() + m.role.slice(1)}</option>
|
||||
{actions.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className={`role-badge role-badge-${m.role}`}>{m.role}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<div className="room-info-section">
|
||||
<div className="room-info-label">Invite someone</div>
|
||||
<form className="room-info-invite-form" onSubmit={handleInvite}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={inviteUsername}
|
||||
onChange={(e) => setInviteUsername(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="btn-secondary">
|
||||
Invite
|
||||
</button>
|
||||
</form>
|
||||
<div className="room-info-label">Add someone</div>
|
||||
<UserPicker
|
||||
users={directoryUsers}
|
||||
excludeUserIds={members.map((m) => m.user_id)}
|
||||
placeholder="Search users to add…"
|
||||
onSelect={handleAddMember}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -80,21 +80,6 @@
|
||||
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 {
|
||||
padding: 10px 16px 6px;
|
||||
font-size: 0.72rem;
|
||||
|
||||
@@ -9,8 +9,6 @@ interface SidebarProps {
|
||||
onSearchChange: (value: string) => void
|
||||
onOpenNewRoom: () => void
|
||||
onOpenBrowse: () => void
|
||||
onOpenInvites: () => void
|
||||
inviteCount: number
|
||||
unavailableOffline?: boolean
|
||||
}
|
||||
|
||||
@@ -21,8 +19,6 @@ export function Sidebar({
|
||||
onSearchChange,
|
||||
onOpenNewRoom,
|
||||
onOpenBrowse,
|
||||
onOpenInvites,
|
||||
inviteCount,
|
||||
unavailableOffline,
|
||||
}: SidebarProps) {
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
@@ -52,15 +48,6 @@ export function Sidebar({
|
||||
</div>
|
||||
|
||||
<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}>
|
||||
<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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { ApiError } from '../api/client'
|
||||
import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots'
|
||||
import { getUserAvatarUrl } from '../api/users'
|
||||
import { UserPicker } from '../components/UserPicker'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { hashIndex } from '../lib/avatar'
|
||||
import type {
|
||||
@@ -52,6 +53,7 @@ export function AdminPage() {
|
||||
const [tab, setTab] = useState<Tab>('users')
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [rooms, setRooms] = useState<AdminRoom[]>([])
|
||||
const [transferringRoomId, setTransferringRoomId] = useState<string | null>(null)
|
||||
const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([])
|
||||
const [auditHasMore, setAuditHasMore] = useState(true)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
@@ -191,18 +193,16 @@ export function AdminPage() {
|
||||
})
|
||||
}
|
||||
|
||||
async function handleTransferOwnership(r: AdminRoom) {
|
||||
const username = prompt(`Transfer #${r.name} to which username? (must already be a member)`)
|
||||
if (!username) return
|
||||
const target = users.find((u) => u.username === username.trim())
|
||||
if (!target) {
|
||||
setError(`No known user named "${username}"`)
|
||||
return
|
||||
}
|
||||
function toggleTransfer(roomId: string) {
|
||||
setTransferringRoomId((prev) => (prev === roomId ? null : roomId))
|
||||
}
|
||||
|
||||
async function handleTransferOwnership(r: AdminRoom, targetId: string) {
|
||||
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)))
|
||||
})
|
||||
setTransferringRoomId(null)
|
||||
}
|
||||
|
||||
async function handleCreateBot() {
|
||||
@@ -465,24 +465,38 @@ export function AdminPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>#{r.name}</td>
|
||||
<td>{r.is_private ? 'Private' : 'Open'}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${r.is_archived ? 'inactive' : 'active'}`}>
|
||||
{r.is_archived ? 'Archived' : 'Active'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{r.member_count}</td>
|
||||
<td className="admin-actions">
|
||||
<button type="button" disabled={busyId === r.id} onClick={() => handleToggleArchive(r)}>
|
||||
{r.is_archived ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
<button type="button" disabled={busyId === r.id} onClick={() => handleTransferOwnership(r)}>
|
||||
Transfer ownership
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<Fragment key={r.id}>
|
||||
<tr>
|
||||
<td>#{r.name}</td>
|
||||
<td>{r.is_private ? 'Private' : 'Open'}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${r.is_archived ? 'inactive' : 'active'}`}>
|
||||
{r.is_archived ? 'Archived' : 'Active'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{r.member_count}</td>
|
||||
<td className="admin-actions">
|
||||
<button type="button" disabled={busyId === r.id} onClick={() => handleToggleArchive(r)}>
|
||||
{r.is_archived ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
<button type="button" disabled={busyId === r.id} onClick={() => toggleTransfer(r.id)}>
|
||||
{transferringRoomId === r.id ? 'Cancel' : 'Transfer ownership'}
|
||||
</button>
|
||||
</td>
|
||||
</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>
|
||||
</table>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { NetworkError } from '../api/client'
|
||||
import { listMyInvites } from '../api/invites'
|
||||
import { listMyRooms, listRoomMembers } from '../api/rooms'
|
||||
import { BrowseRoomsModal } from '../components/BrowseRoomsModal'
|
||||
import { ChatPane } from '../components/ChatPane'
|
||||
import { InvitesModal } from '../components/InvitesModal'
|
||||
import { NewRoomModal } from '../components/NewRoomModal'
|
||||
import { OfflineBanner } from '../components/OfflineBanner'
|
||||
import { RoomInfoPanel } from '../components/RoomInfoPanel'
|
||||
@@ -16,7 +14,7 @@ import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
|
||||
import type { MyRoomItem, RoomMember } from '../types'
|
||||
import './ChatShellPage.css'
|
||||
|
||||
type ModalKind = 'new' | 'browse' | 'invites' | null
|
||||
type ModalKind = 'new' | 'browse' | null
|
||||
|
||||
export function ChatShellPage() {
|
||||
const { roomId } = useParams<{ roomId?: string }>()
|
||||
@@ -28,7 +26,6 @@ export function ChatShellPage() {
|
||||
const [rooms, setRooms] = useState<MyRoomItem[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [members, setMembers] = useState<RoomMember[]>([])
|
||||
const [inviteCount, setInviteCount] = useState(0)
|
||||
const [infoOpen, setInfoOpen] = useState(false)
|
||||
const [modal, setModal] = useState<ModalKind>(null)
|
||||
const [roomsUnavailableOffline, setRoomsUnavailableOffline] = useState(false)
|
||||
@@ -59,12 +56,6 @@ export function ChatShellPage() {
|
||||
refreshRooms().catch(() => {})
|
||||
}, [refreshRooms])
|
||||
|
||||
useEffect(() => {
|
||||
listMyInvites()
|
||||
.then((list) => setInviteCount(list.length))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshMembers()
|
||||
// Also re-run when the logged-in user's own profile changes (display
|
||||
@@ -92,8 +83,6 @@ export function ChatShellPage() {
|
||||
onSearchChange={setSearch}
|
||||
onOpenNewRoom={() => setModal('new')}
|
||||
onOpenBrowse={() => setModal('browse')}
|
||||
onOpenInvites={() => setModal('invites')}
|
||||
inviteCount={inviteCount}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
+7
-16
@@ -9,6 +9,13 @@ export interface User {
|
||||
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 interface Room {
|
||||
@@ -39,22 +46,6 @@ export interface RoomMember {
|
||||
|
||||
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 {
|
||||
emoji: string
|
||||
count: number
|
||||
|
||||
Reference in New Issue
Block a user