Add admin-invited signups and email notifications (Gitea issue #15)

Site admins can invite a brand-new person by email from the Admin portal
Users tab -- a signup-link email lets them set their own username/password
and lands them in the app already logged in. Existing users invited to a
room now also get an email. Closes the "invited but never notified" gap
from both directions.

SMTP is configured through the Admin Settings tab at runtime (not the env
file), persisted in a new smtp_settings table with the password encrypted
at rest via a Fernet key derived from SESSION_SECRET -- the first
reversible secret this app stores in the database. A "send test email"
button surfaces real delivery errors; the invite/notification paths
themselves never fail loudly, since an SMTP outage shouldn't block an
action that already succeeded in the database.

New site_invites table mirrors RoomInvite's shape but targets an email
address with no room context; the raw signup token is hashed the same way
API tokens are, and only ever exists in the email link. POST /api/signup
is the first genuinely public, unauthenticated account-creation endpoint
in this app, reusing the existing register_user path for identical
validation.
This commit is contained in:
2026-08-14 17:38:56 -06:00
parent ad1beccd3a
commit b724f8a33b
28 changed files with 1561 additions and 20 deletions
+6
View File
@@ -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 # 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 ### 3d. Frontend build
`backend/app/main.py` serves `frontend/dist` directly (alongside `/api` and `backend/app/main.py` serves `frontend/dist` directly (alongside `/api` and
+66 -9
View File
@@ -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 FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
CRUD (open and private), room roles (owner/admin/member) and invites, a CRUD (open and private), room roles (owner/admin/member) and 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/ site-admin portal (user/room/bot management + an audit log), a bot/
extension layer (scoped API tokens, live bot WebSocket access, incoming and extension layer (scoped API tokens, live bot WebSocket access, incoming and
outgoing webhooks, message editing), image uploads in chat messages, emoji outgoing webhooks, message editing), image uploads in chat messages, emoji
reactions on messages, and self-service user profiles (display name, reactions on messages, self-service user profiles (display name, avatar),
avatar). See `../ARCHITECTURE.md` for the full system design and the and admin-issued email invites for new accounts plus email notifications
phased build plan. 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. This is an **invite-only site**: there is no public registration endpoint.
Accounts are created by an operator on the app server — see step 4 below. Accounts are created by an operator on the app server — see step 4 below.
@@ -117,18 +118,22 @@ app/
require_room_member, require_room_role, require_room_member, require_room_role,
require_site_admin, require_scope require_site_admin, require_scope
security.py argon2 password hashing + token generate/hash (sha256) 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 storage.py uploaded-image validation (Pillow), downscaling
(optionally square-cropped, for avatars), and (optionally square-cropped, for avatars), and
on-disk save/read -- see Image uploads below on-disk save/read -- see Image uploads below
cli.py `python -m app.cli create-user` / `generate-vapid-keys` cli.py `python -m app.cli create-user` / `generate-vapid-keys`
models/ SQLAlchemy models (users, rooms, room_memberships, models/ SQLAlchemy models (users, rooms, room_memberships,
messages, message_images, message_reactions, messages, message_images, message_reactions,
room_invites, push_subscriptions, room_invites, site_invites, smtp_settings,
admin_audit_log, api_tokens, webhooks_incoming, push_subscriptions, admin_audit_log, api_tokens,
event_subscriptions) webhooks_incoming, event_subscriptions)
schemas/ Pydantic request/response models schemas/ Pydantic request/response models
routers/ auth, rooms, users, invites, push, admin, bots, routers/ auth, rooms, users, invites, signup, push, admin,
webhooks, health bots, webhooks, health
services/ business logic called by routers services/ business logic called by routers
ws/ connection_manager (local sockets), presence + ws/ connection_manager (local sockets), presence +
broadcaster (Redis), /ws/chat handler 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 per-message, which is the more correct behavior for a field the sender can
change after the fact. 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 ## Notes / scope decisions
- Invite-only site registration: no `POST /api/auth/register`. Accounts are - Invite-only site registration: no `POST /api/auth/register`. Accounts are
@@ -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 ###
+23
View File
@@ -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()
+2 -1
View File
@@ -11,7 +11,7 @@ from redis.asyncio import Redis
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from app.config import settings from app.config import settings
from app.routers import admin, auth, bots, health, invites, push, rooms, 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.broadcaster import RoomBroadcaster
from app.ws.chat import router as ws_router from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager from app.ws.connection_manager import ConnectionManager
@@ -72,6 +72,7 @@ def create_app() -> FastAPI:
app.include_router(health.router) app.include_router(health.router)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(signup.router)
app.include_router(rooms.router) app.include_router(rooms.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(invites.router) app.include_router(invites.router)
+4
View File
@@ -9,6 +9,8 @@ from app.models.message_image import MessageImage
from app.models.message_reaction import MessageReaction from app.models.message_reaction import MessageReaction
from app.models.push_subscription import PushSubscription from app.models.push_subscription import PushSubscription
from app.models.room import Room 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.user import User
from app.models.webhook_incoming import WebhookIncoming from app.models.webhook_incoming import WebhookIncoming
@@ -23,6 +25,8 @@ __all__ = [
"MessageReaction", "MessageReaction",
"RoomInvite", "RoomInvite",
"InviteStatus", "InviteStatus",
"SiteInvite",
"SmtpSettings",
"PushSubscription", "PushSubscription",
"AdminAuditLog", "AdminAuditLog",
"ApiToken", "ApiToken",
+40
View File
@@ -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")
+31
View File
@@ -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
)
+108 -1
View File
@@ -1,6 +1,6 @@
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,6 +14,8 @@ from app.schemas.admin import (
ResetPasswordRequest, ResetPasswordRequest,
TransferOwnershipRequest, 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.schemas.webhook import EventSubscriptionAdminRead, WebhookIncomingAdminRead
from app.services.admin_service import ( from app.services.admin_service import (
CannotActOnSelfError, CannotActOnSelfError,
@@ -29,6 +31,15 @@ from app.services.admin_service import (
transfer_ownership_admin, transfer_ownership_admin,
) )
from app.services.audit import list_audit_log 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 ( from app.services.webhook_service import (
list_all_event_subscriptions_admin, list_all_event_subscriptions_admin,
list_all_incoming_webhooks_admin, list_all_incoming_webhooks_admin,
@@ -272,3 +283,99 @@ async def list_event_subscriptions_admin_endpoint(
) )
for s in subscriptions 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}")
+4 -1
View File
@@ -390,12 +390,15 @@ def _to_invite_read(invite) -> InviteRead:
async def create_invite_endpoint( async def create_invite_endpoint(
room_id: uuid.UUID, room_id: uuid.UUID,
data: InviteCreate, data: InviteCreate,
request: Request,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
await require_room_role(room_id, current_user, db, RoomRole.admin) await require_room_role(room_id, current_user, db, RoomRole.admin)
try: 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: except TargetUserNotFoundError:
raise HTTPException(status_code=404, detail="No user with that username") raise HTTPException(status_code=404, detail="No user with that username")
except AlreadyMemberError: except AlreadyMemberError:
+43
View File
@@ -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
+31
View File
@@ -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)
+24
View File
@@ -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
+63
View File
@@ -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.",
)
+16 -2
View File
@@ -6,7 +6,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.models import 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): class TargetUserNotFoundError(Exception):
@@ -38,7 +39,11 @@ class InviteExpiredError(Exception):
async def create_invite( 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: ) -> RoomInvite:
result = await db.execute(select(User).where(User.username == target_username)) result = await db.execute(select(User).where(User.username == target_username))
target = result.scalar_one_or_none() target = result.scalar_one_or_none()
@@ -73,6 +78,15 @@ async def create_invite(
await db.commit() await db.commit()
await db.refresh(invite) await db.refresh(invite)
invite.target_user = target 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 return invite
+102
View File
@@ -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
@@ -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
+2
View File
@@ -20,6 +20,8 @@ dependencies = [
"gunicorn>=23.0", "gunicorn>=23.0",
"Pillow>=10.0", "Pillow>=10.0",
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"aiosmtplib>=3.0",
"cryptography>=43.0",
] ]
[project.scripts] [project.scripts]
+61 -1
View File
@@ -1,10 +1,28 @@
import uuid import uuid
from datetime import datetime, timedelta, timezone 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 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"): async def _create_private_room(client, name="secret"):
resp = await client.post("/api/rooms", json={"name": name, "is_private": True}) resp = await client.post("/api/rooms", json={"name": name, "is_private": True})
assert resp.status_code == 201, resp.text 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") await login_as(client, "bob")
resp = await client.post(f"/api/invites/{invite['id']}/accept") resp = await client.post(f"/api/invites/{invite['id']}/accept")
assert resp.status_code == 400 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
+170
View File
@@ -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
+174
View File
@@ -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
+2
View File
@@ -3,6 +3,7 @@ import { AuthProvider } from './context/AuthContext'
import { AdminRoute } from './components/AdminRoute' import { AdminRoute } from './components/AdminRoute'
import { ProtectedRoute } from './components/ProtectedRoute' import { ProtectedRoute } from './components/ProtectedRoute'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
import { SignupPage } from './pages/SignupPage'
import { ChatShellPage } from './pages/ChatShellPage' import { ChatShellPage } from './pages/ChatShellPage'
import { AdminPage } from './pages/AdminPage' import { AdminPage } from './pages/AdminPage'
@@ -11,6 +12,7 @@ function App() {
<AuthProvider> <AuthProvider>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route <Route
path="/rooms" path="/rooms"
element={ element={
+41
View File
@@ -4,6 +4,8 @@ import type {
AdminUser, AdminUser,
AuditLogEntry, AuditLogEntry,
EventSubscriptionAdmin, EventSubscriptionAdmin,
SiteInvite,
SmtpSettings,
WebhookIncomingAdmin, WebhookIncomingAdmin,
} from '../types' } from '../types'
@@ -64,3 +66,42 @@ export function listAllIncomingWebhooks(): Promise<WebhookIncomingAdmin[]> {
export function listAllEventSubscriptions(): Promise<EventSubscriptionAdmin[]> { export function listAllEventSubscriptions(): Promise<EventSubscriptionAdmin[]> {
return apiFetch<EventSubscriptionAdmin[]>('/api/admin/event-subscriptions') return apiFetch<EventSubscriptionAdmin[]>('/api/admin/event-subscriptions')
} }
export function inviteUser(email: string): Promise<SiteInvite> {
return apiFetch<SiteInvite>('/api/admin/invites', {
method: 'POST',
body: JSON.stringify({ email }),
})
}
export function listSiteInvites(): Promise<SiteInvite[]> {
return apiFetch<SiteInvite[]>('/api/admin/invites')
}
export function revokeSiteInvite(inviteId: string): Promise<SiteInvite> {
return apiFetch<SiteInvite>(`/api/admin/invites/${inviteId}`, { method: 'DELETE' })
}
export function getSmtpSettings(): Promise<SmtpSettings | null> {
return apiFetch<SmtpSettings | null>('/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<SmtpSettings> {
return apiFetch<SmtpSettings>('/api/admin/settings/smtp', {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export function sendTestSmtpEmail(): Promise<void> {
return apiFetch<void>('/api/admin/settings/smtp/test', { method: 'POST' })
}
+17
View File
@@ -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<User> {
return apiFetch<User>('/api/signup', {
method: 'POST',
body: JSON.stringify({ token, username, password }),
})
}
+64
View File
@@ -228,3 +228,67 @@
font-size: 0.95rem; font-size: 0.95rem;
margin: var(--sp-6) 0 var(--sp-3); 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;
}
+234 -5
View File
@@ -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 { Link } from 'react-router-dom'
import { import {
archiveRoom, archiveRoom,
deactivateUser, deactivateUser,
demoteUser, demoteUser,
getSmtpSettings,
inviteUser,
listAdminRooms, listAdminRooms,
listAdminUsers, listAdminUsers,
listAllEventSubscriptions, listAllEventSubscriptions,
listAllIncomingWebhooks, listAllIncomingWebhooks,
listAuditLog, listAuditLog,
listSiteInvites,
promoteUser, promoteUser,
reactivateUser, reactivateUser,
resetUserPassword, resetUserPassword,
revokeSiteInvite,
sendTestSmtpEmail,
transferOwnershipAdmin, transferOwnershipAdmin,
unarchiveRoom, unarchiveRoom,
updateSmtpSettings,
} from '../api/admin' } from '../api/admin'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots' import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots'
@@ -28,6 +34,8 @@ import type {
AuditLogEntry, AuditLogEntry,
Bot, Bot,
EventSubscriptionAdmin, EventSubscriptionAdmin,
SiteInvite,
SmtpSettings,
WebhookIncomingAdmin, WebhookIncomingAdmin,
} from '../types' } from '../types'
import { TopBar } from '../components/TopBar' import { TopBar } from '../components/TopBar'
@@ -58,6 +66,22 @@ export function AdminPage() {
const [incomingWebhooks, setIncomingWebhooks] = useState<WebhookIncomingAdmin[]>([]) const [incomingWebhooks, setIncomingWebhooks] = useState<WebhookIncomingAdmin[]>([])
const [eventSubscriptions, setEventSubscriptions] = useState<EventSubscriptionAdmin[]>([]) const [eventSubscriptions, setEventSubscriptions] = useState<EventSubscriptionAdmin[]>([])
const [siteInvites, setSiteInvites] = useState<SiteInvite[]>([])
const [inviteEmail, setInviteEmail] = useState('')
const [invitingBusy, setInvitingBusy] = useState(false)
const [smtpSettings, setSmtpSettings] = useState<SmtpSettings | null>(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<string | null>(null)
function reportError(err: unknown) { function reportError(err: unknown) {
setError(err instanceof ApiError ? err.message : String(err)) setError(err instanceof ApiError ? err.message : String(err))
} }
@@ -88,8 +112,31 @@ export function AdminPage() {
listAllEventSubscriptions().then(setEventSubscriptions).catch(reportError) 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(() => { useEffect(() => {
if (tab === 'users') loadUsers() if (tab === 'users') {
loadUsers()
loadSiteInvites()
}
if (tab === 'rooms') { if (tab === 'rooms') {
loadRooms() loadRooms()
if (users.length === 0) loadUsers() // needed to resolve usernames for ownership transfer if (users.length === 0) loadUsers() // needed to resolve usernames for ownership transfer
@@ -99,6 +146,7 @@ export function AdminPage() {
loadWebhooksAdmin() loadWebhooksAdmin()
} }
if (tab === 'audit') loadAuditLog() if (tab === 'audit') loadAuditLog()
if (tab === 'settings') loadSmtpSettings()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab]) }, [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 ( return (
<div className="admin-page"> <div className="admin-page">
<TopBar /> <TopBar />
@@ -243,6 +349,49 @@ export function AdminPage() {
{error && <p className="admin-error">{error}</p>} {error && <p className="admin-error">{error}</p>}
{tab === 'users' && ( {tab === 'users' && (
<>
<div className="admin-create-form">
<input
type="email"
placeholder="Email address to invite"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
/>
<button
type="button"
className="btn-secondary"
disabled={invitingBusy || !inviteEmail.trim()}
onClick={handleInviteUser}
>
{invitingBusy ? 'Sending…' : 'Send invite'}
</button>
</div>
{siteInvites.length > 0 && (
<div className="admin-token-list">
{siteInvites.map((invite) => (
<div key={invite.id} className="admin-token-row">
<span className="admin-token-scopes">{invite.email}</span>
<span className="admin-token-meta">
{invite.status}
{invite.status === 'pending' &&
` · expires ${new Date(invite.expires_at).toLocaleDateString()}`}
</span>
{invite.status === 'pending' && (
<button
type="button"
className="admin-token-revoke"
disabled={busyId === invite.id}
onClick={() => handleRevokeSiteInvite(invite)}
>
Revoke
</button>
)}
</div>
))}
</div>
)}
<table className="admin-table"> <table className="admin-table">
<thead> <thead>
<tr> <tr>
@@ -300,6 +449,7 @@ export function AdminPage() {
))} ))}
</tbody> </tbody>
</table> </table>
</>
)} )}
{tab === 'rooms' && ( {tab === 'rooms' && (
@@ -523,9 +673,88 @@ export function AdminPage() {
)} )}
{tab === 'settings' && ( {tab === 'settings' && (
<p className="admin-placeholder"> <>
System settings are coming in a future phase — there's nothing configurable yet. <h2 className="admin-subheading">SMTP (outgoing email)</h2>
</p> {!smtpLoaded && <p className="admin-placeholder">Loading…</p>}
{smtpLoaded && (
<form className="admin-settings-form" onSubmit={handleSaveSmtpSettings}>
<div className="admin-settings-row">
<label className="admin-settings-field">
Host
<input
type="text"
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder="smtp.example.com"
required
/>
</label>
<label className="admin-settings-field admin-settings-field-narrow">
Port
<input
type="number"
value={smtpPort}
onChange={(e) => setSmtpPort(e.target.value)}
min={1}
max={65535}
required
/>
</label>
</div>
<div className="admin-settings-row">
<label className="admin-settings-field">
Username
<input
type="text"
value={smtpUsername}
onChange={(e) => setSmtpUsername(e.target.value)}
/>
</label>
<label className="admin-settings-field">
Password
<input
type="password"
value={smtpPassword}
onChange={(e) => setSmtpPassword(e.target.value)}
placeholder={smtpSettings?.has_password ? 'Leave blank to keep current' : ''}
/>
</label>
</div>
<label className="admin-settings-field">
From address
<input
type="email"
value={smtpFromAddress}
onChange={(e) => setSmtpFromAddress(e.target.value)}
placeholder="noreply@example.com"
required
/>
</label>
<label className="admin-settings-checkbox">
<input
type="checkbox"
checked={smtpUseTls}
onChange={(e) => setSmtpUseTls(e.target.checked)}
/>
Use TLS
</label>
<div className="admin-settings-actions">
<button type="submit" className="btn-primary" disabled={smtpSaving}>
{smtpSaving ? 'Saving' : 'Save'}
</button>
<button
type="button"
className="btn-secondary"
disabled={smtpTestBusy || !smtpSettings}
onClick={handleSendTestEmail}
>
{smtpTestBusy ? 'Sending' : 'Send test email'}
</button>
</div>
{smtpTestResult && <p className="admin-settings-test-result">{smtpTestResult}</p>}
</form>
)}
</>
)} )}
</div> </div>
</div> </div>
+107
View File
@@ -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<string | null>(null)
const [validationError, setValidationError] = useState<string | null>(null)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(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 <Navigate to="/rooms" replace />
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 (
<div className="login-screen">
<div className="login-card">
<div className="login-brand">
<img src={logo} alt="" />
<span>KeepItTalking</span>
</div>
{checking && <p className="login-copy">Checking your invite</p>}
{!checking && validationError && (
<>
<p className="login-copy">{validationError}</p>
<p className="login-copy">Ask whoever invited you to send a new invite.</p>
</>
)}
{!checking && !validationError && (
<>
<p className="login-copy">Set up your account for {email}.</p>
<form className="login-form" onSubmit={handleSubmit}>
<label>
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
maxLength={50}
autoFocus
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</label>
{error && <p className="login-error">{error}</p>}
<button type="submit" className="btn-primary" disabled={submitting}>
Create account
</button>
</form>
</>
)}
</div>
</div>
)
}
+19
View File
@@ -206,3 +206,22 @@ export interface EventSubscriptionAdmin extends EventSubscription {
room_name: string | null room_name: string | null
created_by_username: string 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
}