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
+108 -1
View File
@@ -1,6 +1,6 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,6 +14,8 @@ from app.schemas.admin import (
ResetPasswordRequest,
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.services.admin_service import (
CannotActOnSelfError,
@@ -29,6 +31,15 @@ from app.services.admin_service import (
transfer_ownership_admin,
)
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 (
list_all_event_subscriptions_admin,
list_all_incoming_webhooks_admin,
@@ -272,3 +283,99 @@ async def list_event_subscriptions_admin_endpoint(
)
for s in subscriptions
]
@router.post("/invites", response_model=SiteInviteRead, status_code=201)
async def create_site_invite_endpoint(
data: SiteInviteCreate,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
return await create_site_invite(db, current_user, str(request.base_url), data.email)
@router.get("/invites", response_model=list[SiteInviteRead])
async def list_site_invites_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
return await list_site_invites(db)
@router.delete("/invites/{invite_id}", response_model=SiteInviteRead)
async def revoke_site_invite_endpoint(
invite_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
try:
return await revoke_site_invite(db, current_user, invite_id)
except SiteInviteNotFoundError:
raise HTTPException(status_code=404, detail="Invite not found")
except SiteInviteNotPendingError:
raise HTTPException(status_code=400, detail="Invite is no longer pending")
@router.get("/settings/smtp", response_model=SmtpSettingsRead | None)
async def get_smtp_settings_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
cfg = await get_smtp_settings(db)
if cfg is None:
return None
return SmtpSettingsRead(
host=cfg.host,
port=cfg.port,
username=cfg.username,
has_password=bool(cfg.password_encrypted),
from_address=cfg.from_address,
use_tls=cfg.use_tls,
updated_at=cfg.updated_at,
)
@router.put("/settings/smtp", response_model=SmtpSettingsRead)
async def update_smtp_settings_endpoint(
data: SmtpSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
cfg = await upsert_smtp_settings(
db,
host=data.host,
port=data.port,
username=data.username,
password=data.password,
from_address=data.from_address,
use_tls=data.use_tls,
)
return SmtpSettingsRead(
host=cfg.host,
port=cfg.port,
username=cfg.username,
has_password=bool(cfg.password_encrypted),
from_address=cfg.from_address,
use_tls=cfg.use_tls,
updated_at=cfg.updated_at,
)
@router.post("/settings/smtp/test", status_code=204)
async def test_smtp_settings_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
try:
await send_test_email(db, current_user.email)
except SmtpNotConfiguredError:
raise HTTPException(status_code=400, detail="SMTP is not configured yet")
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Failed to send test email: {exc}")
+4 -1
View File
@@ -390,12 +390,15 @@ def _to_invite_read(invite) -> InviteRead:
async def create_invite_endpoint(
room_id: uuid.UUID,
data: InviteCreate,
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_role(room_id, current_user, db, RoomRole.admin)
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:
raise HTTPException(status_code=404, detail="No user with that username")
except AlreadyMemberError:
+43
View File
@@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.site_invite import SignupComplete, SignupValidateRead
from app.schemas.user import UserRead
from app.services.auth_service import DuplicateUserError
from app.services.site_invite_service import (
SiteInviteInvalidError,
complete_signup,
validate_signup_token,
)
router = APIRouter(prefix="/api/signup", tags=["signup"])
@router.get("/validate", response_model=SignupValidateRead)
async def validate_signup_endpoint(
token: str = Query(...),
db: AsyncSession = Depends(get_db),
):
try:
invite = await validate_signup_token(db, token)
except SiteInviteInvalidError:
raise HTTPException(status_code=400, detail="This invite link is invalid or has expired")
return SignupValidateRead(email=invite.email)
@router.post("", response_model=UserRead)
async def complete_signup_endpoint(
data: SignupComplete,
request: Request,
db: AsyncSession = Depends(get_db),
):
try:
user = await complete_signup(db, data.token, data.username, data.password)
except SiteInviteInvalidError:
raise HTTPException(status_code=400, detail="This invite link is invalid or has expired")
except DuplicateUserError:
raise HTTPException(status_code=409, detail="That username or email is already taken")
request.session["user_id"] = str(user.id)
return user