From 98cb2f867ab2e2155751132f891392f6f067b680 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 23 Jun 2026 01:34:23 +0200 Subject: [PATCH] feat: Mandant/Reseller ohne E-Mail anlegbar (Temp-Passwort statt Einladung) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin_email bzw. reseller email optional; ohne E-Mail wird interne Login- Kennung (@.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 --- DEVLOG.md | 28 ++++++++ backend/app/routers/reseller.py | 8 ++- backend/app/routers/tenants.py | 54 ++++++++++------ backend/app/schemas/auth.py | 5 +- backend/app/schemas/reseller.py | 13 +++- backend/app/services/tenant_service.py | 68 ++++++++++++++------ backend/tests/test_reseller.py | 20 ++++++ frontend/src/pages/ResellerCompaniesPage.tsx | 45 +++++++++++-- frontend/src/pages/TenantsPage.tsx | 30 ++++++--- 9 files changed, 215 insertions(+), 56 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index f17fea5..69a0730 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1994,3 +1994,31 @@ Keine Commits in dieser Session. - 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 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- diff --git a/backend/app/routers/reseller.py b/backend/app/routers/reseller.py index e9efb2e..fe64967 100644 --- a/backend/app/routers/reseller.py +++ b/backend/app/routers/reseller.py @@ -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, ) diff --git a/backend/app/routers/tenants.py b/backend/app/routers/tenants.py index ceef42c..761ddf7 100644 --- a/backend/app/routers/tenants.py +++ b/backend/app/routers/tenants.py @@ -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, ) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 5e83639..cf4369f 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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 → '@.local'). + # Daher kein EmailStr-Zwang – das Passwort ist das eigentliche Gate. + email: str = Field(min_length=1, max_length=255) password: str diff --git a/backend/app/schemas/reseller.py b/backend/app/schemas/reseller.py index c3e13c5..dfdb342 100644 --- a/backend/app/schemas/reseller.py +++ b/backend/app/schemas/reseller.py @@ -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) diff --git a/backend/app/services/tenant_service.py b/backend/app/services/tenant_service.py index 6b7d68a..8356a26 100644 --- a/backend/app/services/tenant_service.py +++ b/backend/app/services/tenant_service.py @@ -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.""" diff --git a/backend/tests/test_reseller.py b/backend/tests/test_reseller.py index 50cef03..05be122 100644 --- a/backend/tests/test_reseller.py +++ b/backend/tests/test_reseller.py @@ -79,6 +79,26 @@ async def test_reseller_creates_and_sees_only_own_company(client: AsyncClient, d 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") async def test_reseller_cannot_access_admin_endpoints(client: AsyncClient, db_session): await _make_user(db_session, email="res-c@p.de", role="RESELLER") diff --git a/frontend/src/pages/ResellerCompaniesPage.tsx b/frontend/src/pages/ResellerCompaniesPage.tsx index 4eae55a..5716985 100644 --- a/frontend/src/pages/ResellerCompaniesPage.tsx +++ b/frontend/src/pages/ResellerCompaniesPage.tsx @@ -18,6 +18,7 @@ export function ResellerCompaniesPage() { const [showNew, setShowNew] = useState(false) const [form, setForm] = useState({ ...empty }) const [busy, setBusy] = useState(false) + const [cred, setCred] = useState<{ email: string; password: string } | null>(null) async function load() { try { setCompanies(await api.get('/reseller/companies')) } @@ -32,8 +33,13 @@ export function ResellerCompaniesPage() { async function createCompany() { setBusy(true); setError(null) try { - await api.post('/reseller/companies', form) - setShowNew(false); setForm({ ...empty }); await load() + const body = { ...form, admin_email: form.admin_email.trim() || undefined } + const res = await api.post( + '/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) { setError(e instanceof Error ? e.message : 'Fehler beim Anlegen') } finally { setBusy(false) } @@ -120,8 +126,8 @@ export function ResellerCompaniesPage() { setForm({ ...form, plan: e.target.value })} />
-

Erster Administrator (erhält Einladung per E-Mail):

- setForm({ ...form, admin_email: e.target.value })} /> +

Erster Administrator. Mit E-Mail wird eine Einladung verschickt – ohne E-Mail erhältst du ein einmaliges Temporär-Passwort zum Weitergeben.

+ setForm({ ...form, admin_email: e.target.value })} />
setForm({ ...form, admin_first_name: e.target.value })} /> setForm({ ...form, admin_last_name: e.target.value })} /> @@ -137,6 +143,37 @@ export function ResellerCompaniesPage() {
)} + + {cred && setCred(null)} />} + + ) +} + +export function CredDialog({ title, cred, onClose }: { + title: string; cred: { email: string; password: string }; onClose: () => void +}) { + return ( +
+
+

{title}

+
+

⚠ Jetzt notieren – das Passwort wird nur einmal angezeigt.

+
+

Login

+

{cred.email}

+
+
+

Temporäres Passwort

+

{cred.password}

+
+
+
+ + +
+
) } diff --git a/frontend/src/pages/TenantsPage.tsx b/frontend/src/pages/TenantsPage.tsx index 9aa5ac8..004592e 100644 --- a/frontend/src/pages/TenantsPage.tsx +++ b/frontend/src/pages/TenantsPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { api } from '../api/client' import { Layout } from '../components/Layout' +import { CredDialog } from './ResellerCompaniesPage' interface Tenant { id: string; name: string; slug: string; plan: string; is_active: boolean @@ -27,6 +28,7 @@ export function TenantsPage() { const [showNewReseller, setShowNewReseller] = useState(false) const [rForm, setRForm] = useState({ ...emptyReseller }) const [busy, setBusy] = useState(false) + const [cred, setCred] = useState<{ title: string; email: string; password: string } | null>(null) async function load() { try { @@ -49,10 +51,15 @@ export function TenantsPage() { setBusy(true); setError(null) try { const q = form.reseller_id ? `?reseller_id=${form.reseller_id}` : '' - const { reseller_id, ...body } = form + const { reseller_id, ...rest } = form void reseller_id - await api.post(`/admin/tenants${q}`, body) - setShowNew(false); setForm({ ...empty, reseller_id: '' }); await load() + const body = { ...rest, admin_email: rest.admin_email.trim() || undefined } + 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) { setError(e instanceof Error ? e.message : 'Fehler beim Anlegen') } finally { setBusy(false) } @@ -71,8 +78,11 @@ export function TenantsPage() { async function createReseller() { setBusy(true); setError(null) try { - await api.post('/admin/resellers', rForm) - setShowNewReseller(false); setRForm({ ...emptyReseller }); await load() + const body = { ...rForm, email: rForm.email.trim() || undefined } + 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) { setError(e instanceof Error ? e.message : 'Fehler beim Anlegen') } finally { setBusy(false) } @@ -204,8 +214,8 @@ export function TenantsPage() {
-

Erster Administrator (erhält Einladung per E-Mail):

- setForm({ ...form, admin_email: e.target.value })} /> +

Erster Administrator. Mit E-Mail → Einladung; ohne E-Mail → einmaliges Temp-Passwort.

+ setForm({ ...form, admin_email: e.target.value })} />
setForm({ ...form, admin_first_name: e.target.value })} /> setForm({ ...form, admin_last_name: e.target.value })} /> @@ -215,14 +225,16 @@ export function TenantsPage() { {showNewReseller && ( setShowNewReseller(false)} onSubmit={createReseller} busy={busy}> -

Der Reseller erhält eine Einladung per E-Mail und kann danach eigene Firmen anlegen und verwalten.

- setRForm({ ...rForm, email: e.target.value })} /> +

Mit E-Mail erhält der Reseller eine Einladung – ohne E-Mail ein einmaliges Temp-Passwort. Danach kann er eigene Firmen anlegen und verwalten.

+ setRForm({ ...rForm, email: e.target.value })} />
setRForm({ ...rForm, first_name: e.target.value })} /> setRForm({ ...rForm, last_name: e.target.value })} />
)} + + {cred && setCred(null)} />}
)