diff --git a/DEVLOG.md b/DEVLOG.md index 74855ae..f17fea5 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index dec5f84..c528f2b 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 2b43b5f..2398657 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 ──────────────────────────────────────────────────────────────────── diff --git a/backend/app/models/company.py b/backend/app/models/company.py index fbd7e07..83774b4 100644 --- a/backend/app/models/company.py +++ b/backend/app/models/company.py @@ -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: diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 7937028..5ef9f2b 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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", diff --git a/backend/app/routers/reseller.py b/backend/app/routers/reseller.py new file mode 100644 index 0000000..e9efb2e --- /dev/null +++ b/backend/app/routers/reseller.py @@ -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] diff --git a/backend/app/routers/tenants.py b/backend/app/routers/tenants.py new file mode 100644 index 0000000..ceef42c --- /dev/null +++ b/backend/app/routers/tenants.py @@ -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), + ) diff --git a/backend/app/schemas/reseller.py b/backend/app/schemas/reseller.py new file mode 100644 index 0000000..c3e13c5 --- /dev/null +++ b/backend/app/schemas/reseller.py @@ -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) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 8a80834..c046604 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -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) diff --git a/backend/app/services/tenant_service.py b/backend/app/services/tenant_service.py new file mode 100644 index 0000000..6b7d68a --- /dev/null +++ b/backend/app/services/tenant_service.py @@ -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() diff --git a/backend/migrations/versions/0034_reseller.py b/backend/migrations/versions/0034_reseller.py new file mode 100644 index 0000000..8974a78 --- /dev/null +++ b/backend/migrations/versions/0034_reseller.py @@ -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) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e3138e9..426b522 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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_cid(): return f"({_BYPASS} OR {_CID})" +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()): diff --git a/backend/tests/test_reseller.py b/backend/tests/test_reseller.py new file mode 100644 index 0000000..50cef03 --- /dev/null +++ b/backend/tests/test_reseller.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f6570a0..630b191 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 29c8b80..d1a6a3f 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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[] = [ diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 4178f3f..02c1565 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -25,7 +25,13 @@ 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) - navigate('/dashboard') + // 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() { diff --git a/frontend/src/pages/ResellerCompaniesPage.tsx b/frontend/src/pages/ResellerCompaniesPage.tsx new file mode 100644 index 0000000..4eae55a --- /dev/null +++ b/frontend/src/pages/ResellerCompaniesPage.tsx @@ -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(null) + const [companies, setCompanies] = useState([]) + const [error, setError] = useState(null) + const [showNew, setShowNew] = useState(false) + const [form, setForm] = useState({ ...empty }) + const [busy, setBusy] = useState(false) + + async function load() { + try { setCompanies(await api.get('/reseller/companies')) } + catch (e: unknown) { setError(e instanceof Error ? e.message : 'Fehler beim Laden') } + } + + useEffect(() => { + api.get('/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 ( +
+
+
+
+
+ TM +
+ TimeMaster · Reseller +
+
+ {me ? `${me.first_name} ${me.last_name}` : ''} + +
+
+
+ +
+
+
+

Meine Firmen

+

{active} aktiv · {companies.length} gesamt

+
+ +
+ + {error &&
{error}
} + +
+ {companies.map(t => ( +
+
+
+

{t.name}

+

{t.slug}

+
+ {t.plan} +
+
👥 {t.active_user_count}/{t.user_count} Nutzer
+ {t.admin_email &&
Admin: {t.admin_email}
} + +
+ ))} + {companies.length === 0 && ( +
Noch keine Firmen angelegt.
+ )} +
+
+ + {showNew && ( +
setShowNew(false)}> +
e.stopPropagation()}> +

Neue Firma anlegen

+
+ setForm({ ...form, name: e.target.value })} /> +
+ setForm({ ...form, country: e.target.value })} /> + setForm({ ...form, plan: e.target.value })} /> +
+
+

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

+ setForm({ ...form, admin_email: e.target.value })} /> +
+ setForm({ ...form, admin_first_name: e.target.value })} /> + setForm({ ...form, admin_last_name: e.target.value })} /> +
+
+
+ + +
+
+
+ )} +
+ ) +} + +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 +} diff --git a/frontend/src/pages/TenantsPage.tsx b/frontend/src/pages/TenantsPage.tsx new file mode 100644 index 0000000..9aa5ac8 --- /dev/null +++ b/frontend/src/pages/TenantsPage.tsx @@ -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(null) + const [tab, setTab] = useState<'tenants' | 'resellers'>('tenants') + const [tenants, setTenants] = useState([]) + const [resellers, setResellers] = useState([]) + const [error, setError] = useState(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('/admin/tenants'), + api.get('/admin/resellers'), + ]) + setTenants(t); setResellers(r) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Fehler beim Laden') + } + } + + useEffect(() => { + api.get('/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 ( + +
+
+

Mandanten

+ {tab === 'tenants' ? ( + + ) : ( + + )} +
+ +
+ {(['tenants', 'resellers'] as const).map(t => ( + + ))} +
+ + {error &&
{error}
} + + {tab === 'tenants' && ( +
+ + + + + + + + + + + + + {tenants.map(t => ( + + + + + + + + + ))} + {tenants.length === 0 && } + +
FirmaPlanNutzerAdminResellerStatus
{t.name}
{t.slug}
{t.plan}{t.active_user_count}/{t.user_count}{t.admin_email ?? '—'} + + + +
Keine Firmen.
+
+ )} + + {tab === 'resellers' && ( +
+ + + + + + + + + + + {resellers.map(r => ( + + + + + + + ))} + {resellers.length === 0 && } + +
NameE-MailFirmenStatus
{r.first_name} {r.last_name}{r.email}{r.company_count} + +
Keine Reseller.
+
+ )} + + {showNew && ( + setShowNew(false)} onSubmit={createTenant} busy={busy}> + setForm({ ...form, name: e.target.value })} /> +
+ setForm({ ...form, country: e.target.value })} /> + setForm({ ...form, plan: e.target.value })} /> +
+ + + +
+

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

+ setForm({ ...form, admin_email: e.target.value })} /> +
+ setForm({ ...form, admin_first_name: e.target.value })} /> + setForm({ ...form, admin_last_name: e.target.value })} /> +
+
+ )} + + {showNewReseller && ( + setShowNewReseller(false)} onSubmit={createReseller} busy={busy}> +

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

+ setRForm({ ...rForm, email: e.target.value })} /> +
+ setRForm({ ...rForm, first_name: e.target.value })} /> + setRForm({ ...rForm, last_name: e.target.value })} /> +
+
+ )} +
+
+ ) +} + +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 +} + +function Modal({ title, children, onClose, onSubmit, busy }: { + title: string; children: React.ReactNode; onClose: () => void; onSubmit: () => void; busy: boolean +}) { + return ( +
+
e.stopPropagation()}> +

{title}

+
{children}
+
+ + +
+
+
+ ) +}