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
+32
View File
@@ -26,6 +26,14 @@
.btn { display: flex; align-items: center; justify-content: center; width: 100%; min-height: 44px;
margin-top: 1.25rem; background: #c97c3a; color: #fff; border: none; border-radius: 9999px;
font-size: 1rem; font-weight: 700; cursor: pointer; }
.claim-banner { background: #fff5e8; border: 1px solid #f0c987; border-radius: 10px;
padding: .75rem .875rem; margin-bottom: 1.25rem; font-size: .875rem; line-height: 1.4;
color: #5c3f1c; }
.claim-banner strong { display: block; margin-bottom: .25rem; }
.claim-check { display: flex; align-items: flex-start; gap: .625rem; margin-top: 1rem;
font-size: .875rem; line-height: 1.35; cursor: pointer; }
.claim-check input[type="checkbox"] { width: 18px; height: 18px; flex: 0 0 auto;
margin-top: 2px; accent-color: #c97c3a; cursor: pointer; }
</style>
</head>
<body>
@@ -33,6 +41,18 @@
<h1>Set up your frame</h1>
<p class="subtitle">Create an account or sign in to link this frame.</p>
{% if already_claimed %}
<p class="claim-banner" role="status">
<strong>This frame is already linked to another account.</strong>
If youre taking it over, tick the box below — the previous
owners photos and history for this frame will be permanently
removed.
</p>
{% if claim_error %}
<p class="field-error" role="alert" style="margin-bottom:1rem">{{ claim_error }}</p>
{% endif %}
{% endif %}
<div class="tabs">
<a href="#register" class="tab {% if not login_error %}active{% endif %}" data-tab="register">Create account</a>
<a href="#login" class="tab {% if login_error %}active{% endif %}" data-tab="login">Sign in</a>
@@ -73,6 +93,12 @@
<p class="field-error" role="alert">{{ error.message }}</p>
{% endfor %}
</div>
{% if already_claimed %}
<label class="claim-check">
<input type="checkbox" name="claim_device" value="1" required>
<span>Claim this frame as my own (deletes the previous owners photos and history)</span>
</label>
{% endif %}
<button type="submit" class="btn">Create account &amp; link frame</button>
{{ form_end(reg_form) }}
</div>
@@ -91,6 +117,12 @@
<label for="login-pass">Password</label>
<input type="password" id="login-pass" name="_password" autocomplete="current-password">
</div>
{% if already_claimed %}
<label class="claim-check">
<input type="checkbox" name="claim_device" value="1" required>
<span>Claim this frame as my own (deletes the previous owners photos and history)</span>
</label>
{% endif %}
<button type="submit" class="btn">Sign in &amp; link frame</button>
</form>
</div>