feat: Mandant/Reseller ohne E-Mail anlegbar (Temp-Passwort statt Einladung)
- admin_email bzw. reseller email optional; ohne E-Mail wird interne Login- Kennung (<name>@<slug>.local) + einmaliges Temp-Passwort erzeugt, Account sofort aktiv, kein Mailversand - TenantOut/ResellerOut: initial_password (einmalig) ergänzt - LoginRequest.email: str statt EmailStr (Kennung muss kein zustellbares Postfach sein; .local-Domains sind sonst nicht einloggbar) - Frontend: E-Mail-Felder optional, CredDialog zeigt Login + Temp-Passwort einmalig - Test: Anlage ohne E-Mail + Login mit generierten Zugangsdaten; 173/173 grün Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ Firma bootstrappt unter kurzzeitigem Bypass (privilegierte, auditierte Operation
|
||||
und stellt danach den RLS-Kontext des Actors wieder her.
|
||||
"""
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
@@ -38,18 +39,36 @@ class TenantService:
|
||||
counter += 1
|
||||
return slug
|
||||
|
||||
async def _unique_email(self, local_part: str, domain: str, db: AsyncSession) -> str:
|
||||
"""Erzeugt eine eindeutige, syntaktisch gültige interne Login-Kennung,
|
||||
falls keine echte E-Mail angegeben wurde (z.B. 'max.mustermann@alpha-gmbh.local')."""
|
||||
local = re.sub(r"[^a-z0-9.]+", ".", local_part.lower()).strip(".") or "admin"
|
||||
dom = re.sub(r"[^a-z0-9.-]+", "-", domain.lower()).strip("-.") or "tenant"
|
||||
email, counter = f"{local}@{dom}.local", 1
|
||||
while await db.scalar(select(User).where(User.email == email)):
|
||||
email = f"{local}{counter}@{dom}.local"
|
||||
counter += 1
|
||||
return email
|
||||
|
||||
async def create_tenant(
|
||||
self, data: TenantCreate, actor: User, db: AsyncSession,
|
||||
reseller_id: UUID | None,
|
||||
) -> Company:
|
||||
) -> tuple[Company, User, str | None]:
|
||||
"""Legt Firma + Erst-Admin an.
|
||||
|
||||
Mit admin_email → Einladung per Mail (Passwort wird beim Accept gesetzt).
|
||||
Ohne admin_email → interne Login-Kennung + Temp-Passwort (einmalig zurück-
|
||||
gegeben als 3. Tupel-Element), Account sofort aktiv, kein Mailversand.
|
||||
"""
|
||||
# E-Mail global eindeutig (User.email ist unique)
|
||||
if await db.scalar(select(User).where(User.email == data.admin_email)):
|
||||
if data.admin_email and await db.scalar(select(User).where(User.email == data.admin_email)):
|
||||
raise HTTPException(status_code=400, detail="E-Mail ist bereits vergeben.")
|
||||
|
||||
slug = await self._unique_slug(data.name, db)
|
||||
|
||||
# Bootstrap unter Bypass – neue, leere Firma; danach Kontext wiederherstellen.
|
||||
await db.execute(text("SET LOCAL app.bypass_rls = 'on'"))
|
||||
initial_password: str | None = None
|
||||
try:
|
||||
company = Company(
|
||||
name=data.name, slug=slug, plan=data.plan or "trial",
|
||||
@@ -58,18 +77,30 @@ class TenantService:
|
||||
db.add(company)
|
||||
await db.flush()
|
||||
|
||||
raw_token, token_hash = generate_invite_token()
|
||||
admin = User(
|
||||
company_id=company.id,
|
||||
email=data.admin_email,
|
||||
first_name=data.admin_first_name,
|
||||
last_name=data.admin_last_name,
|
||||
role=UserRole.COMPANY_ADMIN,
|
||||
password_hash=hash_password(raw_token), # temporär, beim Accept überschrieben
|
||||
invite_token_hash=token_hash,
|
||||
invite_expires=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
is_active=False,
|
||||
)
|
||||
if data.admin_email:
|
||||
raw_token, token_hash = generate_invite_token()
|
||||
admin = User(
|
||||
company_id=company.id, email=data.admin_email,
|
||||
first_name=data.admin_first_name, last_name=data.admin_last_name,
|
||||
role=UserRole.COMPANY_ADMIN,
|
||||
password_hash=hash_password(raw_token), # temporär, beim Accept überschrieben
|
||||
invite_token_hash=token_hash,
|
||||
invite_expires=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
is_active=False,
|
||||
)
|
||||
else:
|
||||
# Kein Mailversand: interne Kennung + sofort nutzbares Temp-Passwort
|
||||
login = await self._unique_email(
|
||||
f"{data.admin_first_name}.{data.admin_last_name}", slug, db
|
||||
)
|
||||
initial_password = secrets.token_urlsafe(9)
|
||||
admin = User(
|
||||
company_id=company.id, email=login,
|
||||
first_name=data.admin_first_name, last_name=data.admin_last_name,
|
||||
role=UserRole.COMPANY_ADMIN,
|
||||
password_hash=hash_password(initial_password),
|
||||
is_active=True,
|
||||
)
|
||||
db.add(admin)
|
||||
await db.flush()
|
||||
|
||||
@@ -84,20 +115,21 @@ class TenantService:
|
||||
entity_id=company.id,
|
||||
new_value={
|
||||
"name": company.name, "reseller_id": str(reseller_id) if reseller_id else None,
|
||||
"admin_email": admin.email,
|
||||
"admin_email": admin.email, "invited": bool(data.admin_email),
|
||||
},
|
||||
))
|
||||
await db.flush()
|
||||
|
||||
from app.services.email_service import email_service
|
||||
await email_service.send_invite(admin, actor, raw_token, db)
|
||||
if data.admin_email:
|
||||
from app.services.email_service import email_service
|
||||
await email_service.send_invite(admin, actor, raw_token, db)
|
||||
finally:
|
||||
# RLS-Kontext des Actors wiederherstellen
|
||||
if actor.role == UserRole.RESELLER:
|
||||
await db.execute(text("SET LOCAL app.bypass_rls = 'off'"))
|
||||
# SUPER_ADMIN: Bypass bleibt aktiv (Normalzustand)
|
||||
|
||||
return company
|
||||
return company, admin, initial_password
|
||||
|
||||
async def list_tenants(self, db: AsyncSession) -> list[TenantOut]:
|
||||
"""Alle für den aktuellen RLS-Kontext sichtbaren Firmen + Kennzahlen."""
|
||||
|
||||
Reference in New Issue
Block a user