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:
parent
03407b76e3
commit
ebaf509a2f
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
102
README.md
Normal 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
17
backend/.editorconfig
Normal 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
54
backend/.env
Normal 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
4
backend/.env.dev
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
###> symfony/framework-bundle ###
|
||||||
|
APP_SECRET=6a99dc78ab52a33deba7f8bd986720dc
|
||||||
|
###< symfony/framework-bundle ###
|
||||||
14
backend/.gitignore
vendored
Normal file
14
backend/.gitignore
vendored
Normal 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
21
backend/bin/console
Executable 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);
|
||||||
|
};
|
||||||
7
backend/compose.override.yaml
Normal file
7
backend/compose.override.yaml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
services:
|
||||||
|
###> doctrine/doctrine-bundle ###
|
||||||
|
database:
|
||||||
|
ports:
|
||||||
|
- "5432"
|
||||||
|
###< doctrine/doctrine-bundle ###
|
||||||
25
backend/compose.yaml
Normal file
25
backend/compose.yaml
Normal 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
90
backend/composer.json
Normal 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
8273
backend/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
backend/config/bundles.php
Normal file
13
backend/config/bundles.php
Normal 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],
|
||||||
|
];
|
||||||
7
backend/config/packages/api_platform.yaml
Normal file
7
backend/config/packages/api_platform.yaml
Normal 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']
|
||||||
19
backend/config/packages/cache.yaml
Normal file
19
backend/config/packages/cache.yaml
Normal 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
|
||||||
46
backend/config/packages/doctrine.yaml
Normal file
46
backend/config/packages/doctrine.yaml
Normal 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
|
||||||
6
backend/config/packages/doctrine_migrations.yaml
Normal file
6
backend/config/packages/doctrine_migrations.yaml
Normal 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
|
||||||
15
backend/config/packages/framework.yaml
Normal file
15
backend/config/packages/framework.yaml
Normal 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
|
||||||
4
backend/config/packages/lexik_jwt_authentication.yaml
Normal file
4
backend/config/packages/lexik_jwt_authentication.yaml
Normal 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)%'
|
||||||
22
backend/config/packages/messenger.yaml
Normal file
22
backend/config/packages/messenger.yaml
Normal 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://'
|
||||||
10
backend/config/packages/nelmio_cors.yaml
Normal file
10
backend/config/packages/nelmio_cors.yaml
Normal 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
|
||||||
3
backend/config/packages/property_info.yaml
Normal file
3
backend/config/packages/property_info.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
framework:
|
||||||
|
property_info:
|
||||||
|
with_constructor_extractor: true
|
||||||
10
backend/config/packages/routing.yaml
Normal file
10
backend/config/packages/routing.yaml
Normal 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
|
||||||
55
backend/config/packages/security.yaml
Normal file
55
backend/config/packages/security.yaml
Normal 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
|
||||||
6
backend/config/packages/twig.yaml
Normal file
6
backend/config/packages/twig.yaml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
twig:
|
||||||
|
file_name_pattern: '*.twig'
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
twig:
|
||||||
|
strict_variables: true
|
||||||
11
backend/config/packages/validator.yaml
Normal file
11
backend/config/packages/validator.yaml
Normal 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
|
||||||
5
backend/config/preload.php
Normal file
5
backend/config/preload.php
Normal 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
1817
backend/config/reference.php
Normal file
File diff suppressed because it is too large
Load Diff
11
backend/config/routes.yaml
Normal file
11
backend/config/routes.yaml
Normal 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
|
||||||
4
backend/config/routes/api_platform.yaml
Normal file
4
backend/config/routes/api_platform.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
api_platform:
|
||||||
|
resource: .
|
||||||
|
type: api_platform
|
||||||
|
prefix: /api
|
||||||
4
backend/config/routes/framework.yaml
Normal file
4
backend/config/routes/framework.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
when@dev:
|
||||||
|
_errors:
|
||||||
|
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
||||||
|
prefix: /_error
|
||||||
3
backend/config/routes/security.yaml
Normal file
3
backend/config/routes/security.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
_security_logout:
|
||||||
|
resource: security.route_loader.logout
|
||||||
|
type: service
|
||||||
23
backend/config/services.yaml
Normal file
23
backend/config/services.yaml
Normal 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
0
backend/migrations/.gitignore
vendored
Normal file
65
backend/migrations/Version20260530191712.php
Normal file
65
backend/migrations/Version20260530191712.php
Normal 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`');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/migrations/Version20260531085615.php
Normal file
33
backend/migrations/Version20260531085615.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
82
backend/public/assets/brand.css
Normal file
82
backend/public/assets/brand.css
Normal 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
9
backend/public/index.php
Normal 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
0
backend/src/ApiResource/.gitignore
vendored
Normal file
37
backend/src/Command/BackfillShortCodesCommand.php
Normal file
37
backend/src/Command/BackfillShortCodesCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
backend/src/Command/SeedCommand.php
Normal file
121
backend/src/Command/SeedCommand.php
Normal 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
0
backend/src/Controller/.gitignore
vendored
Normal file
77
backend/src/Controller/CompanyBrandingController.php
Normal file
77
backend/src/Controller/CompanyBrandingController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
backend/src/Controller/MeController.php
Normal file
38
backend/src/Controller/MeController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
backend/src/Controller/PublicProfileController.php
Normal file
97
backend/src/Controller/PublicProfileController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
backend/src/Controller/ResellerProvisioningController.php
Normal file
79
backend/src/Controller/ResellerProvisioningController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/src/Controller/SecurityController.php
Normal file
20
backend/src/Controller/SecurityController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
backend/src/Controller/ShortLinkController.php
Normal file
34
backend/src/Controller/ShortLinkController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
backend/src/Doctrine/TenantExtension.php
Normal file
120
backend/src/Doctrine/TenantExtension.php
Normal 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
0
backend/src/Entity/.gitignore
vendored
Normal file
182
backend/src/Entity/Company.php
Normal file
182
backend/src/Entity/Company.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
backend/src/Entity/ContactLink.php
Normal file
113
backend/src/Entity/ContactLink.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
134
backend/src/Entity/Domain.php
Normal file
134
backend/src/Entity/Domain.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
374
backend/src/Entity/Employee.php
Normal file
374
backend/src/Entity/Employee.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
173
backend/src/Entity/Location.php
Normal file
173
backend/src/Entity/Location.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
131
backend/src/Entity/PlatformPlan.php
Normal file
131
backend/src/Entity/PlatformPlan.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
145
backend/src/Entity/Reseller.php
Normal file
145
backend/src/Entity/Reseller.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/Entity/ResellerOwnedInterface.php
Normal file
13
backend/src/Entity/ResellerOwnedInterface.php
Normal 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
183
backend/src/Entity/User.php
Normal 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
11
backend/src/Kernel.php
Normal 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
0
backend/src/Repository/.gitignore
vendored
Normal file
18
backend/src/Repository/CompanyRepository.php
Normal file
18
backend/src/Repository/CompanyRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/Repository/ContactLinkRepository.php
Normal file
18
backend/src/Repository/ContactLinkRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/Repository/DomainRepository.php
Normal file
18
backend/src/Repository/DomainRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
backend/src/Repository/EmployeeRepository.php
Normal file
51
backend/src/Repository/EmployeeRepository.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/Repository/LocationRepository.php
Normal file
18
backend/src/Repository/LocationRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/Repository/PlatformPlanRepository.php
Normal file
18
backend/src/Repository/PlatformPlanRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/Repository/ResellerRepository.php
Normal file
18
backend/src/Repository/ResellerRepository.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/src/Repository/UserRepository.php
Normal file
32
backend/src/Repository/UserRepository.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
38
backend/src/Security/TenantContext.php
Normal file
38
backend/src/Security/TenantContext.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
backend/src/Service/VCardBuilder.php
Normal file
73
backend/src/Service/VCardBuilder.php
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
backend/src/State/TenantStampProcessor.php
Normal file
102
backend/src/State/TenantStampProcessor.php
Normal 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
214
backend/symfony.lock
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/templates/base.html.twig
Normal file
16
backend/templates/base.html.twig
Normal 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>
|
||||||
154
backend/templates/public/profile.html.twig
Normal file
154
backend/templates/public/profile.html.twig
Normal 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
54
docker-compose.yml
Normal 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
25
docker/nginx/default.conf
Normal 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
25
docker/php/Dockerfile
Normal 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
|
||||||
@ -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.
|
||||||
|
|||||||
BIN
docs/design-reference/website-home.jpeg
Normal file
BIN
docs/design-reference/website-home.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 782 KiB |
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
5
frontend/README.md
Normal 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
13
frontend/index.html
Normal 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
1759
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
frontend/package.json
Normal file
25
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
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
24
frontend/public/icons.svg
Normal 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
7
frontend/src/App.vue
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
38
frontend/src/api/client.ts
Normal file
38
frontend/src/api/client.ts
Normal 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
|
||||||
39
frontend/src/api/resources.ts
Normal file
39
frontend/src/api/resources.ts
Normal 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/, ''))
|
||||||
|
}
|
||||||
78
frontend/src/assets/brand.css
Normal file
78
frontend/src/assets/brand.css
Normal 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); }
|
||||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal 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 |
95
frontend/src/components/HelloWorld.vue
Normal file
95
frontend/src/components/HelloWorld.vue
Normal 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>
|
||||||
32
frontend/src/components/Modal.vue
Normal file
32
frontend/src/components/Modal.vue
Normal 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>
|
||||||
91
frontend/src/layouts/DashboardLayout.vue
Normal file
91
frontend/src/layouts/DashboardLayout.vue
Normal 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
10
frontend/src/main.ts
Normal 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')
|
||||||
41
frontend/src/router/index.ts
Normal file
41
frontend/src/router/index.ts
Normal 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
|
||||||
55
frontend/src/stores/auth.ts
Normal file
55
frontend/src/stores/auth.ts
Normal 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
296
frontend/src/style.css
Normal 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
Loading…
Reference in New Issue
Block a user