From 88d618c1afd4da2a0a8beb6df6242c8f559e4831 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 15:22:13 -0800 Subject: [PATCH] feat: replace My Account submenu with consolidated popup modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- shared/components/rstack-identity.ts | 800 +++++++++++++++++---------- 1 file changed, 510 insertions(+), 290 deletions(-) diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 7781f22..31313b3 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -413,29 +413,7 @@ export class RStackIdentity extends HTMLElement { ${notifsHTML} - - + @@ -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 = ` @@ -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" ? ` - -
- -

Add Email

-

Link an email for notifications and account recovery.

- -
- -
-
-
- ` : ` - -
- -

Verify Email

-

Enter the 6-digit code sent to ${emailAddr.replace(/

- -
- - -
-
-
- `; - 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 = ` + + + `; + attachListeners(); + }; + + const renderEmailSection = () => { + const isOpen = openSection === "email"; + let body = ""; + if (isOpen) { + if (emailStep === "input") { + body = ` + `; + } else { + body = ` + `; + } + } + return ` + `; + }; + + const renderDeviceSection = () => { + const isOpen = openSection === "device"; + const body = isOpen ? ` + ` : ""; + return ` + `; + }; + + const renderRecoverySection = () => { + const isOpen = openSection === "recovery"; + let body = ""; + if (isOpen) { + if (guardiansLoading) { + body = ``; + } else { + const guardiansHTML = guardians.length > 0 + ? `
${guardians.map(g => ` +
+
+ 🧩 +
+ ${g.name.replace(/${g.email.replace(/` : ""} + ${g.status === "accepted" ? "Accepted" : "Pending invite"} +
+
+ +
+ `).join("")}
` : ""; + + const infoHTML = guardians.length < 2 + ? `
Add at least 2 trusted guardians to enable social recovery. Threshold: ${guardiansThreshold} of ${Math.max(guardians.length, 2)} needed to recover.
` + : `
Social recovery is active. ${guardiansThreshold} of ${guardians.length} guardians needed to recover your account.
`; + + body = ` + `; + } + } + return ` + `; + }; + + const renderAddressSection = () => { + const isOpen = openSection === "address"; + let body = ""; + if (isOpen) { + if (addressesLoading) { + body = ``; + } else { + const listHTML = addresses.length > 0 + ? `
${addresses.map(a => ` +
+
+ ${a.street.replace(/ + ${a.city.replace(/ +
+ +
+ `).join("")}
` : ""; + + body = ` + `; + } + } + return ` + `; + }; + + 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 = ' 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 = ' 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 = ` - -
- -

Add Second Device

-

Register an additional passkey for backup access. Use this on a different device or browser.

-
- -
-
-
Each device you register can independently sign in to your account.
-
- `; - - 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 = ' 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 - ? `
${guardians.map(g => ` -
-
- ${g.name.replace(/${g.email.replace(/` : ""} - ${g.status === "accepted" ? "Accepted" : "Pending invite"} -
- -
- `).join("")}
` - : ""; - - const infoHTML = guardians.length < 2 - ? `
Add at least 2 trusted guardians to enable social recovery. Threshold: ${threshold} of ${Math.max(guardians.length, 2)} needed to recover.
` - : `
Social recovery is active. ${threshold} of ${guardians.length} guardians needed to recover your account.
`; - - overlay.innerHTML = ` - -
- -

Social Recovery

-

Choose trusted contacts who can help recover your account.

- ${loading ? '
Loading guardians...
' : ` - ${guardians.length < 3 ? `
- - - -
` : ""} - ${guardiansHTML} - ${infoHTML} - `} -
-
- `; - 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 = ' 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);