mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
feat(web): account pairing folded into Plan card and Apply orchestrator
Pairing was previously its own post-telnet pop-up pane —
loadAccountIDSuggestions(deviceId) was called only after a successful
telnet migration, leaving the user to interact with a separate panel
and click a separate "Pair Account" button. XML migrations didn't
surface pairing at all.
The Plan card now has its own Account pairing section between Service
URLs and Suggested plan, with the same affordances (current state,
7-digit input, Generate button, datastore picker) but always
visible. The implicit intent — read by readPlanPairTarget — is:
- empty input + currently paired → no pairing step (current ID kept)
- empty input + currently unpaired → no pairing step (warning hint visible)
- input matches summary.account_id → no pairing step
- input is exactly 7 digits, differs → pair step queued at Apply
- input is non-empty but malformed → blocks Apply with a clear error
Both Apply orchestrators (applySuggestedPlan, applyCustomPlan) now
queue a `pairAccount(deviceId, accountId)` call when the intent says
to. It runs *after* the URL flip / DNS / CA steps so the user sees
the migration succeed before pairing — pairing is independent of
the migration target so order is purely UX. First-failure-aborts is
preserved: a pair-account error stops the rest of the sequence.
Removed:
- #pair-account-pane HTML and all its descendants
- loadAccountIDSuggestions / generateAccountID / pairAccount(deviceId)
(the old pane-bound functions)
- the "if method === telnet → loadAccountIDSuggestions" trigger in migrate()
Added:
- renderPlanPairing(summary, deviceId) — populates the section on
every showSummary
- loadPlanAccountSuggestions(deviceId) — fetches /setup/account-id-
suggestions; gracefully degrades on failure
- onPlanPairIDChange / onPlanPairPick / generatePlanAccountID — UI
handlers with implicit-intent status hints
- readPlanPairTarget — orchestrator-facing intent extractor
- pairAccount(deviceId, accountId) — POSTs and throws on failure
(replaces the old pane-bound function with a step-friendly shape)
- resetPlanCardForDeviceSwitch clears the pairing input on speaker
change so the previous device's ID can't leak
Backend untouched — all the pairing endpoints (/setup/account-id-
suggestions, /setup/pair-account) and the setup.PairAccount + telnet-
fallback logic stay exactly as-is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a7c9bb1eae
commit
102770e301
@@ -890,6 +890,28 @@
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 14px">
|
||||
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Account pairing</h4>
|
||||
<p id="plan-pair-current" style="margin: 0 0 6px 0; font-size: 0.85em; color: #555">—</p>
|
||||
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap">
|
||||
<label for="plan-pair-id">Account ID:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="plan-pair-id"
|
||||
maxlength="7"
|
||||
pattern="[0-9]{7}"
|
||||
placeholder="1234567"
|
||||
style="font-family: monospace; width: 8em"
|
||||
oninput="onPlanPairIDChange()"
|
||||
/>
|
||||
<button type="button" onclick="generatePlanAccountID()" style="font-size: 0.85em">Generate</button>
|
||||
<select id="plan-pair-existing" onchange="onPlanPairPick()" style="font-size: 0.85em">
|
||||
<option value="">— pick from datastore —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="plan-pair-status" style="margin-top: 4px; font-size: 0.85em; color: #666"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style="margin: 0 0 6px 0; font-size: 0.95em">Suggested plan</h4>
|
||||
<div
|
||||
@@ -1009,60 +1031,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="pair-account-pane"
|
||||
style="display: none; margin-bottom: 20px; padding: 10px; border: 1px solid #ddd; background-color: #fff8e1;"
|
||||
>
|
||||
<h4 style="margin-top: 0">Pair Account</h4>
|
||||
<p
|
||||
id="pair-account-current"
|
||||
style="margin: 5px 0; display: none"
|
||||
></p>
|
||||
<div id="pair-account-fresh" style="display: none">
|
||||
<p style="margin: 5px 0">
|
||||
This speaker has no margeAccountUUID set
|
||||
(factory-reset or never paired). Choose an account
|
||||
ID to attach it to:
|
||||
</p>
|
||||
<div style="margin: 8px 0">
|
||||
<label for="pair-account-existing"
|
||||
>Existing account:</label
|
||||
>
|
||||
<select id="pair-account-existing">
|
||||
<option value="">-- pick from datastore --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="margin: 8px 0">
|
||||
<label for="pair-account-input">7-digit ID:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="pair-account-input"
|
||||
maxlength="7"
|
||||
pattern="[0-9]{7}"
|
||||
placeholder="1234567"
|
||||
style="font-family: monospace; width: 8em"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick="generateAccountID()"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
id="pair-account-btn"
|
||||
type="button"
|
||||
style="margin-top: 8px"
|
||||
>
|
||||
Pair Account
|
||||
</button>
|
||||
<div
|
||||
id="pair-account-status"
|
||||
style="margin-top: 8px; font-size: 0.9em"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- XML diff pair: Current Config | Planned Config. Shown
|
||||
when URL flip = xml in the Customize form. -->
|
||||
<div class="diff-container" id="xml-diff-row" style="display: none">
|
||||
|
||||
@@ -1772,6 +1772,7 @@ async function showSummary(deviceId) {
|
||||
renderMigrationState(summary);
|
||||
renderPlan(summary);
|
||||
renderPlanCurrentURLs(summary);
|
||||
renderPlanPairing(summary, deviceId);
|
||||
renderPreflightWarnings(summary);
|
||||
|
||||
// Mirror the global target URL into the Plan card's input.
|
||||
@@ -1991,122 +1992,177 @@ async function reboot(deviceId, ip) {
|
||||
}
|
||||
}
|
||||
|
||||
// loadAccountIDSuggestions queries the server for the device's current
|
||||
// margeAccountUUID and the list of known accounts in the datastore, and
|
||||
// renders the pair-account pane accordingly.
|
||||
async function loadAccountIDSuggestions(deviceId) {
|
||||
const pane = document.getElementById("pair-account-pane");
|
||||
if (!pane) return;
|
||||
// --- Plan card: account pairing ----------------------------------------
|
||||
|
||||
const currentP = document.getElementById("pair-account-current");
|
||||
const freshDiv = document.getElementById("pair-account-fresh");
|
||||
const existingSelect = document.getElementById("pair-account-existing");
|
||||
const input = document.getElementById("pair-account-input");
|
||||
const btn = document.getElementById("pair-account-btn");
|
||||
const statusDiv = document.getElementById("pair-account-status");
|
||||
// renderPlanPairing populates the Account pairing section of the Plan
|
||||
// card from the live summary (margeAccountUUID is in summary.account_id
|
||||
// after populateDeviceInfo) and loads known IDs from the datastore.
|
||||
async function renderPlanPairing(summary, deviceId) {
|
||||
const currentP = document.getElementById("plan-pair-current");
|
||||
const input = document.getElementById("plan-pair-id");
|
||||
const status = document.getElementById("plan-pair-status");
|
||||
|
||||
pane.style.display = "block";
|
||||
statusDiv.innerText = "Loading...";
|
||||
if (currentP) {
|
||||
if (summary.is_paired && summary.account_id) {
|
||||
currentP.replaceChildren(
|
||||
document.createTextNode("Current: ✅ Paired (account "),
|
||||
Object.assign(document.createElement("strong"), {textContent: summary.account_id}),
|
||||
document.createTextNode(") — leave as-is to keep, or change the ID to re-pair"),
|
||||
);
|
||||
} else {
|
||||
currentP.replaceChildren(
|
||||
document.createTextNode("Current: ❌ Not paired (factory-reset or never paired) — set an ID to pair as part of Apply"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.value = summary.account_id || "";
|
||||
input.dataset.currentId = summary.account_id || "";
|
||||
input.style.borderColor = "";
|
||||
}
|
||||
if (status) {
|
||||
status.innerText = "";
|
||||
status.style.color = "#666";
|
||||
}
|
||||
|
||||
await loadPlanAccountSuggestions(deviceId);
|
||||
|
||||
// Run the change handler once so the status hint reflects the
|
||||
// pre-filled value (matches current → "no pairing needed").
|
||||
onPlanPairIDChange();
|
||||
}
|
||||
|
||||
// loadPlanAccountSuggestions populates the datastore-pick dropdown
|
||||
// from /setup/account-id-suggestions. Quietly degrades on failure —
|
||||
// the input + Generate button still work standalone.
|
||||
async function loadPlanAccountSuggestions(deviceId) {
|
||||
const select = document.getElementById("plan-pair-existing");
|
||||
if (!select) return;
|
||||
|
||||
select.replaceChildren();
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.innerText = "— pick from datastore —";
|
||||
select.appendChild(placeholder);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/setup/account-id-suggestions/" + encodeURIComponent(deviceId),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
// Reset
|
||||
existingSelect.innerHTML = "<option value=\"\">-- pick from datastore --</option>";
|
||||
(data.known || []).forEach((/** @type {string} */ id) => {
|
||||
const resp = await fetch("/setup/account-id-suggestions/" + encodeURIComponent(deviceId));
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
for (const id of data.known || []) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(id);
|
||||
opt.textContent = String(id);
|
||||
existingSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
if (data.current) {
|
||||
currentP.style.display = "block";
|
||||
// Build the paragraph with createElement so the user-controlled
|
||||
// account ID never becomes HTML.
|
||||
currentP.replaceChildren(
|
||||
document.createTextNode("Speaker is already paired with account "),
|
||||
Object.assign(document.createElement("strong"), {textContent: data.current}),
|
||||
document.createTextNode(". You can keep it (recommended) or re-pair to a different ID."),
|
||||
);
|
||||
input.value = data.current;
|
||||
freshDiv.style.display = "block";
|
||||
} else {
|
||||
currentP.style.display = "none";
|
||||
freshDiv.style.display = "block";
|
||||
input.value = "";
|
||||
select.appendChild(opt);
|
||||
}
|
||||
} catch (e) { /* best-effort */ }
|
||||
}
|
||||
|
||||
btn.onclick = () => pairAccount(deviceId);
|
||||
statusDiv.innerText = "";
|
||||
} catch (error) {
|
||||
statusDiv.innerText = "Failed to load suggestions: " + error;
|
||||
// onPlanPairPick mirrors the dropdown choice into the input and
|
||||
// triggers validation.
|
||||
function onPlanPairPick() {
|
||||
const select = document.getElementById("plan-pair-existing");
|
||||
const input = document.getElementById("plan-pair-id");
|
||||
if (select && select.value && input) {
|
||||
input.value = select.value;
|
||||
onPlanPairIDChange();
|
||||
}
|
||||
}
|
||||
|
||||
// generateAccountID picks a random 7-digit ID and writes it into the input,
|
||||
// avoiding any existing IDs already shown in the dropdown so we don't
|
||||
// accidentally collide with a known datastore entry.
|
||||
function generateAccountID() {
|
||||
const select = document.getElementById("pair-account-existing");
|
||||
const input = document.getElementById("pair-account-input");
|
||||
// generatePlanAccountID picks a random 7-digit ID, avoiding values
|
||||
// already shown in the dropdown so we don't accidentally collide with
|
||||
// known datastore entries on this machine.
|
||||
function generatePlanAccountID() {
|
||||
const input = document.getElementById("plan-pair-id");
|
||||
if (!input) return;
|
||||
|
||||
const select = document.getElementById("plan-pair-existing");
|
||||
const known = new Set();
|
||||
if (select) {
|
||||
Array.from(select.options).forEach((o) => {
|
||||
if (o.value) known.add(o.value);
|
||||
});
|
||||
for (const o of select.options) if (o.value) known.add(o.value);
|
||||
}
|
||||
|
||||
// 7-digit number from 1_000_000 to 9_999_999.
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const n = Math.floor(Math.random() * 9_000_000) + 1_000_000;
|
||||
const s = String(n);
|
||||
if (!known.has(s)) {
|
||||
input.value = s;
|
||||
onPlanPairIDChange();
|
||||
return;
|
||||
}
|
||||
}
|
||||
input.value = "";
|
||||
onPlanPairIDChange();
|
||||
}
|
||||
|
||||
async function pairAccount(deviceId) {
|
||||
if (!deviceId) {
|
||||
alert("Please select a device.");
|
||||
return;
|
||||
}
|
||||
const select = document.getElementById("pair-account-existing");
|
||||
const input = document.getElementById("pair-account-input");
|
||||
const statusDiv = document.getElementById("pair-account-status");
|
||||
// onPlanPairIDChange validates the input and surfaces the implicit
|
||||
// intent: empty/matches-current → no pairing step queued; differs
|
||||
// → pairing step queued at Apply time.
|
||||
function onPlanPairIDChange() {
|
||||
const input = document.getElementById("plan-pair-id");
|
||||
const status = document.getElementById("plan-pair-status");
|
||||
if (!input || !status) return;
|
||||
|
||||
let accountID = (input && input.value || "").trim();
|
||||
if (!accountID && select && select.value) accountID = select.value;
|
||||
const v = (input.value || "").trim();
|
||||
const currentId = input.dataset.currentId || "";
|
||||
|
||||
if (!/^[0-9]{7}$/.test(accountID)) {
|
||||
statusDiv.innerText = "Account ID must be exactly 7 digits.";
|
||||
return;
|
||||
}
|
||||
|
||||
statusDiv.innerText = "Pairing...";
|
||||
|
||||
try {
|
||||
const url = "/setup/pair-account/" + encodeURIComponent(deviceId)
|
||||
+ "?account_id=" + encodeURIComponent(accountID);
|
||||
const response = await fetch(url, {method: "POST"});
|
||||
const result = await response.json();
|
||||
showCommandOutput({ok: result.ok, output: result.output, message: result.error});
|
||||
if (result.ok) {
|
||||
statusDiv.innerText = "Paired via " + (result.result && result.result.method || "?")
|
||||
+ ". Reboot the speaker to apply.";
|
||||
if (!v) {
|
||||
if (currentId) {
|
||||
status.innerText = "→ no pairing step (current ID retained)";
|
||||
status.style.color = "#666";
|
||||
} else {
|
||||
statusDiv.innerText = "Pair failed: " + (result.error || "unknown error");
|
||||
status.innerText = "→ no pairing step (device stays unpaired — pair via the official Bose app later if needed)";
|
||||
status.style.color = "#bf6900";
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.innerText = "Error pairing: " + error;
|
||||
input.style.borderColor = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^\d{7}$/.test(v)) {
|
||||
status.innerText = "❌ Account ID must be exactly 7 digits";
|
||||
status.style.color = "#c62828";
|
||||
input.style.borderColor = "#c62828";
|
||||
return;
|
||||
}
|
||||
|
||||
if (v === currentId) {
|
||||
status.innerText = "→ matches current ID — no pairing step needed";
|
||||
status.style.color = "#666";
|
||||
input.style.borderColor = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentId) {
|
||||
status.innerText = `→ will re-pair from ${currentId} to ${v}`;
|
||||
} else {
|
||||
status.innerText = `→ will pair with ${v}`;
|
||||
}
|
||||
status.style.color = "#1976d2";
|
||||
input.style.borderColor = "";
|
||||
}
|
||||
|
||||
// readPlanPairTarget returns null when no pairing step should run, or
|
||||
// the {accountId, valid} for Apply orchestration. Empty input or input
|
||||
// matching current = null (no step). Invalid input also returns null
|
||||
// but with valid=false so callers can refuse to continue.
|
||||
function readPlanPairTarget() {
|
||||
const input = document.getElementById("plan-pair-id");
|
||||
if (!input) return null;
|
||||
const v = (input.value || "").trim();
|
||||
if (!v) return null;
|
||||
if (!/^\d{7}$/.test(v)) return {accountId: v, valid: false};
|
||||
const currentId = input.dataset.currentId || "";
|
||||
if (v === currentId) return null;
|
||||
return {accountId: v, valid: true};
|
||||
}
|
||||
|
||||
// pairAccount POSTs to /setup/pair-account and throws on failure so
|
||||
// the Apply orchestrator's first-failure-aborts logic kicks in.
|
||||
async function pairAccount(deviceId, accountId) {
|
||||
const url = `/setup/pair-account/${encodeURIComponent(deviceId)}?account_id=${encodeURIComponent(accountId)}`;
|
||||
const resp = await fetch(url, {method: "POST"});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok || !result.ok) {
|
||||
throw new Error(result.error || result.message || `pair-account returned ${resp.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2185,13 +2241,6 @@ async function migrate(deviceId, ip, method) {
|
||||
const customize = rebootBtn.closest("details");
|
||||
if (customize) customize.open = true;
|
||||
|
||||
// For the telnet path, surface the account-id picker. Pairing is
|
||||
// only needed when the device's margeAccountUUID is empty, but
|
||||
// we always show the panel so the user can re-pair if they want.
|
||||
if (method === "telnet") {
|
||||
loadAccountIDSuggestions(deviceId);
|
||||
}
|
||||
|
||||
// Re-show summary but with prominence on reboot
|
||||
summaryDiv.style.display = "block";
|
||||
} else {
|
||||
@@ -2659,6 +2708,18 @@ function resetPlanCardForDeviceSwitch() {
|
||||
|
||||
const customizeStatus = document.getElementById("customize-apply-status");
|
||||
if (customizeStatus) customizeStatus.innerText = "";
|
||||
|
||||
// Pairing input — clear so the renderPlanPairing call later in
|
||||
// showSummary populates it from the new device's account_id
|
||||
// rather than the previous device's pre-typed value.
|
||||
const pairInput = document.getElementById("plan-pair-id");
|
||||
if (pairInput) {
|
||||
pairInput.value = "";
|
||||
pairInput.style.borderColor = "";
|
||||
delete pairInput.dataset.currentId;
|
||||
}
|
||||
const pairStatus = document.getElementById("plan-pair-status");
|
||||
if (pairStatus) pairStatus.innerText = "";
|
||||
}
|
||||
|
||||
// toggleSoundcorkMode reapplies defaults so the /marge suffix appears
|
||||
@@ -3143,6 +3204,17 @@ async function applySuggestedPlan() {
|
||||
status.style.color = "#555";
|
||||
}
|
||||
|
||||
// Pair-target intent from the Plan card. null = no pairing step;
|
||||
// {valid:false} = invalid input that blocks Apply.
|
||||
const pair = readPlanPairTarget();
|
||||
if (pair && !pair.valid) {
|
||||
if (status) {
|
||||
status.innerText = "Aborted — invalid account ID";
|
||||
status.style.color = "#c62828";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Visible pre-flight panel — backend summary re-fetch, plus the
|
||||
// applicable device-side checks (HTTPS connection, DNS redirection)
|
||||
// run automatically so the user gets feedback without having to
|
||||
@@ -3175,6 +3247,25 @@ async function applySuggestedPlan() {
|
||||
// device id. The empty string keeps the existing call shape.
|
||||
await migrate(deviceId, "", method);
|
||||
|
||||
// Pairing runs after the URL flip so the user sees migration
|
||||
// succeed before pairing — pair-account is independent of the
|
||||
// migration target so order is purely UX.
|
||||
if (pair && pair.valid) {
|
||||
if (status) {
|
||||
status.innerText = "Pairing account " + pair.accountId + "…";
|
||||
status.style.color = "#555";
|
||||
}
|
||||
try {
|
||||
await pairAccount(deviceId, pair.accountId);
|
||||
} catch (e) {
|
||||
if (status) {
|
||||
status.innerText = "❌ Pair failed: " + e.message;
|
||||
status.style.color = "#c62828";
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (status) status.innerText = "";
|
||||
}
|
||||
|
||||
@@ -3594,6 +3685,14 @@ async function applyCustomPlan() {
|
||||
status.style.color = color || "#555";
|
||||
};
|
||||
|
||||
// Pair-target intent from the Plan card. null = no pairing step;
|
||||
// {valid:false} = invalid input that blocks Apply.
|
||||
const pair = readPlanPairTarget();
|
||||
if (pair && !pair.valid) {
|
||||
setStatus("Aborted — invalid account ID", "#c62828");
|
||||
return;
|
||||
}
|
||||
|
||||
const steps = [];
|
||||
const methods = [];
|
||||
if (flip === "xml" || flip === "telnet") {
|
||||
@@ -3608,9 +3707,13 @@ async function applyCustomPlan() {
|
||||
steps.push({label: "Install local CA", run: () => trustCA(deviceId, ip)});
|
||||
methods.push("trust-ca");
|
||||
}
|
||||
if (pair && pair.valid) {
|
||||
steps.push({label: `Pair account ${pair.accountId}`, run: () => pairAccount(deviceId, pair.accountId)});
|
||||
methods.push("pair-account");
|
||||
}
|
||||
|
||||
if (steps.length === 0) {
|
||||
setStatus("Pick at least one axis above.", "#c62828");
|
||||
setStatus("Pick at least one axis above (or change the pairing ID).", "#c62828");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user