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 = `
+
+
+
+
My Account
+
+ ${renderEmailSection()}
+ ${renderDeviceSection()}
+ ${renderRecoverySection()}
+ ${renderAddressSection()}
+
+
+
+
${backupEnabled ? "Save to encrypted server" : "Save locally — you manage your own data"}
+
+
+
+
+
+
+
+
+ `;
+ attachListeners();
+ };
+
+ const renderEmailSection = () => {
+ const isOpen = openSection === "email";
+ let body = "";
+ if (isOpen) {
+ if (emailStep === "input") {
+ body = `
+
+
Link an email for notifications and account recovery.
+
+
+
+
+
+
`;
+ } else {
+ body = `
+
+
Enter the 6-digit code sent to ${emailAddr.replace(/
+
+
+
+
+
+
+
`;
+ }
+ }
+ return `
+
+
+ ${body}
+
`;
+ };
+
+ const renderDeviceSection = () => {
+ const isOpen = openSection === "device";
+ const body = isOpen ? `
+
+
Register an additional passkey for backup access.
+
+
+
+
+
Each device you register can independently sign in to your account.
+
` : "";
+ return `
+
+
+ ${body}
+
`;
+ };
+
+ const renderRecoverySection = () => {
+ const isOpen = openSection === "recovery";
+ let body = "";
+ if (isOpen) {
+ if (guardiansLoading) {
+ body = ``;
+ } else {
+ const guardiansHTML = guardians.length > 0
+ ? `` : "";
+
+ 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 = `
+
+
Choose trusted contacts who can help recover your account.
+ ${guardians.length < 3 ? `
+
+
+
+
` : ""}
+ ${guardiansHTML}
+ ${infoHTML}
+
+
`;
+ }
+ }
+ return `
+
+
+ ${body}
+
`;
+ };
+
+ const renderAddressSection = () => {
+ const isOpen = openSection === "address";
+ let body = "";
+ if (isOpen) {
+ if (addressesLoading) {
+ body = ``;
+ } else {
+ const listHTML = addresses.length > 0
+ ? `` : "";
+
+ body = `
+ `;
+ }
+ }
+ return `
+
+
+ ${body}
+
`;
+ };
+
+ 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
- ? ``
- : "";
-
- 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);