feat: replace My Account submenu with consolidated popup modal

Consolidates email, device, recovery, postal address, data storage,
and dark mode settings into a single scrollable modal with collapsible
section cards — matching the existing My Spaces modal pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-02 15:22:13 -08:00
parent 25174b87e6
commit 88d618c1af
1 changed files with 510 additions and 290 deletions

View File

@ -413,29 +413,7 @@ export class RStackIdentity extends HTMLElement {
<div class="dropdown-header">${displayName}</div>
${notifsHTML}
<div class="dropdown-divider"></div>
<button class="dropdown-item submenu-toggle" data-action="toggle-account">
👤 My Account <span class="submenu-arrow"></span>
</button>
<div class="submenu" id="account-submenu">
<button class="dropdown-item submenu-item" data-action="add-email"> Add Email</button>
<button class="dropdown-item submenu-item" data-action="add-device">📱 Add Second Device</button>
<button class="dropdown-item submenu-item" data-action="add-recovery">🛡 Add Social Recovery</button>
<div class="dropdown-divider"></div>
<div class="dropdown-item submenu-item toggle-row">
🌙 Dark Mode
<label class="toggle-switch">
<input type="checkbox" id="theme-toggle" />
<span class="toggle-slider"></span>
</label>
</div>
<div class="dropdown-item submenu-item toggle-row">
🔒 Encrypted Backup
<label class="toggle-switch">
<input type="checkbox" id="backup-toggle" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<button class="dropdown-item" data-action="my-account">👤 My Account</button>
<button class="dropdown-item" data-action="my-spaces">🌐 My Spaces</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item dropdown-item--danger" data-action="signout">🚪 Sign Out</button>
@ -488,13 +466,6 @@ export class RStackIdentity extends HTMLElement {
el.addEventListener("click", (e) => {
e.stopPropagation();
const action = (el as HTMLElement).dataset.action;
if (action === "toggle-account") {
const submenu = this.#shadow.getElementById("account-submenu")!;
const arrow = (el as HTMLElement).querySelector(".submenu-arrow")!;
submenu.classList.toggle("open");
arrow.textContent = submenu.classList.contains("open") ? "▾" : "▸";
return;
}
dropdown.classList.remove("open");
if (action === "signout") {
clearSession();
@ -503,45 +474,13 @@ export class RStackIdentity extends HTMLElement {
this.#notifications = [];
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
} else if (action === "add-email") {
this.#showAddEmailModal();
} else if (action === "add-device") {
this.#showAddDeviceModal();
} else if (action === "add-recovery") {
this.#showAddRecoveryModal();
} else if (action === "my-account") {
this.#showAccountModal();
} else if (action === "my-spaces") {
this.#showSpacesModal();
}
});
});
// Theme toggle
const themeToggle = this.#shadow.getElementById("theme-toggle") as HTMLInputElement;
if (themeToggle) {
const currentTheme = localStorage.getItem("canvas-theme") || "dark";
themeToggle.checked = currentTheme === "dark";
themeToggle.addEventListener("change", (e) => {
e.stopPropagation();
const newTheme = themeToggle.checked ? "dark" : "light";
localStorage.setItem("canvas-theme", newTheme);
document.body.setAttribute("data-theme", newTheme);
document.querySelectorAll(".rstack-header, .rstack-tab-row").forEach(el => el.setAttribute("data-theme", newTheme));
this.dispatchEvent(new CustomEvent("theme-change", { bubbles: true, composed: true, detail: { theme: newTheme } }));
this.#render();
});
}
// Backup toggle
const backupToggle = this.#shadow.getElementById("backup-toggle") as HTMLInputElement;
if (backupToggle) {
backupToggle.checked = isEncryptedBackupEnabled();
backupToggle.addEventListener("change", (e) => {
e.stopPropagation();
const enabled = backupToggle.checked;
setEncryptedBackupEnabled(enabled);
this.dispatchEvent(new CustomEvent("backup-toggle", { bubbles: true, composed: true, detail: { enabled } }));
});
}
} else {
this.#shadow.innerHTML = `
<style>${STYLES}</style>
@ -761,54 +700,295 @@ export class RStackIdentity extends HTMLElement {
render();
}
// ── Settings modals ──
// ── Account modal (consolidated) ──
#showAddEmailModal(): void {
if (document.querySelector(".rstack-auth-overlay")) return;
#showAccountModal(): void {
if (document.querySelector(".rstack-account-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
let step: "input" | "verify" = "input";
let emailAddr = "";
overlay.className = "rstack-account-overlay";
const render = () => {
overlay.innerHTML = step === "input" ? `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Add Email</h2>
<p>Link an email for notifications and account recovery.</p>
<input class="input" id="s-email" type="email" placeholder="you@example.com" />
<div class="actions">
<button class="btn btn--primary" data-action="send-code">Send Verification Code</button>
</div>
<div class="error" id="s-error"></div>
</div>
` : `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Verify Email</h2>
<p>Enter the 6-digit code sent to <strong>${emailAddr.replace(/</g, "&lt;")}</strong></p>
<input class="input" id="s-code" type="text" placeholder="000000" maxlength="6" inputmode="numeric" />
<div class="actions">
<button class="btn btn--secondary" data-action="back">Back</button>
<button class="btn btn--primary" data-action="verify">Verify</button>
</div>
<div class="error" id="s-error"></div>
</div>
`;
attach();
};
const session = getSession();
if (!session) return;
let openSection: string | null = null;
// Lazy-loaded data
let guardians: { id: string; name: string; email?: string; status: string }[] = [];
let guardiansThreshold = 2;
let guardiansLoaded = false;
let guardiansLoading = false;
let addresses: { id: string; street: string; city: string; state: string; zip: string; country: string }[] = [];
let addressesLoaded = false;
let addressesLoading = false;
let emailStep: "input" | "verify" = "input";
let emailAddr = "";
const close = () => overlay.remove();
const attach = () => {
const render = () => {
const backupEnabled = isEncryptedBackupEnabled();
const currentTheme = localStorage.getItem("canvas-theme") || "dark";
const isDark = currentTheme === "dark";
overlay.innerHTML = `
<style>${MODAL_STYLES}${SETTINGS_STYLES}${ACCOUNT_MODAL_STYLES}</style>
<div class="account-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>My Account</h2>
${renderEmailSection()}
${renderDeviceSection()}
${renderRecoverySection()}
${renderAddressSection()}
<div class="account-section account-section--inline">
<div class="account-section-header">
<span>🔒 Data Storage</span>
<label class="toggle-switch">
<input type="checkbox" id="acct-backup-toggle" ${backupEnabled ? "checked" : ""} />
<span class="toggle-slider"></span>
</label>
</div>
<div class="toggle-hint" id="backup-hint">${backupEnabled ? "Save to encrypted server" : "Save locally — you manage your own data"}</div>
</div>
<div class="account-section account-section--inline">
<div class="account-section-header">
<span>🌙 Dark Mode</span>
<label class="toggle-switch">
<input type="checkbox" id="acct-theme-toggle" ${isDark ? "checked" : ""} />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="error" id="acct-error"></div>
</div>
`;
attachListeners();
};
const renderEmailSection = () => {
const isOpen = openSection === "email";
let body = "";
if (isOpen) {
if (emailStep === "input") {
body = `
<div class="account-section-body">
<p style="color:#94a3b8;font-size:0.85rem;margin:0 0 12px">Link an email for notifications and account recovery.</p>
<input class="input" id="acct-email" type="email" placeholder="you@example.com" />
<div class="actions">
<button class="btn btn--primary" data-action="send-code">Send Verification Code</button>
</div>
<div class="error" id="email-error"></div>
</div>`;
} else {
body = `
<div class="account-section-body">
<p style="color:#94a3b8;font-size:0.85rem;margin:0 0 12px">Enter the 6-digit code sent to <strong>${emailAddr.replace(/</g, "&lt;")}</strong></p>
<input class="input" id="acct-code" type="text" placeholder="000000" maxlength="6" inputmode="numeric" />
<div class="actions">
<button class="btn btn--secondary" data-action="email-back">Back</button>
<button class="btn btn--primary" data-action="verify-email">Verify</button>
</div>
<div class="error" id="email-error"></div>
</div>`;
}
}
return `
<div class="account-section${isOpen ? " open" : ""}">
<div class="account-section-header" data-section="email">
<span> Email</span>
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
</div>
${body}
</div>`;
};
const renderDeviceSection = () => {
const isOpen = openSection === "device";
const body = isOpen ? `
<div class="account-section-body">
<p style="color:#94a3b8;font-size:0.85rem;margin:0 0 12px">Register an additional passkey for backup access.</p>
<div class="actions actions--stack">
<button class="btn btn--primary" data-action="register-device">🔑 Register Passkey on This Device</button>
</div>
<div class="error" id="device-error"></div>
<div class="info-text">Each device you register can independently sign in to your account.</div>
</div>` : "";
return `
<div class="account-section${isOpen ? " open" : ""}">
<div class="account-section-header" data-section="device">
<span>📱 Connect Another Device</span>
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
</div>
${body}
</div>`;
};
const renderRecoverySection = () => {
const isOpen = openSection === "recovery";
let body = "";
if (isOpen) {
if (guardiansLoading) {
body = `<div class="account-section-body"><div style="text-align:center;padding:1rem;color:#94a3b8"><span class="spinner"></span> Loading guardians...</div></div>`;
} else {
const guardiansHTML = guardians.length > 0
? `<div class="contact-list">${guardians.map(g => `
<div class="contact-item">
<div style="display:flex;align-items:center;gap:8px;min-width:0;flex:1">
<span class="guardian-piece">🧩</span>
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;flex:1">
<span>${g.name.replace(/</g, "&lt;")}${g.email ? ` <span style="opacity:0.5;font-size:0.8rem">${g.email.replace(/</g, "&lt;")}</span>` : ""}</span>
<span style="font-size:0.7rem;color:${g.status === "accepted" ? "#34d399" : "#fbbf24"}">${g.status === "accepted" ? "Accepted" : "Pending invite"}</span>
</div>
</div>
<button class="contact-remove" data-remove-guardian="${g.id}">&times;</button>
</div>
`).join("")}</div>` : "";
const infoHTML = guardians.length < 2
? `<div class="info-text">Add at least 2 trusted guardians to enable social recovery. Threshold: ${guardiansThreshold} of ${Math.max(guardians.length, 2)} needed to recover.</div>`
: `<div class="info-text" style="color:#34d399">Social recovery is active. ${guardiansThreshold} of ${guardians.length} guardians needed to recover your account.</div>`;
body = `
<div class="account-section-body">
<p style="color:#94a3b8;font-size:0.85rem;margin:0 0 12px">Choose trusted contacts who can help recover your account.</p>
${guardians.length < 3 ? `<div class="input-row">
<input class="input input--inline" id="acct-guardian-name" type="text" placeholder="Guardian name" />
<input class="input input--inline" id="acct-guardian-email" type="email" placeholder="Email (optional)" />
<button class="btn btn--small btn--primary" data-action="add-guardian">Add</button>
</div>` : ""}
${guardiansHTML}
${infoHTML}
<div class="error" id="recovery-error"></div>
</div>`;
}
}
return `
<div class="account-section${isOpen ? " open" : ""}">
<div class="account-section-header" data-section="recovery">
<span>🛡 Social Recovery</span>
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
</div>
${body}
</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:#94a3b8"><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:#94a3b8">${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 loadGuardians = async () => {
if (guardiansLoaded || guardiansLoading) return;
guardiansLoading = true;
render();
try {
const res = await fetch(`${ENCRYPTID_URL}/api/guardians`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (res.ok) {
const data = await res.json();
guardians = data.guardians || [];
guardiansThreshold = data.threshold || 2;
}
} catch { /* offline */ }
guardiansLoaded = true;
guardiansLoading = false;
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 attachListeners = () => {
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
// Section toggle headers
overlay.querySelectorAll("[data-section]").forEach(el => {
el.addEventListener("click", () => {
const section = (el as HTMLElement).dataset.section!;
openSection = openSection === section ? null : section;
if (openSection === "recovery") loadGuardians();
if (openSection === "address") loadAddresses();
render();
if (openSection === "email") setTimeout(() => (overlay.querySelector("#acct-email") as HTMLInputElement)?.focus(), 50);
if (openSection === "recovery") setTimeout(() => (overlay.querySelector("#acct-guardian-name") as HTMLInputElement)?.focus(), 50);
});
});
// Email: send code
overlay.querySelector('[data-action="send-code"]')?.addEventListener("click", async () => {
const input = overlay.querySelector("#s-email") as HTMLInputElement;
const err = overlay.querySelector("#s-error") as HTMLElement;
const input = overlay.querySelector("#acct-email") as HTMLInputElement;
const err = overlay.querySelector("#email-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="send-code"]') as HTMLButtonElement;
emailAddr = input.value.trim();
if (!emailAddr || !emailAddr.includes("@")) { err.textContent = "Enter a valid email address."; input.focus(); return; }
@ -820,20 +1000,21 @@ export class RStackIdentity extends HTMLElement {
body: JSON.stringify({ email: emailAddr }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Failed to send verification code");
step = "verify"; render();
setTimeout(() => (overlay.querySelector("#s-code") as HTMLInputElement)?.focus(), 50);
emailStep = "verify"; render();
setTimeout(() => (overlay.querySelector("#acct-code") as HTMLInputElement)?.focus(), 50);
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "Send Verification Code";
err.textContent = e.message;
}
});
overlay.querySelector('[data-action="back"]')?.addEventListener("click", () => { step = "input"; render(); });
overlay.querySelector('[data-action="email-back"]')?.addEventListener("click", () => { emailStep = "input"; render(); });
overlay.querySelector('[data-action="verify"]')?.addEventListener("click", async () => {
const input = overlay.querySelector("#s-code") as HTMLInputElement;
const err = overlay.querySelector("#s-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="verify"]') as HTMLButtonElement;
// Email: verify code
overlay.querySelector('[data-action="verify-email"]')?.addEventListener("click", async () => {
const input = overlay.querySelector("#acct-code") as HTMLInputElement;
const err = overlay.querySelector("#email-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="verify-email"]') as HTMLButtonElement;
const code = input.value.trim();
if (!code) { err.textContent = "Enter the verification code."; input.focus(); return; }
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Verifying...';
@ -852,178 +1033,80 @@ export class RStackIdentity extends HTMLElement {
}
});
overlay.querySelector("#s-email")?.addEventListener("keydown", (e) => {
// Email: enter key
overlay.querySelector("#acct-email")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="send-code"]') as HTMLElement)?.click();
});
overlay.querySelector("#s-code")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="verify"]') as HTMLElement)?.click();
overlay.querySelector("#acct-code")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="verify-email"]') as HTMLElement)?.click();
});
};
document.body.appendChild(overlay);
render();
}
// Device: register passkey
overlay.querySelector('[data-action="register-device"]')?.addEventListener("click", async () => {
const err = overlay.querySelector("#device-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="register-device"]') as HTMLButtonElement;
err.textContent = "";
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Registering...';
try {
const startRes = await fetch(`${ENCRYPTID_URL}/api/account/device/start`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
});
if (!startRes.ok) throw new Error((await startRes.json().catch(() => ({}))).error || "Failed to start device registration");
const { options: serverOptions, userId } = await startRes.json();
const username = session.claims.username || "";
#showAddDeviceModal(): void {
if (document.querySelector(".rstack-auth-overlay")) return;
const session = getSession();
if (!session) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
overlay.innerHTML = `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Add Second Device</h2>
<p>Register an additional passkey for backup access. Use this on a different device or browser.</p>
<div class="actions actions--stack">
<button class="btn btn--primary" data-action="register-device">🔑 Register Passkey on This Device</button>
</div>
<div class="error" id="s-error"></div>
<div class="info-text">Each device you register can independently sign in to your account.</div>
</div>
`;
const close = () => overlay.remove();
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-action="register-device"]')?.addEventListener("click", async () => {
const err = overlay.querySelector("#s-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="register-device"]') as HTMLButtonElement;
err.textContent = "";
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Registering...';
try {
const startRes = await fetch(`${ENCRYPTID_URL}/api/account/device/start`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
});
if (!startRes.ok) throw new Error((await startRes.json().catch(() => ({}))).error || "Failed to start device registration");
const { options: serverOptions, userId } = await startRes.json();
const username = session.claims.username || "";
const credential = (await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rp: { id: serverOptions.rp?.id || "rspace.online", name: serverOptions.rp?.name || "EncryptID" },
user: {
id: new Uint8Array(base64urlToBuffer(serverOptions.user?.id || userId)),
name: username || session.claims.sub,
displayName: username || session.claims.sub,
const credential = (await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rp: { id: serverOptions.rp?.id || "rspace.online", name: serverOptions.rp?.name || "EncryptID" },
user: {
id: new Uint8Array(base64urlToBuffer(serverOptions.user?.id || userId)),
name: username || session.claims.sub,
displayName: username || session.claims.sub,
},
pubKeyCredParams: serverOptions.pubKeyCredParams || [
{ alg: -7, type: "public-key" as const },
{ alg: -257, type: "public-key" as const },
],
authenticatorSelection: { residentKey: "required", requireResidentKey: true, userVerification: "required" },
attestation: "none",
timeout: 60000,
},
pubKeyCredParams: serverOptions.pubKeyCredParams || [
{ alg: -7, type: "public-key" as const },
{ alg: -257, type: "public-key" as const },
],
authenticatorSelection: { residentKey: "required", requireResidentKey: true, userVerification: "required" },
attestation: "none",
timeout: 60000,
},
})) as PublicKeyCredential;
if (!credential) throw new Error("Passkey creation failed");
})) as PublicKeyCredential;
if (!credential) throw new Error("Passkey creation failed");
const response = credential.response as AuthenticatorAttestationResponse;
const publicKey = response.getPublicKey?.();
const response = credential.response as AuthenticatorAttestationResponse;
const publicKey = response.getPublicKey?.();
const completeRes = await fetch(`${ENCRYPTID_URL}/api/account/device/complete`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey ? bufferToBase64url(publicKey) : "",
transports: response.getTransports?.() || [],
},
}),
});
if (!completeRes.ok) throw new Error((await completeRes.json().catch(() => ({}))).error || "Device registration failed");
const completeRes = await fetch(`${ENCRYPTID_URL}/api/account/device/complete`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey ? bufferToBase64url(publicKey) : "",
transports: response.getTransports?.() || [],
},
}),
});
if (!completeRes.ok) throw new Error((await completeRes.json().catch(() => ({}))).error || "Device registration failed");
btn.innerHTML = "Device Registered";
btn.className = "btn btn--success";
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "device-added" } }));
setTimeout(close, 1500);
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "🔑 Register Passkey on This Device";
err.textContent = e.name === "NotAllowedError" ? "Passkey creation was cancelled." : e.message;
}
});
document.body.appendChild(overlay);
}
#showAddRecoveryModal(): void {
if (document.querySelector(".rstack-auth-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
let guardians: { id: string; name: string; email?: string; status: string }[] = [];
let threshold = 2;
let loading = true;
const render = () => {
const guardiansHTML = guardians.length > 0
? `<div class="contact-list">${guardians.map(g => `
<div class="contact-item">
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;flex:1">
<span>${g.name.replace(/</g, "&lt;")}${g.email ? ` <span style="opacity:0.5;font-size:0.8rem">${g.email.replace(/</g, "&lt;")}</span>` : ""}</span>
<span style="font-size:0.7rem;color:${g.status === "accepted" ? "#34d399" : "#fbbf24"}">${g.status === "accepted" ? "Accepted" : "Pending invite"}</span>
</div>
<button class="contact-remove" data-remove-id="${g.id}">&times;</button>
</div>
`).join("")}</div>`
: "";
const infoHTML = guardians.length < 2
? `<div class="info-text">Add at least 2 trusted guardians to enable social recovery. Threshold: ${threshold} of ${Math.max(guardians.length, 2)} needed to recover.</div>`
: `<div class="info-text" style="color:#34d399">Social recovery is active. ${threshold} of ${guardians.length} guardians needed to recover your account.</div>`;
overlay.innerHTML = `
<style>${MODAL_STYLES}${SETTINGS_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>Social Recovery</h2>
<p>Choose trusted contacts who can help recover your account.</p>
${loading ? '<div style="text-align:center;padding:1rem;color:#94a3b8"><span class="spinner"></span> Loading guardians...</div>' : `
${guardians.length < 3 ? `<div class="input-row">
<input class="input input--inline" id="s-name" type="text" placeholder="Guardian name" />
<input class="input input--inline" id="s-email" type="email" placeholder="Email (optional)" />
<button class="btn btn--small btn--primary" data-action="add-guardian">Add</button>
</div>` : ""}
${guardiansHTML}
${infoHTML}
`}
<div class="error" id="s-error"></div>
</div>
`;
attach();
};
const close = () => overlay.remove();
const loadGuardians = async () => {
try {
const res = await fetch(`${ENCRYPTID_URL}/api/guardians`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (res.ok) {
const data = await res.json();
guardians = data.guardians || [];
threshold = data.threshold || 2;
btn.innerHTML = "Device Registered";
btn.className = "btn btn--success";
this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "device-added" } }));
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "🔑 Register Passkey on This Device";
err.textContent = e.name === "NotAllowedError" ? "Passkey creation was cancelled." : e.message;
}
} catch { /* offline */ }
loading = false;
render();
};
const attach = () => {
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
});
// Recovery: add guardian
overlay.querySelector('[data-action="add-guardian"]')?.addEventListener("click", async () => {
const nameInput = overlay.querySelector("#s-name") as HTMLInputElement;
const emailInput = overlay.querySelector("#s-email") as HTMLInputElement;
const err = overlay.querySelector("#s-error") as HTMLElement;
const nameInput = overlay.querySelector("#acct-guardian-name") as HTMLInputElement;
const emailInput = overlay.querySelector("#acct-guardian-email") as HTMLInputElement;
const err = overlay.querySelector("#recovery-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="add-guardian"]') as HTMLButtonElement;
const name = nameInput.value.trim();
const email = emailInput.value.trim();
@ -1040,24 +1123,25 @@ export class RStackIdentity extends HTMLElement {
if (!res.ok) throw new Error(data.error || "Failed to add guardian");
guardians.push({ id: data.guardian.id, name: data.guardian.name, email: data.guardian.email, status: data.guardian.status });
render();
setTimeout(() => (overlay.querySelector("#s-name") as HTMLInputElement)?.focus(), 50);
setTimeout(() => (overlay.querySelector("#acct-guardian-name") as HTMLInputElement)?.focus(), 50);
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "Add";
err.textContent = e.message;
}
});
overlay.querySelector("#s-name")?.addEventListener("keydown", (e) => {
overlay.querySelector("#acct-guardian-name")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-guardian"]') as HTMLElement)?.click();
});
overlay.querySelector("#s-email")?.addEventListener("keydown", (e) => {
overlay.querySelector("#acct-guardian-email")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-guardian"]') as HTMLElement)?.click();
});
overlay.querySelectorAll("[data-remove-id]").forEach(el => {
// Recovery: remove guardian
overlay.querySelectorAll("[data-remove-guardian]").forEach(el => {
el.addEventListener("click", async () => {
const id = (el as HTMLElement).dataset.removeId!;
const err = overlay.querySelector("#s-error") as HTMLElement;
const id = (el as HTMLElement).dataset.removeGuardian!;
const err = overlay.querySelector("#recovery-error") as HTMLElement;
try {
const res = await fetch(`${ENCRYPTID_URL}/api/guardians/${id}`, {
method: "DELETE",
@ -1067,15 +1151,93 @@ export class RStackIdentity extends HTMLElement {
guardians = guardians.filter(g => g.id !== id);
render();
} catch (e: any) {
err.textContent = e.message;
if (err) err.textContent = e.message;
}
});
});
// 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;
}
});
});
// Data Storage toggle
const backupToggle = overlay.querySelector("#acct-backup-toggle") as HTMLInputElement;
if (backupToggle) {
backupToggle.addEventListener("change", (e) => {
e.stopPropagation();
const enabled = backupToggle.checked;
setEncryptedBackupEnabled(enabled);
const hint = overlay.querySelector("#backup-hint") as HTMLElement;
if (hint) hint.textContent = enabled ? "Save to encrypted server" : "Save locally — you manage your own data";
this.dispatchEvent(new CustomEvent("backup-toggle", { bubbles: true, composed: true, detail: { enabled } }));
});
}
// Dark Mode toggle
const themeToggle = overlay.querySelector("#acct-theme-toggle") as HTMLInputElement;
if (themeToggle) {
themeToggle.addEventListener("change", (e) => {
e.stopPropagation();
const newTheme = themeToggle.checked ? "dark" : "light";
localStorage.setItem("canvas-theme", newTheme);
document.body.setAttribute("data-theme", newTheme);
document.querySelectorAll(".rstack-header, .rstack-tab-row").forEach(el => el.setAttribute("data-theme", newTheme));
this.dispatchEvent(new CustomEvent("theme-change", { bubbles: true, composed: true, detail: { theme: newTheme } }));
this.#render();
});
}
};
document.body.appendChild(overlay);
render();
loadGuardians();
}
// ── Spaces modal ──
@ -1325,19 +1487,6 @@ const STYLES = `
.notif-btn--deny { background: rgba(239,68,68,0.15); color: #ef4444; }
.notif-btn--deny:hover:not(:disabled) { background: rgba(239,68,68,0.25); }
/* Submenu accordion */
.submenu {
max-height: 0; overflow: hidden;
transition: max-height 0.2s ease-out;
}
.submenu.open { max-height: 300px; }
.submenu-item { padding-left: 32px !important; font-size: 0.825rem; }
.submenu-toggle { position: relative; }
.submenu-arrow {
position: absolute; right: 16px;
font-size: 0.7rem; transition: transform 0.2s;
}
/* Toggle switch */
.toggle-row {
display: flex; align-items: center;
@ -1464,6 +1613,77 @@ const SETTINGS_STYLES = `
.threshold-hint { color: #64748b; font-size: 0.8rem; }
`;
const ACCOUNT_MODAL_STYLES = `
.rstack-account-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px); display: flex; align-items: center;
justify-content: center; z-index: 10000; animation: fadeIn 0.2s;
}
.account-modal {
background: #1e293b; border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px; padding: 2rem; max-width: 520px; width: 92%;
max-height: 85vh; overflow-y: auto; color: white; position: relative;
box-shadow: 0 20px 60px rgba(0,0,0,0.4); animation: slideUp 0.3s;
text-align: left;
}
.account-modal h2 {
font-size: 1.5rem; margin-bottom: 1rem;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.account-section {
border: 1px solid rgba(255,255,255,0.08); border-radius: 10px;
margin-bottom: 8px; overflow: hidden; transition: border-color 0.2s;
}
.account-section:hover { border-color: rgba(255,255,255,0.15); }
.account-section.open { border-color: rgba(6,182,212,0.3); }
.account-section-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; cursor: pointer; font-size: 0.9rem; font-weight: 500;
transition: background 0.15s; user-select: none;
}
.account-section-header:hover { background: rgba(255,255,255,0.04); }
.section-arrow { font-size: 0.7rem; color: #64748b; transition: transform 0.2s; }
.account-section-body {
padding: 0 16px 16px; animation: fadeIn 0.15s;
}
.account-section--inline {
border: 1px solid rgba(255,255,255,0.08); border-radius: 10px;
margin-bottom: 8px; padding: 4px 0;
}
.account-section--inline .account-section-header { cursor: default; }
.account-section--inline .account-section-header:hover { background: none; }
.toggle-hint {
padding: 0 16px 10px; font-size: 0.75rem; color: #64748b; line-height: 1.4;
}
.guardian-piece { font-size: 1.1rem; flex-shrink: 0; }
.address-form {
display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px;
}
.address-form .input { margin-bottom: 0; }
.address-row { display: flex; gap: 8px; }
.address-row .input { flex: 1; margin-bottom: 0; }
/* Toggle switch (duplicated for body-level modal) */
.toggle-switch {
position: relative; width: 36px; height: 20px;
display: inline-block; flex-shrink: 0;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0; border-radius: 10px;
background: rgba(255,255,255,0.15); cursor: pointer;
transition: background 0.2s;
}
.toggle-slider::before {
content: ""; position: absolute;
width: 16px; height: 16px; border-radius: 50%;
left: 2px; bottom: 2px; background: white;
transition: transform 0.2s;
}
.toggle-switch input:checked + .toggle-slider { background: #059669; }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
`;
const SPACES_STYLES = `
.rstack-spaces-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);