Rechte: User in Employee verschmolzen (eine Identität pro Person)

Beseitigt die Doppelung Admin-Login vs. Mitarbeiter — jeder ist ein Employee
mit optionalem Login/Rechtegruppe (Voraussetzung für Mitarbeiter-Zeiterfassung).

- Employee implementiert UserInterface/PasswordAuthenticated (loginEmail unique,
  password, roles); User-Entität entfernt; Security-Provider → Employee.loginEmail
- Plattform = Reseller mit isPlatform + Org-Firma; Reseller haben Org-Firma
  (Company.isResellerOrg) für ihr Personal → alles = Reseller→Firma→Mitarbeiter
- TenantContext leitet Reseller/Company aus dem Mitarbeiter ab (Reseller-/
  Plattform-Admin = reseller-weit)
- UserAdminController: Login pro Mitarbeiter vergeben/entziehen
  (POST/DELETE /api/employees/{id}/login), /api/users = Logins-Übersicht
- Provisioning/Seed auf das neue Modell; Migrationen zu einer Baseline gesquasht
- Frontend: EmployeesView Login-Block + UsersView (Logins-Übersicht)

Verifiziert: Login, /me, Mandantenscoping, delegierter Grant (Eskalation→403),
öffentliches Profil, SPA-Flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-01 17:27:38 +02:00
parent cac6b26a0d
commit bcc06e697b
19 changed files with 327 additions and 654 deletions

View File

@ -5,8 +5,8 @@ security:
providers: providers:
app_user_provider: app_user_provider:
entity: entity:
class: App\Entity\User class: App\Entity\Employee
property: email property: loginEmail
firewalls: firewalls:
dev: dev:

View File

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260531085615 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE employee ADD short_code VARCHAR(16) DEFAULT NULL');
$this->addSql('CREATE UNIQUE INDEX uniq_employee_shortcode ON employee (short_code)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP INDEX uniq_employee_shortcode ON employee');
$this->addSql('ALTER TABLE employee DROP short_code');
}
}

View File

@ -1,33 +0,0 @@
<?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 Version20260531092327 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 card_template (id BINARY(16) NOT NULL, name VARCHAR(120) NOT NULL, type VARCHAR(20) NOT NULL, width_mm DOUBLE PRECISION NOT NULL, height_mm DOUBLE PRECISION NOT NULL, bleed_mm DOUBLE PRECISION NOT NULL, safe_mm DOUBLE PRECISION NOT NULL, front JSON NOT NULL, back JSON NOT NULL, created_at DATETIME NOT NULL, company_id BINARY(16) DEFAULT NULL, INDEX IDX_2E51D100979B1AD6 (company_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE card_template ADD CONSTRAINT FK_2E51D100979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE card_template DROP FOREIGN KEY FK_2E51D100979B1AD6');
$this->addSql('DROP TABLE card_template');
}
}

View File

@ -1,34 +0,0 @@
<?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 Version20260531150051 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// Hintergrund-PDF-Pfad + eigene Schriften. fonts wird nullable hinzugefügt und
// für bestehende Zeilen mit '[]' befüllt (sonst verletzt '' den JSON-CHECK), dann NOT NULL.
$this->addSql('ALTER TABLE card_template ADD background_path VARCHAR(255) DEFAULT NULL, ADD fonts JSON DEFAULT NULL');
$this->addSql("UPDATE card_template SET fonts = '[]' WHERE fonts IS NULL");
$this->addSql('ALTER TABLE card_template MODIFY fonts JSON NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE card_template DROP background_path, DROP fonts');
}
}

View File

@ -10,7 +10,7 @@ use Doctrine\Migrations\AbstractMigration;
/** /**
* Auto-generated Migration: Please modify to your needs! * Auto-generated Migration: Please modify to your needs!
*/ */
final class Version20260530191712 extends AbstractMigration final class Version20260601135652 extends AbstractMigration
{ {
public function getDescription(): string public function getDescription(): string
{ {
@ -20,14 +20,15 @@ final class Version20260530191712 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
// this up() migration is auto-generated, please modify it to your needs // 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 card_template (id BINARY(16) NOT NULL, name VARCHAR(120) NOT NULL, type VARCHAR(20) NOT NULL, width_mm DOUBLE PRECISION NOT NULL, height_mm DOUBLE PRECISION NOT NULL, bleed_mm DOUBLE PRECISION NOT NULL, safe_mm DOUBLE PRECISION NOT NULL, front JSON NOT NULL, back JSON NOT NULL, background_path VARCHAR(255) DEFAULT NULL, fonts JSON NOT NULL, created_at DATETIME NOT NULL, company_id BINARY(16) DEFAULT NULL, INDEX IDX_2E51D100979B1AD6 (company_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$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, is_reseller_org TINYINT 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 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 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 employee (id BINARY(16) NOT NULL, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, slug VARCHAR(120) NOT NULL, short_code VARCHAR(16) DEFAULT 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, login_email VARCHAR(180) DEFAULT NULL, password VARCHAR(255) DEFAULT NULL, roles 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), UNIQUE INDEX uniq_employee_shortcode (short_code), UNIQUE INDEX uniq_employee_login_email (login_email), 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 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 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 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, is_platform TINYINT 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 card_template ADD CONSTRAINT FK_2E51D100979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
$this->addSql('ALTER TABLE company ADD CONSTRAINT FK_4FBF094F91E6A19D FOREIGN KEY (reseller_id) REFERENCES reseller (id)'); $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 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 domain ADD CONSTRAINT FK_A7A91E0B979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
@ -35,14 +36,12 @@ final class Version20260530191712 extends AbstractMigration
$this->addSql('ALTER TABLE employee ADD CONSTRAINT FK_5D9F75A164D218E FOREIGN KEY (location_id) REFERENCES location (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 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 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 public function down(Schema $schema): void
{ {
// this down() migration is auto-generated, please modify it to your needs // this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE card_template DROP FOREIGN KEY FK_2E51D100979B1AD6');
$this->addSql('ALTER TABLE company DROP FOREIGN KEY FK_4FBF094F91E6A19D'); $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 contact_link DROP FOREIGN KEY FK_1E531B0E8C03F15C');
$this->addSql('ALTER TABLE domain DROP FOREIGN KEY FK_A7A91E0B979B1AD6'); $this->addSql('ALTER TABLE domain DROP FOREIGN KEY FK_A7A91E0B979B1AD6');
@ -50,9 +49,7 @@ final class Version20260530191712 extends AbstractMigration
$this->addSql('ALTER TABLE employee DROP FOREIGN KEY FK_5D9F75A164D218E'); $this->addSql('ALTER TABLE employee DROP FOREIGN KEY FK_5D9F75A164D218E');
$this->addSql('ALTER TABLE location DROP FOREIGN KEY FK_5E9E89CB979B1AD6'); $this->addSql('ALTER TABLE location DROP FOREIGN KEY FK_5E9E89CB979B1AD6');
$this->addSql('ALTER TABLE reseller DROP FOREIGN KEY FK_18015899FDA9C8C9'); $this->addSql('ALTER TABLE reseller DROP FOREIGN KEY FK_18015899FDA9C8C9');
$this->addSql('ALTER TABLE `user` DROP FOREIGN KEY FK_8D93D64991E6A19D'); $this->addSql('DROP TABLE card_template');
$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 company');
$this->addSql('DROP TABLE contact_link'); $this->addSql('DROP TABLE contact_link');
$this->addSql('DROP TABLE domain'); $this->addSql('DROP TABLE domain');
@ -60,6 +57,5 @@ final class Version20260530191712 extends AbstractMigration
$this->addSql('DROP TABLE location'); $this->addSql('DROP TABLE location');
$this->addSql('DROP TABLE platform_plan'); $this->addSql('DROP TABLE platform_plan');
$this->addSql('DROP TABLE reseller'); $this->addSql('DROP TABLE reseller');
$this->addSql('DROP TABLE `user`');
} }
} }

View File

@ -8,16 +8,15 @@ use App\Entity\Employee;
use App\Entity\Location; use App\Entity\Location;
use App\Entity\PlatformPlan; use App\Entity\PlatformPlan;
use App\Entity\Reseller; use App\Entity\Reseller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(name: 'app:seed', description: 'Legt Demo-Daten an (Admin, Reseller, Firmen, Mitarbeiter).')] #[AsCommand(name: 'app:seed', description: 'Demo-Daten (Plattform/Reseller/Firmen/Mitarbeiter, alles als Employee).')]
final class SeedCommand extends Command final class SeedCommand extends Command
{ {
public function __construct( public function __construct(
@ -30,91 +29,88 @@ final class SeedCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
if (null !== $this->em->getRepository(Employee::class)->findOneBy(['loginEmail' => 'admin@vcard4reseller.de'])) {
if (null !== $this->em->getRepository(User::class)->findOneBy(['email' => 'admin@vcard4reseller.de'])) {
$io->warning('Demo-Daten existieren bereits — übersprungen.'); $io->warning('Demo-Daten existieren bereits — übersprungen.');
return Command::SUCCESS; return Command::SUCCESS;
} }
// Plattform-Paket $plan = (new PlatformPlan())->setName('Professional')->setSlug('professional')
$plan = (new PlatformPlan())
->setName('Professional')->setSlug('professional')
->setPricePerMonth(24900)->setMaxProfiles(500)->setMaxCompanies(8) ->setPricePerMonth(24900)->setMaxProfiles(500)->setMaxCompanies(8)
->setFeatures(['vcard', 'wallet', 'nfc', 'print']); ->setFeatures(['vcard', 'wallet', 'nfc', 'print']);
$this->em->persist($plan); $this->em->persist($plan);
// Plattform-Admin // Plattform-Betreiber = Reseller mit isPlatform + Org-Firma + 2 Plattform-Admins
$admin = (new User())->setEmail('admin@vcard4reseller.de')->setRoles([User::ROLE_PLATFORM_ADMIN]); [$platform, $pOrg] = $this->reseller('vcard4reseller', 'platform', $plan, true);
$admin->setPassword($this->hasher->hashPassword($admin, 'admin')); $this->staff($pOrg, 'Thomas', 'Peterson', 'admin@vcard4reseller.de', 'admin', Employee::ROLE_PLATFORM_ADMIN);
$this->em->persist($admin); $this->staff($pOrg, 'Co', 'Admin', 'admin2@vcard4reseller.de', 'admin', Employee::ROLE_PLATFORM_ADMIN);
// Zwei Reseller mit je einer Firma (Beweis der Mandantentrennung) // Reseller „Demo Druckerei" + Org-Firma + Reseller-Admin + Kundenfirma
$this->createReseller($plan, 'Demo Druckerei', 'demo', 'reseller@demo.de', 'Muster GmbH', 'muster', 'firma@muster.de', 'Erika', 'Mustermann'); [$demo, $dOrg] = $this->reseller('Demo Druckerei', 'demo', $plan, false);
$this->createReseller($plan, 'Print Studio', 'printstudio', 'reseller@printstudio.de', 'Beispiel AG', 'beispiel', 'firma@beispiel.de', 'Max', 'Beispiel'); $this->staff($dOrg, 'Demo', 'Reseller', 'reseller@demo.de', 'reseller', Employee::ROLE_RESELLER_ADMIN);
$this->customer($demo, 'Muster GmbH', 'muster', 'firma@muster.de', 'firma', 'Erika', 'Mustermann');
// Reseller „Print Studio"
[$ps, $psOrg] = $this->reseller('Print Studio', 'printstudio', $plan, false);
$this->staff($psOrg, 'Print', 'Studio', 'reseller@printstudio.de', 'reseller', Employee::ROLE_RESELLER_ADMIN);
$this->customer($ps, 'Beispiel AG', 'beispiel', 'firma@beispiel.de', 'firma', 'Max', 'Beispiel');
$this->em->flush(); $this->em->flush();
$io->success('Demo-Daten angelegt.'); $io->success('Demo-Daten angelegt (alles als Mitarbeiter mit optionalem Login).');
$io->table(['Rolle', 'E-Mail', 'Passwort'], [ $io->table(['Rechtegruppe', 'Login', 'Passwort'], [
['Plattform-Admin', 'admin@vcard4reseller.de', 'admin'], ['Plattform-Admin', 'admin@vcard4reseller.de', 'admin'],
['Reseller-Admin', 'reseller@demo.de', 'reseller'], ['Reseller-Admin', 'reseller@demo.de', 'reseller'],
['Reseller-Admin', 'reseller@printstudio.de', 'reseller'],
['Firmen-Admin', 'firma@muster.de', 'firma'], ['Firmen-Admin', 'firma@muster.de', 'firma'],
['Firmen-Admin', 'firma@beispiel.de', 'firma'],
]); ]);
return Command::SUCCESS; return Command::SUCCESS;
} }
private function createReseller( /** @return array{0: Reseller, 1: Company} Reseller + Org-Firma */
PlatformPlan $plan, private function reseller(string $name, string $slug, PlatformPlan $plan, bool $isPlatform): array
string $resellerName, {
string $resellerSlug, $reseller = (new Reseller())->setName($name)->setSlug($slug)
string $resellerEmail, ->setPrimaryDomain($slug.'.vcard4reseller.de')->setPlatformPlan($plan)->setIsPlatform($isPlatform);
string $companyName, $org = (new Company())->setName($name)->setSlug($slug.'-team')->setReseller($reseller)->setIsResellerOrg(true);
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); $this->em->persist($reseller);
$this->em->persist($org);
$resellerAdmin = (new User()) return [$reseller, $org];
->setEmail($resellerEmail)->setRoles([User::ROLE_RESELLER_ADMIN])->setReseller($reseller); }
$resellerAdmin->setPassword($this->hasher->hashPassword($resellerAdmin, 'reseller'));
$this->em->persist($resellerAdmin);
$company = (new Company()) private function staff(Company $company, string $first, string $last, string $email, string $pw, string $role): Employee
->setName($companyName)->setSlug($companySlug)->setReseller($reseller)->setSelfEditEnabled(true); {
$e = (new Employee())->setFirstName($first)->setLastName($last)
->setSlug(strtolower($first.'-'.$last))->setCompany($company)
->setEmail($email)->setLoginEmail($email)->setRoles([$role]);
$e->setPassword($this->hasher->hashPassword($e, $pw));
$this->em->persist($e);
return $e;
}
private function customer(Reseller $reseller, string $companyName, string $companySlug, string $adminEmail, string $adminPw, string $first, string $last): void
{
$company = (new Company())->setName($companyName)->setSlug($companySlug)->setReseller($reseller)->setSelfEditEnabled(true);
$this->em->persist($company); $this->em->persist($company);
$companyAdmin = (new User()) $location = (new Location())->setName('Hauptsitz')->setStreet('Musterstr. 1')
->setEmail($companyEmail)->setRoles([User::ROLE_COMPANY_ADMIN]) ->setPostalCode('10115')->setCity('Berlin')->setCountry('DE')->setCompany($company);
->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); $this->em->persist($location);
$employee = (new Employee()) // Firmen-Admin (Mitarbeiter mit Login)
->setFirstName($firstName)->setLastName($lastName) $this->staff($company, 'Firmen', 'Admin', $adminEmail, $adminPw, Employee::ROLE_COMPANY_ADMIN)
->setSlug(strtolower($firstName.'-'.$lastName)) ->setSlug('firmen-admin');
->setPosition('Geschäftsführung')->setEmail(strtolower($firstName).'@'.$companySlug.'.de')
->setPhone('+49 30 1234567')->setCompany($company)->setLocation($location) // Öffentliches Profil-Beispiel (ohne Login)
->setSelfEditAllowed(true); $employee = (new Employee())->setFirstName($first)->setLastName($last)
->setSlug(strtolower($first.'-'.$last))->setPosition('Geschäftsführung')
->setEmail(strtolower($first).'@'.$companySlug.'.de')->setPhone('+49 30 1234567')
->setCompany($company)->setLocation($location)->setSelfEditAllowed(true);
$this->em->persist($employee); $this->em->persist($employee);
$link = (new ContactLink()) $link = (new ContactLink())->setType('linkedin')->setUrl('https://linkedin.com/in/'.strtolower($first))->setPosition(0);
->setType('linkedin')->setUrl('https://linkedin.com/in/'.strtolower($firstName))
->setPosition(0);
$employee->addContactLink($link); $employee->addContactLink($link);
$this->em->persist($link); $this->em->persist($link);
} }

View File

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

View File

@ -2,9 +2,10 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\Company;
use App\Entity\Employee;
use App\Entity\PlatformPlan; use App\Entity\PlatformPlan;
use App\Entity\Reseller; use App\Entity\Reseller;
use App\Entity\User;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
@ -14,8 +15,8 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
/** /**
* Legt einen Reseller an optional zusammen mit seinem Admin-Benutzer, * Legt einen Reseller an: Reseller + Org-Firma (für sein Personal) + optional
* damit sich der Reseller direkt einloggen kann. Nur für Plattform-Admins. * einen Admin-Mitarbeiter mit Login. Nur für Plattform-Admins.
*/ */
#[IsGranted('ROLE_PLATFORM_ADMIN')] #[IsGranted('ROLE_PLATFORM_ADMIN')]
final class ResellerProvisioningController final class ResellerProvisioningController
@ -48,19 +49,32 @@ final class ResellerProvisioningController
} }
} }
// Org-Firma des Resellers (beherbergt dessen Personal)
$orgCompany = (new Company())
->setName($name)
->setSlug($slug.'-team')
->setReseller($reseller)
->setIsResellerOrg(true);
$this->em->persist($reseller);
$this->em->persist($orgCompany);
$adminEmail = trim((string) ($d['adminEmail'] ?? '')); $adminEmail = trim((string) ($d['adminEmail'] ?? ''));
$adminPassword = (string) ($d['adminPassword'] ?? ''); $adminPassword = (string) ($d['adminPassword'] ?? '');
$admin = null; $admin = null;
if ('' !== $adminEmail && '' !== $adminPassword) { if ('' !== $adminEmail && '' !== $adminPassword) {
$admin = (new User()) $admin = (new Employee())
->setEmail($adminEmail) ->setFirstName((string) ($d['adminFirstName'] ?? 'Admin'))
->setRoles([User::ROLE_RESELLER_ADMIN]) ->setLastName((string) ($d['adminLastName'] ?? $name))
->setReseller($reseller); ->setSlug('admin')
->setCompany($orgCompany)
->setLoginEmail($adminEmail)
->setRoles([Employee::ROLE_RESELLER_ADMIN]);
$admin->setEmail($adminEmail);
$admin->setPassword($this->hasher->hashPassword($admin, $adminPassword)); $admin->setPassword($this->hasher->hashPassword($admin, $adminPassword));
} }
try { try {
$this->em->persist($reseller);
if ($admin) { if ($admin) {
$this->em->persist($admin); $this->em->persist($admin);
} }

View File

@ -2,15 +2,11 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\Company;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\Reseller;
use App\Entity\User;
use App\Security\TenantContext; use App\Security\TenantContext;
use App\Service\RoleService; use App\Service\RoleService;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@ -22,18 +18,17 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
/** /**
* Benutzerverwaltung mit delegierter Rechtevergabe (KONZEPT §2). * Logins/Rechtegruppen werden pro Mitarbeiter vergeben (KONZEPT §2 + Merge).
* Ab Firmen-Admin; jede Rollenvergabe wird vom RoleService scope-/level-geprüft. * Delegiert & scope-geprüft über RoleService.
*/ */
#[IsGranted('ROLE_COMPANY_ADMIN')] #[IsGranted('ROLE_COMPANY_ADMIN')]
#[Route('/api/users')]
final class UserAdminController final class UserAdminController
{ {
private const ROLE_TO_GROUP = [ private const ROLE_TO_GROUP = [
User::ROLE_PLATFORM_ADMIN => 'platform_admin', Employee::ROLE_PLATFORM_ADMIN => 'platform_admin',
User::ROLE_RESELLER_ADMIN => 'reseller_admin', Employee::ROLE_RESELLER_ADMIN => 'reseller_admin',
User::ROLE_COMPANY_ADMIN => 'company_admin', Employee::ROLE_COMPANY_ADMIN => 'company_admin',
User::ROLE_EMPLOYEE => 'employee', Employee::ROLE_EMPLOYEE => 'employee',
]; ];
public function __construct( public function __construct(
@ -41,177 +36,124 @@ final class UserAdminController
private readonly RoleService $roles, private readonly RoleService $roles,
private readonly TenantContext $tenant, private readonly TenantContext $tenant,
private readonly UserPasswordHasherInterface $hasher, private readonly UserPasswordHasherInterface $hasher,
private readonly Security $security,
) { ) {
} }
#[Route('/assignable-groups', name: 'users_assignable_groups', methods: ['GET'])] #[Route('/api/users/assignable-groups', name: 'users_assignable_groups', methods: ['GET'])]
public function assignableGroups(): JsonResponse public function assignableGroups(): JsonResponse
{ {
return new JsonResponse(['groups' => $this->roles->assignableGroups()]); return new JsonResponse(['groups' => $this->roles->assignableGroups()]);
} }
#[Route('', name: 'users_list', methods: ['GET'])] /** Übersicht aller Mitarbeiter mit Login (mandantengescoped). */
#[Route('/api/users', name: 'users_list', methods: ['GET'])]
public function list(): JsonResponse public function list(): JsonResponse
{ {
$qb = $this->em->getRepository(User::class)->createQueryBuilder('u'); $qb = $this->em->getRepository(Employee::class)->createQueryBuilder('e')
->join('e.company', 'c')
->andWhere('e.loginEmail IS NOT NULL');
// Scope: Plattform sieht alle; Reseller nur eigenen Reseller; Firma nur eigene Firma
if (!$this->tenant->isPlatformAdmin()) { if (!$this->tenant->isPlatformAdmin()) {
if (null !== $company = $this->tenant->getCompany()) { if (null !== $company = $this->tenant->getCompany()) {
$qb->andWhere('u.company = :company')->setParameter('company', $company->getId(), 'uuid'); $qb->andWhere('c.id = :cid')->setParameter('cid', $company->getId(), 'uuid');
} elseif (null !== $reseller = $this->tenant->getReseller()) { } elseif (null !== $reseller = $this->tenant->getReseller()) {
$qb->andWhere('u.reseller = :reseller')->setParameter('reseller', $reseller->getId(), 'uuid'); $qb->andWhere('c.reseller = :rid')->setParameter('rid', $reseller->getId(), 'uuid');
} }
} }
$users = array_map($this->serialize(...), $qb->getQuery()->getResult()); $rows = array_map($this->serialize(...), $qb->getQuery()->getResult());
return new JsonResponse(['member' => $users, 'totalItems' => count($users)]); return new JsonResponse(['member' => $rows, 'totalItems' => count($rows)]);
} }
#[Route('', name: 'users_create', methods: ['POST'])] #[Route('/api/employees/{id}/login', name: 'employee_login_grant', methods: ['POST'])]
public function create(Request $request): JsonResponse public function grant(string $id, Request $request): JsonResponse
{ {
$employee = $this->employee($id);
$d = json_decode($request->getContent(), true) ?? []; $d = json_decode($request->getContent(), true) ?? [];
$email = trim((string) ($d['email'] ?? ''));
$password = (string) ($d['password'] ?? '');
$group = (string) ($d['group'] ?? ''); $group = (string) ($d['group'] ?? '');
if ('' === $email || '' === $password || '' === $group) { $password = (string) ($d['password'] ?? '');
throw new BadRequestHttpException('email, password und group sind erforderlich.'); $loginEmail = trim((string) ($d['loginEmail'] ?? $employee->getEmail() ?? ''));
if ('' === $password || '' === $loginEmail) {
throw new BadRequestHttpException('Login-E-Mail und Passwort erforderlich.');
} }
$this->roles->assertCanAssign($group, $employee->getCompany()->getReseller(), $employee->getCompany());
[$reseller, $company, $employee] = $this->resolveTarget($d, $group); $employee->setLoginEmail($loginEmail)
$this->roles->assertCanAssign($group, $reseller, $company); ->setRoles([$this->roles->roleForGroup($group)]);
$employee->setPassword($this->hasher->hashPassword($employee, $password));
$user = (new User())
->setEmail($email)
->setRoles([$this->roles->roleForGroup($group)])
->setReseller($reseller)
->setCompany($company)
->setEmployee($employee);
$user->setPassword($this->hasher->hashPassword($user, $password));
try { try {
$this->em->persist($user);
$this->em->flush(); $this->em->flush();
} catch (UniqueConstraintViolationException) { } catch (UniqueConstraintViolationException) {
throw new BadRequestHttpException('E-Mail bereits vergeben.'); throw new BadRequestHttpException('Login-E-Mail bereits vergeben.');
} }
return new JsonResponse($this->serialize($user), 201); return new JsonResponse($this->serialize($employee), 201);
} }
#[Route('/{id}', name: 'users_update', methods: ['PATCH'])] #[Route('/api/employees/{id}/login', name: 'employee_login_revoke', methods: ['DELETE'])]
public function update(string $id, Request $request): JsonResponse public function revoke(string $id): JsonResponse
{ {
$user = $this->find($id); $employee = $this->employee($id);
$this->assertInScope($user); if ($this->tenant->getEmployee() === $employee) {
$d = json_decode($request->getContent(), true) ?? []; throw new AccessDeniedHttpException('Eigenen Zugang nicht entziehen.');
if (isset($d['group'])) {
$group = (string) $d['group'];
$this->roles->assertCanAssign($group, $user->getReseller(), $user->getCompany());
$user->setRoles([$this->roles->roleForGroup($group)]);
} }
if (isset($d['status'])) { $employee->setLoginEmail(null)->setPassword(null)->setRoles([]);
$user->setStatus((string) $d['status']);
}
if (!empty($d['password'])) {
$user->setPassword($this->hasher->hashPassword($user, (string) $d['password']));
}
$this->em->flush();
return new JsonResponse($this->serialize($user));
}
#[Route('/{id}', name: 'users_delete', methods: ['DELETE'])]
public function delete(string $id): JsonResponse
{
$user = $this->find($id);
$this->assertInScope($user);
if ($this->security->getUser() === $user) {
throw new AccessDeniedHttpException('Eigenen Zugang nicht löschbar.');
}
$this->em->remove($user);
$this->em->flush(); $this->em->flush();
return new JsonResponse(null, 204); return new JsonResponse(null, 204);
} }
/** @return array{0: ?Reseller, 1: ?Company, 2: ?Employee} */ private function employee(string $id): Employee
private function resolveTarget(array $d, string $group): array
{ {
$employee = !empty($d['employeeId']) $employee = $this->em->getRepository(Employee::class)->find(Uuid::fromString($id));
? $this->em->getRepository(Employee::class)->find(Uuid::fromString($d['employeeId'])) if (!$employee instanceof Employee) {
: null; throw new NotFoundHttpException('Mitarbeiter nicht gefunden.');
$company = $employee?->getCompany()
?? (!empty($d['companyId']) ? $this->em->getRepository(Company::class)->find(Uuid::fromString($d['companyId'])) : null);
$reseller = $company?->getReseller()
?? (!empty($d['resellerId']) ? $this->em->getRepository(Reseller::class)->find(Uuid::fromString($d['resellerId'])) : null);
// Reseller-Admin legt Peer/Untergebene im eigenen Reseller an, ohne resellerId
if (null === $reseller && null === $company && 'reseller_admin' === $group) {
$reseller = $this->tenant->getReseller();
}
// Firmen-Admin ohne Angabe → eigene Firma
if (null === $company && in_array($group, ['company_admin', 'employee'], true)) {
$company = $this->tenant->getCompany();
$reseller = $company?->getReseller() ?? $reseller;
} }
$this->assertInScope($employee);
return [$reseller, $company, $employee]; return $employee;
} }
private function find(string $id): User private function assertInScope(Employee $employee): void
{
$user = $this->em->getRepository(User::class)->find(Uuid::fromString($id));
if (!$user instanceof User) {
throw new NotFoundHttpException('Benutzer nicht gefunden.');
}
return $user;
}
/** Ziel-Benutzer muss im Mandanten-Teilbaum des Akteurs liegen. */
private function assertInScope(User $user): void
{ {
if ($this->tenant->isPlatformAdmin()) { if ($this->tenant->isPlatformAdmin()) {
return; return;
} }
if (null !== $company = $this->tenant->getCompany()) { if (null !== $company = $this->tenant->getCompany()) {
if (null === $user->getCompany() || !$user->getCompany()->getId()->equals($company->getId())) { if (!$employee->getCompany()->getId()->equals($company->getId())) {
throw new AccessDeniedHttpException('Außerhalb der eigenen Firma.'); throw new AccessDeniedHttpException('Außerhalb der eigenen Firma.');
} }
return; return;
} }
if (null !== $reseller = $this->tenant->getReseller()) { if (null !== $reseller = $this->tenant->getReseller()) {
if (null === $user->getReseller() || !$user->getReseller()->getId()->equals($reseller->getId())) { if ($employee->getCompany()->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Außerhalb des eigenen Resellers.'); throw new AccessDeniedHttpException('Außerhalb des eigenen Resellers.');
} }
} }
} }
private function serialize(User $u): array private function serialize(Employee $e): array
{ {
$group = 'employee'; $group = 'employee';
foreach (self::ROLE_TO_GROUP as $role => $g) { foreach (self::ROLE_TO_GROUP as $role => $g) {
if (in_array($role, $u->getRoles(), true)) { if (in_array($role, $e->getRoles(), true)) {
$group = $g; $group = $g;
break; break;
} }
} }
$company = $e->getCompany();
return [ return [
'id' => (string) $u->getId(), 'employeeId' => (string) $e->getId(),
'email' => $u->getEmail(), 'email' => $e->getLoginEmail(),
'name' => trim($e->getFirstName().' '.$e->getLastName()),
'group' => $group, 'group' => $group,
'status' => $u->getStatus(), 'company' => ['id' => (string) $company->getId(), 'name' => $company->getName()],
'reseller' => $u->getReseller() ? ['id' => (string) $u->getReseller()->getId(), 'name' => $u->getReseller()->getName()] : null, 'reseller' => $company->getReseller() ? ['id' => (string) $company->getReseller()->getId(), 'name' => $company->getReseller()->getName()] : null,
'company' => $u->getCompany() ? ['id' => (string) $u->getCompany()->getId(), 'name' => $u->getCompany()->getName()] : null,
'employeeId' => $u->getEmployee() ? (string) $u->getEmployee()->getId() : null,
]; ];
} }
} }

View File

@ -45,6 +45,10 @@ class Company implements ResellerOwnedInterface
#[ORM\Column(length: 20)] #[ORM\Column(length: 20)]
private string $status = 'active'; private string $status = 'active';
/** Org-Firma des Resellers (beherbergt dessen Personal), keine Kundenfirma. */
#[ORM\Column]
private bool $isResellerOrg = false;
/** Erlaubt Mitarbeiter-Self-Service generell (zusätzlich pro Employee freizugeben). */ /** Erlaubt Mitarbeiter-Self-Service generell (zusätzlich pro Employee freizugeben). */
#[ORM\Column] #[ORM\Column]
private bool $selfEditEnabled = false; private bool $selfEditEnabled = false;
@ -121,6 +125,18 @@ class Company implements ResellerOwnedInterface
return $this; return $this;
} }
public function isResellerOrg(): bool
{
return $this->isResellerOrg;
}
public function setIsResellerOrg(bool $isResellerOrg): self
{
$this->isResellerOrg = $isResellerOrg;
return $this;
}
public function isSelfEditEnabled(): bool public function isSelfEditEnabled(): bool
{ {
return $this->selfEditEnabled; return $this->selfEditEnabled;

View File

@ -8,17 +8,28 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType; 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; use Symfony\Component\Uid\Uuid;
/** /**
* Mitarbeiterprofil Single Source of Truth für alle Ausgabekanäle. * Mitarbeiterprofil Single Source of Truth für alle Ausgabekanäle UND
* Login-Identität (Auth). Login/Passwort sind optional: ein Mitarbeiter kann
* ohne eigenes Login existieren (Visitenkarte/NFC) und bei Bedarf eine
* Rechtegruppe + Login erhalten.
*/ */
#[ORM\Entity(repositoryClass: EmployeeRepository::class)] #[ORM\Entity(repositoryClass: EmployeeRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_employee_company_slug', fields: ['company', 'slug'])] #[ORM\UniqueConstraint(name: 'uniq_employee_company_slug', fields: ['company', 'slug'])]
#[ORM\UniqueConstraint(name: 'uniq_employee_shortcode', fields: ['shortCode'])] #[ORM\UniqueConstraint(name: 'uniq_employee_shortcode', fields: ['shortCode'])]
#[ORM\UniqueConstraint(name: 'uniq_employee_login_email', fields: ['loginEmail'])]
#[ApiResource(security: "is_granted('ROLE_COMPANY_ADMIN')")] #[ApiResource(security: "is_granted('ROLE_COMPANY_ADMIN')")]
class Employee implements ResellerOwnedInterface class Employee implements ResellerOwnedInterface, 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\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)] #[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id; private Uuid $id;
@ -83,8 +94,17 @@ class Employee implements ResellerOwnedInterface
#[ORM\ManyToOne(targetEntity: Location::class)] #[ORM\ManyToOne(targetEntity: Location::class)]
private ?Location $location = null; private ?Location $location = null;
#[ORM\OneToOne(targetEntity: User::class, mappedBy: 'employee')] // --- Login/Auth (optional) ---
private ?User $user = null; /** Eindeutige Login-E-Mail; null = kein Login. */
#[ORM\Column(length: 180, nullable: true)]
private ?string $loginEmail = null;
#[ORM\Column(nullable: true)]
private ?string $password = null;
/** @var string[] Rechtegruppe(n); leer = nur Profil ohne Adminrechte. */
#[ORM\Column(type: 'json')]
private array $roles = [];
/** @var Collection<int, ContactLink> */ /** @var Collection<int, ContactLink> */
#[ORM\OneToMany(targetEntity: ContactLink::class, mappedBy: 'employee', cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(targetEntity: ContactLink::class, mappedBy: 'employee', cascade: ['persist', 'remove'], orphanRemoval: true)]
@ -317,18 +337,63 @@ class Employee implements ResellerOwnedInterface
return $this; return $this;
} }
public function getUser(): ?User // --- Login/Auth ---
public function getLoginEmail(): ?string
{ {
return $this->user; return $this->loginEmail;
} }
public function setUser(?User $user): self public function setLoginEmail(?string $loginEmail): self
{ {
$this->user = $user; $this->loginEmail = $loginEmail;
return $this; return $this;
} }
public function hasLogin(): bool
{
return null !== $this->loginEmail && null !== $this->password;
}
public function getUserIdentifier(): string
{
return (string) $this->loginEmail;
}
/** @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 eraseCredentials(): void
{
}
/** @return Collection<int, ContactLink> */ /** @return Collection<int, ContactLink> */
public function getContactLinks(): Collection public function getContactLinks(): Collection
{ {

View File

@ -34,6 +34,10 @@ class Reseller
#[ORM\Column(length: 20)] #[ORM\Column(length: 20)]
private string $status = 'active'; private string $status = 'active';
/** Markiert den Plattform-Betreiber (selbst ein Reseller). */
#[ORM\Column]
private bool $isPlatform = false;
/** Branding-Defaults (Logo, Farben, Fonts). */ /** Branding-Defaults (Logo, Farben, Fonts). */
#[ORM\Column(type: 'json')] #[ORM\Column(type: 'json')]
private array $brandingConfig = []; private array $brandingConfig = [];
@ -108,6 +112,18 @@ class Reseller
return $this; return $this;
} }
public function isPlatform(): bool
{
return $this->isPlatform;
}
public function setIsPlatform(bool $isPlatform): self
{
$this->isPlatform = $isPlatform;
return $this;
}
public function getBrandingConfig(): array public function getBrandingConfig(): array
{ {
return $this->brandingConfig; return $this->brandingConfig;

View File

@ -1,183 +0,0 @@
<?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
}
}

View File

@ -5,17 +5,30 @@ namespace App\Repository;
use App\Entity\Employee; use App\Entity\Employee;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; 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<Employee> * @extends ServiceEntityRepository<Employee>
*/ */
class EmployeeRepository extends ServiceEntityRepository class EmployeeRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
parent::__construct($registry, Employee::class); parent::__construct($registry, Employee::class);
} }
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof Employee) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
/** /**
* Lädt ein öffentlich sichtbares (aktives) Profil anhand Firmen- und * Lädt ein öffentlich sichtbares (aktives) Profil anhand Firmen- und
* Mitarbeiter-Slug. Nicht mandantengefiltert diese Seiten sind öffentlich. * Mitarbeiter-Slug. Nicht mandantengefiltert diese Seiten sind öffentlich.

View File

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

View File

@ -3,13 +3,17 @@
namespace App\Security; namespace App\Security;
use App\Entity\Company; use App\Entity\Company;
use App\Entity\Employee;
use App\Entity\Reseller; use App\Entity\Reseller;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
/** /**
* Liefert den Mandantenkontext (Reseller/Company) des eingeloggten Nutzers. * Liefert den Mandantenkontext (Reseller/Company) des eingeloggten Mitarbeiters.
* Plattform-Admins haben keinen Mandantenkontext und sehen alles. * Alles wird aus dem Employee abgeleitet (Employee = Login-Identität):
* - Reseller = company.reseller
* - Company = die eigene Firma NUR für Firmen-Admins/Mitarbeiter; Reseller-/
* Plattform-Admins arbeiten reseller-weit (company = null).
* Plattform-Admins sehen alles.
*/ */
final class TenantContext final class TenantContext
{ {
@ -17,22 +21,30 @@ final class TenantContext
{ {
} }
public function getEmployee(): ?Employee
{
$user = $this->security->getUser();
return $user instanceof Employee ? $user : null;
}
public function isPlatformAdmin(): bool public function isPlatformAdmin(): bool
{ {
return $this->security->isGranted(User::ROLE_PLATFORM_ADMIN); return $this->security->isGranted(Employee::ROLE_PLATFORM_ADMIN);
} }
public function getReseller(): ?Reseller public function getReseller(): ?Reseller
{ {
$user = $this->security->getUser(); return $this->getEmployee()?->getCompany()->getReseller();
return $user instanceof User ? $user->getReseller() : null;
} }
public function getCompany(): ?Company public function getCompany(): ?Company
{ {
$user = $this->security->getUser(); // Reseller-/Plattform-Admins sind reseller-weit unterwegs → keine Company-Einschränkung
if ($this->security->isGranted(Employee::ROLE_RESELLER_ADMIN)) {
return null;
}
return $user instanceof User ? $user->getCompany() : null; return $this->getEmployee()?->getCompany();
} }
} }

View File

@ -3,8 +3,8 @@
namespace App\Service; namespace App\Service;
use App\Entity\Company; use App\Entity\Company;
use App\Entity\Employee;
use App\Entity\Reseller; use App\Entity\Reseller;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@ -16,10 +16,10 @@ final class RoleService
{ {
/** Rechtegruppen-Schlüssel (UI) → Symfony-Rolle + Ebene (höher = mehr Rechte). */ /** Rechtegruppen-Schlüssel (UI) → Symfony-Rolle + Ebene (höher = mehr Rechte). */
private const GROUPS = [ private const GROUPS = [
'platform_admin' => ['role' => User::ROLE_PLATFORM_ADMIN, 'level' => 3], 'platform_admin' => ['role' => Employee::ROLE_PLATFORM_ADMIN, 'level' => 3],
'reseller_admin' => ['role' => User::ROLE_RESELLER_ADMIN, 'level' => 2], 'reseller_admin' => ['role' => Employee::ROLE_RESELLER_ADMIN, 'level' => 2],
'company_admin' => ['role' => User::ROLE_COMPANY_ADMIN, 'level' => 1], 'company_admin' => ['role' => Employee::ROLE_COMPANY_ADMIN, 'level' => 1],
'employee' => ['role' => User::ROLE_EMPLOYEE, 'level' => 0], 'employee' => ['role' => Employee::ROLE_EMPLOYEE, 'level' => 0],
]; ];
public function __construct(private readonly Security $security) public function __construct(private readonly Security $security)
@ -39,13 +39,13 @@ final class RoleService
/** Höchste Ebene des aktuell eingeloggten Akteurs (respektiert Rollen-Hierarchie). */ /** Höchste Ebene des aktuell eingeloggten Akteurs (respektiert Rollen-Hierarchie). */
public function actorLevel(): int public function actorLevel(): int
{ {
if ($this->security->isGranted(User::ROLE_PLATFORM_ADMIN)) { if ($this->security->isGranted(Employee::ROLE_PLATFORM_ADMIN)) {
return 3; return 3;
} }
if ($this->security->isGranted(User::ROLE_RESELLER_ADMIN)) { if ($this->security->isGranted(Employee::ROLE_RESELLER_ADMIN)) {
return 2; return 2;
} }
if ($this->security->isGranted(User::ROLE_COMPANY_ADMIN)) { if ($this->security->isGranted(Employee::ROLE_COMPANY_ADMIN)) {
return 1; return 1;
} }
@ -86,7 +86,7 @@ final class RoleService
return; return;
} }
/** @var User $actor */ /** @var Employee $actor */
$actor = $this->security->getUser(); $actor = $this->security->getUser();
// Regel 2: nur im eigenen Mandanten-Teilbaum // Regel 2: nur im eigenen Mandanten-Teilbaum

View File

@ -39,7 +39,7 @@ const loading = ref(true)
const search = ref('') const search = ref('')
const canManageUsers = computed(() => auth.isCompanyAdmin || auth.isResellerAdmin || auth.isPlatformAdmin) const canManageUsers = computed(() => auth.isCompanyAdmin || auth.isResellerAdmin || auth.isPlatformAdmin)
const usersByEmployee = ref<Record<string, { id: string; group: string }>>({}) const usersByEmployee = ref<Record<string, { group: string }>>({})
const assignableGroups = ref<string[]>([]) const assignableGroups = ref<string[]>([])
const loginForm = ref({ group: 'employee', password: '' }) const loginForm = ref({ group: 'employee', password: '' })
@ -73,7 +73,7 @@ async function load() {
try { try {
const [u, g] = await Promise.all([client.get('/users'), client.get('/users/assignable-groups')]) const [u, g] = await Promise.all([client.get('/users'), client.get('/users/assignable-groups')])
usersByEmployee.value = Object.fromEntries( usersByEmployee.value = Object.fromEntries(
u.data.member.filter((x: any) => x.employeeId).map((x: any) => [x.employeeId, { id: x.id, group: x.group }]), u.data.member.filter((x: any) => x.employeeId).map((x: any) => [x.employeeId, { group: x.group }]),
) )
assignableGroups.value = g.data.groups assignableGroups.value = g.data.groups
} catch { /* Mitarbeiter-Rolle darf /users nicht egal */ } } catch { /* Mitarbeiter-Rolle darf /users nicht egal */ }
@ -84,17 +84,16 @@ async function load() {
async function grantLogin(e: Employee) { async function grantLogin(e: Employee) {
if (!e.email) { alert('Der Mitarbeiter braucht eine E-Mail für den Login.'); return } if (!e.email) { alert('Der Mitarbeiter braucht eine E-Mail für den Login.'); return }
try { try {
await client.post('/users', { email: e.email, password: loginForm.value.password, group: loginForm.value.group, employeeId: e.id }) await client.post(`/employees/${e.id}/login`, { group: loginForm.value.group, password: loginForm.value.password })
loginForm.value = { group: 'employee', password: '' } loginForm.value = { group: 'employee', password: '' }
await load() await load()
} catch (err: any) { } catch (err: any) {
alert(err?.response?.data?.error ?? 'Login anlegen fehlgeschlagen.') alert(err?.response?.data?.error ?? err?.response?.data?.detail ?? 'Login anlegen fehlgeschlagen.')
} }
} }
async function removeLogin(e: Employee) { async function removeLogin(e: Employee) {
const u = usersByEmployee.value[e.id] if (!usersByEmployee.value[e.id] || !confirm('Login für diesen Mitarbeiter entfernen?')) return
if (!u || !confirm('Login für diesen Mitarbeiter entfernen?')) return await client.delete(`/employees/${e.id}/login`)
await client.delete(`/users/${u.id}`)
await load() await load()
} }

View File

@ -1,142 +1,62 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { list } from '@/api/resources'
import client from '@/api/client' import client from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue'
interface AdminUser { interface LoginRow {
id: string; email: string; group: string; status: string employeeId: string; email: string; name: string; group: string
reseller: { id: string; name: string } | null
company: { id: string; name: string } | null company: { id: string; name: string } | null
employeeId: string | null reseller: { id: string; name: string } | null
} }
interface Company { '@id': string; id: string; name: string }
interface Reseller { '@id': string; id: string; name: string }
const auth = useAuthStore()
const GROUP_LABEL: Record<string, string> = { const GROUP_LABEL: Record<string, string> = {
platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin', platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin',
company_admin: 'Firmen-Admin', employee: 'Mitarbeiter', company_admin: 'Firmen-Admin', employee: 'Mitarbeiter',
} }
const users = ref<AdminUser[]>([]) const rows = ref<LoginRow[]>([])
const groups = ref<string[]>([])
const companies = ref<Company[]>([])
const resellers = ref<Reseller[]>([])
const loading = ref(true) const loading = ref(true)
const showForm = ref(false)
const saving = ref(false)
const error = ref('')
const blank = () => ({ email: '', password: '', group: '', companyId: '', resellerId: '' })
const form = ref(blank())
const isCompanyLevel = computed(() => ['company_admin', 'employee'].includes(form.value.group))
const needsResellerPick = computed(() => form.value.group === 'reseller_admin' && auth.isPlatformAdmin)
async function load() { async function load() {
loading.value = true loading.value = true
const [u, g] = await Promise.all([ rows.value = (await client.get('/users')).data.member
client.get('/users').then((r) => r.data),
client.get('/users/assignable-groups').then((r) => r.data),
])
users.value = u.member
groups.value = g.groups
companies.value = (await list<Company>('companies').then((r) => r.member).catch(() => []))
if (auth.isPlatformAdmin) resellers.value = await list<Reseller>('resellers').then((r) => r.member).catch(() => [])
loading.value = false loading.value = false
} }
async function revoke(r: LoginRow) {
function openCreate() { if (!confirm(`Login von „${r.email}" entfernen?`)) return
form.value = blank() try { await client.delete(`/employees/${r.employeeId}/login`); await load() }
form.value.group = groups.value[groups.value.length - 1] ?? '' // niedrigste Gruppe vorwählen catch { alert('Entfernen fehlgeschlagen.') }
if (companies.value.length === 1) form.value.companyId = companies.value[0].id
error.value = ''
showForm.value = true
} }
async function submit() {
saving.value = true; error.value = ''
const payload: Record<string, unknown> = { email: form.value.email, password: form.value.password, group: form.value.group }
if (isCompanyLevel.value && form.value.companyId) payload.companyId = form.value.companyId
if (needsResellerPick.value && form.value.resellerId) payload.resellerId = form.value.resellerId
try {
await client.post('/users', payload)
showForm.value = false; await load()
} catch (e: any) {
error.value = e?.response?.data?.error ?? e?.response?.data?.detail ?? 'Anlegen fehlgeschlagen.'
} finally { saving.value = false }
}
async function del(u: AdminUser) {
if (!confirm(`Benutzer „${u.email}" löschen?`)) return
try { await client.delete(`/users/${u.id}`); await load() }
catch { alert('Löschen fehlgeschlagen.') }
}
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<section> <section>
<div class="page-head"> <div class="page-head">
<div><h1>Benutzer</h1><p class="muted">Logins & Rechtegruppen Vergabe nur innerhalb der eigenen Ebene</p></div> <h1>Benutzer / Logins</h1>
<button class="btn btn-primary" @click="openCreate">+ Benutzer anlegen</button> <p class="muted">Übersicht aller Mitarbeiter mit Login. Vergabe erfolgt über die Mitarbeiterliste (Rechtegruppe).</p>
</div> </div>
<div class="card"> <div class="card">
<table class="tbl"> <table class="tbl">
<thead><tr><th>E-Mail</th><th>Rechtegruppe</th><th>Zuordnung</th><th>Status</th><th></th></tr></thead> <thead><tr><th>Login</th><th>Name</th><th>Rechtegruppe</th><th>Zuordnung</th><th></th></tr></thead>
<tbody> <tbody>
<tr v-if="loading"><td colspan="5" class="empty">Lädt</td></tr> <tr v-if="loading"><td colspan="5" class="empty">Lädt</td></tr>
<tr v-else-if="!users.length"><td colspan="5" class="empty">Keine Benutzer.</td></tr> <tr v-else-if="!rows.length"><td colspan="5" class="empty">Noch keine Logins.</td></tr>
<tr v-for="u in users" :key="u.id"> <tr v-for="r in rows" :key="r.employeeId">
<td><strong>{{ u.email }}</strong></td> <td><strong>{{ r.email }}</strong></td>
<td><span class="badge badge-role">{{ GROUP_LABEL[u.group] ?? u.group }}</span></td> <td>{{ r.name }}</td>
<td class="muted">{{ u.company?.name ?? u.reseller?.name ?? 'Plattform' }}</td> <td><span class="badge badge-role">{{ GROUP_LABEL[r.group] ?? r.group }}</span></td>
<td><span class="badge" :class="u.status === 'active' ? 'badge-active' : 'badge-inactive'">{{ u.status }}</span></td> <td class="muted">{{ r.company?.name ?? r.reseller?.name ?? 'Plattform' }}</td>
<td class="right"><button class="btn btn-ghost btn-sm" @click="del(u)">Löschen</button></td> <td class="right"><button class="btn btn-ghost btn-sm" @click="revoke(r)">Login entfernen</button></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<Modal v-if="showForm" title="Benutzer anlegen" @close="showForm = false">
<form @submit.prevent="submit">
<div class="field"><label>E-Mail</label><input class="input" v-model="form.email" type="email" required /></div>
<div class="field"><label>Passwort</label><input class="input" v-model="form.password" type="password" required minlength="6" /></div>
<div class="field">
<label>Rechtegruppe</label>
<select class="input" v-model="form.group">
<option v-for="g in groups" :key="g" :value="g">{{ GROUP_LABEL[g] ?? g }}</option>
</select>
</div>
<div class="field" v-if="isCompanyLevel && companies.length > 1">
<label>Firma</label>
<select class="input" v-model="form.companyId">
<option value=""> wählen </option>
<option v-for="c in companies" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="field" v-if="needsResellerPick">
<label>Reseller</label>
<select class="input" v-model="form.resellerId">
<option value=""> wählen </option>
<option v-for="r in resellers" :key="r.id" :value="r.id">{{ r.name }}</option>
</select>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button type="button" class="btn btn-ghost" @click="showForm = false">Abbrechen</button>
<button class="btn btn-primary" :disabled="saving">{{ saving ? 'Speichern…' : 'Anlegen' }}</button>
</div>
</form>
</Modal>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; } .page-head { margin-bottom: 1.4rem; }
.page-head .muted { margin: .2rem 0 0; } .page-head .muted { margin: .2rem 0 0; }
.tbl { width: 100%; border-collapse: collapse; } .tbl { width: 100%; border-collapse: collapse; }
.tbl th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); padding: .9rem 1.2rem; border-bottom: 1px solid var(--line); } .tbl th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); padding: .9rem 1.2rem; border-bottom: 1px solid var(--line); }
@ -145,6 +65,4 @@ onMounted(load)
.right { text-align: right; } .right { text-align: right; }
.empty { text-align: center; color: var(--muted); padding: 2rem; } .empty { text-align: center; color: var(--muted); padding: 2rem; }
.badge-role { background: var(--psc-orange-soft); color: var(--psc-orange-dark); } .badge-role { background: var(--psc-orange-soft); color: var(--psc-orange-dark); }
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
.error { color: var(--danger); font-size: .88rem; }
</style> </style>