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:
parent
25174b87e6
commit
88d618c1af
|
|
@ -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">×</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">×</button>
|
||||
<h2>Verify Email</h2>
|
||||
<p>Enter the 6-digit code sent to <strong>${emailAddr.replace(/</g, "<")}</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">×</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, "<")}</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, "<")}${g.email ? ` <span style="opacity:0.5;font-size:0.8rem">${g.email.replace(/</g, "<")}</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}">×</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, "<")}</span>
|
||||
<span style="color:#94a3b8">${a.city.replace(/</g, "<")}, ${a.state.replace(/</g, "<")} ${a.zip.replace(/</g, "<")} ${a.country.replace(/</g, "<")}</span>
|
||||
</div>
|
||||
<button class="contact-remove" data-remove-address="${a.id}">×</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">×</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, "<")}${g.email ? ` <span style="opacity:0.5;font-size:0.8rem">${g.email.replace(/</g, "<")}</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}">×</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">×</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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue