diff --git a/backend/src/Controller/BrandingAdminController.php b/backend/src/Controller/BrandingAdminController.php new file mode 100644 index 0000000..834a343 --- /dev/null +++ b/backend/src/Controller/BrandingAdminController.php @@ -0,0 +1,194 @@ +serialize()); + } + + #[Route('/api/my-branding', name: 'my_branding_put', methods: ['PUT'])] + #[IsGranted('ROLE_COMPANY_ADMIN')] + public function put(Request $request): JsonResponse + { + [, $entity] = $this->target(); + $data = json_decode($request->getContent(), true) ?? []; + $cfg = $entity->getBrandingConfig(); + + foreach (['primaryColor', 'primaryColorDark', 'primaryColorSoft'] as $key) { + if (\array_key_exists($key, $data)) { + $hex = $this->hex($data[$key] ?? null); + if (null !== $hex) { + $cfg[$key] = $hex; + } else { + unset($cfg[$key]); + } + } + } + if (\array_key_exists('tagline', $data)) { + $tag = trim((string) ($data['tagline'] ?? '')); + if ('' !== $tag) { + $cfg['tagline'] = mb_substr($tag, 0, 120); + } else { + unset($cfg['tagline']); + } + } + + $entity->setBrandingConfig($cfg); + $this->em->flush(); + + return new JsonResponse($this->serialize()); + } + + #[Route('/api/my-branding/logo', name: 'my_branding_logo_post', methods: ['POST'])] + #[IsGranted('ROLE_COMPANY_ADMIN')] + public function uploadLogo(Request $request): JsonResponse + { + [$scope, $entity] = $this->target(); + $file = $request->files->get('file'); + if (!$file instanceof UploadedFile) { + throw new BadRequestHttpException('Keine Datei (Feld "file").'); + } + $ext = strtolower((string) $file->getClientOriginalExtension()); + if (!\in_array($ext, ['png', 'jpg', 'jpeg', 'svg'], true)) { + throw new BadRequestHttpException('Nur PNG, JPG oder SVG erlaubt.'); + } + + $cfg = $entity->getBrandingConfig(); + $old = \is_string($cfg['logoKey'] ?? null) ? $cfg['logoKey'] : null; + $key = sprintf('branding/%s-%s/logo-%s.%s', $scope, $entity->getId()->toRfc4122(), bin2hex(random_bytes(4)), $ext); + $this->cardAssets->write($key, (string) file_get_contents($file->getPathname())); + if (null !== $old && $old !== $key && $this->cardAssets->fileExists($old)) { + $this->cardAssets->delete($old); + } + $cfg['logoKey'] = $key; + $entity->setBrandingConfig($cfg); + $this->em->flush(); + + return new JsonResponse($this->serialize(), 201); + } + + #[Route('/api/my-branding/logo', name: 'my_branding_logo_delete', methods: ['DELETE'])] + #[IsGranted('ROLE_COMPANY_ADMIN')] + public function deleteLogo(): JsonResponse + { + [, $entity] = $this->target(); + $cfg = $entity->getBrandingConfig(); + $key = \is_string($cfg['logoKey'] ?? null) ? $cfg['logoKey'] : null; + if (null !== $key && $this->cardAssets->fileExists($key)) { + $this->cardAssets->delete($key); + } + unset($cfg['logoKey']); + $entity->setBrandingConfig($cfg); + $this->em->flush(); + + return new JsonResponse($this->serialize()); + } + + /** Öffentliche Logo-Auslieferung (vom Branding-Endpoint verlinkt). */ + #[Route('/api/branding/logo/{scope}/{id}.png', name: 'branding_logo', methods: ['GET'])] + public function logo(string $scope, string $id): Response + { + $entity = $this->load($scope, $id); + $key = \is_string($entity->getBrandingConfig()['logoKey'] ?? null) ? $entity->getBrandingConfig()['logoKey'] : null; + if (null === $key || !$this->cardAssets->fileExists($key)) { + throw new NotFoundHttpException('Kein Logo.'); + } + $bytes = $this->cardAssets->read($key); + $mime = str_ends_with($key, '.svg') ? 'image/svg+xml' : (str_ends_with($key, '.png') ? 'image/png' : 'image/jpeg'); + + return new Response($bytes, 200, ['Content-Type' => $mime, 'Cache-Control' => 'public, max-age=86400']); + } + + /** @return array{0:string,1:Company|Reseller} */ + private function target(): array + { + $company = $this->tenant->getCompany(); + if ($company instanceof Company) { + return [BrandingService::SCOPE_COMPANY, $company]; + } + $reseller = $this->tenant->getReseller(); + if ($reseller instanceof Reseller) { + return [BrandingService::SCOPE_RESELLER, $reseller]; + } + throw new AccessDeniedHttpException('Kein Branding-Kontext.'); + } + + private function load(string $scope, string $id): Company|Reseller + { + $uuid = Uuid::fromString($id); + $entity = BrandingService::SCOPE_COMPANY === $scope + ? $this->em->getRepository(Company::class)->find($uuid) + : $this->em->getRepository(Reseller::class)->find($uuid); + if (!$entity instanceof Company && !$entity instanceof Reseller) { + throw new NotFoundHttpException('Nicht gefunden.'); + } + + return $entity; + } + + /** @return array */ + private function serialize(): array + { + [$scope, $entity] = $this->target(); + $cfg = $entity->getBrandingConfig(); + $tenant = $entity instanceof Company + ? new \App\Service\ResolvedTenant(\App\Service\ResolvedTenant::KIND_COMPANY, $entity->getReseller(), $entity) + : new \App\Service\ResolvedTenant(\App\Service\ResolvedTenant::KIND_RESELLER, $entity); + $host = $entity instanceof Company ? $this->resolver->companyHost($entity) : $this->resolver->resellerHost($entity); + + return [ + 'scope' => $scope, + 'name' => $entity->getName(), + 'address' => $host, + 'primaryColor' => $cfg['primaryColor'] ?? '#f58220', + 'primaryColorDark' => $cfg['primaryColorDark'] ?? '#d96500', + 'primaryColorSoft' => $cfg['primaryColorSoft'] ?? '#fff2e7', + 'tagline' => $cfg['tagline'] ?? '', + 'hasLogo' => '' !== (string) ($cfg['logoKey'] ?? ''), + 'logoUrl' => $this->branding->logoUrl($tenant), + ]; + } + + private function hex(mixed $value): ?string + { + return (\is_string($value) && preg_match('/^#[0-9a-fA-F]{6}$/', $value)) ? strtolower($value) : null; + } +} diff --git a/backend/src/Service/BrandingService.php b/backend/src/Service/BrandingService.php index b9fd354..4782002 100644 --- a/backend/src/Service/BrandingService.php +++ b/backend/src/Service/BrandingService.php @@ -2,6 +2,10 @@ namespace App\Service; +use App\Entity\Company; +use App\Entity\Reseller; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; + /** * 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 @@ -9,37 +13,35 @@ namespace App\Service; */ final class BrandingService { + public const SCOPE_RESELLER = 'reseller'; + public const SCOPE_COMPANY = 'company'; + /** Plattform-Standard (vcard4reseller-Orange). */ - private const DEFAULTS = [ + private const COLOR_DEFAULTS = [ 'primaryColor' => '#f58220', 'primaryColorDark' => '#d96500', 'primaryColorSoft' => '#fff2e7', - 'logoUrl' => null, 'tagline' => null, ]; + public function __construct(private readonly UrlGeneratorInterface $urls) + { + } + /** * @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 => $_) { + $branding = self::COLOR_DEFAULTS; + foreach ($this->configChain($tenant) as $config) { + foreach (self::COLOR_DEFAULTS as $key => $_) { if (isset($config[$key]) && '' !== $config[$key]) { $branding[$key] = $config[$key]; } } } + $branding['logoUrl'] = $this->logoUrl($tenant); return [ 'level' => $tenant->kind, @@ -50,12 +52,45 @@ final class BrandingService ]; } + /** Öffentliche Logo-URL eines Tenants (am spezifischsten zuerst), oder null. */ + public function logoUrl(ResolvedTenant $tenant): ?string + { + if (null !== $tenant->company && '' !== (string) ($tenant->company->getBrandingConfig()['logoKey'] ?? '')) { + return $this->logoRoute(self::SCOPE_COMPANY, (string) $tenant->company->getId(), $tenant->company->getBrandingConfig()['logoKey']); + } + if (null !== $tenant->reseller && '' !== (string) ($tenant->reseller->getBrandingConfig()['logoKey'] ?? '')) { + return $this->logoRoute(self::SCOPE_RESELLER, (string) $tenant->reseller->getId(), $tenant->reseller->getBrandingConfig()['logoKey']); + } + + return null; + } + + /** @return array> */ + private function configChain(ResolvedTenant $tenant): array + { + $chain = []; + if (null !== $tenant->reseller) { + $chain[] = $tenant->reseller->getBrandingConfig(); + } + if (null !== $tenant->company) { + $chain[] = $tenant->company->getBrandingConfig(); + } + + return $chain; + } + + private function logoRoute(string $scope, string $id, mixed $logoKey): string + { + return $this->urls->generate('branding_logo', ['scope' => $scope, 'id' => $id]) + .'?v='.substr(sha1((string) $logoKey), 0, 8); + } + private function name(ResolvedTenant $tenant): string { - if ($tenant->isCompany() && null !== $tenant->company) { + if ($tenant->isCompany() && $tenant->company instanceof Company) { return $tenant->company->getName(); } - if ($tenant->isReseller() && null !== $tenant->reseller) { + if ($tenant->isReseller() && $tenant->reseller instanceof Reseller) { return $tenant->reseller->getName(); } diff --git a/frontend/src/layouts/DashboardLayout.vue b/frontend/src/layouts/DashboardLayout.vue index 1b5ccf7..74cea9d 100644 --- a/frontend/src/layouts/DashboardLayout.vue +++ b/frontend/src/layouts/DashboardLayout.vue @@ -20,6 +20,7 @@ const topNav = computed(() => { { label: 'Einloggen als', to: '/app/companies', icon: 'M3 7h18v13H3zM8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', show: auth.isResellerAdmin }, { label: 'Produkte', to: '/app/products', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12', show: auth.isResellerAdmin }, { label: 'Bestellungen', to: '/app/orders', icon: 'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4zM3 6h18M16 10a4 4 0 0 1-8 0', show: auth.isResellerAdmin }, + { label: 'Branding', to: '/app/branding', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: auth.isResellerAdmin }, { label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: above }, ].filter((i) => i.show) }) @@ -35,6 +36,7 @@ const leftNav = computed(() => { { label: 'Standorte', to: '/app/locations', icon: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', show: inCompany }, { label: 'Domains', to: '/app/domains', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z', show: inCompany }, { label: 'Design', to: '/app/design', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: inCompany }, + { label: 'Branding', to: '/app/branding', icon: 'M2 12a10 10 0 1 0 20 0 10 10 0 0 0-20 0zM12 2a15 15 0 0 1 0 20M2 12h20', show: inCompany }, { label: 'Wallet', to: '/app/wallet', icon: 'M3 7h18v12H3zM3 10h18M16 14h2', show: inCompany }, { label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: inCompany }, ].filter((i) => i.show) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index ada84db..913c790 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -25,6 +25,7 @@ const router = createRouter({ { path: 'locations', name: 'locations', component: () => import('@/views/LocationsView.vue') }, { path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.vue') }, { path: 'design', name: 'design', component: () => import('@/views/DesignView.vue') }, + { path: 'branding', name: 'branding', component: () => import('@/views/BrandingView.vue') }, { path: 'wallet', name: 'wallet', component: () => import('@/views/WalletDesignView.vue') }, { path: 'settings', name: 'settings', component: () => import('@/views/PlaceholderView.vue'), props: { title: 'Einstellungen' } }, ], diff --git a/frontend/src/views/BrandingView.vue b/frontend/src/views/BrandingView.vue new file mode 100644 index 0000000..1e511b2 --- /dev/null +++ b/frontend/src/views/BrandingView.vue @@ -0,0 +1,184 @@ + + + + +