feat(identity): add notification dot on My Account + remove postal address

Show red alert dot on "My Account" dropdown item when email, multi-device,
or social recovery tasks are incomplete. Remove postal address section
from the account modal (render, state, loader, listeners).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-13 09:15:17 -04:00
parent c91921592b
commit 1e5f04398b
1 changed files with 22 additions and 134 deletions

View File

@ -470,9 +470,10 @@ export class RStackIdentity extends HTMLElement {
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
try {
const { ts, ok } = JSON.parse(cached);
if (Date.now() - ts < 30 * 60 * 1000) {
if (!ok) this.#showRecoveryDot();
const c = JSON.parse(cached);
if (Date.now() - c.ts < 30 * 60 * 1000) {
if (!c.socialRecovery) this.#showRecoveryDot();
if (!c.email || !c.multiDevice || !c.socialRecovery) this.#showAccountDot();
return;
}
} catch { /* stale cache */ }
@ -484,9 +485,9 @@ export class RStackIdentity extends HTMLElement {
});
if (!res.ok) return;
const status = await res.json();
const recoveryOk = status.socialRecovery === true;
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), ok: recoveryOk }));
if (!recoveryOk) this.#showRecoveryDot();
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), email: !!status.email, multiDevice: !!status.multiDevice, socialRecovery: !!status.socialRecovery }));
if (!status.socialRecovery) this.#showRecoveryDot();
if (!status.email || !status.multiDevice || !status.socialRecovery) this.#showAccountDot();
} catch { /* offline */ }
}
@ -499,6 +500,14 @@ export class RStackIdentity extends HTMLElement {
wrap.appendChild(dot);
}
#showAccountDot() {
const btn = this.#shadow.querySelector('[data-action="my-account"]');
if (!btn || btn.querySelector(".acct-alert-dot")) return;
const dot = document.createElement("span");
dot.className = "acct-alert-dot";
btn.appendChild(dot);
}
async #checkDeviceNudge() {
const session = getSession();
if (!session?.accessToken) return;
@ -1360,10 +1369,6 @@ export class RStackIdentity extends HTMLElement {
let devicesLoaded = false;
let devicesLoading = false;
let addresses: { id: string; street: string; city: string; state: string; zip: string; country: string }[] = [];
let addressesLoaded = false;
let addressesLoading = false;
// Connections data
let connectionsLoaded = false;
let connectionsLoading = false;
@ -1418,7 +1423,6 @@ export class RStackIdentity extends HTMLElement {
${renderEmailSection()}
${renderDeviceSection()}
${renderRecoverySection()}
${renderAddressSection()}
<div class="account-section account-section--inline${!backupEnabled ? " section--warning" : ""}">
<div class="account-section-header">
@ -1714,52 +1718,7 @@ export class RStackIdentity extends HTMLElement {
</div>`;
};
const renderAddressSection = () => {
const isOpen = openSection === "address";
let body = "";
if (isOpen) {
if (addressesLoading) {
body = `<div class="account-section-body"><div style="text-align:center;padding:1rem;color:var(--rs-text-secondary)"><span class="spinner"></span> Loading addresses...</div></div>`;
} else {
const listHTML = addresses.length > 0
? `<div class="contact-list">${addresses.map(a => `
<div class="contact-item">
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;flex:1;font-size:0.85rem">
<span>${a.street.replace(/</g, "&lt;")}</span>
<span style="color:var(--rs-text-secondary)">${a.city.replace(/</g, "&lt;")}, ${a.state.replace(/</g, "&lt;")} ${a.zip.replace(/</g, "&lt;")} ${a.country.replace(/</g, "&lt;")}</span>
</div>
<button class="contact-remove" data-remove-address="${a.id}">&times;</button>
</div>
`).join("")}</div>` : "";
body = `
<div class="account-section-body">
<div class="address-form">
<input class="input" id="acct-street" type="text" placeholder="Street address" />
<div class="address-row">
<input class="input" id="acct-city" type="text" placeholder="City" />
<input class="input" id="acct-state" type="text" placeholder="State" />
</div>
<div class="address-row">
<input class="input" id="acct-zip" type="text" placeholder="ZIP / Postal code" />
<input class="input" id="acct-country" type="text" placeholder="Country" />
</div>
<button class="btn btn--primary" data-action="save-address" style="align-self:flex-start">Save Address</button>
</div>
${listHTML}
<div class="error" id="address-error"></div>
</div>`;
}
}
return `
<div class="account-section${isOpen ? " open" : ""}">
<div class="account-section-header" data-section="address">
<span>🏠 Postal Address</span>
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
</div>
${body}
</div>`;
};
const renderShortcutsSection = () => {
const isOpen = openSection === "shortcuts";
@ -1944,31 +1903,6 @@ export class RStackIdentity extends HTMLElement {
render();
};
const loadAddresses = async () => {
if (addressesLoaded || addressesLoading) return;
addressesLoading = true;
render();
try {
const res = await fetch(`${ENCRYPTID_URL}/api/user/addresses`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (res.ok) {
const data = await res.json();
addresses = (data.addresses || []).map((a: any) => {
try {
const decoded = JSON.parse(atob(a.ciphertext));
return { id: a.id, ...decoded };
} catch {
return { id: a.id, street: "", city: "", state: "", zip: "", country: "" };
}
});
}
} catch { /* offline */ }
addressesLoaded = true;
addressesLoading = false;
render();
};
const loadDevices = async () => {
if (devicesLoaded || devicesLoading) return;
devicesLoading = true;
@ -2003,7 +1937,6 @@ export class RStackIdentity extends HTMLElement {
const section = (el as HTMLElement).dataset.section!;
openSection = openSection === section ? null : section;
if (openSection === "recovery") loadGuardians();
if (openSection === "address") loadAddresses();
if (openSection === "device") loadDevices();
if (openSection === "connections") loadConnections();
render();
@ -2261,58 +2194,6 @@ export class RStackIdentity extends HTMLElement {
showWalkthrough = false; walkthroughStep = 0; render();
});
// Address: save
overlay.querySelector('[data-action="save-address"]')?.addEventListener("click", async () => {
const street = (overlay.querySelector("#acct-street") as HTMLInputElement)?.value.trim() || "";
const city = (overlay.querySelector("#acct-city") as HTMLInputElement)?.value.trim() || "";
const state = (overlay.querySelector("#acct-state") as HTMLInputElement)?.value.trim() || "";
const zip = (overlay.querySelector("#acct-zip") as HTMLInputElement)?.value.trim() || "";
const country = (overlay.querySelector("#acct-country") as HTMLInputElement)?.value.trim() || "";
const err = overlay.querySelector("#address-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="save-address"]') as HTMLButtonElement;
if (!street || !city) { err.textContent = "Street and city are required."; return; }
err.textContent = "";
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Saving...';
const payload = { street, city, state, zip, country };
const ciphertext = btoa(JSON.stringify(payload));
try {
const res = await fetch(`${ENCRYPTID_URL}/api/user/addresses`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({ ciphertext }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to save address");
addresses.push({ id: data.id || data.address?.id || String(Date.now()), ...payload });
render();
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "Save Address";
err.textContent = e.message;
}
});
// Address: remove
overlay.querySelectorAll("[data-remove-address]").forEach(el => {
el.addEventListener("click", async () => {
const id = (el as HTMLElement).dataset.removeAddress!;
const err = overlay.querySelector("#address-error") as HTMLElement;
try {
const res = await fetch(`${ENCRYPTID_URL}/api/user/addresses/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (!res.ok) throw new Error("Failed to remove address");
addresses = addresses.filter(a => a.id !== id);
render();
} catch (e: any) {
if (err) err.textContent = e.message;
}
});
});
// Device: rename credential
overlay.querySelectorAll("[data-rename-credential]").forEach(el => {
el.addEventListener("click", async () => {
@ -2820,6 +2701,13 @@ const STYLES = `
0%, 100% { box-shadow: 0 0 4px rgba(248,113,113,0.4); }
50% { box-shadow: 0 0 10px rgba(248,113,113,0.8); }
}
/* Account alert dot on "My Account" dropdown item */
.acct-alert-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #f87171; margin-left: auto; flex-shrink: 0;
box-shadow: 0 0 6px rgba(248,113,113,0.6);
animation: recovery-pulse 2s ease-in-out infinite;
}
/* Persona switcher in dropdown */
.dropdown-label {