added backends, improved templating, rbac

This commit is contained in:
2026-06-10 14:40:22 +02:00
parent d9ca48addc
commit ce43f2658f
28 changed files with 1183 additions and 78 deletions

View File

@@ -1,12 +1,16 @@
from fastapi import APIRouter
from .admin import router as admin_router
from .auth import router as auth_router
from .campaigns import router as campaigns_router
from .audit import router as audit_router
from .system import router as system_router
from .mail import router as mail_router
router = APIRouter(prefix="/api/v1")
router.include_router(auth_router)
router.include_router(campaigns_router)
router.include_router(admin_router)
router.include_router(audit_router)
router.include_router(system_router)
router.include_router(mail_router)

128
server/app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,128 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.api.v1.schemas import GroupInfo, LoginRequest, LoginResponse, MeResponse, RoleInfo, TenantInfo, TenantMembershipInfo, UserInfo
from app.auth.dependencies import ApiPrincipal, get_api_principal
from app.db.models import Tenant, User
from app.db.session import get_session
from app.security.passwords import verify_password
from app.security.sessions import collect_user_groups, collect_user_roles, collect_user_scopes, create_auth_session, revoke_auth_session
router = APIRouter(prefix="/auth", tags=["auth"])
def _tenant_info(tenant: Tenant) -> TenantInfo:
return TenantInfo(id=tenant.id, slug=tenant.slug, name=tenant.name)
def _user_info(user: User) -> UserInfo:
return UserInfo(id=user.id, email=user.email, display_name=user.display_name, is_tenant_admin=user.is_tenant_admin)
def _roles_info(roles) -> list[RoleInfo]:
return [RoleInfo(id=r.id, slug=r.slug, name=r.name, permissions=r.permissions or []) for r in roles]
def _groups_info(groups) -> list[GroupInfo]:
return [GroupInfo(id=g.id, slug=g.slug, name=g.name) for g in groups]
def _tenant_memberships_for_email(session: Session, email: str) -> list[TenantMembershipInfo]:
"""Return tenants that currently contain an active user with this email.
The current data model still stores users inside one tenant. Until a dedicated
cross-tenant identity table exists, matching by email is the lightweight bridge
that lets the frontend render tenant context and later expose switching only
when more than one membership exists.
"""
rows = (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.email == email, User.is_active.is_(True), Tenant.is_active.is_(True))
.order_by(Tenant.name.asc())
.all()
)
memberships: list[TenantMembershipInfo] = []
for user, tenant in rows:
roles = collect_user_roles(session, user)
memberships.append(
TenantMembershipInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
roles=[role.slug for role in roles],
is_active=True,
)
)
return memberships
def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[User, Tenant]:
query = (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.email == payload.email, User.is_active.is_(True), Tenant.is_active.is_(True))
)
if payload.tenant_slug:
query = query.filter(Tenant.slug == payload.tenant_slug)
rows = query.order_by(Tenant.name.asc()).all()
for user, tenant in rows:
if user.password_hash and verify_password(payload.password, user.password_hash):
return user, tenant
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request, session: Session = Depends(get_session)):
user, tenant = _resolve_login_user(session, payload)
user_agent = request.headers.get("user-agent")
ip_address = request.client.host if request.client else None
created = create_auth_session(session, user=user, user_agent=user_agent, ip_address=ip_address)
roles = collect_user_roles(session, user)
groups = collect_user_groups(session, user)
scopes = collect_user_scopes(session, user)
tenants = _tenant_memberships_for_email(session, user.email)
session.commit()
active_tenant = _tenant_info(tenant)
return LoginResponse(
access_token=created.token,
expires_at=created.model.expires_at,
user=_user_info(user),
tenant=active_tenant,
active_tenant=active_tenant,
tenants=tenants,
scopes=scopes,
roles=_roles_info(roles),
groups=_groups_info(groups),
)
@router.get("/me", response_model=MeResponse)
def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)):
tenant = session.get(Tenant, principal.tenant_id)
roles = collect_user_roles(session, principal.user)
groups = collect_user_groups(session, principal.user)
active_tenant = _tenant_info(tenant)
return MeResponse(
user=_user_info(principal.user),
tenant=active_tenant,
active_tenant=active_tenant,
tenants=_tenant_memberships_for_email(session, principal.user.email),
scopes=principal.scopes,
roles=_roles_info(roles),
groups=_groups_info(groups),
)
@router.post("/logout")
def logout(request: Request, session: Session = Depends(get_session)):
authorization = request.headers.get("authorization") or ""
if authorization.lower().startswith("bearer "):
revoke_auth_session(session, authorization[7:].strip())
session.commit()
return {"ok": True}

116
server/app/api/v1/mail.py Normal file
View File

@@ -0,0 +1,116 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.api.v1.schemas import (
MailConnectionTestResponse,
MailImapFolderListResponse,
MailImapFolderResponse,
MailImapTestRequest,
MailSmtpTestRequest,
)
from app.auth.dependencies import ApiPrincipal, require_scope
from app.mailer.sending.imap import list_imap_folders, test_imap_login
from app.mailer.sending.smtp import test_smtp_login
router = APIRouter(prefix="/mail", tags=["mail"])
def _safe_error_message(exc: Exception) -> str:
text = str(exc).strip()
return text or exc.__class__.__name__
@router.post("/test-smtp", response_model=MailConnectionTestResponse)
def test_smtp_settings(
payload: MailSmtpTestRequest,
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
"""Test SMTP connectivity/login without sending any message."""
del principal
try:
result = test_smtp_login(smtp_config=payload)
return MailConnectionTestResponse(
ok=True,
protocol="smtp",
host=result.host,
port=result.port,
security=result.security,
message="SMTP connection successful.",
details={"authenticated": result.authenticated},
)
except Exception as exc:
return MailConnectionTestResponse(
ok=False,
protocol="smtp",
host=payload.host,
port=payload.port,
security=payload.security.value,
message=_safe_error_message(exc),
details={"error_type": exc.__class__.__name__},
)
@router.post("/test-imap", response_model=MailConnectionTestResponse)
def test_imap_settings(
payload: MailImapTestRequest,
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
"""Test IMAP connectivity/login without selecting or appending messages."""
del principal
try:
result = test_imap_login(imap_config=payload)
return MailConnectionTestResponse(
ok=True,
protocol="imap",
host=result.host,
port=result.port,
security=result.security,
message="IMAP connection successful.",
details={"authenticated": result.authenticated},
)
except Exception as exc:
return MailConnectionTestResponse(
ok=False,
protocol="imap",
host=payload.host,
port=payload.port,
security=payload.security.value,
message=_safe_error_message(exc),
details={"error_type": exc.__class__.__name__},
)
@router.post("/list-imap-folders", response_model=MailImapFolderListResponse)
def list_imap_folder_settings(
payload: MailImapTestRequest,
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
):
"""List visible IMAP folders and return the best Sent-folder guess."""
del principal
try:
result = list_imap_folders(imap_config=payload)
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
return MailImapFolderListResponse(
ok=True,
host=result.host,
port=result.port,
security=result.security,
message=f"Found {len(folders)} IMAP folder(s).",
folders=folders,
detected_sent_folder=result.detected_sent_folder,
)
except Exception as exc:
return MailImapFolderListResponse(
ok=False,
host=payload.host,
port=payload.port,
security=payload.security.value,
message=_safe_error_message(exc),
folders=[],
detected_sent_folder=None,
details={"error_type": exc.__class__.__name__},
)

View File

@@ -1,10 +1,12 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from app.mailer.campaign.models import ImapConfig, SmtpConfig
class CampaignCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -126,6 +128,43 @@ class BuildCampaignRequest(BaseModel):
write_eml: bool = True
class MailSmtpTestRequest(SmtpConfig):
"""SMTP settings supplied directly from the WebUI mail settings form."""
class MailImapTestRequest(ImapConfig):
"""IMAP settings supplied directly from the WebUI mail settings form."""
enabled: bool = True
class MailConnectionTestResponse(BaseModel):
ok: bool
protocol: Literal["smtp", "imap"]
host: str | None = None
port: int | None = None
security: str | None = None
message: str
details: dict[str, Any] = Field(default_factory=dict)
class MailImapFolderResponse(BaseModel):
name: str
flags: list[str] = Field(default_factory=list)
class MailImapFolderListResponse(BaseModel):
ok: bool
protocol: Literal["imap"] = "imap"
host: str | None = None
port: int | None = None
security: str | None = None
message: str
folders: list[MailImapFolderResponse] = Field(default_factory=list)
detected_sent_folder: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
class ApiKeyCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -200,3 +239,70 @@ class AuditLogItemResponse(BaseModel):
class AuditLogListResponse(BaseModel):
items: list[AuditLogItemResponse]
class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
email: str
password: str
# Kept optional for backwards compatibility and future tenant-switch login flows.
# The WebUI no longer sends it. If omitted, the backend resolves the user by email.
tenant_slug: str | None = None
class TenantInfo(BaseModel):
id: str
slug: str
name: str
class TenantMembershipInfo(TenantInfo):
roles: list[str] = Field(default_factory=list)
is_active: bool = True
class UserInfo(BaseModel):
id: str
email: str
display_name: str | None = None
is_tenant_admin: bool = False
class RoleInfo(BaseModel):
id: str
slug: str
name: str
permissions: list[str] = Field(default_factory=list)
class GroupInfo(BaseModel):
id: str
slug: str
name: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_at: datetime
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
roles: list[RoleInfo] = Field(default_factory=list)
groups: list[GroupInfo] = Field(default_factory=list)
class MeResponse(BaseModel):
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
roles: list[RoleInfo] = Field(default_factory=list)
groups: list[GroupInfo] = Field(default_factory=list)