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:
@@ -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 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.chat import router as ws_router
|
||||
from app.ws.connection_manager import ConnectionManager
|
||||
@@ -72,6 +72,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(signup.router)
|
||||
app.include_router(rooms.router)
|
||||
app.include_router(users.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.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
|
||||
)
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.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):
|
||||
@@ -38,7 +39,11 @@ class InviteExpiredError(Exception):
|
||||
|
||||
|
||||
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:
|
||||
result = await db.execute(select(User).where(User.username == target_username))
|
||||
target = result.scalar_one_or_none()
|
||||
@@ -73,6 +78,15 @@ async def create_invite(
|
||||
await db.commit()
|
||||
await db.refresh(invite)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user