Private
Public Access
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:
@@ -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
@@ -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 ###
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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.",
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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={
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user