feat(setup): "Claim this frame" checkbox for previously-bound MACs
CI / test (push) Has been cancelled
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:
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user