- 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 <noreply@anthropic.com>
445 lines
10 KiB
PHP
445 lines
10 KiB
PHP
<?php
|
||
|
||
namespace App\Entity;
|
||
|
||
use ApiPlatform\Metadata\ApiResource;
|
||
use App\Repository\EmployeeRepository;
|
||
use Doctrine\Common\Collections\ArrayCollection;
|
||
use Doctrine\Common\Collections\Collection;
|
||
use Doctrine\ORM\Mapping as ORM;
|
||
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||
use Symfony\Component\Security\Core\User\UserInterface;
|
||
use Symfony\Component\Serializer\Attribute\Ignore;
|
||
use Symfony\Component\Uid\Uuid;
|
||
|
||
/**
|
||
* 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, 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';
|
||
/** 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)]
|
||
private Uuid $id;
|
||
|
||
#[ORM\Column(length: 100)]
|
||
private string $firstName;
|
||
|
||
#[ORM\Column(length: 100)]
|
||
private string $lastName;
|
||
|
||
/** Öffentlicher Slug (eindeutig innerhalb der Firma). */
|
||
#[ORM\Column(length: 120)]
|
||
private string $slug;
|
||
|
||
/**
|
||
* Stabiler Kurz-Code für NFC-Tags / gedruckte QR-Codes. Verweist per
|
||
* Redirect (/t/{code}) immer auf das aktuelle Profil – bleibt also gleich,
|
||
* auch wenn sich der Slug ändert. Generiert beim Anlegen, nicht editierbar.
|
||
*/
|
||
#[ORM\Column(length: 16, nullable: true)]
|
||
private ?string $shortCode = null;
|
||
|
||
#[ORM\Column(length: 150, nullable: true)]
|
||
private ?string $title = null;
|
||
|
||
#[ORM\Column(length: 150, nullable: true)]
|
||
private ?string $position = null;
|
||
|
||
#[ORM\Column(length: 150, nullable: true)]
|
||
private ?string $department = null;
|
||
|
||
#[ORM\Column(length: 180, nullable: true)]
|
||
private ?string $email = null;
|
||
|
||
#[ORM\Column(length: 50, nullable: true)]
|
||
private ?string $phone = null;
|
||
|
||
#[ORM\Column(length: 50, nullable: true)]
|
||
private ?string $mobile = null;
|
||
|
||
#[ORM\Column(length: 255, nullable: true)]
|
||
private ?string $photoPath = null;
|
||
|
||
#[ORM\Column(type: 'text', nullable: true)]
|
||
private ?string $bio = null;
|
||
|
||
#[ORM\Column(length: 20)]
|
||
private string $status = 'active';
|
||
|
||
/** Pro-Mitarbeiter-Freigabe für Self-Service (zusätzlich zu Company::selfEditEnabled). */
|
||
#[ORM\Column]
|
||
private bool $selfEditAllowed = false;
|
||
|
||
/** @var string[] Felder, die der Mitarbeiter selbst ändern darf (leer = alle erlaubten). */
|
||
#[ORM\Column(type: 'json')]
|
||
private array $editableFields = [];
|
||
|
||
#[ORM\ManyToOne(targetEntity: Company::class, inversedBy: 'employees')]
|
||
#[ORM\JoinColumn(nullable: false)]
|
||
private Company $company;
|
||
|
||
#[ORM\ManyToOne(targetEntity: Location::class)]
|
||
private ?Location $location = null;
|
||
|
||
// --- 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); Standard = ROLE_CONTACT (reines Profil). */
|
||
#[ORM\Column(type: 'json')]
|
||
private array $roles = [self::ROLE_CONTACT];
|
||
|
||
/** @var Collection<int, ContactLink> */
|
||
#[ORM\OneToMany(targetEntity: ContactLink::class, mappedBy: 'employee', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||
private Collection $contactLinks;
|
||
|
||
#[ORM\Column(type: 'datetime_immutable')]
|
||
private \DateTimeImmutable $createdAt;
|
||
|
||
#[ORM\Column(type: 'datetime_immutable')]
|
||
private \DateTimeImmutable $updatedAt;
|
||
|
||
public function __construct()
|
||
{
|
||
$this->id = Uuid::v7();
|
||
$this->shortCode = bin2hex(random_bytes(4));
|
||
$this->createdAt = new \DateTimeImmutable();
|
||
$this->updatedAt = new \DateTimeImmutable();
|
||
$this->contactLinks = new ArrayCollection();
|
||
}
|
||
|
||
public function getShortCode(): ?string
|
||
{
|
||
return $this->shortCode;
|
||
}
|
||
|
||
public function ensureShortCode(): void
|
||
{
|
||
if (null === $this->shortCode) {
|
||
$this->shortCode = bin2hex(random_bytes(4));
|
||
}
|
||
}
|
||
|
||
public function getId(): Uuid
|
||
{
|
||
return $this->id;
|
||
}
|
||
|
||
public function getFirstName(): string
|
||
{
|
||
return $this->firstName;
|
||
}
|
||
|
||
public function setFirstName(string $firstName): self
|
||
{
|
||
$this->firstName = $firstName;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function getLastName(): string
|
||
{
|
||
return $this->lastName;
|
||
}
|
||
|
||
public function setLastName(string $lastName): self
|
||
{
|
||
$this->lastName = $lastName;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function getSlug(): string
|
||
{
|
||
return $this->slug;
|
||
}
|
||
|
||
public function setSlug(string $slug): self
|
||
{
|
||
$this->slug = $slug;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function getTitle(): ?string
|
||
{
|
||
return $this->title;
|
||
}
|
||
|
||
public function setTitle(?string $title): self
|
||
{
|
||
$this->title = $title;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function getPosition(): ?string
|
||
{
|
||
return $this->position;
|
||
}
|
||
|
||
public function setPosition(?string $position): self
|
||
{
|
||
$this->position = $position;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function getDepartment(): ?string
|
||
{
|
||
return $this->department;
|
||
}
|
||
|
||
public function setDepartment(?string $department): self
|
||
{
|
||
$this->department = $department;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function getEmail(): ?string
|
||
{
|
||
return $this->email;
|
||
}
|
||
|
||
public function setEmail(?string $email): self
|
||
{
|
||
$this->email = $email;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function getPhone(): ?string
|
||
{
|
||
return $this->phone;
|
||
}
|
||
|
||
public function setPhone(?string $phone): self
|
||
{
|
||
$this->phone = $phone;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function getMobile(): ?string
|
||
{
|
||
return $this->mobile;
|
||
}
|
||
|
||
public function setMobile(?string $mobile): self
|
||
{
|
||
$this->mobile = $mobile;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function getPhotoPath(): ?string
|
||
{
|
||
return $this->photoPath;
|
||
}
|
||
|
||
public function setPhotoPath(?string $photoPath): self
|
||
{
|
||
$this->photoPath = $photoPath;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function getBio(): ?string
|
||
{
|
||
return $this->bio;
|
||
}
|
||
|
||
public function setBio(?string $bio): self
|
||
{
|
||
$this->bio = $bio;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function getStatus(): string
|
||
{
|
||
return $this->status;
|
||
}
|
||
|
||
public function setStatus(string $status): self
|
||
{
|
||
$this->status = $status;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function isSelfEditAllowed(): bool
|
||
{
|
||
return $this->selfEditAllowed;
|
||
}
|
||
|
||
public function setSelfEditAllowed(bool $selfEditAllowed): self
|
||
{
|
||
$this->selfEditAllowed = $selfEditAllowed;
|
||
|
||
return $this;
|
||
}
|
||
|
||
/** @return string[] */
|
||
public function getEditableFields(): array
|
||
{
|
||
return $this->editableFields;
|
||
}
|
||
|
||
/** @param string[] $editableFields */
|
||
public function setEditableFields(array $editableFields): self
|
||
{
|
||
$this->editableFields = $editableFields;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function getCompany(): Company
|
||
{
|
||
return $this->company;
|
||
}
|
||
|
||
public function setCompany(Company $company): self
|
||
{
|
||
$this->company = $company;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function getLocation(): ?Location
|
||
{
|
||
return $this->location;
|
||
}
|
||
|
||
public function setLocation(?Location $location): self
|
||
{
|
||
$this->location = $location;
|
||
|
||
return $this;
|
||
}
|
||
|
||
// --- Login/Auth ---
|
||
|
||
public function getLoginEmail(): ?string
|
||
{
|
||
return $this->loginEmail;
|
||
}
|
||
|
||
public function setLoginEmail(?string $loginEmail): self
|
||
{
|
||
$this->loginEmail = $loginEmail;
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function hasLogin(): bool
|
||
{
|
||
return null !== $this->loginEmail && null !== $this->password;
|
||
}
|
||
|
||
#[Ignore]
|
||
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;
|
||
}
|
||
|
||
#[Ignore]
|
||
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> */
|
||
public function getContactLinks(): Collection
|
||
{
|
||
return $this->contactLinks;
|
||
}
|
||
|
||
public function addContactLink(ContactLink $link): self
|
||
{
|
||
if (!$this->contactLinks->contains($link)) {
|
||
$this->contactLinks->add($link);
|
||
$link->setEmployee($this);
|
||
}
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function removeContactLink(ContactLink $link): self
|
||
{
|
||
$this->contactLinks->removeElement($link);
|
||
|
||
return $this;
|
||
}
|
||
|
||
public function getReseller(): ?Reseller
|
||
{
|
||
return $this->company->getReseller();
|
||
}
|
||
|
||
public function getCreatedAt(): \DateTimeImmutable
|
||
{
|
||
return $this->createdAt;
|
||
}
|
||
|
||
public function getUpdatedAt(): \DateTimeImmutable
|
||
{
|
||
return $this->updatedAt;
|
||
}
|
||
|
||
public function touch(): void
|
||
{
|
||
$this->updatedAt = new \DateTimeImmutable();
|
||
}
|
||
}
|