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
+28
View File
@@ -1994,3 +1994,31 @@ Keine Commits in dieser Session.
- frontend/src/pages/QrStampSettingsPage.tsx | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - frontend/src/pages/QrStampSettingsPage.tsx | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
--- ---
## 2026-06-23 00:50 01:22 (32m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- 898a81f feat: Reseller-Rolle + SUPER_ADMIN-Mandantenübersicht
### Geänderte Dateien
- DEVLOG.md | 48 +++++++++++++++++++++++
- backend/app/core/dependencies.py | 10 ++++-
- backend/app/main.py | 3 ++
- backend/app/models/company.py | 18 ++++++++-
- backend/app/models/user.py | 5 ++-
- backend/app/routers/reseller.py | 83 ++++++++++++++++++++++++++++++++++++++++
- backend/app/routers/tenants.py | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- backend/app/schemas/reseller.py | 69 ++++++++++++++++++++++++++++++++++
- backend/app/services/auth_service.py | 7 ++++
- backend/app/services/tenant_service.py | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- backend/migrations/versions/0034_reseller.py | 70 ++++++++++++++++++++++++++++++++++
- backend/tests/conftest.py | 14 +++++--
- backend/tests/test_reseller.py | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/App.tsx | 4 ++
- frontend/src/components/Layout.tsx | 1 +
- frontend/src/context/AuthContext.tsx | 8 +++-
- frontend/src/pages/ResellerCompaniesPage.tsx | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/TenantsPage.tsx | 255 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
+5 -3
View File
@@ -38,15 +38,17 @@ async def create_company(
current_user: User = require_role(*_reseller_roles), current_user: User = require_role(*_reseller_roles),
db: AsyncSession = Depends(get_db), 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, 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( return TenantOut(
id=company.id, name=company.name, slug=company.slug, plan=company.plan, 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, 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(), 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. SUPER_ADMIN läuft mit aktivem RLS-Bypass und sieht daher alle Firmen.
Nur SUPER_ADMIN darf Reseller anlegen und Firmen einem Reseller zuordnen. Nur SUPER_ADMIN darf Reseller anlegen und Firmen einem Reseller zuordnen.
""" """
import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from uuid import UUID from uuid import UUID
@@ -51,11 +52,13 @@ async def create_tenant(
reseller = await db.get(User, reseller_id) reseller = await db.get(User, reseller_id)
if reseller is None or reseller.role != UserRole.RESELLER: if reseller is None or reseller.role != UserRole.RESELLER:
raise HTTPException(status_code=400, detail="Ungültiger 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, data, actor=current_user, db=db, reseller_id=reseller_id,
) )
tenants = {t.id: t for t in await tenant_service.list_tenants(db)} 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) @router.patch("/tenants/{company_id}", response_model=TenantOut)
@@ -142,37 +145,52 @@ async def create_reseller(
current_user: User = require_role(*_sa), current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db), 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.") raise HTTPException(status_code=400, detail="E-Mail ist bereits vergeben.")
raw_token, token_hash = generate_invite_token() initial_password: str | None = None
reseller = User( if data.email:
company_id=None, raw_token, token_hash = generate_invite_token()
email=data.email, reseller = User(
first_name=data.first_name, company_id=None, email=data.email,
last_name=data.last_name, first_name=data.first_name, last_name=data.last_name,
role=UserRole.RESELLER, role=UserRole.RESELLER,
password_hash=hash_password(raw_token), # temporär, beim Accept überschrieben password_hash=hash_password(raw_token), # temporär, beim Accept überschrieben
invite_token_hash=token_hash, invite_token_hash=token_hash,
invite_expires=datetime.now(timezone.utc) + timedelta(days=7), invite_expires=datetime.now(timezone.utc) + timedelta(days=7),
is_active=False, 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) db.add(reseller)
await db.flush() await db.flush()
db.add(AuditLog( db.add(AuditLog(
company_id=None, user_id=current_user.id, company_id=None, user_id=current_user.id,
action="reseller_created", entity_type="user", entity_id=reseller.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() await db.flush()
from app.services.email_service import email_service if data.email:
await email_service.send_invite(reseller, current_user, raw_token, db) from app.services.email_service import email_service
await email_service.send_invite(reseller, current_user, raw_token, db)
return ResellerOut( return ResellerOut(
id=reseller.id, email=reseller.email, first_name=reseller.first_name, id=reseller.id, email=reseller.email, first_name=reseller.first_name,
last_name=reseller.last_name, is_active=reseller.is_active, last_name=reseller.last_name, is_active=reseller.is_active,
created_at=reseller.created_at, company_count=0, 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): 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 password: str
+10 -3
View File
@@ -25,17 +25,22 @@ class TenantOut(BaseModel):
active_user_count: int = 0 active_user_count: int = 0
admin_email: str | None = None admin_email: str | None = None
created_at: datetime | 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): class TenantCreate(BaseModel):
"""Neue Firma anlegen (durch Reseller oder SUPER_ADMIN). """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) name: str = Field(min_length=2, max_length=255)
country: str = Field(default="DE", max_length=10) country: str = Field(default="DE", max_length=10)
plan: str = Field(default="trial", max_length=50) 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_first_name: str = Field(min_length=1, max_length=100)
admin_last_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 is_active: bool
created_at: datetime | None = None created_at: datetime | None = None
company_count: int = 0 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): class ResellerCreate(BaseModel):
email: EmailStr email: EmailStr | None = None
first_name: str = Field(min_length=1, max_length=100) first_name: str = Field(min_length=1, max_length=100)
last_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. und stellt danach den RLS-Kontext des Actors wieder her.
""" """
import re import re
import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from uuid import UUID from uuid import UUID
@@ -38,18 +39,36 @@ class TenantService:
counter += 1 counter += 1
return slug 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( async def create_tenant(
self, data: TenantCreate, actor: User, db: AsyncSession, self, data: TenantCreate, actor: User, db: AsyncSession,
reseller_id: UUID | None, 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) # 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.") raise HTTPException(status_code=400, detail="E-Mail ist bereits vergeben.")
slug = await self._unique_slug(data.name, db) slug = await self._unique_slug(data.name, db)
# Bootstrap unter Bypass neue, leere Firma; danach Kontext wiederherstellen. # Bootstrap unter Bypass neue, leere Firma; danach Kontext wiederherstellen.
await db.execute(text("SET LOCAL app.bypass_rls = 'on'")) await db.execute(text("SET LOCAL app.bypass_rls = 'on'"))
initial_password: str | None = None
try: try:
company = Company( company = Company(
name=data.name, slug=slug, plan=data.plan or "trial", name=data.name, slug=slug, plan=data.plan or "trial",
@@ -58,18 +77,30 @@ class TenantService:
db.add(company) db.add(company)
await db.flush() await db.flush()
raw_token, token_hash = generate_invite_token() if data.admin_email:
admin = User( raw_token, token_hash = generate_invite_token()
company_id=company.id, admin = User(
email=data.admin_email, company_id=company.id, email=data.admin_email,
first_name=data.admin_first_name, first_name=data.admin_first_name, last_name=data.admin_last_name,
last_name=data.admin_last_name, role=UserRole.COMPANY_ADMIN,
role=UserRole.COMPANY_ADMIN, password_hash=hash_password(raw_token), # temporär, beim Accept überschrieben
password_hash=hash_password(raw_token), # temporär, beim Accept überschrieben invite_token_hash=token_hash,
invite_token_hash=token_hash, invite_expires=datetime.now(timezone.utc) + timedelta(days=7),
invite_expires=datetime.now(timezone.utc) + timedelta(days=7), is_active=False,
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) db.add(admin)
await db.flush() await db.flush()
@@ -84,20 +115,21 @@ class TenantService:
entity_id=company.id, entity_id=company.id,
new_value={ new_value={
"name": company.name, "reseller_id": str(reseller_id) if reseller_id else None, "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() await db.flush()
from app.services.email_service import email_service if data.admin_email:
await email_service.send_invite(admin, actor, raw_token, db) from app.services.email_service import email_service
await email_service.send_invite(admin, actor, raw_token, db)
finally: finally:
# RLS-Kontext des Actors wiederherstellen # RLS-Kontext des Actors wiederherstellen
if actor.role == UserRole.RESELLER: if actor.role == UserRole.RESELLER:
await db.execute(text("SET LOCAL app.bypass_rls = 'off'")) await db.execute(text("SET LOCAL app.bypass_rls = 'off'"))
# SUPER_ADMIN: Bypass bleibt aktiv (Normalzustand) # SUPER_ADMIN: Bypass bleibt aktiv (Normalzustand)
return company return company, admin, initial_password
async def list_tenants(self, db: AsyncSession) -> list[TenantOut]: async def list_tenants(self, db: AsyncSession) -> list[TenantOut]:
"""Alle für den aktuellen RLS-Kontext sichtbaren Firmen + Kennzahlen.""" """Alle für den aktuellen RLS-Kontext sichtbaren Firmen + Kennzahlen."""
+20
View File
@@ -79,6 +79,26 @@ async def test_reseller_creates_and_sees_only_own_company(client: AsyncClient, d
assert rid_a != rid_b assert rid_a != rid_b
@pytest.mark.asyncio(loop_scope="session")
async def test_create_company_without_email_returns_temp_password(client: AsyncClient, db_session):
await _make_user(db_session, email="res-noemail@p.de", role="RESELLER")
h = await _login(client, "res-noemail@p.de")
r = await client.post("/api/v1/reseller/companies", json={
"name": "Delta GmbH", "admin_first_name": "De", "admin_last_name": "Lta",
}, headers=h)
assert r.status_code == 201, r.text
body = r.json()
assert body["initial_password"], "Temp-Passwort muss zurückgegeben werden"
assert body["admin_email"], "Login-Kennung muss erzeugt werden"
# Mit den generierten Zugangsdaten kann sich der Admin sofort einloggen
ok = await client.post("/api/v1/auth/login", json={
"email": body["admin_email"], "password": body["initial_password"],
})
assert ok.status_code == 200, ok.text
@pytest.mark.asyncio(loop_scope="session") @pytest.mark.asyncio(loop_scope="session")
async def test_reseller_cannot_access_admin_endpoints(client: AsyncClient, db_session): async def test_reseller_cannot_access_admin_endpoints(client: AsyncClient, db_session):
await _make_user(db_session, email="res-c@p.de", role="RESELLER") await _make_user(db_session, email="res-c@p.de", role="RESELLER")
+41 -4
View File
@@ -18,6 +18,7 @@ export function ResellerCompaniesPage() {
const [showNew, setShowNew] = useState(false) const [showNew, setShowNew] = useState(false)
const [form, setForm] = useState({ ...empty }) const [form, setForm] = useState({ ...empty })
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [cred, setCred] = useState<{ email: string; password: string } | null>(null)
async function load() { async function load() {
try { setCompanies(await api.get<Tenant[]>('/reseller/companies')) } try { setCompanies(await api.get<Tenant[]>('/reseller/companies')) }
@@ -32,8 +33,13 @@ export function ResellerCompaniesPage() {
async function createCompany() { async function createCompany() {
setBusy(true); setError(null) setBusy(true); setError(null)
try { try {
await api.post('/reseller/companies', form) const body = { ...form, admin_email: form.admin_email.trim() || undefined }
setShowNew(false); setForm({ ...empty }); await load() const res = await api.post<Tenant & { initial_password?: string | null; admin_email?: string | null }>(
'/reseller/companies', body,
)
setShowNew(false); setForm({ ...empty })
if (res.initial_password) setCred({ email: res.admin_email ?? '', password: res.initial_password })
await load()
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Anlegen') setError(e instanceof Error ? e.message : 'Fehler beim Anlegen')
} finally { setBusy(false) } } finally { setBusy(false) }
@@ -120,8 +126,8 @@ export function ResellerCompaniesPage() {
<L label="Plan"><input className={inp} value={form.plan} onChange={e => setForm({ ...form, plan: e.target.value })} /></L> <L label="Plan"><input className={inp} value={form.plan} onChange={e => setForm({ ...form, plan: e.target.value })} /></L>
</div> </div>
<hr /> <hr />
<p className="text-xs text-gray-500">Erster Administrator (erhält Einladung per E-Mail):</p> <p className="text-xs text-gray-500">Erster Administrator. Mit E-Mail wird eine Einladung verschickt ohne E-Mail erhältst du ein einmaliges Temporär-Passwort zum Weitergeben.</p>
<L label="Admin E-Mail"><input className={inp} value={form.admin_email} onChange={e => setForm({ ...form, admin_email: e.target.value })} /></L> <L label="Admin E-Mail (optional)"><input className={inp} placeholder="leer lassen für Temp-Passwort" value={form.admin_email} onChange={e => setForm({ ...form, admin_email: e.target.value })} /></L>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<L label="Vorname"><input className={inp} value={form.admin_first_name} onChange={e => setForm({ ...form, admin_first_name: e.target.value })} /></L> <L label="Vorname"><input className={inp} value={form.admin_first_name} onChange={e => setForm({ ...form, admin_first_name: e.target.value })} /></L>
<L label="Nachname"><input className={inp} value={form.admin_last_name} onChange={e => setForm({ ...form, admin_last_name: e.target.value })} /></L> <L label="Nachname"><input className={inp} value={form.admin_last_name} onChange={e => setForm({ ...form, admin_last_name: e.target.value })} /></L>
@@ -137,6 +143,37 @@ export function ResellerCompaniesPage() {
</div> </div>
</div> </div>
)} )}
{cred && <CredDialog title="Zugangsdaten Firmen-Administrator" cred={cred} onClose={() => setCred(null)} />}
</div>
)
}
export function CredDialog({ title, cred, onClose }: {
title: string; cred: { email: string; password: string }; onClose: () => void
}) {
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-[60] p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6 space-y-4">
<h2 className="text-lg font-bold text-gray-900">{title}</h2>
<div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-4 space-y-3">
<p className="text-xs font-semibold text-amber-800"> Jetzt notieren das Passwort wird nur einmal angezeigt.</p>
<div>
<p className="text-xs text-gray-500">Login</p>
<p className="font-mono text-sm break-all">{cred.email}</p>
</div>
<div>
<p className="text-xs text-gray-500">Temporäres Passwort</p>
<p className="font-mono text-sm break-all select-all">{cred.password}</p>
</div>
</div>
<div className="flex justify-end gap-2">
<button onClick={() => navigator.clipboard?.writeText(`${cred.email} / ${cred.password}`)}
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Kopieren</button>
<button onClick={onClose}
className="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700">Verstanden</button>
</div>
</div>
</div> </div>
) )
} }
+21 -9
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { api } from '../api/client' import { api } from '../api/client'
import { Layout } from '../components/Layout' import { Layout } from '../components/Layout'
import { CredDialog } from './ResellerCompaniesPage'
interface Tenant { interface Tenant {
id: string; name: string; slug: string; plan: string; is_active: boolean id: string; name: string; slug: string; plan: string; is_active: boolean
@@ -27,6 +28,7 @@ export function TenantsPage() {
const [showNewReseller, setShowNewReseller] = useState(false) const [showNewReseller, setShowNewReseller] = useState(false)
const [rForm, setRForm] = useState({ ...emptyReseller }) const [rForm, setRForm] = useState({ ...emptyReseller })
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [cred, setCred] = useState<{ title: string; email: string; password: string } | null>(null)
async function load() { async function load() {
try { try {
@@ -49,10 +51,15 @@ export function TenantsPage() {
setBusy(true); setError(null) setBusy(true); setError(null)
try { try {
const q = form.reseller_id ? `?reseller_id=${form.reseller_id}` : '' const q = form.reseller_id ? `?reseller_id=${form.reseller_id}` : ''
const { reseller_id, ...body } = form const { reseller_id, ...rest } = form
void reseller_id void reseller_id
await api.post(`/admin/tenants${q}`, body) const body = { ...rest, admin_email: rest.admin_email.trim() || undefined }
setShowNew(false); setForm({ ...empty, reseller_id: '' }); await load() const res = await api.post<{ initial_password?: string | null; admin_email?: string | null }>(
`/admin/tenants${q}`, body,
)
setShowNew(false); setForm({ ...empty, reseller_id: '' })
if (res.initial_password) setCred({ title: 'Zugangsdaten Firmen-Administrator', email: res.admin_email ?? '', password: res.initial_password })
await load()
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Anlegen') setError(e instanceof Error ? e.message : 'Fehler beim Anlegen')
} finally { setBusy(false) } } finally { setBusy(false) }
@@ -71,8 +78,11 @@ export function TenantsPage() {
async function createReseller() { async function createReseller() {
setBusy(true); setError(null) setBusy(true); setError(null)
try { try {
await api.post('/admin/resellers', rForm) const body = { ...rForm, email: rForm.email.trim() || undefined }
setShowNewReseller(false); setRForm({ ...emptyReseller }); await load() const res = await api.post<{ initial_password?: string | null; email?: string | null }>('/admin/resellers', body)
setShowNewReseller(false); setRForm({ ...emptyReseller })
if (res.initial_password) setCred({ title: 'Zugangsdaten Reseller', email: res.email ?? '', password: res.initial_password })
await load()
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Anlegen') setError(e instanceof Error ? e.message : 'Fehler beim Anlegen')
} finally { setBusy(false) } } finally { setBusy(false) }
@@ -204,8 +214,8 @@ export function TenantsPage() {
</select> </select>
</Field> </Field>
<hr className="my-1" /> <hr className="my-1" />
<p className="text-xs text-gray-500">Erster Administrator (erhält Einladung per E-Mail):</p> <p className="text-xs text-gray-500">Erster Administrator. Mit E-Mail Einladung; ohne E-Mail einmaliges Temp-Passwort.</p>
<Field label="Admin E-Mail"><input className={inp} value={form.admin_email} onChange={e => setForm({ ...form, admin_email: e.target.value })} /></Field> <Field label="Admin E-Mail (optional)"><input className={inp} placeholder="leer lassen für Temp-Passwort" value={form.admin_email} onChange={e => setForm({ ...form, admin_email: e.target.value })} /></Field>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Vorname"><input className={inp} value={form.admin_first_name} onChange={e => setForm({ ...form, admin_first_name: e.target.value })} /></Field> <Field label="Vorname"><input className={inp} value={form.admin_first_name} onChange={e => setForm({ ...form, admin_first_name: e.target.value })} /></Field>
<Field label="Nachname"><input className={inp} value={form.admin_last_name} onChange={e => setForm({ ...form, admin_last_name: e.target.value })} /></Field> <Field label="Nachname"><input className={inp} value={form.admin_last_name} onChange={e => setForm({ ...form, admin_last_name: e.target.value })} /></Field>
@@ -215,14 +225,16 @@ export function TenantsPage() {
{showNewReseller && ( {showNewReseller && (
<Modal title="Neuen Reseller anlegen" onClose={() => setShowNewReseller(false)} onSubmit={createReseller} busy={busy}> <Modal title="Neuen Reseller anlegen" onClose={() => setShowNewReseller(false)} onSubmit={createReseller} busy={busy}>
<p className="text-xs text-gray-500">Der Reseller erhält eine Einladung per E-Mail und kann danach eigene Firmen anlegen und verwalten.</p> <p className="text-xs text-gray-500">Mit E-Mail erhält der Reseller eine Einladung ohne E-Mail ein einmaliges Temp-Passwort. Danach kann er eigene Firmen anlegen und verwalten.</p>
<Field label="E-Mail"><input className={inp} value={rForm.email} onChange={e => setRForm({ ...rForm, email: e.target.value })} /></Field> <Field label="E-Mail (optional)"><input className={inp} placeholder="leer lassen für Temp-Passwort" value={rForm.email} onChange={e => setRForm({ ...rForm, email: e.target.value })} /></Field>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Vorname"><input className={inp} value={rForm.first_name} onChange={e => setRForm({ ...rForm, first_name: e.target.value })} /></Field> <Field label="Vorname"><input className={inp} value={rForm.first_name} onChange={e => setRForm({ ...rForm, first_name: e.target.value })} /></Field>
<Field label="Nachname"><input className={inp} value={rForm.last_name} onChange={e => setRForm({ ...rForm, last_name: e.target.value })} /></Field> <Field label="Nachname"><input className={inp} value={rForm.last_name} onChange={e => setRForm({ ...rForm, last_name: e.target.value })} /></Field>
</div> </div>
</Modal> </Modal>
)} )}
{cred && <CredDialog title={cred.title} cred={cred} onClose={() => setCred(null)} />}
</div> </div>
</Layout> </Layout>
) )