Files
ds-chat/backend/app/services/site_invite_service.py
T
ksmith b724f8a33b 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.
2026-08-14 17:38:56 -06:00

103 lines
3.1 KiB
Python

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