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; namespace App\Service;
use App\Entity\AccessCode;
use App\Entity\Booking; use App\Entity\Booking;
use App\Entity\BookingSeries; use App\Entity\BookingSeries;
use App\Entity\Resource; use App\Entity\Resource;
use App\Entity\ResourceGroup; use App\Entity\ResourceGroup;
use App\Entity\User; use App\Entity\User;
use App\Enum\CodeVisibility;
use App\Enum\ResourceType; use App\Enum\ResourceType;
use App\Model\SeriesResult; use App\Model\SeriesResult;
use App\Model\TimeRange; 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> * @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> * @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> <p class="text-sm text-gray-500 pb-4">Alle Buchungen verwalten.</p>
</a> </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 The header is deliberately not navigation, so the dashboard is the only place an
admin screen can be reached from. 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; namespace App\Tests\Service;
use App\Entity\AccessCode;
use App\Entity\Booking; use App\Entity\Booking;
use App\Entity\BookingSeries; use App\Entity\BookingSeries;
use App\Entity\Resource; use App\Entity\Resource;
use App\Entity\ResourceGroup; use App\Entity\ResourceGroup;
use App\Entity\User; use App\Entity\User;
use App\Enum\CodeVisibility;
use App\Enum\RecurrenceRule; use App\Enum\RecurrenceRule;
use App\Enum\ResourceType; use App\Enum\ResourceType;
use App\Model\SeriesResult; use App\Model\SeriesResult;
@@ -152,6 +154,32 @@ class AuditLoggerTest extends TestCase
self::assertSame('[email protected]', $records[1]->context['account']); 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 private function record(): LogRecord
{ {
return $this->handler->getRecords()[0]; return $this->handler->getRecords()[0];