feat: Mandant/Reseller ohne E-Mail anlegbar (Temp-Passwort statt Einladung)
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled

- 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:
2026-06-23 01:34:23 +02:00
co-authored by Claude Opus 4.8
parent 898a81f499
commit 98cb2f867a
9 changed files with 215 additions and 56 deletions
+5 -3
View File
@@ -38,15 +38,17 @@ async def create_company(
current_user: User = require_role(*_reseller_roles),
db: AsyncSession = Depends(get_db),
):
company = await tenant_service.create_tenant(
company, admin, initial_password = await tenant_service.create_tenant(
data, actor=current_user, db=db, reseller_id=current_user.id,
)
# Antwort aus der frisch erstellten Firma (Kennzahlen 1 Admin, 0 aktiv)
# Antwort aus der frisch erstellten Firma; bei Einladung Account inaktiv,
# bei Temp-Passwort sofort aktiv.
return TenantOut(
id=company.id, name=company.name, slug=company.slug, plan=company.plan,
is_active=company.is_active, country=company.country, reseller_id=company.reseller_id,
reseller_name=f"{current_user.first_name} {current_user.last_name}".strip(),
user_count=1, active_user_count=0, admin_email=data.admin_email,
user_count=1, active_user_count=1 if initial_password else 0,
admin_email=admin.email, initial_password=initial_password,
)
+36 -18
View File
@@ -3,6 +3,7 @@
SUPER_ADMIN läuft mit aktivem RLS-Bypass und sieht daher alle Firmen.
Nur SUPER_ADMIN darf Reseller anlegen und Firmen einem Reseller zuordnen.
"""
import secrets
from datetime import datetime, timedelta, timezone
from uuid import UUID
@@ -51,11 +52,13 @@ async def create_tenant(
reseller = await db.get(User, reseller_id)
if reseller is None or reseller.role != UserRole.RESELLER:
raise HTTPException(status_code=400, detail="Ungültiger Reseller.")
company = await tenant_service.create_tenant(
company, admin, initial_password = await tenant_service.create_tenant(
data, actor=current_user, db=db, reseller_id=reseller_id,
)
tenants = {t.id: t for t in await tenant_service.list_tenants(db)}
return tenants[company.id]
result = tenants[company.id]
result.initial_password = initial_password
return result
@router.patch("/tenants/{company_id}", response_model=TenantOut)
@@ -142,37 +145,52 @@ async def create_reseller(
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
if await db.scalar(select(User).where(User.email == data.email)):
if data.email and await db.scalar(select(User).where(User.email == data.email)):
raise HTTPException(status_code=400, detail="E-Mail ist bereits vergeben.")
raw_token, token_hash = generate_invite_token()
reseller = User(
company_id=None,
email=data.email,
first_name=data.first_name,
last_name=data.last_name,
role=UserRole.RESELLER,
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,
)
initial_password: str | None = None
if data.email:
raw_token, token_hash = generate_invite_token()
reseller = User(
company_id=None, email=data.email,
first_name=data.first_name, last_name=data.last_name,
role=UserRole.RESELLER,
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 tenant_service._unique_email(
f"{data.first_name}.{data.last_name}", "reseller", db
)
initial_password = secrets.token_urlsafe(9)
reseller = User(
company_id=None, email=login,
first_name=data.first_name, last_name=data.last_name,
role=UserRole.RESELLER,
password_hash=hash_password(initial_password),
is_active=True,
)
db.add(reseller)
await db.flush()
db.add(AuditLog(
company_id=None, user_id=current_user.id,
action="reseller_created", entity_type="user", entity_id=reseller.id,
new_value={"email": reseller.email},
new_value={"email": reseller.email, "invited": bool(data.email)},
))
await db.flush()
from app.services.email_service import email_service
await email_service.send_invite(reseller, current_user, raw_token, db)
if data.email:
from app.services.email_service import email_service
await email_service.send_invite(reseller, current_user, raw_token, db)
return ResellerOut(
id=reseller.id, email=reseller.email, first_name=reseller.first_name,
last_name=reseller.last_name, is_active=reseller.is_active,
created_at=reseller.created_at, company_count=0,
initial_password=initial_password,
)
+4 -1
View File
@@ -19,7 +19,10 @@ class RegisterRequest(BaseModel):
class LoginRequest(BaseModel):
email: EmailStr
# Login-Kennung: i.d.R. eine E-Mail, kann aber auch eine intern erzeugte
# Kennung sein (z.B. Mandanten-Admin ohne E-Mail → '<name>@<slug>.local').
# Daher kein EmailStr-Zwang das Passwort ist das eigentliche Gate.
email: str = Field(min_length=1, max_length=255)
password: str
+10 -3
View File
@@ -25,17 +25,22 @@ class TenantOut(BaseModel):
active_user_count: int = 0
admin_email: str | None = None
created_at: datetime | None = None
# Nur bei Anlage ohne E-Mail befüllt: einmalig anzuzeigende Zugangsdaten
# (Login-Kennung = admin_email, Passwort hier). Sonst None.
initial_password: str | None = None
class TenantCreate(BaseModel):
"""Neue Firma anlegen (durch Reseller oder SUPER_ADMIN).
Es wird automatisch ein erster COMPANY_ADMIN per Einladung angelegt.
Es wird automatisch ein erster COMPANY_ADMIN angelegt. Mit admin_email wird
eine Einladung verschickt; ohne admin_email wird eine interne Login-Kennung
plus Temporär-Passwort erzeugt und einmalig zurückgegeben (kein Mailversand).
"""
name: str = Field(min_length=2, max_length=255)
country: str = Field(default="DE", max_length=10)
plan: str = Field(default="trial", max_length=50)
admin_email: EmailStr
admin_email: EmailStr | None = None
admin_first_name: str = Field(min_length=1, max_length=100)
admin_last_name: str = Field(min_length=1, max_length=100)
@@ -61,9 +66,11 @@ class ResellerOut(BaseModel):
is_active: bool
created_at: datetime | None = None
company_count: int = 0
# Nur bei Anlage ohne E-Mail befüllt: einmalig anzuzeigendes Temporär-Passwort.
initial_password: str | None = None
class ResellerCreate(BaseModel):
email: EmailStr
email: EmailStr | None = None
first_name: str = Field(min_length=1, max_length=100)
last_name: str = Field(min_length=1, max_length=100)
+50 -18
View File
@@ -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."""