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:
@@ -1946,3 +1946,51 @@ Keine Commits in dieser Session.
|
|||||||
- backend/app/routers/public_stamp.py | 11 +++++++++--
|
- 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 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -60,7 +60,15 @@ async def get_current_user(
|
|||||||
# Every other role gets the RLS fence applied: set company_id and disable
|
# Every other role gets the RLS fence applied: set company_id and disable
|
||||||
# bypass so subsequent queries in the same transaction are automatically
|
# bypass so subsequent queries in the same transaction are automatically
|
||||||
# filtered to the user's company.
|
# 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.
|
# Sicherheits-Invariante: safe_company_id muss eine valide UUID sein.
|
||||||
# Der _uuid.UUID()-Round-Trip verhindert SQL-Injection auch bei zukünftigen
|
# Der _uuid.UUID()-Round-Trip verhindert SQL-Injection auch bei zukünftigen
|
||||||
# Refactorings (z.B. falls user.company_id einmal ein String aus einem
|
# Refactorings (z.B. falls user.company_id einmal ein String aus einem
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.routers import audit
|
|||||||
from app.routers import special_assignments
|
from app.routers import special_assignments
|
||||||
from app.routers import hours_payouts
|
from app.routers import hours_payouts
|
||||||
from app.routers import public_stamp
|
from app.routers import public_stamp
|
||||||
|
from app.routers import reseller, tenants
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -98,6 +99,8 @@ app.include_router(busylight.router, prefix=API_PREFIX)
|
|||||||
app.include_router(audit.router, prefix=API_PREFIX)
|
app.include_router(audit.router, prefix=API_PREFIX)
|
||||||
app.include_router(special_assignments.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(hours_payouts.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(reseller.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(tenants.router, prefix=API_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
# ── Health ────────────────────────────────────────────────────────────────────
|
# ── Health ────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
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.dialects.postgresql import JSONB, UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -26,6 +26,17 @@ class Company(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||||
plan: Mapped[str] = mapped_column(String(50), default="trial")
|
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)
|
logo_url: Mapped[str | None] = mapped_column(Text)
|
||||||
country: Mapped[str] = mapped_column(String(10), default="DE")
|
country: Mapped[str] = mapped_column(String(10), default="DE")
|
||||||
state: Mapped[str | None] = mapped_column(String(10))
|
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
|
overtime_max_carryover_hours: Mapped[int | None] = mapped_column(Integer, nullable=True) # None = alles
|
||||||
|
|
||||||
# Relationships
|
# 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")
|
departments: Mapped[list["Department"]] = relationship("Department", back_populates="company", lazy="noload")
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
class UserRole(str, enum.Enum):
|
class UserRole(str, enum.Enum):
|
||||||
SUPER_ADMIN = "SUPER_ADMIN"
|
SUPER_ADMIN = "SUPER_ADMIN"
|
||||||
|
RESELLER = "RESELLER"
|
||||||
COMPANY_ADMIN = "COMPANY_ADMIN"
|
COMPANY_ADMIN = "COMPANY_ADMIN"
|
||||||
HR = "HR"
|
HR = "HR"
|
||||||
MANAGER = "MANAGER"
|
MANAGER = "MANAGER"
|
||||||
@@ -77,7 +78,9 @@ class User(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
# Relationships
|
# 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: Mapped["Department | None"] = relationship(
|
||||||
"Department",
|
"Department",
|
||||||
primaryjoin="User.department_id == Department.id",
|
primaryjoin="User.department_id == Department.id",
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -144,6 +144,13 @@ class AuthService:
|
|||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise HTTPException(status_code=403, detail="Account is deactivated")
|
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
|
auth_ok = False
|
||||||
if user.auth_provider == AuthProvider.LDAP:
|
if user.auth_provider == AuthProvider.LDAP:
|
||||||
ldap_cfg = await ldap_service.get_config(user.company_id, db)
|
ldap_cfg = await ldap_service.get_config(user.company_id, db)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -18,9 +18,16 @@ TestSessionLocal = async_sessionmaker(test_engine, class_=AsyncSession, expire_o
|
|||||||
_BYPASS = "COALESCE(current_setting('app.bypass_rls', true), 'off') = 'on'"
|
_BYPASS = "COALESCE(current_setting('app.bypass_rls', true), 'off') = 'on'"
|
||||||
_CID = "company_id = NULLIF(current_setting('app.company_id', true), '')::uuid"
|
_CID = "company_id = NULLIF(current_setting('app.company_id', true), '')::uuid"
|
||||||
_IID = "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_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 (
|
def _rls_using_join(): return (
|
||||||
f"({_BYPASS} OR user_id IN (SELECT id FROM users WHERE {_CID}))"
|
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()):
|
for sql in enable("companies", _rls_using_iid()):
|
||||||
await conn.execute(text(sql))
|
await conn.execute(text(sql))
|
||||||
for table in _COMPANY_COL_TABLES:
|
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))
|
await conn.execute(text(sql))
|
||||||
for table in _USER_JOIN_TABLES:
|
for table in _USER_JOIN_TABLES:
|
||||||
for sql in enable(table, _rls_using_join()):
|
for sql in enable(table, _rls_using_join()):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -30,6 +30,8 @@ import { MobilePage } from './pages/mobile/MobilePage'
|
|||||||
import { MobileLoginPage } from './pages/mobile/MobileLoginPage'
|
import { MobileLoginPage } from './pages/mobile/MobileLoginPage'
|
||||||
import { SpecialAssignmentsPage } from './pages/SpecialAssignmentsPage'
|
import { SpecialAssignmentsPage } from './pages/SpecialAssignmentsPage'
|
||||||
import { HoursPayoutPage } from './pages/HoursPayoutPage'
|
import { HoursPayoutPage } from './pages/HoursPayoutPage'
|
||||||
|
import { TenantsPage } from './pages/TenantsPage'
|
||||||
|
import { ResellerCompaniesPage } from './pages/ResellerCompaniesPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -65,6 +67,8 @@ export default function App() {
|
|||||||
<Route path='/settings/audit-log' element={<AuditLogPage />} />
|
<Route path='/settings/audit-log' element={<AuditLogPage />} />
|
||||||
<Route path='/hr/special-assignments' element={<SpecialAssignmentsPage />} />
|
<Route path='/hr/special-assignments' element={<SpecialAssignmentsPage />} />
|
||||||
<Route path='/hr/payouts' element={<HoursPayoutPage />} />
|
<Route path='/hr/payouts' element={<HoursPayoutPage />} />
|
||||||
|
<Route path='/admin/tenants' element={<TenantsPage />} />
|
||||||
|
<Route path='/reseller' element={<ResellerCompaniesPage />} />
|
||||||
<Route path='/profile' element={<ProfilePage />} />
|
<Route path='/profile' element={<ProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path='*' element={<Navigate to='/login' replace />} />
|
<Route path='*' element={<Navigate to='/login' replace />} />
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const MAIN_NAV: NavItem[] = [
|
|||||||
{ path: '/hr/special-assignments', label: 'Sondervertretungen', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER'] },
|
{ 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: '/hr/payouts', label: 'Stunden-Auszahlung', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] },
|
||||||
{ path: '/users', label: 'Mitarbeiter', 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[] = [
|
const SETTINGS_NAV: NavItem[] = [
|
||||||
|
|||||||
@@ -25,8 +25,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
localStorage.setItem('access_token', data.access_token)
|
localStorage.setItem('access_token', data.access_token)
|
||||||
// refresh_token NICHT mehr in localStorage speichern (M-2: HttpOnly-Cookie)
|
// refresh_token NICHT mehr in localStorage speichern (M-2: HttpOnly-Cookie)
|
||||||
setToken(data.access_token)
|
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')
|
navigate('/dashboard')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
// Cookie wird vom Backend gelöscht; kein refresh_token aus localStorage nötig
|
// 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>
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user