Fundament: Symfony+API-Platform-Backend & Vue-SPA (Phase 0–2)

Stack & Setup
- Dockerisierte Dev-Umgebung (PHP 8.4-FPM, Nginx, MariaDB 11.4)
- Symfony 7.4 + API Platform 4.3, Doctrine ORM, LexikJWT, Messenger
- Vue 3 + TS (Vite), Vue Router, Pinia, Axios

Kern-Domäne & Auth
- Entitäten: User, PlatformPlan, Reseller, Company, Domain, Location,
  Employee, ContactLink (UUIDv7)
- JWT-Login (/api/login), Rollen-Hierarchie, /api/me
- Mandantentrennung via API-Platform-Query-Extension (Lesen) +
  TenantStampProcessor (Schreiben)

Öffentliche Profile (SSR)
- Profil-Landingpage, vCard-Download, QR-Code im Marken-Look
- Stabiler NFC/QR-Kurz-Link /t/{code} -> Redirect aufs aktuelle Profil
- Firmenspezifisches Branding (Farben/Logo) auf der Profilseite

Verwaltungsoberfläche (SPA)
- Brand-Look (dunkle Sidebar), rollenbasierte Navigation
- Dashboard, Reseller (+Provisioning), Firmen, Mitarbeiter, Standorte,
  Domains, Design/Branding mit Live-Vorschau

Konzept & Doku: docs/KONZEPT.md (inkl. Wallet/Sync §12), README.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-05-31 11:12:53 +02:00
parent 03407b76e3
commit ebaf509a2f
114 changed files with 17693 additions and 2 deletions

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# Abhängigkeiten
/backend/vendor/
/frontend/node_modules/
/frontend/dist/
# Symfony
/backend/var/
/backend/.env.local
/backend/.env.*.local
/backend/config/jwt/*.pem
# Umgebung
.env.local
*.log
# IDE/OS
.idea/
.vscode/
.DS_Store
# Playwright-Artefakte
/.playwright-mcp/
backend/.playwright-mcp/

102
README.md Normal file
View File

@ -0,0 +1,102 @@
# vcard4reseller
White-Label-Plattform für digitale Visitenkarten (Reseller → Firmenkunde → Mitarbeiter).
Konzept & Datenmodell: siehe [`docs/KONZEPT.md`](docs/KONZEPT.md).
## Stack
- **Backend:** Symfony 7.4 + API Platform 4.3, Doctrine ORM, LexikJWT, Messenger
- **Frontend:** Vue 3 + TypeScript (Vite), Vue Router, Pinia, Axios
- **DB:** MariaDB 11.4
- **Dev-Umgebung:** Docker (PHP-FPM, Nginx, MariaDB)
## Verzeichnisse
```
backend/ Symfony-API (JSON/JSON-LD)
frontend/ Vue-3-SPA (Dashboards)
docker/ Dockerfile (PHP) + Nginx-Config
docs/ Konzept & Datenmodell
```
## Schnellstart
Voraussetzung: Docker + Node 25.
```bash
# 1) Backend-Stack starten (PHP, Nginx, MariaDB)
export UID=$(id -u) GID=$(id -g)
docker compose up -d php nginx mariadb
# 2) JWT-Schlüssel erzeugen (einmalig)
docker compose exec php php bin/console lexik:jwt:generate-keypair --skip-if-exists
# 3) Frontend (Dev-Server mit API-Proxy auf :8080)
cd frontend && npm install && npm run dev
```
- API: http://localhost:8080/api
- Frontend: http://localhost:5173
- MariaDB: localhost:3306 (DB `vcard4reseller`, User `app` / `app`)
### Nützliche Befehle
```bash
# Symfony-Console im Container
docker compose exec php php bin/console <cmd>
# Migration erstellen / ausführen
docker compose exec php php bin/console doctrine:migrations:diff
docker compose exec php php bin/console doctrine:migrations:migrate
```
## Status
**Phase 0 (Setup) + Phase 1 (Kern-Domäne & Auth) abgeschlossen.**
Phase 1 umfasst: Entitäten (User, PlatformPlan, Reseller, Company, Domain,
Location, Employee, ContactLink), JWT-Login (`POST /api/login`),
Rollen-Hierarchie und automatische Mandantentrennung über eine
API-Platform-Query-Extension (`src/Doctrine/TenantExtension.php`).
Demo-Daten via `docker compose exec php php bin/console app:seed`:
| Rolle | E-Mail | Passwort |
|-------|--------|----------|
| Plattform-Admin | admin@vcard4reseller.de | admin |
| Reseller-Admin | reseller@demo.de | reseller |
| Firmen-Admin | firma@muster.de | firma |
**Phase 2 (öffentliche Profile) läuft.** Bereits umgesetzt: serverseitig
gerenderte Profilseite, vCard-Download und QR-Code im Marken-Look von
vcard4reseller.de (Design-Tokens in `backend/public/assets/brand.css`,
Referenz in `docs/design-reference/`).
Öffentliche Endpunkte (kein Login):
- `GET /p/{firma}/{mitarbeiter}` — Profil-Landingpage (Twig/SSR)
- `GET /p/{firma}/{mitarbeiter}/vcard.vcf` — vCard-Download
- `GET /p/{firma}/{mitarbeiter}/qr.png` — QR-Code (codiert die stabile Kurz-URL)
- `GET /t/{code}` — stabiler NFC/QR-Kurz-Link → Redirect aufs aktuelle Profil
Beispiel (nach `app:seed`): http://localhost:8080/p/muster/erika-mustermann
**Verwaltungsoberfläche (Vue-SPA) läuft.** Echtes Login gegen `/api/login`,
rollenbasierte App-Shell (dunkle Sidebar + Topbar im Brand-Look) und live an
die API gebundene Screens:
- **Dashboard** — Kennzahlen (rollenabhängig: Reseller/Firmen/Mitarbeiter/…)
- **Reseller** — Übersicht + Anlegen inkl. Admin-Zugang (nur Plattform-Admin)
- **Firmen** — Liste + Anlegen/Löschen (Reseller)
- **Mitarbeiter** — Tabelle, Suche, Anlegen/Bearbeiten/Löschen, Link zur öffentlichen Profilseite
- **Standorte**, **Domains** — Liste + Anlegen (Domains mit A-Record-Hinweis)
- **Design** — firmenspezifisches Branding (Primärfarbe/Logo) mit Live-Vorschau
- **Einstellungen** — Platzhalter
`/api/me` liefert der SPA Rollen + Mandantenkontext.
Start: `cd frontend && npm run dev` → http://localhost:5173 (Login z. B.
reseller@demo.de / reseller).
Nächster Schritt: Wallet-Pässe (Konzept §12, Google zuerst), E-Mail-Signaturen,
Druckdaten. Siehe `docs/KONZEPT.md` §9.

17
backend/.editorconfig Normal file
View File

@ -0,0 +1,17 @@
# editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[{compose.yaml,compose.*.yaml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false

54
backend/.env Normal file
View File

@ -0,0 +1,54 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
###> symfony/routing ###
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="mysql://app:app@mariadb:3306/vcard4reseller?serverVersion=11.4.0-MariaDB&charset=utf8mb4"
###< doctrine/doctrine-bundle ###
###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=d75959918d9ccc5c89c62edbd6e6c6af82d6e2a3d303c53a6f3328e94a05b60a
###< lexik/jwt-authentication-bundle ###
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###

4
backend/.env.dev Normal file
View File

@ -0,0 +1,4 @@
###> symfony/framework-bundle ###
APP_SECRET=6a99dc78ab52a33deba7f8bd986720dc
###< symfony/framework-bundle ###

14
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###

21
backend/bin/console Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};

View File

@ -0,0 +1,7 @@
services:
###> doctrine/doctrine-bundle ###
database:
ports:
- "5432"
###< doctrine/doctrine-bundle ###

25
backend/compose.yaml Normal file
View File

@ -0,0 +1,25 @@
services:
###> doctrine/doctrine-bundle ###
database:
image: postgres:${POSTGRES_VERSION:-16}-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
# You should definitely change the password in production
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
POSTGRES_USER: ${POSTGRES_USER:-app}
healthcheck:
test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- database_data:/var/lib/postgresql/data:rw
# You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
# - ./docker/db/data:/var/lib/postgresql/data:rw
###< doctrine/doctrine-bundle ###
volumes:
###> doctrine/doctrine-bundle ###
database_data:
###< doctrine/doctrine-bundle ###

90
backend/composer.json Normal file
View File

@ -0,0 +1,90 @@
{
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/doctrine-orm": "^4.3",
"api-platform/symfony": "^4.3",
"doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6",
"endroid/qr-code": "^6.1",
"lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6",
"phpdocumentor/reflection-docblock": "^6.0",
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "7.4.*",
"symfony/console": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/expression-language": "7.4.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "7.4.*",
"symfony/messenger": "7.4.*",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
"symfony/runtime": "7.4.*",
"symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/uid": "7.4.*",
"symfony/validator": "7.4.*",
"symfony/yaml": "7.4.*"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"bump-after-update": true,
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.4.*"
}
},
"require-dev": {
"symfony/maker-bundle": "^1.67"
}
}

8273
backend/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true],
];

View File

@ -0,0 +1,7 @@
api_platform:
title: Hello API Platform
version: 1.0.0
defaults:
stateless: true
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']

View File

@ -0,0 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null

View File

@ -0,0 +1,46 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
orm:
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@ -0,0 +1,6 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View File

@ -0,0 +1,15 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
#esi: true
#fragments: true
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

View File

@ -0,0 +1,4 @@
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'

View File

@ -0,0 +1,22 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
# failed: 'doctrine://default?queue_name=failed'
sync: 'sync://'
routing:
# Route your messages to the transports
# 'App\Message\YourMessage': async
# when@test:
# framework:
# messenger:
# transports:
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
# async: 'in-memory://'

View File

@ -0,0 +1,10 @@
nelmio_cors:
defaults:
origin_regex: true
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
expose_headers: ['Link']
max_age: 3600
paths:
'^/': null

View File

@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

View File

@ -0,0 +1,10 @@
framework:
router:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
default_uri: '%env(DEFAULT_URI)%'
when@prod:
framework:
router:
strict_requirements: null

View File

@ -0,0 +1,55 @@
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_profiler|_wdt|assets|build)/
security: false
# Öffentlicher Login-Endpunkt: tauscht E-Mail/Passwort gegen ein JWT
login:
pattern: ^/api/login$
stateless: true
json_login:
check_path: /api/login
username_path: email
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
# Geschützte API: JWT im Authorization-Header
api:
pattern: ^/api
stateless: true
provider: app_user_provider
jwt: ~
main:
lazy: true
provider: app_user_provider
access_control:
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
role_hierarchy:
ROLE_PLATFORM_ADMIN: [ROLE_RESELLER_ADMIN, ROLE_COMPANY_ADMIN, ROLE_EMPLOYEE]
ROLE_RESELLER_ADMIN: [ROLE_COMPANY_ADMIN]
ROLE_COMPANY_ADMIN: [ROLE_EMPLOYEE]
when@test:
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4
time_cost: 3
memory_cost: 10

View File

@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View File

@ -0,0 +1,11 @@
framework:
validation:
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []
when@test:
framework:
validation:
not_compromised_password: false

View File

@ -0,0 +1,5 @@
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}

1817
backend/config/reference.php Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
# This file is the entry point to configure the routes of your app.
# Methods with the #[Route] attribute are automatically imported.
# See also https://symfony.com/doc/current/routing.html
# To list all registered routes, run the following command:
# bin/console debug:router
controllers:
resource: routing.controllers

View File

@ -0,0 +1,4 @@
api_platform:
resource: .
type: api_platform
prefix: /api

View File

@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error

View File

@ -0,0 +1,3 @@
_security_logout:
resource: security.route_loader.logout
type: service

View File

@ -0,0 +1,23 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# See also https://symfony.com/doc/current/service_container/import.html
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

0
backend/migrations/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,65 @@
<?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 Version20260530191712 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('CREATE TABLE company (id BINARY(16) NOT NULL, name VARCHAR(150) NOT NULL, slug VARCHAR(100) NOT NULL, status VARCHAR(20) NOT NULL, self_edit_enabled TINYINT NOT NULL, branding_config JSON NOT NULL, created_at DATETIME NOT NULL, reseller_id BINARY(16) NOT NULL, INDEX IDX_4FBF094F91E6A19D (reseller_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE contact_link (id BINARY(16) NOT NULL, type VARCHAR(40) NOT NULL, url VARCHAR(500) NOT NULL, label VARCHAR(120) DEFAULT NULL, position INT NOT NULL, employee_id BINARY(16) NOT NULL, INDEX IDX_1E531B0E8C03F15C (employee_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE domain (id BINARY(16) NOT NULL, hostname VARCHAR(255) NOT NULL, type VARCHAR(20) NOT NULL, status VARCHAR(20) NOT NULL, tls_status VARCHAR(20) NOT NULL, verification_checked_at DATETIME DEFAULT NULL, company_id BINARY(16) NOT NULL, UNIQUE INDEX UNIQ_A7A91E0BE551C011 (hostname), INDEX IDX_A7A91E0B979B1AD6 (company_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE employee (id BINARY(16) NOT NULL, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, slug VARCHAR(120) NOT NULL, title VARCHAR(150) DEFAULT NULL, position VARCHAR(150) DEFAULT NULL, department VARCHAR(150) DEFAULT NULL, email VARCHAR(180) DEFAULT NULL, phone VARCHAR(50) DEFAULT NULL, mobile VARCHAR(50) DEFAULT NULL, photo_path VARCHAR(255) DEFAULT NULL, bio LONGTEXT DEFAULT NULL, status VARCHAR(20) NOT NULL, self_edit_allowed TINYINT NOT NULL, editable_fields JSON NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, company_id BINARY(16) NOT NULL, location_id BINARY(16) DEFAULT NULL, INDEX IDX_5D9F75A1979B1AD6 (company_id), INDEX IDX_5D9F75A164D218E (location_id), UNIQUE INDEX uniq_employee_company_slug (company_id, slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE location (id BINARY(16) NOT NULL, name VARCHAR(150) NOT NULL, street VARCHAR(255) DEFAULT NULL, postal_code VARCHAR(20) DEFAULT NULL, city VARCHAR(120) DEFAULT NULL, country VARCHAR(2) DEFAULT NULL, phone VARCHAR(50) DEFAULT NULL, email VARCHAR(180) DEFAULT NULL, branding_override JSON NOT NULL, company_id BINARY(16) NOT NULL, INDEX IDX_5E9E89CB979B1AD6 (company_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE platform_plan (id BINARY(16) NOT NULL, name VARCHAR(100) NOT NULL, slug VARCHAR(100) NOT NULL, price_per_month INT NOT NULL, max_profiles INT NOT NULL, max_companies INT NOT NULL, features JSON NOT NULL, UNIQUE INDEX UNIQ_59523C51989D9B62 (slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE reseller (id BINARY(16) NOT NULL, name VARCHAR(150) NOT NULL, slug VARCHAR(100) NOT NULL, primary_domain VARCHAR(255) DEFAULT NULL, status VARCHAR(20) NOT NULL, branding_config JSON NOT NULL, created_at DATETIME NOT NULL, platform_plan_id BINARY(16) DEFAULT NULL, UNIQUE INDEX UNIQ_18015899989D9B62 (slug), INDEX IDX_18015899FDA9C8C9 (platform_plan_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE `user` (id BINARY(16) NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, last_login_at DATETIME DEFAULT NULL, reseller_id BINARY(16) DEFAULT NULL, company_id BINARY(16) DEFAULT NULL, employee_id BINARY(16) DEFAULT NULL, INDEX IDX_8D93D64991E6A19D (reseller_id), INDEX IDX_8D93D649979B1AD6 (company_id), UNIQUE INDEX UNIQ_8D93D6498C03F15C (employee_id), UNIQUE INDEX uniq_user_email (email), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE company ADD CONSTRAINT FK_4FBF094F91E6A19D FOREIGN KEY (reseller_id) REFERENCES reseller (id)');
$this->addSql('ALTER TABLE contact_link ADD CONSTRAINT FK_1E531B0E8C03F15C FOREIGN KEY (employee_id) REFERENCES employee (id)');
$this->addSql('ALTER TABLE domain ADD CONSTRAINT FK_A7A91E0B979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
$this->addSql('ALTER TABLE employee ADD CONSTRAINT FK_5D9F75A1979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
$this->addSql('ALTER TABLE employee ADD CONSTRAINT FK_5D9F75A164D218E FOREIGN KEY (location_id) REFERENCES location (id)');
$this->addSql('ALTER TABLE location ADD CONSTRAINT FK_5E9E89CB979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
$this->addSql('ALTER TABLE reseller ADD CONSTRAINT FK_18015899FDA9C8C9 FOREIGN KEY (platform_plan_id) REFERENCES platform_plan (id)');
$this->addSql('ALTER TABLE `user` ADD CONSTRAINT FK_8D93D64991E6A19D FOREIGN KEY (reseller_id) REFERENCES reseller (id)');
$this->addSql('ALTER TABLE `user` ADD CONSTRAINT FK_8D93D649979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
$this->addSql('ALTER TABLE `user` ADD CONSTRAINT FK_8D93D6498C03F15C FOREIGN KEY (employee_id) REFERENCES employee (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE company DROP FOREIGN KEY FK_4FBF094F91E6A19D');
$this->addSql('ALTER TABLE contact_link DROP FOREIGN KEY FK_1E531B0E8C03F15C');
$this->addSql('ALTER TABLE domain DROP FOREIGN KEY FK_A7A91E0B979B1AD6');
$this->addSql('ALTER TABLE employee DROP FOREIGN KEY FK_5D9F75A1979B1AD6');
$this->addSql('ALTER TABLE employee DROP FOREIGN KEY FK_5D9F75A164D218E');
$this->addSql('ALTER TABLE location DROP FOREIGN KEY FK_5E9E89CB979B1AD6');
$this->addSql('ALTER TABLE reseller DROP FOREIGN KEY FK_18015899FDA9C8C9');
$this->addSql('ALTER TABLE `user` DROP FOREIGN KEY FK_8D93D64991E6A19D');
$this->addSql('ALTER TABLE `user` DROP FOREIGN KEY FK_8D93D649979B1AD6');
$this->addSql('ALTER TABLE `user` DROP FOREIGN KEY FK_8D93D6498C03F15C');
$this->addSql('DROP TABLE company');
$this->addSql('DROP TABLE contact_link');
$this->addSql('DROP TABLE domain');
$this->addSql('DROP TABLE employee');
$this->addSql('DROP TABLE location');
$this->addSql('DROP TABLE platform_plan');
$this->addSql('DROP TABLE reseller');
$this->addSql('DROP TABLE `user`');
}
}

View File

@ -0,0 +1,33 @@
<?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 Version20260531085615 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 short_code VARCHAR(16) DEFAULT NULL');
$this->addSql('CREATE UNIQUE INDEX uniq_employee_shortcode ON employee (short_code)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP INDEX uniq_employee_shortcode ON employee');
$this->addSql('ALTER TABLE employee DROP short_code');
}
}

View File

@ -0,0 +1,82 @@
/* vcard4reseller — Marken-Design-System (orientiert an vcard4reseller.de) */
:root {
--psc-orange: #f58220;
--psc-orange-dark: #d96500;
--psc-orange-soft: #fff2e7;
--psc-orange-soft-2: #fff8f1;
--psc-border: #f4d4bb;
--text: #343434;
--dark: #222222;
--muted: #6f6f6f;
--bg: #f7f7f7;
--white: #ffffff;
--success: #238636;
--shadow: 0 18px 45px rgba(30, 30, 30, 0.10);
--shadow-sm: 0 6px 18px rgba(30, 30, 30, 0.08);
--radius: 22px;
--radius-sm: 14px;
--font: Arial, Helvetica, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--font);
color: var(--text);
background: var(--bg);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { color: var(--dark); font-weight: 700; line-height: 1.2; margin: 0 0 .4em; }
a { color: var(--psc-orange-dark); text-decoration: none; }
a:hover { text-decoration: underline; }
/* Marken-Logo (Wortmarke) */
.brand-logo {
display: inline-flex;
align-items: center;
font-weight: 700;
font-size: 1.15rem;
color: var(--dark);
letter-spacing: -0.01em;
}
.brand-logo .tag {
background: var(--psc-orange);
color: var(--white);
padding: 2px 10px;
border-radius: 999px;
margin-left: 4px;
}
/* Pill-Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: .5rem;
padding: .7rem 1.4rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: .95rem;
border: 1px solid transparent;
cursor: pointer;
transition: transform .08s ease, box-shadow .15s ease, background .15s ease;
}
.btn:hover { text-decoration: none; transform: translateY(-1px); }
.btn-primary { background: var(--psc-orange); color: var(--white); box-shadow: var(--shadow-sm); }
.btn-primary:hover { background: var(--psc-orange-dark); color: var(--white); }
.btn-soft { background: var(--psc-orange-soft); color: var(--psc-orange-dark); }
.btn-soft:hover { background: var(--psc-border); color: var(--psc-orange-dark); }
.btn-ghost { background: var(--white); color: var(--dark); border-color: #e7e7e7; }
/* Karte */
.card {
background: var(--white);
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid #f0f0f0;
}
.muted { color: var(--muted); }

9
backend/public/index.php Normal file
View File

@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return static function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

0
backend/src/ApiResource/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,37 @@
<?php
namespace App\Command;
use App\Repository\EmployeeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:backfill-shortcodes', description: 'Vergibt fehlende NFC/QR-Kurz-Codes an bestehende Mitarbeiter.')]
final class BackfillShortCodesCommand extends Command
{
public function __construct(
private readonly EmployeeRepository $employees,
private readonly EntityManagerInterface $em,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$missing = $this->employees->findWithoutShortCode();
foreach ($missing as $employee) {
$employee->ensureShortCode();
}
$this->em->flush();
$io->success(sprintf('%d Mitarbeiter mit Kurz-Code versehen.', count($missing)));
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace App\Command;
use App\Entity\Company;
use App\Entity\ContactLink;
use App\Entity\Employee;
use App\Entity\Location;
use App\Entity\PlatformPlan;
use App\Entity\Reseller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(name: 'app:seed', description: 'Legt Demo-Daten an (Admin, Reseller, Firmen, Mitarbeiter).')]
final class SeedCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly UserPasswordHasherInterface $hasher,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (null !== $this->em->getRepository(User::class)->findOneBy(['email' => 'admin@vcard4reseller.de'])) {
$io->warning('Demo-Daten existieren bereits — übersprungen.');
return Command::SUCCESS;
}
// Plattform-Paket
$plan = (new PlatformPlan())
->setName('Professional')->setSlug('professional')
->setPricePerMonth(24900)->setMaxProfiles(500)->setMaxCompanies(8)
->setFeatures(['vcard', 'wallet', 'nfc', 'print']);
$this->em->persist($plan);
// Plattform-Admin
$admin = (new User())->setEmail('admin@vcard4reseller.de')->setRoles([User::ROLE_PLATFORM_ADMIN]);
$admin->setPassword($this->hasher->hashPassword($admin, 'admin'));
$this->em->persist($admin);
// Zwei Reseller mit je einer Firma (Beweis der Mandantentrennung)
$this->createReseller($plan, 'Demo Druckerei', 'demo', 'reseller@demo.de', 'Muster GmbH', 'muster', 'firma@muster.de', 'Erika', 'Mustermann');
$this->createReseller($plan, 'Print Studio', 'printstudio', 'reseller@printstudio.de', 'Beispiel AG', 'beispiel', 'firma@beispiel.de', 'Max', 'Beispiel');
$this->em->flush();
$io->success('Demo-Daten angelegt.');
$io->table(['Rolle', 'E-Mail', 'Passwort'], [
['Plattform-Admin', 'admin@vcard4reseller.de', 'admin'],
['Reseller-Admin', 'reseller@demo.de', 'reseller'],
['Reseller-Admin', 'reseller@printstudio.de', 'reseller'],
['Firmen-Admin', 'firma@muster.de', 'firma'],
['Firmen-Admin', 'firma@beispiel.de', 'firma'],
]);
return Command::SUCCESS;
}
private function createReseller(
PlatformPlan $plan,
string $resellerName,
string $resellerSlug,
string $resellerEmail,
string $companyName,
string $companySlug,
string $companyEmail,
string $firstName,
string $lastName,
): void {
$reseller = (new Reseller())
->setName($resellerName)->setSlug($resellerSlug)
->setPrimaryDomain($resellerSlug.'.vcard4reseller.de')
->setPlatformPlan($plan);
$this->em->persist($reseller);
$resellerAdmin = (new User())
->setEmail($resellerEmail)->setRoles([User::ROLE_RESELLER_ADMIN])->setReseller($reseller);
$resellerAdmin->setPassword($this->hasher->hashPassword($resellerAdmin, 'reseller'));
$this->em->persist($resellerAdmin);
$company = (new Company())
->setName($companyName)->setSlug($companySlug)->setReseller($reseller)->setSelfEditEnabled(true);
$this->em->persist($company);
$companyAdmin = (new User())
->setEmail($companyEmail)->setRoles([User::ROLE_COMPANY_ADMIN])
->setReseller($reseller)->setCompany($company);
$companyAdmin->setPassword($this->hasher->hashPassword($companyAdmin, 'firma'));
$this->em->persist($companyAdmin);
$location = (new Location())
->setName('Hauptsitz')->setStreet('Musterstr. 1')->setPostalCode('10115')
->setCity('Berlin')->setCountry('DE')->setCompany($company);
$this->em->persist($location);
$employee = (new Employee())
->setFirstName($firstName)->setLastName($lastName)
->setSlug(strtolower($firstName.'-'.$lastName))
->setPosition('Geschäftsführung')->setEmail(strtolower($firstName).'@'.$companySlug.'.de')
->setPhone('+49 30 1234567')->setCompany($company)->setLocation($location)
->setSelfEditAllowed(true);
$this->em->persist($employee);
$link = (new ContactLink())
->setType('linkedin')->setUrl('https://linkedin.com/in/'.strtolower($firstName))
->setPosition(0);
$employee->addContactLink($link);
$this->em->persist($link);
}
}

0
backend/src/Controller/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,77 @@
<?php
namespace App\Controller;
use App\Entity\Company;
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;
/**
* Branding (Logo/Farben) einer Firma setzen. Erlaubt Firmen-Admins die
* Pflege ihres eigenen Brandings, ohne die Firma selbst ändern zu können.
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class CompanyBrandingController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
) {
}
#[Route('/api/companies/{id}/branding', name: 'company_branding', methods: ['PATCH'])]
public function __invoke(string $id, Request $request): JsonResponse
{
$company = $this->em->getRepository(Company::class)->find(Uuid::fromString($id));
if (!$company instanceof Company) {
throw new NotFoundHttpException('Firma nicht gefunden.');
}
$this->assertAccess($company);
$data = json_decode($request->getContent(), true) ?? [];
$company->setBrandingConfig($this->sanitize($data));
$this->em->flush();
return new JsonResponse($company->getBrandingConfig());
}
private function assertAccess(Company $company): void
{
if ($this->tenant->isPlatformAdmin()) {
return;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller || $company->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Firma gehört nicht zum eigenen Mandanten.');
}
$own = $this->tenant->getCompany();
if (null !== $own && !$company->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Nur die eigene Firma darf bearbeitet werden.');
}
}
/** Nur erlaubte, validierte Felder übernehmen (verhindert CSS-Injection). */
private function sanitize(array $data): array
{
$out = [];
foreach (['primaryColor', 'primaryDark'] as $key) {
$val = (string) ($data[$key] ?? '');
if (preg_match('/^#[0-9a-fA-F]{6}$/', $val)) {
$out[$key] = $val;
}
}
$logo = (string) ($data['logoUrl'] ?? '');
if (str_starts_with($logo, 'https://') || str_starts_with($logo, '/')) {
$out['logoUrl'] = $logo;
}
return $out;
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Controller;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
/**
* Liefert Infos zum eingeloggten Nutzer für die SPA (Rollen + Mandantenkontext).
*/
final class MeController
{
public function __construct(private readonly Security $security)
{
}
#[Route('/api/me', name: 'api_me', methods: ['GET'])]
public function __invoke(): JsonResponse
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return new JsonResponse(['error' => 'Not authenticated'], 401);
}
$reseller = $user->getReseller();
$company = $user->getCompany();
return new JsonResponse([
'id' => (string) $user->getId(),
'email' => $user->getEmail(),
'roles' => $user->getRoles(),
'reseller' => $reseller ? ['id' => (string) $reseller->getId(), 'name' => $reseller->getName()] : null,
'company' => $company ? ['id' => (string) $company->getId(), 'name' => $company->getName()] : null,
]);
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Controller;
use App\Entity\Employee;
use App\Repository\EmployeeRepository;
use App\Service\VCardBuilder;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Encoding\Encoding;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\Writer\PngWriter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Öffentliche, serverseitig gerenderte Profilseiten (SSR, SEO).
* Nicht mandantengefiltert und ohne Auth bewusst öffentlich.
*/
final class PublicProfileController extends AbstractController
{
public function __construct(private readonly EmployeeRepository $employees)
{
}
#[Route('/p/{companySlug}/{slug}', name: 'public_profile', methods: ['GET'])]
public function show(string $companySlug, string $slug): Response
{
$employee = $this->resolve($companySlug, $slug);
return $this->render('public/profile.html.twig', [
'e' => $employee,
'profileUrl' => $this->profileUrl($employee),
'shareUrl' => $this->shareUrl($employee),
]);
}
#[Route('/p/{companySlug}/{slug}/vcard.vcf', name: 'public_profile_vcard', methods: ['GET'])]
public function vcard(string $companySlug, string $slug, VCardBuilder $builder): Response
{
$employee = $this->resolve($companySlug, $slug);
return new Response($builder->build($employee), 200, [
'Content-Type' => 'text/vcard; charset=utf-8',
'Content-Disposition' => sprintf('attachment; filename="%s.vcf"', $employee->getSlug()),
]);
}
#[Route('/p/{companySlug}/{slug}/qr.png', name: 'public_profile_qr', methods: ['GET'])]
public function qr(string $companySlug, string $slug): Response
{
$employee = $this->resolve($companySlug, $slug);
$result = (new Builder(
writer: new PngWriter(),
data: $this->shareUrl($employee),
encoding: new Encoding('UTF-8'),
errorCorrectionLevel: ErrorCorrectionLevel::Medium,
size: 320,
margin: 12,
))->build();
return new Response($result->getString(), 200, ['Content-Type' => $result->getMimeType()]);
}
private function resolve(string $companySlug, string $slug): Employee
{
$employee = $this->employees->findPublic($companySlug, $slug);
if (null === $employee) {
throw $this->createNotFoundException('Profil nicht gefunden.');
}
return $employee;
}
private function profileUrl(Employee $employee): string
{
return $this->generateUrl('public_profile', [
'companySlug' => $employee->getCompany()->getSlug(),
'slug' => $employee->getSlug(),
], UrlGeneratorInterface::ABSOLUTE_URL);
}
/**
* Stabile Teilen-URL: bevorzugt den Kurz-Code (/t/{code}, NFC/QR-tauglich),
* fällt sonst auf die Profil-URL zurück.
*/
private function shareUrl(Employee $employee): string
{
if (null !== $employee->getShortCode()) {
return $this->generateUrl('short_link', ['code' => $employee->getShortCode()], UrlGeneratorInterface::ABSOLUTE_URL);
}
return $this->profileUrl($employee);
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Controller;
use App\Entity\PlatformPlan;
use App\Entity\Reseller;
use App\Entity\User;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Legt einen Reseller an optional zusammen mit seinem Admin-Benutzer,
* damit sich der Reseller direkt einloggen kann. Nur für Plattform-Admins.
*/
#[IsGranted('ROLE_PLATFORM_ADMIN')]
final class ResellerProvisioningController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly UserPasswordHasherInterface $hasher,
) {
}
#[Route('/api/platform/provision-reseller', name: 'platform_provision_reseller', methods: ['POST'])]
public function __invoke(Request $request): JsonResponse
{
$d = json_decode($request->getContent(), true) ?? [];
$name = trim((string) ($d['name'] ?? ''));
$slug = trim((string) ($d['slug'] ?? ''));
if ('' === $name || '' === $slug) {
return new JsonResponse(['error' => 'name und slug sind erforderlich.'], 422);
}
$reseller = (new Reseller())->setName($name)->setSlug($slug);
if (!empty($d['primaryDomain'])) {
$reseller->setPrimaryDomain((string) $d['primaryDomain']);
}
if (!empty($d['planId'])) {
$plan = $this->em->getRepository(PlatformPlan::class)->find($d['planId']);
if ($plan instanceof PlatformPlan) {
$reseller->setPlatformPlan($plan);
}
}
$adminEmail = trim((string) ($d['adminEmail'] ?? ''));
$adminPassword = (string) ($d['adminPassword'] ?? '');
$admin = null;
if ('' !== $adminEmail && '' !== $adminPassword) {
$admin = (new User())
->setEmail($adminEmail)
->setRoles([User::ROLE_RESELLER_ADMIN])
->setReseller($reseller);
$admin->setPassword($this->hasher->hashPassword($admin, $adminPassword));
}
try {
$this->em->persist($reseller);
if ($admin) {
$this->em->persist($admin);
}
$this->em->flush();
} catch (UniqueConstraintViolationException) {
return new JsonResponse(['error' => 'Slug oder Admin-E-Mail bereits vergeben.'], 422);
}
return new JsonResponse([
'id' => (string) $reseller->getId(),
'name' => $reseller->getName(),
'slug' => $reseller->getSlug(),
'adminCreated' => null !== $admin,
], 201);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
final class SecurityController
{
/**
* Login-Endpunkt. Die eigentliche Authentifizierung erledigt der
* json_login-Authenticator (LexikJWT) bereits vor dem Controller
* diese Methode wird im Erfolgsfall nie erreicht.
*/
#[Route('/api/login', name: 'api_login', methods: ['POST'])]
public function login(): JsonResponse
{
return new JsonResponse(['error' => 'Authentication failed'], 401);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Controller;
use App\Repository\EmployeeRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Stabiler Kurz-Link für NFC-Tags / gedruckte QR-Codes.
* Leitet anhand des unveränderlichen Codes immer auf das aktuelle Profil um.
*/
final class ShortLinkController extends AbstractController
{
public function __construct(private readonly EmployeeRepository $employees)
{
}
#[Route('/t/{code}', name: 'short_link', methods: ['GET'])]
public function __invoke(string $code): Response
{
$employee = $this->employees->findByShortCode($code);
if (null === $employee) {
throw $this->createNotFoundException('Unbekannter Code.');
}
return new RedirectResponse($this->generateUrl('public_profile', [
'companySlug' => $employee->getCompany()->getSlug(),
'slug' => $employee->getSlug(),
]), 302);
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Company;
use App\Entity\ContactLink;
use App\Entity\Domain;
use App\Entity\Employee;
use App\Entity\Location;
use App\Security\TenantContext;
use Doctrine\ORM\QueryBuilder;
/**
* Schränkt jede API-Query automatisch auf den Mandanten des eingeloggten
* Nutzers ein (Reseller-, ggf. zusätzlich Company-Ebene).
* Plattform-Admins werden nicht eingeschränkt.
*/
final class TenantExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(private readonly TenantContext $tenant)
{
}
public function applyToCollection(
QueryBuilder $qb,
QueryNameGeneratorInterface $nameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
$this->apply($qb, $resourceClass);
}
/** @param array<string, mixed> $identifiers */
public function applyToItem(
QueryBuilder $qb,
QueryNameGeneratorInterface $nameGenerator,
string $resourceClass,
array $identifiers,
?Operation $operation = null,
array $context = [],
): void {
$this->apply($qb, $resourceClass);
}
private function apply(QueryBuilder $qb, string $resourceClass): void
{
// Plattform-Admins sehen alles
if ($this->tenant->isPlatformAdmin()) {
return;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller) {
// Kein Mandantenkontext → nichts ausliefern (statt Datenleck)
$qb->andWhere('1 = 0');
return;
}
$alias = $qb->getRootAliases()[0];
// Join-Pfad zur Reseller-/Company-Spalte je nach Entität
[$companyAlias, $resellerExpr] = match ($resourceClass) {
Company::class => [$alias, "$alias.reseller"],
Location::class, Domain::class => [
$this->joinOnce($qb, "$alias.company", 'tc'),
'tc.reseller',
],
Employee::class => [
$this->joinOnce($qb, "$alias.company", 'tc'),
'tc.reseller',
],
ContactLink::class => (function () use ($qb, $alias) {
$this->joinOnce($qb, "$alias.employee", 'te');
$c = $this->joinOnce($qb, 'te.company', 'tc');
return [$c, 'tc.reseller'];
})(),
default => [null, null],
};
if (null === $resellerExpr) {
return; // nicht mandantengebunden (z. B. PlatformPlan)
}
$qb->andWhere("$resellerExpr = :tenant_reseller")
->setParameter('tenant_reseller', $reseller->getId(), 'uuid');
// Firmen-Admins/Mitarbeiter zusätzlich auf ihre Company einschränken
$company = $this->tenant->getCompany();
if (null !== $company) {
if (Company::class === $resourceClass) {
$qb->andWhere("$alias = :tenant_company");
} else {
$qb->andWhere("$companyAlias = :tenant_company");
}
$qb->setParameter('tenant_company', $company->getId(), 'uuid');
}
}
/** Fügt einen Join nur hinzu, falls der Alias noch nicht existiert. */
private function joinOnce(QueryBuilder $qb, string $path, string $alias): string
{
foreach ($qb->getDQLPart('join') as $joins) {
foreach ($joins as $join) {
if ($join->getAlias() === $alias) {
return $alias;
}
}
}
$qb->join($path, $alias);
return $alias;
}
}

0
backend/src/Entity/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,182 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\CompanyRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Firmenkunde eines Resellers.
*/
#[ORM\Entity(repositoryClass: CompanyRepository::class)]
#[ApiResource(
operations: [
// Lesen: auch Firmen-Admins (Tenant-Extension beschränkt auf die eigene Firma)
new GetCollection(security: "is_granted('ROLE_COMPANY_ADMIN')"),
new Get(security: "is_granted('ROLE_COMPANY_ADMIN')"),
// Schreiben: nur Reseller-Admins (und Plattform-Admins via Hierarchie)
new Post(security: "is_granted('ROLE_RESELLER_ADMIN')"),
new Patch(security: "is_granted('ROLE_RESELLER_ADMIN')"),
new Delete(security: "is_granted('ROLE_RESELLER_ADMIN')"),
],
)]
class Company implements ResellerOwnedInterface
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 150)]
private string $name;
#[ORM\Column(length: 100)]
private string $slug;
#[ORM\Column(length: 20)]
private string $status = 'active';
/** Erlaubt Mitarbeiter-Self-Service generell (zusätzlich pro Employee freizugeben). */
#[ORM\Column]
private bool $selfEditEnabled = false;
#[ORM\Column(type: 'json')]
private array $brandingConfig = [];
#[ORM\ManyToOne(targetEntity: Reseller::class, inversedBy: 'companies')]
#[ORM\JoinColumn(nullable: false)]
private Reseller $reseller;
/** @var Collection<int, Location> */
#[ORM\OneToMany(targetEntity: Location::class, mappedBy: 'company')]
private Collection $locations;
/** @var Collection<int, Employee> */
#[ORM\OneToMany(targetEntity: Employee::class, mappedBy: 'company')]
private Collection $employees;
/** @var Collection<int, Domain> */
#[ORM\OneToMany(targetEntity: Domain::class, mappedBy: 'company')]
private Collection $domains;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->id = Uuid::v7();
$this->createdAt = new \DateTimeImmutable();
$this->locations = new ArrayCollection();
$this->employees = new ArrayCollection();
$this->domains = new ArrayCollection();
}
public function getId(): Uuid
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getSlug(): string
{
return $this->slug;
}
public function setSlug(string $slug): self
{
$this->slug = $slug;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function isSelfEditEnabled(): bool
{
return $this->selfEditEnabled;
}
public function setSelfEditEnabled(bool $selfEditEnabled): self
{
$this->selfEditEnabled = $selfEditEnabled;
return $this;
}
public function getBrandingConfig(): array
{
return $this->brandingConfig;
}
public function setBrandingConfig(array $brandingConfig): self
{
$this->brandingConfig = $brandingConfig;
return $this;
}
public function getReseller(): ?Reseller
{
return $this->reseller;
}
public function setReseller(Reseller $reseller): self
{
$this->reseller = $reseller;
return $this;
}
/** @return Collection<int, Location> */
public function getLocations(): Collection
{
return $this->locations;
}
/** @return Collection<int, Employee> */
public function getEmployees(): Collection
{
return $this->employees;
}
/** @return Collection<int, Domain> */
public function getDomains(): Collection
{
return $this->domains;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\ContactLinkRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Social-/Web-Link auf einem Mitarbeiterprofil.
*/
#[ORM\Entity(repositoryClass: ContactLinkRepository::class)]
#[ApiResource(security: "is_granted('ROLE_COMPANY_ADMIN')")]
class ContactLink implements ResellerOwnedInterface
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
/** z. B. website, linkedin, xing, instagram, email, phone. */
#[ORM\Column(length: 40)]
private string $type;
#[ORM\Column(length: 500)]
private string $url;
#[ORM\Column(length: 120, nullable: true)]
private ?string $label = null;
#[ORM\Column]
private int $position = 0;
#[ORM\ManyToOne(targetEntity: Employee::class, inversedBy: 'contactLinks')]
#[ORM\JoinColumn(nullable: false)]
private Employee $employee;
public function __construct()
{
$this->id = Uuid::v7();
}
public function getId(): Uuid
{
return $this->id;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getUrl(): string
{
return $this->url;
}
public function setUrl(string $url): self
{
$this->url = $url;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(?string $label): self
{
$this->label = $label;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): self
{
$this->position = $position;
return $this;
}
public function getEmployee(): Employee
{
return $this->employee;
}
public function setEmployee(Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getReseller(): ?Reseller
{
return $this->employee->getReseller();
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
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).
*/
#[ORM\Entity(repositoryClass: DomainRepository::class)]
#[ApiResource(security: "is_granted('ROLE_COMPANY_ADMIN')")]
class Domain implements ResellerOwnedInterface
{
public const TYPE_SUBDOMAIN = 'subdomain';
public const TYPE_CUSTOM = 'custom';
public const STATUS_PENDING = 'pending';
public const STATUS_VERIFIED = 'verified';
public const STATUS_FAILED = 'failed';
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 255, unique: true)]
private string $hostname;
#[ORM\Column(length: 20)]
private string $type = self::TYPE_SUBDOMAIN;
#[ORM\Column(length: 20)]
private string $status = self::STATUS_PENDING;
#[ORM\Column(length: 20)]
private string $tlsStatus = 'none';
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $verificationCheckedAt = null;
#[ORM\ManyToOne(targetEntity: Company::class, inversedBy: 'domains')]
#[ORM\JoinColumn(nullable: false)]
private Company $company;
public function __construct()
{
$this->id = Uuid::v7();
}
public function getId(): Uuid
{
return $this->id;
}
public function getHostname(): string
{
return $this->hostname;
}
public function setHostname(string $hostname): self
{
$this->hostname = $hostname;
return $this;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function getTlsStatus(): string
{
return $this->tlsStatus;
}
public function setTlsStatus(string $tlsStatus): self
{
$this->tlsStatus = $tlsStatus;
return $this;
}
public function getVerificationCheckedAt(): ?\DateTimeImmutable
{
return $this->verificationCheckedAt;
}
public function setVerificationCheckedAt(?\DateTimeImmutable $verificationCheckedAt): self
{
$this->verificationCheckedAt = $verificationCheckedAt;
return $this;
}
public function getCompany(): Company
{
return $this->company;
}
public function setCompany(Company $company): self
{
$this->company = $company;
return $this;
}
public function getReseller(): ?Reseller
{
return $this->company->getReseller();
}
}

View File

@ -0,0 +1,374 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\EmployeeRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Mitarbeiterprofil Single Source of Truth für alle Ausgabekanäle.
*/
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_employee_company_slug', fields: ['company', 'slug'])]
#[ORM\UniqueConstraint(name: 'uniq_employee_shortcode', fields: ['shortCode'])]
#[ApiResource(security: "is_granted('ROLE_COMPANY_ADMIN')")]
class Employee implements ResellerOwnedInterface
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 100)]
private string $firstName;
#[ORM\Column(length: 100)]
private string $lastName;
/** Öffentlicher Slug (eindeutig innerhalb der Firma). */
#[ORM\Column(length: 120)]
private string $slug;
/**
* Stabiler Kurz-Code für NFC-Tags / gedruckte QR-Codes. Verweist per
* Redirect (/t/{code}) immer auf das aktuelle Profil bleibt also gleich,
* auch wenn sich der Slug ändert. Generiert beim Anlegen, nicht editierbar.
*/
#[ORM\Column(length: 16, nullable: true)]
private ?string $shortCode = null;
#[ORM\Column(length: 150, nullable: true)]
private ?string $title = null;
#[ORM\Column(length: 150, nullable: true)]
private ?string $position = null;
#[ORM\Column(length: 150, nullable: true)]
private ?string $department = null;
#[ORM\Column(length: 180, nullable: true)]
private ?string $email = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $phone = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $mobile = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $photoPath = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $bio = null;
#[ORM\Column(length: 20)]
private string $status = 'active';
/** Pro-Mitarbeiter-Freigabe für Self-Service (zusätzlich zu Company::selfEditEnabled). */
#[ORM\Column]
private bool $selfEditAllowed = false;
/** @var string[] Felder, die der Mitarbeiter selbst ändern darf (leer = alle erlaubten). */
#[ORM\Column(type: 'json')]
private array $editableFields = [];
#[ORM\ManyToOne(targetEntity: Company::class, inversedBy: 'employees')]
#[ORM\JoinColumn(nullable: false)]
private Company $company;
#[ORM\ManyToOne(targetEntity: Location::class)]
private ?Location $location = null;
#[ORM\OneToOne(targetEntity: User::class, mappedBy: 'employee')]
private ?User $user = null;
/** @var Collection<int, ContactLink> */
#[ORM\OneToMany(targetEntity: ContactLink::class, mappedBy: 'employee', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $contactLinks;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $updatedAt;
public function __construct()
{
$this->id = Uuid::v7();
$this->shortCode = bin2hex(random_bytes(4));
$this->createdAt = new \DateTimeImmutable();
$this->updatedAt = new \DateTimeImmutable();
$this->contactLinks = new ArrayCollection();
}
public function getShortCode(): ?string
{
return $this->shortCode;
}
public function ensureShortCode(): void
{
if (null === $this->shortCode) {
$this->shortCode = bin2hex(random_bytes(4));
}
}
public function getId(): Uuid
{
return $this->id;
}
public function getFirstName(): string
{
return $this->firstName;
}
public function setFirstName(string $firstName): self
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): string
{
return $this->lastName;
}
public function setLastName(string $lastName): self
{
$this->lastName = $lastName;
return $this;
}
public function getSlug(): string
{
return $this->slug;
}
public function setSlug(string $slug): self
{
$this->slug = $slug;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): self
{
$this->title = $title;
return $this;
}
public function getPosition(): ?string
{
return $this->position;
}
public function setPosition(?string $position): self
{
$this->position = $position;
return $this;
}
public function getDepartment(): ?string
{
return $this->department;
}
public function setDepartment(?string $department): self
{
$this->department = $department;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): self
{
$this->email = $email;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): self
{
$this->phone = $phone;
return $this;
}
public function getMobile(): ?string
{
return $this->mobile;
}
public function setMobile(?string $mobile): self
{
$this->mobile = $mobile;
return $this;
}
public function getPhotoPath(): ?string
{
return $this->photoPath;
}
public function setPhotoPath(?string $photoPath): self
{
$this->photoPath = $photoPath;
return $this;
}
public function getBio(): ?string
{
return $this->bio;
}
public function setBio(?string $bio): self
{
$this->bio = $bio;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function isSelfEditAllowed(): bool
{
return $this->selfEditAllowed;
}
public function setSelfEditAllowed(bool $selfEditAllowed): self
{
$this->selfEditAllowed = $selfEditAllowed;
return $this;
}
/** @return string[] */
public function getEditableFields(): array
{
return $this->editableFields;
}
/** @param string[] $editableFields */
public function setEditableFields(array $editableFields): self
{
$this->editableFields = $editableFields;
return $this;
}
public function getCompany(): Company
{
return $this->company;
}
public function setCompany(Company $company): self
{
$this->company = $company;
return $this;
}
public function getLocation(): ?Location
{
return $this->location;
}
public function setLocation(?Location $location): self
{
$this->location = $location;
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): self
{
$this->user = $user;
return $this;
}
/** @return Collection<int, ContactLink> */
public function getContactLinks(): Collection
{
return $this->contactLinks;
}
public function addContactLink(ContactLink $link): self
{
if (!$this->contactLinks->contains($link)) {
$this->contactLinks->add($link);
$link->setEmployee($this);
}
return $this;
}
public function removeContactLink(ContactLink $link): self
{
$this->contactLinks->removeElement($link);
return $this;
}
public function getReseller(): ?Reseller
{
return $this->company->getReseller();
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): \DateTimeImmutable
{
return $this->updatedAt;
}
public function touch(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
}

View File

@ -0,0 +1,173 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\LocationRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Standort eines Firmenkunden.
*/
#[ORM\Entity(repositoryClass: LocationRepository::class)]
#[ApiResource(security: "is_granted('ROLE_COMPANY_ADMIN')")]
class Location implements ResellerOwnedInterface
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 150)]
private string $name;
#[ORM\Column(length: 255, nullable: true)]
private ?string $street = null;
#[ORM\Column(length: 20, nullable: true)]
private ?string $postalCode = null;
#[ORM\Column(length: 120, nullable: true)]
private ?string $city = null;
#[ORM\Column(length: 2, nullable: true)]
private ?string $country = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $phone = null;
#[ORM\Column(length: 180, nullable: true)]
private ?string $email = null;
/** Standort-spezifisches Branding-Override. */
#[ORM\Column(type: 'json')]
private array $brandingOverride = [];
#[ORM\ManyToOne(targetEntity: Company::class, inversedBy: 'locations')]
#[ORM\JoinColumn(nullable: false)]
private Company $company;
public function __construct()
{
$this->id = Uuid::v7();
}
public function getId(): Uuid
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): self
{
$this->street = $street;
return $this;
}
public function getPostalCode(): ?string
{
return $this->postalCode;
}
public function setPostalCode(?string $postalCode): self
{
$this->postalCode = $postalCode;
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): self
{
$this->city = $city;
return $this;
}
public function getCountry(): ?string
{
return $this->country;
}
public function setCountry(?string $country): self
{
$this->country = $country;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): self
{
$this->phone = $phone;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): self
{
$this->email = $email;
return $this;
}
public function getBrandingOverride(): array
{
return $this->brandingOverride;
}
public function setBrandingOverride(array $brandingOverride): self
{
$this->brandingOverride = $brandingOverride;
return $this;
}
public function getCompany(): Company
{
return $this->company;
}
public function setCompany(Company $company): self
{
$this->company = $company;
return $this;
}
public function getReseller(): ?Reseller
{
return $this->company->getReseller();
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Get;
use App\Repository\PlatformPlanRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Reseller-Paket der Plattform (Starter/Professional/Business).
* Definiert Preis und Limits, die gegenüber dem Reseller durchgesetzt werden.
*/
#[ORM\Entity(repositoryClass: PlatformPlanRepository::class)]
#[ApiResource(
operations: [new GetCollection(), new Get()],
security: "is_granted('ROLE_RESELLER_ADMIN')",
)]
class PlatformPlan
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 100)]
private string $name;
#[ORM\Column(length: 100, unique: true)]
private string $slug;
/** Monatspreis in Cent. */
#[ORM\Column]
private int $pricePerMonth = 0;
#[ORM\Column]
private int $maxProfiles = 0;
#[ORM\Column]
private int $maxCompanies = 0;
/** @var string[] */
#[ORM\Column]
private array $features = [];
public function __construct()
{
$this->id = Uuid::v7();
}
public function getId(): Uuid
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getSlug(): string
{
return $this->slug;
}
public function setSlug(string $slug): self
{
$this->slug = $slug;
return $this;
}
public function getPricePerMonth(): int
{
return $this->pricePerMonth;
}
public function setPricePerMonth(int $pricePerMonth): self
{
$this->pricePerMonth = $pricePerMonth;
return $this;
}
public function getMaxProfiles(): int
{
return $this->maxProfiles;
}
public function setMaxProfiles(int $maxProfiles): self
{
$this->maxProfiles = $maxProfiles;
return $this;
}
public function getMaxCompanies(): int
{
return $this->maxCompanies;
}
public function setMaxCompanies(int $maxCompanies): self
{
$this->maxCompanies = $maxCompanies;
return $this;
}
/** @return string[] */
public function getFeatures(): array
{
return $this->features;
}
/** @param string[] $features */
public function setFeatures(array $features): self
{
$this->features = $features;
return $this;
}
}

View File

@ -0,0 +1,145 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\ResellerRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
/**
* Oberster Mandant: Druckerei/Agentur, die Firmenkunden verwaltet.
*/
#[ORM\Entity(repositoryClass: ResellerRepository::class)]
#[ApiResource(security: "is_granted('ROLE_PLATFORM_ADMIN')")]
class Reseller
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 150)]
private string $name;
#[ORM\Column(length: 100, unique: true)]
private string $slug;
/** Haupt-Domain des Resellers (für Subdomains der Firmenkunden). */
#[ORM\Column(length: 255, nullable: true)]
private ?string $primaryDomain = null;
#[ORM\Column(length: 20)]
private string $status = 'active';
/** Branding-Defaults (Logo, Farben, Fonts). */
#[ORM\Column(type: 'json')]
private array $brandingConfig = [];
#[ORM\ManyToOne(targetEntity: PlatformPlan::class)]
private ?PlatformPlan $platformPlan = null;
/** @var Collection<int, Company> */
#[ORM\OneToMany(targetEntity: Company::class, mappedBy: 'reseller')]
private Collection $companies;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->id = Uuid::v7();
$this->createdAt = new \DateTimeImmutable();
$this->companies = new ArrayCollection();
}
public function getId(): Uuid
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getSlug(): string
{
return $this->slug;
}
public function setSlug(string $slug): self
{
$this->slug = $slug;
return $this;
}
public function getPrimaryDomain(): ?string
{
return $this->primaryDomain;
}
public function setPrimaryDomain(?string $primaryDomain): self
{
$this->primaryDomain = $primaryDomain;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function getBrandingConfig(): array
{
return $this->brandingConfig;
}
public function setBrandingConfig(array $brandingConfig): self
{
$this->brandingConfig = $brandingConfig;
return $this;
}
public function getPlatformPlan(): ?PlatformPlan
{
return $this->platformPlan;
}
public function setPlatformPlan(?PlatformPlan $platformPlan): self
{
$this->platformPlan = $platformPlan;
return $this;
}
/** @return Collection<int, Company> */
public function getCompanies(): Collection
{
return $this->companies;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Entity;
/**
* Markiert Entitäten, die zu genau einem Reseller gehören.
* Der Doctrine-Mandantenfilter schränkt Queries automatisch auf den
* Reseller des eingeloggten Nutzers ein (siehe App\Doctrine\ResellerFilter).
*/
interface ResellerOwnedInterface
{
public function getReseller(): ?Reseller;
}

183
backend/src/Entity/User.php Normal file
View File

@ -0,0 +1,183 @@
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
#[ORM\UniqueConstraint(name: 'uniq_user_email', fields: ['email'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
public const ROLE_PLATFORM_ADMIN = 'ROLE_PLATFORM_ADMIN';
public const ROLE_RESELLER_ADMIN = 'ROLE_RESELLER_ADMIN';
public const ROLE_COMPANY_ADMIN = 'ROLE_COMPANY_ADMIN';
public const ROLE_EMPLOYEE = 'ROLE_EMPLOYEE';
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
#[ORM\Column(length: 180)]
private string $email;
/** @var string[] */
#[ORM\Column]
private array $roles = [];
#[ORM\Column]
private string $password;
#[ORM\Column(length: 20)]
private string $status = 'active';
/** Reseller-Kontext (für Reseller-Admins). */
#[ORM\ManyToOne(targetEntity: Reseller::class)]
private ?Reseller $reseller = null;
/** Firmen-Kontext (für Firmen-Admins). */
#[ORM\ManyToOne(targetEntity: Company::class)]
private ?Company $company = null;
/** Verknüpftes Mitarbeiterprofil (für Self-Service). */
#[ORM\OneToOne(targetEntity: Employee::class, inversedBy: 'user')]
private ?Employee $employee = null;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $lastLoginAt = null;
public function __construct()
{
$this->id = Uuid::v7();
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): Uuid
{
return $this->id;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getUserIdentifier(): string
{
return $this->email;
}
/** @return string[] */
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_values(array_unique($roles));
}
/** @param string[] $roles */
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): self
{
$this->password = $password;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function getReseller(): ?Reseller
{
return $this->reseller;
}
public function setReseller(?Reseller $reseller): self
{
$this->reseller = $reseller;
return $this;
}
public function getCompany(): ?Company
{
return $this->company;
}
public function setCompany(?Company $company): self
{
$this->company = $company;
return $this;
}
public function getEmployee(): ?Employee
{
return $this->employee;
}
public function setEmployee(?Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getLastLoginAt(): ?\DateTimeImmutable
{
return $this->lastLoginAt;
}
public function setLastLoginAt(?\DateTimeImmutable $lastLoginAt): self
{
$this->lastLoginAt = $lastLoginAt;
return $this;
}
public function eraseCredentials(): void
{
// ggf. temporäre, sensible Daten löschen
}
}

11
backend/src/Kernel.php Normal file
View File

@ -0,0 +1,11 @@
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
}

0
backend/src/Repository/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\Company;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Company>
*/
class CompanyRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Company::class);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\ContactLink;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ContactLink>
*/
class ContactLinkRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ContactLink::class);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\Domain;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Domain>
*/
class DomainRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Domain::class);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Repository;
use App\Entity\Employee;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Employee>
*/
class EmployeeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Employee::class);
}
/**
* Lädt ein öffentlich sichtbares (aktives) Profil anhand Firmen- und
* Mitarbeiter-Slug. Nicht mandantengefiltert diese Seiten sind öffentlich.
*/
public function findPublic(string $companySlug, string $slug): ?Employee
{
return $this->createQueryBuilder('e')
->join('e.company', 'c')
->andWhere('c.slug = :companySlug')
->andWhere('e.slug = :slug')
->andWhere('e.status = :status')
->setParameter('companySlug', $companySlug)
->setParameter('slug', $slug)
->setParameter('status', 'active')
->getQuery()
->getOneOrNullResult();
}
/** Aktives Profil über den stabilen NFC/QR-Kurz-Code (für /t/{code}). */
public function findByShortCode(string $shortCode): ?Employee
{
return $this->findOneBy(['shortCode' => $shortCode, 'status' => 'active']);
}
/** @return Employee[] Mitarbeiter ohne Kurz-Code (für Backfill). */
public function findWithoutShortCode(): array
{
return $this->createQueryBuilder('e')
->andWhere('e.shortCode IS NULL')
->getQuery()
->getResult();
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\Location;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Location>
*/
class LocationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Location::class);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\PlatformPlan;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PlatformPlan>
*/
class PlatformPlanRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PlatformPlan::class);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\Reseller;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Reseller>
*/
class ResellerRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Reseller::class);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Security;
use App\Entity\Company;
use App\Entity\Reseller;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Liefert den Mandantenkontext (Reseller/Company) des eingeloggten Nutzers.
* Plattform-Admins haben keinen Mandantenkontext und sehen alles.
*/
final class TenantContext
{
public function __construct(private readonly Security $security)
{
}
public function isPlatformAdmin(): bool
{
return $this->security->isGranted(User::ROLE_PLATFORM_ADMIN);
}
public function getReseller(): ?Reseller
{
$user = $this->security->getUser();
return $user instanceof User ? $user->getReseller() : null;
}
public function getCompany(): ?Company
{
$user = $this->security->getUser();
return $user instanceof User ? $user->getCompany() : null;
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Service;
use App\Entity\Employee;
/**
* Erzeugt eine vCard (RFC 6350, Version 3.0 breite Kompatibilität mit
* iOS, Android, Outlook) aus einem Mitarbeiterprofil.
*/
final class VCardBuilder
{
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()));
if ($company) {
$lines[] = 'ORG:'.$this->esc($company->getName());
}
if ($e->getPosition()) {
$lines[] = 'TITLE:'.$this->esc($e->getPosition());
}
if ($e->getEmail()) {
$lines[] = 'EMAIL;TYPE=WORK:'.$this->esc($e->getEmail());
}
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());
}
foreach ($e->getContactLinks() as $link) {
$lines[] = 'URL:'.$this->esc($link->getUrl());
}
if ($e->getBio()) {
$lines[] = 'NOTE:'.$this->esc($e->getBio());
}
$lines[] = 'REV:'.$e->getUpdatedAt()->format('Ymd\THis\Z');
$lines[] = 'END:VCARD';
// vCard verlangt CRLF-Zeilenenden
return implode("\r\n", $lines)."\r\n";
}
/** Escaping gemäß vCard-Spezifikation. */
private function esc(string $value): string
{
return str_replace(
['\\', "\n", ',', ';'],
['\\\\', '\\n', '\\,', '\\;'],
$value,
);
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Company;
use App\Entity\ContactLink;
use App\Entity\Domain;
use App\Entity\Employee;
use App\Entity\Location;
use App\Security\TenantContext;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* Stempelt beim Schreiben (POST/PATCH) den Mandanten automatisch aus dem
* eingeloggten Kontext und verhindert Cross-Tenant-Referenzen.
* Plattform-Admins sind nicht eingeschränkt.
*
* Dekoriert den Doctrine-Persist-Processor von API Platform.
*/
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
final class TenantStampProcessor implements ProcessorInterface
{
public function __construct(
#[AutowireDecorated]
private readonly ProcessorInterface $inner,
private readonly TenantContext $tenant,
) {
}
/**
* @param array<string, mixed> $uriVariables
* @param array<string, mixed> $context
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
$this->stampAndValidate($data);
return $this->inner->process($data, $operation, $uriVariables, $context);
}
private function stampAndValidate(mixed $data): void
{
// Plattform-Admins dürfen mandantenübergreifend schreiben
if ($this->tenant->isPlatformAdmin()) {
return;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller && $this->isTenantOwned($data)) {
throw new AccessDeniedHttpException('Kein Mandantenkontext.');
}
match (true) {
$data instanceof Company => $data->setReseller($reseller),
$data instanceof Location,
$data instanceof Domain => $this->assertCompany($data->getCompany()),
$data instanceof Employee => $this->assertEmployee($data),
$data instanceof ContactLink => $this->assertCompany($data->getEmployee()->getCompany()),
default => null,
};
}
private function assertEmployee(Employee $employee): void
{
$this->assertCompany($employee->getCompany());
// Standort muss zur selben Firma gehören
$location = $employee->getLocation();
if (null !== $location && !$location->getCompany()->getId()->equals($employee->getCompany()->getId())) {
throw new AccessDeniedHttpException('Standort gehört nicht zur Firma.');
}
}
/** Prüft, dass die referenzierte Firma im Mandanten des Nutzers liegt. */
private function assertCompany(Company $company): void
{
$reseller = $this->tenant->getReseller();
if (null === $reseller || null === $company->getReseller()
|| !$company->getReseller()->getId()->equals($reseller->getId())) {
throw new AccessDeniedHttpException('Firma gehört nicht zum eigenen Reseller.');
}
// Firmen-Admins dürfen nur in ihrer eigenen Firma schreiben
$own = $this->tenant->getCompany();
if (null !== $own && !$company->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Schreibzugriff nur auf die eigene Firma.');
}
}
private function isTenantOwned(mixed $data): bool
{
return $data instanceof Company
|| $data instanceof Location
|| $data instanceof Domain
|| $data instanceof Employee
|| $data instanceof ContactLink;
}
}

214
backend/symfony.lock Normal file
View File

@ -0,0 +1,214 @@
{
"api-platform/symfony": {
"version": "4.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.0",
"ref": "e9952e9f393c2d048f10a78f272cd35e807d972b"
},
"files": [
"config/packages/api_platform.yaml",
"config/routes/api_platform.yaml",
"src/ApiResource/.gitignore"
]
},
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fdd756167454623e21f1d769c5b814b243782a67"
}
},
"doctrine/doctrine-bundle": {
"version": "3.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "d39a3bd844edfe90c20ae520b804a3bf4f82b4ad"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "4.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.1",
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
},
"files": [
"config/packages/doctrine_migrations.yaml",
"migrations/.gitignore"
]
},
"lexik/jwt-authentication-bundle": {
"version": "3.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.5",
"ref": "e9481b233a11ef7e15fe055a2b21fd3ac1aa2bb7"
},
"files": [
"config/packages/lexik_jwt_authentication.yaml"
]
},
"nelmio/cors-bundle": {
"version": "2.6",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.5",
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
},
"files": [
"config/packages/nelmio_cors.yaml"
]
},
"symfony/console": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
},
"files": [
"bin/console"
]
},
"symfony/flex": {
"version": "2.11",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.4",
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
},
"files": [
".env",
".env.dev"
]
},
"symfony/framework-bundle": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "d5dcd308c8becd725c9d8b91e31aab1ff0bbc30b"
},
"files": [
"config/packages/cache.yaml",
"config/packages/framework.yaml",
"config/preload.php",
"config/routes/framework.yaml",
"config/services.yaml",
"public/index.php",
"src/Controller/.gitignore",
"src/Kernel.php",
".editorconfig"
]
},
"symfony/maker-bundle": {
"version": "1.67",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/messenger": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
},
"files": [
"config/packages/messenger.yaml"
]
},
"symfony/property-info": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded"
},
"files": [
"config/packages/routing.yaml",
"config/routes.yaml"
]
},
"symfony/security-bundle": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "c42fee7802181cdd50f61b8622715829f5d2335c"
},
"files": [
"config/packages/security.yaml",
"config/routes/security.yaml"
]
},
"symfony/twig-bundle": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "f250159ebe99153d0c640a3e7742876fc7453f2c"
},
"files": [
"config/packages/twig.yaml",
"templates/base.html.twig"
]
},
"symfony/uid": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
}
},
"symfony/validator": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
},
"files": [
"config/packages/validator.yaml"
]
}
}

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}vcard4reseller{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><rect width=%22128%22 height=%22128%22 rx=%2228%22 fill=%22%23f58220%22/><text y=%221.0em%22 x=%220.5em%22 text-anchor=%22middle%22 font-size=%2284%22 font-family=%22Arial%22 font-weight=%22700%22 fill=%22%23fff%22>v</text></svg>">
<link rel="stylesheet" href="{{ asset('assets/brand.css') }}">
{% block stylesheets %}{% endblock %}
{% block meta %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
{% block javascripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,154 @@
{% extends 'base.html.twig' %}
{% set fullName = (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 %}
{% block title %}{{ fullName }} {{ e.company.name }}{% endblock %}
{% block meta %}
<meta name="description" content="{{ (e.position ? e.position ~ ' · ' : '') ~ e.company.name }}">
<meta property="og:title" content="{{ fullName }}">
<meta property="og:description" content="{{ (e.position ? e.position ~ ' · ' : '') ~ e.company.name }}">
<meta property="og:type" content="profile">
{% endblock %}
{% block stylesheets %}
{% if primary %}
<style>
:root {
--psc-orange: {{ primary }};
--psc-orange-dark: {{ primaryDark }};
--psc-orange-soft: color-mix(in srgb, {{ primary }} 14%, white);
--psc-orange-soft-2: color-mix(in srgb, {{ primary }} 7%, white);
--psc-border: color-mix(in srgb, {{ primary }} 32%, white);
}
</style>
{% endif %}
<style>
.wrap { max-width: 480px; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
.vc__logo { display: block; max-height: 38px; margin: 0 auto .4rem; }
.vc {
background: var(--white);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
border: 1px solid #f0f0f0;
}
.vc__cover {
height: 120px;
background: linear-gradient(135deg, var(--psc-orange) 0%, var(--psc-orange-dark) 100%);
display: flex; align-items: center; justify-content: center;
}
.vc__cover-logo { max-height: 46px; max-width: 70%; filter: brightness(0) invert(1); opacity: .95; }
.vc__head { padding: 0 1.6rem 1.4rem; margin-top: -56px; text-align: center; }
.vc__avatar {
width: 112px; height: 112px; border-radius: 50%;
border: 5px solid var(--white);
background: var(--psc-orange-soft);
color: var(--psc-orange-dark);
display: inline-flex; align-items: center; justify-content: center;
font-size: 2.4rem; font-weight: 700; object-fit: cover;
box-shadow: var(--shadow-sm);
}
.vc__name { font-size: 1.6rem; margin: .7rem 0 .15rem; }
.vc__role { color: var(--muted); font-size: 1rem; }
.vc__org {
display: inline-block; margin-top: .6rem;
background: var(--psc-orange-soft); color: var(--psc-orange-dark);
padding: .25rem .9rem; border-radius: 999px; font-weight: 600; font-size: .85rem;
}
.vc__actions { display: grid; gap: .6rem; padding: 0 1.6rem 1.4rem; }
.vc__row { display: flex; gap: .6rem; }
.vc__row .btn { flex: 1; justify-content: center; }
.btn-block { width: 100%; justify-content: center; }
.vc__section { padding: 1.2rem 1.6rem; border-top: 1px solid #f2f2f2; }
.vc__label { font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 700; margin-bottom: .7rem; }
.links { display: grid; gap: .5rem; }
.link {
display: flex; align-items: center; gap: .7rem;
padding: .7rem .9rem; border: 1px solid #eee; border-radius: var(--radius-sm);
color: var(--text); font-weight: 600; font-size: .92rem;
}
.link:hover { border-color: var(--psc-border); background: var(--psc-orange-soft-2); text-decoration: none; }
.link .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--psc-orange); }
.bio { color: var(--text); font-size: .95rem; }
.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; }
.foot { text-align: center; margin-top: 1.6rem; color: var(--muted); font-size: .8rem; }
.foot .brand-logo { font-size: .95rem; }
</style>
{% endblock %}
{% block body %}
<div class="wrap">
<div class="vc">
<div class="vc__cover">
{% if logo %}<img class="vc__cover-logo" src="{{ logo }}" alt="{{ e.company.name }}">{% endif %}
</div>
<div class="vc__head">
{% if e.photoPath %}
<img class="vc__avatar" src="{{ e.photoPath }}" alt="{{ fullName }}">
{% else %}
<div class="vc__avatar">{{ (e.firstName|first ~ e.lastName|first)|upper }}</div>
{% endif %}
<h1 class="vc__name">{{ fullName }}</h1>
{% if e.position or e.department %}
<div class="vc__role">{{ e.position }}{% if e.position and e.department %} · {% endif %}{{ e.department }}</div>
{% endif %}
<div class="vc__org">{{ e.company.name }}</div>
</div>
<div class="vc__actions">
<a class="btn btn-primary btn-block" href="{{ path('public_profile_vcard', {companySlug: e.company.slug, slug: e.slug}) }}">
⬇ Kontakt speichern (vCard)
</a>
<div class="vc__row">
{% 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 %}
</div>
</div>
{% if e.bio %}
<div class="vc__section">
<div class="vc__label">Über mich</div>
<p class="bio">{{ e.bio }}</p>
</div>
{% endif %}
{% if e.contactLinks|length %}
<div class="vc__section">
<div class="vc__label">Links</div>
<div class="links">
{% for link in e.contactLinks %}
<a class="link" href="{{ link.url }}" target="_blank" rel="noopener">
<span class="dot"></span>{{ link.label ?: link.type|capitalize }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
<div class="vc__section qr">
<div class="vc__label">Profil teilen</div>
<img src="{{ path('public_profile_qr', {companySlug: e.company.slug, slug: e.slug}) }}" alt="QR-Code zum Profil">
<p>QR-Code scannen, um dieses Profil zu öffnen</p>
</div>
</div>
<div class="foot">
bereitgestellt über
<span class="brand-logo">{{ reseller ? reseller.name : 'vcard4' }}<span class="tag">reseller</span></span>
</div>
</div>
{% endblock %}

54
docker-compose.yml Normal file
View File

@ -0,0 +1,54 @@
services:
php:
build:
context: ./docker/php
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
volumes:
- ./backend:/app
depends_on:
mariadb:
condition: service_healthy
environment:
DATABASE_URL: "mysql://app:app@mariadb:3306/vcard4reseller?serverVersion=11.4.0-MariaDB&charset=utf8mb4"
nginx:
image: nginx:1.27-alpine
ports:
- "8080:80"
volumes:
- ./backend:/app:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- php
mariadb:
image: mariadb:11.4
ports:
- "3306:3306"
environment:
MARIADB_ROOT_PASSWORD: root
MARIADB_DATABASE: vcard4reseller
MARIADB_USER: app
MARIADB_PASSWORD: app
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 10
node:
image: node:25-alpine
working_dir: /app
volumes:
- ./frontend:/app
ports:
- "5173:5173"
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
profiles: ["frontend"]
volumes:
mariadb_data:

25
docker/nginx/default.conf Normal file
View File

@ -0,0 +1,25 @@
server {
listen 80;
server_name _;
root /app/public;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass php:9000;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $document_root;
internal;
}
# Direkte .php-Aufrufe (außer index.php) blockieren
location ~ \.php$ {
return 404;
}
client_max_body_size 16m;
}

25
docker/php/Dockerfile Normal file
View File

@ -0,0 +1,25 @@
FROM php:8.4-fpm-bookworm
# System-Abhängigkeiten für die PHP-Extensions
RUN apt-get update && apt-get install -y --no-install-recommends \
git unzip libicu-dev libzip-dev libpng-dev libjpeg-dev libfreetype6-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j"$(nproc)" intl pdo_mysql zip gd opcache \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Composer aus dem offiziellen Image übernehmen
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Symfony CLI (praktisch für Maker/Server/Checks)
RUN curl -1sLf 'https://dl.cloudsmith.io/public/symfony/stable/setup.deb.sh' | bash \
&& apt-get update && apt-get install -y --no-install-recommends symfony-cli \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Nicht-root-User passend zur Host-UID (vermeidet Datei-Rechte-Probleme)
ARG UID=1000
ARG GID=1000
RUN groupmod -g "${GID}" www-data 2>/dev/null || true \
&& usermod -u "${UID}" -g "${GID}" www-data 2>/dev/null || true
WORKDIR /app
USER www-data

View File

@ -94,7 +94,8 @@ und legen sie versioniert ab. Öffentliche Endpunkte liefern immer den aktuellen
- **NfcTag** — physischer Tag. Felder: `uid`, `shortUrl`, `employee_id` (nullable bis Zuweisung), `status` (`unassigned`/`active`/`disabled`), `lastScanAt`. - **NfcTag** — physischer Tag. Felder: `uid`, `shortUrl`, `employee_id` (nullable bis Zuweisung), `status` (`unassigned`/`active`/`disabled`), `lastScanAt`.
- **QrCode** — generierter QR (statisch oder dynamische Redirect-URL). Felder: `target`, `imageAsset`, `employee_id`. - **QrCode** — generierter QR (statisch oder dynamische Redirect-URL). Felder: `target`, `imageAsset`, `employee_id`.
- **WalletPass** — Referenz auf Apple/Google-Pass. Felder: `provider`, `serial`, `passUrl`, `employee_id`, `lastGeneratedAt`. - **WalletPass** — Referenz auf Apple/Google-Pass. Felder: `provider` (`apple`|`google`), `serial`, `authToken` (Apple Web-Service-Auth), `passUrl`, `employee_id`, `lastGeneratedAt`. Siehe §12.
- **WalletDevice** — registriertes Apple-Gerät für Push-Updates (Apple-spezifisch). Felder: `deviceLibraryId`, `pushToken`, `serial` (→ WalletPass), `registeredAt`. Google braucht das nicht (Server-Push über die API).
- **GeneratedArtifact** (optional, generisch) — Cache abgeleiteter Outputs: `type` (vcard/print_pdf/signature_html), `employee_id`, `payload`/`fileRef`, `generatedAt`, `version`. - **GeneratedArtifact** (optional, generisch) — Cache abgeleiteter Outputs: `type` (vcard/print_pdf/signature_html), `employee_id`, `payload`/`fileRef`, `generatedAt`, `version`.
### Vertrieb & Abrechnung ### Vertrieb & Abrechnung
@ -132,6 +133,7 @@ erDiagram
Employee ||--o| NfcTag : verknüpft Employee ||--o| NfcTag : verknüpft
Employee ||--o{ QrCode : hat Employee ||--o{ QrCode : hat
Employee ||--o| WalletPass : hat Employee ||--o| WalletPass : hat
WalletPass ||--o{ WalletDevice : "Apple-Push"
Employee ||--o{ GeneratedArtifact : erzeugt Employee ||--o{ GeneratedArtifact : erzeugt
Employee ||--o| User : "Self-Service" Employee ||--o| User : "Self-Service"
@ -226,7 +228,7 @@ Alle unter `/api`, JWT-geschützt, mandantengescoped. Beispiele:
### Noch offen ### Noch offen
4. **Wallet-Pässe:** Apple Developer Account + Google Wallet API vorhanden? (Zertifikate erforderlich) 4. **Wallet-Pässe:** Apple Developer Account + Google Wallet API vorhanden? (Zertifikate erforderlich). White-Label-Frage: läuft der Apple-Pass unter *einem* zentralen Pass Type ID (Plattform) oder pro Reseller? Konzept dazu in §12.
5. **Druckdaten:** Welches Format erwarten die Druckereien (PDF/X, bestimmte Maße, Beschnitt)? Gibt es Vorlagen? 5. **Druckdaten:** Welches Format erwarten die Druckereien (PDF/X, bestimmte Maße, Beschnitt)? Gibt es Vorlagen?
6. **Bestehende Daten/Branding:** Existieren Design-Assets/CI zur bestehenden vcard4reseller.de, die wir übernehmen? 6. **Bestehende Daten/Branding:** Existieren Design-Assets/CI zur bestehenden vcard4reseller.de, die wir übernehmen?
@ -245,3 +247,55 @@ Alle unter `/api`, JWT-geschützt, mandantengescoped. Beispiele:
**Datenmodell-Ergänzung** an `Company` bzw. neue Entität **`Domain`**: **Datenmodell-Ergänzung** an `Company` bzw. neue Entität **`Domain`**:
`hostname`, `type` (`subdomain`|`custom`), `status` (`pending`|`verified`|`failed`), `verificationCheckedAt`, `tlsStatus`, `company_id`. `hostname`, `type` (`subdomain`|`custom`), `status` (`pending`|`verified`|`failed`), `verificationCheckedAt`, `tlsStatus`, `company_id`.
---
## 12. Wallet-Pässe & Kontakt-Synchronisation
### Das Grundproblem
Eine heruntergeladene **vCard (.vcf) ist ein Schnappschuss**: Einmal in der Kontakte-App gespeichert, aktualisiert sie sich nicht mehr. Ändert ein Mitarbeiter seine Nummer, haben alle Empfänger veraltete Daten. Das ist eine OS-Einschränkung. Es gibt drei Wege zu „aktuellen Daten":
| Weg | Echter Auto-Sync? | Wo landet es? | Aufwand |
|-----|-------------------|---------------|---------|
| **Wallet-Pass** (Apple/Google) | **Ja, over-the-air** | Wallet-App (nicht Kontakte) | hoch (Zertifikate) |
| **Link behalten** (QR/NFC/Kurz-URL) | Daten aktuell *beim Öffnen*, vCard wird live generiert | Browser → Kontakt bei Bedarf neu | ✅ umgesetzt (Phase 2) |
| **CardDAV** | Ja, auf Kontakt-Ebene | native Kontakte-App | mittel, für Einzel-Empfänger unpraktisch (account-basiert) |
**Fazit:** Den einzigen echten „Push in eine bereits gespeicherte Karte" liefert der **Wallet-Pass**. Der **Link** (unsere Profilseite) ist der pragmatische Standard: nicht die Daten, sondern der Link wird gespeichert; die vCard erzeugen wir bei jedem Abruf frisch.
### Apple Wallet (PassKit)
- `.pkpass` = ZIP aus `pass.json` + Bildern + `manifest.json` + **PKCS#7-Signatur** (Pass Type ID-Zertifikat + Apple-WWDR-Cert).
- Pass-Stil `generic` (Visitenkarte): Name / Position / Firma / Telefon / E-Mail / Link.
- PHP: z. B. `includable/php-pkpass`, gekapselt in einem `WalletService`.
- **Sync (Push):** Pass enthält `webServiceURL` + `authenticationToken`.
1. Nutzer fügt Pass hinzu → Gerät **registriert** sich (→ `WalletDevice`).
2. Profil ändert sich → **leerer APNs-Push** an registrierte Geräte.
3. Gerät holt aktualisierten Pass von unserer **PassKit-Web-Service-API** ab.
- Nötige Endpunkte (PassKit Web Service): `register`, `unregister`, `list serials`, `latest pass`.
### Google Wallet
- REST-API + Service-Account (kostenlos). *Generic*-Pass-**Klasse** + pro Mitarbeiter ein **Objekt**.
- Hinzufügen via „Add to Google Wallet"-Link (signiertes JWT).
- **Sync:** Objekt server-seitig per API **patchen** → Google pusht selbst. Kein APNs, keine Geräte-Registrierung. → einfacher als Apple, daher **als erster Wallet-Kanal empfohlen**.
### Einbettung in die Architektur
```
Employee geändert ──► ProfileUpdated-Event ──► Messenger (async)
├─ Apple: Pass neu signieren + APNs-Push an WalletDevices
└─ Google: Wallet-Objekt per API patchen (Google pusht selbst)
```
Neue Bausteine: `WalletPass`/`WalletDevice` (Datenmodell §4), `WalletService` (Pass bauen/signieren/patchen), `ApplePassController` (Web-Service-Endpunkte), Messenger-Handler am `ProfileUpdated`-Event (gleicher Trigger wie `Employee::touch()`).
### White-Label-Überlegung
Apple Pass Type ID ist an *einen* Apple-Account gebunden. Optionen: **(a)** ein zentraler Pass Type ID der Plattform für alle Reseller (einfacher, Branding über Pass-Felder/Logo) oder **(b)** pro Reseller ein eigener (aufwändig, jeder Reseller braucht Apple-Account). Empfehlung: **(a)** zentral, Reseller-/Firmen-Branding über Logo & Farben im Pass. → zu klären (offene Frage #4).
### Voraussetzungen (offen)
- Apple Developer Account + Pass Type ID + Zertifikat (`.p12`) — für Apple Wallet.
- Google-Cloud-Projekt + Wallet-API + Service-Account — für Google Wallet.

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
frontend/README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1759
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
frontend/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.16.1",
"pinia": "^3.0.4",
"vue": "^3.5.34",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vue-tsc": "^3.2.8"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

7
frontend/src/App.vue Normal file
View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>

View File

@ -0,0 +1,38 @@
import axios from 'axios'
// Zentraler HTTP-Client für die Symfony-API.
// Im Dev läuft alles über den Vite-Proxy (/api → :8080).
const client = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
headers: {
Accept: 'application/ld+json',
},
})
// JWT (falls vorhanden) an jede Anfrage hängen
client.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Bei abgelaufenem/ungültigem Token automatisch ausloggen
client.interceptors.response.use(
(res) => res,
(error) => {
const status = error?.response?.status
const url: string = error?.config?.url ?? ''
if (status === 401 && !url.includes('/login')) {
localStorage.removeItem('token')
localStorage.removeItem('user')
if (location.pathname !== '/login') {
location.href = '/login'
}
}
return Promise.reject(error)
},
)
export default client

View File

@ -0,0 +1,39 @@
import client from './client'
// Minimaler Wrapper um die API-Platform-Resourcen (Hydra/JSON-LD).
export interface Collection<T> {
member: T[]
totalItems: number
}
const LD = { headers: { 'Content-Type': 'application/ld+json' } }
export async function list<T>(
resource: string,
params: Record<string, unknown> = {},
): Promise<Collection<T>> {
const { data } = await client.get(`/${resource}`, { params })
return { member: data.member ?? [], totalItems: data.totalItems ?? 0 }
}
export async function getOne<T>(iri: string): Promise<T> {
const { data } = await client.get(iri.replace(/^\/api/, ''))
return data as T
}
export async function create<T>(resource: string, payload: Record<string, unknown>): Promise<T> {
const { data } = await client.post(`/${resource}`, payload, LD)
return data as T
}
export async function update<T>(iri: string, payload: Record<string, unknown>): Promise<T> {
const { data } = await client.patch(iri.replace(/^\/api/, ''), payload, {
headers: { 'Content-Type': 'application/merge-patch+json' },
})
return data as T
}
export async function remove(iri: string): Promise<void> {
await client.delete(iri.replace(/^\/api/, ''))
}

View File

@ -0,0 +1,78 @@
/* vcard4reseller — Design-Tokens (orientiert an vcard4reseller.de) */
:root {
--psc-orange: #f58220;
--psc-orange-dark: #d96500;
--psc-orange-soft: #fff2e7;
--psc-orange-soft-2: #fff8f1;
--psc-border: #f4d4bb;
--text: #343434;
--dark: #222222;
--sidebar: #1d1d1f;
--sidebar-hover: #2a2a2d;
--muted: #6f6f6f;
--bg: #f7f7f7;
--white: #ffffff;
--success: #238636;
--danger: #c0392b;
--line: #ececec;
--shadow: 0 18px 45px rgba(30, 30, 30, 0.10);
--shadow-sm: 0 6px 18px rgba(30, 30, 30, 0.08);
--radius: 18px;
--radius-sm: 12px;
--font: Arial, Helvetica, sans-serif;
}
* { box-sizing: border-box; }
html, body, #app { height: 100%; }
body {
margin: 0;
font-family: var(--font);
color: var(--text);
background: var(--bg);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { color: var(--dark); font-weight: 700; margin: 0; }
a { color: var(--psc-orange-dark); text-decoration: none; }
/* Wortmarke */
.brand-logo { display: inline-flex; align-items: center; font-weight: 700; font-size: 1.1rem; }
.brand-logo .tag { background: var(--psc-orange); color: #fff; padding: 1px 9px; border-radius: 999px; margin-left: 3px; }
/* Buttons */
.btn {
display: inline-flex; align-items: center; gap: .5rem;
padding: .6rem 1.1rem; border-radius: var(--radius-sm);
font-weight: 600; font-size: .9rem; border: 1px solid transparent;
cursor: pointer; font-family: var(--font);
transition: transform .08s ease, background .15s ease, box-shadow .15s ease;
}
.btn:hover { transform: translateY(-1px); }
.btn:disabled { opacity: .55; cursor: not-allowed; transform: none; }
.btn-primary { background: var(--psc-orange); color: #fff; box-shadow: var(--shadow-sm); }
.btn-primary:hover { background: var(--psc-orange-dark); }
.btn-soft { background: var(--psc-orange-soft); color: var(--psc-orange-dark); }
.btn-ghost { background: #fff; color: var(--dark); border-color: #e3e3e3; }
.btn-sm { padding: .35rem .6rem; font-size: .82rem; }
/* Karten */
.card { background: #fff; border-radius: var(--radius); box-shadow: var(--shadow-sm); border: 1px solid #f0f0f0; }
/* Form-Felder */
.field { display: flex; flex-direction: column; gap: .3rem; margin-bottom: .9rem; }
.field label { font-size: .8rem; font-weight: 600; color: var(--muted); }
.input, select.input, textarea.input {
padding: .6rem .75rem; border: 1px solid #e0e0e0; border-radius: var(--radius-sm);
font-size: .92rem; font-family: var(--font); color: var(--text); background: #fff;
}
.input:focus { outline: none; border-color: var(--psc-orange); box-shadow: 0 0 0 3px var(--psc-orange-soft); }
/* Status-Badge */
.badge { display: inline-block; padding: .15rem .6rem; border-radius: 999px; font-size: .75rem; font-weight: 600; }
.badge-active { background: #e7f6ec; color: var(--success); }
.badge-inactive { background: #f0f0f0; color: var(--muted); }
.muted { color: var(--muted); }

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,95 @@
<script setup lang="ts">
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
defineProps<{ title: string }>()
const emit = defineEmits<{ close: [] }>()
</script>
<template>
<div class="overlay" @click.self="emit('close')">
<div class="card modal">
<header class="modal__head">
<h3>{{ title }}</h3>
<button class="x" @click="emit('close')" aria-label="Schließen">×</button>
</header>
<div class="modal__body">
<slot />
</div>
</div>
</div>
</template>
<style scoped>
.overlay {
position: fixed; inset: 0; background: rgba(20, 20, 20, .45);
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__head {
display: flex; align-items: center; justify-content: space-between;
padding: 1.1rem 1.4rem; border-bottom: 1px solid var(--line);
}
.modal__body { padding: 1.4rem; }
.x { background: none; border: none; font-size: 1.6rem; line-height: 1; cursor: pointer; color: var(--muted); }
</style>

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, RouterView, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
interface NavItem { label: string; to: string; icon: string; show: boolean }
const nav = computed<NavItem[]>(() => [
{ label: 'Dashboard', to: '/app', icon: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z', show: true },
{ label: 'Reseller', to: '/app/resellers', icon: 'M3 21h18M5 21V7l8-4v18M19 21V11l-6-3', show: auth.isPlatformAdmin },
{ label: 'Firmen', to: '/app/companies', icon: 'M3 7h18v13H3zM8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', show: auth.isResellerAdmin || auth.isPlatformAdmin },
{ 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: true },
{ 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: auth.isCompanyAdmin || 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.isCompanyAdmin || auth.isResellerAdmin },
{ label: 'Design', to: '/app/design', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: true },
{ label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: true },
].filter((i) => i.show))
const contextLabel = computed(() =>
auth.user?.company?.name ?? auth.user?.reseller?.name ?? 'Plattform',
)
function logout() {
auth.logout()
router.push('/login')
}
</script>
<template>
<div class="shell">
<aside class="sidebar">
<div class="sidebar__brand">
<span class="brand-logo" style="color:#fff">vcard4<span class="tag">reseller</span></span>
</div>
<nav class="sidebar__nav">
<RouterLink v-for="item in nav" :key="item.to" :to="item.to" class="navlink"
:class="{ active: $route.path === item.to }">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"><path :d="item.icon" /></svg>
<span>{{ item.label }}</span>
</RouterLink>
</nav>
</aside>
<div class="main">
<header class="topbar">
<div class="topbar__ctx">
<span class="muted">Mandant</span>
<strong>{{ contextLabel }}</strong>
</div>
<div class="topbar__user">
<span class="muted">{{ auth.user?.email }}</span>
<button class="btn btn-ghost btn-sm" @click="logout">Abmelden</button>
</div>
</header>
<main class="content">
<RouterView />
</main>
</div>
</div>
</template>
<style scoped>
.shell { display: flex; min-height: 100vh; }
.sidebar {
width: 240px; flex-shrink: 0; background: var(--sidebar); color: #cfcfd2;
display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh;
}
.sidebar__brand { padding: 1.4rem 1.3rem; font-size: 1.15rem; }
.sidebar__nav { display: flex; flex-direction: column; gap: 2px; padding: .5rem .7rem; }
.navlink {
display: flex; align-items: center; gap: .8rem; padding: .7rem .8rem;
border-radius: var(--radius-sm); color: #c2c2c6; font-size: .92rem; font-weight: 600;
}
.navlink:hover { background: var(--sidebar-hover); color: #fff; text-decoration: none; }
.navlink.active { background: var(--psc-orange); color: #fff; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.6rem; background: #fff; border-bottom: 1px solid var(--line);
}
.topbar__ctx { display: flex; flex-direction: column; line-height: 1.2; }
.topbar__ctx .muted { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; }
.topbar__user { display: flex; align-items: center; gap: 1rem; }
.content { padding: 1.8rem; }
</style>

10
frontend/src/main.ts Normal file
View File

@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './assets/brand.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,41 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: '/app' },
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
},
{
path: '/app',
component: () => import('@/layouts/DashboardLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'dashboard', component: () => import('@/views/DashboardView.vue') },
{ path: 'resellers', name: 'resellers', component: () => import('@/views/ResellersView.vue') },
{ path: 'companies', name: 'companies', component: () => import('@/views/CompaniesView.vue') },
{ path: 'employees', name: 'employees', component: () => import('@/views/EmployeesView.vue') },
{ 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: 'settings', name: 'settings', component: () => import('@/views/PlaceholderView.vue'), props: { title: 'Einstellungen' } },
],
},
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.name === 'login' && auth.isAuthenticated) {
return { path: '/app' }
}
})
export default router

View File

@ -0,0 +1,55 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import client from '@/api/client'
export interface TenantRef {
id: string
name: string
}
export interface CurrentUser {
id: string
email: string
roles: string[]
reseller: TenantRef | null
company: TenantRef | null
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('token'))
const user = ref<CurrentUser | null>(
JSON.parse(localStorage.getItem('user') || 'null'),
)
const isAuthenticated = computed(() => !!token.value)
const roles = computed(() => user.value?.roles ?? [])
const isPlatformAdmin = computed(() => roles.value.includes('ROLE_PLATFORM_ADMIN'))
const isResellerAdmin = computed(() => roles.value.includes('ROLE_RESELLER_ADMIN'))
const isCompanyAdmin = computed(() => roles.value.includes('ROLE_COMPANY_ADMIN'))
async function login(email: string, password: string) {
const { data } = await client.post('/login', { email, password })
token.value = data.token
localStorage.setItem('token', data.token)
await fetchMe()
}
async function fetchMe() {
const { data } = await client.get<CurrentUser>('/me')
user.value = data
localStorage.setItem('user', JSON.stringify(data))
}
function logout() {
token.value = null
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
return {
token, user, isAuthenticated, roles,
isPlatformAdmin, isResellerAdmin, isCompanyAdmin,
login, fetchMe, logout,
}
})

296
frontend/src/style.css Normal file
View File

@ -0,0 +1,296 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

Some files were not shown because too many files have changed in this diff Show More