Compare commits

...

20 Commits

Author SHA1 Message Date
d6370724a0 Mitarbeiter-Formular: Standort nach „Allgemein", Login/Rechte als eigenes Modal
- Standort (und Firma-Zuordnung beim Anlegen) in den Tab „Allgemein" verschoben
- „Zugang & NFC" zeigt nur noch NFC-Link + Button „Login & Rechte verwalten",
  der ein separates Modal öffnet → klar erkennbar, dass Login extra aktiviert
  wird (Passwort = Login an)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:23:14 +02:00
456decb195 Import: Privatadresse, Excel-Support, Beispiel-Vorlagen
- Backend: Privatadress-Felder (privateStreet … privateCountry) → addressPrivate,
  Adress-Merge je Block generalisiert
- Frontend: Excel-Parsing (SheetJS/xlsx 0.20.3 vom CDN, gepatcht), private
  Zielfelder + Synonyme, Beispiel-CSV und Beispiel-Excel als Download
- Datei-Upload akzeptiert CSV & XLSX/XLS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:16:15 +02:00
b0396bcab8 Mitarbeiter: CSV-Import-Assistent mit Mapping, Vorschau & Upsert
- EmployeeImportController POST /api/employees/import: feldzugeordnete Zeilen,
  optionales Unique-Feld (email/emailPrivate/phone/mobile) für Aktualisierung
  bestehender Datensätze; Adressfelder werden gemerged; eindeutiger Slug
- EmployeeImport.vue: CSV einlesen (Datei/Einfügen, Trennzeichen-Erkennung),
  Auto-Mapping per Header, manuelles Mapping, Vorschau erster Datensatz,
  Ergebnis (angelegt/aktualisiert/übersprungen + Hinweise)
- „Importieren"-Button in der Mitarbeiterliste

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:58:14 +02:00
e60871fcd3 Wallet: neue Mitarbeiterfelder als Bindings; Modal breiter + Overflow-Fix
- WalletService BINDINGS + bindingValue: Titel, Privat-E-Mail, Fax, Zentrale,
  Website, Adresse (einzeilig); fullName mit Titel-Präfix
- Wallet-Design-Dropdown-Labels (deutsch) ergänzt
- Modal --wide auf 820px; grid2/grid-addr min-width:0, schmalere Vorwahl
  → kein horizontaler Overflow im Mitarbeiter-Formular mehr

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:38:56 +02:00
fa321fb6a5 Profil + vCard mit neuen Mitarbeiterfeldern; Modal breiter
- VCardBuilder: Titel (N-Präfix), Abteilung (ORG), Privat-E-Mail, Mobil,
  Zentrale, Fax, Website, Geschäfts-/Privatadresse, Foto (PHOTO URI)
- Profilseite: Foto über echte URL, Kontakt-Sektion (alle Nummern/Mails/Web),
  Adresse geschäftlich+privat, Branding via BrandingService (Reseller-Vererbung)
- Mitarbeiter-Formular nutzt das breite Modal (kein Overflow mehr)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:33:46 +02:00
862385dbe0 Mitarbeiter: erweitertes Datenmodell + Tab-Formular
- Neue Felder: Anrede, akad. Titel, Privat-E-Mail, Fax, Zentrale, Website,
  Geschäfts-/Privatadresse (JSON), Über mich
- Foto-Upload (S3) + öffentliche Auslieferung /p/photo/{id}.jpg, Avatar in Liste
- Social-/Kontakt-Links: GET/PUT /api/employees/{id}/contact-links (Replace)
- Formular in Tabs: Allgemein / Kontakt / Adresse / Social / Zugang & NFC
- Telefonfelder mit Länder-Vorwahl + Emoji-Flagge (PhoneInput), Adress-Land
  per Flaggen-Auswahl (CountrySelect), countries.ts (Vorwahlen)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:56:22 +02:00
46b5c4e7ad UI: Branding & Domains wieder volle Breite (Padding bleibt)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:30:44 +02:00
440952560e UI: Domains-Seite Breite & Padding (Layout-Fix)
Karten auf max. 940px begrenzt, Standard-Adresse-Karte mit Innenabstand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:26:00 +02:00
eb46395f79 UI: Branding-Karten Innenabstand (Padding)
Die globale .card hat kein Padding; Branding-Karten füllten daher randlos.
Padding 1.4rem/1.5rem ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:19:41 +02:00
990e1dbe86 UI: Branding-Seite Breite begrenzen (Layout-Fix)
Grid max-width 940px + schmalere Farb-/Slogan-Felder, damit die Karte auf
breiten Monitoren nicht über die ganze Fläche zieht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:18:12 +02:00
8daef8e98f White-Label Phase 5: DNS-Automatik für Firmen-Subdomains
- DnsProvisioner (dependency-frei, cURL) legt pro Reseller *.<slug>.<portal>
  A-Record via Hetzner-Cloud-DNS-API an (deckt firma.reseller.portal ab,
  was der globale *.<portal>-Eintrag nicht kann)
- ResellerDnsListener (Doctrine postPersist/preRemove), fail-soft,
  überspringt Plattform-Reseller
- Env HCLOUD_DNS_TOKEN/HCLOUD_DNS_ZONE_NAME (leer = aus); Terraform reicht
  Cloud-Token + Zone an die App-Nodes durch (nur bei manage_dns)
- Ziel-IP = APP_EDGE_IP oder DNS-Auflösung der Portal-Domain

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:14:41 +02:00
c542b2f9be White-Label Phase 4: Custom-Domains (CRUD + DNS-Verify)
- DomainController /api/my-domains: Liste (Standard-Host + edgeTarget),
  Anlegen (pending), DNS-Verify (CNAME/A → Plattform-Edge), Löschen
- Domain-API auf read-only beschränkt → verified-Domains nur via
  verifiziertem Controller (kein TLS-Gate-Bypass)
- DomainsView neu: Standard-Adresse, eigene Domains, DNS-Anleitung,
  Prüfen-Button; Reseller-Topnav „Domains"
- APP_EDGE_IP optional (sonst Portal-DNS-Auflösung)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:24:57 +02:00
a233c34599 White-Label Phase 3: Branding-Verwaltung
- BrandingAdminController: GET/PUT /api/my-branding (Farben, Tagline),
  Logo-Upload/-Löschen, öffentliche Logo-Auslieferung /api/branding/logo/...
- BrandingService liefert logoUrl aus S3-logoKey (Firma → Reseller → Default)
- BrandingView (Reseller-Topnav + Firmen-Sidebar): Farbwähler, Slogan,
  Logo-Upload mit Live-Vorschau, Anzeige der eigenen Adresse

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:15:27 +02:00
bcd8ba969a White-Label Phase 2: Login-Scoping je Host
- TenantAccessPolicy: Plattform-Host nur Plattform-/Reseller-Admins,
  Reseller-Host nur dessen Nutzer, Firmen-Host nur Firmen-Nutzer
  (+ zugehöriger Reseller-Admin); Plattform-Admin überall.
- LoginSuccessHandler prüft vor JWT-Ausstellung → 403 bei falschem Host.
- Login zeigt die 403-Hinweismeldung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:40:20 +02:00
b8f9a50731 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>
2026-06-09 12:58:20 +02:00
936e25e162 Deploy: git safe.directory für Rollout setzen
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:37:00 +02:00
e792c4d4f5 Fix: Caddy als trusted proxy → generierte URLs nutzen https
Symfony vertraute Caddys X-Forwarded-Proto nicht, daher lauteten
QR- und Wallet-Barcode-URLs http:// statt https://. framework.trusted_proxies
auf %env(TRUSTED_PROXIES)% gesetzt (Prod: 10.0.0.0/16, Dev: 127.0.0.1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:37:00 +02:00
183b7c10b0 UI: Portal-Dashboard nur Reseller-KPI
Plattform-Admin sah auf dem Dashboard auch Firmen/Mitarbeiter — jetzt nur noch
Reseller (konsistent zur Portal-Navi). Firmen-KPI nur Reseller, Mitarbeiter-KPI
nur Reseller/Firma.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:22:23 +02:00
366d57da9b UI: Portal-Navi auf Reseller reduziert
Plattform-Admin braucht Firmen/Mitarbeiter/Produkte/Bestellungen nicht direkt
(Zugriff via „Einloggen als" in einen Reseller). Portal-Topnav = Dashboard,
Reseller, Einstellungen. Reseller behält Einloggen-als/Produkte/Bestellungen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:15:54 +02:00
cc33040b4b Deploy: HTTP/3 (QUIC) in Caddy deaktiviert
Manche Security-Suites (AVG Webschutz) zerlegen QUIC → ERR_QUIC_PROTOCOL_ERROR
bei betroffenen Kunden. Caddy global auf `servers { protocols h1 h2 }` (nur
HTTP/1.1 + HTTP/2 über TCP), kein alt-svc h3 mehr. Caddy-Server bekommt
ignore_changes=[user_data] (Caddyfile-Änderung per reload, kein Recreate).
Live-Caddy bereits nachgezogen (Caddyfile in-place + caddy reload).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:00:18 +02:00
48 changed files with 3130 additions and 208 deletions

View File

@ -49,6 +49,15 @@ JWT_PASSPHRASE=d75959918d9ccc5c89c62edbd6e6c6af82d6e2a3d303c53a6f3328e94a05b60a
###> App ###
# Portal-Domain (für On-Demand-TLS-Autorisierung). In Prod auf die echte Domain setzen.
APP_PORTAL_DOMAIN=localhost
# Reverse-Proxy, dem X-Forwarded-* vertraut wird. Dev: kein echter Proxy → leer.
# Prod (.env.prod.local): das private Netz von Caddy, z. B. 10.0.0.0/16.
TRUSTED_PROXIES=127.0.0.1
# Öffentliche IP der Caddy-Edge für Custom-Domain-Verifizierung. Optional:
# leer → es wird die DNS-Auflösung von APP_PORTAL_DOMAIN genutzt.
APP_EDGE_IP=
# Hetzner-Cloud-DNS-Automatik für Firmen-Subdomains (*.reseller.portal). Leer = aus.
HCLOUD_DNS_TOKEN=
HCLOUD_DNS_ZONE_NAME=
###< App ###
###> S3 / Object Storage (Druck-Assets) ###

View File

@ -2,6 +2,11 @@
framework:
secret: '%env(APP_SECRET)%'
# Hinter Caddy (Reverse-Proxy): dessen X-Forwarded-Proto/Host vertrauen, damit
# generierte Absolut-URLs (QR, Wallet-Barcode) https + richtige Domain nutzen.
trusted_proxies: '%env(TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port']
# Note that the session will be started ONLY if you read or write from it.
session: true

View File

@ -21,7 +21,7 @@ security:
check_path: /api/login
username_path: email
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
success_handler: App\Security\LoginSuccessHandler
failure_handler: lexik_jwt_authentication.handler.authentication_failure
# Geschützte API: JWT im Authorization-Header
@ -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:

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,31 @@
<?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 Version20260609164206 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE employee ADD salutation VARCHAR(20) DEFAULT NULL, ADD email_private VARCHAR(180) DEFAULT NULL, ADD fax VARCHAR(50) DEFAULT NULL, ADD phone_central VARCHAR(50) DEFAULT NULL, ADD website VARCHAR(255) DEFAULT NULL, ADD address_business JSON DEFAULT NULL, ADD address_private JSON DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE employee DROP salutation, DROP email_private, DROP fax, DROP phone_central, DROP website, DROP address_business, DROP address_private');
}
}

View File

@ -0,0 +1,194 @@
<?php
namespace App\Controller;
use App\Entity\Company;
use App\Entity\Reseller;
use App\Security\TenantContext;
use App\Service\BrandingService;
use App\Service\TenantResolver;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* White-Label-Branding pflegen: Reseller-Admin brandet seinen Reseller,
* Firmen-Admin seine Firma (Farben, Tagline, Logo). Plus öffentliche Logo-Auslieferung.
*/
final class BrandingAdminController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
private readonly TenantResolver $resolver,
private readonly BrandingService $branding,
#[Autowire(service: 'card_assets.storage')]
private readonly FilesystemOperator $cardAssets,
) {
}
#[Route('/api/my-branding', name: 'my_branding_get', methods: ['GET'])]
#[IsGranted('ROLE_COMPANY_ADMIN')]
public function get(): JsonResponse
{
return new JsonResponse($this->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<string, mixed> */
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;
}
}

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

@ -0,0 +1,196 @@
<?php
namespace App\Controller;
use App\Entity\Company;
use App\Entity\Domain;
use App\Entity\Reseller;
use App\Security\TenantContext;
use App\Service\TenantResolver;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Eigene Domains eines Tenants (KONZEPT §11): jeder Reseller/jede Firma kann
* neben der Standard-Subdomain eigene Domains hinterlegen. Aktivierung per
* CNAME/A auf die Plattform-Edge + DNS-Verifizierung; danach stellt Caddy
* On-Demand automatisch ein TLS-Zertifikat aus.
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class DomainController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
private readonly TenantResolver $resolver,
#[Autowire('%env(APP_PORTAL_DOMAIN)%')]
private readonly string $portalDomain,
#[Autowire('%env(APP_EDGE_IP)%')]
private readonly string $edgeIp,
) {
}
#[Route('/api/my-domains', name: 'my_domains_list', methods: ['GET'])]
public function list(): JsonResponse
{
[$scope, $entity] = $this->target();
$defaultHost = $entity instanceof Company
? $this->resolver->companyHost($entity)
: $this->resolver->resellerHost($entity);
return new JsonResponse([
'scope' => $scope,
'defaultHost' => $defaultHost,
'edgeTarget' => $this->portalDomain,
'edgeIp' => $this->edgeIp(),
'domains' => array_map([$this, 'serialize'], $this->ownDomains($entity)),
]);
}
#[Route('/api/my-domains', name: 'my_domains_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
[, $entity] = $this->target();
$data = json_decode($request->getContent(), true) ?? [];
$hostname = $this->normalizeHostname((string) ($data['hostname'] ?? ''));
if (null === $hostname) {
throw new BadRequestHttpException('Ungültiger Hostname.');
}
if (null !== $this->em->getRepository(Domain::class)->findOneBy(['hostname' => $hostname])) {
throw new BadRequestHttpException('Diese Domain ist bereits hinterlegt.');
}
$domain = (new Domain())
->setHostname($hostname)
->setType(Domain::TYPE_CUSTOM)
->setStatus(Domain::STATUS_PENDING);
if ($entity instanceof Company) {
$domain->setCompany($entity);
} else {
$domain->setReseller($entity);
}
$this->em->persist($domain);
$this->em->flush();
return new JsonResponse($this->serialize($domain), 201);
}
#[Route('/api/my-domains/{id}/verify', name: 'my_domains_verify', methods: ['POST'])]
public function verify(string $id): JsonResponse
{
$domain = $this->ownDomain($id);
$verified = $this->dnsPointsToUs($domain->getHostname());
$domain->setStatus($verified ? Domain::STATUS_VERIFIED : Domain::STATUS_FAILED);
$domain->setVerificationCheckedAt(new \DateTimeImmutable());
$this->em->flush();
return new JsonResponse([
'domain' => $this->serialize($domain),
'ok' => $verified,
'message' => $verified
? 'Domain verifiziert. Das TLS-Zertifikat wird beim ersten Aufruf automatisch ausgestellt.'
: 'DNS zeigt noch nicht auf die Plattform. Bitte CNAME/A-Eintrag prüfen (Propagierung kann dauern).',
]);
}
#[Route('/api/my-domains/{id}', name: 'my_domains_delete', methods: ['DELETE'])]
public function delete(string $id): JsonResponse
{
$domain = $this->ownDomain($id);
$this->em->remove($domain);
$this->em->flush();
return new JsonResponse(null, 204);
}
/** @return array{0:string,1:Company|Reseller} */
private function target(): array
{
$company = $this->tenant->getCompany();
if ($company instanceof Company) {
return ['company', $company];
}
$reseller = $this->tenant->getReseller();
if ($reseller instanceof Reseller) {
return ['reseller', $reseller];
}
throw new AccessDeniedHttpException('Kein Domain-Kontext.');
}
/** @return Domain[] */
private function ownDomains(Company|Reseller $entity): array
{
$criteria = $entity instanceof Company ? ['company' => $entity] : ['reseller' => $entity];
return $this->em->getRepository(Domain::class)->findBy($criteria, ['hostname' => 'ASC']);
}
private function ownDomain(string $id): Domain
{
$domain = $this->em->getRepository(Domain::class)->find(Uuid::fromString($id));
if (!$domain instanceof Domain) {
throw new NotFoundHttpException('Domain nicht gefunden.');
}
[, $entity] = $this->target();
$owns = $entity instanceof Company
? $domain->getCompany()?->getId()->equals($entity->getId())
: $domain->getReseller()?->getId()->equals($entity->getId()) && null === $domain->getCompany();
if (true !== $owns) {
throw new AccessDeniedHttpException('Domain gehört nicht zum eigenen Kontext.');
}
return $domain;
}
/** Prüft, ob der Hostname (per A oder via CNAME) auf die Plattform-Edge zeigt. */
private function dnsPointsToUs(string $hostname): bool
{
$hostIps = gethostbynamel($hostname);
if (false === $hostIps || [] === $hostIps) {
return false;
}
$ourIps = null !== $this->edgeIp() ? [$this->edgeIp()] : (gethostbynamel($this->portalDomain) ?: []);
return [] !== array_intersect($hostIps, $ourIps);
}
private function edgeIp(): ?string
{
$ip = trim((string) $this->edgeIp);
return '' !== $ip ? $ip : null;
}
private function normalizeHostname(string $host): ?string
{
$host = strtolower(trim($host));
$host = preg_replace('#^https?://#', '', $host) ?? $host;
$host = rtrim($host, '/.');
if (1 !== preg_match('/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/', $host)) {
return null;
}
return $host;
}
/** @return array<string, mixed> */
private function serialize(Domain $domain): array
{
return [
'id' => (string) $domain->getId(),
'hostname' => $domain->getHostname(),
'type' => $domain->getType(),
'status' => $domain->getStatus(),
'tlsStatus' => $domain->getTlsStatus(),
'checkedAt' => $domain->getVerificationCheckedAt()?->format(\DateTimeInterface::ATOM),
];
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Controller;
use App\Entity\ContactLink;
use App\Entity\Employee;
use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Ersetzt die Social-/Kontakt-Links eines Mitarbeiters in einem Rutsch
* (das Mitarbeiter-Formular bearbeitet immer das ganze Set).
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class EmployeeContactLinksController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
) {
}
#[Route('/api/employees/{id}/contact-links', name: 'employee_links_get', methods: ['GET'])]
public function listLinks(string $id): JsonResponse
{
$employee = $this->scoped($id);
$out = [];
foreach ($employee->getContactLinks() as $link) {
$out[] = ['type' => $link->getType(), 'url' => $link->getUrl(), 'label' => $link->getLabel()];
}
return new JsonResponse(['links' => $out]);
}
#[Route('/api/employees/{id}/contact-links', name: 'employee_links_put', methods: ['PUT'])]
public function replace(string $id, Request $request): JsonResponse
{
$employee = $this->scoped($id);
foreach ($employee->getContactLinks()->toArray() as $link) {
$employee->removeContactLink($link);
}
$data = json_decode($request->getContent(), true) ?? [];
$links = \is_array($data['links'] ?? null) ? $data['links'] : [];
$pos = 0;
$out = [];
foreach ($links as $l) {
$url = trim((string) ($l['url'] ?? ''));
if ('' === $url) {
continue;
}
$type = substr(trim((string) ($l['type'] ?? 'custom')), 0, 40) ?: 'custom';
$label = trim((string) ($l['label'] ?? ''));
$link = (new ContactLink())
->setType($type)
->setUrl(substr($url, 0, 500))
->setLabel('' !== $label ? substr($label, 0, 120) : null)
->setPosition($pos++);
$employee->addContactLink($link);
$out[] = ['type' => $type, 'url' => $url, 'label' => '' !== $label ? $label : null];
}
$this->em->flush();
return new JsonResponse(['links' => $out]);
}
private function scoped(string $id): Employee
{
$employee = $this->em->getRepository(Employee::class)->find(Uuid::fromString($id));
if (!$employee instanceof Employee) {
throw new NotFoundHttpException('Mitarbeiter nicht gefunden.');
}
if ($this->tenant->isPlatformAdmin()) {
return $employee;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || true !== $employee->getReseller()?->getId()->equals($reseller->getId())) {
throw new AccessDeniedHttpException('Mitarbeiter gehört nicht zum eigenen Mandanten.');
}
$own = $this->tenant->getCompany();
if (null !== $own && !$employee->getCompany()->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Nur die eigene Firma.');
}
return $employee;
}
}

View File

@ -0,0 +1,188 @@
<?php
namespace App\Controller;
use App\Entity\Company;
use App\Entity\Employee;
use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Stapel-Import von Mitarbeiter-/Adressdaten für eine Firma (CSV Mapping im
* Frontend hier bereits feldzugeordnete Zeilen). Optional Aktualisierung
* bestehender Datensätze über ein wählbares Unique-Feld (Upsert).
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class EmployeeImportController
{
/** Skalare Profilfelder, die importiert werden dürfen. */
private const SCALAR = [
'salutation', 'title', 'firstName', 'lastName', 'position', 'department',
'email', 'emailPrivate', 'phone', 'mobile', 'fax', 'phoneCentral', 'website', 'bio',
];
/** Felder der Geschäftsadresse. */
private const ADDRESS = ['street', 'houseNumber', 'addressLine2', 'zip', 'city', 'state', 'country'];
/** Felder der Privatadresse (Import-Keys → Adress-Teilfeld). */
private const ADDRESS_PRIVATE = [
'privateStreet' => 'street', 'privateHouseNumber' => 'houseNumber', 'privateAddressLine2' => 'addressLine2',
'privateZip' => 'zip', 'privateCity' => 'city', 'privateState' => 'state', 'privateCountry' => 'country',
];
/** Felder, die als Unique-Schlüssel zum Abgleich erlaubt sind. */
private const UNIQUE_ALLOWED = ['email', 'emailPrivate', 'phone', 'mobile', 'slug'];
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
) {
}
#[Route('/api/employees/import', name: 'employees_import', methods: ['POST'])]
public function import(Request $request): JsonResponse
{
$company = $this->tenant->getCompany();
if (!$company instanceof Company) {
throw new BadRequestHttpException('Import bitte im Firmenkontext ausführen.');
}
$data = json_decode($request->getContent(), true) ?? [];
$rows = \is_array($data['rows'] ?? null) ? $data['rows'] : [];
$uniqueField = \in_array($data['uniqueField'] ?? null, self::UNIQUE_ALLOWED, true) ? $data['uniqueField'] : null;
$created = 0;
$updated = 0;
$skipped = 0;
$errors = [];
foreach ($rows as $i => $row) {
if (!\is_array($row)) {
continue;
}
$values = $this->extract($row);
$employee = null;
if (null !== $uniqueField && '' !== (string) ($values[$uniqueField] ?? '')) {
$employee = $this->em->getRepository(Employee::class)
->findOneBy(['company' => $company, $uniqueField => $values[$uniqueField]]);
}
if (null === $employee) {
if ('' === (string) ($values['firstName'] ?? '') || '' === (string) ($values['lastName'] ?? '')) {
++$skipped;
$errors[] = ['row' => $i + 1, 'message' => 'Vor- und Nachname nötig zum Anlegen.'];
continue;
}
$employee = (new Employee())
->setCompany($company)
->setStatus('active')
->setSlug($this->uniqueSlug($company, $values['firstName'], $values['lastName']));
$this->apply($employee, $values, true);
$this->em->persist($employee);
++$created;
} else {
$this->apply($employee, $values, false);
$employee->touch();
++$updated;
}
}
$this->em->flush();
return new JsonResponse([
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'errors' => \array_slice($errors, 0, 50),
]);
}
/**
* @param array<string, mixed> $row
*
* @return array<string, string>
*/
private function extract(array $row): array
{
$out = [];
foreach ([...self::SCALAR, ...self::ADDRESS, ...array_keys(self::ADDRESS_PRIVATE)] as $key) {
if (\array_key_exists($key, $row)) {
$out[$key] = trim((string) $row[$key]);
}
}
return $out;
}
/** @param array<string, string> $v */
private function apply(Employee $e, array $v, bool $isNew): void
{
$setters = [
'salutation' => 'setSalutation', 'title' => 'setTitle', 'firstName' => 'setFirstName',
'lastName' => 'setLastName', 'position' => 'setPosition', 'department' => 'setDepartment',
'email' => 'setEmail', 'emailPrivate' => 'setEmailPrivate', 'phone' => 'setPhone',
'mobile' => 'setMobile', 'fax' => 'setFax', 'phoneCentral' => 'setPhoneCentral',
'website' => 'setWebsite', 'bio' => 'setBio',
];
foreach ($setters as $key => $setter) {
if (\array_key_exists($key, $v) && '' !== $v[$key]) {
$e->{$setter}($v[$key]);
}
}
// Geschäftsadresse: vorhandene Werte beibehalten, neue überschreiben
$bizMap = array_combine(self::ADDRESS, self::ADDRESS);
if (null !== ($biz = $this->mergeAddress($isNew ? [] : ($e->getAddressBusiness() ?? []), $v, $bizMap))) {
$e->setAddressBusiness($biz);
}
// Privatadresse (Import-Keys mit private-Präfix)
if (null !== ($priv = $this->mergeAddress($isNew ? [] : ($e->getAddressPrivate() ?? []), $v, self::ADDRESS_PRIVATE))) {
$e->setAddressPrivate($priv);
}
}
/**
* @param array<string, mixed> $existing
* @param array<string, string> $v
* @param array<string, string> $map Import-Key Adress-Teilfeld
*
* @return array<string, string>|null null = nichts zu setzen
*/
private function mergeAddress(array $existing, array $v, array $map): ?array
{
$touched = false;
foreach ($map as $importKey => $addrKey) {
if (\array_key_exists($importKey, $v) && '' !== $v[$importKey]) {
$existing[$addrKey] = $v[$importKey];
$touched = true;
}
}
return $touched ? $existing : null;
}
private function uniqueSlug(Company $company, string $first, string $last): string
{
$base = $this->slugify($first.'-'.$last) ?: 'mitarbeiter';
$repo = $this->em->getRepository(Employee::class);
$slug = $base;
$n = 1;
while (null !== $repo->findOneBy(['company' => $company, 'slug' => $slug])) {
$slug = $base.'-'.(++$n);
}
return $slug;
}
private function slugify(string $s): string
{
$s = strtolower(trim($s));
$s = str_replace(['ä', 'ö', 'ü', 'ß'], ['ae', 'oe', 'ue', 'ss'], $s);
$s = preg_replace('/[^a-z0-9]+/', '-', $s) ?? '';
return trim($s, '-');
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace App\Controller;
use App\Entity\Employee;
use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Mitarbeiter-Foto: Upload/Löschen (geschützt, mandantengeprüft) und
* öffentliche Auslieferung für die Profilseite/Visitenkarte.
*/
final class EmployeePhotoController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
#[Autowire(service: 'card_assets.storage')]
private readonly FilesystemOperator $cardAssets,
) {
}
#[Route('/api/employees/{id}/photo', name: 'employee_photo_post', methods: ['POST'])]
#[IsGranted('ROLE_COMPANY_ADMIN')]
public function upload(string $id, Request $request): JsonResponse
{
$employee = $this->scoped($id);
$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', 'webp'], true)) {
throw new BadRequestHttpException('Nur PNG, JPG oder WebP erlaubt.');
}
$old = $employee->getPhotoPath();
$key = sprintf('employee-photos/%s-%s.%s', $employee->getId()->toRfc4122(), bin2hex(random_bytes(4)), 'jpeg' === $ext ? 'jpg' : $ext);
$this->cardAssets->write($key, (string) file_get_contents($file->getPathname()));
if (null !== $old && $old !== $key && $this->cardAssets->fileExists($old)) {
$this->cardAssets->delete($old);
}
$employee->setPhotoPath($key);
$this->em->flush();
return new JsonResponse(['photoPath' => $key, 'photoUrl' => $this->photoUrl($employee)], 201);
}
#[Route('/api/employees/{id}/photo', name: 'employee_photo_delete', methods: ['DELETE'])]
#[IsGranted('ROLE_COMPANY_ADMIN')]
public function delete(string $id): JsonResponse
{
$employee = $this->scoped($id);
$key = $employee->getPhotoPath();
if (null !== $key && $this->cardAssets->fileExists($key)) {
$this->cardAssets->delete($key);
}
$employee->setPhotoPath(null);
$this->em->flush();
return new JsonResponse(['photoPath' => null, 'photoUrl' => null]);
}
/** Öffentliche Auslieferung (Profilseite/Visitenkarte). */
#[Route('/p/photo/{id}.jpg', name: 'employee_photo_public', methods: ['GET'])]
public function show(string $id): Response
{
$employee = $this->em->getRepository(Employee::class)->find(Uuid::fromString($id));
$key = $employee?->getPhotoPath();
if (null === $key || !$this->cardAssets->fileExists($key)) {
throw new NotFoundHttpException('Kein Foto.');
}
$mime = str_ends_with($key, '.png') ? 'image/png' : (str_ends_with($key, '.webp') ? 'image/webp' : 'image/jpeg');
return new Response($this->cardAssets->read($key), 200, [
'Content-Type' => $mime,
'Cache-Control' => 'public, max-age=86400',
]);
}
private function photoUrl(Employee $employee): ?string
{
return null !== $employee->getPhotoPath()
? '/p/photo/'.$employee->getId().'.jpg?v='.substr(sha1($employee->getPhotoPath()), 0, 8)
: null;
}
private function scoped(string $id): Employee
{
$employee = $this->em->getRepository(Employee::class)->find(Uuid::fromString($id));
if (!$employee instanceof Employee) {
throw new NotFoundHttpException('Mitarbeiter nicht gefunden.');
}
if ($this->tenant->isPlatformAdmin()) {
return $employee;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || true !== $employee->getReseller()?->getId()->equals($reseller->getId())) {
throw new AccessDeniedHttpException('Mitarbeiter gehört nicht zum eigenen Mandanten.');
}
$own = $this->tenant->getCompany();
if (null !== $own && !$employee->getCompany()->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Nur die eigene Firma.');
}
return $employee;
}
}

View File

@ -4,6 +4,8 @@ namespace App\Controller;
use App\Entity\Employee;
use App\Repository\EmployeeRepository;
use App\Service\BrandingService;
use App\Service\ResolvedTenant;
use App\Service\VCardBuilder;
use App\Service\WalletService;
use Endroid\QrCode\Builder\Builder;
@ -26,12 +28,15 @@ final class PublicProfileController extends AbstractController
}
#[Route('/p/{companySlug}/{slug}', name: 'public_profile', methods: ['GET'])]
public function show(string $companySlug, string $slug, WalletService $wallet): Response
public function show(string $companySlug, string $slug, WalletService $wallet, BrandingService $branding): Response
{
$employee = $this->resolve($companySlug, $slug);
$company = $employee->getCompany();
$tenant = new ResolvedTenant(ResolvedTenant::KIND_COMPANY, $company->getReseller(), $company);
return $this->render('public/profile.html.twig', [
'e' => $employee,
'branding' => $branding->forTenant($tenant)['branding'],
'profileUrl' => $this->profileUrl($employee),
'shareUrl' => $this->shareUrl($employee),
'walletEnabled' => null !== $employee->getShortCode()

View File

@ -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);
}

View File

@ -30,8 +30,10 @@ final class WalletDesignController
{
private const BINDING_LABELS = [
'fullName' => 'Name', 'firstName' => 'Vorname', 'lastName' => 'Nachname',
'position' => 'Position', 'department' => 'Abteilung', 'email' => 'E-Mail',
'phone' => 'Telefon', 'mobile' => 'Mobil', 'company' => 'Firma', 'profileUrl' => 'Profil-Link',
'title' => 'Titel', 'position' => 'Position', 'department' => 'Abteilung',
'email' => 'E-Mail', 'emailPrivate' => 'E-Mail (privat)',
'phone' => 'Telefon', 'mobile' => 'Mobil', 'fax' => 'Fax', 'phoneCentral' => 'Zentrale',
'website' => 'Website', 'company' => 'Firma', 'address' => 'Adresse', 'profileUrl' => 'Profil-Link',
];
public function __construct(

View File

@ -3,16 +3,25 @@
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Repository\DomainRepository;
use Doctrine\ORM\Mapping as ORM;
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).
*/
// Schreiben läuft ausschließlich über DomainController (DNS-Verifizierung),
// damit keine 'verified'-Domain das On-Demand-TLS-Gate umgehen kann.
#[ORM\Entity(repositoryClass: DomainRepository::class)]
#[ApiResource(security: "is_granted('ROLE_COMPANY_ADMIN')")]
#[ApiResource(
operations: [new GetCollection(), new Get()],
security: "is_granted('ROLE_RESELLER_ADMIN')",
)]
class Domain implements ResellerOwnedInterface
{
public const TYPE_SUBDOMAIN = 'subdomain';
@ -41,9 +50,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 +130,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 +144,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';
}
}

View File

@ -79,6 +79,31 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent
#[ORM\Column(type: 'text', nullable: true)]
private ?string $bio = null;
/** Anrede: Herr / Frau / Divers / null. */
#[ORM\Column(length: 20, nullable: true)]
private ?string $salutation = null;
#[ORM\Column(length: 180, nullable: true)]
private ?string $emailPrivate = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $fax = null;
/** Zentrale / Durchwahl. */
#[ORM\Column(length: 50, nullable: true)]
private ?string $phoneCentral = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $website = null;
/** Geschäftsadresse: {street,houseNumber,addressLine2,zip,city,state,country}. */
#[ORM\Column(type: 'json', nullable: true)]
private ?array $addressBusiness = null;
/** Privatadresse (gleiche Struktur). */
#[ORM\Column(type: 'json', nullable: true)]
private ?array $addressPrivate = null;
#[ORM\Column(length: 20)]
private string $status = 'active';
@ -278,6 +303,94 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent
return $this;
}
public function getSalutation(): ?string
{
return $this->salutation;
}
public function setSalutation(?string $salutation): self
{
$this->salutation = $salutation;
return $this;
}
public function getEmailPrivate(): ?string
{
return $this->emailPrivate;
}
public function setEmailPrivate(?string $emailPrivate): self
{
$this->emailPrivate = $emailPrivate;
return $this;
}
public function getFax(): ?string
{
return $this->fax;
}
public function setFax(?string $fax): self
{
$this->fax = $fax;
return $this;
}
public function getPhoneCentral(): ?string
{
return $this->phoneCentral;
}
public function setPhoneCentral(?string $phoneCentral): self
{
$this->phoneCentral = $phoneCentral;
return $this;
}
public function getWebsite(): ?string
{
return $this->website;
}
public function setWebsite(?string $website): self
{
$this->website = $website;
return $this;
}
/** @return array<string, mixed>|null */
public function getAddressBusiness(): ?array
{
return $this->addressBusiness;
}
/** @param array<string, mixed>|null $addressBusiness */
public function setAddressBusiness(?array $addressBusiness): self
{
$this->addressBusiness = $addressBusiness;
return $this;
}
/** @return array<string, mixed>|null */
public function getAddressPrivate(): ?array
{
return $this->addressPrivate;
}
/** @param array<string, mixed>|null $addressPrivate */
public function setAddressPrivate(?array $addressPrivate): self
{
$this->addressPrivate = $addressPrivate;
return $this;
}
public function getStatus(): string
{
return $this->status;

View File

@ -0,0 +1,37 @@
<?php
namespace App\EventListener;
use App\Entity\Reseller;
use App\Service\DnsProvisioner;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Events;
/**
* Hält den Firmen-Subdomain-Wildcard (*.<slug>.<portal>) pro Reseller im DNS
* aktuell. Fail-soft: DNS-Fehler dürfen die Reseller-Anlage nicht verhindern.
*/
#[AsEntityListener(event: Events::postPersist, method: 'onPostPersist', entity: Reseller::class)]
#[AsEntityListener(event: Events::preRemove, method: 'onPreRemove', entity: Reseller::class)]
final class ResellerDnsListener
{
public function __construct(private readonly DnsProvisioner $dns)
{
}
public function onPostPersist(Reseller $reseller): void
{
if ($reseller->isPlatform()) {
return; // Plattform läuft direkt auf der Portal-Domain
}
$this->dns->ensureResellerWildcard($reseller->getSlug());
}
public function onPreRemove(Reseller $reseller): void
{
if ($reseller->isPlatform()) {
return;
}
$this->dns->removeResellerWildcard($reseller->getSlug());
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Security;
use App\Entity\Employee;
use App\Service\ResolvedTenant;
use App\Service\TenantResolver;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
/**
* Prüft beim Login, ob der Nutzer auf dem aufgerufenen Host (White-Label-Tenant)
* überhaupt anmeldeberechtigt ist sonst 403. Danach erst stellt der Lexik-
* Handler das JWT aus. Wird in security.yaml als success_handler eingehängt.
*/
final class LoginSuccessHandler implements AuthenticationSuccessHandlerInterface
{
public function __construct(
#[Autowire(service: 'lexik_jwt_authentication.handler.authentication_success')]
private readonly AuthenticationSuccessHandlerInterface $inner,
private readonly TenantResolver $resolver,
private readonly TenantAccessPolicy $policy,
) {
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
{
$user = $token->getUser();
if ($user instanceof Employee) {
$tenant = $this->resolver->resolve($request->getHost()) ?? ResolvedTenant::platform();
if (!$this->policy->canLogin($user, $tenant)) {
return new JsonResponse(
['message' => 'Für diese Adresse besteht kein Zugang. Bitte die richtige Login-Adresse verwenden.'],
Response::HTTP_FORBIDDEN,
);
}
}
return $this->inner->onAuthenticationSuccess($request, $token);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Security;
use App\Entity\Employee;
use App\Service\ResolvedTenant;
/**
* Entscheidet, ob ein Mitarbeiter sich auf dem Host eines bestimmten Tenants
* anmelden darf (White-Label-Scoping, KONZEPT §11):
* - Plattform-Host: nur Plattform- und Reseller-Admins (Firmen nutzen ihre Subdomain).
* - Reseller-Host: alle Nutzer dieses Resellers (inkl. seiner Firmen).
* - Firmen-Host: nur Nutzer dieser Firma (plus deren Reseller-Admin).
* Plattform-Admins dürfen überall (Support/Verwaltung).
*/
final class TenantAccessPolicy
{
public function canLogin(Employee $user, ResolvedTenant $tenant): bool
{
$roles = $user->getRoles();
$isPlatform = \in_array(Employee::ROLE_PLATFORM_ADMIN, $roles, true);
$isReseller = \in_array(Employee::ROLE_RESELLER_ADMIN, $roles, true);
if ($isPlatform) {
return true;
}
$userReseller = $user->getReseller();
$userCompany = $user->getCompany();
return match ($tenant->kind) {
ResolvedTenant::KIND_PLATFORM => $isReseller,
ResolvedTenant::KIND_RESELLER => null !== $userReseller
&& null !== $tenant->reseller
&& $userReseller->getId()->equals($tenant->reseller->getId()),
ResolvedTenant::KIND_COMPANY => $this->canLoginCompany($userCompany, $userReseller, $isReseller, $tenant),
default => false,
};
}
private function canLoginCompany(
?\App\Entity\Company $userCompany,
?\App\Entity\Reseller $userReseller,
bool $isReseller,
ResolvedTenant $tenant,
): bool {
if (null !== $userCompany && null !== $tenant->company
&& $userCompany->getId()->equals($tenant->company->getId())) {
return true;
}
// Reseller-Admin des zugehörigen Resellers darf den Firmen-Host ebenfalls nutzen
return $isReseller && null !== $userReseller && null !== $tenant->reseller
&& $userReseller->getId()->equals($tenant->reseller->getId());
}
}

View File

@ -0,0 +1,99 @@
<?php
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
* eigenes Branding automatisch das ihres Resellers.
*/
final class BrandingService
{
public const SCOPE_RESELLER = 'reseller';
public const SCOPE_COMPANY = 'company';
/** Plattform-Standard (vcard4reseller-Orange). */
private const COLOR_DEFAULTS = [
'primaryColor' => '#f58220',
'primaryColorDark' => '#d96500',
'primaryColorSoft' => '#fff2e7',
'tagline' => null,
];
public function __construct(private readonly UrlGeneratorInterface $urls)
{
}
/**
* @return array{level:string,name:string,reseller:?string,customDomain:bool,branding:array<string,mixed>}
*/
public function forTenant(ResolvedTenant $tenant): array
{
$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,
'name' => $this->name($tenant),
'reseller' => $tenant->reseller?->getName(),
'customDomain' => $tenant->customDomain,
'branding' => $branding,
];
}
/** Ö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<int, array<string,mixed>> */
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() && $tenant->company instanceof Company) {
return $tenant->company->getName();
}
if ($tenant->isReseller() && $tenant->reseller instanceof Reseller) {
return $tenant->reseller->getName();
}
return 'vcard4reseller';
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Legt pro Reseller einen Wildcard-DNS-Eintrag *.<slug>.<portal> an, damit die
* Firmen-Subdomains (firma.reseller.portal, zwei Ebenen tief) auflösen der
* globale *.<portal>-Eintrag deckt nur eine Ebene ab (KONZEPT §11).
*
* Nutzt die in die Hetzner-Cloud-API integrierte DNS-API (gleicher Cloud-Token
* wie Terraform, siehe dns.tf). Ohne Konfiguration ein No-Op (Dev/Tests).
* Fehler brechen die Reseller-Anlage nie ab (fail-soft).
*/
final class DnsProvisioner
{
private const API = 'https://api.hetzner.cloud/v1';
private ?string $zoneId = null;
public function __construct(
#[Autowire('%env(HCLOUD_DNS_TOKEN)%')]
private readonly string $token,
#[Autowire('%env(HCLOUD_DNS_ZONE_NAME)%')]
private readonly string $zoneName,
#[Autowire('%env(APP_PORTAL_DOMAIN)%')]
private readonly string $portalDomain,
#[Autowire('%env(APP_EDGE_IP)%')]
private readonly string $edgeIp,
private readonly LoggerInterface $logger,
) {
}
public function isConfigured(): bool
{
return '' !== trim($this->token) && '' !== trim($this->zoneName) && null !== $this->targetIp();
}
public function ensureResellerWildcard(string $slug): bool
{
if (!$this->isConfigured()) {
return false;
}
$zone = $this->zoneId();
if (null === $zone) {
return false;
}
[$status, $body] = $this->request('POST', "/zones/$zone/rrsets", [
'name' => $this->rrsetName($slug),
'type' => 'A',
'ttl' => 300,
'records' => [['value' => $this->targetIp()]],
]);
if ($status >= 200 && $status < 300) {
return true;
}
if (409 === $status || 422 === $status) {
return true; // existiert bereits
}
$this->logger->warning('DNS-Wildcard anlegen fehlgeschlagen', ['slug' => $slug, 'status' => $status, 'body' => $body]);
return false;
}
public function removeResellerWildcard(string $slug): bool
{
if (!$this->isConfigured()) {
return false;
}
$zone = $this->zoneId();
if (null === $zone) {
return false;
}
[$status] = $this->request('DELETE', sprintf('/zones/%s/rrsets/%s/A', $zone, rawurlencode($this->rrsetName($slug))));
return $status >= 200 && $status < 300;
}
/** rrset-Name relativ zur Zone, z. B. "*.demo" (Portal == Zone) bzw. "*.demo.sub". */
private function rrsetName(string $slug): string
{
$full = '*.'.$slug.'.'.$this->portalDomain;
$zone = trim($this->zoneName, '.');
if ($full === $zone) {
return '@';
}
if (str_ends_with($full, '.'.$zone)) {
return substr($full, 0, -\strlen('.'.$zone));
}
return $full;
}
private function targetIp(): ?string
{
$ip = trim($this->edgeIp);
if ('' !== $ip) {
return $ip;
}
$resolved = gethostbynamel($this->portalDomain) ?: [];
return $resolved[0] ?? null;
}
private function zoneId(): ?string
{
if (null !== $this->zoneId) {
return $this->zoneId;
}
[$status, $body] = $this->request('GET', '/zones?name='.rawurlencode(trim($this->zoneName, '.')));
if ($status >= 200 && $status < 300) {
$id = $body['zones'][0]['id'] ?? null;
if (null !== $id) {
return $this->zoneId = (string) $id;
}
}
$this->logger->warning('DNS-Zone nicht gefunden', ['zone' => $this->zoneName, 'status' => $status]);
return null;
}
/**
* @param array<string, mixed>|null $payload
*
* @return array{0:int,1:array<string,mixed>}
*/
private function request(string $method, string $path, ?array $payload = null): array
{
$ch = curl_init(self::API.$path);
$headers = ['Authorization: Bearer '.$this->token, 'Content-Type: application/json'];
curl_setopt_array($ch, [
\CURLOPT_CUSTOMREQUEST => $method,
\CURLOPT_RETURNTRANSFER => true,
\CURLOPT_HTTPHEADER => $headers,
\CURLOPT_TIMEOUT => 8,
\CURLOPT_CONNECTTIMEOUT => 4,
]);
if (null !== $payload) {
curl_setopt($ch, \CURLOPT_POSTFIELDS, json_encode($payload));
}
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, \CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if (false === $raw) {
$this->logger->warning('DNS-API nicht erreichbar', ['path' => $path, 'error' => $err]);
return [0, []];
}
$decoded = json_decode((string) $raw, true);
return [$status, \is_array($decoded) ? $decoded : []];
}
}

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

@ -3,6 +3,7 @@
namespace App\Service;
use App\Entity\Employee;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Erzeugt eine vCard (RFC 6350, Version 3.0 breite Kompatibilität mit
@ -10,46 +11,73 @@ use App\Entity\Employee;
*/
final class VCardBuilder
{
public function __construct(private readonly UrlGeneratorInterface $urls)
{
}
public function build(Employee $e): string
{
$company = $e->getCompany();
$location = $e->getLocation();
$lines = [];
$lines[] = 'BEGIN:VCARD';
$lines[] = 'VERSION:3.0';
$lines[] = 'N:'.$this->esc($e->getLastName()).';'.$this->esc($e->getFirstName()).';;;';
$lines[] = 'FN:'.$this->esc(trim($e->getFirstName().' '.$e->getLastName()));
// N: Nachname;Vorname;;Präfix(Titel);
$lines[] = 'N:'.$this->esc($e->getLastName()).';'.$this->esc($e->getFirstName()).';;'
.$this->esc((string) $e->getTitle()).';';
$lines[] = 'FN:'.$this->esc(trim(($e->getTitle() ? $e->getTitle().' ' : '').$e->getFirstName().' '.$e->getLastName()));
if ($company) {
$lines[] = 'ORG:'.$this->esc($company->getName());
// ORG:Firma;Abteilung
$org = $this->esc($company->getName());
if ($e->getDepartment()) {
$org .= ';'.$this->esc($e->getDepartment());
}
$lines[] = 'ORG:'.$org;
}
if ($e->getPosition()) {
$lines[] = 'TITLE:'.$this->esc($e->getPosition());
}
if ($e->getEmail()) {
$lines[] = 'EMAIL;TYPE=WORK:'.$this->esc($e->getEmail());
}
if ($e->getEmailPrivate()) {
$lines[] = 'EMAIL;TYPE=HOME:'.$this->esc($e->getEmailPrivate());
}
if ($e->getPhone()) {
$lines[] = 'TEL;TYPE=WORK,VOICE:'.$this->esc($e->getPhone());
}
if ($e->getMobile()) {
$lines[] = 'TEL;TYPE=CELL:'.$this->esc($e->getMobile());
}
if ($location && ($location->getStreet() || $location->getCity())) {
// ADR: ;;Straße;Ort;;PLZ;Land
$lines[] = 'ADR;TYPE=WORK:;;'
.$this->esc((string) $location->getStreet()).';'
.$this->esc((string) $location->getCity()).';;'
.$this->esc((string) $location->getPostalCode()).';'
.$this->esc((string) $location->getCountry());
if ($e->getPhoneCentral()) {
$lines[] = 'TEL;TYPE=WORK:'.$this->esc($e->getPhoneCentral());
}
if ($e->getFax()) {
$lines[] = 'TEL;TYPE=WORK,FAX:'.$this->esc($e->getFax());
}
if ($e->getWebsite()) {
$lines[] = 'URL:'.$this->esc($e->getWebsite());
}
$this->addAddress($lines, 'WORK', $e->getAddressBusiness() ?? $this->fromLocation($e));
$this->addAddress($lines, 'HOME', $e->getAddressPrivate());
foreach ($e->getContactLinks() as $link) {
$lines[] = 'URL:'.$this->esc($link->getUrl());
}
if (null !== $e->getPhotoPath()) {
$lines[] = 'PHOTO;VALUE=URI:'.$this->urls->generate(
'employee_photo_public',
['id' => (string) $e->getId()],
UrlGeneratorInterface::ABSOLUTE_URL,
);
}
if ($e->getBio()) {
$lines[] = 'NOTE:'.$this->esc($e->getBio());
}
@ -61,6 +89,50 @@ final class VCardBuilder
return implode("\r\n", $lines)."\r\n";
}
/**
* @param string[] $lines
* @param array<string, mixed>|null $a
*/
private function addAddress(array &$lines, string $type, ?array $a): void
{
if (null === $a) {
return;
}
$street = trim((string) ($a['street'] ?? '').' '.(string) ($a['houseNumber'] ?? ''));
$city = (string) ($a['city'] ?? '');
if ('' === $street && '' === $city) {
return;
}
// ADR: PO;Ext;Straße;Ort;Region;PLZ;Land
$lines[] = 'ADR;TYPE='.$type.':;'
.$this->esc((string) ($a['addressLine2'] ?? '')).';'
.$this->esc($street).';'
.$this->esc($city).';'
.$this->esc((string) ($a['state'] ?? '')).';'
.$this->esc((string) ($a['zip'] ?? '')).';'
.$this->esc((string) ($a['country'] ?? ''));
}
/**
* Fallback: Geschäftsadresse aus dem zugeordneten Standort.
*
* @return array<string, mixed>|null
*/
private function fromLocation(Employee $e): ?array
{
$l = $e->getLocation();
if (null === $l || (!$l->getStreet() && !$l->getCity())) {
return null;
}
return [
'street' => (string) $l->getStreet(),
'city' => (string) $l->getCity(),
'zip' => (string) $l->getPostalCode(),
'country' => (string) $l->getCountry(),
];
}
/** Escaping gemäß vCard-Spezifikation. */
private function esc(string $value): string
{

View File

@ -18,8 +18,9 @@ final class WalletService
{
/** Verfügbare Datenfelder (Mitarbeiterprofil). */
public const BINDINGS = [
'fullName', 'firstName', 'lastName', 'position', 'department',
'email', 'phone', 'mobile', 'company', 'profileUrl',
'fullName', 'firstName', 'lastName', 'title', 'position', 'department',
'email', 'emailPrivate', 'phone', 'mobile', 'fax', 'phoneCentral',
'website', 'company', 'address', 'profileUrl',
];
/** Apple-Slots; Google bildet sie auf Header/Textmodule ab. */
public const SLOTS = ['primary', 'secondary', 'auxiliary', 'back'];
@ -97,20 +98,38 @@ final class WalletService
public function bindingValue(Employee $e, string $binding): string
{
return match ($binding) {
'fullName' => trim($e->getFirstName().' '.$e->getLastName()),
'fullName' => trim(($e->getTitle() ? $e->getTitle().' ' : '').$e->getFirstName().' '.$e->getLastName()),
'firstName' => $e->getFirstName(),
'lastName' => $e->getLastName(),
'title' => (string) ($e->getTitle() ?? ''),
'position' => (string) ($e->getPosition() ?? ''),
'department' => (string) ($e->getDepartment() ?? ''),
'email' => (string) ($e->getEmail() ?? ''),
'emailPrivate' => (string) ($e->getEmailPrivate() ?? ''),
'phone' => (string) ($e->getPhone() ?? ''),
'mobile' => (string) ($e->getMobile() ?? ''),
'fax' => (string) ($e->getFax() ?? ''),
'phoneCentral' => (string) ($e->getPhoneCentral() ?? ''),
'website' => (string) ($e->getWebsite() ?? ''),
'company' => $e->getCompany()->getName(),
'address' => $this->formatAddress($e->getAddressBusiness()),
'profileUrl' => $this->shareUrl($e),
default => '',
};
}
/** Einzeilige Geschäftsadresse: "Straße Nr., PLZ Ort". */
private function formatAddress(?array $a): string
{
if (null === $a) {
return '';
}
$street = trim((string) ($a['street'] ?? '').' '.(string) ($a['houseNumber'] ?? ''));
$city = trim((string) ($a['zip'] ?? '').' '.(string) ($a['city'] ?? ''));
return trim(implode(', ', array_filter([$street, $city])), ', ');
}
/** Rohes Logo-PNG/JPG aus dem Object-Storage (für die öffentliche Logo-URL). */
public function logoBytes(Company $company): ?string
{

View File

@ -1,13 +1,29 @@
{% extends 'base.html.twig' %}
{% set fullName = (e.firstName ~ ' ' ~ e.lastName)|trim %}
{% set fullName = ((e.title ? e.title ~ ' ' : '') ~ e.firstName ~ ' ' ~ e.lastName)|trim %}
{% set reseller = e.company.reseller %}
{# Firmenspezifisches Branding defensiv validiert #}
{% set b = e.company.brandingConfig %}
{% set primary = (b.primaryColor is defined and b.primaryColor matches '/^#[0-9a-fA-F]{6}$/') ? b.primaryColor : null %}
{% set primaryDark = (b.primaryDark is defined and b.primaryDark matches '/^#[0-9a-fA-F]{6}$/') ? b.primaryDark : primary %}
{% set logo = (b.logoUrl is defined and (b.logoUrl starts with 'https://' or b.logoUrl starts with '/')) ? b.logoUrl : null %}
{# White-Label-Branding (Firma → Reseller → Standard), aus dem BrandingService #}
{% set primary = branding.primaryColor %}
{% set primaryDark = branding.primaryColorDark %}
{% set logo = branding.logoUrl %}
{% macro addressBlock(a, label) %}
{% set s = a.street|default('') %}
{% set l2 = a.addressLine2|default('') %}
{% set zip = a.zip|default('') %}
{% set city = a.city|default('') %}
{% set st = a.state|default('') %}
{% set co = a.country|default('') %}
<div class="addr">
{% if label %}<span class="at">{{ label }}</span>{% endif %}
{% if s %}{{ s }} {{ a.houseNumber|default('') }}<br>{% endif %}
{% if l2 %}{{ l2 }}<br>{% endif %}
{% if zip or city %}{{ zip }} {{ city }}<br>{% endif %}
{% if st %}{{ st }}<br>{% endif %}
{% if co %}{{ co }}{% endif %}
</div>
{% endmacro %}
{% block title %}{{ fullName }} {{ e.company.name }}{% endblock %}
@ -80,6 +96,19 @@
.link .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--psc-orange); }
.bio { color: var(--text); font-size: .95rem; }
.contact { display: grid; gap: .45rem; }
.crow {
display: flex; align-items: baseline; justify-content: space-between; gap: 1rem;
padding: .6rem .9rem; border: 1px solid #eee; border-radius: var(--radius-sm);
color: var(--text);
}
.crow:hover { border-color: var(--psc-border); background: var(--psc-orange-soft-2); text-decoration: none; }
.crow .ck { color: var(--muted); font-size: .82rem; font-weight: 600; white-space: nowrap; }
.crow .cv { font-weight: 600; font-size: .92rem; text-align: right; word-break: break-word; }
.addr { font-size: .95rem; line-height: 1.55; color: var(--text); }
.addr + .addr { margin-top: .9rem; }
.addr .at { display: block; font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 700; margin-bottom: .25rem; }
.qr { text-align: center; }
.qr img { width: 190px; height: 190px; border-radius: var(--radius-sm); border: 1px solid #eee; }
.qr p { color: var(--muted); font-size: .82rem; margin: .6rem 0 0; }
@ -97,7 +126,7 @@
</div>
<div class="vc__head">
{% if e.photoPath %}
<img class="vc__avatar" src="{{ e.photoPath }}" alt="{{ fullName }}">
<img class="vc__avatar" src="{{ path('employee_photo_public', {id: e.id}) }}" alt="{{ fullName }}">
{% else %}
<div class="vc__avatar">{{ (e.firstName|first ~ e.lastName|first)|upper }}</div>
{% endif %}
@ -116,6 +145,7 @@
{% if e.phone %}<a class="btn btn-soft" href="tel:{{ e.phone }}">Anrufen</a>{% endif %}
{% if e.mobile %}<a class="btn btn-soft" href="tel:{{ e.mobile }}">Mobil</a>{% endif %}
{% if e.email %}<a class="btn btn-soft" href="mailto:{{ e.email }}">E-Mail</a>{% endif %}
{% if e.website %}<a class="btn btn-soft" href="{{ e.website }}" target="_blank" rel="noopener">Web</a>{% endif %}
</div>
</div>
@ -126,6 +156,36 @@
</div>
{% endif %}
{% set hasContact = e.phone or e.mobile or e.phoneCentral or e.fax or e.email or e.emailPrivate or e.website %}
{% if hasContact %}
<div class="vc__section">
<div class="vc__label">Kontakt</div>
<div class="contact">
{% if e.phone %}<a class="crow" href="tel:{{ e.phone }}"><span class="ck">Telefon</span><span class="cv">{{ e.phone }}</span></a>{% endif %}
{% if e.mobile %}<a class="crow" href="tel:{{ e.mobile }}"><span class="ck">Mobil</span><span class="cv">{{ e.mobile }}</span></a>{% endif %}
{% if e.phoneCentral %}<a class="crow" href="tel:{{ e.phoneCentral }}"><span class="ck">Zentrale</span><span class="cv">{{ e.phoneCentral }}</span></a>{% endif %}
{% if e.fax %}<div class="crow"><span class="ck">Fax</span><span class="cv">{{ e.fax }}</span></div>{% endif %}
{% if e.email %}<a class="crow" href="mailto:{{ e.email }}"><span class="ck">E-Mail</span><span class="cv">{{ e.email }}</span></a>{% endif %}
{% if e.emailPrivate %}<a class="crow" href="mailto:{{ e.emailPrivate }}"><span class="ck">E-Mail privat</span><span class="cv">{{ e.emailPrivate }}</span></a>{% endif %}
{% if e.website %}<a class="crow" href="{{ e.website }}" target="_blank" rel="noopener"><span class="ck">Website</span><span class="cv">{{ e.website }}</span></a>{% endif %}
</div>
</div>
{% endif %}
{% set ab = e.addressBusiness %}
{% set ap = e.addressPrivate %}
{% if ab or ap %}
<div class="vc__section">
<div class="vc__label">Adresse</div>
{% if ab %}
{{ _self.addressBlock(ab, ap ? 'Geschäftlich' : null) }}
{% endif %}
{% if ap %}
{{ _self.addressBlock(ap, 'Privat') }}
{% endif %}
</div>
{% endif %}
{% if e.contactLinks|length %}
<div class="vc__section">
<div class="vc__label">Links</div>

View File

@ -11,6 +11,8 @@ write_files:
DATABASE_URL="${database_url}"
CORS_ALLOW_ORIGIN=${cors_allow_origin}
TRUSTED_PROXIES=10.0.0.0/16
HCLOUD_DNS_TOKEN=${hcloud_dns_token}
HCLOUD_DNS_ZONE_NAME=${dns_zone_name}
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=${jwt_passphrase}

View File

@ -6,6 +6,11 @@ write_files:
content: |
{
email ${acme_email}
# HTTP/3 (QUIC) deaktiviert: manche Security-Suites (z. B. AVG Webschutz)
# zerlegen QUIC → ERR_QUIC_PROTOCOL_ERROR. Nur HTTP/1.1 + HTTP/2 über TCP.
servers {
protocols h1 h2
}
on_demand_tls {
# Caddy fragt die App, ob es für die Domain ein Zertifikat ausstellen darf
ask http://${ask_upstream}/internal/tls-allowed

View File

@ -21,6 +21,7 @@ resource "terraform_data" "app_deploy" {
inline = [
"set -e",
"cd /opt/vcard4",
"git config --global --add safe.directory /opt/vcard4",
"git fetch --depth 1 origin ${var.repo_branch}",
"git reset --hard origin/${var.repo_branch}",
"DOMAIN='${var.domain}' RUN_MIGRATIONS='${count.index == 0 ? "true" : "false"}' bash /opt/vcard4/deploy/update.sh",

View File

@ -111,6 +111,9 @@ resource "hcloud_server" "app" {
s3_bucket = var.s3_bucket
s3_key = var.s3_key
s3_secret = var.s3_secret
# DNS-Automatik für Firmen-Subdomains (nur wenn wir das DNS verwalten)
hcloud_dns_token = var.manage_dns ? var.hcloud_token : ""
dns_zone_name = var.manage_dns ? var.dns_zone_name : ""
})
network {
@ -176,5 +179,11 @@ resource "hcloud_server" "caddy" {
ip = local.caddy_private_ip
}
# Caddyfile-Änderungen werden auf dem laufenden Node aktualisiert (caddy reload),
# nicht durch Server-Neuerstellung (sonst neue IP DNS).
lifecycle {
ignore_changes = [user_data]
}
depends_on = [hcloud_network_subnet.subnet, hcloud_server.app]
}

View File

@ -12,7 +12,8 @@
"pdfjs-dist": "^6.0.227",
"pinia": "^3.0.4",
"vue": "^3.5.34",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
"@types/node": "^24.12.3",
@ -2032,6 +2033,18 @@
"peerDependencies": {
"typescript": ">=5.0.0"
}
},
"node_modules/xlsx": {
"version": "0.20.3",
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
"license": "Apache-2.0",
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
}
}
}

View File

@ -13,7 +13,8 @@
"pdfjs-dist": "^6.0.227",
"pinia": "^3.0.4",
"vue": "^3.5.34",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"devDependencies": {
"@types/node": "^24.12.3",

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
// Länderauswahl (Emoji-Flagge + Name) für Adressen. v-model = ISO-Code.
import { COUNTRIES, flagEmoji } from '@/data/countries'
withDefaults(defineProps<{ modelValue?: string | null }>(), { modelValue: '' })
defineEmits<{ 'update:modelValue': [string] }>()
</script>
<template>
<select
class="input"
:value="modelValue ?? ''"
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
>
<option value=""> Land wählen </option>
<option v-for="c in COUNTRIES" :key="c.code" :value="c.code">{{ flagEmoji(c.code) }} {{ c.name }}</option>
</select>
</template>

View File

@ -0,0 +1,368 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import * as XLSX from 'xlsx'
import client from '@/api/client'
import Modal from '@/components/Modal.vue'
const emit = defineEmits<{ close: []; done: [] }>()
interface Target { key: string; label: string }
const TARGETS: Target[] = [
{ key: 'salutation', label: 'Anrede' },
{ key: 'title', label: 'Titel' },
{ key: 'firstName', label: 'Vorname' },
{ key: 'lastName', label: 'Nachname' },
{ key: 'position', label: 'Position' },
{ key: 'department', label: 'Abteilung' },
{ key: 'email', label: 'E-Mail' },
{ key: 'emailPrivate', label: 'E-Mail privat' },
{ key: 'phone', label: 'Telefon' },
{ key: 'mobile', label: 'Mobil' },
{ key: 'fax', label: 'Fax' },
{ key: 'phoneCentral', label: 'Zentrale' },
{ key: 'website', label: 'Website' },
{ key: 'street', label: 'Straße' },
{ key: 'houseNumber', label: 'Nr.' },
{ key: 'addressLine2', label: 'Adresszusatz' },
{ key: 'zip', label: 'PLZ' },
{ key: 'city', label: 'Ort' },
{ key: 'state', label: 'Bundesland' },
{ key: 'country', label: 'Land' },
{ key: 'privateStreet', label: 'Privat: Straße' },
{ key: 'privateHouseNumber', label: 'Privat: Nr.' },
{ key: 'privateAddressLine2', label: 'Privat: Zusatz' },
{ key: 'privateZip', label: 'Privat: PLZ' },
{ key: 'privateCity', label: 'Privat: Ort' },
{ key: 'privateState', label: 'Privat: Bundesland' },
{ key: 'privateCountry', label: 'Privat: Land' },
{ key: 'bio', label: 'Über mich' },
]
const UNIQUE_KEYS = ['email', 'emailPrivate', 'phone', 'mobile']
const SYNONYMS: Record<string, string> = {
anrede: 'salutation', salutation: 'salutation',
titel: 'title', title: 'title',
vorname: 'firstName', firstname: 'firstName', first: 'firstName',
nachname: 'lastName', lastname: 'lastName', last: 'lastName', name: 'lastName', surname: 'lastName',
position: 'position', funktion: 'position', rolle: 'position', role: 'position',
abteilung: 'department', department: 'department', dept: 'department',
email: 'email', 'e-mail': 'email', mail: 'email', emailgeschaeftlich: 'email',
emailprivat: 'emailPrivate', privatemail: 'emailPrivate', emailprivate: 'emailPrivate',
telefon: 'phone', phone: 'phone', tel: 'phone', festnetz: 'phone',
mobil: 'mobile', mobile: 'mobile', handy: 'mobile', cell: 'mobile',
fax: 'fax',
zentrale: 'phoneCentral', durchwahl: 'phoneCentral',
website: 'website', web: 'website', url: 'website', homepage: 'website',
strasse: 'street', street: 'street', str: 'street',
nr: 'houseNumber', hausnummer: 'houseNumber', hausnr: 'houseNumber', houseno: 'houseNumber',
adresszusatz: 'addressLine2', zusatz: 'addressLine2', line2: 'addressLine2',
plz: 'zip', zip: 'zip', postleitzahl: 'zip', postal: 'zip',
ort: 'city', stadt: 'city', city: 'city',
bundesland: 'state', state: 'state', region: 'state',
land: 'country', country: 'country',
privatstrasse: 'privateStreet', privstrasse: 'privateStreet',
privatnr: 'privateHouseNumber', privathausnummer: 'privateHouseNumber',
privatzusatz: 'privateAddressLine2',
privatplz: 'privateZip',
privatort: 'privateCity', privatstadt: 'privateCity',
privatbundesland: 'privateState',
privatland: 'privateCountry',
bio: 'bio', uebermich: 'bio', about: 'bio',
}
const SAMPLE_HEADERS = [
'Anrede', 'Titel', 'Vorname', 'Nachname', 'Position', 'Abteilung',
'E-Mail', 'E-Mail privat', 'Telefon', 'Mobil', 'Fax', 'Zentrale', 'Website',
'Straße', 'Nr', 'Adresszusatz', 'PLZ', 'Ort', 'Bundesland', 'Land',
'Privat Straße', 'Privat Nr', 'Privat PLZ', 'Privat Ort', 'Privat Land', 'Über mich',
]
const SAMPLE_ROWS = [
['Frau', 'Dr.', 'Erika', 'Mustermann', 'Geschäftsführerin', 'Leitung',
'erika@muster.de', 'erika@privat.de', '+49 30 1234567', '+49 151 2345678', '+49 30 1234599', '+49 30 1234500', 'https://muster.de',
'Hauptstraße', '1', '', '10115', 'Berlin', 'Berlin', 'DE',
'Waldweg', '5', '14467', 'Potsdam', 'DE', 'Kümmert sich um alles.'],
['Herr', '', 'Max', 'Beispiel', 'Vertrieb', 'Sales',
'max@muster.de', '', '+49 30 7654321', '+49 160 1112223', '', '', '',
'Marktplatz', '12', '', '20095', 'Hamburg', 'Hamburg', 'DE',
'', '', '', '', '', ''],
]
const rawText = ref('')
const parsed = ref<{ headers: string[]; rows: string[][] } | null>(null)
const mapping = ref<Record<string, number>>({})
const uniqueField = ref('')
const importing = ref(false)
const result = ref<{ created: number; updated: number; skipped: number; errors: { row: number; message: string }[] } | null>(null)
const error = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
function parseCsv(text: string): { headers: string[]; rows: string[][] } | null {
const t = text.replace(/\r\n?/g, '\n').trim()
if (!t) return null
const firstLine = t.split('\n')[0]
const delim = firstLine.split(';').length > firstLine.split(',').length ? ';' : ','
const rows: string[][] = []
let field = ''
let row: string[] = []
let inQ = false
for (let i = 0; i < t.length; i++) {
const ch = t[i]
if (inQ) {
if (ch === '"') {
if (t[i + 1] === '"') { field += '"'; i++ } else inQ = false
} else field += ch
} else if (ch === '"') inQ = true
else if (ch === delim) { row.push(field); field = '' }
else if (ch === '\n') { row.push(field); rows.push(row); row = []; field = '' }
else field += ch
}
row.push(field)
rows.push(row)
const headers = (rows.shift() ?? []).map((h) => h.trim())
return { headers, rows: rows.filter((r) => r.some((c) => c.trim() !== '')) }
}
function norm(s: string) {
return s.toLowerCase().replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] ?? c)).replace(/[^a-z0-9]/g, '')
}
function applyParsed(headers: unknown[], rows: unknown[][]) {
error.value = ''
result.value = null
const cleanHeaders = headers.map((h) => String(h ?? '').trim())
const cleanRows = rows
.map((r) => cleanHeaders.map((_, i) => String(r[i] ?? '')))
.filter((r) => r.some((c) => c.trim() !== ''))
if (!cleanHeaders.length || !cleanRows.length) {
error.value = 'Keine Daten erkannt. Erwartet: Kopfzeile + mindestens eine Datenzeile.'
return
}
parsed.value = { headers: cleanHeaders, rows: cleanRows }
const map: Record<string, number> = {}
for (const t of TARGETS) map[t.key] = -1
cleanHeaders.forEach((h, idx) => {
const target = SYNONYMS[norm(h)]
if (target && map[target] === -1) map[target] = idx
})
mapping.value = map
uniqueField.value = map.email >= 0 ? 'email' : ''
}
function doParse() {
const p = parseCsv(rawText.value)
if (!p) { error.value = 'Keine Daten erkannt.'; return }
applyParsed(p.headers, p.rows)
}
async function parseXlsx(file: File) {
try {
const wb = XLSX.read(await file.arrayBuffer(), { type: 'array' })
const ws = wb.Sheets[wb.SheetNames[0]]
const aoa = XLSX.utils.sheet_to_json<unknown[]>(ws, { header: 1, raw: false, defval: '' })
applyParsed((aoa.shift() ?? []) as unknown[], aoa)
} catch {
error.value = 'Excel-Datei konnte nicht gelesen werden.'
}
}
function onFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const name = file.name.toLowerCase()
if (name.endsWith('.xlsx') || name.endsWith('.xls')) {
parseXlsx(file)
} else {
const reader = new FileReader()
reader.onload = () => { rawText.value = String(reader.result ?? ''); doParse() }
reader.readAsText(file)
}
}
function triggerDownload(url: string, name: string) {
const a = document.createElement('a')
a.href = url
a.download = name
a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
function downloadSampleCsv() {
const csv = [SAMPLE_HEADERS, ...SAMPLE_ROWS].map((r) => r.join(';')).join('\r\n')
triggerDownload(URL.createObjectURL(new Blob(['' + csv], { type: 'text/csv;charset=utf-8' })), 'mitarbeiter-vorlage.csv')
}
function downloadSampleXlsx() {
const ws = XLSX.utils.aoa_to_sheet([SAMPLE_HEADERS, ...SAMPLE_ROWS])
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Mitarbeiter')
XLSX.writeFile(wb, 'mitarbeiter-vorlage.xlsx')
}
const mappedTargets = computed(() => TARGETS.filter((t) => mapping.value[t.key] >= 0))
const uniqueOptions = computed(() => mappedTargets.value.filter((t) => UNIQUE_KEYS.includes(t.key)))
function rowToObject(r: string[]): Record<string, string> {
const o: Record<string, string> = {}
for (const t of TARGETS) {
const idx = mapping.value[t.key]
if (idx >= 0) o[t.key] = (r[idx] ?? '').trim()
}
return o
}
const preview = computed(() => {
if (!parsed.value?.rows.length) return []
const obj = rowToObject(parsed.value.rows[0])
return mappedTargets.value
.map((t) => ({ label: t.label, value: obj[t.key] ?? '' }))
.filter((x) => x.value !== '')
})
const canImport = computed(() => mapping.value.firstName >= 0 && mapping.value.lastName >= 0)
async function runImport() {
if (!parsed.value) return
importing.value = true
error.value = ''
try {
const rows = parsed.value.rows.map(rowToObject)
const { data } = await client.post('/employees/import', {
rows,
uniqueField: uniqueField.value || null,
})
result.value = data
emit('done')
} catch (e: unknown) {
const ex = e as { response?: { data?: { message?: string; detail?: string } } }
error.value = ex.response?.data?.message ?? ex.response?.data?.detail ?? 'Import fehlgeschlagen.'
} finally {
importing.value = false
}
}
function reset() {
parsed.value = null
result.value = null
rawText.value = ''
}
</script>
<template>
<Modal wide title="Mitarbeiter importieren" @close="emit('close')">
<!-- Schritt 1: Daten einlesen -->
<div v-if="!parsed">
<p class="muted" style="margin-top:0">
CSV- oder Excel-Datei hochladen oder CSV-Daten einfügen (Kopfzeile mit Spaltennamen).
</p>
<div class="sample">
<span class="muted small">Vorlage:</span>
<button class="linkbtn" @click="downloadSampleCsv">Beispiel-CSV</button>
<button class="linkbtn" @click="downloadSampleXlsx">Beispiel-Excel</button>
</div>
<div class="field">
<input ref="fileInput" type="file" accept=".csv,.xlsx,.xls,text/csv" hidden @change="onFile" />
<button class="btn btn-soft btn-sm" @click="fileInput?.click()">Datei wählen (CSV / Excel)</button>
</div>
<div class="field">
<label>oder CSV einfügen</label>
<textarea class="input mono" rows="7" v-model="rawText"
placeholder="Vorname;Nachname;E-Mail;Telefon;Straße;PLZ;Ort&#10;Erika;Mustermann;erika@muster.de;+49 30 1;Hauptstr. 1;10115;Berlin"></textarea>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button class="btn btn-ghost" @click="emit('close')">Abbrechen</button>
<button class="btn btn-primary" :disabled="!rawText.trim()" @click="doParse">Weiter</button>
</div>
</div>
<!-- Schritt 2: Mapping + Vorschau -->
<div v-else-if="!result">
<p class="muted" style="margin-top:0">{{ parsed.rows.length }} Datensätze erkannt. Ordnen Sie die Spalten zu.</p>
<div class="map-grid">
<div v-for="t in TARGETS" :key="t.key" class="map-row">
<span class="map-label">{{ t.label }}</span>
<select class="input" v-model.number="mapping[t.key]">
<option :value="-1"> nicht importieren </option>
<option v-for="(h, i) in parsed.headers" :key="i" :value="i">{{ h || ('Spalte ' + (i + 1)) }}</option>
</select>
</div>
</div>
<div class="opts">
<div class="field">
<label>Abgleich-Feld (Aktualisierung)</label>
<select class="input" v-model="uniqueField">
<option value=""> immer neu anlegen </option>
<option v-for="t in uniqueOptions" :key="t.key" :value="t.key">{{ t.label }}</option>
</select>
<p class="muted small">Vorhandene Mitarbeiter mit gleichem Wert werden aktualisiert statt neu angelegt.</p>
</div>
</div>
<div class="preview">
<div class="nfc__label">Vorschau (erster Datensatz)</div>
<div v-if="preview.length" class="pv-list">
<div v-for="p in preview" :key="p.label" class="pv-row"><span>{{ p.label }}</span><strong>{{ p.value }}</strong></div>
</div>
<p v-else class="muted small">Keine Felder zugeordnet.</p>
</div>
<p v-if="!canImport" class="warn small">Vorname und Nachname müssen zugeordnet sein.</p>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button class="btn btn-ghost" @click="reset">Zurück</button>
<button class="btn btn-primary" :disabled="!canImport || importing" @click="runImport">
{{ importing ? 'Importiere…' : `${parsed.rows.length} importieren` }}
</button>
</div>
</div>
<!-- Schritt 3: Ergebnis -->
<div v-else>
<div class="result">
<div class="rstat"><strong>{{ result.created }}</strong><span>angelegt</span></div>
<div class="rstat"><strong>{{ result.updated }}</strong><span>aktualisiert</span></div>
<div class="rstat"><strong>{{ result.skipped }}</strong><span>übersprungen</span></div>
</div>
<div v-if="result.errors.length" class="errlist">
<div class="nfc__label">Hinweise</div>
<div v-for="(er, i) in result.errors" :key="i" class="small">Zeile {{ er.row }}: {{ er.message }}</div>
</div>
<div class="actions">
<button class="btn btn-ghost" @click="reset">Weiterer Import</button>
<button class="btn btn-primary" @click="emit('close')">Fertig</button>
</div>
</div>
</Modal>
</template>
<style scoped>
.field { margin-bottom: .9rem; }
.mono { font-family: ui-monospace, monospace; font-size: .82rem; }
.sample { display: flex; align-items: center; gap: .8rem; margin-bottom: .9rem; }
.linkbtn { background: none; border: none; padding: 0; color: var(--psc-orange-dark); font-weight: 600; font-size: .85rem; cursor: pointer; text-decoration: underline; font-family: var(--font); }
.linkbtn:hover { color: var(--psc-orange); }
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1.2rem; }
.error { color: var(--danger); font-size: .88rem; }
.warn { color: var(--psc-orange-dark); }
.small { font-size: .8rem; }
.map-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem .9rem; }
.map-row { display: grid; grid-template-columns: 9rem 1fr; align-items: center; gap: .5rem; min-width: 0; }
.map-label { font-size: .85rem; font-weight: 600; color: var(--text); }
.opts { margin: 1.1rem 0; }
.opts .muted { margin: .3rem 0 0; }
.preview { background: #f7f7f8; border: 1px solid var(--line); border-radius: var(--radius-sm); padding: .8rem .9rem; }
.nfc__label { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 700; margin-bottom: .5rem; }
.pv-list { display: grid; gap: .3rem; }
.pv-row { display: flex; justify-content: space-between; gap: 1rem; font-size: .88rem; }
.pv-row span { color: var(--muted); }
.pv-row strong { text-align: right; word-break: break-word; }
.result { display: flex; gap: 1rem; margin-bottom: 1rem; }
.rstat { flex: 1; text-align: center; background: var(--psc-orange-soft-2); border: 1px solid var(--psc-border); border-radius: var(--radius-sm); padding: 1rem; }
.rstat strong { display: block; font-size: 1.8rem; color: var(--dark); }
.rstat span { color: var(--muted); font-size: .82rem; }
.errlist { background: #fff7f6; border: 1px solid #f4d4d0; border-radius: var(--radius-sm); padding: .7rem .9rem; margin-bottom: 1rem; max-height: 160px; overflow: auto; }
@media (max-width: 640px) { .map-grid { grid-template-columns: 1fr; } }
</style>

View File

@ -23,7 +23,7 @@ const emit = defineEmits<{ close: [] }>()
display: flex; align-items: center; justify-content: center; padding: 1rem; z-index: 50;
}
.modal { width: 100%; max-width: 480px; max-height: 90vh; overflow: auto; }
.modal--wide { max-width: 680px; }
.modal--wide { max-width: 820px; }
.modal__head {
display: flex; align-items: center; justify-content: space-between;
padding: 1.1rem 1.4rem; border-bottom: 1px solid var(--line);

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
// Telefonfeld: Länder-Vorwahl per Auswahlfeld (Emoji-Flagge + Vorwahl) + Nummer.
// v-model speichert die volle internationale Nummer als String ("+49 151 ").
import { ref, watch } from 'vue'
import { COUNTRIES, dialOf, flagEmoji, splitPhone } from '@/data/countries'
const props = withDefaults(defineProps<{ modelValue?: string | null; placeholder?: string }>(), {
modelValue: '',
placeholder: '151 23456789',
})
const emit = defineEmits<{ 'update:modelValue': [string] }>()
const code = ref('DE')
const number = ref('')
watch(
() => props.modelValue,
(v) => {
const p = splitPhone(v)
code.value = p.code
number.value = p.number
},
{ immediate: true },
)
function emitValue() {
const n = number.value.trim()
emit('update:modelValue', n ? `${dialOf(code.value)} ${n}` : '')
}
</script>
<template>
<div class="phone">
<select class="input dial" v-model="code" @change="emitValue" :title="'Ländervorwahl'">
<option v-for="c in COUNTRIES" :key="c.code" :value="c.code">
{{ flagEmoji(c.code) }} {{ c.dial }}
</option>
</select>
<input class="input num" type="tel" v-model="number" :placeholder="placeholder" @input="emitValue" />
</div>
</template>
<style scoped>
.phone { display: flex; gap: .5rem; min-width: 0; }
.dial { flex: 0 0 6.75rem; padding-left: .5rem; padding-right: .3rem; min-width: 0; }
.num { flex: 1; min-width: 0; }
</style>

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

@ -0,0 +1,96 @@
// Länder mit ISO-Code, deutschem Namen und Telefon-Vorwahl.
// Die Flagge wird als Emoji aus dem ISO-Code abgeleitet (keine Asset-Datei).
export interface Country {
code: string // ISO 3166-1 alpha-2
name: string
dial: string
}
/** Emoji-Flagge aus dem ISO-Code (Regional Indicator Symbols). */
export function flagEmoji(code: string): string {
return code
.toUpperCase()
.replace(/[^A-Z]/g, '')
.replace(/./g, (c) => String.fromCodePoint(127397 + c.charCodeAt(0)))
}
// DACH zuerst, dann übriges Europa und wichtige Länder weltweit.
export const COUNTRIES: Country[] = [
{ code: 'DE', name: 'Deutschland', dial: '+49' },
{ code: 'AT', name: 'Österreich', dial: '+43' },
{ code: 'CH', name: 'Schweiz', dial: '+41' },
{ code: 'LI', name: 'Liechtenstein', dial: '+423' },
{ code: 'LU', name: 'Luxemburg', dial: '+352' },
{ code: 'BE', name: 'Belgien', dial: '+32' },
{ code: 'NL', name: 'Niederlande', dial: '+31' },
{ code: 'FR', name: 'Frankreich', dial: '+33' },
{ code: 'IT', name: 'Italien', dial: '+39' },
{ code: 'ES', name: 'Spanien', dial: '+34' },
{ code: 'PT', name: 'Portugal', dial: '+351' },
{ code: 'GB', name: 'Vereinigtes Königreich', dial: '+44' },
{ code: 'IE', name: 'Irland', dial: '+353' },
{ code: 'DK', name: 'Dänemark', dial: '+45' },
{ code: 'SE', name: 'Schweden', dial: '+46' },
{ code: 'NO', name: 'Norwegen', dial: '+47' },
{ code: 'FI', name: 'Finnland', dial: '+358' },
{ code: 'IS', name: 'Island', dial: '+354' },
{ code: 'PL', name: 'Polen', dial: '+48' },
{ code: 'CZ', name: 'Tschechien', dial: '+420' },
{ code: 'SK', name: 'Slowakei', dial: '+421' },
{ code: 'HU', name: 'Ungarn', dial: '+36' },
{ code: 'SI', name: 'Slowenien', dial: '+386' },
{ code: 'HR', name: 'Kroatien', dial: '+385' },
{ code: 'RO', name: 'Rumänien', dial: '+40' },
{ code: 'BG', name: 'Bulgarien', dial: '+359' },
{ code: 'GR', name: 'Griechenland', dial: '+30' },
{ code: 'EE', name: 'Estland', dial: '+372' },
{ code: 'LV', name: 'Lettland', dial: '+371' },
{ code: 'LT', name: 'Litauen', dial: '+370' },
{ code: 'RS', name: 'Serbien', dial: '+381' },
{ code: 'UA', name: 'Ukraine', dial: '+380' },
{ code: 'TR', name: 'Türkei', dial: '+90' },
{ code: 'RU', name: 'Russland', dial: '+7' },
{ code: 'US', name: 'USA', dial: '+1' },
{ code: 'CA', name: 'Kanada', dial: '+1' },
{ code: 'MX', name: 'Mexiko', dial: '+52' },
{ code: 'BR', name: 'Brasilien', dial: '+55' },
{ code: 'AR', name: 'Argentinien', dial: '+54' },
{ code: 'AU', name: 'Australien', dial: '+61' },
{ code: 'NZ', name: 'Neuseeland', dial: '+64' },
{ code: 'ZA', name: 'Südafrika', dial: '+27' },
{ code: 'AE', name: 'Ver. Arab. Emirate', dial: '+971' },
{ code: 'IL', name: 'Israel', dial: '+972' },
{ code: 'IN', name: 'Indien', dial: '+91' },
{ code: 'CN', name: 'China', dial: '+86' },
{ code: 'JP', name: 'Japan', dial: '+81' },
{ code: 'KR', name: 'Südkorea', dial: '+82' },
{ code: 'SG', name: 'Singapur', dial: '+65' },
{ code: 'HK', name: 'Hongkong', dial: '+852' },
]
const BY_CODE = new Map(COUNTRIES.map((c) => [c.code, c]))
export function countryByCode(code: string | null | undefined): Country | undefined {
return code ? BY_CODE.get(code.toUpperCase()) : undefined
}
/** Vorwahl zu einem ISO-Code (Default +49). */
export function dialOf(code: string): string {
return BY_CODE.get(code.toUpperCase())?.dial ?? '+49'
}
/**
* Zerlegt eine gespeicherte Nummer ("+49 151 …") in Länder-Code + Restnummer.
* Längste passende Vorwahl gewinnt; ohne Treffer Default DE.
*/
export function splitPhone(value: string | null | undefined): { code: string; number: string } {
const s = (value ?? '').trim()
if ('' === s) return { code: 'DE', number: '' }
let best: Country | undefined
for (const c of COUNTRIES) {
if (s.startsWith(c.dial) && (!best || c.dial.length > best.dial.length)) best = c
}
if (best) return { code: best.code, number: s.slice(best.dial.length).trim() }
return { code: 'DE', number: s.replace(/^\+/, '') }
}

View File

@ -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()
@ -14,13 +14,14 @@ const topNav = computed<NavItem[]>(() => {
const above = auth.isResellerAdmin || auth.isPlatformAdmin
return [
{ label: 'Dashboard', to: '/app', icon: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z', show: above },
// Plattform-Ebene: nur Reseller (Firmen/Mitarbeiter/Produkte/Bestellungen via Einloggen als")
{ label: 'Reseller', to: '/app/resellers', icon: 'M3 21h18M5 21V7l8-4v18M19 21V11l-6-3', show: auth.isPlatformAdmin },
// Plattform: Firmen" (alle); Reseller: Einloggen als" (eigene Firmenkunden)
{ label: auth.isPlatformAdmin ? 'Firmen' : 'Einloggen als', to: '/app/companies', icon: 'M3 7h18v13H3zM8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', show: above },
// Plattform: einloggbare Mitarbeiter (firmenübergreifend)
{ label: 'Mitarbeiter', to: '/app/employees', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', show: auth.isPlatformAdmin },
{ 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: above },
{ 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: above },
// Reseller-Ebene: eigene Firmenkunden + Produkte + Bestellungen
{ 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: '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: 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)
})
@ -36,6 +37,7 @@ const leftNav = computed<NavItem[]>(() => {
{ 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)
@ -71,7 +73,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>

View File

@ -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'))

View File

@ -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' } },
],

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

@ -0,0 +1,186 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import client from '@/api/client'
import { useBrandingStore } from '@/stores/branding'
interface BrandingDto {
scope: string
name: string
address: string
primaryColor: string
primaryColorDark: string
primaryColorSoft: string
tagline: string
hasLogo: boolean
logoUrl: string | null
}
const brandingStore = useBrandingStore()
const data = ref<BrandingDto | null>(null)
const loading = ref(true)
const saving = ref(false)
const saved = ref(false)
const error = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
async function load() {
loading.value = true
try {
data.value = (await client.get<BrandingDto>('/my-branding')).data
} catch {
error.value = 'Branding konnte nicht geladen werden.'
} finally {
loading.value = false
}
}
async function save() {
if (!data.value) return
saving.value = true
saved.value = false
error.value = ''
try {
const { data: res } = await client.put<BrandingDto>('/my-branding', {
primaryColor: data.value.primaryColor,
primaryColorDark: data.value.primaryColorDark,
primaryColorSoft: data.value.primaryColorSoft,
tagline: data.value.tagline,
})
data.value = res
saved.value = true
await brandingStore.load()
} catch {
error.value = 'Speichern fehlgeschlagen.'
} finally {
saving.value = false
}
}
async function onLogoChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
error.value = ''
const form = new FormData()
form.append('file', file)
try {
data.value = (await client.post<BrandingDto>('/my-branding/logo', form)).data
await brandingStore.load()
} catch {
error.value = 'Logo-Upload fehlgeschlagen (PNG, JPG oder SVG).'
} finally {
if (fileInput.value) fileInput.value.value = ''
}
}
async function removeLogo() {
try {
data.value = (await client.delete<BrandingDto>('/my-branding/logo')).data
await brandingStore.load()
} catch {
error.value = 'Logo konnte nicht entfernt werden.'
}
}
onMounted(load)
</script>
<template>
<section>
<div class="page-head">
<div>
<h1>Branding</h1>
<p class="muted">So erscheint Ihr Auftritt für Kunden auf Ihrer Adresse.</p>
</div>
<button class="btn btn-primary" :disabled="saving || loading" @click="save">
{{ saving ? 'Speichern…' : 'Speichern' }}
</button>
</div>
<p v-if="loading" class="muted">Lädt</p>
<div v-else-if="data" class="grid">
<div class="card">
<h3>Logo</h3>
<div class="logo-row">
<div class="logo-preview">
<img v-if="data.logoUrl" :src="data.logoUrl" alt="Logo" />
<span v-else class="muted sm">Kein Logo</span>
</div>
<div class="logo-actions">
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/svg+xml" hidden @change="onLogoChange" />
<button class="btn btn-soft btn-sm" @click="fileInput?.click()">{{ data.hasLogo ? 'Logo ersetzen' : 'Logo hochladen' }}</button>
<button v-if="data.hasLogo" class="btn btn-ghost btn-sm" @click="removeLogo">Entfernen</button>
<p class="muted sm">PNG, JPG oder SVG. Wird in Login & Kopfzeile angezeigt.</p>
</div>
</div>
<h3 style="margin-top:1.6rem">Farben</h3>
<div class="colors">
<label class="color">
<span>Primärfarbe</span>
<span class="swatch"><input type="color" v-model="data.primaryColor" /><code>{{ data.primaryColor }}</code></span>
</label>
<label class="color">
<span>Primär dunkel (Hover)</span>
<span class="swatch"><input type="color" v-model="data.primaryColorDark" /><code>{{ data.primaryColorDark }}</code></span>
</label>
<label class="color">
<span>Primär hell (Flächen)</span>
<span class="swatch"><input type="color" v-model="data.primaryColorSoft" /><code>{{ data.primaryColorSoft }}</code></span>
</label>
</div>
<h3 style="margin-top:1.6rem">Slogan</h3>
<input class="input slogan-input" v-model="data.tagline" maxlength="120" placeholder="z. B. Ihre Druckerei vor Ort" />
<p v-if="error" class="error" style="margin-top:1rem">{{ error }}</p>
<p v-if="saved" class="ok" style="margin-top:1rem">Gespeichert.</p>
</div>
<div class="card preview" :style="{ '--p': data.primaryColor, '--pd': data.primaryColorDark, '--ps': data.primaryColorSoft }">
<div class="muted sm" style="margin-bottom:.6rem">Vorschau</div>
<div class="prev-bar">
<img v-if="data.logoUrl" :src="data.logoUrl" alt="Logo" class="prev-logo" />
<strong v-else>{{ data.name }}</strong>
</div>
<p class="prev-tag">{{ data.tagline || '—' }}</p>
<button class="prev-btn">Primär-Button</button>
<span class="prev-chip">Soft-Fläche</span>
<p class="muted sm" style="margin-top:1.2rem">Adresse: <code>{{ data.address }}</code></p>
</div>
</div>
</section>
</template>
<style scoped>
.page-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1.4rem; }
.page-head .muted { margin: .2rem 0 0; }
.grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 1.2rem; align-items: start; }
.card { padding: 1.4rem 1.5rem; }
.card h3 { font-size: .95rem; margin-bottom: .7rem; }
.sm { font-size: .8rem; }
.error { color: var(--danger); font-size: .88rem; }
.ok { color: var(--success); font-size: .88rem; }
.logo-row { display: flex; gap: 1.2rem; align-items: center; }
.logo-preview { width: 96px; height: 96px; border: 1px dashed var(--line); border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; background: #fafafa; flex-shrink: 0; }
.logo-preview img { max-width: 84px; max-height: 84px; }
.logo-actions { display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; }
.logo-actions .muted { margin: .2rem 0 0; }
.colors { display: flex; flex-direction: column; gap: .7rem; max-width: 440px; }
.slogan-input { max-width: 440px; }
.color { display: flex; align-items: center; justify-content: space-between; font-size: .9rem; font-weight: 600; }
.swatch { display: inline-flex; align-items: center; gap: .6rem; }
.swatch input[type=color] { width: 38px; height: 30px; border: 1px solid var(--line); border-radius: 8px; background: none; cursor: pointer; padding: 0; }
.swatch code { color: var(--muted); font-size: .82rem; }
.preview { position: sticky; top: 80px; }
.prev-bar { display: flex; align-items: center; min-height: 34px; }
.prev-logo { max-height: 30px; max-width: 160px; }
.prev-tag { color: var(--muted); font-size: .85rem; margin: .3rem 0 1rem; }
.prev-btn { background: var(--p); color: #fff; border: none; border-radius: var(--radius-sm); padding: .55rem 1rem; font-weight: 700; font-family: var(--font); cursor: default; }
.prev-btn:hover { background: var(--pd); }
.prev-chip { display: inline-block; margin-top: .7rem; background: var(--ps); color: var(--pd); padding: .35rem .8rem; border-radius: 999px; font-size: .82rem; font-weight: 700; }
@media (max-width: 820px) { .grid { grid-template-columns: 1fr; } }
</style>

View File

@ -27,13 +27,16 @@ async function count(resource: string): Promise<number> {
onMounted(async () => {
const result: Stat[] = []
// Plattform-Ebene: nur Reseller (Firmen/Mitarbeiter via Einloggen als")
if (auth.isPlatformAdmin) {
result.push({ label: 'Reseller', value: await count('resellers'), to: '/app/resellers', icon: ICON.reseller, tone: 'orange' })
}
if (auth.isResellerAdmin || auth.isPlatformAdmin) {
if (auth.isResellerAdmin) {
result.push({ label: 'Firmen', value: await count('companies'), to: '/app/companies', icon: ICON.company, tone: 'blue' })
}
if (auth.isResellerAdmin || auth.isCompanyAdmin) {
result.push({ label: 'Mitarbeiter', value: await count('employees'), to: '/app/employees', icon: ICON.people, tone: 'orange' })
}
if (auth.isCompanyAdmin || auth.isResellerAdmin) {
result.push({ label: 'Standorte', value: await count('locations'), to: '/app/locations', icon: ICON.pin, tone: 'green' })
result.push({ label: 'Domains', value: await count('domains'), to: '/app/domains', icon: ICON.globe, tone: 'gray' })

View File

@ -1,101 +1,131 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { list, create, remove } from '@/api/resources'
import { useAuthStore } from '@/stores/auth'
import { ref, onMounted } from 'vue'
import client from '@/api/client'
import Modal from '@/components/Modal.vue'
interface Domain {
'@id': string; id: string; hostname: string; type: string; status: string; tlsStatus: string; company: string
interface DomainItem {
id: string; hostname: string; type: string; status: string; tlsStatus: string; checkedAt: string | null
}
interface DomainsDto {
scope: string; defaultHost: string; edgeTarget: string; edgeIp: string | null; domains: DomainItem[]
}
interface Company { '@id': string; name: string }
const auth = useAuthStore()
// Beispiel-IP für Custom-Domains (A-Record). In Prod aus Config.
const PLATFORM_IP = '203.0.113.10'
const domains = ref<Domain[]>([])
const companies = ref<Company[]>([])
const data = ref<DomainsDto | null>(null)
const loading = ref(true)
const showForm = ref(false)
const saving = ref(false)
const error = ref('')
const blank = () => ({ hostname: '', type: 'custom', company: '' })
const form = ref(blank())
const ownCompanyIri = computed(() =>
auth.user?.company ? `/api/companies/${auth.user.company.id}` : companies.value[0]?.['@id'] ?? '',
)
const companyMap = computed(() => Object.fromEntries(companies.value.map((c) => [c['@id'], c.name])))
const hostname = ref('')
const busy = ref('')
const notice = ref('')
async function load() {
loading.value = true
;[companies.value, domains.value] = await Promise.all([
list<Company>('companies').then((r) => r.member).catch(() => []),
list<Domain>('domains').then((r) => r.member),
])
try {
data.value = (await client.get<DomainsDto>('/my-domains')).data
} catch {
error.value = 'Domains konnten nicht geladen werden.'
} finally {
loading.value = false
}
function openCreate() { form.value = blank(); form.value.company = ownCompanyIri.value; error.value = ''; showForm.value = true }
}
async function submit() {
saving.value = true; error.value = ''
try {
await create('domains', { hostname: form.value.hostname, type: form.value.type, company: form.value.company || ownCompanyIri.value })
showForm.value = false; await load()
} catch { error.value = 'Speichern fehlgeschlagen (Hostname evtl. schon vergeben).' } finally { saving.value = false }
await client.post('/my-domains', { hostname: hostname.value })
showForm.value = false; hostname.value = ''
await load()
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } }
error.value = err.response?.data?.message ?? 'Speichern fehlgeschlagen.'
} finally { saving.value = false }
}
async function verify(d: DomainItem) {
busy.value = d.id; notice.value = ''
try {
const { data: res } = await client.post<{ ok: boolean; message: string }>(`/my-domains/${d.id}/verify`)
notice.value = res.message
await load()
} catch { notice.value = 'Prüfung fehlgeschlagen.' } finally { busy.value = '' }
}
async function del(d: DomainItem) {
if (!confirm(`Domain „${d.hostname}" löschen?`)) return
await client.delete(`/my-domains/${d.id}`)
await load()
}
function statusLabel(s: string) {
return s === 'verified' ? 'Verifiziert' : s === 'failed' ? 'Nicht erreichbar' : 'Ausstehend'
}
function statusClass(s: string) {
return s === 'verified' ? 'badge-active' : s === 'failed' ? 'badge-danger' : 'badge-inactive'
}
async function del(d: Domain) { if (confirm(`Domain „${d.hostname}" löschen?`)) { await remove(d['@id']); await load() } }
function statusClass(s: string) { return s === 'verified' ? 'badge-active' : 'badge-inactive' }
onMounted(load)
</script>
<template>
<section>
<div class="page-head">
<div><h1>Domains</h1><p class="muted">Subdomains & eigene Domains der Firmenkunden</p></div>
<button class="btn btn-primary" @click="openCreate">+ Domain hinzufügen</button>
<div><h1>Domains</h1><p class="muted">Ihre Standard-Adresse und eigene Domains.</p></div>
<button class="btn btn-primary" @click="showForm = true">+ Eigene Domain</button>
</div>
<div class="card">
<p v-if="loading" class="muted">Lädt</p>
<template v-else-if="data">
<div class="card default-card">
<div>
<div class="muted sm">Standard-Adresse</div>
<strong class="host">{{ data.defaultHost }}</strong>
</div>
<span class="badge badge-active">aktiv</span>
</div>
<div class="card" style="margin-top:1rem">
<table class="tbl">
<thead><tr><th>Hostname</th><th>Typ</th><th>Status</th><th>TLS</th><th>Firma</th><th></th></tr></thead>
<thead><tr><th>Eigene Domain</th><th>Status</th><th>TLS</th><th></th></tr></thead>
<tbody>
<tr v-if="loading"><td colspan="6" class="empty">Lädt</td></tr>
<tr v-else-if="!domains.length"><td colspan="6" class="empty">Noch keine Domains.</td></tr>
<tr v-for="d in domains" :key="d.id">
<tr v-if="!data.domains.length"><td colspan="4" class="empty">Noch keine eigene Domain hinterlegt.</td></tr>
<tr v-for="d in data.domains" :key="d.id">
<td><strong>{{ d.hostname }}</strong></td>
<td class="muted">{{ d.type === 'custom' ? 'Eigene' : 'Subdomain' }}</td>
<td><span class="badge" :class="statusClass(d.status)">{{ d.status }}</span></td>
<td class="muted">{{ d.tlsStatus }}</td>
<td class="muted">{{ companyMap[d.company] ?? '' }}</td>
<td class="right"><button class="btn btn-ghost btn-sm" @click="del(d)">Löschen</button></td>
<td><span class="badge" :class="statusClass(d.status)">{{ statusLabel(d.status) }}</span></td>
<td class="muted">{{ d.status === 'verified' ? 'automatisch' : '' }}</td>
<td class="right">
<button v-if="d.status !== 'verified'" class="btn btn-soft btn-sm" :disabled="busy === d.id" @click="verify(d)">
{{ busy === d.id ? 'Prüfe…' : 'Prüfen' }}
</button>
<button class="btn btn-ghost btn-sm" @click="del(d)">Löschen</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="notice" class="notice">{{ notice }}</p>
</template>
<Modal v-if="showForm" title="Domain hinzufügen" @close="showForm = false">
<Modal v-if="showForm" title="Eigene Domain hinzufügen" @close="showForm = false">
<form @submit.prevent="submit">
<div class="field">
<label>Typ</label>
<select class="input" v-model="form.type">
<option value="custom">Eigene Domain</option>
<option value="subdomain">Subdomain</option>
</select>
<label>Hostname</label>
<input class="input" v-model="hostname" required placeholder="karten.ihre-firma.de" />
</div>
<div class="field"><label>Hostname</label><input class="input" v-model="form.hostname" required placeholder="visitenkarte.firma.de" /></div>
<div v-if="form.type === 'custom'" class="hint">
Nach dem Anlegen einen <strong>A-Record</strong> auf <code>{{ PLATFORM_IP }}</code> setzen.
Die Plattform prüft die Domain und stellt automatisch ein TLS-Zertifikat aus.
</div>
<div class="field" v-if="auth.isResellerAdmin || auth.isPlatformAdmin">
<label>Firma</label>
<select class="input" v-model="form.company">
<option v-for="c in companies" :key="c['@id']" :value="c['@id']">{{ c.name }}</option>
</select>
<div class="hint" v-if="data">
Richten Sie bei Ihrem DNS-Anbieter einen Eintrag auf die Plattform ein:
<ul>
<li>Subdomain (z. B. <code>karten.ihre-firma.de</code>): <strong>CNAME</strong>
<code>{{ data.edgeTarget }}</code></li>
<li>Haupt-Domain (z. B. <code>ihre-firma.de</code>): <strong>A-Record</strong>
<code>{{ data.edgeIp || 'IP der Plattform-Edge' }}</code></li>
</ul>
Danach Prüfen" das TLS-Zertifikat wird beim ersten Aufruf automatisch ausgestellt.
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button type="button" class="btn btn-ghost" @click="showForm = false">Abbrechen</button>
<button class="btn btn-primary" :disabled="saving">{{ saving ? 'Speichern…' : 'Speichern' }}</button>
<button class="btn btn-primary" :disabled="saving">{{ saving ? 'Speichern…' : 'Hinzufügen' }}</button>
</div>
</form>
</Modal>
@ -105,14 +135,22 @@ onMounted(load)
<style scoped>
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; }
.page-head .muted { margin: .2rem 0 0; }
.sm { font-size: .8rem; }
.default-card { display: flex; align-items: center; justify-content: space-between; padding: 1.1rem 1.4rem; }
.default-card .host { font-size: 1.1rem; display: block; margin-top: .2rem; }
.tbl { width: 100%; border-collapse: collapse; }
.tbl th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); padding: .9rem 1.2rem; border-bottom: 1px solid var(--line); }
.tbl td { padding: .9rem 1.2rem; border-bottom: 1px solid #f4f4f4; }
.tbl td { padding: .9rem 1.2rem; border-bottom: 1px solid #f4f4f4; vertical-align: middle; }
.tbl tr:last-child td { border-bottom: none; }
.right { text-align: right; }
.right { text-align: right; white-space: nowrap; }
.right .btn + .btn { margin-left: .4rem; }
.empty { text-align: center; color: var(--muted); padding: 2rem; }
.badge-danger { background: #fdecea; color: var(--danger); }
.hint { background: var(--psc-orange-soft-2); border: 1px solid var(--psc-border); border-radius: var(--radius-sm); padding: .8rem .9rem; font-size: .85rem; margin-bottom: .9rem; }
.hint ul { margin: .5rem 0; padding-left: 1.1rem; }
.hint li { margin: .25rem 0; }
.hint code { background: #fff; padding: 1px 6px; border-radius: 5px; }
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
.error { color: var(--danger); font-size: .88rem; }
.notice { margin-top: .8rem; background: var(--psc-orange-soft-2); border: 1px solid var(--psc-border); border-radius: var(--radius-sm); padding: .7rem .9rem; font-size: .88rem; }
</style>

View File

@ -5,6 +5,9 @@ import { list, create, update, remove } from '@/api/resources'
import client from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue'
import PhoneInput from '@/components/PhoneInput.vue'
import CountrySelect from '@/components/CountrySelect.vue'
import EmployeeImport from '@/components/EmployeeImport.vue'
const GROUP_LABEL: Record<string, string> = {
platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin',
@ -18,23 +21,37 @@ const GROUP_LEVEL: Record<string, number> = {
platform_admin: 4, reseller_admin: 3, company_admin: 2, employee: 1, contact: 0,
}
// Bekannte Social-Netzwerke (weitere Links frei ergänzbar)
const NETWORKS = [
{ type: 'linkedin', label: 'LinkedIn', ph: 'https://linkedin.com/in/…' },
{ type: 'xing', label: 'XING', ph: 'https://xing.com/profile/…' },
{ type: 'instagram', label: 'Instagram', ph: 'https://instagram.com/…' },
{ type: 'facebook', label: 'Facebook', ph: 'https://facebook.com/…' },
{ type: 'twitter', label: 'X (Twitter)', ph: 'https://x.com/…' },
{ type: 'youtube', label: 'YouTube', ph: 'https://youtube.com/@…' },
{ type: 'tiktok', label: 'TikTok', ph: 'https://tiktok.com/@…' },
{ type: 'github', label: 'GitHub', ph: 'https://github.com/…' },
{ type: 'whatsapp', label: 'WhatsApp', ph: 'https://wa.me/49…' },
] as const
const KNOWN_TYPES = NETWORKS.map((n) => n.type) as readonly string[]
interface Address {
street?: string; houseNumber?: string; addressLine2?: string
zip?: string; city?: string; state?: string; country?: string
}
interface ContactLinkDto { type: string; url: string; label: string | null }
interface Employee {
'@id': string
id: string
firstName: string
lastName: string
slug: string
position: string | null
department: string | null
email: string | null
phone: string | null
mobile: string | null
status: string
shortCode: string | null
roles: string[]
login: boolean
company: string
location: string | null
'@id': string; id: string
firstName: string; lastName: string; slug: string
salutation: string | null; title: string | null
position: string | null; department: string | null; bio: string | null
email: string | null; emailPrivate: string | null
phone: string | null; mobile: string | null; fax: string | null
phoneCentral: string | null; website: string | null
status: string; shortCode: string | null; roles: string[]; login: boolean
company: string; location: string | null; photoPath: string | null
addressBusiness: Address | null; addressPrivate: Address | null
contactLinks: string[] // IRIs; volle Objekte via GET .../contact-links
}
interface Company { '@id': string; name: string; slug: string }
interface Location { '@id': string; name: string; company: string }
@ -69,7 +86,6 @@ async function workAs(e: Employee) {
const companyMap = computed(() => Object.fromEntries(companies.value.map((c) => [c['@id'], c])))
function companyName(iri: string) { return companyMap.value[iri]?.name ?? '' }
// Portal-Ebene (Plattform-Admin): firmenübergreifend, nur einloggbare Mitarbeiter
const portalMode = computed(() => auth.isPlatformAdmin)
const filtered = computed(() => {
@ -115,8 +131,10 @@ async function saveAccess(e: Employee) {
accessForm.value.password = ''
await load()
editing.value = employees.value.find((x) => x.id === e.id) ?? null
} catch (err: any) {
alert(err?.response?.data?.error ?? err?.response?.data?.detail ?? 'Speichern fehlgeschlagen.')
showAccess.value = false
} catch (err: unknown) {
const ex = err as { response?: { data?: { error?: string; detail?: string } } }
alert(ex?.response?.data?.error ?? ex?.response?.data?.detail ?? 'Speichern fehlgeschlagen.')
}
}
async function removeLogin(e: Employee) {
@ -124,6 +142,18 @@ async function removeLogin(e: Employee) {
await client.delete(`/employees/${e.id}/login`)
await load()
editing.value = employees.value.find((x) => x.id === e.id) ?? null
showAccess.value = false
}
// --- Import ---
const showImport = ref(false)
// --- Login & Rechte (eigenes Modal) ---
const showAccess = ref(false)
function openAccess() {
if (!editing.value) return
accessForm.value = { group: groupOf(editing.value), password: '' }
showAccess.value = true
}
// --- Anlegen / Bearbeiten ---
@ -131,12 +161,33 @@ const showForm = ref(false)
const saving = ref(false)
const error = ref('')
const editing = ref<Employee | null>(null)
const activeTab = ref('allgemein')
const emptyAddress = (): Address => ({ street: '', houseNumber: '', addressLine2: '', zip: '', city: '', state: '', country: '' })
const blank = () => ({
firstName: '', lastName: '', slug: '', position: '', department: '',
email: '', phone: '', mobile: '', company: '', location: '',
salutation: '', title: '', firstName: '', lastName: '', slug: '',
position: '', department: '', bio: '',
email: '', emailPrivate: '', phone: '', mobile: '', fax: '', phoneCentral: '', website: '',
addressBusiness: emptyAddress(), addressPrivate: emptyAddress(),
company: '', location: '',
})
const form = ref(blank())
// Social
const socialKnown = ref<Record<string, string>>({})
const customLinks = ref<{ label: string; url: string }[]>([])
// Foto
const photoFile = ref<File | null>(null)
const photoPreview = ref<string | null>(null)
const photoRemoved = ref(false)
const photoInput = ref<HTMLInputElement | null>(null)
const currentPhotoUrl = computed(() =>
editing.value?.photoPath && !photoRemoved.value
? `${PUBLIC_BASE}/p/photo/${editing.value.id}.jpg?v=${Date.now()}`
: null,
)
const ownCompanyIri = computed(() => {
if (auth.user?.company) return `/api/companies/${auth.user.company.id}`
return companies.value[0]?.['@id'] ?? ''
@ -145,46 +196,127 @@ const availableLocations = computed(() =>
locations.value.filter((l) => l.company === (form.value.company || ownCompanyIri.value)),
)
function resetSocialPhoto() {
socialKnown.value = Object.fromEntries(KNOWN_TYPES.map((t) => [t, '']))
customLinks.value = []
photoFile.value = null
photoPreview.value = null
photoRemoved.value = false
}
function openCreate() {
editing.value = null
form.value = blank()
form.value.company = ownCompanyIri.value
resetSocialPhoto()
activeTab.value = 'allgemein'
error.value = ''
showForm.value = true
}
function openEdit(e: Employee) {
async function openEdit(e: Employee) {
editing.value = e
form.value = {
salutation: e.salutation ?? '', title: e.title ?? '',
firstName: e.firstName, lastName: e.lastName, slug: e.slug,
position: e.position ?? '', department: e.department ?? '',
email: e.email ?? '', phone: e.phone ?? '', mobile: e.mobile ?? '',
position: e.position ?? '', department: e.department ?? '', bio: e.bio ?? '',
email: e.email ?? '', emailPrivate: e.emailPrivate ?? '',
phone: e.phone ?? '', mobile: e.mobile ?? '', fax: e.fax ?? '',
phoneCentral: e.phoneCentral ?? '', website: e.website ?? '',
addressBusiness: { ...emptyAddress(), ...(e.addressBusiness ?? {}) },
addressPrivate: { ...emptyAddress(), ...(e.addressPrivate ?? {}) },
company: e.company, location: e.location ?? '',
}
resetSocialPhoto()
accessForm.value = { group: groupOf(e), password: '' }
activeTab.value = 'allgemein'
error.value = ''
showForm.value = true
try {
const { data } = await client.get<{ links: ContactLinkDto[] }>(`/employees/${e.id}/contact-links`)
for (const l of data.links ?? []) {
if (KNOWN_TYPES.includes(l.type)) socialKnown.value[l.type] = l.url
else customLinks.value.push({ label: l.label ?? '', url: l.url })
}
} catch { /* keine Links egal */ }
}
function cleanAddress(a: Address): Address | null {
const out: Address = {}
let any = false
for (const [k, v] of Object.entries(a)) {
const t = (v ?? '').toString().trim()
if (t) { (out as Record<string, string>)[k] = t; any = true }
}
return any ? out : null
}
function onPhotoChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
photoFile.value = file
photoPreview.value = URL.createObjectURL(file)
photoRemoved.value = false
}
function removePhoto() {
photoFile.value = null
photoPreview.value = null
photoRemoved.value = true
if (photoInput.value) photoInput.value.value = ''
}
function buildLinks(): ContactLinkDto[] {
const links: ContactLinkDto[] = []
for (const t of KNOWN_TYPES) {
const url = (socialKnown.value[t] ?? '').trim()
if (url) links.push({ type: t, url, label: null })
}
for (const c of customLinks.value) {
if (c.url.trim()) links.push({ type: 'link', url: c.url.trim(), label: c.label.trim() || null })
}
return links
}
async function submit() {
error.value = ''
saving.value = true
const payload: Record<string, unknown> = {
salutation: form.value.salutation || null,
title: form.value.title || null,
firstName: form.value.firstName,
lastName: form.value.lastName,
slug: form.value.slug || slugify(`${form.value.firstName}-${form.value.lastName}`),
position: form.value.position || null,
department: form.value.department || null,
bio: form.value.bio || null,
email: form.value.email || null,
emailPrivate: form.value.emailPrivate || null,
phone: form.value.phone || null,
mobile: form.value.mobile || null,
fax: form.value.fax || null,
phoneCentral: form.value.phoneCentral || null,
website: form.value.website || null,
location: form.value.location || null,
addressBusiness: cleanAddress(form.value.addressBusiness),
addressPrivate: cleanAddress(form.value.addressPrivate),
}
try {
let saved: Employee
if (editing.value) {
await update(editing.value['@id'], payload)
saved = await update<Employee>(editing.value['@id'], payload)
} else {
payload.company = form.value.company || ownCompanyIri.value
await create('employees', payload)
saved = await create<Employee>('employees', payload)
}
const id = saved.id ?? editing.value?.id
if (id) {
await client.put(`/employees/${id}/contact-links`, { links: buildLinks() })
if (photoFile.value) {
const fd = new FormData()
fd.append('file', photoFile.value)
await client.post(`/employees/${id}/photo`, fd)
} else if (photoRemoved.value && editing.value?.photoPath) {
await client.delete(`/employees/${id}/photo`)
}
}
showForm.value = false
await load()
@ -205,6 +337,14 @@ function copyShort(e: Employee) {
navigator.clipboard?.writeText(`${PUBLIC_BASE}/t/${e.shortCode}`)
}
const TABS = [
{ key: 'allgemein', label: 'Allgemein' },
{ key: 'kontakt', label: 'Kontakt' },
{ key: 'adresse', label: 'Adresse' },
{ key: 'social', label: 'Social' },
{ key: 'zugang', label: 'Zugang & NFC' },
]
onMounted(load)
</script>
@ -215,7 +355,10 @@ onMounted(load)
<h1>Mitarbeiter</h1>
<p class="muted">{{ portalMode ? 'Alle einloggbaren Mitarbeiter der Plattform' : 'Profile als Single Source of Truth für alle Kanäle' }}</p>
</div>
<button v-if="!portalMode" class="btn btn-primary" @click="openCreate">+ Mitarbeiter hinzufügen</button>
<div v-if="!portalMode" class="head-actions">
<button class="btn btn-soft" @click="showImport = true">Importieren</button>
<button class="btn btn-primary" @click="openCreate">+ Mitarbeiter hinzufügen</button>
</div>
</div>
<div class="card">
@ -233,7 +376,10 @@ onMounted(load)
<tr v-for="e in filtered" :key="e.id">
<td>
<div class="who">
<span class="avatar">{{ initials(e).toUpperCase() }}</span>
<span class="avatar">
<img v-if="e.photoPath" :src="`${PUBLIC_BASE}/p/photo/${e.id}.jpg`" alt="" />
<template v-else>{{ initials(e).toUpperCase() }}</template>
</span>
<div>
<strong>{{ e.firstName }} {{ e.lastName }}</strong>
<div class="muted small">{{ e.email }}</div>
@ -257,23 +403,44 @@ onMounted(load)
</table>
</div>
<Modal v-if="showForm" :title="editing ? 'Mitarbeiter bearbeiten' : 'Mitarbeiter hinzufügen'" @close="showForm = false">
<Modal v-if="showForm" wide :title="editing ? 'Mitarbeiter bearbeiten' : 'Mitarbeiter hinzufügen'" @close="showForm = false">
<div class="tabs">
<button v-for="t in TABS" :key="t.key" type="button" class="tab" :class="{ active: activeTab === t.key }" @click="activeTab = t.key">{{ t.label }}</button>
</div>
<form @submit.prevent="submit">
<!-- Allgemein -->
<div v-show="activeTab === 'allgemein'" class="panel">
<div class="photo-row">
<div class="photo-preview">
<img v-if="photoPreview" :src="photoPreview" alt="" />
<img v-else-if="currentPhotoUrl" :src="currentPhotoUrl" alt="" />
<span v-else class="muted small">Kein Foto</span>
</div>
<div class="photo-actions">
<input ref="photoInput" type="file" accept="image/png,image/jpeg,image/webp" hidden @change="onPhotoChange" />
<button type="button" class="btn btn-soft btn-sm" @click="photoInput?.click()">Foto wählen</button>
<button v-if="photoPreview || currentPhotoUrl" type="button" class="btn btn-ghost btn-sm" @click="removePhoto">Entfernen</button>
<p class="muted small">PNG, JPG oder WebP.</p>
</div>
</div>
<div class="grid2">
<div class="field">
<label>Anrede</label>
<select class="input" v-model="form.salutation">
<option value=""></option><option>Herr</option><option>Frau</option><option>Divers</option>
</select>
</div>
<div class="field"><label>Titel</label><input class="input" v-model="form.title" placeholder="Dr., Prof. …" /></div>
</div>
<div class="grid2">
<div class="field"><label>Vorname</label><input class="input" v-model="form.firstName" required /></div>
<div class="field"><label>Nachname</label><input class="input" v-model="form.lastName" required /></div>
</div>
<div class="field"><label>Slug (URL)</label><input class="input" v-model="form.slug" :placeholder="slugify(form.firstName + '-' + form.lastName)" /></div>
<div class="grid2">
<div class="field"><label>Position</label><input class="input" v-model="form.position" /></div>
<div class="field"><label>Abteilung</label><input class="input" v-model="form.department" /></div>
</div>
<div class="field"><label>E-Mail</label><input class="input" v-model="form.email" type="email" /></div>
<div class="grid2">
<div class="field"><label>Telefon</label><input class="input" v-model="form.phone" /></div>
<div class="field"><label>Mobil</label><input class="input" v-model="form.mobile" /></div>
</div>
<div class="grid2">
<div class="field" v-if="!editing && (auth.isResellerAdmin || auth.isPlatformAdmin)">
<label>Firma</label>
<select class="input" v-model="form.company">
@ -287,7 +454,78 @@ onMounted(load)
<option v-for="l in availableLocations" :key="l['@id']" :value="l['@id']">{{ l.name }}</option>
</select>
</div>
<div class="field"><label>Slug (URL)</label><input class="input" v-model="form.slug" :placeholder="slugify(form.firstName + '-' + form.lastName)" /></div>
<div class="field"><label>Über mich</label><textarea class="input" rows="3" v-model="form.bio"></textarea></div>
</div>
<!-- Kontakt -->
<div v-show="activeTab === 'kontakt'" class="panel">
<div class="grid2">
<div class="field"><label>E-Mail (geschäftlich)</label><input class="input" type="email" v-model="form.email" /></div>
<div class="field"><label>E-Mail (privat)</label><input class="input" type="email" v-model="form.emailPrivate" /></div>
</div>
<div class="field"><label>Telefon</label><PhoneInput v-model="form.phone" /></div>
<div class="field"><label>Mobil</label><PhoneInput v-model="form.mobile" placeholder="151 23456789" /></div>
<div class="grid2">
<div class="field"><label>Fax</label><PhoneInput v-model="form.fax" /></div>
<div class="field"><label>Zentrale / Durchwahl</label><PhoneInput v-model="form.phoneCentral" /></div>
</div>
<div class="field"><label>Website</label><input class="input" v-model="form.website" placeholder="https://…" /></div>
</div>
<!-- Adresse -->
<div v-show="activeTab === 'adresse'" class="panel">
<div class="addr-block">
<div class="nfc__label">Geschäftsadresse</div>
<div class="grid-addr">
<div class="field s3"><label>Straße</label><input class="input" v-model="form.addressBusiness.street" /></div>
<div class="field"><label>Nr.</label><input class="input" v-model="form.addressBusiness.houseNumber" /></div>
</div>
<div class="field"><label>Adresszusatz</label><input class="input" v-model="form.addressBusiness.addressLine2" /></div>
<div class="grid-addr">
<div class="field"><label>PLZ</label><input class="input" v-model="form.addressBusiness.zip" /></div>
<div class="field s3"><label>Ort</label><input class="input" v-model="form.addressBusiness.city" /></div>
</div>
<div class="grid2">
<div class="field"><label>Bundesland / Region</label><input class="input" v-model="form.addressBusiness.state" /></div>
<div class="field"><label>Land</label><CountrySelect v-model="form.addressBusiness.country" /></div>
</div>
</div>
<div class="addr-block">
<div class="nfc__label">Privatadresse</div>
<div class="grid-addr">
<div class="field s3"><label>Straße</label><input class="input" v-model="form.addressPrivate.street" /></div>
<div class="field"><label>Nr.</label><input class="input" v-model="form.addressPrivate.houseNumber" /></div>
</div>
<div class="field"><label>Adresszusatz</label><input class="input" v-model="form.addressPrivate.addressLine2" /></div>
<div class="grid-addr">
<div class="field"><label>PLZ</label><input class="input" v-model="form.addressPrivate.zip" /></div>
<div class="field s3"><label>Ort</label><input class="input" v-model="form.addressPrivate.city" /></div>
</div>
<div class="grid2">
<div class="field"><label>Bundesland / Region</label><input class="input" v-model="form.addressPrivate.state" /></div>
<div class="field"><label>Land</label><CountrySelect v-model="form.addressPrivate.country" /></div>
</div>
</div>
</div>
<!-- Social -->
<div v-show="activeTab === 'social'" class="panel">
<div v-for="n in NETWORKS" :key="n.type" class="field">
<label>{{ n.label }}</label>
<input class="input" v-model="socialKnown[n.type]" :placeholder="n.ph" />
</div>
<div class="nfc__label" style="margin-top:1rem">Weitere Links</div>
<div v-for="(c, i) in customLinks" :key="i" class="custom-link">
<input class="input" v-model="c.label" placeholder="Bezeichnung" />
<input class="input" v-model="c.url" placeholder="https://…" />
<button type="button" class="btn btn-ghost btn-sm" @click="customLinks.splice(i, 1)"></button>
</div>
<button type="button" class="btn btn-soft btn-sm" @click="customLinks.push({ label: '', url: '' })">+ Link</button>
</div>
<!-- Zugang & NFC -->
<div v-show="activeTab === 'zugang'" class="panel">
<div v-if="editing && editing.shortCode" class="nfc">
<div class="nfc__label">NFC / QR-Link (stabil auf Tags schreiben)</div>
<div class="nfc__row">
@ -297,24 +535,14 @@ onMounted(load)
</div>
<div v-if="editing && canManageUsers" class="login-box">
<div class="nfc__label">Rechtegruppe & Login</div>
<div class="grid2">
<div class="field">
<label>Rechtegruppe</label>
<select class="input" v-model="accessForm.group">
<option v-for="g in assignableGroups" :key="g" :value="g">{{ GROUP_LABEL[g] ?? g }}</option>
</select>
</div>
<div class="field">
<label>{{ editing.login ? 'Neues Passwort (optional)' : 'Passwort (für Login)' }}</label>
<input class="input" type="password" v-model="accessForm.password" minlength="6" placeholder="leer = kein Login" />
</div>
</div>
<div class="access-actions">
<button type="button" class="btn btn-soft btn-sm" @click="saveAccess(editing)">Rechtegruppe übernehmen</button>
<button v-if="editing.login" type="button" class="btn btn-ghost btn-sm" @click="removeLogin(editing)">Login entziehen</button>
<span class="muted small">Aktuell: {{ GROUP_LABEL[groupOf(editing)] }} · Login {{ editing.login ? 'aktiv' : 'inaktiv' }}</span>
<div class="nfc__label">Login & Rechtegruppe</div>
<p class="muted small" style="margin:.2rem 0 .7rem">
Aktuell: <strong>{{ GROUP_LABEL[groupOf(editing)] }}</strong> · Login
{{ editing.login ? 'aktiv 🔑' : 'inaktiv' }}. Der Login wird separat über ein Passwort aktiviert.
</p>
<button type="button" class="btn btn-soft btn-sm" @click="openAccess">Login &amp; Rechte verwalten </button>
</div>
<p v-else-if="!editing" class="muted small">Login &amp; Rechtegruppe nach dem Anlegen vergeben.</p>
</div>
<p v-if="error" class="error">{{ error }}</p>
@ -324,12 +552,39 @@ onMounted(load)
</div>
</form>
</Modal>
<Modal v-if="showAccess && editing" :title="`Login & Rechte ${editing.firstName} ${editing.lastName}`" @close="showAccess = false">
<p class="muted" style="margin-top:0">
Mit einem <strong>Passwort</strong> wird der Login aktiviert. Ohne Passwort bleibt der Eintrag ein reines
Profil (Visitenkarte) ohne Anmeldung.
</p>
<div class="field">
<label>Rechtegruppe</label>
<select class="input" v-model="accessForm.group">
<option v-for="g in assignableGroups" :key="g" :value="g">{{ GROUP_LABEL[g] ?? g }}</option>
</select>
</div>
<div class="field">
<label>{{ editing.login ? 'Neues Passwort (optional)' : 'Passwort (aktiviert Login)' }}</label>
<input class="input" type="password" v-model="accessForm.password" minlength="6" placeholder="leer = kein Login" />
</div>
<p class="muted small">Aktuell: {{ GROUP_LABEL[groupOf(editing)] }} · Login {{ editing.login ? 'aktiv' : 'inaktiv' }}</p>
<div class="actions">
<button v-if="editing.login" type="button" class="btn btn-ghost" @click="removeLogin(editing)">Login entziehen</button>
<span style="flex:1"></span>
<button type="button" class="btn btn-ghost" @click="showAccess = false">Schließen</button>
<button type="button" class="btn btn-primary" @click="saveAccess(editing)">Speichern</button>
</div>
</Modal>
<EmployeeImport v-if="showImport" @close="showImport = false" @done="load" />
</section>
</template>
<style scoped>
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; }
.page-head .muted { margin: .2rem 0 0; }
.head-actions { display: flex; gap: .6rem; flex-shrink: 0; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.2rem; border-bottom: 1px solid var(--line); }
.search { flex: 1; max-width: 420px; }
.tbl { width: 100%; border-collapse: collapse; }
@ -337,19 +592,41 @@ onMounted(load)
.tbl td { padding: .8rem 1.2rem; border-bottom: 1px solid #f4f4f4; vertical-align: middle; }
.tbl tr:last-child td { border-bottom: none; }
.who { display: flex; align-items: center; gap: .8rem; }
.avatar { width: 38px; height: 38px; border-radius: 50%; background: var(--psc-orange-soft); color: var(--psc-orange-dark); display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: .85rem; }
.avatar { width: 38px; height: 38px; border-radius: 50%; background: var(--psc-orange-soft); color: var(--psc-orange-dark); display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: .85rem; overflow: hidden; flex-shrink: 0; }
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.small { font-size: .8rem; }
.right { text-align: right; white-space: nowrap; }
.right .btn { margin-left: .3rem; }
.empty { text-align: center; color: var(--muted); padding: 2rem; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
.grid2 > * { min-width: 0; }
.grid-addr { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
.grid-addr > * { min-width: 0; }
.grid-addr .s3 { grid-column: span 1; }
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1.2rem; }
.error { color: var(--danger); font-size: .88rem; }
.nfc { background: var(--psc-orange-soft-2); border: 1px solid var(--psc-border); border-radius: var(--radius-sm); padding: .7rem .9rem; margin-top: .4rem; }
.tabs { display: flex; gap: .3rem; border-bottom: 1px solid var(--line); margin-bottom: 1rem; flex-wrap: wrap; }
.tab { background: none; border: none; padding: .6rem .9rem; font-weight: 600; font-size: .9rem; color: var(--muted); cursor: pointer; border-bottom: 2px solid transparent; font-family: var(--font); }
.tab:hover { color: var(--text); }
.tab.active { color: var(--psc-orange-dark); border-bottom-color: var(--psc-orange); }
.panel { min-height: 260px; }
.panel .field { margin-bottom: .8rem; }
.photo-row { display: flex; gap: 1.2rem; align-items: center; margin-bottom: 1rem; }
.photo-preview { width: 84px; height: 84px; border-radius: 50%; border: 1px dashed var(--line); background: #fafafa; display: flex; align-items: center; justify-content: center; overflow: hidden; flex-shrink: 0; }
.photo-preview img { width: 100%; height: 100%; object-fit: cover; }
.photo-actions { display: flex; flex-direction: column; gap: .4rem; align-items: flex-start; }
.photo-actions .muted { margin: .1rem 0 0; }
.addr-block { margin-bottom: 1.2rem; }
.custom-link { display: grid; grid-template-columns: 1fr 1.6fr auto; gap: .5rem; margin-bottom: .5rem; align-items: center; }
.nfc { background: var(--psc-orange-soft-2); border: 1px solid var(--psc-border); border-radius: var(--radius-sm); padding: .7rem .9rem; margin-top: .8rem; }
.nfc__label { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 700; margin-bottom: .4rem; }
.nfc__row { display: flex; align-items: center; justify-content: space-between; gap: .6rem; }
.nfc__row code { font-size: .82rem; word-break: break-all; }
.login-box { background: #f7f7f8; border: 1px solid var(--line); border-radius: var(--radius-sm); padding: .7rem .9rem; margin-top: .6rem; }
.login-box { background: #f7f7f8; border: 1px solid var(--line); border-radius: var(--radius-sm); padding: .7rem .9rem; margin-top: .8rem; }
.login-box .grid2 { margin-bottom: .4rem; }
.access-actions { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.badge-role { background: var(--psc-orange-soft); color: var(--psc-orange-dark); }

View File

@ -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('')
@ -18,8 +20,11 @@ async function submit() {
try {
await auth.login(email.value, password.value)
router.push((route.query.redirect as string) ?? '/app')
} catch {
error.value = 'Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen.'
} catch (e: unknown) {
const err = e as { response?: { status?: number; data?: { message?: string } } }
error.value = err.response?.status === 403 && err.response.data?.message
? err.response.data.message
: 'Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen.'
} finally {
loading.value = false
}
@ -30,10 +35,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>

View File

@ -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,
},
},
},