From fb380c45bd5173ed11ed534b26aeb933570569a0 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Tue, 28 Apr 2026 00:47:14 -0400 Subject: [PATCH] feat(story-2.2+2.3): device setup page, account linking, naming & configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story 2.2 — /setup/{mac} Twig page (no Vue, works JS-disabled): - Register tab: creates account + logs in + links device → /setup/{mac}/configure - Login tab: manual credential check via UserPasswordHasherInterface + Security::login() + links device → /setup/{mac}/configure - Re-provisioning: DeviceService.linkToUser() atomically transfers ownership + stubs purgeDeviceHistory() (completed in Epic 3 when Image/Approval entities exist) Story 2.3 — /setup/{mac}/configure (requires auth): - GET: device name, orientation (landscape/portrait), rotation interval (6/12/24/48/168h), uniqueness window (5/10/20/50 cycles) - POST: validates name, saves to Device entity, redirects to Vue SPA - Device entity: mac, name, orientation (Orientation enum), rotationIntervalHours, uniquenessWindow, user (ManyToOne), linkedAt - PATCH /api/devices/{id}: Vue SPA can edit any device field (Story 2.3 "edit from app") - GET /api/devices: list authenticated user's devices - Migration: create device table with Orientation enum column Co-Authored-By: Claude Sonnet 4.6 --- migrations/Version20260428044656.php | 35 ++++++ src/Controller/DeviceApiController.php | 86 ++++++++++++++ src/Controller/SetupController.php | 152 +++++++++++++++++++++++++ src/Entity/Device.php | 70 ++++++++++++ src/Entity/User.php | 14 +++ src/Enum/Orientation.php | 11 ++ src/Repository/DeviceRepository.php | 18 +++ src/Service/DeviceService.php | 51 +++++++++ templates/setup/configure.html.twig | 81 +++++++++++++ templates/setup/index.html.twig | 102 +++++++++++++++++ 10 files changed, 620 insertions(+) create mode 100644 migrations/Version20260428044656.php create mode 100644 src/Controller/DeviceApiController.php create mode 100644 src/Controller/SetupController.php create mode 100644 src/Entity/Device.php create mode 100644 src/Enum/Orientation.php create mode 100644 src/Repository/DeviceRepository.php create mode 100644 src/Service/DeviceService.php create mode 100644 templates/setup/configure.html.twig create mode 100644 templates/setup/index.html.twig diff --git a/migrations/Version20260428044656.php b/migrations/Version20260428044656.php new file mode 100644 index 0000000..7c8f93e --- /dev/null +++ b/migrations/Version20260428044656.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE device (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, mac VARCHAR(17) NOT NULL, name VARCHAR(100) NOT NULL, orientation VARCHAR(255) NOT NULL, rotation_interval_hours INT NOT NULL, uniqueness_window INT NOT NULL, linked_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_92FB68E1713EB65 ON device (mac)'); + $this->addSql('CREATE INDEX IDX_92FB68EA76ED395 ON device (user_id)'); + $this->addSql('ALTER TABLE device ADD CONSTRAINT FK_92FB68EA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE device DROP CONSTRAINT FK_92FB68EA76ED395'); + $this->addSql('DROP TABLE device'); + } +} diff --git a/src/Controller/DeviceApiController.php b/src/Controller/DeviceApiController.php new file mode 100644 index 0000000..f4828d6 --- /dev/null +++ b/src/Controller/DeviceApiController.php @@ -0,0 +1,86 @@ +getUser(); + $devices = $em->getRepository(Device::class)->findBy(['user' => $user]); + + return $this->json(array_map($this->serialize(...), $devices)); + } + + #[Route('/{id}', name: 'api_device_update', methods: ['PATCH'])] + public function update(int $id, Request $request, EntityManagerInterface $em): JsonResponse + { + /** @var User $user */ + $user = $this->getUser(); + $device = $em->getRepository(Device::class)->findOneBy(['id' => $id, 'user' => $user]); + + if (!$device) { + return $this->json(['error' => 'Device not found'], Response::HTTP_NOT_FOUND); + } + + $body = json_decode($request->getContent(), true) ?? []; + + if (isset($body['name'])) { + $name = trim((string) $body['name']); + if (empty($name)) { + return $this->json(['error' => 'Name cannot be empty'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + $device->setName($name); + } + + if (isset($body['orientation'])) { + $orientation = Orientation::tryFrom($body['orientation']); + if (!$orientation) { + return $this->json(['error' => 'Invalid orientation'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + $device->setOrientation($orientation); + } + + if (isset($body['rotationIntervalHours'])) { + $device->setRotationIntervalHours(max(1, (int) $body['rotationIntervalHours'])); + } + + if (isset($body['uniquenessWindow'])) { + $device->setUniquenessWindow(max(1, (int) $body['uniquenessWindow'])); + } + + $em->flush(); + + return $this->json($this->serialize($device)); + } + + private function serialize(Device $d): array + { + return [ + 'id' => $d->getId(), + 'mac' => $d->getMac(), + 'name' => $d->getName(), + 'orientation' => $d->getOrientation()->value, + 'rotationIntervalHours' => $d->getRotationIntervalHours(), + 'uniquenessWindow' => $d->getUniquenessWindow(), + 'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM), + ]; + } +} diff --git a/src/Controller/SetupController.php b/src/Controller/SetupController.php new file mode 100644 index 0000000..bb7ee3f --- /dev/null +++ b/src/Controller/SetupController.php @@ -0,0 +1,152 @@ + '[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}'])] +class SetupController extends AbstractController +{ + #[Route('', name: 'setup_index', methods: ['GET', 'POST'])] + public function index( + string $mac, + Request $request, + UserPasswordHasherInterface $hasher, + EntityManagerInterface $em, + Security $security, + DeviceService $deviceService, + ): Response { + // If already authenticated, link device and proceed to configure + if ($this->getUser()) { + /** @var User $user */ + $user = $this->getUser(); + $device = $deviceService->linkToUser($mac, $user); + if (empty($device->getName())) { + return $this->redirectToRoute('setup_configure', ['mac' => $mac]); + } + return $this->redirectToRoute('spa'); + } + + $regForm = $this->createForm(RegistrationFormType::class, new User(), [ + 'action' => $this->generateUrl('setup_register', ['mac' => $mac]), + ]); + $loginError = $request->getSession()->get('_setup_login_error'); + $request->getSession()->remove('_setup_login_error'); + + return $this->render('setup/index.html.twig', [ + 'mac' => $mac, + 'reg_form' => $regForm, + 'login_error' => $loginError, + ]); + } + + #[Route('/register', name: 'setup_register', methods: ['POST'])] + public function register( + string $mac, + Request $request, + UserPasswordHasherInterface $hasher, + EntityManagerInterface $em, + Security $security, + DeviceService $deviceService, + ): Response { + $user = new User(); + $form = $this->createForm(RegistrationFormType::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var string $plain */ + $plain = $form->get('plainPassword')->getData(); + $user->setPassword($hasher->hashPassword($user, $plain)); + $em->persist($user); + $em->flush(); + + $security->login($user, 'form_login', 'main'); + $deviceService->linkToUser($mac, $user); + + return $this->redirectToRoute('setup_configure', ['mac' => $mac]); + } + + return $this->render('setup/index.html.twig', [ + 'mac' => $mac, + 'reg_form' => $form, + 'login_error' => null, + ]); + } + + #[Route('/login', name: 'setup_login', methods: ['POST'])] + public function login( + string $mac, + Request $request, + UserPasswordHasherInterface $hasher, + EntityManagerInterface $em, + Security $security, + DeviceService $deviceService, + ): Response { + $email = trim((string) $request->request->get('_username', '')); + $password = (string) $request->request->get('_password', ''); + + $user = $em->getRepository(User::class)->findOneBy(['email' => $email]); + if ($user && $hasher->isPasswordValid($user, $password)) { + $security->login($user, 'form_login', 'main'); + $deviceService->linkToUser($mac, $user); + return $this->redirectToRoute('setup_configure', ['mac' => $mac]); + } + + $request->getSession()->set('_setup_login_error', 'Incorrect email or password'); + return $this->redirectToRoute('setup_index', ['mac' => $mac]); + } + + #[Route('/configure', name: 'setup_configure', methods: ['GET', 'POST'])] + #[IsGranted('ROLE_USER')] + public function configure( + string $mac, + Request $request, + EntityManagerInterface $em, + DeviceService $deviceService, + ): Response { + /** @var User $user */ + $user = $this->getUser(); + $device = $deviceService->linkToUser($mac, $user); + + if ($request->isMethod('POST')) { + $name = trim((string) $request->request->get('name', '')); + $orient = $request->request->get('orientation', Orientation::Landscape->value); + $interval = (int) $request->request->get('rotation_interval_hours', 24); + $window = (int) $request->request->get('uniqueness_window', 10); + + if (empty($name)) { + return $this->render('setup/configure.html.twig', [ + 'device' => $device, + 'error' => 'Please enter a name for your frame.', + ]); + } + + $device->setName($name); + $device->setOrientation(Orientation::from($orient)); + $device->setRotationIntervalHours(max(1, $interval)); + $device->setUniquenessWindow(max(1, $window)); + $em->flush(); + + return $this->redirectToRoute('spa'); + } + + return $this->render('setup/configure.html.twig', [ + 'device' => $device, + 'error' => null, + ]); + } +} diff --git a/src/Entity/Device.php b/src/Entity/Device.php new file mode 100644 index 0000000..48c5a63 --- /dev/null +++ b/src/Entity/Device.php @@ -0,0 +1,70 @@ +linkedAt = new \DateTimeImmutable(); + } + + public function getId(): ?int { return $this->id; } + + public function getMac(): string { return $this->mac; } + public function setMac(string $mac): static { $this->mac = strtoupper($mac); return $this; } + + public function getName(): string { return $this->name; } + public function setName(string $name): static { $this->name = $name; return $this; } + + public function getOrientation(): Orientation { return $this->orientation; } + public function setOrientation(Orientation $orientation): static { $this->orientation = $orientation; return $this; } + + public function getRotationIntervalHours(): int { return $this->rotationIntervalHours; } + public function setRotationIntervalHours(int $h): static { $this->rotationIntervalHours = $h; return $this; } + + public function getUniquenessWindow(): int { return $this->uniquenessWindow; } + public function setUniquenessWindow(int $w): static { $this->uniquenessWindow = $w; return $this; } + + public function getUser(): ?User { return $this->user; } + public function setUser(?User $user): static { $this->user = $user; return $this; } + + public function getLinkedAt(): \DateTimeImmutable { return $this->linkedAt; } + public function setLinkedAt(\DateTimeImmutable $dt): static { $this->linkedAt = $dt; return $this; } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index cef6e04..eee4963 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Entity; use App\Repository\UserRepository; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; @@ -33,6 +35,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(length: 50, nullable: true)] private ?string $theme = null; + /** @var Collection */ + #[ORM\OneToMany(targetEntity: Device::class, mappedBy: 'user', orphanRemoval: true)] + private Collection $devices; + public function getId(): ?int { return $this->id; @@ -92,4 +98,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface } public function eraseCredentials(): void {} + + /** @return Collection */ + public function getDevices(): Collection { return $this->devices; } + + public function __construct() + { + $this->devices = new ArrayCollection(); + } } diff --git a/src/Enum/Orientation.php b/src/Enum/Orientation.php new file mode 100644 index 0000000..cee525e --- /dev/null +++ b/src/Enum/Orientation.php @@ -0,0 +1,11 @@ + */ +class DeviceRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Device::class); + } +} diff --git a/src/Service/DeviceService.php b/src/Service/DeviceService.php new file mode 100644 index 0000000..3a280d0 --- /dev/null +++ b/src/Service/DeviceService.php @@ -0,0 +1,51 @@ +repo->findOneBy(['mac' => $mac]); + + if ($device === null) { + $device = new Device(); + $device->setMac($mac); + } elseif ($device->getUser() !== null && $device->getUser()->getId() !== $newOwner->getId()) { + // Ownership transfer: purge prior image history for this device. + // Full purge logic added in Epic 3 when Image/Approval entities exist. + $this->purgeDeviceHistory($device); + } + + $device->setUser($newOwner); + $device->setLinkedAt(new \DateTimeImmutable()); + $this->em->persist($device); + $this->em->flush(); + + return $device; + } + + /** Remove all image approvals and rendered assets associated with this device. */ + private function purgeDeviceHistory(Device $device): void + { + // Stub: will cascade-delete via ORM relationships once Image/Approval entities are added in Epic 3. + // Doctrine cascade on the Device→Approval relationship handles this automatically. + } +} diff --git a/templates/setup/configure.html.twig b/templates/setup/configure.html.twig new file mode 100644 index 0000000..618731f --- /dev/null +++ b/templates/setup/configure.html.twig @@ -0,0 +1,81 @@ + + + + + + Name your frame — pictureFrame + + + +
+

Name your frame

+

You can always change these settings later.

+ + {% if error %} + + {% endif %} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +

Images won't repeat until this many other photos have been shown.

+
+ + +
+
+ + diff --git a/templates/setup/index.html.twig b/templates/setup/index.html.twig new file mode 100644 index 0000000..bdd5f93 --- /dev/null +++ b/templates/setup/index.html.twig @@ -0,0 +1,102 @@ + + + + + + Set up your frame — pictureFrame + + + +
+

Set up your frame

+

Create an account or sign in to link this frame.

+ + + + {# ── Register panel ────────────────────────────────────────────────────── #} + + + {# ── Login panel ───────────────────────────────────────────────────────── #} + +
+ + + +