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:
parent
936e25e162
commit
b8f9a50731
@ -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:
|
||||||
|
|||||||
35
backend/migrations/Version20260609075521.php
Normal file
35
backend/migrations/Version20260609075521.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/src/Controller/BrandingController.php
Normal file
26
backend/src/Controller/BrandingController.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
backend/src/Service/BrandingService.php
Normal file
64
backend/src/Service/BrandingService.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
46
backend/src/Service/ResolvedTenant.php
Normal file
46
backend/src/Service/ResolvedTenant.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
125
backend/src/Service/TenantResolver.php
Normal file
125
backend/src/Service/TenantResolver.php
Normal 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, '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
20
frontend/src/components/TenantBrand.vue
Normal file
20
frontend/src/components/TenantBrand.vue
Normal 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>
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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'))
|
||||||
|
|||||||
68
frontend/src/stores/branding.ts
Normal file
68
frontend/src/stores/branding.ts
Normal 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 }
|
||||||
|
})
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user