diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index fe42e4c..eeb0fa7 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -169,6 +169,12 @@ 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). +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 +admin sets it up. + ### 3d. Frontend build `backend/app/main.py` serves `frontend/dist` directly (alongside `/api` and diff --git a/backend/README.md b/backend/README.md index d84a58b..ff874e0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,4 +1,4 @@ -# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, emoji & reactions, user profiles) +# 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 @@ -7,9 +7,10 @@ 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, and self-service user profiles (display name, -avatar). See `../ARCHITECTURE.md` for the full system design and the -phased build plan. +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. 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. @@ -117,18 +118,22 @@ app/ require_room_member, require_room_role, require_site_admin, require_scope security.py argon2 password hashing + token generate/hash (sha256) + crypto.py Fernet encrypt/decrypt keyed from SESSION_SECRET -- + the only reversible secret this app stores in + the database (SMTP password), see Site invites + & email below storage.py uploaded-image validation (Pillow), downscaling (optionally square-cropped, for avatars), and on-disk save/read -- see Image uploads below 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, push_subscriptions, - admin_audit_log, api_tokens, webhooks_incoming, - event_subscriptions) + room_invites, 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, push, admin, bots, - webhooks, health + routers/ auth, rooms, users, invites, signup, push, admin, + bots, webhooks, health services/ business logic called by routers ws/ connection_manager (local sockets), presence + broadcaster (Redis), /ws/chat handler @@ -415,6 +420,58 @@ resolves both live from the room's member list instead of freezing them per-message, which is the more correct behavior for a field the sender can 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 +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. + +**Email sending** (`app/services/email_service.py`, using `aiosmtplib`): +`send_email(db, to, subject, body)` is the fire-and-forget path used by +invite flows — if `SmtpSettings` isn't configured yet it logs at debug and +returns (same "silently skip if unconfigured" UX push notifications already +use for a missing VAPID key), and it never raises on delivery failure (an +SMTP outage must not block an invite/membership action that already +succeeded in the database). `send_test_email(db, to)` is the one exception — +used only by the admin "send test email" button, it raises so the UI can +show *why* it failed instead of a silent no-op. Plain-text bodies only, no +HTML templates, matching this codebase's existing minimalism. + +**SMTP configuration** (`app/models/smtp_settings.py`, `app/routers/admin.py`'s +`/settings/smtp` endpoints) lives in the database, not the env file — the +Admin Settings tab edits it at runtime with no redeploy. It's the first +reversible secret this app stores in the database (`password_hash` is +one-way, API tokens are looked up by hash and never decrypted), so it's +encrypted at rest via `app/crypto.py`: a Fernet key derived from the +already-required `SESSION_SECRET` rather than a new env var. A blank +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` +(`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 +hashing/uniqueness handling, and logs the new user in immediately (same +session-cookie line `auth.py`'s `login()` uses) so they land in the app +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. + +Scope cuts: no outgoing-webhook event type for these (matching image +uploads/reactions), no resend for a site invite (revoke + re-invite covers +it), no HTML email templates. + ## Notes / scope decisions - Invite-only site registration: no `POST /api/auth/register`. Accounts are diff --git a/backend/alembic/versions/41139ce908df_site_invites_and_smtp_settings.py b/backend/alembic/versions/41139ce908df_site_invites_and_smtp_settings.py new file mode 100644 index 0000000..67bd709 --- /dev/null +++ b/backend/alembic/versions/41139ce908df_site_invites_and_smtp_settings.py @@ -0,0 +1,59 @@ +"""site invites and smtp settings + +Revision ID: 41139ce908df +Revises: f6e024985d4d +Create Date: 2026-08-14 17:23:11.263283 + +""" +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 = '41139ce908df' +down_revision: Union[str, Sequence[str], None] = 'f6e024985d4d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('smtp_settings', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('host', sa.String(length=255), nullable=False), + sa.Column('port', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=255), nullable=True), + sa.Column('password_encrypted', sa.Text(), nullable=True), + sa.Column('from_address', sa.String(length=255), nullable=False), + sa.Column('use_tls', sa.Boolean(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('site_invites', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('invited_by', sa.Uuid(), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('status', postgresql.ENUM('pending', 'accepted', 'revoked', name='invite_status', create_type=False), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['invited_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_site_invites_email'), 'site_invites', ['email'], unique=False) + op.create_index(op.f('ix_site_invites_token_hash'), 'site_invites', ['token_hash'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_site_invites_token_hash'), table_name='site_invites') + op.drop_index(op.f('ix_site_invites_email'), table_name='site_invites') + op.drop_table('site_invites') + op.drop_table('smtp_settings') + # ### end Alembic commands ### diff --git a/backend/app/crypto.py b/backend/app/crypto.py new file mode 100644 index 0000000..57d3127 --- /dev/null +++ b/backend/app/crypto.py @@ -0,0 +1,23 @@ +import base64 +import hashlib + +from cryptography.fernet import Fernet + +from app.config import settings + + +def _fernet() -> Fernet: + # Derives a stable Fernet key from SESSION_SECRET rather than requiring + # a new env var -- this is the only reversible secret this app stores + # in the database (SMTP password), so it gets real encryption at rest, + # but doesn't need its own deployment configuration to do it. + key = hashlib.sha256(settings.session_secret.encode()).digest() + return Fernet(base64.urlsafe_b64encode(key)) + + +def encrypt(plaintext: str) -> str: + return _fernet().encrypt(plaintext.encode()).decode() + + +def decrypt(ciphertext: str) -> str: + return _fernet().decrypt(ciphertext.encode()).decode() diff --git a/backend/app/main.py b/backend/app/main.py index 01b5e2c..fa4f89f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,7 @@ from redis.asyncio import Redis from starlette.middleware.sessions import SessionMiddleware from app.config import settings -from app.routers import admin, auth, bots, health, invites, push, rooms, users, webhooks +from app.routers import admin, auth, bots, health, invites, 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 @@ -72,6 +72,7 @@ def create_app() -> FastAPI: app.include_router(health.router) app.include_router(auth.router) + app.include_router(signup.router) app.include_router(rooms.router) app.include_router(users.router) app.include_router(invites.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index edcc12b..1457f92 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,6 +9,8 @@ from app.models.message_image import MessageImage from app.models.message_reaction import MessageReaction from app.models.push_subscription import PushSubscription from app.models.room import Room +from app.models.site_invite import SiteInvite +from app.models.smtp_settings import SmtpSettings from app.models.user import User from app.models.webhook_incoming import WebhookIncoming @@ -23,6 +25,8 @@ __all__ = [ "MessageReaction", "RoomInvite", "InviteStatus", + "SiteInvite", + "SmtpSettings", "PushSubscription", "AdminAuditLog", "ApiToken", diff --git a/backend/app/models/site_invite.py b/backend/app/models/site_invite.py new file mode 100644 index 0000000..d56740f --- /dev/null +++ b/backend/app/models/site_invite.py @@ -0,0 +1,40 @@ +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import DateTime, Enum, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base +from app.models.invite import InviteStatus + +DEFAULT_SITE_INVITE_LIFETIME = timedelta(days=7) + + +def _default_expires_at() -> datetime: + return datetime.now(timezone.utc) + DEFAULT_SITE_INVITE_LIFETIME + + +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.""" + + __tablename__ = "site_invites" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String(255), index=True, nullable=False) + invited_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False) + # The raw token only ever exists in the invite email link -- like an API + # token, it's a bearer secret looked up by itself, so it's stored hashed + # (app.security.hash_token), not in plaintext. + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + status: Mapped[InviteStatus] = mapped_column( + Enum(InviteStatus, name="invite_status"), default=InviteStatus.pending, nullable=False + ) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_default_expires_at, nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + inviter = relationship("User") diff --git a/backend/app/models/smtp_settings.py b/backend/app/models/smtp_settings.py new file mode 100644 index 0000000..ac7e23a --- /dev/null +++ b/backend/app/models/smtp_settings.py @@ -0,0 +1,31 @@ +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class SmtpSettings(Base): + """A single-row table (enforced in the service layer, not the schema -- + there's no clean single-row DB constraint) holding the site's SMTP + configuration, set through the Admin UI at runtime rather than the env + file.""" + + __tablename__ = "smtp_settings" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + host: Mapped[str] = mapped_column(String(255), nullable=False) + port: Mapped[int] = mapped_column(Integer, nullable=False) + username: Mapped[str | None] = mapped_column(String(255)) + # Encrypted at rest (app.crypto) with a key derived from SESSION_SECRET + # -- the only reversible secret this app stores in the database, unlike + # password_hash (one-way) or api_tokens (looked up by hash, never + # decrypted). + password_encrypted: Mapped[str | None] = mapped_column(Text) + from_address: Mapped[str] = mapped_column(String(255), nullable=False) + use_tls: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 72ddedb..64121f0 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -1,6 +1,6 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -14,6 +14,8 @@ from app.schemas.admin import ( ResetPasswordRequest, TransferOwnershipRequest, ) +from app.schemas.site_invite import SiteInviteCreate, SiteInviteRead +from app.schemas.smtp_settings import SmtpSettingsRead, SmtpSettingsUpdate from app.schemas.webhook import EventSubscriptionAdminRead, WebhookIncomingAdminRead from app.services.admin_service import ( CannotActOnSelfError, @@ -29,6 +31,15 @@ from app.services.admin_service import ( transfer_ownership_admin, ) from app.services.audit import list_audit_log +from app.services.email_service import SmtpNotConfiguredError, send_test_email +from app.services.site_invite_service import ( + SiteInviteNotFoundError, + SiteInviteNotPendingError, + create_site_invite, + list_site_invites, + revoke_site_invite, +) +from app.services.smtp_settings_service import get_smtp_settings, upsert_smtp_settings from app.services.webhook_service import ( list_all_event_subscriptions_admin, list_all_incoming_webhooks_admin, @@ -272,3 +283,99 @@ async def list_event_subscriptions_admin_endpoint( ) for s in subscriptions ] + + +@router.post("/invites", response_model=SiteInviteRead, status_code=201) +async def create_site_invite_endpoint( + data: SiteInviteCreate, + request: Request, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + require_site_admin(current_user) + return await create_site_invite(db, current_user, str(request.base_url), data.email) + + +@router.get("/invites", response_model=list[SiteInviteRead]) +async def list_site_invites_endpoint( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + require_site_admin(current_user) + return await list_site_invites(db) + + +@router.delete("/invites/{invite_id}", response_model=SiteInviteRead) +async def revoke_site_invite_endpoint( + invite_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + require_site_admin(current_user) + try: + return await revoke_site_invite(db, current_user, invite_id) + except SiteInviteNotFoundError: + raise HTTPException(status_code=404, detail="Invite not found") + except SiteInviteNotPendingError: + raise HTTPException(status_code=400, detail="Invite is no longer pending") + + +@router.get("/settings/smtp", response_model=SmtpSettingsRead | None) +async def get_smtp_settings_endpoint( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + require_site_admin(current_user) + cfg = await get_smtp_settings(db) + if cfg is None: + return None + return SmtpSettingsRead( + host=cfg.host, + port=cfg.port, + username=cfg.username, + has_password=bool(cfg.password_encrypted), + from_address=cfg.from_address, + use_tls=cfg.use_tls, + updated_at=cfg.updated_at, + ) + + +@router.put("/settings/smtp", response_model=SmtpSettingsRead) +async def update_smtp_settings_endpoint( + data: SmtpSettingsUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + require_site_admin(current_user) + cfg = await upsert_smtp_settings( + db, + host=data.host, + port=data.port, + username=data.username, + password=data.password, + from_address=data.from_address, + use_tls=data.use_tls, + ) + return SmtpSettingsRead( + host=cfg.host, + port=cfg.port, + username=cfg.username, + has_password=bool(cfg.password_encrypted), + from_address=cfg.from_address, + use_tls=cfg.use_tls, + updated_at=cfg.updated_at, + ) + + +@router.post("/settings/smtp/test", status_code=204) +async def test_smtp_settings_endpoint( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + require_site_admin(current_user) + try: + await send_test_email(db, current_user.email) + except SmtpNotConfiguredError: + raise HTTPException(status_code=400, detail="SMTP is not configured yet") + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Failed to send test email: {exc}") diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py index d9bb861..b38c490 100644 --- a/backend/app/routers/rooms.py +++ b/backend/app/routers/rooms.py @@ -390,12 +390,15 @@ def _to_invite_read(invite) -> InviteRead: async def create_invite_endpoint( room_id: uuid.UUID, data: InviteCreate, + 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) + invite = await create_invite( + db, room_id, current_user.id, data.target_username, str(request.base_url) + ) except TargetUserNotFoundError: raise HTTPException(status_code=404, detail="No user with that username") except AlreadyMemberError: diff --git a/backend/app/routers/signup.py b/backend/app/routers/signup.py new file mode 100644 index 0000000..434b369 --- /dev/null +++ b/backend/app/routers/signup.py @@ -0,0 +1,43 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.schemas.site_invite import SignupComplete, SignupValidateRead +from app.schemas.user import UserRead +from app.services.auth_service import DuplicateUserError +from app.services.site_invite_service import ( + SiteInviteInvalidError, + complete_signup, + validate_signup_token, +) + +router = APIRouter(prefix="/api/signup", tags=["signup"]) + + +@router.get("/validate", response_model=SignupValidateRead) +async def validate_signup_endpoint( + token: str = Query(...), + db: AsyncSession = Depends(get_db), +): + try: + invite = await validate_signup_token(db, token) + except SiteInviteInvalidError: + raise HTTPException(status_code=400, detail="This invite link is invalid or has expired") + return SignupValidateRead(email=invite.email) + + +@router.post("", response_model=UserRead) +async def complete_signup_endpoint( + data: SignupComplete, + request: Request, + db: AsyncSession = Depends(get_db), +): + try: + user = await complete_signup(db, data.token, data.username, data.password) + except SiteInviteInvalidError: + raise HTTPException(status_code=400, detail="This invite link is invalid or has expired") + except DuplicateUserError: + raise HTTPException(status_code=409, detail="That username or email is already taken") + + request.session["user_id"] = str(user.id) + return user diff --git a/backend/app/schemas/site_invite.py b/backend/app/schemas/site_invite.py new file mode 100644 index 0000000..82017a6 --- /dev/null +++ b/backend/app/schemas/site_invite.py @@ -0,0 +1,31 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +from app.models import InviteStatus + + +class SiteInviteCreate(BaseModel): + email: EmailStr + + +class SiteInviteRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email: str + invited_by: uuid.UUID + status: InviteStatus + expires_at: datetime + created_at: datetime + + +class SignupValidateRead(BaseModel): + email: str + + +class SignupComplete(BaseModel): + token: str + username: str = Field(min_length=3, max_length=50) + password: str = Field(min_length=8, max_length=200) diff --git a/backend/app/schemas/smtp_settings.py b/backend/app/schemas/smtp_settings.py new file mode 100644 index 0000000..c43e918 --- /dev/null +++ b/backend/app/schemas/smtp_settings.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from pydantic import BaseModel, EmailStr, Field + + +class SmtpSettingsUpdate(BaseModel): + host: str = Field(min_length=1, max_length=255) + port: int = Field(ge=1, le=65535) + username: str | None = None + # None/omitted = keep the existing password unchanged -- the frontend + # never has the plaintext to send back, only whether one is set. + password: str | None = None + from_address: EmailStr + use_tls: bool = True + + +class SmtpSettingsRead(BaseModel): + host: str + port: int + username: str | None + has_password: bool + from_address: str + use_tls: bool + updated_at: datetime diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..d5963c8 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,63 @@ +import logging +from email.message import EmailMessage + +import aiosmtplib +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crypto import decrypt +from app.models import SmtpSettings +from app.services.smtp_settings_service import get_smtp_settings + +logger = logging.getLogger(__name__) + + +class SmtpNotConfiguredError(Exception): + pass + + +async def _deliver(cfg: SmtpSettings, to_address: str, subject: str, body: str) -> None: + """Raises on failure -- internal helper only. Callers decide whether to + swallow (send_email) or surface (send_test_email) the error.""" + message = EmailMessage() + message["From"] = cfg.from_address + message["To"] = to_address + message["Subject"] = subject + message.set_content(body) + + password = decrypt(cfg.password_encrypted) if cfg.password_encrypted else None + await aiosmtplib.send( + message, + hostname=cfg.host, + port=cfg.port, + username=cfg.username or None, + password=password, + use_tls=cfg.use_tls, + ) + + +async def send_email(db: AsyncSession, to_address: str, subject: str, body: str) -> None: + """Best-effort -- used by invite/notification flows. Never raises: an + SMTP outage or missing configuration must never block an action (an + invite, a room membership) that already succeeded in the database.""" + cfg = await get_smtp_settings(db) + if cfg is None: + logger.debug("SMTP not configured; skipping email to %s", to_address) + return + try: + await _deliver(cfg, to_address, subject, body) + except Exception: + logger.warning("Failed to send email to %s", to_address, exc_info=True) + + +async def send_test_email(db: AsyncSession, to_address: str) -> None: + """Used only by the admin 'send test email' button -- raises so the + admin UI can show why it failed instead of a silent no-op.""" + cfg = await get_smtp_settings(db) + if cfg is None: + raise SmtpNotConfiguredError() + await _deliver( + cfg, + to_address, + "KeepItTalking test email", + "This is a test email from KeepItTalking to confirm your SMTP settings are working.", + ) diff --git a/backend/app/services/invite_service.py b/backend/app/services/invite_service.py index 908fdbd..831596a 100644 --- a/backend/app/services/invite_service.py +++ b/backend/app/services/invite_service.py @@ -6,7 +6,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.models import InviteStatus, RoomInvite, RoomMembership, RoomRole, User +from app.models import InviteStatus, Room, RoomInvite, RoomMembership, RoomRole, User +from app.services.email_service import send_email class TargetUserNotFoundError(Exception): @@ -38,7 +39,11 @@ class InviteExpiredError(Exception): async def create_invite( - db: AsyncSession, room_id: uuid.UUID, invited_by: uuid.UUID, target_username: str + 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() @@ -73,6 +78,15 @@ async def create_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 diff --git a/backend/app/services/site_invite_service.py b/backend/app/services/site_invite_service.py new file mode 100644 index 0000000..03964ca --- /dev/null +++ b/backend/app/services/site_invite_service.py @@ -0,0 +1,102 @@ +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, SiteInvite, User +from app.schemas.user import UserCreate +from app.security import hash_token +from app.services.audit import record_audit_log +from app.services.auth_service import register_user +from app.services.email_service import send_email + + +class SiteInviteNotFoundError(Exception): + pass + + +class SiteInviteNotPendingError(Exception): + pass + + +class SiteInviteInvalidError(Exception): + pass + + +async def create_site_invite( + db: AsyncSession, actor: User, base_url: str, email: str +) -> SiteInvite: + raw_token = secrets.token_urlsafe(32) + invite_id = uuid.uuid4() + invite = SiteInvite( + id=invite_id, email=email, invited_by=actor.id, token_hash=hash_token(raw_token) + ) + db.add(invite) + record_audit_log(db, actor, "user.invite", "invite", invite_id, {"email": email}) + await db.commit() + await db.refresh(invite) + + signup_link = f"{base_url.rstrip('/')}/signup?token={raw_token}" + await send_email( + db, + email, + "You're invited to join KeepItTalking", + f"You've been invited to join KeepItTalking by {actor.username}.\n\n" + f"Set up your account here:\n{signup_link}\n\n" + f"This link expires in 7 days.", + ) + return invite + + +async def list_site_invites(db: AsyncSession) -> list[SiteInvite]: + result = await db.execute( + select(SiteInvite) + .options(selectinload(SiteInvite.inviter)) + .order_by(SiteInvite.created_at.desc()) + ) + return list(result.scalars().all()) + + +async def revoke_site_invite(db: AsyncSession, actor: User, invite_id: uuid.UUID) -> SiteInvite: + invite = await db.get(SiteInvite, invite_id) + if invite is None: + raise SiteInviteNotFoundError() + if invite.status != InviteStatus.pending: + raise SiteInviteNotPendingError() + + invite.status = InviteStatus.revoked + record_audit_log(db, actor, "invite.revoke", "invite", invite.id) + await db.commit() + await db.refresh(invite) + return invite + + +async def _get_pending_invite_by_token(db: AsyncSession, token: str) -> SiteInvite: + result = await db.execute( + select(SiteInvite).where(SiteInvite.token_hash == hash_token(token)) + ) + invite = result.scalar_one_or_none() + if invite is None or invite.status != InviteStatus.pending: + raise SiteInviteInvalidError() + if invite.expires_at <= datetime.now(timezone.utc): + raise SiteInviteInvalidError() + return invite + + +async def validate_signup_token(db: AsyncSession, token: str) -> SiteInvite: + return await _get_pending_invite_by_token(db, token) + + +async def complete_signup(db: AsyncSession, token: str, username: str, password: str) -> User: + invite = await _get_pending_invite_by_token(db, token) + + user = await register_user( + db, UserCreate(username=username, email=invite.email, password=password) + ) + + invite.status = InviteStatus.accepted + await db.commit() + return user diff --git a/backend/app/services/smtp_settings_service.py b/backend/app/services/smtp_settings_service.py new file mode 100644 index 0000000..1065264 --- /dev/null +++ b/backend/app/services/smtp_settings_service.py @@ -0,0 +1,48 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.crypto import encrypt +from app.models import SmtpSettings + + +async def get_smtp_settings(db: AsyncSession) -> SmtpSettings | None: + result = await db.execute(select(SmtpSettings).limit(1)) + return result.scalar_one_or_none() + + +async def upsert_smtp_settings( + db: AsyncSession, + *, + host: str, + port: int, + username: str | None, + password: str | None, + from_address: str, + use_tls: bool, +) -> SmtpSettings: + settings_row = await get_smtp_settings(db) + if settings_row is None: + settings_row = SmtpSettings( + host=host, + port=port, + username=username, + from_address=from_address, + use_tls=use_tls, + ) + db.add(settings_row) + else: + settings_row.host = host + settings_row.port = port + settings_row.username = username + settings_row.from_address = from_address + settings_row.use_tls = use_tls + + # A blank password in the request means "keep the current one" -- the + # frontend never has the plaintext to send back, only whether one is + # already set (SmtpSettingsRead.has_password). + if password: + settings_row.password_encrypted = encrypt(password) + + await db.commit() + await db.refresh(settings_row) + return settings_row diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 77ee739..d3b2be0 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -20,6 +20,8 @@ dependencies = [ "gunicorn>=23.0", "Pillow>=10.0", "python-multipart>=0.0.9", + "aiosmtplib>=3.0", + "cryptography>=43.0", ] [project.scripts] diff --git a/backend/tests/test_invites.py b/backend/tests/test_invites.py index 45a0b12..e71a45f 100644 --- a/backend/tests/test_invites.py +++ b/backend/tests/test_invites.py @@ -1,10 +1,28 @@ import uuid from datetime import datetime, timedelta, timezone -from app.models import RoomInvite +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 @@ -171,3 +189,45 @@ async def test_expired_invite_rejected_on_accept(client, db_session): await login_as(client, "bob") resp = await client.post(f"/api/invites/{invite['id']}/accept") assert resp.status_code == 400 + + +async def test_create_invite_sends_email_to_target(client, db_session, monkeypatch): + calls = [] + + async def fake_send(message, **kwargs): + calls.append(kwargs) + + monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send) + + alice = await register_and_login(client, db_session, username="alice") + await _make_admin(db_session, alice["id"]) + await _configure_smtp(client) + room = await _create_private_room(client) + await register_and_login(client, db_session, username="bob") + await login_as(client, "alice") + + resp = await client.post( + f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"} + ) + assert resp.status_code == 201 + assert len(calls) == 1 + assert calls[0]["hostname"] == "smtp.example.com" + + +async def test_create_invite_succeeds_even_if_email_delivery_fails(client, db_session, monkeypatch): + async def fake_send(message, **kwargs): + raise ConnectionRefusedError("boom") + + monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send) + + alice = await register_and_login(client, db_session, username="alice") + await _make_admin(db_session, alice["id"]) + await _configure_smtp(client) + room = await _create_private_room(client) + await register_and_login(client, db_session, username="bob") + await login_as(client, "alice") + + resp = await client.post( + f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"} + ) + assert resp.status_code == 201 diff --git a/backend/tests/test_site_invites.py b/backend/tests/test_site_invites.py new file mode 100644 index 0000000..b6c5fa6 --- /dev/null +++ b/backend/tests/test_site_invites.py @@ -0,0 +1,170 @@ +import re +import uuid +from datetime import datetime, timedelta, timezone + +from app.models import SiteInvite, 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() + + +def _fake_smtp(monkeypatch): + calls = [] + + async def fake_send(message, **kwargs): + calls.append({"message": message, **kwargs}) + + monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send) + return calls + + +async def _configure_smtp(client): + resp = await client.put( + "/api/admin/settings/smtp", + json={ + "host": "smtp.example.com", + "port": 587, + "username": "bot", + "password": "secret", + "from_address": "noreply@example.com", + "use_tls": True, + }, + ) + assert resp.status_code == 200, resp.text + + +def _extract_token(body: str) -> str: + match = re.search(r"token=([^\s&]+)", body) + assert match, f"no token found in email body: {body}" + return match.group(1) + + +async def test_create_site_invite_requires_admin(client, db_session): + await register_and_login(client, db_session, username="alice") + resp = await client.post("/api/admin/invites", json={"email": "newperson@example.com"}) + assert resp.status_code == 403 + + +async def test_signup_flow_end_to_end(client, db_session, monkeypatch): + calls = _fake_smtp(monkeypatch) + admin = await register_and_login(client, db_session, username="admin1") + await _make_admin(db_session, admin["id"]) + await _configure_smtp(client) + + resp = await client.post("/api/admin/invites", json={"email": "newperson@example.com"}) + assert resp.status_code == 201, resp.text + invite = resp.json() + assert invite["email"] == "newperson@example.com" + assert invite["status"] == "pending" + + assert len(calls) == 1 + token = _extract_token(calls[0]["message"].get_content()) + + validate = await client.get(f"/api/signup/validate?token={token}") + assert validate.status_code == 200 + assert validate.json()["email"] == "newperson@example.com" + + complete = await client.post( + "/api/signup", + json={"token": token, "username": "newperson", "password": "password123"}, + ) + assert complete.status_code == 200, complete.text + assert complete.json()["email"] == "newperson@example.com" + + me = await client.get("/api/auth/me") + assert me.status_code == 200 + assert me.json()["username"] == "newperson" + + +async def test_invalid_token_rejected(client, db_session): + await register_and_login(client, db_session, username="alice") + + validate = await client.get("/api/signup/validate?token=not-a-real-token") + assert validate.status_code == 400 + + complete = await client.post( + "/api/signup", + json={"token": "not-a-real-token", "username": "someone", "password": "password123"}, + ) + assert complete.status_code == 400 + + +async def test_expired_token_rejected(client, db_session, monkeypatch): + calls = _fake_smtp(monkeypatch) + admin = await register_and_login(client, db_session, username="admin1") + await _make_admin(db_session, admin["id"]) + await _configure_smtp(client) + + resp = await client.post("/api/admin/invites", json={"email": "late@example.com"}) + invite_id = resp.json()["id"] + token = _extract_token(calls[0]["message"].get_content()) + + db_invite = await db_session.get(SiteInvite, uuid.UUID(invite_id)) + db_invite.expires_at = datetime.now(timezone.utc) - timedelta(days=1) + await db_session.commit() + + complete = await client.post( + "/api/signup", + json={"token": token, "username": "late", "password": "password123"}, + ) + assert complete.status_code == 400 + + +async def test_used_token_cannot_be_reused(client, db_session, monkeypatch): + calls = _fake_smtp(monkeypatch) + admin = await register_and_login(client, db_session, username="admin1") + await _make_admin(db_session, admin["id"]) + await _configure_smtp(client) + + await client.post("/api/admin/invites", json={"email": "once@example.com"}) + token = _extract_token(calls[0]["message"].get_content()) + + first = await client.post( + "/api/signup", + json={"token": token, "username": "onceuser", "password": "password123"}, + ) + assert first.status_code == 200 + + second = await client.post( + "/api/signup", + json={"token": token, "username": "onceuser2", "password": "password123"}, + ) + assert second.status_code == 400 + + +async def test_revoke_site_invite_prevents_signup(client, db_session, monkeypatch): + calls = _fake_smtp(monkeypatch) + admin = await register_and_login(client, db_session, username="admin1") + await _make_admin(db_session, admin["id"]) + await _configure_smtp(client) + + resp = await client.post("/api/admin/invites", json={"email": "revoked@example.com"}) + invite_id = resp.json()["id"] + token = _extract_token(calls[0]["message"].get_content()) + + revoke = await client.delete(f"/api/admin/invites/{invite_id}") + assert revoke.status_code == 200 + assert revoke.json()["status"] == "revoked" + + complete = await client.post( + "/api/signup", + json={"token": token, "username": "revokeduser", "password": "password123"}, + ) + assert complete.status_code == 400 + + +async def test_list_site_invites(client, db_session, monkeypatch): + _fake_smtp(monkeypatch) + admin = await register_and_login(client, db_session, username="admin1") + await _make_admin(db_session, admin["id"]) + await _configure_smtp(client) + + await client.post("/api/admin/invites", json={"email": "listed@example.com"}) + resp = await client.get("/api/admin/invites") + assert resp.status_code == 200 + emails = [i["email"] for i in resp.json()] + assert "listed@example.com" in emails diff --git a/backend/tests/test_smtp_settings.py b/backend/tests/test_smtp_settings.py new file mode 100644 index 0000000..5bae548 --- /dev/null +++ b/backend/tests/test_smtp_settings.py @@ -0,0 +1,174 @@ +import uuid + +from sqlalchemy import select + +from app.crypto import decrypt +from app.models import SmtpSettings, User +from tests.conftest import register_and_login + + +async def _get_settings_row(db_session) -> SmtpSettings: + result = await db_session.execute(select(SmtpSettings)) + return result.scalar_one() + + +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_smtp_settings_require_admin(client, db_session): + await register_and_login(client, db_session, username="alice") + resp = await client.get("/api/admin/settings/smtp") + assert resp.status_code == 403 + + resp = await client.put( + "/api/admin/settings/smtp", + json={ + "host": "smtp.example.com", + "port": 587, + "from_address": "noreply@example.com", + }, + ) + assert resp.status_code == 403 + + +async def test_smtp_settings_get_before_configured(client, db_session): + admin = await register_and_login(client, db_session, username="admin1") + await _make_admin(db_session, admin["id"]) + + resp = await client.get("/api/admin/settings/smtp") + assert resp.status_code == 200 + assert resp.json() is None + + +async def test_smtp_settings_update_and_password_never_returned(client, db_session): + admin = await register_and_login(client, db_session, username="admin1") + await _make_admin(db_session, admin["id"]) + + resp = await client.put( + "/api/admin/settings/smtp", + json={ + "host": "smtp.example.com", + "port": 587, + "username": "bot", + "password": "super-secret", + "from_address": "noreply@example.com", + "use_tls": True, + }, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert "password" not in body + assert body["has_password"] is True + assert body["host"] == "smtp.example.com" + + get_resp = await client.get("/api/admin/settings/smtp") + assert get_resp.status_code == 200 + assert "password" not in get_resp.json() + assert get_resp.json()["has_password"] is True + + +async def test_smtp_settings_password_encrypted_at_rest(client, db_session): + admin = await register_and_login(client, db_session, username="admin1") + await _make_admin(db_session, admin["id"]) + + await client.put( + "/api/admin/settings/smtp", + json={ + "host": "smtp.example.com", + "port": 587, + "password": "super-secret", + "from_address": "noreply@example.com", + }, + ) + + row = await _get_settings_row(db_session) + assert row.password_encrypted != "super-secret" + assert decrypt(row.password_encrypted) == "super-secret" + + +async def test_smtp_settings_blank_password_keeps_existing(client, db_session): + admin = await register_and_login(client, db_session, username="admin1") + await _make_admin(db_session, admin["id"]) + + await client.put( + "/api/admin/settings/smtp", + json={ + "host": "smtp.example.com", + "port": 587, + "password": "first-password", + "from_address": "noreply@example.com", + }, + ) + encrypted_before = (await _get_settings_row(db_session)).password_encrypted + + resp = await client.put( + "/api/admin/settings/smtp", + json={ + "host": "smtp.example.com", + "port": 2525, + "from_address": "noreply@example.com", + }, + ) + assert resp.status_code == 200 + assert resp.json()["port"] == 2525 + assert resp.json()["has_password"] is True + + row = await _get_settings_row(db_session) + assert row.password_encrypted == encrypted_before + + +async def test_send_test_email_not_configured(client, db_session): + admin = await register_and_login(client, db_session, username="admin1") + await _make_admin(db_session, admin["id"]) + + resp = await client.post("/api/admin/settings/smtp/test") + assert resp.status_code == 400 + + +async def test_send_test_email_success(client, db_session, monkeypatch): + calls = [] + + async def fake_send(message, **kwargs): + calls.append(kwargs) + + monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send) + + admin = await register_and_login(client, db_session, username="admin1") + await _make_admin(db_session, admin["id"]) + await client.put( + "/api/admin/settings/smtp", + json={ + "host": "smtp.example.com", + "port": 587, + "from_address": "noreply@example.com", + }, + ) + + resp = await client.post("/api/admin/settings/smtp/test") + assert resp.status_code == 204 + assert len(calls) == 1 + assert calls[0]["hostname"] == "smtp.example.com" + + +async def test_send_test_email_surfaces_failure(client, db_session, monkeypatch): + async def fake_send(message, **kwargs): + raise ConnectionRefusedError("boom") + + monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send) + + admin = await register_and_login(client, db_session, username="admin1") + await _make_admin(db_session, admin["id"]) + await client.put( + "/api/admin/settings/smtp", + json={ + "host": "smtp.example.com", + "port": 587, + "from_address": "noreply@example.com", + }, + ) + + resp = await client.post("/api/admin/settings/smtp/test") + assert resp.status_code == 502 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54b7943..73596c2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { AuthProvider } from './context/AuthContext' import { AdminRoute } from './components/AdminRoute' import { ProtectedRoute } from './components/ProtectedRoute' import { LoginPage } from './pages/LoginPage' +import { SignupPage } from './pages/SignupPage' import { ChatShellPage } from './pages/ChatShellPage' import { AdminPage } from './pages/AdminPage' @@ -11,6 +12,7 @@ function App() { } /> + } /> { export function listAllEventSubscriptions(): Promise { return apiFetch('/api/admin/event-subscriptions') } + +export function inviteUser(email: string): Promise { + return apiFetch('/api/admin/invites', { + method: 'POST', + body: JSON.stringify({ email }), + }) +} + +export function listSiteInvites(): Promise { + return apiFetch('/api/admin/invites') +} + +export function revokeSiteInvite(inviteId: string): Promise { + return apiFetch(`/api/admin/invites/${inviteId}`, { method: 'DELETE' }) +} + +export function getSmtpSettings(): Promise { + return apiFetch('/api/admin/settings/smtp') +} + +export interface SmtpSettingsPayload { + host: string + port: number + username?: string | null + password?: string | null + from_address: string + use_tls: boolean +} + +export function updateSmtpSettings(payload: SmtpSettingsPayload): Promise { + return apiFetch('/api/admin/settings/smtp', { + method: 'PUT', + body: JSON.stringify(payload), + }) +} + +export function sendTestSmtpEmail(): Promise { + return apiFetch('/api/admin/settings/smtp/test', { method: 'POST' }) +} diff --git a/frontend/src/api/signup.ts b/frontend/src/api/signup.ts new file mode 100644 index 0000000..e7a6a10 --- /dev/null +++ b/frontend/src/api/signup.ts @@ -0,0 +1,17 @@ +import { apiFetch } from './client' +import type { User } from '../types' + +export function validateSignupToken(token: string): Promise<{ email: string }> { + return apiFetch<{ email: string }>(`/api/signup/validate?token=${encodeURIComponent(token)}`) +} + +export function completeSignup( + token: string, + username: string, + password: string, +): Promise { + return apiFetch('/api/signup', { + method: 'POST', + body: JSON.stringify({ token, username, password }), + }) +} diff --git a/frontend/src/pages/AdminPage.css b/frontend/src/pages/AdminPage.css index 6f94402..99ef307 100644 --- a/frontend/src/pages/AdminPage.css +++ b/frontend/src/pages/AdminPage.css @@ -228,3 +228,67 @@ font-size: 0.95rem; margin: var(--sp-6) 0 var(--sp-3); } + +.admin-settings-form { + display: flex; + flex-direction: column; + gap: var(--sp-4); + max-width: 480px; +} + +.admin-settings-row { + display: flex; + gap: var(--sp-3); +} + +.admin-settings-field { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.78rem; + color: var(--ds-muted); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-settings-field-narrow { + flex: 0 0 120px; +} + +.admin-settings-field input { + background: var(--ds-surface-2); + border: 1px solid var(--ds-border); + border-radius: var(--radius); + padding: 9px 11px; + font-size: 0.9rem; + color: var(--ds-text); + text-transform: none; + letter-spacing: 0; + font-weight: 400; +} + +.admin-settings-field input:focus { + border-color: var(--ds-accent); + outline: none; +} + +.admin-settings-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.86rem; + color: var(--ds-text); +} + +.admin-settings-actions { + display: flex; + gap: var(--sp-2); +} + +.admin-settings-test-result { + font-size: 0.84rem; + color: var(--ds-muted); + margin: 0; +} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 06ffba4..39eb95f 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -1,19 +1,25 @@ -import { Fragment, useEffect, useState } from 'react' +import { Fragment, useEffect, useState, type FormEvent } from 'react' import { Link } from 'react-router-dom' import { archiveRoom, deactivateUser, demoteUser, + getSmtpSettings, + inviteUser, listAdminRooms, listAdminUsers, listAllEventSubscriptions, listAllIncomingWebhooks, listAuditLog, + listSiteInvites, promoteUser, reactivateUser, resetUserPassword, + revokeSiteInvite, + sendTestSmtpEmail, transferOwnershipAdmin, unarchiveRoom, + updateSmtpSettings, } from '../api/admin' import { ApiError } from '../api/client' import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots' @@ -28,6 +34,8 @@ import type { AuditLogEntry, Bot, EventSubscriptionAdmin, + SiteInvite, + SmtpSettings, WebhookIncomingAdmin, } from '../types' import { TopBar } from '../components/TopBar' @@ -58,6 +66,22 @@ export function AdminPage() { const [incomingWebhooks, setIncomingWebhooks] = useState([]) const [eventSubscriptions, setEventSubscriptions] = useState([]) + const [siteInvites, setSiteInvites] = useState([]) + const [inviteEmail, setInviteEmail] = useState('') + const [invitingBusy, setInvitingBusy] = useState(false) + + const [smtpSettings, setSmtpSettings] = useState(null) + const [smtpLoaded, setSmtpLoaded] = useState(false) + const [smtpHost, setSmtpHost] = useState('') + const [smtpPort, setSmtpPort] = useState('587') + const [smtpUsername, setSmtpUsername] = useState('') + const [smtpPassword, setSmtpPassword] = useState('') + const [smtpFromAddress, setSmtpFromAddress] = useState('') + const [smtpUseTls, setSmtpUseTls] = useState(true) + const [smtpSaving, setSmtpSaving] = useState(false) + const [smtpTestBusy, setSmtpTestBusy] = useState(false) + const [smtpTestResult, setSmtpTestResult] = useState(null) + function reportError(err: unknown) { setError(err instanceof ApiError ? err.message : String(err)) } @@ -88,8 +112,31 @@ export function AdminPage() { listAllEventSubscriptions().then(setEventSubscriptions).catch(reportError) } + function loadSiteInvites() { + listSiteInvites().then(setSiteInvites).catch(reportError) + } + + function loadSmtpSettings() { + getSmtpSettings() + .then((cfg) => { + setSmtpSettings(cfg) + setSmtpLoaded(true) + if (cfg) { + setSmtpHost(cfg.host) + setSmtpPort(String(cfg.port)) + setSmtpUsername(cfg.username ?? '') + setSmtpFromAddress(cfg.from_address) + setSmtpUseTls(cfg.use_tls) + } + }) + .catch(reportError) + } + useEffect(() => { - if (tab === 'users') loadUsers() + if (tab === 'users') { + loadUsers() + loadSiteInvites() + } if (tab === 'rooms') { loadRooms() if (users.length === 0) loadUsers() // needed to resolve usernames for ownership transfer @@ -99,6 +146,7 @@ export function AdminPage() { loadWebhooksAdmin() } if (tab === 'audit') loadAuditLog() + if (tab === 'settings') loadSmtpSettings() // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab]) @@ -210,6 +258,64 @@ export function AdminPage() { }) } + async function handleInviteUser() { + const email = inviteEmail.trim() + if (!email) return + setInvitingBusy(true) + setError(null) + try { + await inviteUser(email) + setInviteEmail('') + loadSiteInvites() + } catch (err) { + reportError(err) + } finally { + setInvitingBusy(false) + } + } + + async function handleRevokeSiteInvite(invite: SiteInvite) { + await withBusy(invite.id, async () => { + const updated = await revokeSiteInvite(invite.id) + setSiteInvites((prev) => prev.map((i) => (i.id === updated.id ? updated : i))) + }) + } + + async function handleSaveSmtpSettings(e: FormEvent) { + e.preventDefault() + setSmtpSaving(true) + setError(null) + try { + const updated = await updateSmtpSettings({ + host: smtpHost.trim(), + port: Number(smtpPort), + username: smtpUsername.trim() || null, + password: smtpPassword || undefined, + from_address: smtpFromAddress.trim(), + use_tls: smtpUseTls, + }) + setSmtpSettings(updated) + setSmtpPassword('') + } catch (err) { + reportError(err) + } finally { + setSmtpSaving(false) + } + } + + async function handleSendTestEmail() { + setSmtpTestBusy(true) + setSmtpTestResult(null) + try { + await sendTestSmtpEmail() + setSmtpTestResult('Test email sent — check your inbox.') + } catch (err) { + setSmtpTestResult(err instanceof ApiError ? err.message : String(err)) + } finally { + setSmtpTestBusy(false) + } + } + return (
@@ -243,6 +349,49 @@ export function AdminPage() { {error &&

{error}

} {tab === 'users' && ( + <> +
+ setInviteEmail(e.target.value)} + /> + +
+ + {siteInvites.length > 0 && ( +
+ {siteInvites.map((invite) => ( +
+ {invite.email} + + {invite.status} + {invite.status === 'pending' && + ` · expires ${new Date(invite.expires_at).toLocaleDateString()}`} + + {invite.status === 'pending' && ( + + )} +
+ ))} +
+ )} + @@ -300,6 +449,7 @@ export function AdminPage() { ))}
+ )} {tab === 'rooms' && ( @@ -523,9 +673,88 @@ export function AdminPage() { )} {tab === 'settings' && ( -

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

+ <> +

SMTP (outgoing email)

+ {!smtpLoaded &&

Loading…

} + {smtpLoaded && ( +
+
+ + +
+
+ + +
+ + +
+ + +
+ {smtpTestResult &&

{smtpTestResult}

} +
+ )} + )}
diff --git a/frontend/src/pages/SignupPage.tsx b/frontend/src/pages/SignupPage.tsx new file mode 100644 index 0000000..4b2dfc9 --- /dev/null +++ b/frontend/src/pages/SignupPage.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { Navigate, useNavigate, useSearchParams } from 'react-router-dom' +import { ApiError } from '../api/client' +import { completeSignup, validateSignupToken } from '../api/signup' +import { useAuth } from '../context/AuthContext' +import logo from '../assets/logo.png' +import './LoginPage.css' + +export function SignupPage() { + const { user, updateUser } = useAuth() + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const token = searchParams.get('token') ?? '' + + const [checking, setChecking] = useState(true) + const [email, setEmail] = useState(null) + const [validationError, setValidationError] = useState(null) + + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + if (!token) { + setValidationError('This invite link is missing a token.') + setChecking(false) + return + } + validateSignupToken(token) + .then((result) => setEmail(result.email)) + .catch((err) => { + setValidationError(err instanceof ApiError ? err.message : 'This invite link is invalid.') + }) + .finally(() => setChecking(false)) + }, [token]) + + if (user) return + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + setError(null) + setSubmitting(true) + try { + const newUser = await completeSignup(token, username, password) + updateUser(newUser) + navigate('/rooms') + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Something went wrong') + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+ + KeepItTalking +
+ + {checking &&

Checking your invite…

} + + {!checking && validationError && ( + <> +

{validationError}

+

Ask whoever invited you to send a new invite.

+ + )} + + {!checking && !validationError && ( + <> +

Set up your account for {email}.

+
+ + + {error &&

{error}

} + +
+ + )} +
+
+ ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d8a6330..4ec1076 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -206,3 +206,22 @@ export interface EventSubscriptionAdmin extends EventSubscription { room_name: string | null created_by_username: string } + +export interface SiteInvite { + id: string + email: string + invited_by: string + status: InviteStatus + expires_at: string + created_at: string +} + +export interface SmtpSettings { + host: string + port: number + username: string | null + has_password: boolean + from_address: string + use_tls: boolean + updated_at: string +}