feat: Reseller-Rolle + SUPER_ADMIN-Mandantenübersicht

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 da4745013e
commit fcda813ba6
18 changed files with 1198 additions and 8 deletions
+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: