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

+1
View File
@@ -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

+28
View File
@@ -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;
}
}
+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>
*/
+59
View File
@@ -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 %}
+82
View File
@@ -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 %}
+9
View File
@@ -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;
}
}
+28
View File
@@ -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];