added backends, improved templating, rbac
This commit is contained in:
@@ -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
128
server/app/api/v1/auth.py
Normal 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
116
server/app/api/v1/mail.py
Normal 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__},
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user