Files
pictureFrame-webApp/templates/setup/index.html.twig
T
football2801 ece0defe3f
CI / test (push) Has been cancelled
feat(setup): "Claim this frame" checkbox for previously-bound MACs
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>
2026-05-08 14:45:52 -04:00

147 lines
7.8 KiB
Twig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Set up your frame — pictureFrame</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; min-height: 100dvh; background: #fdf6ee; color: #3a2e22;
display: flex; align-items: flex-start; justify-content: center; padding: 2rem 1rem; }
.card { width: 100%; max-width: 400px; }
h1 { font-size: 1.4rem; font-weight: 700; margin-bottom: .25rem; }
.subtitle { font-size: .875rem; color: #8a7060; margin-bottom: 1.5rem; }
.tabs { display: flex; border-bottom: 1px solid #e8d9c4; margin-bottom: 1.5rem; }
.tab { flex: 1; padding: .75rem; text-align: center; font-weight: 700; font-size: .9rem;
color: #8a7060; text-decoration: none; border-bottom: 2px solid transparent; transition: color .15s; }
.tab.active { color: #c97c3a; border-bottom-color: #c97c3a; }
.panel { display: none; } .panel.active { display: block; }
.field { margin-bottom: 1rem; }
label { display: block; font-size: .8125rem; font-weight: 600; color: #8a7060; margin-bottom: .375rem; }
input[type="email"], input[type="password"], input[type="text"] {
width: 100%; min-height: 44px; padding: 0 .875rem; border: 1px solid #e8d9c4;
border-radius: 10px; background: #fff; font-size: 1rem; color: #3a2e22; }
input[aria-invalid="true"] { border-color: #c0392b; }
.field-error { margin-top: .25rem; font-size: .8125rem; color: #c0392b; }
.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>
<div class="card">
<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>
</div>
{# ── Register panel ────────────────────────────────────────────────────── #}
<div id="register" class="panel {% if not login_error %}active{% endif %}">
{{ form_start(reg_form, {action: path('setup_register', {mac: mac}), attr: {novalidate: 'novalidate'}}) }}
<div class="field">
{{ form_label(reg_form.email) }}
{{ form_widget(reg_form.email, {attr: {
id: 'reg-email',
'aria-invalid': reg_form.email.vars.errors|length > 0 ? 'true' : 'false'
}}) }}
{% for error in reg_form.email.vars.errors %}
<p class="field-error" role="alert">{{ error.message }}</p>
{% endfor %}
</div>
<div class="field">
{{ form_label(reg_form.plainPassword.first) }}
{{ form_widget(reg_form.plainPassword.first, {attr: {
id: 'reg-pass',
autocomplete: 'new-password',
'aria-invalid': reg_form.plainPassword.first.vars.errors|length > 0 ? 'true' : 'false'
}}) }}
{% for error in reg_form.plainPassword.first.vars.errors %}
<p class="field-error" role="alert">{{ error.message }}</p>
{% endfor %}
</div>
<div class="field">
{{ form_label(reg_form.plainPassword.second) }}
{{ form_widget(reg_form.plainPassword.second, {attr: {
id: 'reg-pass-confirm',
autocomplete: 'new-password',
'aria-invalid': reg_form.plainPassword.vars.errors|length > 0 ? 'true' : 'false'
}}) }}
{% for error in reg_form.plainPassword.vars.errors %}
<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>
{# ── Login panel ───────────────────────────────────────────────────────── #}
<div id="login" class="panel {% if login_error %}active{% endif %}">
<form method="post" action="{{ path('setup_login', {mac: mac}) }}" novalidate>
{% if login_error %}
<p class="field-error" role="alert" style="margin-bottom:1rem">{{ login_error }}</p>
{% endif %}
<div class="field">
<label for="login-email">Email address</label>
<input type="email" id="login-email" name="_username" autocomplete="email" min-height="44px">
</div>
<div class="field">
<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>
</div>
<script>
(function () {
var tabs = document.querySelectorAll('.tab');
var panels = document.querySelectorAll('.panel');
tabs.forEach(function (tab) {
tab.addEventListener('click', function (e) {
e.preventDefault();
var target = tab.dataset.tab;
tabs.forEach(function (t) { t.classList.toggle('active', t.dataset.tab === target); });
panels.forEach(function (p) { p.classList.toggle('active', p.id === target); });
});
});
}());
</script>
</body>
</html>