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:
|
||||
- { 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:
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
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 { 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() {
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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'))
|
||||
|
||||
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 { 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() {
|
||||
<div class="login-bg">
|
||||
<div class="card login">
|
||||
<div class="login__brand">
|
||||
<BrandLogo size="1.5rem" />
|
||||
<TenantBrand size="1.5rem" />
|
||||
</div>
|
||||
<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">
|
||||
<div class="field">
|
||||
<label>E-Mail</label>
|
||||
|
||||
@ -15,9 +15,11 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// 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': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
changeOrigin: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user