feat: door access codes display and admin crud

This commit is contained in:
fromme committed 2026-09-25 13:08:39 +02:00
1 parent 91419294b8
commit afa3f17d77
20 files changed
+1428

No files matched your search

@@ -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;
}
}
+131
View File
@@ -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;
}
}
+71
View File
@@ -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),
));
}
}
+60
View File
@@ -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,
]);
}
}
+70
View File
@@ -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();
}
}
+58
View File
@@ -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>
*/