White-Label Phase 1: Host→Tenant-Auflösung + Branding

- Domain-Entity polymorph (Reseller ODER Firma)
- TenantResolver: Host → Plattform / reseller.portal / firma.reseller.portal
  / verifizierte Custom-Domain
- Öffentliches GET /api/branding (Name, Ebene, Farben, Logo) nach Host
- TLS-Gate nutzt TenantResolver (nur bekannte Hosts → Zertifikat)
- Frontend: Branding-Store lädt vor Mount, färbt Theme um, TenantBrand-
  Komponente (Logo/Name je Tenant), Login zeigt Tenant
- Vite-Proxy reicht Original-Host durch (lokales White-Label-Testing)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-09 12:58:20 +02:00
parent 936e25e162
commit b8f9a50731
14 changed files with 436 additions and 32 deletions

View File

@ -38,6 +38,7 @@ security:
access_control: access_control:
- { path: ^/api/login, roles: PUBLIC_ACCESS } - { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api/docs, roles: PUBLIC_ACCESS } - { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/branding, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
role_hierarchy: role_hierarchy:

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260609075521 extends AbstractMigration
{
public function getDescription(): string
{
return 'Domain polymorph: company_id nullable + reseller_id (Reseller-eigene Domains)';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE domain ADD reseller_id BINARY(16) DEFAULT NULL, CHANGE company_id company_id BINARY(16) DEFAULT NULL');
$this->addSql('ALTER TABLE domain ADD CONSTRAINT FK_A7A91E0B91E6A19D FOREIGN KEY (reseller_id) REFERENCES reseller (id)');
$this->addSql('CREATE INDEX IDX_A7A91E0B91E6A19D ON domain (reseller_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE domain DROP FOREIGN KEY FK_A7A91E0B91E6A19D');
$this->addSql('DROP INDEX IDX_A7A91E0B91E6A19D ON domain');
$this->addSql('ALTER TABLE domain DROP reseller_id, CHANGE company_id company_id BINARY(16) NOT NULL');
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Controller;
use App\Service\BrandingService;
use App\Service\ResolvedTenant;
use App\Service\TenantResolver;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
/**
* Öffentliches Branding zum aufgerufenen Host (KONZEPT §11): die SPA fragt beim
* Start, in welchem White-Label-Kontext sie läuft, und themed sich entsprechend.
* Kein Login nötig (Login-Seite muss bereits gebrandet sein).
*/
final class BrandingController
{
#[Route('/api/branding', name: 'public_branding', methods: ['GET'])]
public function __invoke(Request $request, TenantResolver $resolver, BrandingService $branding): JsonResponse
{
$tenant = $resolver->resolve($request->getHost()) ?? ResolvedTenant::platform();
return new JsonResponse($branding->forTenant($tenant));
}
}

View File

@ -2,8 +2,7 @@
namespace App\Controller; namespace App\Controller;
use App\Repository\DomainRepository; use App\Service\TenantResolver;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@ -11,31 +10,21 @@ use Symfony\Component\Routing\Attribute\Route;
/** /**
* On-Demand-TLS-Autorisierung für Caddy (KONZEPT §11): Caddy fragt vor dem * On-Demand-TLS-Autorisierung für Caddy (KONZEPT §11): Caddy fragt vor dem
* Ausstellen eines Let's-Encrypt-Zertifikats hier nach, ob die Domain erlaubt ist. * Ausstellen eines Let's-Encrypt-Zertifikats hier nach, ob die Domain erlaubt ist.
* Erlaubt = Portal-Domain oder eine verifizierte Custom-Domain aus der DB. * Erlaubt = jeder Host, den der TenantResolver kennt (Portal-Domain, Reseller-/
* Firmen-Standard-Subdomain oder verifizierte Custom-Domain).
* 200 ausstellen, sonst ablehnen (verhindert unbegrenzte Zertifikatsanfragen). * 200 ausstellen, sonst ablehnen (verhindert unbegrenzte Zertifikatsanfragen).
*/ */
final class TlsCheckController final class TlsCheckController
{ {
public function __construct(
#[Autowire('%env(APP_PORTAL_DOMAIN)%')]
private readonly string $portalDomain,
) {
}
#[Route('/internal/tls-allowed', name: 'tls_allowed', methods: ['GET'])] #[Route('/internal/tls-allowed', name: 'tls_allowed', methods: ['GET'])]
public function __invoke(Request $request, DomainRepository $domains): Response public function __invoke(Request $request, TenantResolver $resolver): Response
{ {
$host = strtolower(trim((string) $request->query->get('domain'))); $host = (string) $request->query->get('domain');
if ('' === $host) { if ('' === trim($host)) {
return new Response('missing domain', 400); return new Response('missing domain', 400);
} }
$portal = strtolower($this->portalDomain); if (null !== $resolver->resolve($host)) {
if ($host === $portal || $host === 'www.'.$portal) {
return new Response('ok', 200);
}
if (null !== $domains->findVerifiedByHostname($host)) {
return new Response('ok', 200); return new Response('ok', 200);
} }

View File

@ -9,10 +9,12 @@ use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
/** /**
* Sub- oder Custom-Domain eines Firmenkunden (siehe KONZEPT §11). * Sub- oder Custom-Domain eines Tenants (siehe KONZEPT §11).
* Gehört zu genau EINEM Reseller ODER einer Firma (Firmen-Domain ist via
* company.reseller implizit auch reseller-zugeordnet).
*/ */
#[ORM\Entity(repositoryClass: DomainRepository::class)] #[ORM\Entity(repositoryClass: DomainRepository::class)]
#[ApiResource(security: "is_granted('ROLE_COMPANY_ADMIN')")] #[ApiResource(security: "is_granted('ROLE_RESELLER_ADMIN')")]
class Domain implements ResellerOwnedInterface class Domain implements ResellerOwnedInterface
{ {
public const TYPE_SUBDOMAIN = 'subdomain'; public const TYPE_SUBDOMAIN = 'subdomain';
@ -41,9 +43,15 @@ class Domain implements ResellerOwnedInterface
#[ORM\Column(type: 'datetime_immutable', nullable: true)] #[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $verificationCheckedAt = null; private ?\DateTimeImmutable $verificationCheckedAt = null;
/** Firmen-Domain (genau eines von company/reseller ist gesetzt). */
#[ORM\ManyToOne(targetEntity: Company::class, inversedBy: 'domains')] #[ORM\ManyToOne(targetEntity: Company::class, inversedBy: 'domains')]
#[ORM\JoinColumn(nullable: false)] #[ORM\JoinColumn(nullable: true)]
private Company $company; private ?Company $company = null;
/** Reseller-Domain (für reseller-eigene Subdomain/Custom-Domain). */
#[ORM\ManyToOne(targetEntity: Reseller::class)]
#[ORM\JoinColumn(nullable: true)]
private ?Reseller $reseller = null;
public function __construct() public function __construct()
{ {
@ -115,12 +123,12 @@ class Domain implements ResellerOwnedInterface
return $this; return $this;
} }
public function getCompany(): Company public function getCompany(): ?Company
{ {
return $this->company; return $this->company;
} }
public function setCompany(Company $company): self public function setCompany(?Company $company): self
{ {
$this->company = $company; $this->company = $company;
@ -129,6 +137,19 @@ class Domain implements ResellerOwnedInterface
public function getReseller(): ?Reseller public function getReseller(): ?Reseller
{ {
return $this->company->getReseller(); return $this->company?->getReseller() ?? $this->reseller;
}
public function setReseller(?Reseller $reseller): self
{
$this->reseller = $reseller;
return $this;
}
/** Ebene der Domain: 'company' oder 'reseller'. */
public function getScope(): string
{
return null !== $this->company ? 'company' : 'reseller';
} }
} }

View File

@ -0,0 +1,64 @@
<?php
namespace App\Service;
/**
* Baut aus einem aufgelösten Tenant das Branding (Name, Farben, Logo) für die SPA.
* Fallback-Kette: Firma Reseller Plattform-Standard. So erbt eine Firma ohne
* eigenes Branding automatisch das ihres Resellers.
*/
final class BrandingService
{
/** Plattform-Standard (vcard4reseller-Orange). */
private const DEFAULTS = [
'primaryColor' => '#f58220',
'primaryColorDark' => '#d96500',
'primaryColorSoft' => '#fff2e7',
'logoUrl' => null,
'tagline' => null,
];
/**
* @return array{level:string,name:string,reseller:?string,customDomain:bool,branding:array<string,mixed>}
*/
public function forTenant(ResolvedTenant $tenant): array
{
// Branding-Ebenen von allgemein nach speziell zusammenführen
$configs = [];
if (null !== $tenant->reseller) {
$configs[] = $tenant->reseller->getBrandingConfig();
}
if (null !== $tenant->company) {
$configs[] = $tenant->company->getBrandingConfig();
}
$branding = self::DEFAULTS;
foreach ($configs as $config) {
foreach (self::DEFAULTS as $key => $_) {
if (isset($config[$key]) && '' !== $config[$key]) {
$branding[$key] = $config[$key];
}
}
}
return [
'level' => $tenant->kind,
'name' => $this->name($tenant),
'reseller' => $tenant->reseller?->getName(),
'customDomain' => $tenant->customDomain,
'branding' => $branding,
];
}
private function name(ResolvedTenant $tenant): string
{
if ($tenant->isCompany() && null !== $tenant->company) {
return $tenant->company->getName();
}
if ($tenant->isReseller() && null !== $tenant->reseller) {
return $tenant->reseller->getName();
}
return 'vcard4reseller';
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Service;
use App\Entity\Company;
use App\Entity\Reseller;
/**
* Ergebnis der Host-Auflösung (siehe TenantResolver): welche Ebene/welcher
* Mandant gehört zum aufgerufenen Hostnamen.
*/
final readonly class ResolvedTenant
{
public const KIND_PLATFORM = 'platform';
public const KIND_RESELLER = 'reseller';
public const KIND_COMPANY = 'company';
public function __construct(
public string $kind,
public ?Reseller $reseller = null,
public ?Company $company = null,
/** true = eigene Domain des Kunden, false = Standard-Subdomain/Portal. */
public bool $customDomain = false,
) {
}
public static function platform(): self
{
return new self(self::KIND_PLATFORM);
}
public function isPlatform(): bool
{
return self::KIND_PLATFORM === $this->kind;
}
public function isReseller(): bool
{
return self::KIND_RESELLER === $this->kind;
}
public function isCompany(): bool
{
return self::KIND_COMPANY === $this->kind;
}
}

View File

@ -0,0 +1,125 @@
<?php
namespace App\Service;
use App\Entity\Company;
use App\Entity\Domain;
use App\Entity\Reseller;
use App\Repository\CompanyRepository;
use App\Repository\DomainRepository;
use App\Repository\ResellerRepository;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Löst einen Request-Hostnamen auf die zugehörige Tenant-Ebene auf (KONZEPT §11).
*
* - <portal> Plattform (Admin/Portal)
* - <reseller>.<portal> Reseller (White-Label)
* - <firma>.<reseller>.<portal> Firma
* - verifizierte Custom-Domain Reseller oder Firma
*
* Liefert null, wenn der Host unbekannt ist (z. B. zufällige Subdomain)
* darauf stützt sich das TLS-Gate, um keine Zertifikate für Fremd-Hosts zu holen.
*/
final class TenantResolver
{
private string $portalDomain;
public function __construct(
#[Autowire('%env(APP_PORTAL_DOMAIN)%')]
string $portalDomain,
private readonly ResellerRepository $resellers,
private readonly CompanyRepository $companies,
private readonly DomainRepository $domains,
) {
$this->portalDomain = $this->normalize($portalDomain);
}
public function resolve(string $host): ?ResolvedTenant
{
$host = $this->normalize($host);
if ('' === $host) {
return null;
}
$portal = $this->portalDomain;
if ($host === $portal || $host === 'www.'.$portal) {
return ResolvedTenant::platform();
}
// Standard-Subdomains unter der Portal-Domain
if (str_ends_with($host, '.'.$portal)) {
$sub = substr($host, 0, -\strlen('.'.$portal));
$parts = explode('.', $sub);
if (1 === \count($parts)) {
$reseller = $this->findReseller($parts[0]);
return $reseller ? new ResolvedTenant(ResolvedTenant::KIND_RESELLER, $reseller) : null;
}
if (2 === \count($parts)) {
[$companySlug, $resellerSlug] = $parts;
$reseller = $this->findReseller($resellerSlug);
$company = $reseller ? $this->findCompany($companySlug, $reseller) : null;
return $company ? new ResolvedTenant(ResolvedTenant::KIND_COMPANY, $reseller, $company) : null;
}
return null;
}
// Custom-Domain (eigene Domain des Kunden)
$domain = $this->domains->findVerifiedByHostname($host);
if (null === $domain) {
return null;
}
if (null !== $domain->getCompany()) {
return new ResolvedTenant(ResolvedTenant::KIND_COMPANY, $domain->getReseller(), $domain->getCompany(), true);
}
if (null !== $domain->getReseller()) {
return new ResolvedTenant(ResolvedTenant::KIND_RESELLER, $domain->getReseller(), null, true);
}
return null;
}
/** Standard-Host eines Resellers, z. B. "muster.vcard4reseller.app". */
public function resellerHost(Reseller $reseller): string
{
return $reseller->getSlug().'.'.$this->portalDomain;
}
/** Standard-Host einer Firma, z. B. "acme.muster.vcard4reseller.app". */
public function companyHost(Company $company): string
{
$reseller = $company->getReseller();
return $company->getSlug().'.'.($reseller?->getSlug() ?? 'app').'.'.$this->portalDomain;
}
public function getPortalDomain(): string
{
return $this->portalDomain;
}
private function findReseller(string $slug): ?Reseller
{
return $this->resellers->findOneBy(['slug' => $slug, 'status' => 'active']);
}
private function findCompany(string $slug, Reseller $reseller): ?Company
{
return $this->companies->findOneBy(['slug' => $slug, 'reseller' => $reseller, 'status' => 'active']);
}
private function normalize(string $host): string
{
$host = strtolower(trim($host));
if (false !== ($pos = strpos($host, ':'))) {
$host = substr($host, 0, $pos);
}
return rtrim($host, '.');
}
}

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
// Markendarstellung je Tenant: eigenes Logo Name (White-Label) vcard4reseller-Wortmarke.
import { useBrandingStore } from '@/stores/branding'
import BrandLogo from '@/components/BrandLogo.vue'
withDefaults(defineProps<{ size?: string }>(), { size: '1.25rem' })
const branding = useBrandingStore()
</script>
<template>
<img v-if="branding.colors.logoUrl" :src="branding.colors.logoUrl" :alt="branding.name"
class="tbrand-logo" :style="{ height: size }" />
<span v-else-if="branding.isWhiteLabel" class="tbrand-name" :style="{ fontSize: size }">{{ branding.name }}</span>
<BrandLogo v-else :size="size" />
</template>
<style scoped>
.tbrand-logo { display: block; width: auto; }
.tbrand-name { font-weight: 800; color: var(--dark); letter-spacing: -0.01em; white-space: nowrap; }
</style>

View File

@ -2,7 +2,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { RouterLink, RouterView, useRouter } from 'vue-router' import { RouterLink, RouterView, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import BrandLogo from '@/components/BrandLogo.vue' import TenantBrand from '@/components/TenantBrand.vue'
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter() const router = useRouter()
@ -70,7 +70,7 @@ async function stopImpersonation() {
<div class="shell"> <div class="shell">
<header class="topbar"> <header class="topbar">
<div class="topbar__brand"> <div class="topbar__brand">
<BrandLogo size="1.2rem" /> <TenantBrand size="1.2rem" />
<span class="level" :class="'level--' + (auth.isPlatformAdmin ? 'portal' : auth.isResellerAdmin ? 'reseller' : 'firma')">{{ levelLabel }}</span> <span class="level" :class="'level--' + (auth.isPlatformAdmin ? 'portal' : auth.isResellerAdmin ? 'reseller' : 'firma')">{{ levelLabel }}</span>
</div> </div>

View File

@ -3,8 +3,11 @@ import { createPinia } from 'pinia'
import './assets/brand.css' import './assets/brand.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { useBrandingStore } from './stores/branding'
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
app.mount('#app')
// White-Label-Theme zum Host laden, bevor die App sichtbar wird (kein Farb-Flackern)
useBrandingStore().load().finally(() => app.mount('#app'))

View File

@ -0,0 +1,68 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import client from '@/api/client'
export type TenantLevel = 'platform' | 'reseller' | 'company'
interface BrandingColors {
primaryColor: string
primaryColorDark: string
primaryColorSoft: string
logoUrl: string | null
tagline: string | null
}
interface BrandingData {
level: TenantLevel
name: string
reseller: string | null
customDomain: boolean
branding: BrandingColors
}
const DEFAULT_COLORS: BrandingColors = {
primaryColor: '#f58220',
primaryColorDark: '#d96500',
primaryColorSoft: '#fff2e7',
logoUrl: null,
tagline: null,
}
/**
* White-Label-Branding zum aufgerufenen Host (siehe Backend /api/branding).
* Wird beim App-Start geladen und färbt die Oberfläche um.
*/
export const useBrandingStore = defineStore('branding', () => {
const level = ref<TenantLevel>('platform')
const name = ref('vcard4reseller')
const reseller = ref<string | null>(null)
const customDomain = ref(false)
const colors = ref<BrandingColors>({ ...DEFAULT_COLORS })
const loaded = ref(false)
const isWhiteLabel = computed(() => level.value !== 'platform')
function applyTheme() {
const root = document.documentElement
root.style.setProperty('--psc-orange', colors.value.primaryColor)
root.style.setProperty('--psc-orange-dark', colors.value.primaryColorDark)
root.style.setProperty('--psc-orange-soft', colors.value.primaryColorSoft)
}
async function load() {
try {
const { data } = await client.get<BrandingData>('/branding')
level.value = data.level
name.value = data.name
reseller.value = data.reseller
customDomain.value = data.customDomain
colors.value = { ...DEFAULT_COLORS, ...data.branding }
applyTheme()
} catch {
// Fällt auf das Standard-Branding zurück
} finally {
loaded.value = true
}
}
return { level, name, reseller, customDomain, colors, loaded, isWhiteLabel, load }
})

View File

@ -2,8 +2,10 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import BrandLogo from '@/components/BrandLogo.vue' import { useBrandingStore } from '@/stores/branding'
import TenantBrand from '@/components/TenantBrand.vue'
const branding = useBrandingStore()
const email = ref('') const email = ref('')
const password = ref('') const password = ref('')
const error = ref('') const error = ref('')
@ -30,10 +32,12 @@ async function submit() {
<div class="login-bg"> <div class="login-bg">
<div class="card login"> <div class="card login">
<div class="login__brand"> <div class="login__brand">
<BrandLogo size="1.5rem" /> <TenantBrand size="1.5rem" />
</div> </div>
<h2>Anmelden</h2> <h2>Anmelden</h2>
<p class="muted">Zugang zur Verwaltungsoberfläche</p> <p class="muted">
{{ branding.isWhiteLabel ? `Zugang zu ${branding.name}` : 'Zugang zur Verwaltungsoberfläche' }}
</p>
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<div class="field"> <div class="field">
<label>E-Mail</label> <label>E-Mail</label>

View File

@ -15,9 +15,11 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
// API des Symfony-Backends im Dev durchreichen → kein CORS-Problem // API des Symfony-Backends im Dev durchreichen → kein CORS-Problem
// changeOrigin:false reicht den Original-Host durch (z. B. demo.localhost:5173),
// damit White-Label-Branding/Tenant-Auflösung auch lokal greift.
'/api': { '/api': {
target: 'http://localhost:8080', target: 'http://localhost:8080',
changeOrigin: true, changeOrigin: false,
}, },
}, },
}, },