diff --git a/backend/config/packages/security.yaml b/backend/config/packages/security.yaml index bff0863..b005716 100644 --- a/backend/config/packages/security.yaml +++ b/backend/config/packages/security.yaml @@ -5,8 +5,8 @@ security: providers: app_user_provider: entity: - class: App\Entity\User - property: email + class: App\Entity\Employee + property: loginEmail firewalls: dev: diff --git a/backend/migrations/Version20260531085615.php b/backend/migrations/Version20260531085615.php deleted file mode 100644 index a6d7a83..0000000 --- a/backend/migrations/Version20260531085615.php +++ /dev/null @@ -1,33 +0,0 @@ -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'); - } -} diff --git a/backend/migrations/Version20260531092327.php b/backend/migrations/Version20260531092327.php deleted file mode 100644 index 92169bf..0000000 --- a/backend/migrations/Version20260531092327.php +++ /dev/null @@ -1,33 +0,0 @@ -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'); - } -} diff --git a/backend/migrations/Version20260531150051.php b/backend/migrations/Version20260531150051.php deleted file mode 100644 index 0986c72..0000000 --- a/backend/migrations/Version20260531150051.php +++ /dev/null @@ -1,34 +0,0 @@ -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'); - } -} diff --git a/backend/migrations/Version20260530191712.php b/backend/migrations/Version20260601135652.php similarity index 63% rename from backend/migrations/Version20260530191712.php rename to backend/migrations/Version20260601135652.php index 4a451b8..6993b53 100644 --- a/backend/migrations/Version20260530191712.php +++ b/backend/migrations/Version20260601135652.php @@ -10,7 +10,7 @@ use Doctrine\Migrations\AbstractMigration; /** * Auto-generated Migration: Please modify to your needs! */ -final class Version20260530191712 extends AbstractMigration +final class Version20260601135652 extends AbstractMigration { public function getDescription(): string { @@ -20,14 +20,15 @@ final class Version20260530191712 extends AbstractMigration 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 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 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 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('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('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 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)'); @@ -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 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 card_template DROP FOREIGN KEY FK_2E51D100979B1AD6'); $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'); @@ -50,9 +49,7 @@ final class Version20260530191712 extends AbstractMigration $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 card_template'); $this->addSql('DROP TABLE company'); $this->addSql('DROP TABLE contact_link'); $this->addSql('DROP TABLE domain'); @@ -60,6 +57,5 @@ final class Version20260530191712 extends AbstractMigration $this->addSql('DROP TABLE location'); $this->addSql('DROP TABLE platform_plan'); $this->addSql('DROP TABLE reseller'); - $this->addSql('DROP TABLE `user`'); } } diff --git a/backend/src/Command/SeedCommand.php b/backend/src/Command/SeedCommand.php index 4ab3fb9..df1f612 100644 --- a/backend/src/Command/SeedCommand.php +++ b/backend/src/Command/SeedCommand.php @@ -8,16 +8,15 @@ 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\Output\OutputInterface; 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).')] +#[AsCommand(name: 'app:seed', description: 'Demo-Daten (Plattform/Reseller/Firmen/Mitarbeiter, alles als Employee).')] final class SeedCommand extends Command { public function __construct( @@ -30,91 +29,88 @@ final class SeedCommand extends Command 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'])) { + if (null !== $this->em->getRepository(Employee::class)->findOneBy(['loginEmail' => 'admin@vcard4reseller.de'])) { $io->warning('Demo-Daten existieren bereits — übersprungen.'); 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) ->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); + // Plattform-Betreiber = Reseller mit isPlatform + Org-Firma + 2 Plattform-Admins + [$platform, $pOrg] = $this->reseller('vcard4reseller', 'platform', $plan, true); + $this->staff($pOrg, 'Thomas', 'Peterson', 'admin@vcard4reseller.de', 'admin', Employee::ROLE_PLATFORM_ADMIN); + $this->staff($pOrg, 'Co', 'Admin', 'admin2@vcard4reseller.de', 'admin', Employee::ROLE_PLATFORM_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'); + // Reseller „Demo Druckerei" + Org-Firma + Reseller-Admin + Kundenfirma + [$demo, $dOrg] = $this->reseller('Demo Druckerei', 'demo', $plan, false); + $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(); - $io->success('Demo-Daten angelegt.'); - $io->table(['Rolle', 'E-Mail', 'Passwort'], [ + $io->success('Demo-Daten angelegt (alles als Mitarbeiter mit optionalem Login).'); + $io->table(['Rechtegruppe', 'Login', '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); + /** @return array{0: Reseller, 1: Company} Reseller + Org-Firma */ + private function reseller(string $name, string $slug, PlatformPlan $plan, bool $isPlatform): array + { + $reseller = (new Reseller())->setName($name)->setSlug($slug) + ->setPrimaryDomain($slug.'.vcard4reseller.de')->setPlatformPlan($plan)->setIsPlatform($isPlatform); + $org = (new Company())->setName($name)->setSlug($slug.'-team')->setReseller($reseller)->setIsResellerOrg(true); $this->em->persist($reseller); + $this->em->persist($org); - $resellerAdmin = (new User()) - ->setEmail($resellerEmail)->setRoles([User::ROLE_RESELLER_ADMIN])->setReseller($reseller); - $resellerAdmin->setPassword($this->hasher->hashPassword($resellerAdmin, 'reseller')); - $this->em->persist($resellerAdmin); + return [$reseller, $org]; + } - $company = (new Company()) - ->setName($companyName)->setSlug($companySlug)->setReseller($reseller)->setSelfEditEnabled(true); + private function staff(Company $company, string $first, string $last, string $email, string $pw, string $role): Employee + { + $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); - $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); + $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); + // Firmen-Admin (Mitarbeiter mit Login) + $this->staff($company, 'Firmen', 'Admin', $adminEmail, $adminPw, Employee::ROLE_COMPANY_ADMIN) + ->setSlug('firmen-admin'); + + // Öffentliches Profil-Beispiel (ohne Login) + $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); - $link = (new ContactLink()) - ->setType('linkedin')->setUrl('https://linkedin.com/in/'.strtolower($firstName)) - ->setPosition(0); + $link = (new ContactLink())->setType('linkedin')->setUrl('https://linkedin.com/in/'.strtolower($first))->setPosition(0); $employee->addContactLink($link); $this->em->persist($link); } diff --git a/backend/src/Controller/MeController.php b/backend/src/Controller/MeController.php index c312409..6d14dd7 100644 --- a/backend/src/Controller/MeController.php +++ b/backend/src/Controller/MeController.php @@ -2,13 +2,13 @@ namespace App\Controller; -use App\Entity\User; +use App\Entity\Employee; 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). + * Infos zum eingeloggten Mitarbeiter für die SPA (Rollen + Mandantenkontext). */ final class MeController { @@ -19,20 +19,21 @@ final class MeController #[Route('/api/me', name: 'api_me', methods: ['GET'])] public function __invoke(): JsonResponse { - $user = $this->security->getUser(); - if (!$user instanceof User) { + $employee = $this->security->getUser(); + if (!$employee instanceof Employee) { return new JsonResponse(['error' => 'Not authenticated'], 401); } - $reseller = $user->getReseller(); - $company = $user->getCompany(); + $company = $employee->getCompany(); + $reseller = $company->getReseller(); 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, + 'id' => (string) $employee->getId(), + 'email' => $employee->getLoginEmail(), + 'name' => trim($employee->getFirstName().' '.$employee->getLastName()), + 'roles' => $employee->getRoles(), + 'reseller' => $reseller ? ['id' => (string) $reseller->getId(), 'name' => $reseller->getName(), 'isPlatform' => $reseller->isPlatform()] : null, + 'company' => ['id' => (string) $company->getId(), 'name' => $company->getName(), 'isResellerOrg' => $company->isResellerOrg()], ]); } } diff --git a/backend/src/Controller/ResellerProvisioningController.php b/backend/src/Controller/ResellerProvisioningController.php index ae0bd87..3a3ed79 100644 --- a/backend/src/Controller/ResellerProvisioningController.php +++ b/backend/src/Controller/ResellerProvisioningController.php @@ -2,9 +2,10 @@ namespace App\Controller; +use App\Entity\Company; +use App\Entity\Employee; 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; @@ -14,8 +15,8 @@ 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. + * Legt einen Reseller an: Reseller + Org-Firma (für sein Personal) + optional + * einen Admin-Mitarbeiter mit Login. Nur für Plattform-Admins. */ #[IsGranted('ROLE_PLATFORM_ADMIN')] 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'] ?? '')); $adminPassword = (string) ($d['adminPassword'] ?? ''); $admin = null; if ('' !== $adminEmail && '' !== $adminPassword) { - $admin = (new User()) - ->setEmail($adminEmail) - ->setRoles([User::ROLE_RESELLER_ADMIN]) - ->setReseller($reseller); + $admin = (new Employee()) + ->setFirstName((string) ($d['adminFirstName'] ?? 'Admin')) + ->setLastName((string) ($d['adminLastName'] ?? $name)) + ->setSlug('admin') + ->setCompany($orgCompany) + ->setLoginEmail($adminEmail) + ->setRoles([Employee::ROLE_RESELLER_ADMIN]); + $admin->setEmail($adminEmail); $admin->setPassword($this->hasher->hashPassword($admin, $adminPassword)); } try { - $this->em->persist($reseller); if ($admin) { $this->em->persist($admin); } diff --git a/backend/src/Controller/UserAdminController.php b/backend/src/Controller/UserAdminController.php index ec59e86..b045a77 100644 --- a/backend/src/Controller/UserAdminController.php +++ b/backend/src/Controller/UserAdminController.php @@ -2,15 +2,11 @@ namespace App\Controller; -use App\Entity\Company; use App\Entity\Employee; -use App\Entity\Reseller; -use App\Entity\User; use App\Security\TenantContext; use App\Service\RoleService; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; -use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -22,18 +18,17 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Uid\Uuid; /** - * Benutzerverwaltung mit delegierter Rechtevergabe (KONZEPT §2). - * Ab Firmen-Admin; jede Rollenvergabe wird vom RoleService scope-/level-geprüft. + * Logins/Rechtegruppen werden pro Mitarbeiter vergeben (KONZEPT §2 + Merge). + * Delegiert & scope-geprüft über RoleService. */ #[IsGranted('ROLE_COMPANY_ADMIN')] -#[Route('/api/users')] final class UserAdminController { private const ROLE_TO_GROUP = [ - User::ROLE_PLATFORM_ADMIN => 'platform_admin', - User::ROLE_RESELLER_ADMIN => 'reseller_admin', - User::ROLE_COMPANY_ADMIN => 'company_admin', - User::ROLE_EMPLOYEE => 'employee', + Employee::ROLE_PLATFORM_ADMIN => 'platform_admin', + Employee::ROLE_RESELLER_ADMIN => 'reseller_admin', + Employee::ROLE_COMPANY_ADMIN => 'company_admin', + Employee::ROLE_EMPLOYEE => 'employee', ]; public function __construct( @@ -41,177 +36,124 @@ final class UserAdminController private readonly RoleService $roles, private readonly TenantContext $tenant, 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 { 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 { - $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 (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()) { - $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'])] - public function create(Request $request): JsonResponse + #[Route('/api/employees/{id}/login', name: 'employee_login_grant', methods: ['POST'])] + public function grant(string $id, Request $request): JsonResponse { + $employee = $this->employee($id); $d = json_decode($request->getContent(), true) ?? []; - $email = trim((string) ($d['email'] ?? '')); - $password = (string) ($d['password'] ?? ''); $group = (string) ($d['group'] ?? ''); - if ('' === $email || '' === $password || '' === $group) { - throw new BadRequestHttpException('email, password und group sind erforderlich.'); + $password = (string) ($d['password'] ?? ''); + $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); - $this->roles->assertCanAssign($group, $reseller, $company); - - $user = (new User()) - ->setEmail($email) - ->setRoles([$this->roles->roleForGroup($group)]) - ->setReseller($reseller) - ->setCompany($company) - ->setEmployee($employee); - $user->setPassword($this->hasher->hashPassword($user, $password)); + $employee->setLoginEmail($loginEmail) + ->setRoles([$this->roles->roleForGroup($group)]); + $employee->setPassword($this->hasher->hashPassword($employee, $password)); try { - $this->em->persist($user); $this->em->flush(); } 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'])] - public function update(string $id, Request $request): JsonResponse + #[Route('/api/employees/{id}/login', name: 'employee_login_revoke', methods: ['DELETE'])] + public function revoke(string $id): JsonResponse { - $user = $this->find($id); - $this->assertInScope($user); - $d = json_decode($request->getContent(), true) ?? []; - - if (isset($d['group'])) { - $group = (string) $d['group']; - $this->roles->assertCanAssign($group, $user->getReseller(), $user->getCompany()); - $user->setRoles([$this->roles->roleForGroup($group)]); + $employee = $this->employee($id); + if ($this->tenant->getEmployee() === $employee) { + throw new AccessDeniedHttpException('Eigenen Zugang nicht entziehen.'); } - if (isset($d['status'])) { - $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); + $employee->setLoginEmail(null)->setPassword(null)->setRoles([]); $this->em->flush(); return new JsonResponse(null, 204); } - /** @return array{0: ?Reseller, 1: ?Company, 2: ?Employee} */ - private function resolveTarget(array $d, string $group): array + private function employee(string $id): Employee { - $employee = !empty($d['employeeId']) - ? $this->em->getRepository(Employee::class)->find(Uuid::fromString($d['employeeId'])) - : null; - $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; + $employee = $this->em->getRepository(Employee::class)->find(Uuid::fromString($id)); + if (!$employee instanceof Employee) { + throw new NotFoundHttpException('Mitarbeiter nicht gefunden.'); } + $this->assertInScope($employee); - return [$reseller, $company, $employee]; + return $employee; } - private function find(string $id): User - { - $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 + private function assertInScope(Employee $employee): void { if ($this->tenant->isPlatformAdmin()) { return; } 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.'); } return; } 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.'); } } } - private function serialize(User $u): array + private function serialize(Employee $e): array { $group = 'employee'; foreach (self::ROLE_TO_GROUP as $role => $g) { - if (in_array($role, $u->getRoles(), true)) { + if (in_array($role, $e->getRoles(), true)) { $group = $g; break; } } + $company = $e->getCompany(); return [ - 'id' => (string) $u->getId(), - 'email' => $u->getEmail(), + 'employeeId' => (string) $e->getId(), + 'email' => $e->getLoginEmail(), + 'name' => trim($e->getFirstName().' '.$e->getLastName()), 'group' => $group, - 'status' => $u->getStatus(), - 'reseller' => $u->getReseller() ? ['id' => (string) $u->getReseller()->getId(), 'name' => $u->getReseller()->getName()] : null, - 'company' => $u->getCompany() ? ['id' => (string) $u->getCompany()->getId(), 'name' => $u->getCompany()->getName()] : null, - 'employeeId' => $u->getEmployee() ? (string) $u->getEmployee()->getId() : null, + 'company' => ['id' => (string) $company->getId(), 'name' => $company->getName()], + 'reseller' => $company->getReseller() ? ['id' => (string) $company->getReseller()->getId(), 'name' => $company->getReseller()->getName()] : null, ]; } } diff --git a/backend/src/Entity/Company.php b/backend/src/Entity/Company.php index c8e4290..db4f57c 100644 --- a/backend/src/Entity/Company.php +++ b/backend/src/Entity/Company.php @@ -45,6 +45,10 @@ class Company implements ResellerOwnedInterface #[ORM\Column(length: 20)] 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). */ #[ORM\Column] private bool $selfEditEnabled = false; @@ -121,6 +125,18 @@ class Company implements ResellerOwnedInterface return $this; } + public function isResellerOrg(): bool + { + return $this->isResellerOrg; + } + + public function setIsResellerOrg(bool $isResellerOrg): self + { + $this->isResellerOrg = $isResellerOrg; + + return $this; + } + public function isSelfEditEnabled(): bool { return $this->selfEditEnabled; diff --git a/backend/src/Entity/Employee.php b/backend/src/Entity/Employee.php index c1a03ae..ca192b6 100644 --- a/backend/src/Entity/Employee.php +++ b/backend/src/Entity/Employee.php @@ -8,17 +8,28 @@ 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\Security\Core\User\PasswordAuthenticatedUserInterface; +use Symfony\Component\Security\Core\User\UserInterface; 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\UniqueConstraint(name: 'uniq_employee_company_slug', fields: ['company', 'slug'])] #[ORM\UniqueConstraint(name: 'uniq_employee_shortcode', fields: ['shortCode'])] +#[ORM\UniqueConstraint(name: 'uniq_employee_login_email', fields: ['loginEmail'])] #[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\Column(type: UuidType::NAME, unique: true)] private Uuid $id; @@ -83,8 +94,17 @@ class Employee implements ResellerOwnedInterface #[ORM\ManyToOne(targetEntity: Location::class)] private ?Location $location = null; - #[ORM\OneToOne(targetEntity: User::class, mappedBy: 'employee')] - private ?User $user = null; + // --- Login/Auth (optional) --- + /** 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 */ #[ORM\OneToMany(targetEntity: ContactLink::class, mappedBy: 'employee', cascade: ['persist', 'remove'], orphanRemoval: true)] @@ -317,18 +337,63 @@ class Employee implements ResellerOwnedInterface 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; } + 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 */ public function getContactLinks(): Collection { diff --git a/backend/src/Entity/Reseller.php b/backend/src/Entity/Reseller.php index 295b5e0..8f4d384 100644 --- a/backend/src/Entity/Reseller.php +++ b/backend/src/Entity/Reseller.php @@ -34,6 +34,10 @@ class Reseller #[ORM\Column(length: 20)] private string $status = 'active'; + /** Markiert den Plattform-Betreiber (selbst ein Reseller). */ + #[ORM\Column] + private bool $isPlatform = false; + /** Branding-Defaults (Logo, Farben, Fonts). */ #[ORM\Column(type: 'json')] private array $brandingConfig = []; @@ -108,6 +112,18 @@ class Reseller return $this; } + public function isPlatform(): bool + { + return $this->isPlatform; + } + + public function setIsPlatform(bool $isPlatform): self + { + $this->isPlatform = $isPlatform; + + return $this; + } + public function getBrandingConfig(): array { return $this->brandingConfig; diff --git a/backend/src/Entity/User.php b/backend/src/Entity/User.php deleted file mode 100644 index 2e38ebe..0000000 --- a/backend/src/Entity/User.php +++ /dev/null @@ -1,183 +0,0 @@ -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 - } -} diff --git a/backend/src/Repository/EmployeeRepository.php b/backend/src/Repository/EmployeeRepository.php index 1a2e609..9ff77a4 100644 --- a/backend/src/Repository/EmployeeRepository.php +++ b/backend/src/Repository/EmployeeRepository.php @@ -5,17 +5,30 @@ namespace App\Repository; use App\Entity\Employee; 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 */ -class EmployeeRepository extends ServiceEntityRepository +class EmployeeRepository extends ServiceEntityRepository implements PasswordUpgraderInterface { public function __construct(ManagerRegistry $registry) { 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 * Mitarbeiter-Slug. Nicht mandantengefiltert – diese Seiten sind öffentlich. diff --git a/backend/src/Repository/UserRepository.php b/backend/src/Repository/UserRepository.php deleted file mode 100644 index f9e9ad0..0000000 --- a/backend/src/Repository/UserRepository.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ -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(); - } -} diff --git a/backend/src/Security/TenantContext.php b/backend/src/Security/TenantContext.php index 3ac619b..7a4621a 100644 --- a/backend/src/Security/TenantContext.php +++ b/backend/src/Security/TenantContext.php @@ -3,13 +3,17 @@ namespace App\Security; use App\Entity\Company; +use App\Entity\Employee; 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. + * Liefert den Mandantenkontext (Reseller/Company) des eingeloggten Mitarbeiters. + * 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 { @@ -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 { - return $this->security->isGranted(User::ROLE_PLATFORM_ADMIN); + return $this->security->isGranted(Employee::ROLE_PLATFORM_ADMIN); } public function getReseller(): ?Reseller { - $user = $this->security->getUser(); - - return $user instanceof User ? $user->getReseller() : null; + return $this->getEmployee()?->getCompany()->getReseller(); } 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(); } } diff --git a/backend/src/Service/RoleService.php b/backend/src/Service/RoleService.php index b42558f..42e7b40 100644 --- a/backend/src/Service/RoleService.php +++ b/backend/src/Service/RoleService.php @@ -3,8 +3,8 @@ namespace App\Service; use App\Entity\Company; +use App\Entity\Employee; use App\Entity\Reseller; -use App\Entity\User; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -16,10 +16,10 @@ final class RoleService { /** Rechtegruppen-Schlüssel (UI) → Symfony-Rolle + Ebene (höher = mehr Rechte). */ private const GROUPS = [ - 'platform_admin' => ['role' => User::ROLE_PLATFORM_ADMIN, 'level' => 3], - 'reseller_admin' => ['role' => User::ROLE_RESELLER_ADMIN, 'level' => 2], - 'company_admin' => ['role' => User::ROLE_COMPANY_ADMIN, 'level' => 1], - 'employee' => ['role' => User::ROLE_EMPLOYEE, 'level' => 0], + 'platform_admin' => ['role' => Employee::ROLE_PLATFORM_ADMIN, 'level' => 3], + 'reseller_admin' => ['role' => Employee::ROLE_RESELLER_ADMIN, 'level' => 2], + 'company_admin' => ['role' => Employee::ROLE_COMPANY_ADMIN, 'level' => 1], + 'employee' => ['role' => Employee::ROLE_EMPLOYEE, 'level' => 0], ]; public function __construct(private readonly Security $security) @@ -39,13 +39,13 @@ final class RoleService /** Höchste Ebene des aktuell eingeloggten Akteurs (respektiert Rollen-Hierarchie). */ public function actorLevel(): int { - if ($this->security->isGranted(User::ROLE_PLATFORM_ADMIN)) { + if ($this->security->isGranted(Employee::ROLE_PLATFORM_ADMIN)) { return 3; } - if ($this->security->isGranted(User::ROLE_RESELLER_ADMIN)) { + if ($this->security->isGranted(Employee::ROLE_RESELLER_ADMIN)) { return 2; } - if ($this->security->isGranted(User::ROLE_COMPANY_ADMIN)) { + if ($this->security->isGranted(Employee::ROLE_COMPANY_ADMIN)) { return 1; } @@ -86,7 +86,7 @@ final class RoleService return; } - /** @var User $actor */ + /** @var Employee $actor */ $actor = $this->security->getUser(); // Regel 2: nur im eigenen Mandanten-Teilbaum diff --git a/frontend/src/views/EmployeesView.vue b/frontend/src/views/EmployeesView.vue index a5093d4..c4564d1 100644 --- a/frontend/src/views/EmployeesView.vue +++ b/frontend/src/views/EmployeesView.vue @@ -39,7 +39,7 @@ const loading = ref(true) const search = ref('') const canManageUsers = computed(() => auth.isCompanyAdmin || auth.isResellerAdmin || auth.isPlatformAdmin) -const usersByEmployee = ref>({}) +const usersByEmployee = ref>({}) const assignableGroups = ref([]) const loginForm = ref({ group: 'employee', password: '' }) @@ -73,7 +73,7 @@ async function load() { try { const [u, g] = await Promise.all([client.get('/users'), client.get('/users/assignable-groups')]) 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 } catch { /* Mitarbeiter-Rolle darf /users nicht – egal */ } @@ -84,17 +84,16 @@ async function load() { async function grantLogin(e: Employee) { if (!e.email) { alert('Der Mitarbeiter braucht eine E-Mail für den Login.'); return } 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: '' } await load() } 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) { - const u = usersByEmployee.value[e.id] - if (!u || !confirm('Login für diesen Mitarbeiter entfernen?')) return - await client.delete(`/users/${u.id}`) + if (!usersByEmployee.value[e.id] || !confirm('Login für diesen Mitarbeiter entfernen?')) return + await client.delete(`/employees/${e.id}/login`) await load() } diff --git a/frontend/src/views/UsersView.vue b/frontend/src/views/UsersView.vue index 8bc4db2..4f6d81b 100644 --- a/frontend/src/views/UsersView.vue +++ b/frontend/src/views/UsersView.vue @@ -1,142 +1,62 @@