feat: door access codes display and admin crud
This commit is contained in:
1 parent
91419294b8
commit
afa3f17d77
20 files changed
+1428
No files matched your search
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/></g></svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260925120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds the access codes for doors, the lift and the barrier';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// No unique key: rotating a code means a second row for the same door, kept apart by
|
||||
// valid_from. The index is on that, since every read orders by it.
|
||||
$this->addSql('CREATE TABLE access_code (id INT AUTO_INCREMENT NOT NULL, label VARCHAR(50) NOT NULL, code VARCHAR(32) NOT NULL, valid_from DATE NOT NULL, description VARCHAR(255) DEFAULT NULL, visibility VARCHAR(20) NOT NULL, INDEX idx_access_code_valid_from (valid_from), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE access_code');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\AccessCode;
|
||||
|
||||
use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Entity\AccessCode;
|
||||
use App\Repository\AccessCodeRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* The quick look: what do I type at this door, right now.
|
||||
*
|
||||
* Read-only and deliberately thin -- one card per door with the code that works today, large
|
||||
* enough to read on a phone held at arm's length in a badly lit lobby. Maintaining the codes
|
||||
* happens in the table under /admin/access-codes, which is where the history, the dated-ahead
|
||||
* codes and the buttons live.
|
||||
*
|
||||
* Nothing here is dated: a code that starts next week is not shown until it starts, and one that
|
||||
* has been replaced is gone the day its successor takes over. Both would only be one more number
|
||||
* on the screen for somebody to mistype.
|
||||
*/
|
||||
final class IndexController extends AbstractController
|
||||
{
|
||||
use CurrentUserTrait;
|
||||
|
||||
public function __construct(private readonly AccessCodeRepository $codes)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/access-codes', name: 'app_access_code', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$today = new \DateTimeImmutable('today');
|
||||
|
||||
return $this->render('access_code/index.html.twig', [
|
||||
'codes' => $this->current($this->codes->findCurrentReadableBy($this->currentUser()->getRoles(), $today)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One code per door: the newest of those already in force, which the repository puts first.
|
||||
*
|
||||
* A door only has more than one here because its code was rotated, and then the older rows
|
||||
* are history -- readable in the admin table, not on the screen somebody is standing at a
|
||||
* door with.
|
||||
*
|
||||
* @param list<AccessCode> $codes ordered by label, newest first within a label
|
||||
*
|
||||
* @return list<AccessCode>
|
||||
*/
|
||||
private function current(array $codes): array
|
||||
{
|
||||
$current = [];
|
||||
|
||||
foreach ($codes as $code) {
|
||||
$current[$code->getLabel()] ??= $code;
|
||||
}
|
||||
|
||||
return array_values($current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccessCode;
|
||||
|
||||
use App\Entity\AccessCode;
|
||||
use App\Enum\CodeVisibility;
|
||||
use App\Form\Admin\AccessCodeFormType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Security\Role;
|
||||
use App\Service\AuditLogger;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted(Role::ADMIN)]
|
||||
final class CreateController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly AuditLogger $audit,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/access-codes/new', name: 'app_admin_access_code_new', methods: ['GET', 'POST'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// Prefilled with today: a code being entered is usually one that already works. Dating
|
||||
// it forward is the deliberate act, and that is the one worth typing.
|
||||
$code = new AccessCode('', '', new \DateTimeImmutable('today'), null, CodeVisibility::EVERYONE);
|
||||
$form = $this->createForm(AccessCodeFormType::class, $code, [
|
||||
'action' => $this->generateUrl('app_admin_access_code_new'),
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->em->persist($code);
|
||||
$this->em->flush();
|
||||
|
||||
$this->audit->accessCodeCreated($code);
|
||||
$this->addFlash('success', \sprintf('%s angelegt.', $code->getLabel()));
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_access_code'));
|
||||
}
|
||||
|
||||
return $this->render('admin/_modal_access_code.html.twig', [
|
||||
'form' => $form,
|
||||
'code' => $code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccessCode;
|
||||
|
||||
use App\Controller\Traits\ConfirmedActionTrait;
|
||||
use App\Entity\AccessCode;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Security\Role;
|
||||
use App\Service\AuditLogger;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Nothing refers to an access code, so there is nothing to refuse the deletion over -- only the
|
||||
* confirmation, which is there because a deleted code is not recoverable from the screen that
|
||||
* deleted it.
|
||||
*/
|
||||
#[IsGranted(Role::ADMIN)]
|
||||
final class DeleteController extends AbstractController
|
||||
{
|
||||
use ConfirmedActionTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly AuditLogger $audit,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/access-codes/{id}/delete', name: 'app_admin_access_code_delete', methods: ['GET', 'POST'])]
|
||||
public function index(Request $request, AccessCode $code): Response
|
||||
{
|
||||
if (!$request->isMethod(Request::METHOD_POST)) {
|
||||
return $this->render('admin/_modal_access_code_delete.html.twig', [
|
||||
'code' => $code,
|
||||
'csrf_token_id' => 'delete-access-code-'.$code->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->denyUnlessCsrfValid($request, 'delete-access-code-'.$code->getId());
|
||||
|
||||
$this->audit->accessCodeDeleted((int) $code->getId(), $code->getLabel(), $code->getVisibility());
|
||||
|
||||
$label = $code->getLabel();
|
||||
$this->em->remove($code);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash('success', \sprintf('%s gelöscht.', $label));
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_access_code'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccessCode;
|
||||
|
||||
use App\Entity\AccessCode;
|
||||
use App\Form\Admin\AccessCodeFormType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Security\Role;
|
||||
use App\Service\AuditLogger;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted(Role::ADMIN)]
|
||||
final class EditController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly AuditLogger $audit,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/access-codes/{id}/edit', name: 'app_admin_access_code_edit', methods: ['GET', 'POST'])]
|
||||
public function index(Request $request, AccessCode $code): Response
|
||||
{
|
||||
// Read before the form binds, so the audit record can say that the code itself changed
|
||||
// without saying what it was or what it became.
|
||||
$previousCode = $code->getCode();
|
||||
$previousVisibility = $code->getVisibility();
|
||||
|
||||
$form = $this->createForm(AccessCodeFormType::class, $code, [
|
||||
'action' => $this->generateUrl('app_admin_access_code_edit', ['id' => $code->getId()]),
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->em->flush();
|
||||
|
||||
$this->audit->accessCodeUpdated($code, $previousCode !== $code->getCode(), $previousVisibility);
|
||||
$this->addFlash('success', \sprintf('%s gespeichert.', $code->getLabel()));
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_access_code'));
|
||||
}
|
||||
|
||||
return $this->render('admin/_modal_access_code.html.twig', [
|
||||
'form' => $form,
|
||||
'code' => $code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccessCode;
|
||||
|
||||
use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Entity\AccessCode;
|
||||
use App\Repository\AccessCodeRepository;
|
||||
use App\Security\Role;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Maintaining the codes: every row there is, including the ones the quick look at /access-codes
|
||||
* leaves out -- dated ahead, or replaced by a newer one.
|
||||
*
|
||||
* Which is which is a question about today rather than a stored flag, and it is answered here
|
||||
* because it is a question about the whole list: a template cannot look back over its own rows.
|
||||
*/
|
||||
#[IsGranted(Role::ADMIN)]
|
||||
final class IndexController extends AbstractController
|
||||
{
|
||||
use CurrentUserTrait;
|
||||
|
||||
public function __construct(private readonly AccessCodeRepository $codes)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/access-codes', name: 'app_admin_access_code', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$today = new \DateTimeImmutable('today');
|
||||
|
||||
return $this->render('admin/access_codes.html.twig', [
|
||||
'rows' => $this->rows($this->codes->findReadableBy($this->currentUser()->getRoles()), $today),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per code, each told what it is in relation to the others for the same door.
|
||||
*
|
||||
* The repository hands them over grouped by label, newest first, so the current code of a
|
||||
* door is the first one in its group that has come into force: everything above it is still
|
||||
* to come, everything below it has been replaced. A door whose code has never been rotated
|
||||
* has a group of one, which is the ordinary case and needs no special handling.
|
||||
*
|
||||
* @param list<AccessCode> $codes
|
||||
*
|
||||
* @return list<array{code: AccessCode, upcoming: bool, superseded: bool}>
|
||||
*/
|
||||
private function rows(array $codes, \DateTimeImmutable $today): array
|
||||
{
|
||||
$rows = [];
|
||||
$currentSeen = [];
|
||||
|
||||
foreach ($codes as $code) {
|
||||
$upcoming = $code->isUpcoming($today);
|
||||
$label = $code->getLabel();
|
||||
$isCurrent = !$upcoming && !isset($currentSeen[$label]);
|
||||
|
||||
if ($isCurrent) {
|
||||
$currentSeen[$label] = true;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'code' => $code,
|
||||
'upcoming' => $upcoming,
|
||||
'superseded' => !$upcoming && !$isCurrent,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\CodeVisibility;
|
||||
use App\Repository\AccessCodeRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* A code that opens something in the building: a door, the lift, the barrier.
|
||||
*
|
||||
* Stored in plain text, and that is the feature -- a code nobody can read opens nothing. What
|
||||
* protects it is the firewall in front of the page and `visibility`, not the storage.
|
||||
*
|
||||
* Who a code is for is `visibility` and nothing else: it is the access decision and the caption
|
||||
* at once. Anything about the audience that the two cases do not cover -- "nur donnerstags",
|
||||
* "Reinigungsfirma" -- belongs in the description, which is prose and claims nothing.
|
||||
*
|
||||
* Nothing here is unique. The same door legitimately appears more than once, because rotating a
|
||||
* code means adding the new one with a later `validFrom` while the old row is still the one that
|
||||
* works; which of them is current is a question about today's date, answered when the list is
|
||||
* built rather than by a flag somebody has to remember to clear.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: AccessCodeRepository::class)]
|
||||
#[ORM\Index(name: 'idx_access_code_valid_from', columns: ['valid_from'])]
|
||||
class AccessCode
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 50)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(max: 50)]
|
||||
private string $label;
|
||||
|
||||
#[ORM\Column(length: 32)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(max: 32)]
|
||||
private string $code;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable')]
|
||||
#[Assert\NotNull]
|
||||
private \DateTimeImmutable $validFrom;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(length: 20, enumType: CodeVisibility::class)]
|
||||
private CodeVisibility $visibility = CodeVisibility::EVERYONE;
|
||||
|
||||
public function __construct(
|
||||
string $label,
|
||||
string $code,
|
||||
\DateTimeImmutable $validFrom,
|
||||
?string $description = null,
|
||||
CodeVisibility $visibility = CodeVisibility::EVERYONE,
|
||||
) {
|
||||
$this->label = $label;
|
||||
$this->code = $code;
|
||||
$this->validFrom = $validFrom;
|
||||
$this->description = $description;
|
||||
$this->visibility = $visibility;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): void
|
||||
{
|
||||
$this->label = $label;
|
||||
}
|
||||
|
||||
public function getCode(): string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function setCode(string $code): void
|
||||
{
|
||||
$this->code = $code;
|
||||
}
|
||||
|
||||
public function getValidFrom(): \DateTimeImmutable
|
||||
{
|
||||
return $this->validFrom;
|
||||
}
|
||||
|
||||
public function setValidFrom(\DateTimeImmutable $validFrom): void
|
||||
{
|
||||
$this->validFrom = $validFrom;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): void
|
||||
{
|
||||
$this->description = $description;
|
||||
}
|
||||
|
||||
public function getVisibility(): CodeVisibility
|
||||
{
|
||||
return $this->visibility;
|
||||
}
|
||||
|
||||
public function setVisibility(CodeVisibility $visibility): void
|
||||
{
|
||||
$this->visibility = $visibility;
|
||||
}
|
||||
|
||||
/** Not yet in force: it is listed so nobody is surprised on the day it takes over. */
|
||||
public function isUpcoming(\DateTimeImmutable $on): bool
|
||||
{
|
||||
return $this->validFrom > $on;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
use App\Security\Role;
|
||||
|
||||
/**
|
||||
* Who may read an access code.
|
||||
*
|
||||
* Deliberately an enum with two cases rather than a boolean column: "admin only" is one policy
|
||||
* among several plausible ones -- a code for the reception desk or for the cleaning company is
|
||||
* the obvious next case -- and a third case here costs a case in the match below, where a third
|
||||
* value of a boolean costs a migration.
|
||||
*
|
||||
* This is also what the list shows under "Für wen": the access decision and the caption are one
|
||||
* value, so there is no second, prose field describing an audience that the filter does not
|
||||
* actually enforce.
|
||||
*/
|
||||
enum CodeVisibility: string
|
||||
{
|
||||
/** Every signed-in user, which in this application means everyone who works here. */
|
||||
case EVERYONE = 'everyone';
|
||||
|
||||
/** Administrators only -- a code the whole staff has no business reading. */
|
||||
case ADMIN = 'admin';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::EVERYONE => 'Alle Mitarbeitenden',
|
||||
self::ADMIN => 'Administration',
|
||||
};
|
||||
}
|
||||
|
||||
/** Worth marking in the list: not everyone who can see the page can see this row. */
|
||||
public function isRestricted(): bool
|
||||
{
|
||||
return self::EVERYONE !== $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* The roles this application knows are the ones in Role::ELIGIBLE, so "everyone" needs no
|
||||
* role of its own: reaching the page at all already required one.
|
||||
*
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
public function isReadableBy(array $roles): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::EVERYONE => true,
|
||||
self::ADMIN => \in_array(Role::ADMIN, $roles, true),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The cases somebody with these roles may read, for the repository to filter on.
|
||||
*
|
||||
* @param list<string> $roles
|
||||
*
|
||||
* @return list<self>
|
||||
*/
|
||||
public static function readableBy(array $roles): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
self::cases(),
|
||||
static fn (self $visibility): bool => $visibility->isReadableBy($roles),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Admin;
|
||||
|
||||
use App\Entity\AccessCode;
|
||||
use App\Enum\CodeVisibility;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* An access code. Every field is editable, including the code itself -- correcting a typo in a
|
||||
* code is an edit, while giving a door a new code is a new row with a later start date.
|
||||
*
|
||||
* The required text fields carry `empty_data => ''` for the reason ResourceGroupFormType's
|
||||
* position does: their setters take a string, and a cleared field otherwise reaches them as null
|
||||
* and fails with a TypeError before the NotBlank constraint ever gets to say so.
|
||||
*/
|
||||
final class AccessCodeFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('label', TextType::class, [
|
||||
'empty_data' => '',
|
||||
'label' => 'Bezeichnung',
|
||||
])
|
||||
->add('description', TextareaType::class, [
|
||||
'label' => 'Beschreibung',
|
||||
'required' => false,
|
||||
'attr' => ['rows' => 2],
|
||||
])
|
||||
->add('code', TextType::class, [
|
||||
'empty_data' => '',
|
||||
'label' => 'Code',
|
||||
])
|
||||
->add('validFrom', DateType::class, [
|
||||
'label' => 'Gültig ab',
|
||||
'widget' => 'single_text',
|
||||
])
|
||||
->add('visibility', EnumType::class, [
|
||||
'label' => 'Für wen',
|
||||
'class' => CodeVisibility::class,
|
||||
'choice_label' => static fn (CodeVisibility $visibility): string => $visibility->label(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => AccessCode::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\AccessCode;
|
||||
use App\Enum\CodeVisibility;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<AccessCode>
|
||||
*/
|
||||
class AccessCodeRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, AccessCode::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The code that opens each door today, for the quick-look list.
|
||||
*
|
||||
* Dated-ahead codes are left in the database and out of the query: a code that does not open
|
||||
* anything yet is not an answer to "what do I type", and showing it beside one that does is
|
||||
* how somebody ends up typing the wrong one. Ordered so that the first row per label is the
|
||||
* one in force -- the caller only has to take it.
|
||||
*
|
||||
* @param list<string> $roles
|
||||
*
|
||||
* @return list<AccessCode>
|
||||
*/
|
||||
public function findCurrentReadableBy(array $roles, \DateTimeImmutable $on): array
|
||||
{
|
||||
return $this->createQueryBuilder('c')
|
||||
->andWhere('c.visibility IN (:visibilities)')
|
||||
->andWhere('c.validFrom <= :on')
|
||||
->setParameter('visibilities', CodeVisibility::readableBy($roles))
|
||||
->setParameter('on', $on)
|
||||
->orderBy('c.label', 'ASC')
|
||||
->addOrderBy('c.validFrom', 'DESC')
|
||||
->addOrderBy('c.id', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the holder of these roles may read, grouped by door and newest code first.
|
||||
*
|
||||
* The restriction is applied here rather than in the template, so a code somebody may not
|
||||
* read is never loaded into a response in the first place -- a hidden table row is still a
|
||||
* published code.
|
||||
*
|
||||
* @param list<string> $roles
|
||||
*
|
||||
* @return list<AccessCode>
|
||||
*/
|
||||
public function findReadableBy(array $roles): array
|
||||
{
|
||||
return $this->createQueryBuilder('c')
|
||||
->andWhere('c.visibility IN (:visibilities)')
|
||||
->setParameter('visibilities', CodeVisibility::readableBy($roles))
|
||||
->orderBy('c.label', 'ASC')
|
||||
->addOrderBy('c.validFrom', 'DESC')
|
||||
->addOrderBy('c.id', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\AccessCode;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\BookingSeries;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceGroup;
|
||||
use App\Entity\User;
|
||||
use App\Enum\CodeVisibility;
|
||||
use App\Enum\ResourceType;
|
||||
use App\Model\SeriesResult;
|
||||
use App\Model\TimeRange;
|
||||
@@ -235,6 +237,49 @@ final readonly class AuditLogger
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The code value itself is never logged, here or anywhere else.
|
||||
*
|
||||
* The audit trail is a file that whoever is investigating an incident reads, and a record of
|
||||
* every door code ever set would turn it into a keyring -- one that outlives the codes, since
|
||||
* the log is kept long after a code has been rotated away. What a record needs to answer is
|
||||
* who changed which code and when, and that it can do while naming only the door.
|
||||
*/
|
||||
public function accessCodeCreated(AccessCode $code): void
|
||||
{
|
||||
$this->logger->info('Access code created.', $this->accessCode($code));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $codeChanged whether the secret itself was replaced, as opposed
|
||||
* to a correction to the label or the date -- not
|
||||
* derivable later, since the value is never logged
|
||||
* @param CodeVisibility $previousVisibility spelled out for the same reason as a resource's
|
||||
* `was_active`: widening a code from the
|
||||
* administrators to the whole staff is the one edit
|
||||
* here that changes who may read something
|
||||
*/
|
||||
public function accessCodeUpdated(AccessCode $code, bool $codeChanged, CodeVisibility $previousVisibility): void
|
||||
{
|
||||
$this->logger->info('Access code updated.', $this->accessCode($code) + [
|
||||
'code_changed' => $codeChanged,
|
||||
'previous_visibility' => $previousVisibility->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a snapshot rather than the entity, as the other deletions do: afterwards there is no
|
||||
* id left to read.
|
||||
*/
|
||||
public function accessCodeDeleted(int $id, string $label, CodeVisibility $visibility): void
|
||||
{
|
||||
$this->logger->info('Access code deleted.', [
|
||||
'access_code' => $label,
|
||||
'access_code_id' => $id,
|
||||
'visibility' => $visibility->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -275,6 +320,19 @@ final readonly class AuditLogger
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function accessCode(AccessCode $code): array
|
||||
{
|
||||
return [
|
||||
'access_code' => $code->getLabel(),
|
||||
'access_code_id' => $code->getId(),
|
||||
'valid_from' => $code->getValidFrom()->format('Y-m-d'),
|
||||
'visibility' => $code->getVisibility()->value,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{#
|
||||
The quick look: what to type at a door, on the phone you are holding in front of it.
|
||||
|
||||
One card per door with the code in force today and nothing else -- no dates, no history, no
|
||||
buttons. Maintaining the codes is admin/access_codes.html.twig, which is the table this page
|
||||
used to be.
|
||||
|
||||
The card is sized for reading, not for fitting: the code is the largest thing on the screen,
|
||||
in near-black on white and in a tabular font so 1 and 7 cannot be confused. One column on a
|
||||
phone, more when there is room.
|
||||
#}
|
||||
{% extends 'layout.html.twig' %}
|
||||
|
||||
{% block title %}Zugangscodes · E&P Platz{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<a class="text-sm text-gray-500 hover:text-gray-900" href="{{ path('app_dashboard') }}">← Übersicht</a>
|
||||
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2 pt-2">
|
||||
<div>
|
||||
<h1>Zugangscodes</h1>
|
||||
<p class="subline">Türen, Aufzug etc.</p>
|
||||
</div>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<a class="button button--small button--neutral" href="{{ path('app_admin_access_code') }}">Codes verwalten</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if codes is empty %}
|
||||
<p class="pt-8 text-sm text-gray-500">Zurzeit gilt kein Code.</p>
|
||||
{% else %}
|
||||
<div class="pt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{% for code in codes %}
|
||||
<div class="flex flex-col rounded-md border border-gray-200 p-6" data-access-code>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="text-lg font-bold">{{ code.label }}</span>
|
||||
{% if code.visibility.isRestricted %}
|
||||
<span class="badge bg-gray-100 text-gray-600 shrink-0">{{ code.visibility.label }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if code.description %}
|
||||
<p class="pt-1 text-sm text-gray-500">{{ code.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{# mt-auto, so the code sits at the bottom of the card and the codes of a row
|
||||
line up whether or not a door has a description -- grid stretches the cards
|
||||
of a row to the same height, and this spends the difference above the code
|
||||
rather than below it. Same device as the dashboard tiles.
|
||||
|
||||
tracking-widest so a long code stays readable as digits rather than as a
|
||||
number, and break-all so it never overflows a narrow screen. #}
|
||||
<p class="mt-auto pt-4 font-mono text-4xl font-bold tracking-widest tabular-nums break-all text-gray-900"
|
||||
data-code>{{ code.code }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{# Creating and editing an access code. See admin/_modal_resource.html.twig for how it closes. #}
|
||||
{% extends 'htmx_modal.html.twig' %}
|
||||
|
||||
{% block title %}
|
||||
{%- if code.id -%}
|
||||
{{ code.label }} bearbeiten
|
||||
{%- else -%}
|
||||
Code hinzufügen
|
||||
{%- endif -%}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ form_start(form, { attr: {
|
||||
'hx-post': form.vars.action,
|
||||
'hx-target': '#htmx-modal',
|
||||
'hx-swap': 'outerHTML',
|
||||
} }) }}
|
||||
{{ form_errors(form) }}
|
||||
|
||||
<div class="space-y-4">
|
||||
{{ form_row(form.label) }}
|
||||
{{ form_row(form.description) }}
|
||||
{{ form_row(form.code) }}
|
||||
{{ form_row(form.validFrom) }}
|
||||
{{ form_row(form.visibility) }}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-6">
|
||||
<button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="button button--primary">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends 'htmx_confirmation_modal.html.twig' %}
|
||||
|
||||
{% block title %}Code löschen?{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
Der Code für <em>{{ code.label }}</em> (gültig ab
|
||||
{{ code.validFrom|format_date(pattern: 'd.MM.y') }}) wird gelöscht.
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block button_confirm %}Löschen{% endblock %}
|
||||
@@ -0,0 +1,82 @@
|
||||
{#
|
||||
Maintaining the codes: every row there is, in a table, newest per door first.
|
||||
|
||||
The counterpart of access_code/index.html.twig, which shows one card per door with the code
|
||||
that works today and nothing else. Everything the quick look leaves out is here -- rows the
|
||||
controller marked `upcoming` or `superseded`, muted and labelled -- because this is the
|
||||
screen on which a rotation is planned and checked.
|
||||
|
||||
„Für wen" is the visibility itself: it decides who is served the row and says so in the same
|
||||
breath, so there is nothing to keep in step between the two.
|
||||
#}
|
||||
{% extends 'layout.html.twig' %}
|
||||
|
||||
{% block title %}Zugangscodes verwalten · E&P Platz{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<a class="text-sm text-gray-500 hover:text-gray-900" href="{{ path('app_access_code') }}">← Zugangscodes</a>
|
||||
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2 pt-2">
|
||||
<div>
|
||||
<h1>Zugangscodes verwalten</h1>
|
||||
</div>
|
||||
<button type="button" class="button button--small button--primary"
|
||||
hx-get="{{ path('app_admin_access_code_new') }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">Code hinzufügen</button>
|
||||
</div>
|
||||
|
||||
{% if rows is empty %}
|
||||
<p class="pt-8 text-sm text-gray-500">Noch nichts hinterlegt.</p>
|
||||
{% else %}
|
||||
<div class="pt-8 overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-gray-200 text-gray-500">
|
||||
<tr>
|
||||
<th class="py-2 pr-4 font-semibold">Bezeichnung</th>
|
||||
<th class="py-2 pr-4 font-semibold">Beschreibung</th>
|
||||
<th class="py-2 pr-4 font-semibold">Code</th>
|
||||
<th class="py-2 pr-4 font-semibold">sichtbar für</th>
|
||||
<th class="py-2 pr-4 font-semibold">gültig ab</th>
|
||||
<th class="py-2 font-semibold"><span class="sr-only">Aktionen</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{% for row in rows %}
|
||||
{% set muted = row.upcoming or row.superseded %}
|
||||
<tr data-access-code class="{{ muted ? 'text-gray-400' : '' }}">
|
||||
<td class="py-2 pr-4 font-semibold">{{ row.code.label }}</td>
|
||||
<td class="py-2 pr-4">{{ row.code.description }}</td>
|
||||
<td class="py-2 pr-4 font-mono">{{ row.code.code }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
{% if row.code.visibility.isRestricted %}
|
||||
<span class="badge bg-gray-100 text-gray-600">{{ row.code.visibility.label }}</span>
|
||||
{% else %}
|
||||
{{ row.code.visibility.label }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="py-2 pr-4 whitespace-nowrap">
|
||||
{{ row.code.validFrom|format_date(pattern: 'd.MM.y') }}
|
||||
{% if row.upcoming %}
|
||||
<span class="badge bg-amber-100 text-amber-800 ml-1">noch nicht gültig</span>
|
||||
{% elseif row.superseded %}
|
||||
<span class="badge bg-gray-100 text-gray-600 ml-1">ersetzt</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="button button--small button--neutral"
|
||||
hx-get="{{ path('app_admin_access_code_edit', { id: row.code.id }) }}"
|
||||
hx-target="body" hx-swap="beforeend">Bearbeiten</button>
|
||||
<button type="button" class="button button--small button--neutral"
|
||||
hx-get="{{ path('app_admin_access_code_delete', { id: row.code.id }) }}"
|
||||
hx-target="body" hx-swap="beforeend">Löschen</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -49,6 +49,15 @@
|
||||
<p class="text-sm text-gray-500 pb-4">Alle Buchungen verwalten.</p>
|
||||
</a>
|
||||
|
||||
<a href="{{ path('app_access_code') }}"
|
||||
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
|
||||
<div class="flex items-center gap-3 pb-2">
|
||||
{{ ux_icon('lucide:key-round', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
|
||||
<span class="text-lg font-bold">Zugangscodes</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 pb-4">Codes für Türen, Aufzug und Schranke.</p>
|
||||
</a>
|
||||
|
||||
{#
|
||||
The header is deliberately not navigation, so the dashboard is the only place an
|
||||
admin screen can be reached from.
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\AccessCode;
|
||||
use App\Entity\User;
|
||||
use App\Enum\CodeVisibility;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* The quick look: one card per door with the code that works today.
|
||||
*
|
||||
* Most of what is asserted here is what the page does *not* show -- a code that is not in force
|
||||
* is not an answer to "what do I type", and a code somebody may not read must not reach them at
|
||||
* all. The full list is the admin table's job and is tested there.
|
||||
*/
|
||||
class AccessCodeControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient(server: ['HTTPS' => true]);
|
||||
$this->client->disableReboot();
|
||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->em->getConnection()->beginTransaction();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->em->getConnection()->rollBack();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testItIsBehindTheFirewall(): void
|
||||
{
|
||||
$this->client->request('GET', '/access-codes');
|
||||
|
||||
self::assertResponseRedirects();
|
||||
}
|
||||
|
||||
public function testAnEmployeeSeesACardPerDoor(): void
|
||||
{
|
||||
$this->code('Aufzug', '4711', description: 'Kabine im Erdgeschoss');
|
||||
$this->code('Schranke', '1234');
|
||||
$this->em->flush();
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$cards = $this->client->request('GET', '/access-codes')->filter('[data-access-code]');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertCount(2, $cards);
|
||||
self::assertStringContainsString('Aufzug', $cards->eq(0)->text());
|
||||
self::assertStringContainsString('Kabine im Erdgeschoss', $cards->eq(0)->text());
|
||||
self::assertSame('4711', $cards->eq(0)->filter('[data-code]')->text());
|
||||
self::assertSame('1234', $cards->eq(1)->filter('[data-code]')->text());
|
||||
}
|
||||
|
||||
/**
|
||||
* The restriction is a filter on the query, not a hidden card: a code an employee may not
|
||||
* read must not be in the response at all.
|
||||
*/
|
||||
public function testAnAdminOnlyCodeIsNotSentToAnEmployee(): void
|
||||
{
|
||||
$this->code('Serverraum', '9999', visibility: CodeVisibility::ADMIN);
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$crawler = $this->client->request('GET', '/access-codes');
|
||||
self::assertCount(0, $crawler->filter('[data-access-code]'));
|
||||
self::assertStringNotContainsString('9999', (string) $this->client->getResponse()->getContent());
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]', ['ROLE_ADMIN']));
|
||||
$card = $this->client->request('GET', '/access-codes')->filter('[data-access-code]');
|
||||
self::assertCount(1, $card);
|
||||
self::assertSame('9999', $card->filter('[data-code]')->text());
|
||||
self::assertStringContainsString('Nur Verwaltung', $card->text());
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotating a code. Only the one in force is shown: its predecessor is history, and its
|
||||
* successor does not open anything yet -- both would only be another number to mistype.
|
||||
*/
|
||||
public function testOnlyTheCodeInForceIsShown(): void
|
||||
{
|
||||
$this->code('Aufzug', 'OLD', validFrom: '-2 months');
|
||||
$this->code('Aufzug', 'NOW', validFrom: '-1 day');
|
||||
$this->code('Aufzug', 'NEXT', validFrom: '+1 month');
|
||||
$this->em->flush();
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$cards = $this->client->request('GET', '/access-codes')->filter('[data-access-code]');
|
||||
|
||||
self::assertCount(1, $cards);
|
||||
self::assertSame('NOW', $cards->filter('[data-code]')->text());
|
||||
$body = (string) $this->client->getResponse()->getContent();
|
||||
self::assertStringNotContainsString('OLD', $body);
|
||||
self::assertStringNotContainsString('NEXT', $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* A code that starts on a date starts that morning, not the day after.
|
||||
*/
|
||||
public function testACodeStartingTodayIsInForce(): void
|
||||
{
|
||||
$this->code('Aufzug', '4711', validFrom: 'today');
|
||||
$this->em->flush();
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$cards = $this->client->request('GET', '/access-codes')->filter('[data-access-code]');
|
||||
|
||||
self::assertCount(1, $cards);
|
||||
self::assertSame('4711', $cards->filter('[data-code]')->text());
|
||||
}
|
||||
|
||||
/**
|
||||
* A door that only has a code for next week is simply not on the page yet.
|
||||
*/
|
||||
public function testADoorWithNoCodeInForceIsAbsent(): void
|
||||
{
|
||||
$this->code('Schranke', 'SOON', validFrom: '+1 week');
|
||||
$this->em->flush();
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$crawler = $this->client->request('GET', '/access-codes');
|
||||
|
||||
self::assertCount(0, $crawler->filter('[data-access-code]'));
|
||||
self::assertStringContainsString('Zurzeit gilt kein Code', $crawler->filter('body')->text());
|
||||
}
|
||||
|
||||
public function testOnlyAnAdminIsOfferedTheTable(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
self::assertCount(0, $this->client->request('GET', '/access-codes')->filter('a[href="/admin/access-codes"]'));
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]', ['ROLE_ADMIN']));
|
||||
self::assertCount(1, $this->client->request('GET', '/access-codes')->filter('a[href="/admin/access-codes"]'));
|
||||
}
|
||||
|
||||
public function testTheDashboardLinksHereForEveryone(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/access-codes"]'));
|
||||
}
|
||||
|
||||
private function code(
|
||||
string $label,
|
||||
string $value,
|
||||
string $validFrom = 'today',
|
||||
?string $description = null,
|
||||
CodeVisibility $visibility = CodeVisibility::EVERYONE,
|
||||
): AccessCode {
|
||||
$code = new AccessCode($label, $value, new \DateTimeImmutable($validFrom), $description, $visibility);
|
||||
$this->em->persist($code);
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
private function user(string $email, array $roles = ['ROLE_EMPLOYEE']): User
|
||||
{
|
||||
$existing = $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
|
||||
if (null !== $existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$user = new User($email, 'Test');
|
||||
$user->setRoles($roles);
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin;
|
||||
|
||||
use App\Entity\AccessCode;
|
||||
use App\Entity\User;
|
||||
use App\Enum\CodeVisibility;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Maintaining the access codes: the table of everything there is, and the three writes.
|
||||
*
|
||||
* The quick look at /access-codes is the other half and is tested in AccessCodeControllerTest --
|
||||
* what it shows is one card per door, what this shows is every row including the ones that page
|
||||
* deliberately leaves out.
|
||||
*/
|
||||
class AccessCodeAdminControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient(server: ['HTTPS' => true]);
|
||||
$this->client->disableReboot();
|
||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->em->getConnection()->beginTransaction();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->em->getConnection()->rollBack();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testAnEmployeeCannotReachIt(): void
|
||||
{
|
||||
$code = $this->code('Aufzug', '4711');
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$this->client->request('GET', '/admin/access-codes');
|
||||
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||
|
||||
$this->client->request('GET', '/admin/access-codes/new', server: ['HTTP_HX-Request' => 'true']);
|
||||
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||
|
||||
$this->client->request('GET', \sprintf('/admin/access-codes/%d/edit', $code->getId()), server: ['HTTP_HX-Request' => 'true']);
|
||||
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||
|
||||
$this->client->request('POST', \sprintf('/admin/access-codes/%d/delete', $code->getId()), ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']);
|
||||
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||
$this->em->clear();
|
||||
self::assertNotNull($this->em->find(AccessCode::class, $code->getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every row, newest per door first, each marked with what it is today: the one in force
|
||||
* carries no badge, its successor is „noch nicht gültig" and its predecessor „ersetzt".
|
||||
*/
|
||||
public function testTheTableMarksUpcomingAndSupersededCodes(): void
|
||||
{
|
||||
$this->code('Aufzug', 'OLD', validFrom: '-2 months');
|
||||
$this->code('Aufzug', 'NOW', validFrom: '-1 day');
|
||||
$this->code('Aufzug', 'NEXT', validFrom: '+1 month');
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$rows = $this->client->request('GET', '/admin/access-codes')->filter('[data-access-code]');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertCount(3, $rows);
|
||||
self::assertStringContainsString('NEXT', $rows->eq(0)->text());
|
||||
self::assertStringContainsString('noch nicht gültig', $rows->eq(0)->text());
|
||||
self::assertStringContainsString('NOW', $rows->eq(1)->text());
|
||||
self::assertStringNotContainsString('ersetzt', $rows->eq(1)->text());
|
||||
self::assertStringNotContainsString('noch nicht gültig', $rows->eq(1)->text());
|
||||
self::assertStringContainsString('OLD', $rows->eq(2)->text());
|
||||
self::assertStringContainsString('ersetzt', $rows->eq(2)->text());
|
||||
}
|
||||
|
||||
/**
|
||||
* A door whose code only starts next week has nothing in force -- and nothing may be marked
|
||||
* as replaced, because nothing has replaced anything yet.
|
||||
*/
|
||||
public function testACodeThatHasNotStartedYetSupersedesNothing(): void
|
||||
{
|
||||
$this->code('Schranke', 'SOON', validFrom: '+1 week');
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$rows = $this->client->request('GET', '/admin/access-codes')->filter('[data-access-code]');
|
||||
|
||||
self::assertCount(1, $rows);
|
||||
self::assertStringContainsString('noch nicht gültig', $rows->text());
|
||||
self::assertStringNotContainsString('ersetzt', $rows->text());
|
||||
}
|
||||
|
||||
public function testTheQuickLookOffersTheTableToAnAdmin(): void
|
||||
{
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin/access-codes');
|
||||
|
||||
self::assertCount(1, $crawler->filter('a[href="/access-codes"]'));
|
||||
}
|
||||
|
||||
public function testCreatingACode(): void
|
||||
{
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$response = $this->submit('/admin/access-codes/new', 'access_code_form', [
|
||||
'label' => 'Aufzug',
|
||||
'description' => 'Kabine im Erdgeschoss',
|
||||
'code' => '4711',
|
||||
'validFrom' => '2026-10-01',
|
||||
'visibility' => 'everyone',
|
||||
]);
|
||||
|
||||
self::assertSame('/admin/access-codes', $response->headers->get('HX-Redirect'));
|
||||
$created = $this->em->getRepository(AccessCode::class)->findOneBy(['label' => 'Aufzug']);
|
||||
self::assertNotNull($created);
|
||||
self::assertSame('4711', $created->getCode());
|
||||
self::assertSame('Kabine im Erdgeschoss', $created->getDescription());
|
||||
self::assertSame('2026-10-01', $created->getValidFrom()->format('Y-m-d'));
|
||||
self::assertSame(CodeVisibility::EVERYONE, $created->getVisibility());
|
||||
}
|
||||
|
||||
/**
|
||||
* The date is prefilled with today, because a code being entered usually already works.
|
||||
*/
|
||||
public function testTheNewFormStartsToday(): void
|
||||
{
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin/access-codes/new', server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
self::assertSame(date('Y-m-d'), $crawler->filter('#access_code_form_validFrom')->attr('value'));
|
||||
}
|
||||
|
||||
public function testAMissingLabelIsRejected(): void
|
||||
{
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$response = $this->submit('/admin/access-codes/new', 'access_code_form', [
|
||||
'label' => '',
|
||||
'code' => '4711',
|
||||
'validFrom' => '2026-10-01',
|
||||
'visibility' => 'everyone',
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertCount(0, $this->em->getRepository(AccessCode::class)->findAll());
|
||||
}
|
||||
|
||||
public function testEditingACodeIncludingItsVisibility(): void
|
||||
{
|
||||
$code = $this->code('Serverraum', '4711');
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$response = $this->submit(\sprintf('/admin/access-codes/%d/edit', $code->getId()), 'access_code_form', [
|
||||
'label' => 'Serverraum',
|
||||
'description' => '',
|
||||
'code' => '9999',
|
||||
'validFrom' => $code->getValidFrom()->format('Y-m-d'),
|
||||
'visibility' => 'admin',
|
||||
]);
|
||||
|
||||
self::assertSame('/admin/access-codes', $response->headers->get('HX-Redirect'));
|
||||
$code = $this->reload($code);
|
||||
self::assertSame('9999', $code->getCode());
|
||||
self::assertSame(CodeVisibility::ADMIN, $code->getVisibility());
|
||||
self::assertNull($code->getDescription());
|
||||
}
|
||||
|
||||
/**
|
||||
* Two codes for the same door is the ordinary way to rotate one, so nothing may refuse it.
|
||||
*/
|
||||
public function testASecondCodeForTheSameDoorIsAccepted(): void
|
||||
{
|
||||
$this->code('Aufzug', '4711');
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$this->submit('/admin/access-codes/new', 'access_code_form', [
|
||||
'label' => 'Aufzug',
|
||||
'code' => '9999',
|
||||
'validFrom' => '2026-12-01',
|
||||
'visibility' => 'everyone',
|
||||
]);
|
||||
|
||||
self::assertCount(2, $this->em->getRepository(AccessCode::class)->findBy(['label' => 'Aufzug']));
|
||||
}
|
||||
|
||||
public function testDeletingACode(): void
|
||||
{
|
||||
$code = $this->code('Aufzug', '4711');
|
||||
$id = $code->getId();
|
||||
$this->client->loginUser($this->admin());
|
||||
$uri = \sprintf('/admin/access-codes/%d/delete', $id);
|
||||
|
||||
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
self::assertSame('/admin/access-codes', $this->client->getResponse()->headers->get('HX-Redirect'));
|
||||
$this->em->clear();
|
||||
self::assertNull($this->em->find(AccessCode::class, $id));
|
||||
}
|
||||
|
||||
public function testDeletingACodeNeedsAValidToken(): void
|
||||
{
|
||||
$code = $this->code('Aufzug', '4711');
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$this->client->request('POST', \sprintf('/admin/access-codes/%d/delete', $code->getId()), ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||
$this->em->clear();
|
||||
self::assertNotNull($this->em->find(AccessCode::class, $code->getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the modal, then posts the given fields with its CSRF token -- the round trip a
|
||||
* browser makes.
|
||||
*
|
||||
* @param array<string, string> $fields
|
||||
*/
|
||||
private function submit(string $uri, string $formName, array $fields): Response
|
||||
{
|
||||
$crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$fields['_token'] = $crawler->filter(\sprintf('#%s__token', $formName))->attr('value');
|
||||
$this->client->request('POST', $uri, [$formName => $fields], server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
return $this->client->getResponse();
|
||||
}
|
||||
|
||||
private function confirmationToken(string $uri): string
|
||||
{
|
||||
$crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$vals = $crawler->filter('#htmx-confirmation-modal [hx-post]')->attr('hx-vals');
|
||||
|
||||
return json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Each request resets the entity manager, so what a test holds is detached afterwards.
|
||||
*/
|
||||
private function reload(AccessCode $code): AccessCode
|
||||
{
|
||||
$fresh = $this->em->find(AccessCode::class, $code->getId());
|
||||
self::assertNotNull($fresh);
|
||||
|
||||
return $fresh;
|
||||
}
|
||||
|
||||
private function code(string $label, string $value, string $validFrom = 'today'): AccessCode
|
||||
{
|
||||
$code = new AccessCode($label, $value, new \DateTimeImmutable($validFrom));
|
||||
$this->em->persist($code);
|
||||
$this->em->flush();
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
private function admin(): User
|
||||
{
|
||||
return $this->user('[email protected]', ['ROLE_ADMIN']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
private function user(string $email, array $roles = ['ROLE_EMPLOYEE']): User
|
||||
{
|
||||
$existing = $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
|
||||
if (null !== $existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$user = new User($email, 'Test');
|
||||
$user->setRoles($roles);
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Entity\AccessCode;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\BookingSeries;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceGroup;
|
||||
use App\Entity\User;
|
||||
use App\Enum\CodeVisibility;
|
||||
use App\Enum\RecurrenceRule;
|
||||
use App\Enum\ResourceType;
|
||||
use App\Model\SeriesResult;
|
||||
@@ -152,6 +154,32 @@ class AuditLoggerTest extends TestCase
|
||||
self::assertSame('[email protected]', $records[1]->context['account']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one thing the trail must never carry. It is asserted rather than left to review,
|
||||
* because a helpful "log what changed" added later would leak every door code in the
|
||||
* building into a file that outlives the codes themselves.
|
||||
*/
|
||||
public function testAnAccessCodeIsRecordedWithoutItsValue(): void
|
||||
{
|
||||
$code = new AccessCode('Aufzug', '4711', new \DateTimeImmutable('2026-10-01'));
|
||||
|
||||
$this->audit->accessCodeCreated($code);
|
||||
$this->audit->accessCodeUpdated($code, true, CodeVisibility::ADMIN);
|
||||
$this->audit->accessCodeDeleted(7, 'Aufzug', CodeVisibility::EVERYONE);
|
||||
|
||||
foreach ($this->handler->getRecords() as $record) {
|
||||
self::assertStringNotContainsString('4711', json_encode($record->context, \JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
$records = $this->handler->getRecords();
|
||||
self::assertSame('Aufzug', $records[0]->context['access_code']);
|
||||
self::assertSame('2026-10-01', $records[0]->context['valid_from']);
|
||||
self::assertSame('everyone', $records[0]->context['visibility']);
|
||||
self::assertTrue($records[1]->context['code_changed']);
|
||||
self::assertSame('admin', $records[1]->context['previous_visibility']);
|
||||
self::assertSame(7, $records[2]->context['access_code_id']);
|
||||
}
|
||||
|
||||
private function record(): LogRecord
|
||||
{
|
||||
return $this->handler->getRecords()[0];
|
||||
|
||||
Reference in new issue
Block a user