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
+4
View File
@@ -9,6 +9,8 @@ from app.models.message_image import MessageImage
from app.models.message_reaction import MessageReaction
from app.models.push_subscription import PushSubscription
from app.models.room import Room
from app.models.site_invite import SiteInvite
from app.models.smtp_settings import SmtpSettings
from app.models.user import User
from app.models.webhook_incoming import WebhookIncoming
@@ -23,6 +25,8 @@ __all__ = [
"MessageReaction",
"RoomInvite",
"InviteStatus",
"SiteInvite",
"SmtpSettings",
"PushSubscription",
"AdminAuditLog",
"ApiToken",
+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
)