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 -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.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",
-46
View File
@@ -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])
+1 -1
View File
@@ -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"