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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user