From 2dc40c6ea518b6eda60ba555c2b8b3fb206d28fb Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Mon, 1 Jun 2026 21:44:57 +0200 Subject: [PATCH] Rechte: Mitarbeiter & Benutzer vereint, ROLE_CONTACT als Basis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Jeder Mitarbeiter hat (leere) Login-/Passwortfelder; Standardrolle ROLE_CONTACT (reines Profil). Hochstufen über die Rechtegruppe. - Ebenen-Ladder: contact(0) < employee(1) < company_admin(2) < reseller_admin(3) < platform_admin(4); role_hierarchy + RoleService entsprechend. - PATCH /api/employees/{id}/access: Rechtegruppe setzen (+ optional Passwort/Login); DELETE .../login → zurück auf Kontakt. - Sicherheit: Passwort/userIdentifier per #[Ignore] aus der API-Serialisierung. - Frontend: separate Benutzer-Ansicht entfernt; Mitarbeiter-Liste mit Rechtegruppe-Spalte, Rollen/Login + 'Arbeiten als' inline im Bearbeiten-Dialog. Verifiziert: kein Passwort-Leak, roles/login im Payload, Hochstufen Kontakt→ Mitarbeiter + Login, Eskalation→403, Login entziehen→Kontakt; UI. Co-Authored-By: Claude Opus 4.8 --- backend/config/packages/security.yaml | 7 +- .../src/Controller/UserAdminController.php | 29 +++--- backend/src/Entity/Employee.php | 9 +- backend/src/Service/RoleService.php | 27 +++-- frontend/src/layouts/DashboardLayout.vue | 1 - frontend/src/router/index.ts | 1 - frontend/src/stores/auth.ts | 9 +- frontend/src/views/EmployeesView.vue | 98 ++++++++++++------- frontend/src/views/UsersView.vue | 86 ---------------- 9 files changed, 113 insertions(+), 154 deletions(-) delete mode 100644 frontend/src/views/UsersView.vue diff --git a/backend/config/packages/security.yaml b/backend/config/packages/security.yaml index b005716..b857113 100644 --- a/backend/config/packages/security.yaml +++ b/backend/config/packages/security.yaml @@ -41,9 +41,10 @@ security: - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } role_hierarchy: - ROLE_PLATFORM_ADMIN: [ROLE_RESELLER_ADMIN, ROLE_COMPANY_ADMIN, ROLE_EMPLOYEE] - ROLE_RESELLER_ADMIN: [ROLE_COMPANY_ADMIN] - ROLE_COMPANY_ADMIN: [ROLE_EMPLOYEE] + ROLE_PLATFORM_ADMIN: [ROLE_RESELLER_ADMIN, ROLE_COMPANY_ADMIN, ROLE_EMPLOYEE, ROLE_CONTACT] + ROLE_RESELLER_ADMIN: [ROLE_COMPANY_ADMIN, ROLE_EMPLOYEE, ROLE_CONTACT] + ROLE_COMPANY_ADMIN: [ROLE_EMPLOYEE, ROLE_CONTACT] + ROLE_EMPLOYEE: [ROLE_CONTACT] when@test: security: diff --git a/backend/src/Controller/UserAdminController.php b/backend/src/Controller/UserAdminController.php index b045a77..5b26447 100644 --- a/backend/src/Controller/UserAdminController.php +++ b/backend/src/Controller/UserAdminController.php @@ -66,23 +66,27 @@ final class UserAdminController return new JsonResponse(['member' => $rows, 'totalItems' => count($rows)]); } - #[Route('/api/employees/{id}/login', name: 'employee_login_grant', methods: ['POST'])] - public function grant(string $id, Request $request): JsonResponse + /** Rechtegruppe setzen (+ optional Login/Passwort) — Hochstufen via Rolle. */ + #[Route('/api/employees/{id}/access', name: 'employee_access', methods: ['PATCH'])] + public function setAccess(string $id, Request $request): JsonResponse { $employee = $this->employee($id); $d = json_decode($request->getContent(), true) ?? []; $group = (string) ($d['group'] ?? ''); - $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()); + $employee->setRoles([$this->roles->roleForGroup($group)]); - $employee->setLoginEmail($loginEmail) - ->setRoles([$this->roles->roleForGroup($group)]); - $employee->setPassword($this->hasher->hashPassword($employee, $password)); + // Passwort optional: setzt/aktiviert das Login + $password = (string) ($d['password'] ?? ''); + if ('' !== $password) { + $loginEmail = trim((string) ($d['loginEmail'] ?? $employee->getLoginEmail() ?? $employee->getEmail() ?? '')); + if ('' === $loginEmail) { + throw new BadRequestHttpException('Login-E-Mail erforderlich (oder Kontakt-E-Mail setzen).'); + } + $employee->setLoginEmail($loginEmail); + $employee->setPassword($this->hasher->hashPassword($employee, $password)); + } try { $this->em->flush(); @@ -90,9 +94,10 @@ final class UserAdminController throw new BadRequestHttpException('Login-E-Mail bereits vergeben.'); } - return new JsonResponse($this->serialize($employee), 201); + return new JsonResponse($this->serialize($employee)); } + /** Login entziehen → zurück auf reinen Kontakt. */ #[Route('/api/employees/{id}/login', name: 'employee_login_revoke', methods: ['DELETE'])] public function revoke(string $id): JsonResponse { @@ -100,7 +105,7 @@ final class UserAdminController if ($this->tenant->getEmployee() === $employee) { throw new AccessDeniedHttpException('Eigenen Zugang nicht entziehen.'); } - $employee->setLoginEmail(null)->setPassword(null)->setRoles([]); + $employee->setLoginEmail(null)->setPassword(null)->setRoles([Employee::ROLE_CONTACT]); $this->em->flush(); return new JsonResponse(null, 204); diff --git a/backend/src/Entity/Employee.php b/backend/src/Entity/Employee.php index ca192b6..4bf374c 100644 --- a/backend/src/Entity/Employee.php +++ b/backend/src/Entity/Employee.php @@ -10,6 +10,7 @@ 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\Serializer\Attribute\Ignore; use Symfony\Component\Uid\Uuid; /** @@ -29,6 +30,8 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent public const ROLE_RESELLER_ADMIN = 'ROLE_RESELLER_ADMIN'; public const ROLE_COMPANY_ADMIN = 'ROLE_COMPANY_ADMIN'; public const ROLE_EMPLOYEE = 'ROLE_EMPLOYEE'; + /** Basis-Rolle: reines Profil/Kontakt (Visitenkarte), kein Login nötig. */ + public const ROLE_CONTACT = 'ROLE_CONTACT'; #[ORM\Id] #[ORM\Column(type: UuidType::NAME, unique: true)] @@ -102,9 +105,9 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent #[ORM\Column(nullable: true)] private ?string $password = null; - /** @var string[] Rechtegruppe(n); leer = nur Profil ohne Adminrechte. */ + /** @var string[] Rechtegruppe(n); Standard = ROLE_CONTACT (reines Profil). */ #[ORM\Column(type: 'json')] - private array $roles = []; + private array $roles = [self::ROLE_CONTACT]; /** @var Collection */ #[ORM\OneToMany(targetEntity: ContactLink::class, mappedBy: 'employee', cascade: ['persist', 'remove'], orphanRemoval: true)] @@ -356,6 +359,7 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent return null !== $this->loginEmail && null !== $this->password; } + #[Ignore] public function getUserIdentifier(): string { return (string) $this->loginEmail; @@ -378,6 +382,7 @@ class Employee implements ResellerOwnedInterface, UserInterface, PasswordAuthent return $this; } + #[Ignore] public function getPassword(): ?string { return $this->password; diff --git a/backend/src/Service/RoleService.php b/backend/src/Service/RoleService.php index 9a9d1de..2021310 100644 --- a/backend/src/Service/RoleService.php +++ b/backend/src/Service/RoleService.php @@ -16,12 +16,16 @@ final class RoleService { /** Rechtegruppen-Schlüssel (UI) → Symfony-Rolle + Ebene (höher = mehr Rechte). */ private const GROUPS = [ - '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], + 'platform_admin' => ['role' => Employee::ROLE_PLATFORM_ADMIN, 'level' => 4], + 'reseller_admin' => ['role' => Employee::ROLE_RESELLER_ADMIN, 'level' => 3], + 'company_admin' => ['role' => Employee::ROLE_COMPANY_ADMIN, 'level' => 2], + 'employee' => ['role' => Employee::ROLE_EMPLOYEE, 'level' => 1], + 'contact' => ['role' => Employee::ROLE_CONTACT, 'level' => 0], ]; + /** Mindest-Ebene zum Verwalten anderer (Firmen-Admin). */ + private const MANAGE_LEVEL = 2; + public function __construct(private readonly Security $security) { } @@ -40,12 +44,15 @@ final class RoleService public function actorLevel(): int { if ($this->security->isGranted(Employee::ROLE_PLATFORM_ADMIN)) { - return 3; + return 4; } if ($this->security->isGranted(Employee::ROLE_RESELLER_ADMIN)) { - return 2; + return 3; } if ($this->security->isGranted(Employee::ROLE_COMPANY_ADMIN)) { + return 2; + } + if ($this->security->isGranted(Employee::ROLE_EMPLOYEE)) { return 1; } @@ -90,12 +97,12 @@ final class RoleService if ($targetLevel > $actorLevel) { throw new AccessDeniedHttpException('Keine Berechtigung, diese Rechtegruppe zu vergeben.'); } - if ($actorLevel < 1) { + if ($actorLevel < self::MANAGE_LEVEL) { throw new AccessDeniedHttpException('Keine Berechtigung zur Benutzerverwaltung.'); } // Plattform-Admin: keine Mandantengrenze - if ($actorLevel >= 3) { + if ($actorLevel >= 4) { return; } @@ -103,7 +110,7 @@ final class RoleService $actor = $this->security->getUser(); // Regel 2: nur im eigenen Mandanten-Teilbaum - if ($actorLevel === 2) { // Reseller-Admin + if ($actorLevel === 3) { // Reseller-Admin $actorReseller = $actor->getReseller(); $effectiveReseller = $targetCompany?->getReseller() ?? $targetReseller; if (null === $actorReseller || null === $effectiveReseller @@ -114,7 +121,7 @@ final class RoleService return; } - if ($actorLevel === 1) { // Firmen-Admin + if ($actorLevel === 2) { // Firmen-Admin $actorCompany = $actor->getCompany(); if (null === $actorCompany || null === $targetCompany || !$targetCompany->getId()->equals($actorCompany->getId())) { diff --git a/frontend/src/layouts/DashboardLayout.vue b/frontend/src/layouts/DashboardLayout.vue index dad0db0..9392536 100644 --- a/frontend/src/layouts/DashboardLayout.vue +++ b/frontend/src/layouts/DashboardLayout.vue @@ -16,7 +16,6 @@ const nav = computed(() => [ { label: 'Visitenkarten', to: '/app/card-editor', icon: 'M3 5h18v14H3zM3 10h18M7 15h5', show: true }, { label: 'Standorte', to: '/app/locations', icon: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', show: auth.isCompanyAdmin || auth.isResellerAdmin }, { label: 'Domains', to: '/app/domains', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z', show: auth.isCompanyAdmin || auth.isResellerAdmin }, - { label: 'Benutzer', to: '/app/users', icon: 'M17 21v-2a4 4 0 0 0-3-3.87M9 21v-2a4 4 0 0 0-3-3.87M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM23 11l-2 2-1-1', show: auth.isCompanyAdmin || auth.isResellerAdmin || auth.isPlatformAdmin }, { label: 'Design', to: '/app/design', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: true }, { label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: true }, ].filter((i) => i.show)) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index d8e5c7c..53bfef5 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -20,7 +20,6 @@ const router = createRouter({ { path: 'companies', name: 'companies', component: () => import('@/views/CompaniesView.vue') }, { path: 'employees', name: 'employees', component: () => import('@/views/EmployeesView.vue') }, { path: 'card-editor', name: 'card-editor', component: () => import('@/views/CardEditorView.vue') }, - { path: 'users', name: 'users', component: () => import('@/views/UsersView.vue') }, { path: 'locations', name: 'locations', component: () => import('@/views/LocationsView.vue') }, { path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.vue') }, { path: 'design', name: 'design', component: () => import('@/views/DesignView.vue') }, diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index a646b5e..55d52f3 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -16,10 +16,11 @@ export interface CurrentUser { } const ROLE_LEVEL: Record = { - ROLE_PLATFORM_ADMIN: 3, - ROLE_RESELLER_ADMIN: 2, - ROLE_COMPANY_ADMIN: 1, - ROLE_EMPLOYEE: 0, + ROLE_PLATFORM_ADMIN: 4, + ROLE_RESELLER_ADMIN: 3, + ROLE_COMPANY_ADMIN: 2, + ROLE_EMPLOYEE: 1, + ROLE_CONTACT: 0, } export const useAuthStore = defineStore('auth', () => { diff --git a/frontend/src/views/EmployeesView.vue b/frontend/src/views/EmployeesView.vue index c4564d1..75d7636 100644 --- a/frontend/src/views/EmployeesView.vue +++ b/frontend/src/views/EmployeesView.vue @@ -1,5 +1,6 @@ - - - -