From b8f9a507313d53ec6270d5142a2d0beec75213bf Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Tue, 9 Jun 2026 12:58:20 +0200 Subject: [PATCH] =?UTF-8?q?White-Label=20Phase=201:=20Host=E2=86=92Tenant-?= =?UTF-8?q?Aufl=C3=B6sung=20+=20Branding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/config/packages/security.yaml | 1 + backend/migrations/Version20260609075521.php | 35 +++++ backend/src/Controller/BrandingController.php | 26 ++++ backend/src/Controller/TlsCheckController.php | 25 +--- backend/src/Entity/Domain.php | 35 ++++- backend/src/Service/BrandingService.php | 64 +++++++++ backend/src/Service/ResolvedTenant.php | 46 +++++++ backend/src/Service/TenantResolver.php | 125 ++++++++++++++++++ frontend/src/components/TenantBrand.vue | 20 +++ frontend/src/layouts/DashboardLayout.vue | 4 +- frontend/src/main.ts | 5 +- frontend/src/stores/branding.ts | 68 ++++++++++ frontend/src/views/LoginView.vue | 10 +- frontend/vite.config.ts | 4 +- 14 files changed, 436 insertions(+), 32 deletions(-) create mode 100644 backend/migrations/Version20260609075521.php create mode 100644 backend/src/Controller/BrandingController.php create mode 100644 backend/src/Service/BrandingService.php create mode 100644 backend/src/Service/ResolvedTenant.php create mode 100644 backend/src/Service/TenantResolver.php create mode 100644 frontend/src/components/TenantBrand.vue create mode 100644 frontend/src/stores/branding.ts diff --git a/backend/config/packages/security.yaml b/backend/config/packages/security.yaml index b857113..056c8ac 100644 --- a/backend/config/packages/security.yaml +++ b/backend/config/packages/security.yaml @@ -38,6 +38,7 @@ security: access_control: - { path: ^/api/login, roles: PUBLIC_ACCESS } - { path: ^/api/docs, roles: PUBLIC_ACCESS } + - { path: ^/api/branding, roles: PUBLIC_ACCESS } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } role_hierarchy: diff --git a/backend/migrations/Version20260609075521.php b/backend/migrations/Version20260609075521.php new file mode 100644 index 0000000..81143ec --- /dev/null +++ b/backend/migrations/Version20260609075521.php @@ -0,0 +1,35 @@ +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'); + } +} diff --git a/backend/src/Controller/BrandingController.php b/backend/src/Controller/BrandingController.php new file mode 100644 index 0000000..40d292b --- /dev/null +++ b/backend/src/Controller/BrandingController.php @@ -0,0 +1,26 @@ +resolve($request->getHost()) ?? ResolvedTenant::platform(); + + return new JsonResponse($branding->forTenant($tenant)); + } +} diff --git a/backend/src/Controller/TlsCheckController.php b/backend/src/Controller/TlsCheckController.php index a9e43b0..cc1c191 100644 --- a/backend/src/Controller/TlsCheckController.php +++ b/backend/src/Controller/TlsCheckController.php @@ -2,8 +2,7 @@ namespace App\Controller; -use App\Repository\DomainRepository; -use Symfony\Component\DependencyInjection\Attribute\Autowire; +use App\Service\TenantResolver; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; 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 * 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). */ final class TlsCheckController { - public function __construct( - #[Autowire('%env(APP_PORTAL_DOMAIN)%')] - private readonly string $portalDomain, - ) { - } - #[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'))); - if ('' === $host) { + $host = (string) $request->query->get('domain'); + if ('' === trim($host)) { return new Response('missing domain', 400); } - $portal = strtolower($this->portalDomain); - if ($host === $portal || $host === 'www.'.$portal) { - return new Response('ok', 200); - } - - if (null !== $domains->findVerifiedByHostname($host)) { + if (null !== $resolver->resolve($host)) { return new Response('ok', 200); } diff --git a/backend/src/Entity/Domain.php b/backend/src/Entity/Domain.php index 370014e..869cced 100644 --- a/backend/src/Entity/Domain.php +++ b/backend/src/Entity/Domain.php @@ -9,10 +9,12 @@ use Symfony\Bridge\Doctrine\Types\UuidType; 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)] -#[ApiResource(security: "is_granted('ROLE_COMPANY_ADMIN')")] +#[ApiResource(security: "is_granted('ROLE_RESELLER_ADMIN')")] class Domain implements ResellerOwnedInterface { public const TYPE_SUBDOMAIN = 'subdomain'; @@ -41,9 +43,15 @@ class Domain implements ResellerOwnedInterface #[ORM\Column(type: 'datetime_immutable', nullable: true)] private ?\DateTimeImmutable $verificationCheckedAt = null; + /** Firmen-Domain (genau eines von company/reseller ist gesetzt). */ #[ORM\ManyToOne(targetEntity: Company::class, inversedBy: 'domains')] - #[ORM\JoinColumn(nullable: false)] - private Company $company; + #[ORM\JoinColumn(nullable: true)] + 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() { @@ -115,12 +123,12 @@ class Domain implements ResellerOwnedInterface return $this; } - public function getCompany(): Company + public function getCompany(): ?Company { return $this->company; } - public function setCompany(Company $company): self + public function setCompany(?Company $company): self { $this->company = $company; @@ -129,6 +137,19 @@ class Domain implements ResellerOwnedInterface 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'; } } diff --git a/backend/src/Service/BrandingService.php b/backend/src/Service/BrandingService.php new file mode 100644 index 0000000..b9fd354 --- /dev/null +++ b/backend/src/Service/BrandingService.php @@ -0,0 +1,64 @@ + '#f58220', + 'primaryColorDark' => '#d96500', + 'primaryColorSoft' => '#fff2e7', + 'logoUrl' => null, + 'tagline' => null, + ]; + + /** + * @return array{level:string,name:string,reseller:?string,customDomain:bool,branding:array} + */ + 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'; + } +} diff --git a/backend/src/Service/ResolvedTenant.php b/backend/src/Service/ResolvedTenant.php new file mode 100644 index 0000000..cd641dd --- /dev/null +++ b/backend/src/Service/ResolvedTenant.php @@ -0,0 +1,46 @@ +kind; + } + + public function isReseller(): bool + { + return self::KIND_RESELLER === $this->kind; + } + + public function isCompany(): bool + { + return self::KIND_COMPANY === $this->kind; + } +} diff --git a/backend/src/Service/TenantResolver.php b/backend/src/Service/TenantResolver.php new file mode 100644 index 0000000..28cf476 --- /dev/null +++ b/backend/src/Service/TenantResolver.php @@ -0,0 +1,125 @@ + → Plattform (Admin/Portal) + * - . → Reseller (White-Label) + * - .. → 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, '.'); + } +} diff --git a/frontend/src/components/TenantBrand.vue b/frontend/src/components/TenantBrand.vue new file mode 100644 index 0000000..729b169 --- /dev/null +++ b/frontend/src/components/TenantBrand.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/frontend/src/layouts/DashboardLayout.vue b/frontend/src/layouts/DashboardLayout.vue index 685a2b7..1b5ccf7 100644 --- a/frontend/src/layouts/DashboardLayout.vue +++ b/frontend/src/layouts/DashboardLayout.vue @@ -2,7 +2,7 @@ import { computed } from 'vue' import { RouterLink, RouterView, useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' -import BrandLogo from '@/components/BrandLogo.vue' +import TenantBrand from '@/components/TenantBrand.vue' const auth = useAuthStore() const router = useRouter() @@ -70,7 +70,7 @@ async function stopImpersonation() {
- + {{ levelLabel }}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 260445c..d0c9c79 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -3,8 +3,11 @@ import { createPinia } from 'pinia' import './assets/brand.css' import App from './App.vue' import router from './router' +import { useBrandingStore } from './stores/branding' const app = createApp(App) app.use(createPinia()) 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')) diff --git a/frontend/src/stores/branding.ts b/frontend/src/stores/branding.ts new file mode 100644 index 0000000..0ecbb0b --- /dev/null +++ b/frontend/src/stores/branding.ts @@ -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('platform') + const name = ref('vcard4reseller') + const reseller = ref(null) + const customDomain = ref(false) + const colors = ref({ ...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('/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 } +}) diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 9a45ea5..0c96432 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -2,8 +2,10 @@ import { ref } from 'vue' import { useRouter, useRoute } from 'vue-router' 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 password = ref('') const error = ref('') @@ -30,10 +32,12 @@ async function submit() {