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:
@@ -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 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user