feat: door access codes display and admin crud
This commit is contained in:
1 parent
91419294b8
commit
afa3f17d77
20 files changed
+1428
No files matched your search
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\AccessCode;
|
||||
|
||||
use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Entity\AccessCode;
|
||||
use App\Repository\AccessCodeRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* The quick look: what do I type at this door, right now.
|
||||
*
|
||||
* Read-only and deliberately thin -- one card per door with the code that works today, large
|
||||
* enough to read on a phone held at arm's length in a badly lit lobby. Maintaining the codes
|
||||
* happens in the table under /admin/access-codes, which is where the history, the dated-ahead
|
||||
* codes and the buttons live.
|
||||
*
|
||||
* Nothing here is dated: a code that starts next week is not shown until it starts, and one that
|
||||
* has been replaced is gone the day its successor takes over. Both would only be one more number
|
||||
* on the screen for somebody to mistype.
|
||||
*/
|
||||
final class IndexController extends AbstractController
|
||||
{
|
||||
use CurrentUserTrait;
|
||||
|
||||
public function __construct(private readonly AccessCodeRepository $codes)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/access-codes', name: 'app_access_code', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$today = new \DateTimeImmutable('today');
|
||||
|
||||
return $this->render('access_code/index.html.twig', [
|
||||
'codes' => $this->current($this->codes->findCurrentReadableBy($this->currentUser()->getRoles(), $today)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One code per door: the newest of those already in force, which the repository puts first.
|
||||
*
|
||||
* A door only has more than one here because its code was rotated, and then the older rows
|
||||
* are history -- readable in the admin table, not on the screen somebody is standing at a
|
||||
* door with.
|
||||
*
|
||||
* @param list<AccessCode> $codes ordered by label, newest first within a label
|
||||
*
|
||||
* @return list<AccessCode>
|
||||
*/
|
||||
private function current(array $codes): array
|
||||
{
|
||||
$current = [];
|
||||
|
||||
foreach ($codes as $code) {
|
||||
$current[$code->getLabel()] ??= $code;
|
||||
}
|
||||
|
||||
return array_values($current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccessCode;
|
||||
|
||||
use App\Entity\AccessCode;
|
||||
use App\Enum\CodeVisibility;
|
||||
use App\Form\Admin\AccessCodeFormType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Security\Role;
|
||||
use App\Service\AuditLogger;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted(Role::ADMIN)]
|
||||
final class CreateController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly AuditLogger $audit,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/access-codes/new', name: 'app_admin_access_code_new', methods: ['GET', 'POST'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// Prefilled with today: a code being entered is usually one that already works. Dating
|
||||
// it forward is the deliberate act, and that is the one worth typing.
|
||||
$code = new AccessCode('', '', new \DateTimeImmutable('today'), null, CodeVisibility::EVERYONE);
|
||||
$form = $this->createForm(AccessCodeFormType::class, $code, [
|
||||
'action' => $this->generateUrl('app_admin_access_code_new'),
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->em->persist($code);
|
||||
$this->em->flush();
|
||||
|
||||
$this->audit->accessCodeCreated($code);
|
||||
$this->addFlash('success', \sprintf('%s angelegt.', $code->getLabel()));
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_access_code'));
|
||||
}
|
||||
|
||||
return $this->render('admin/_modal_access_code.html.twig', [
|
||||
'form' => $form,
|
||||
'code' => $code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccessCode;
|
||||
|
||||
use App\Controller\Traits\ConfirmedActionTrait;
|
||||
use App\Entity\AccessCode;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Security\Role;
|
||||
use App\Service\AuditLogger;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Nothing refers to an access code, so there is nothing to refuse the deletion over -- only the
|
||||
* confirmation, which is there because a deleted code is not recoverable from the screen that
|
||||
* deleted it.
|
||||
*/
|
||||
#[IsGranted(Role::ADMIN)]
|
||||
final class DeleteController extends AbstractController
|
||||
{
|
||||
use ConfirmedActionTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly AuditLogger $audit,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/access-codes/{id}/delete', name: 'app_admin_access_code_delete', methods: ['GET', 'POST'])]
|
||||
public function index(Request $request, AccessCode $code): Response
|
||||
{
|
||||
if (!$request->isMethod(Request::METHOD_POST)) {
|
||||
return $this->render('admin/_modal_access_code_delete.html.twig', [
|
||||
'code' => $code,
|
||||
'csrf_token_id' => 'delete-access-code-'.$code->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->denyUnlessCsrfValid($request, 'delete-access-code-'.$code->getId());
|
||||
|
||||
$this->audit->accessCodeDeleted((int) $code->getId(), $code->getLabel(), $code->getVisibility());
|
||||
|
||||
$label = $code->getLabel();
|
||||
$this->em->remove($code);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash('success', \sprintf('%s gelöscht.', $label));
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_access_code'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccessCode;
|
||||
|
||||
use App\Entity\AccessCode;
|
||||
use App\Form\Admin\AccessCodeFormType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Security\Role;
|
||||
use App\Service\AuditLogger;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted(Role::ADMIN)]
|
||||
final class EditController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly AuditLogger $audit,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/access-codes/{id}/edit', name: 'app_admin_access_code_edit', methods: ['GET', 'POST'])]
|
||||
public function index(Request $request, AccessCode $code): Response
|
||||
{
|
||||
// Read before the form binds, so the audit record can say that the code itself changed
|
||||
// without saying what it was or what it became.
|
||||
$previousCode = $code->getCode();
|
||||
$previousVisibility = $code->getVisibility();
|
||||
|
||||
$form = $this->createForm(AccessCodeFormType::class, $code, [
|
||||
'action' => $this->generateUrl('app_admin_access_code_edit', ['id' => $code->getId()]),
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->em->flush();
|
||||
|
||||
$this->audit->accessCodeUpdated($code, $previousCode !== $code->getCode(), $previousVisibility);
|
||||
$this->addFlash('success', \sprintf('%s gespeichert.', $code->getLabel()));
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_admin_access_code'));
|
||||
}
|
||||
|
||||
return $this->render('admin/_modal_access_code.html.twig', [
|
||||
'form' => $form,
|
||||
'code' => $code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\AccessCode;
|
||||
|
||||
use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Entity\AccessCode;
|
||||
use App\Repository\AccessCodeRepository;
|
||||
use App\Security\Role;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Maintaining the codes: every row there is, including the ones the quick look at /access-codes
|
||||
* leaves out -- dated ahead, or replaced by a newer one.
|
||||
*
|
||||
* Which is which is a question about today rather than a stored flag, and it is answered here
|
||||
* because it is a question about the whole list: a template cannot look back over its own rows.
|
||||
*/
|
||||
#[IsGranted(Role::ADMIN)]
|
||||
final class IndexController extends AbstractController
|
||||
{
|
||||
use CurrentUserTrait;
|
||||
|
||||
public function __construct(private readonly AccessCodeRepository $codes)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/access-codes', name: 'app_admin_access_code', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$today = new \DateTimeImmutable('today');
|
||||
|
||||
return $this->render('admin/access_codes.html.twig', [
|
||||
'rows' => $this->rows($this->codes->findReadableBy($this->currentUser()->getRoles()), $today),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per code, each told what it is in relation to the others for the same door.
|
||||
*
|
||||
* The repository hands them over grouped by label, newest first, so the current code of a
|
||||
* door is the first one in its group that has come into force: everything above it is still
|
||||
* to come, everything below it has been replaced. A door whose code has never been rotated
|
||||
* has a group of one, which is the ordinary case and needs no special handling.
|
||||
*
|
||||
* @param list<AccessCode> $codes
|
||||
*
|
||||
* @return list<array{code: AccessCode, upcoming: bool, superseded: bool}>
|
||||
*/
|
||||
private function rows(array $codes, \DateTimeImmutable $today): array
|
||||
{
|
||||
$rows = [];
|
||||
$currentSeen = [];
|
||||
|
||||
foreach ($codes as $code) {
|
||||
$upcoming = $code->isUpcoming($today);
|
||||
$label = $code->getLabel();
|
||||
$isCurrent = !$upcoming && !isset($currentSeen[$label]);
|
||||
|
||||
if ($isCurrent) {
|
||||
$currentSeen[$label] = true;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'code' => $code,
|
||||
'upcoming' => $upcoming,
|
||||
'superseded' => !$upcoming && !$isCurrent,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\CodeVisibility;
|
||||
use App\Repository\AccessCodeRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* A code that opens something in the building: a door, the lift, the barrier.
|
||||
*
|
||||
* Stored in plain text, and that is the feature -- a code nobody can read opens nothing. What
|
||||
* protects it is the firewall in front of the page and `visibility`, not the storage.
|
||||
*
|
||||
* Who a code is for is `visibility` and nothing else: it is the access decision and the caption
|
||||
* at once. Anything about the audience that the two cases do not cover -- "nur donnerstags",
|
||||
* "Reinigungsfirma" -- belongs in the description, which is prose and claims nothing.
|
||||
*
|
||||
* Nothing here is unique. The same door legitimately appears more than once, because rotating a
|
||||
* code means adding the new one with a later `validFrom` while the old row is still the one that
|
||||
* works; which of them is current is a question about today's date, answered when the list is
|
||||
* built rather than by a flag somebody has to remember to clear.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: AccessCodeRepository::class)]
|
||||
#[ORM\Index(name: 'idx_access_code_valid_from', columns: ['valid_from'])]
|
||||
class AccessCode
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 50)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(max: 50)]
|
||||
private string $label;
|
||||
|
||||
#[ORM\Column(length: 32)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(max: 32)]
|
||||
private string $code;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable')]
|
||||
#[Assert\NotNull]
|
||||
private \DateTimeImmutable $validFrom;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(length: 20, enumType: CodeVisibility::class)]
|
||||
private CodeVisibility $visibility = CodeVisibility::EVERYONE;
|
||||
|
||||
public function __construct(
|
||||
string $label,
|
||||
string $code,
|
||||
\DateTimeImmutable $validFrom,
|
||||
?string $description = null,
|
||||
CodeVisibility $visibility = CodeVisibility::EVERYONE,
|
||||
) {
|
||||
$this->label = $label;
|
||||
$this->code = $code;
|
||||
$this->validFrom = $validFrom;
|
||||
$this->description = $description;
|
||||
$this->visibility = $visibility;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): void
|
||||
{
|
||||
$this->label = $label;
|
||||
}
|
||||
|
||||
public function getCode(): string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function setCode(string $code): void
|
||||
{
|
||||
$this->code = $code;
|
||||
}
|
||||
|
||||
public function getValidFrom(): \DateTimeImmutable
|
||||
{
|
||||
return $this->validFrom;
|
||||
}
|
||||
|
||||
public function setValidFrom(\DateTimeImmutable $validFrom): void
|
||||
{
|
||||
$this->validFrom = $validFrom;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): void
|
||||
{
|
||||
$this->description = $description;
|
||||
}
|
||||
|
||||
public function getVisibility(): CodeVisibility
|
||||
{
|
||||
return $this->visibility;
|
||||
}
|
||||
|
||||
public function setVisibility(CodeVisibility $visibility): void
|
||||
{
|
||||
$this->visibility = $visibility;
|
||||
}
|
||||
|
||||
/** Not yet in force: it is listed so nobody is surprised on the day it takes over. */
|
||||
public function isUpcoming(\DateTimeImmutable $on): bool
|
||||
{
|
||||
return $this->validFrom > $on;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
use App\Security\Role;
|
||||
|
||||
/**
|
||||
* Who may read an access code.
|
||||
*
|
||||
* Deliberately an enum with two cases rather than a boolean column: "admin only" is one policy
|
||||
* among several plausible ones -- a code for the reception desk or for the cleaning company is
|
||||
* the obvious next case -- and a third case here costs a case in the match below, where a third
|
||||
* value of a boolean costs a migration.
|
||||
*
|
||||
* This is also what the list shows under "Für wen": the access decision and the caption are one
|
||||
* value, so there is no second, prose field describing an audience that the filter does not
|
||||
* actually enforce.
|
||||
*/
|
||||
enum CodeVisibility: string
|
||||
{
|
||||
/** Every signed-in user, which in this application means everyone who works here. */
|
||||
case EVERYONE = 'everyone';
|
||||
|
||||
/** Administrators only -- a code the whole staff has no business reading. */
|
||||
case ADMIN = 'admin';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::EVERYONE => 'Alle Mitarbeitenden',
|
||||
self::ADMIN => 'Administration',
|
||||
};
|
||||
}
|
||||
|
||||
/** Worth marking in the list: not everyone who can see the page can see this row. */
|
||||
public function isRestricted(): bool
|
||||
{
|
||||
return self::EVERYONE !== $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* The roles this application knows are the ones in Role::ELIGIBLE, so "everyone" needs no
|
||||
* role of its own: reaching the page at all already required one.
|
||||
*
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
public function isReadableBy(array $roles): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::EVERYONE => true,
|
||||
self::ADMIN => \in_array(Role::ADMIN, $roles, true),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The cases somebody with these roles may read, for the repository to filter on.
|
||||
*
|
||||
* @param list<string> $roles
|
||||
*
|
||||
* @return list<self>
|
||||
*/
|
||||
public static function readableBy(array $roles): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
self::cases(),
|
||||
static fn (self $visibility): bool => $visibility->isReadableBy($roles),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Admin;
|
||||
|
||||
use App\Entity\AccessCode;
|
||||
use App\Enum\CodeVisibility;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* An access code. Every field is editable, including the code itself -- correcting a typo in a
|
||||
* code is an edit, while giving a door a new code is a new row with a later start date.
|
||||
*
|
||||
* The required text fields carry `empty_data => ''` for the reason ResourceGroupFormType's
|
||||
* position does: their setters take a string, and a cleared field otherwise reaches them as null
|
||||
* and fails with a TypeError before the NotBlank constraint ever gets to say so.
|
||||
*/
|
||||
final class AccessCodeFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('label', TextType::class, [
|
||||
'empty_data' => '',
|
||||
'label' => 'Bezeichnung',
|
||||
])
|
||||
->add('description', TextareaType::class, [
|
||||
'label' => 'Beschreibung',
|
||||
'required' => false,
|
||||
'attr' => ['rows' => 2],
|
||||
])
|
||||
->add('code', TextType::class, [
|
||||
'empty_data' => '',
|
||||
'label' => 'Code',
|
||||
])
|
||||
->add('validFrom', DateType::class, [
|
||||
'label' => 'Gültig ab',
|
||||
'widget' => 'single_text',
|
||||
])
|
||||
->add('visibility', EnumType::class, [
|
||||
'label' => 'Für wen',
|
||||
'class' => CodeVisibility::class,
|
||||
'choice_label' => static fn (CodeVisibility $visibility): string => $visibility->label(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => AccessCode::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\AccessCode;
|
||||
use App\Enum\CodeVisibility;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<AccessCode>
|
||||
*/
|
||||
class AccessCodeRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, AccessCode::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The code that opens each door today, for the quick-look list.
|
||||
*
|
||||
* Dated-ahead codes are left in the database and out of the query: a code that does not open
|
||||
* anything yet is not an answer to "what do I type", and showing it beside one that does is
|
||||
* how somebody ends up typing the wrong one. Ordered so that the first row per label is the
|
||||
* one in force -- the caller only has to take it.
|
||||
*
|
||||
* @param list<string> $roles
|
||||
*
|
||||
* @return list<AccessCode>
|
||||
*/
|
||||
public function findCurrentReadableBy(array $roles, \DateTimeImmutable $on): array
|
||||
{
|
||||
return $this->createQueryBuilder('c')
|
||||
->andWhere('c.visibility IN (:visibilities)')
|
||||
->andWhere('c.validFrom <= :on')
|
||||
->setParameter('visibilities', CodeVisibility::readableBy($roles))
|
||||
->setParameter('on', $on)
|
||||
->orderBy('c.label', 'ASC')
|
||||
->addOrderBy('c.validFrom', 'DESC')
|
||||
->addOrderBy('c.id', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the holder of these roles may read, grouped by door and newest code first.
|
||||
*
|
||||
* The restriction is applied here rather than in the template, so a code somebody may not
|
||||
* read is never loaded into a response in the first place -- a hidden table row is still a
|
||||
* published code.
|
||||
*
|
||||
* @param list<string> $roles
|
||||
*
|
||||
* @return list<AccessCode>
|
||||
*/
|
||||
public function findReadableBy(array $roles): array
|
||||
{
|
||||
return $this->createQueryBuilder('c')
|
||||
->andWhere('c.visibility IN (:visibilities)')
|
||||
->setParameter('visibilities', CodeVisibility::readableBy($roles))
|
||||
->orderBy('c.label', 'ASC')
|
||||
->addOrderBy('c.validFrom', 'DESC')
|
||||
->addOrderBy('c.id', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\AccessCode;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\BookingSeries;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceGroup;
|
||||
use App\Entity\User;
|
||||
use App\Enum\CodeVisibility;
|
||||
use App\Enum\ResourceType;
|
||||
use App\Model\SeriesResult;
|
||||
use App\Model\TimeRange;
|
||||
@@ -235,6 +237,49 @@ final readonly class AuditLogger
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The code value itself is never logged, here or anywhere else.
|
||||
*
|
||||
* The audit trail is a file that whoever is investigating an incident reads, and a record of
|
||||
* every door code ever set would turn it into a keyring -- one that outlives the codes, since
|
||||
* the log is kept long after a code has been rotated away. What a record needs to answer is
|
||||
* who changed which code and when, and that it can do while naming only the door.
|
||||
*/
|
||||
public function accessCodeCreated(AccessCode $code): void
|
||||
{
|
||||
$this->logger->info('Access code created.', $this->accessCode($code));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $codeChanged whether the secret itself was replaced, as opposed
|
||||
* to a correction to the label or the date -- not
|
||||
* derivable later, since the value is never logged
|
||||
* @param CodeVisibility $previousVisibility spelled out for the same reason as a resource's
|
||||
* `was_active`: widening a code from the
|
||||
* administrators to the whole staff is the one edit
|
||||
* here that changes who may read something
|
||||
*/
|
||||
public function accessCodeUpdated(AccessCode $code, bool $codeChanged, CodeVisibility $previousVisibility): void
|
||||
{
|
||||
$this->logger->info('Access code updated.', $this->accessCode($code) + [
|
||||
'code_changed' => $codeChanged,
|
||||
'previous_visibility' => $previousVisibility->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a snapshot rather than the entity, as the other deletions do: afterwards there is no
|
||||
* id left to read.
|
||||
*/
|
||||
public function accessCodeDeleted(int $id, string $label, CodeVisibility $visibility): void
|
||||
{
|
||||
$this->logger->info('Access code deleted.', [
|
||||
'access_code' => $label,
|
||||
'access_code_id' => $id,
|
||||
'visibility' => $visibility->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -275,6 +320,19 @@ final readonly class AuditLogger
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function accessCode(AccessCode $code): array
|
||||
{
|
||||
return [
|
||||
'access_code' => $code->getLabel(),
|
||||
'access_code_id' => $code->getId(),
|
||||
'valid_from' => $code->getValidFrom()->format('Y-m-d'),
|
||||
'visibility' => $code->getVisibility()->value,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user