feat(setup): "Claim this frame" checkbox for previously-bound MACs
CI / test (push) Has been cancelled

Use case: old owner sells the device to a friend. Friend holds the BOOT
button to wipe NVS, joins the device's AP, sets new WiFi. The old
owner's account is still bound to the MAC server-side, so without
explicit consent the friend would silently take over (or, worse, the
old owner's photos would keep displaying until claim).

Flow now:
  - GET /setup/{mac} detects MAC bound to anyone and renders a
    "Claim this frame as my own" checkbox + a banner explaining what
    the takeover wipes. Both register and login panels carry the
    checkbox; submitting either form without it bounces back through
    the index with a session-flashed error.
  - DeviceService::linkToUser now requires allowClaim=true to
    transfer ownership. Without it, throws DeviceClaimRequiredException
    that the controller catches and turns into the bounce-with-error.
  - On a successful claim, the takeover wipes:
      * old image-device approvals
      * device_image_history rows for the device
      * name, wakeTimes, currentImage*, lockedImage, nextPollExpectedAt
    so the new owner starts from a fresh slate, not inheriting the
    seller's "Living Room / 4:30 AM" preset.
  - Already-logged-in user visiting /setup/{mac} for someone else's
    device falls through to the form (instead of silently transferring
    on page load) so the checkbox is the only path.

Test matrix:
  - SetupControllerTest: 5 new functional cases — checkbox renders for
    bound MACs, register/login without checkbox bounce + retain old
    ownership, register WITH checkbox transfers + purges, logged-in
    other-user falls through to form.
  - DeviceServiceTest: 3 new unit cases — throw without consent,
    isClaimedByAnotherUser true/false matrix, takeover resets device
    state.

Coverage: 99.70% lines / 98.19% methods backend, 333 frontend tests
green via ddev tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 14:45:52 -04:00
parent a9ad014bd1
commit ece0defe3f
6 changed files with 353 additions and 19 deletions
+56 -13
View File
@@ -8,6 +8,7 @@ use App\Entity\Device;
use App\Entity\User;
use App\Enum\Orientation;
use App\Form\RegistrationFormType;
use App\Service\DeviceClaimRequiredException;
use App\Service\DeviceService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -30,15 +31,19 @@ class SetupController extends AbstractController
Security $security,
DeviceService $deviceService,
): Response {
// If already authenticated, link device and proceed to configure
// If already authenticated, try to link silently. If the MAC is owned
// by someone else, fall through to the form so the user has to tick
// the "Claim this frame" checkbox before transferring ownership.
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]);
if (!$deviceService->isClaimedByAnotherUser($mac, $user)) {
$device = $deviceService->linkToUser($mac, $user);
if (empty($device->getName())) {
return $this->redirectToRoute('setup_configure', ['mac' => $mac]);
}
return $this->redirectToRoute('spa');
}
return $this->redirectToRoute('spa');
}
$regForm = $this->createForm(RegistrationFormType::class, new User(), [
@@ -46,11 +51,22 @@ class SetupController extends AbstractController
]);
$loginError = $request->getSession()->get('_setup_login_error');
$request->getSession()->remove('_setup_login_error');
$claimError = $request->getSession()->get('_setup_claim_error');
$request->getSession()->remove('_setup_claim_error');
// The setup page only knows about the MAC at this point — the
// candidate user is whoever's logging in / registering — so we can't
// call isClaimedByAnotherUser() yet. We just check "is the MAC bound
// to anyone at all?" which is enough to surface the checkbox.
$existing = $em->getRepository(Device::class)->findOneBy(['mac' => strtoupper($mac)]);
$alreadyClaimed = $existing !== null && $existing->getUser() !== null;
return $this->render('setup/index.html.twig', [
'mac' => $mac,
'reg_form' => $regForm,
'login_error' => $loginError,
'mac' => $mac,
'reg_form' => $regForm,
'login_error' => $loginError,
'already_claimed' => $alreadyClaimed,
'claim_error' => $claimError,
]);
}
@@ -75,15 +91,33 @@ class SetupController extends AbstractController
$em->flush();
$security->login($user, 'form_login', 'main');
$deviceService->linkToUser($mac, $user);
$allowClaim = $request->request->getBoolean('claim_device');
try {
$deviceService->linkToUser($mac, $user, $allowClaim);
} catch (DeviceClaimRequiredException) {
// New account just created and claim wasn't acknowledged.
// Bounce back through the setup page; the checkbox will be
// visible because the device is now bound (to no one — wait,
// actually still bound to the prior owner since we threw
// before persisting).
$request->getSession()->set(
'_setup_claim_error',
'This frame is already linked to another account. Tick "Claim this frame" to take it over and erase the previous owner\'s photos.',
);
return $this->redirectToRoute('setup_index', ['mac' => $mac]);
}
return $this->redirectToRoute('setup_configure', ['mac' => $mac]);
}
$existing = $em->getRepository(Device::class)->findOneBy(['mac' => strtoupper($mac)]);
return $this->render('setup/index.html.twig', [
'mac' => $mac,
'reg_form' => $form,
'login_error' => null,
'mac' => $mac,
'reg_form' => $form,
'login_error' => null,
'already_claimed' => $existing !== null && $existing->getUser() !== null,
'claim_error' => null,
]);
}
@@ -102,7 +136,16 @@ class SetupController extends AbstractController
$user = $em->getRepository(User::class)->findOneBy(['email' => $email]);
if ($user && $hasher->isPasswordValid($user, $password)) {
$security->login($user, 'form_login', 'main');
$deviceService->linkToUser($mac, $user);
$allowClaim = $request->request->getBoolean('claim_device');
try {
$deviceService->linkToUser($mac, $user, $allowClaim);
} catch (DeviceClaimRequiredException) {
$request->getSession()->set(
'_setup_claim_error',
'This frame is already linked to another account. Tick "Claim this frame" to take it over and erase the previous owner\'s photos.',
);
return $this->redirectToRoute('setup_index', ['mac' => $mac]);
}
return $this->redirectToRoute('setup_configure', ['mac' => $mac]);
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Thrown by DeviceService::linkToUser when the MAC is already bound to a
* different user and the caller hasn't passed allowClaim=true. The setup
* controller catches this and re-renders with the "Claim this frame"
* checkbox visible — a friend who reset the device for a new owner has to
* tick the box to acknowledge the prior owner's data will be wiped.
*/
final class DeviceClaimRequiredException extends \RuntimeException
{
public function __construct()
{
parent::__construct('Device is claimed by a different user; consent required.');
}
}
+37 -4
View File
@@ -19,10 +19,32 @@ class DeviceService
) {}
/**
* Atomically link a MAC address to a user.
* If the device was previously owned by a different user, image history is purged.
* True when the MAC is already claimed by a *different* user. Used by the
* setup flow to gate the "Claim this frame" checkbox so a friend can't
* silently take ownership without confirmation.
*/
public function linkToUser(string $mac, User $newOwner): Device
public function isClaimedByAnotherUser(string $mac, User $candidate): bool
{
$device = $this->repo->findOneBy(['mac' => strtoupper($mac)]);
return $device !== null
&& $device->getUser() !== null
&& $device->getUser()->getId() !== $candidate->getId();
}
/**
* Atomically link a MAC address to a user.
*
* If the device is already owned by a *different* user, this is an
* ownership transfer — and we require explicit consent via $allowClaim,
* since the transfer permanently purges the prior owner's history and
* image-device approvals for this device. The setup page surfaces this
* as a "Claim this frame" checkbox.
*
* @throws DeviceClaimRequiredException when transfer is needed but
* $allowClaim is false. Callers should re-render the setup
* page with the checkbox visible and a message.
*/
public function linkToUser(string $mac, User $newOwner, bool $allowClaim = false): Device
{
$mac = strtoupper($mac);
$device = $this->repo->findOneBy(['mac' => $mac]);
@@ -31,9 +53,20 @@ class DeviceService
$device = new Device();
$device->setMac($mac);
} elseif ($device->getUser() !== null && $device->getUser()->getId() !== $newOwner->getId()) {
if (!$allowClaim) {
throw new DeviceClaimRequiredException();
}
// Ownership transfer: purge prior image history for this device.
// Full purge logic added in Epic 3 when Image/Approval entities exist.
$this->purgeDeviceHistory($device);
// Reset device-specific state so the new owner doesn't inherit
// schedule, locked image, current image, or pending poll info.
$device->setLockedImage(null);
$device->setCurrentImage(null);
$device->setCurrentImageOrientation(null);
$device->setCurrentRenderedAt(null);
$device->setNextPollExpectedAt(null);
$device->setName('');
$device->setWakeTimes([]);
}
$device->setUser($newOwner);