feat: Reseller-Rolle + SUPER_ADMIN-Mandantenübersicht
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled

Mandantenfähigkeit ausgebaut:
- Neue Rolle RESELLER (company_id NULL); companies.reseller_id + is_active
- RLS-Erweiterung (Migration 0034): companies/users zusätzlich auf app.reseller_id
  gefenced → Reseller sieht/verwaltet DB-seitig nur eigene Firmen, keine
  personenbezogenen Zeit-/Abwesenheitsdaten (DSGVO: nur Verwaltung)
- get_current_user setzt app.reseller_id + Bypass aus für RESELLER
- tenant_service: Firma + Erst-Admin (Einladung), Übersicht mit Kennzahlen
- Router /reseller/* (Self-Service) und /admin/* (SUPER_ADMIN: Mandanten + Reseller)
- Login-Sperre bei deaktiviertem Mandanten
- Frontend: TenantsPage (/admin/tenants), eigene ResellerCompaniesPage (/reseller),
  rollenbasierte Login-Weiterleitung, Nav "Mandanten" für SUPER_ADMIN
- 4 neue Tests inkl. Cross-Reseller-RLS-Isolation; 172/172 grün

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 01:15:51 +02:00
co-authored by Claude Opus 4.8
parent de35eaa3ea
commit 898a81f499
18 changed files with 1198 additions and 8 deletions
+48
View File
@@ -1946,3 +1946,51 @@ Keine Commits in dieser Session.
- backend/app/routers/public_stamp.py | 11 +++++++++--
---
## 2026-06-02 22:06 22:11 (4m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
- de35eaa feat: QR-Stempeln als eigener Menüpunkt + Tablet-Link
### Geänderte Dateien
- DEVLOG.md | 12 ++++++
- frontend/DEVLOG.md | 22 +++++++++++
- frontend/src/App.tsx | 2 +
- frontend/src/components/Layout.tsx | 1 +
- frontend/src/pages/CompanySettingsPage.tsx | 167 +-----------------------------------------------------------------------------
- frontend/src/pages/QrStampSettingsPage.tsx | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-06-02 22:12 22:14 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 12 ++++++
- frontend/DEVLOG.md | 22 +++++++++++
- frontend/src/App.tsx | 2 +
- frontend/src/components/Layout.tsx | 1 +
- frontend/src/pages/CompanySettingsPage.tsx | 167 +-----------------------------------------------------------------------------
- frontend/src/pages/QrStampSettingsPage.tsx | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-06-23 00:29 00:30 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 12 +++++++
- frontend/DEVLOG.md | 22 ++++++++++++
- frontend/src/App.tsx | 2 ++
- frontend/src/components/Layout.tsx | 1 +
- frontend/src/pages/CompanySettingsPage.tsx | 167 +------------------------------------------------------------------------------------------
- frontend/src/pages/QrStampSettingsPage.tsx | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
+9 -1
View File
@@ -60,7 +60,15 @@ async def get_current_user(
# Every other role gets the RLS fence applied: set company_id and disable
# bypass so subsequent queries in the same transaction are automatically
# filtered to the user's company.
if user.role != UserRole.SUPER_ADMIN and user.company_id is not None:
if user.role == UserRole.RESELLER:
# Reseller ist an keine Firma gebunden (company_id IS NULL), darf aber
# NICHT alles sehen. Statt company_id setzen wir app.reseller_id und
# deaktivieren den Bypass → RLS-Policies geben nur Firmen frei, deren
# reseller_id == diesem User entspricht (plus deren User zum Verwalten).
safe_reseller_id = str(_uuid.UUID(str(user.id)))
await db.execute(text(f"SET LOCAL app.reseller_id = '{safe_reseller_id}'"))
await db.execute(text("SET LOCAL app.bypass_rls = 'off'"))
elif user.role != UserRole.SUPER_ADMIN and user.company_id is not None:
# Sicherheits-Invariante: safe_company_id muss eine valide UUID sein.
# Der _uuid.UUID()-Round-Trip verhindert SQL-Injection auch bei zukünftigen
# Refactorings (z.B. falls user.company_id einmal ein String aus einem
+3
View File
@@ -18,6 +18,7 @@ from app.routers import audit
from app.routers import special_assignments
from app.routers import hours_payouts
from app.routers import public_stamp
from app.routers import reseller, tenants
@asynccontextmanager
@@ -98,6 +99,8 @@ app.include_router(busylight.router, prefix=API_PREFIX)
app.include_router(audit.router, prefix=API_PREFIX)
app.include_router(special_assignments.router, prefix=API_PREFIX)
app.include_router(hours_payouts.router, prefix=API_PREFIX)
app.include_router(reseller.router, prefix=API_PREFIX)
app.include_router(tenants.router, prefix=API_PREFIX)
# ── Health ────────────────────────────────────────────────────────────────────
+16 -2
View File
@@ -3,7 +3,7 @@ import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, Integer, String, Text
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -26,6 +26,17 @@ class Company(Base):
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
plan: Mapped[str] = mapped_column(String(50), default="trial")
# Mandanten-Status: deaktivierte Firmen können sich nicht mehr einloggen.
# Von Reseller (eigene Firmen) bzw. SUPER_ADMIN umschaltbar.
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Reseller-Besitz: Firma wurde von diesem Reseller angelegt und wird von ihm
# verwaltet (nur Firmen-Ebene, kein Zugriff auf personenbezogene Daten).
# NULL = direkt von der Plattform (SUPER_ADMIN) verwaltet.
reseller_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
logo_url: Mapped[str | None] = mapped_column(Text)
country: Mapped[str] = mapped_column(String(10), default="DE")
state: Mapped[str | None] = mapped_column(String(10))
@@ -71,7 +82,10 @@ class Company(Base):
overtime_max_carryover_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) # None = alles
# Relationships
users: Mapped[list["User"]] = relationship("User", back_populates="company", lazy="noload")
users: Mapped[list["User"]] = relationship(
"User", back_populates="company", lazy="noload",
foreign_keys="User.company_id",
)
departments: Mapped[list["Department"]] = relationship("Department", back_populates="company", lazy="noload")
def __repr__(self) -> str:
+4 -1
View File
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
class UserRole(str, enum.Enum):
SUPER_ADMIN = "SUPER_ADMIN"
RESELLER = "RESELLER"
COMPANY_ADMIN = "COMPANY_ADMIN"
HR = "HR"
MANAGER = "MANAGER"
@@ -77,7 +78,9 @@ class User(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Relationships
company: Mapped["Company"] = relationship("Company", back_populates="users")
company: Mapped["Company"] = relationship(
"Company", back_populates="users", foreign_keys="User.company_id",
)
department: Mapped["Department | None"] = relationship(
"Department",
primaryjoin="User.department_id == Department.id",
+83
View File
@@ -0,0 +1,83 @@
"""Reseller-Selbstverwaltung: ein Reseller verwaltet ausschließlich die von ihm
angelegten Firmen (Firmen-Ebene, keine personenbezogenen Daten).
RLS (app.reseller_id) erzwingt, dass nur eigene Firmen sichtbar/änderbar sind
ein Zugriff auf eine fremde Firmen-ID läuft ins Leere (404).
"""
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi import HTTPException
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.models.audit_log import AuditLog
from app.models.company import Company
from app.models.user import User, UserRole
from app.schemas.reseller import TenantCreate, TenantOut, TenantUpdate
from app.services.tenant_service import tenant_service
router = APIRouter(prefix="/reseller", tags=["Reseller"])
_reseller_roles = (UserRole.RESELLER,)
@router.get("/companies", response_model=list[TenantOut])
async def list_my_companies(
current_user: User = require_role(*_reseller_roles),
db: AsyncSession = Depends(get_db),
):
return await tenant_service.list_tenants(db)
@router.post("/companies", response_model=TenantOut, status_code=201)
async def create_company(
data: TenantCreate,
current_user: User = require_role(*_reseller_roles),
db: AsyncSession = Depends(get_db),
):
company = 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)
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,
)
@router.patch("/companies/{company_id}", response_model=TenantOut)
async def update_company(
company_id: UUID,
data: TenantUpdate,
current_user: User = require_role(*_reseller_roles),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, company_id)
# RLS gibt nur eigene Firmen frei → fremde/unbekannte ID = nicht sichtbar
if company is None or company.reseller_id != current_user.id:
raise HTTPException(status_code=404, detail="Firma nicht gefunden.")
changes = data.model_dump(exclude_none=True)
# Eigentümerschaft ist geprüft → Schreiben (inkl. AuditLog) unter kurzem Bypass,
# da der Reseller keine app.company_id besitzt (audit_logs ist company-gefenced).
await db.execute(text("SET LOCAL app.bypass_rls = 'on'"))
try:
for field, value in changes.items():
setattr(company, field, value)
if changes:
db.add(AuditLog(
company_id=company.id, user_id=current_user.id,
action="tenant_updated", entity_type="company", entity_id=company.id,
new_value=changes,
))
await db.flush()
finally:
await db.execute(text("SET LOCAL app.bypass_rls = 'off'"))
tenants = {t.id: t for t in await tenant_service.list_tenants(db)}
return tenants[company.id]
+201
View File
@@ -0,0 +1,201 @@
"""SUPER_ADMIN: Mandantenübersicht (alle Firmen) + Reseller-Verwaltung.
SUPER_ADMIN läuft mit aktivem RLS-Bypass und sieht daher alle Firmen.
Nur SUPER_ADMIN darf Reseller anlegen und Firmen einem Reseller zuordnen.
"""
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.core.security import generate_invite_token, hash_password
from app.models.audit_log import AuditLog
from app.models.company import Company
from app.models.user import User, UserRole
from app.schemas.reseller import (
ResellerCreate,
ResellerOut,
TenantAssignReseller,
TenantCreate,
TenantOut,
TenantUpdate,
)
from app.services.tenant_service import tenant_service
router = APIRouter(prefix="/admin", tags=["Admin · Tenants"])
_sa = (UserRole.SUPER_ADMIN,)
# ── Mandanten ────────────────────────────────────────────────────────────────
@router.get("/tenants", response_model=list[TenantOut])
async def list_tenants(
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
return await tenant_service.list_tenants(db)
@router.post("/tenants", response_model=TenantOut, status_code=201)
async def create_tenant(
data: TenantCreate,
reseller_id: UUID | None = None,
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
if reseller_id is not None:
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(
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]
@router.patch("/tenants/{company_id}", response_model=TenantOut)
async def update_tenant(
company_id: UUID,
data: TenantUpdate,
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, company_id)
if company is None:
raise HTTPException(status_code=404, detail="Firma nicht gefunden.")
changes = data.model_dump(exclude_none=True)
for field, value in changes.items():
setattr(company, field, value)
if changes:
db.add(AuditLog(
company_id=company.id, user_id=current_user.id,
action="tenant_updated", entity_type="company", entity_id=company.id,
new_value=changes,
))
await db.flush()
tenants = {t.id: t for t in await tenant_service.list_tenants(db)}
return tenants[company.id]
@router.patch("/tenants/{company_id}/reseller", response_model=TenantOut)
async def assign_reseller(
company_id: UUID,
data: TenantAssignReseller,
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
company = await db.get(Company, company_id)
if company is None:
raise HTTPException(status_code=404, detail="Firma nicht gefunden.")
if data.reseller_id is not None:
reseller = await db.get(User, data.reseller_id)
if reseller is None or reseller.role != UserRole.RESELLER:
raise HTTPException(status_code=400, detail="Ungültiger Reseller.")
company.reseller_id = data.reseller_id
db.add(AuditLog(
company_id=company.id, user_id=current_user.id,
action="tenant_reseller_assigned", entity_type="company", entity_id=company.id,
new_value={"reseller_id": str(data.reseller_id) if data.reseller_id else None},
))
await db.flush()
tenants = {t.id: t for t in await tenant_service.list_tenants(db)}
return tenants[company.id]
# ── Reseller ─────────────────────────────────────────────────────────────────
async def _reseller_company_counts(db: AsyncSession) -> dict[UUID, int]:
rows = await db.execute(
select(Company.reseller_id, func.count(Company.id))
.where(Company.reseller_id.isnot(None))
.group_by(Company.reseller_id)
)
return {rid: cnt for rid, cnt in rows}
@router.get("/resellers", response_model=list[ResellerOut])
async def list_resellers(
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
resellers = (await db.scalars(
select(User).where(User.role == UserRole.RESELLER).order_by(User.last_name, User.first_name)
)).all()
counts = await _reseller_company_counts(db)
return [
ResellerOut(
id=r.id, email=r.email, first_name=r.first_name, last_name=r.last_name,
is_active=r.is_active, created_at=r.created_at,
company_count=counts.get(r.id, 0),
)
for r in resellers
]
@router.post("/resellers", response_model=ResellerOut, status_code=201)
async def create_reseller(
data: ResellerCreate,
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
if 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,
)
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},
))
await db.flush()
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,
)
@router.patch("/resellers/{reseller_id}", response_model=ResellerOut)
async def update_reseller(
reseller_id: UUID,
is_active: bool,
current_user: User = require_role(*_sa),
db: AsyncSession = Depends(get_db),
):
reseller = await db.get(User, reseller_id)
if reseller is None or reseller.role != UserRole.RESELLER:
raise HTTPException(status_code=404, detail="Reseller nicht gefunden.")
reseller.is_active = is_active
db.add(AuditLog(
company_id=None, user_id=current_user.id,
action="reseller_updated", entity_type="user", entity_id=reseller.id,
new_value={"is_active": is_active},
))
await db.flush()
counts = await _reseller_company_counts(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=counts.get(reseller.id, 0),
)
+69
View File
@@ -0,0 +1,69 @@
"""Schemas für Reseller-Selbstverwaltung und SUPER_ADMIN-Mandantenübersicht.
Wichtig (DSGVO): Reseller sehen ausschließlich Firmen-Stammdaten und Kennzahlen
(z.B. Nutzeranzahl) KEINE personenbezogenen Zeit-/Abwesenheitsdaten.
"""
import uuid
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
class TenantOut(BaseModel):
"""Eine Firma als Mandant für Reseller- und SUPER_ADMIN-Übersicht."""
model_config = {"from_attributes": True}
id: uuid.UUID
name: str
slug: str
plan: str
is_active: bool
country: str
reseller_id: uuid.UUID | None = None
reseller_name: str | None = None
user_count: int = 0
active_user_count: int = 0
admin_email: str | None = None
created_at: datetime | None = None
class TenantCreate(BaseModel):
"""Neue Firma anlegen (durch Reseller oder SUPER_ADMIN).
Es wird automatisch ein erster COMPANY_ADMIN per Einladung angelegt.
"""
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_first_name: str = Field(min_length=1, max_length=100)
admin_last_name: str = Field(min_length=1, max_length=100)
class TenantUpdate(BaseModel):
name: str | None = Field(None, min_length=2, max_length=255)
plan: str | None = Field(None, max_length=50)
is_active: bool | None = None
class TenantAssignReseller(BaseModel):
"""SUPER_ADMIN: Firma einem Reseller zuordnen (oder lösen mit null)."""
reseller_id: uuid.UUID | None = None
class ResellerOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
email: str
first_name: str
last_name: str
is_active: bool
created_at: datetime | None = None
company_count: int = 0
class ResellerCreate(BaseModel):
email: EmailStr
first_name: str = Field(min_length=1, max_length=100)
last_name: str = Field(min_length=1, max_length=100)
+7
View File
@@ -144,6 +144,13 @@ class AuthService:
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is deactivated")
# Mandanten-Sperre: deaktivierte Firma → kein Login (Reseller/SUPER_ADMIN
# haben company_id IS NULL und sind davon nicht betroffen).
if user.company_id is not None:
company = await db.get(Company, user.company_id)
if company is not None and not company.is_active:
raise HTTPException(status_code=403, detail="Dieser Mandant ist deaktiviert.")
auth_ok = False
if user.auth_provider == AuthProvider.LDAP:
ldap_cfg = await ldap_service.get_config(user.company_id, db)
+154
View File
@@ -0,0 +1,154 @@
"""Mandanten-Verwaltung: Firmen anlegen/auflisten inkl. Kennzahlen.
Genutzt von:
- routers/reseller.py Reseller verwaltet seine EIGENEN Firmen
- routers/tenants.py SUPER_ADMIN verwaltet ALLE Firmen + Reseller
RLS sorgt für die Sichtbarkeitsgrenze: ein Reseller (app.reseller_id) sieht via
companies-/users-Policy automatisch nur seine eigenen Firmen. Das Anlegen einer
Firma bootstrappt unter kurzzeitigem Bypass (privilegierte, auditierte Operation)
und stellt danach den RLS-Kontext des Actors wieder her.
"""
import re
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import generate_invite_token, hash_password
from app.models.audit_log import AuditLog
from app.models.company import Company
from app.models.user import User, UserRole
from app.schemas.reseller import TenantCreate, TenantOut
def _slugify(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:80]
class TenantService:
async def _unique_slug(self, name: str, db: AsyncSession) -> str:
base = _slugify(name) or "firma"
slug, counter = base, 1
while await db.scalar(select(Company).where(Company.slug == slug)):
slug = f"{base}-{counter}"
counter += 1
return slug
async def create_tenant(
self, data: TenantCreate, actor: User, db: AsyncSession,
reseller_id: UUID | None,
) -> Company:
# E-Mail global eindeutig (User.email ist unique)
if 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'"))
try:
company = Company(
name=data.name, slug=slug, plan=data.plan or "trial",
country=data.country or "DE", reseller_id=reseller_id, is_active=True,
)
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,
)
db.add(admin)
await db.flush()
from app.services.absence_service import absence_service
await absence_service.create_defaults_for_company(company.id, db)
db.add(AuditLog(
company_id=company.id,
user_id=actor.id,
action="tenant_created",
entity_type="company",
entity_id=company.id,
new_value={
"name": company.name, "reseller_id": str(reseller_id) if reseller_id else None,
"admin_email": admin.email,
},
))
await db.flush()
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
async def list_tenants(self, db: AsyncSession) -> list[TenantOut]:
"""Alle für den aktuellen RLS-Kontext sichtbaren Firmen + Kennzahlen."""
companies = (await db.scalars(select(Company).order_by(Company.name))).all()
if not companies:
return []
ids = [c.id for c in companies]
# Nutzeranzahl pro Firma
counts: dict[UUID, tuple[int, int]] = {}
rows = await db.execute(
select(
User.company_id,
func.count(User.id),
func.count(User.id).filter(User.is_active.is_(True)),
).where(User.company_id.in_(ids)).group_by(User.company_id)
)
for cid, total, active in rows:
counts[cid] = (total, active)
# Erst-Admin-E-Mail pro Firma
admin_email: dict[UUID, str] = {}
arows = await db.execute(
select(User.company_id, User.email)
.where(User.company_id.in_(ids), User.role == UserRole.COMPANY_ADMIN)
.order_by(User.company_id, User.created_at)
)
for cid, email in arows:
admin_email.setdefault(cid, email)
# Reseller-Namen
reseller_ids = [c.reseller_id for c in companies if c.reseller_id]
reseller_name: dict[UUID, str] = {}
if reseller_ids:
rrows = await db.execute(
select(User.id, User.first_name, User.last_name).where(User.id.in_(reseller_ids))
)
for rid, fn, ln in rrows:
reseller_name[rid] = f"{fn} {ln}".strip()
out: list[TenantOut] = []
for c in companies:
total, active = counts.get(c.id, (0, 0))
out.append(TenantOut(
id=c.id, name=c.name, slug=c.slug, plan=c.plan, is_active=c.is_active,
country=c.country, reseller_id=c.reseller_id,
reseller_name=reseller_name.get(c.reseller_id) if c.reseller_id else None,
user_count=total, active_user_count=active,
admin_email=admin_email.get(c.id),
))
return out
tenant_service = TenantService()
@@ -0,0 +1,70 @@
"""Reseller role + tenant ownership + company is_active + RLS extension
Revision ID: 0034
Revises: 0033
Create Date: 2026-06-23
- Neuer Enum-Wert UserRole.RESELLER
- companies.is_active (Mandanten-Login-Sperre)
- companies.reseller_id (Besitz durch Reseller; NULL = Plattform/SUPER_ADMIN)
- RLS-Policies für companies + users so erweitert, dass ein Reseller (app.reseller_id)
genau seine eigenen Firmen und deren User sieht/verwaltet (keine personenbezogenen
Zeit-/Abwesenheitsdaten diese Tabellen bleiben unverändert auf company_id gefenced).
"""
from alembic import op
from sqlalchemy import text
revision = "0034"
down_revision = "0033"
branch_labels = None
depends_on = None
# ── RLS-Ausdrücke (analog 0024) ──────────────────────────────────────────────
_BYPASS = "COALESCE(current_setting('app.bypass_rls', true), 'off') = 'on'"
_CID = "company_id = NULLIF(current_setting('app.company_id', true), '')::uuid"
_IID = "id = NULLIF(current_setting('app.company_id', true), '')::uuid"
_RID = "reseller_id = NULLIF(current_setting('app.reseller_id', true), '')::uuid"
# Firmen, die dem aktuellen Reseller gehören (Subquery unterliegt selbst der
# companies-Policy, liefert dem Reseller also genau seine eigenen Firmen).
_USER_RESELLER = (
"company_id IN (SELECT id FROM companies WHERE "
"reseller_id = NULLIF(current_setting('app.reseller_id', true), '')::uuid)"
)
def _exec(sql: str) -> None:
op.execute(text(sql))
def _recreate_policies(table: str, using: str) -> None:
for op_name in ("select", "insert", "update", "delete"):
_exec(f"DROP POLICY IF EXISTS rls_{table}_{op_name} ON {table}")
_exec(f"CREATE POLICY rls_{table}_select ON {table} FOR SELECT USING {using}")
_exec(f"CREATE POLICY rls_{table}_insert ON {table} FOR INSERT WITH CHECK {using}")
_exec(f"CREATE POLICY rls_{table}_update ON {table} FOR UPDATE USING {using} WITH CHECK {using}")
_exec(f"CREATE POLICY rls_{table}_delete ON {table} FOR DELETE USING {using}")
def upgrade() -> None:
# 1) Enum-Wert RESELLER (ADD VALUE darf in TX laufen, wird hier nicht verwendet)
_exec("ALTER TYPE userrole ADD VALUE IF NOT EXISTS 'RESELLER'")
# 2) Neue Spalten
_exec("ALTER TABLE companies ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE")
_exec("ALTER TABLE companies ADD COLUMN IF NOT EXISTS reseller_id UUID NULL "
"REFERENCES users(id) ON DELETE SET NULL")
_exec("CREATE INDEX IF NOT EXISTS ix_companies_reseller_id ON companies (reseller_id)")
# 3) RLS erweitern
_recreate_policies("companies", f"({_BYPASS} OR {_IID} OR {_RID})")
_recreate_policies("users", f"({_BYPASS} OR {_CID} OR {_USER_RESELLER})")
def downgrade() -> None:
# RLS auf 0024-Stand zurücksetzen
_recreate_policies("companies", f"({_BYPASS} OR {_IID})")
_recreate_policies("users", f"({_BYPASS} OR {_CID})")
_exec("DROP INDEX IF EXISTS ix_companies_reseller_id")
_exec("ALTER TABLE companies DROP COLUMN IF EXISTS reseller_id")
_exec("ALTER TABLE companies DROP COLUMN IF EXISTS is_active")
# Enum-Wert RESELLER bleibt (PostgreSQL kann Enum-Werte nicht entfernen)
+10 -2
View File
@@ -18,9 +18,16 @@ TestSessionLocal = async_sessionmaker(test_engine, class_=AsyncSession, expire_o
_BYPASS = "COALESCE(current_setting('app.bypass_rls', true), 'off') = 'on'"
_CID = "company_id = NULLIF(current_setting('app.company_id', true), '')::uuid"
_IID = "id = NULLIF(current_setting('app.company_id', true), '')::uuid"
_RID = "reseller_id = NULLIF(current_setting('app.reseller_id', true), '')::uuid"
# Reseller darf User seiner eigenen Firmen verwalten (vgl. Migration 0034)
_USER_RESELLER = (
"company_id IN (SELECT id FROM companies WHERE "
"reseller_id = NULLIF(current_setting('app.reseller_id', true), '')::uuid)"
)
def _rls_using_cid(): return f"({_BYPASS} OR {_CID})"
def _rls_using_iid(): return f"({_BYPASS} OR {_IID})"
def _rls_using_iid(): return f"({_BYPASS} OR {_IID} OR {_RID})"
def _rls_using_users(): return f"({_BYPASS} OR {_CID} OR {_USER_RESELLER})"
def _rls_using_join(): return (
f"({_BYPASS} OR user_id IN (SELECT id FROM users WHERE {_CID}))"
)
@@ -55,7 +62,8 @@ async def _apply_rls(conn) -> None:
for sql in enable("companies", _rls_using_iid()):
await conn.execute(text(sql))
for table in _COMPANY_COL_TABLES:
for sql in enable(table, _rls_using_cid()):
using = _rls_using_users() if table == "users" else _rls_using_cid()
for sql in enable(table, using):
await conn.execute(text(sql))
for table in _USER_JOIN_TABLES:
for sql in enable(table, _rls_using_join()):
+109
View File
@@ -0,0 +1,109 @@
"""Tests für Reseller-Rolle + Mandantenverwaltung + RLS-Isolation."""
import uuid
import pytest
from httpx import AsyncClient
from sqlalchemy import text
from app.core.security import hash_password
from app.models.user import User, UserRole
async def _make_user(db, *, email, role, company_id=None, password="Secret123"):
await db.execute(text("SET LOCAL app.bypass_rls = 'on'"))
user = User(
id=uuid.uuid4(), company_id=company_id, email=email,
password_hash=hash_password(password),
first_name=role.title(), last_name="User",
role=UserRole(role), is_active=True,
)
db.add(user)
await db.commit()
return user.id
async def _login(client, email, password="Secret123"):
r = await client.post("/api/v1/auth/login", json={"email": email, "password": password})
assert r.status_code == 200, r.text
return {"Authorization": f"Bearer {r.json()['access_token']}"}
@pytest.mark.asyncio(loop_scope="session")
async def test_superadmin_creates_reseller(client: AsyncClient, db_session):
await _make_user(db_session, email="sa@platform.de", role="SUPER_ADMIN")
h = await _login(client, "sa@platform.de")
r = await client.post("/api/v1/admin/resellers", json={
"email": "reseller-a@partner.de", "first_name": "Rita", "last_name": "Reseller",
}, headers=h)
assert r.status_code == 201, r.text
assert r.json()["company_count"] == 0
lst = await client.get("/api/v1/admin/resellers", headers=h)
assert any(x["email"] == "reseller-a@partner.de" for x in lst.json())
@pytest.mark.asyncio(loop_scope="session")
async def test_reseller_creates_and_sees_only_own_company(client: AsyncClient, db_session):
rid_a = await _make_user(db_session, email="res-a@p.de", role="RESELLER")
rid_b = await _make_user(db_session, email="res-b@p.de", role="RESELLER")
ha = await _login(client, "res-a@p.de")
hb = await _login(client, "res-b@p.de")
# Reseller A legt eine Firma an
r = await client.post("/api/v1/reseller/companies", json={
"name": "Alpha GmbH", "admin_email": "admin@alpha.de",
"admin_first_name": "Al", "admin_last_name": "Pha",
}, headers=ha)
assert r.status_code == 201, r.text
assert r.json()["name"] == "Alpha GmbH"
# Reseller B legt eine andere Firma an
r2 = await client.post("/api/v1/reseller/companies", json={
"name": "Beta GmbH", "admin_email": "admin@beta.de",
"admin_first_name": "Be", "admin_last_name": "Ta",
}, headers=hb)
assert r2.status_code == 201, r2.text
# A sieht nur Alpha, NICHT Beta (RLS-Isolation)
la = await client.get("/api/v1/reseller/companies", headers=ha)
names_a = {c["name"] for c in la.json()}
assert "Alpha GmbH" in names_a
assert "Beta GmbH" not in names_a
lb = await client.get("/api/v1/reseller/companies", headers=hb)
names_b = {c["name"] for c in lb.json()}
assert "Beta GmbH" in names_b
assert "Alpha GmbH" not in names_b
assert rid_a != rid_b
@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")
h = await _login(client, "res-c@p.de")
r = await client.get("/api/v1/admin/tenants", headers=h)
assert r.status_code == 403
@pytest.mark.asyncio(loop_scope="session")
async def test_deactivated_tenant_blocks_login(client: AsyncClient, db_session):
rid = await _make_user(db_session, email="res-d@p.de", role="RESELLER")
h = await _login(client, "res-d@p.de")
create = await client.post("/api/v1/reseller/companies", json={
"name": "Gamma GmbH", "admin_email": "admin@gamma.de",
"admin_first_name": "Ga", "admin_last_name": "Mma",
}, headers=h)
company_id = create.json()["id"]
# Direkt einen aktiven Mitarbeiter in Gamma anlegen (per DB) und Login testen
await _make_user(db_session, email="emp@gamma.de", role="EMPLOYEE", company_id=company_id)
ok = await client.post("/api/v1/auth/login", json={"email": "emp@gamma.de", "password": "Secret123"})
assert ok.status_code == 200
# Firma deaktivieren → Login gesperrt
await client.patch(f"/api/v1/reseller/companies/{company_id}", json={"is_active": False}, headers=h)
blocked = await client.post("/api/v1/auth/login", json={"email": "emp@gamma.de", "password": "Secret123"})
assert blocked.status_code == 403
assert rid is not None
+4
View File
@@ -30,6 +30,8 @@ import { MobilePage } from './pages/mobile/MobilePage'
import { MobileLoginPage } from './pages/mobile/MobileLoginPage'
import { SpecialAssignmentsPage } from './pages/SpecialAssignmentsPage'
import { HoursPayoutPage } from './pages/HoursPayoutPage'
import { TenantsPage } from './pages/TenantsPage'
import { ResellerCompaniesPage } from './pages/ResellerCompaniesPage'
export default function App() {
return (
@@ -65,6 +67,8 @@ export default function App() {
<Route path='/settings/audit-log' element={<AuditLogPage />} />
<Route path='/hr/special-assignments' element={<SpecialAssignmentsPage />} />
<Route path='/hr/payouts' element={<HoursPayoutPage />} />
<Route path='/admin/tenants' element={<TenantsPage />} />
<Route path='/reseller' element={<ResellerCompaniesPage />} />
<Route path='/profile' element={<ProfilePage />} />
</Route>
<Route path='*' element={<Navigate to='/login' replace />} />
+1
View File
@@ -23,6 +23,7 @@ const MAIN_NAV: NavItem[] = [
{ path: '/hr/special-assignments', label: 'Sondervertretungen', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER'] },
{ path: '/hr/payouts', label: 'Stunden-Auszahlung', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] },
{ path: '/users', label: 'Mitarbeiter', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] },
{ path: '/admin/tenants', label: 'Mandanten', roles: ['SUPER_ADMIN'] },
]
const SETTINGS_NAV: NavItem[] = [
+6
View File
@@ -25,8 +25,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.setItem('access_token', data.access_token)
// refresh_token NICHT mehr in localStorage speichern (M-2: HttpOnly-Cookie)
setToken(data.access_token)
// Reseller haben keine Firma → eigene Mandanten-Oberfläche statt Dashboard
try {
const me = await api.get<{ role: string }>('/auth/me')
navigate(me.role === 'RESELLER' ? '/reseller' : '/dashboard')
} catch {
navigate('/dashboard')
}
}
async function logout() {
// Cookie wird vom Backend gelöscht; kein refresh_token aus localStorage nötig
@@ -0,0 +1,147 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { useAuth } from '../context/AuthContext'
interface Tenant {
id: string; name: string; slug: string; plan: string; is_active: boolean
country: string; user_count: number; active_user_count: number; admin_email: string | null
}
interface Me { first_name: string; last_name: string; role: string }
const empty = { name: '', country: 'DE', plan: 'trial', admin_email: '', admin_first_name: '', admin_last_name: '' }
export function ResellerCompaniesPage() {
const { logout } = useAuth()
const [me, setMe] = useState<Me | null>(null)
const [companies, setCompanies] = useState<Tenant[]>([])
const [error, setError] = useState<string | null>(null)
const [showNew, setShowNew] = useState(false)
const [form, setForm] = useState({ ...empty })
const [busy, setBusy] = useState(false)
async function load() {
try { setCompanies(await api.get<Tenant[]>('/reseller/companies')) }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler beim Laden') }
}
useEffect(() => {
api.get<Me>('/auth/me').then(setMe).catch(() => {})
load()
}, [])
async function createCompany() {
setBusy(true); setError(null)
try {
await api.post('/reseller/companies', form)
setShowNew(false); setForm({ ...empty }); await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Anlegen')
} finally { setBusy(false) }
}
async function toggle(t: Tenant) {
try { await api.patch(`/reseller/companies/${t.id}`, { is_active: !t.is_active }); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
}
const active = companies.filter(c => c.is_active).length
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200 sticky top-0 z-30">
<div className="max-w-5xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">TM</span>
</div>
<span className="font-bold text-gray-800 text-sm">TimeMaster · Reseller</span>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500 hidden sm:block">{me ? `${me.first_name} ${me.last_name}` : ''}</span>
<button onClick={logout} title="Abmelden"
className="p-2 rounded-lg text-gray-500 hover:text-red-600 hover:bg-red-50">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Meine Firmen</h1>
<p className="text-sm text-gray-500">{active} aktiv · {companies.length} gesamt</p>
</div>
<button onClick={() => setShowNew(true)}
className="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700">
+ Firma anlegen
</button>
</div>
{error && <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{companies.map(t => (
<div key={t.id} className={`bg-white rounded-xl shadow-sm border p-5 space-y-3 ${t.is_active ? 'border-gray-200' : 'border-gray-200 opacity-60'}`}>
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="font-semibold text-gray-800">{t.name}</h3>
<p className="text-xs text-gray-400">{t.slug}</p>
</div>
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">{t.plan}</span>
</div>
<div className="text-sm text-gray-600">👥 {t.active_user_count}/{t.user_count} Nutzer</div>
{t.admin_email && <div className="text-xs text-gray-400 truncate">Admin: {t.admin_email}</div>}
<button onClick={() => toggle(t)}
className={`w-full text-xs px-2 py-1.5 rounded-lg font-medium ${
t.is_active ? 'bg-green-50 text-green-700 hover:bg-green-100' : 'bg-red-50 text-red-700 hover:bg-red-100'
}`}>
{t.is_active ? '● aktiv zum Deaktivieren klicken' : '○ deaktiviert zum Aktivieren klicken'}
</button>
</div>
))}
{companies.length === 0 && (
<div className="col-span-full text-center text-gray-400 py-12">Noch keine Firmen angelegt.</div>
)}
</div>
</main>
{showNew && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" onClick={() => setShowNew(false)}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-bold text-gray-900">Neue Firma anlegen</h2>
<div className="space-y-3">
<L label="Firmenname"><input className={inp} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} /></L>
<div className="grid grid-cols-2 gap-3">
<L label="Land"><input className={inp} value={form.country} onChange={e => setForm({ ...form, country: e.target.value })} /></L>
<L label="Plan"><input className={inp} value={form.plan} onChange={e => setForm({ ...form, plan: e.target.value })} /></L>
</div>
<hr />
<p className="text-xs text-gray-500">Erster Administrator (erhält Einladung per E-Mail):</p>
<L label="Admin E-Mail"><input className={inp} value={form.admin_email} onChange={e => setForm({ ...form, admin_email: e.target.value })} /></L>
<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="Nachname"><input className={inp} value={form.admin_last_name} onChange={e => setForm({ ...form, admin_last_name: e.target.value })} /></L>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setShowNew(false)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
<button onClick={createCompany} disabled={busy}
className="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50">
{busy ? 'Speichern…' : 'Anlegen'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
const inp = 'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm'
function L({ label, children }: { label: string; children: React.ReactNode }) {
return <label className="block space-y-1"><span className="text-xs font-medium text-gray-600">{label}</span>{children}</label>
}
+255
View File
@@ -0,0 +1,255 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Layout } from '../components/Layout'
interface Tenant {
id: string; name: string; slug: string; plan: string; is_active: boolean
country: string; reseller_id: string | null; reseller_name: string | null
user_count: number; active_user_count: number; admin_email: string | null
}
interface Reseller {
id: string; email: string; first_name: string; last_name: string
is_active: boolean; company_count: number
}
interface Me { first_name: string; last_name: string; role: string }
const empty = { name: '', country: 'DE', plan: 'trial', admin_email: '', admin_first_name: '', admin_last_name: '' }
const emptyReseller = { email: '', first_name: '', last_name: '' }
export function TenantsPage() {
const [me, setMe] = useState<Me | null>(null)
const [tab, setTab] = useState<'tenants' | 'resellers'>('tenants')
const [tenants, setTenants] = useState<Tenant[]>([])
const [resellers, setResellers] = useState<Reseller[]>([])
const [error, setError] = useState<string | null>(null)
const [showNew, setShowNew] = useState(false)
const [form, setForm] = useState({ ...empty, reseller_id: '' })
const [showNewReseller, setShowNewReseller] = useState(false)
const [rForm, setRForm] = useState({ ...emptyReseller })
const [busy, setBusy] = useState(false)
async function load() {
try {
const [t, r] = await Promise.all([
api.get<Tenant[]>('/admin/tenants'),
api.get<Reseller[]>('/admin/resellers'),
])
setTenants(t); setResellers(r)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
}
}
useEffect(() => {
api.get<Me>('/auth/me').then(setMe).catch(() => {})
load()
}, [])
async function createTenant() {
setBusy(true); setError(null)
try {
const q = form.reseller_id ? `?reseller_id=${form.reseller_id}` : ''
const { reseller_id, ...body } = form
void reseller_id
await api.post(`/admin/tenants${q}`, body)
setShowNew(false); setForm({ ...empty, reseller_id: '' }); await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Anlegen')
} finally { setBusy(false) }
}
async function toggleTenant(t: Tenant) {
try { await api.patch(`/admin/tenants/${t.id}`, { is_active: !t.is_active }); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
}
async function assignReseller(t: Tenant, reseller_id: string) {
try { await api.patch(`/admin/tenants/${t.id}/reseller`, { reseller_id: reseller_id || null }); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
}
async function createReseller() {
setBusy(true); setError(null)
try {
await api.post('/admin/resellers', rForm)
setShowNewReseller(false); setRForm({ ...emptyReseller }); await load()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Anlegen')
} finally { setBusy(false) }
}
async function toggleReseller(r: Reseller) {
try { await api.patch(`/admin/resellers/${r.id}?is_active=${!r.is_active}`, {}); await load() }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler') }
}
return (
<Layout userRole={me?.role ?? ''} userName={me ? `${me.first_name} ${me.last_name}` : ''}>
<div className="max-w-6xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Mandanten</h1>
{tab === 'tenants' ? (
<button onClick={() => setShowNew(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700">
+ Firma anlegen
</button>
) : (
<button onClick={() => setShowNewReseller(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700">
+ Reseller anlegen
</button>
)}
</div>
<div className="flex gap-1 border-b border-gray-200">
{(['tenants', 'resellers'] as const).map(t => (
<button key={t} onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
tab === t ? 'border-blue-600 text-blue-700' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}>
{t === 'tenants' ? `Firmen (${tenants.length})` : `Reseller (${resellers.length})`}
</button>
))}
</div>
{error && <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
{tab === 'tenants' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Firma</th>
<th className="text-left px-4 py-3">Plan</th>
<th className="text-left px-4 py-3">Nutzer</th>
<th className="text-left px-4 py-3">Admin</th>
<th className="text-left px-4 py-3">Reseller</th>
<th className="text-left px-4 py-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{tenants.map(t => (
<tr key={t.id} className={t.is_active ? '' : 'bg-gray-50 text-gray-400'}>
<td className="px-4 py-3 font-medium text-gray-800">{t.name}<div className="text-xs text-gray-400">{t.slug}</div></td>
<td className="px-4 py-3">{t.plan}</td>
<td className="px-4 py-3">{t.active_user_count}/{t.user_count}</td>
<td className="px-4 py-3 text-xs">{t.admin_email ?? '—'}</td>
<td className="px-4 py-3">
<select value={t.reseller_id ?? ''} onChange={e => assignReseller(t, e.target.value)}
className="text-xs border border-gray-200 rounded px-1 py-1 bg-white">
<option value=""> Plattform </option>
{resellers.map(r => <option key={r.id} value={r.id}>{r.first_name} {r.last_name}</option>)}
</select>
</td>
<td className="px-4 py-3">
<button onClick={() => toggleTenant(t)}
className={`text-xs px-2 py-1 rounded-full font-medium ${
t.is_active ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{t.is_active ? 'aktiv' : 'deaktiviert'}
</button>
</td>
</tr>
))}
{tenants.length === 0 && <tr><td colSpan={6} className="px-4 py-8 text-center text-gray-400">Keine Firmen.</td></tr>}
</tbody>
</table>
</div>
)}
{tab === 'resellers' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Name</th>
<th className="text-left px-4 py-3">E-Mail</th>
<th className="text-left px-4 py-3">Firmen</th>
<th className="text-left px-4 py-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{resellers.map(r => (
<tr key={r.id}>
<td className="px-4 py-3 font-medium text-gray-800">{r.first_name} {r.last_name}</td>
<td className="px-4 py-3 text-xs">{r.email}</td>
<td className="px-4 py-3">{r.company_count}</td>
<td className="px-4 py-3">
<button onClick={() => toggleReseller(r)}
className={`text-xs px-2 py-1 rounded-full font-medium ${
r.is_active ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{r.is_active ? 'aktiv' : 'deaktiviert'}
</button>
</td>
</tr>
))}
{resellers.length === 0 && <tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400">Keine Reseller.</td></tr>}
</tbody>
</table>
</div>
)}
{showNew && (
<Modal title="Neue Firma anlegen" onClose={() => setShowNew(false)} onSubmit={createTenant} busy={busy}>
<Field label="Firmenname"><input className={inp} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} /></Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Land"><input className={inp} value={form.country} onChange={e => setForm({ ...form, country: e.target.value })} /></Field>
<Field label="Plan"><input className={inp} value={form.plan} onChange={e => setForm({ ...form, plan: e.target.value })} /></Field>
</div>
<Field label="Reseller (optional)">
<select className={inp} value={form.reseller_id} onChange={e => setForm({ ...form, reseller_id: e.target.value })}>
<option value=""> Plattform (kein Reseller) </option>
{resellers.map(r => <option key={r.id} value={r.id}>{r.first_name} {r.last_name}</option>)}
</select>
</Field>
<hr className="my-1" />
<p className="text-xs text-gray-500">Erster Administrator (erhält Einladung per E-Mail):</p>
<Field label="Admin E-Mail"><input className={inp} value={form.admin_email} onChange={e => setForm({ ...form, admin_email: e.target.value })} /></Field>
<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="Nachname"><input className={inp} value={form.admin_last_name} onChange={e => setForm({ ...form, admin_last_name: e.target.value })} /></Field>
</div>
</Modal>
)}
{showNewReseller && (
<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>
<Field label="E-Mail"><input className={inp} value={rForm.email} onChange={e => setRForm({ ...rForm, email: e.target.value })} /></Field>
<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="Nachname"><input className={inp} value={rForm.last_name} onChange={e => setRForm({ ...rForm, last_name: e.target.value })} /></Field>
</div>
</Modal>
)}
</div>
</Layout>
)
}
const inp = 'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm'
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return <label className="block space-y-1"><span className="text-xs font-medium text-gray-600">{label}</span>{children}</label>
}
function Modal({ title, children, onClose, onSubmit, busy }: {
title: string; children: React.ReactNode; onClose: () => void; onSubmit: () => void; busy: boolean
}) {
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" onClick={onClose}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-bold text-gray-900">{title}</h2>
<div className="space-y-3">{children}</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
<button onClick={onSubmit} disabled={busy}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50">
{busy ? 'Speichern…' : 'Anlegen'}
</button>
</div>
</div>
</div>
)
}