/** * — Custom element for EncryptID sign-in/sign-out. * * Renders either a "Sign In" button or the user avatar + dropdown. * Contains the full WebAuthn auth modal (sign-in + register). * Refactored from lib/rspace-header.ts into a standalone web component. */ const SESSION_KEY = "encryptid_session"; const ENCRYPTID_URL = "https://auth.rspace.online"; interface SessionState { accessToken: string; claims: { sub: string; exp: number; username?: string; did?: string; eid: { authLevel: number; capabilities: { encrypt: boolean; sign: boolean; wallet: boolean }; }; }; } // ── Session helpers (exported for use by other code) ── export function getSession(): SessionState | null { try { const stored = localStorage.getItem(SESSION_KEY); if (!stored) return null; const session = JSON.parse(stored) as SessionState; if (Math.floor(Date.now() / 1000) >= session.claims.exp) { localStorage.removeItem(SESSION_KEY); localStorage.removeItem("rspace-username"); return null; } return session; } catch { return null; } } export function clearSession(): void { localStorage.removeItem(SESSION_KEY); localStorage.removeItem("rspace-username"); } export function isAuthenticated(): boolean { return getSession() !== null; } export function getAccessToken(): string | null { return getSession()?.accessToken ?? null; } export function getUserDID(): string | null { return getSession()?.claims.did ?? getSession()?.claims.sub ?? null; } export function getUsername(): string | null { return getSession()?.claims.username ?? null; } // ── Helpers ── function base64urlToBuffer(b64url: string): ArrayBuffer { const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); const pad = "=".repeat((4 - (b64.length % 4)) % 4); const bin = atob(b64 + pad); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes.buffer; } function bufferToBase64url(buf: ArrayBuffer): string { const bytes = new Uint8Array(buf); let bin = ""; for (let i = 0; i < bytes.byteLength; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } function parseJWT(token: string): Record { const parts = token.split("."); if (parts.length < 2) return {}; try { const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); const pad = "=".repeat((4 - (b64.length % 4)) % 4); return JSON.parse(atob(b64 + pad)); } catch { return {}; } } function storeSession(token: string, username: string, did: string): void { const payload = parseJWT(token) as Record; const session: SessionState = { accessToken: token, claims: { sub: payload.sub || "", exp: payload.exp || 0, username, did, eid: payload.eid || { authLevel: 3, capabilities: { encrypt: true, sign: true, wallet: false } }, }, }; localStorage.setItem(SESSION_KEY, JSON.stringify(session)); if (username) localStorage.setItem("rspace-username", username); } // ── Auto-space resolution after auth ── function autoResolveSpace(token: string, username: string): void { if (!username) return; // Detect current space const currentSpace = _getCurrentSpace(); if (currentSpace !== "demo") return; // Already on a non-demo space // Provision personal space and redirect fetch("/api/spaces/auto-provision", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, }) .then((r) => r.json()) .then((data) => { if (!data.slug) return; const moduleId = _getCurrentModule(); window.location.replace(_navUrl(data.slug, moduleId)); }) .catch(() => {}); } // ── Inline URL helpers (avoid import cycle with url-helpers) ── const _RESERVED = ["www", "rspace", "create", "new", "start", "auth"]; function _isSubdomain(): boolean { const p = window.location.host.split(":")[0].split("."); return p.length >= 3 && p.slice(-2).join(".") === "rspace.online" && !_RESERVED.includes(p[0]); } function _getCurrentSpace(): string { if (_isSubdomain()) return window.location.host.split(":")[0].split(".")[0]; return window.location.pathname.split("/").filter(Boolean)[0] || "demo"; } function _getCurrentModule(): string { const parts = window.location.pathname.split("/").filter(Boolean); return _isSubdomain() ? (parts[0] || "canvas") : (parts[1] || "canvas"); } function _navUrl(space: string, moduleId: string): string { const h = window.location.host.split(":")[0].split("."); const onSub = h.length >= 3 && h.slice(-2).join(".") === "rspace.online" && !_RESERVED.includes(h[0]); if (onSub) { if (h[0] === space) return "/" + moduleId; return window.location.protocol + "//" + space + "." + h.slice(-2).join(".") + "/" + moduleId; } if (window.location.host.includes("rspace.online") && !window.location.host.startsWith("www")) { return window.location.protocol + "//" + space + ".rspace.online/" + moduleId; } return "/" + space + "/" + moduleId; } // ── The custom element ── export class RStackIdentity extends HTMLElement { #shadow: ShadowRoot; constructor() { super(); this.#shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.#render(); } #render() { const session = getSession(); const theme = this.closest("[data-theme]")?.getAttribute("data-theme") || "light"; if (session) { const username = session.claims.username || ""; const did = session.claims.did || session.claims.sub; const displayName = username || (did.length > 24 ? did.slice(0, 16) + "..." + did.slice(-6) : did); const initial = username ? username[0].toUpperCase() : did.slice(8, 10).toUpperCase(); this.#shadow.innerHTML = `
${initial}
${displayName}
`; const toggle = this.#shadow.getElementById("user-toggle")!; const dropdown = this.#shadow.getElementById("dropdown")!; toggle.addEventListener("click", (e) => { e.stopPropagation(); dropdown.classList.toggle("open"); }); document.addEventListener("click", () => dropdown.classList.remove("open")); this.#shadow.querySelectorAll("[data-action]").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); const action = (el as HTMLElement).dataset.action; dropdown.classList.remove("open"); if (action === "signout") { clearSession(); 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 { this.#shadow.innerHTML = ` `; this.#shadow.getElementById("signin-btn")!.addEventListener("click", () => { this.showAuthModal(); }); } } /** Public method: show the auth modal programmatically */ showAuthModal(callbacks?: { onSuccess?: () => void; onCancel?: () => void }): void { if (document.querySelector(".rstack-auth-overlay")) return; const overlay = document.createElement("div"); overlay.className = "rstack-auth-overlay"; let mode: "signin" | "register" = "signin"; const render = () => { overlay.innerHTML = mode === "signin" ? signinHTML() : registerHTML(); attachListeners(); }; const signinHTML = () => `

Sign up / Sign in

Secure, passwordless authentication powered by passkeys.

Powered by EncryptID
`; const registerHTML = () => `

Create your EncryptID

Set up a secure, passwordless identity.

Powered by EncryptID
`; const close = () => { overlay.remove(); }; const handleSignIn = async () => { const errEl = overlay.querySelector("#auth-error") as HTMLElement; const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement; errEl.textContent = ""; btn.disabled = true; btn.innerHTML = ' Authenticating...'; try { const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); if (!startRes.ok) throw new Error("Failed to start authentication"); const { options: serverOptions } = await startRes.json(); const credential = (await navigator.credentials.get({ publicKey: { challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)), rpId: serverOptions.rpId || "rspace.online", userVerification: "required", timeout: 60000, }, })) as PublicKeyCredential; if (!credential) throw new Error("Authentication failed"); const completeRes = await fetch(`${ENCRYPTID_URL}/api/auth/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ challenge: serverOptions.challenge, credential: { credentialId: bufferToBase64url(credential.rawId) }, }), }); const data = await completeRes.json(); if (!completeRes.ok || !data.success) throw new Error(data.error || "Authentication failed"); storeSession(data.token, data.username || "", data.did || ""); close(); this.#render(); this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); callbacks?.onSuccess?.(); // Auto-redirect to personal space autoResolveSpace(data.token, data.username || ""); } catch (err: any) { btn.disabled = false; btn.innerHTML = "🔑 Sign In with Passkey"; errEl.textContent = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed."; } }; const handleRegister = async () => { const usernameInput = overlay.querySelector("#auth-username") as HTMLInputElement; const errEl = overlay.querySelector("#auth-error") as HTMLElement; const btn = overlay.querySelector('[data-action="register"]') as HTMLButtonElement; const username = usernameInput.value.trim(); if (!username) { errEl.textContent = "Please enter a username."; usernameInput.focus(); return; } errEl.textContent = ""; btn.disabled = true; btn.innerHTML = ' Creating passkey...'; try { const startRes = await fetch(`${ENCRYPTID_URL}/api/register/start`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, displayName: username }), }); if (!startRes.ok) throw new Error("Failed to start registration"); const { options: serverOptions, userId } = await startRes.json(); 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)), name: username, displayName: username }, 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("Failed to create credential"); const response = credential.response as AuthenticatorAttestationResponse; const publicKey = response.getPublicKey?.(); const completeRes = await fetch(`${ENCRYPTID_URL}/api/register/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ challenge: serverOptions.challenge, credential: { credentialId: bufferToBase64url(credential.rawId), publicKey: publicKey ? bufferToBase64url(publicKey) : "", transports: response.getTransports?.() || [], }, userId, username, }), }); const data = await completeRes.json(); if (!completeRes.ok || !data.success) throw new Error(data.error || "Registration failed"); storeSession(data.token, username, data.did || ""); close(); this.#render(); this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); callbacks?.onSuccess?.(); // Auto-redirect to personal space autoResolveSpace(data.token, username); } catch (err: any) { btn.disabled = false; btn.innerHTML = "🔐 Create Passkey"; errEl.textContent = err.name === "NotAllowedError" ? "Passkey creation was cancelled." : err.message || "Registration failed."; } }; const attachListeners = () => { overlay.querySelector('[data-action="cancel"]')?.addEventListener("click", () => { close(); callbacks?.onCancel?.(); }); overlay.querySelector('[data-action="signin"]')?.addEventListener("click", handleSignIn); overlay.querySelector('[data-action="register"]')?.addEventListener("click", handleRegister); overlay.querySelector('[data-action="switch-register"]')?.addEventListener("click", () => { mode = "register"; render(); setTimeout(() => (overlay.querySelector("#auth-username") as HTMLInputElement)?.focus(), 50); }); overlay.querySelector('[data-action="switch-signin"]')?.addEventListener("click", () => { mode = "signin"; render(); }); overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter") handleRegister(); }); overlay.addEventListener("click", (e) => { if (e.target === overlay) { close(); callbacks?.onCancel?.(); } }); }; document.body.appendChild(overlay); render(); } // ── Settings modals ── #showAddEmailModal(): void { if (document.querySelector(".rstack-auth-overlay")) return; const overlay = document.createElement("div"); overlay.className = "rstack-auth-overlay"; let step: "input" | "verify" = "input"; let emailAddr = ""; 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 close = () => overlay.remove(); const attach = () => { overlay.querySelector('[data-action="close"]')?.addEventListener("click", close); overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); 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 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; } btn.disabled = true; btn.innerHTML = ' Sending...'; try { const res = await fetch(`${ENCRYPTID_URL}/api/account/email/start`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` }, 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); } 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="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; const code = input.value.trim(); if (!code) { err.textContent = "Enter the verification code."; input.focus(); return; } btn.disabled = true; btn.innerHTML = ' Verifying...'; try { const res = await fetch(`${ENCRYPTID_URL}/api/account/email/verify`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` }, body: JSON.stringify({ email: emailAddr, code }), }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Verification failed"); close(); this.dispatchEvent(new CustomEvent("identity-action", { bubbles: true, composed: true, detail: { action: "email-added", email: emailAddr } })); } catch (e: any) { btn.disabled = false; btn.innerHTML = "Verify"; err.textContent = e.message; } }); overlay.querySelector("#s-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(); }); }; document.body.appendChild(overlay); render(); } #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, }, 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"); 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"); 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; } } catch { /* offline */ } loading = false; render(); }; const attach = () => { overlay.querySelector('[data-action="close"]')?.addEventListener("click", close); overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); 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 btn = overlay.querySelector('[data-action="add-guardian"]') as HTMLButtonElement; const name = nameInput.value.trim(); const email = emailInput.value.trim(); if (!name) { err.textContent = "Enter a guardian name."; nameInput.focus(); return; } err.textContent = ""; btn.disabled = true; btn.innerHTML = ''; try { const res = await fetch(`${ENCRYPTID_URL}/api/guardians`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` }, body: JSON.stringify({ name, email: email || undefined }), }); const data = await res.json(); 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); } catch (e: any) { btn.disabled = false; btn.innerHTML = "Add"; err.textContent = e.message; } }); overlay.querySelector("#s-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) => { if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-guardian"]') as HTMLElement)?.click(); }); overlay.querySelectorAll("[data-remove-id]").forEach(el => { el.addEventListener("click", async () => { const id = (el as HTMLElement).dataset.removeId!; const err = overlay.querySelector("#s-error") as HTMLElement; try { const res = await fetch(`${ENCRYPTID_URL}/api/guardians/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${getAccessToken()}` }, }); if (!res.ok) throw new Error("Failed to remove guardian"); guardians = guardians.filter(g => g.id !== id); render(); } catch (e: any) { err.textContent = e.message; } }); }); }; document.body.appendChild(overlay); render(); loadGuardians(); } static define(tag = "rstack-identity") { if (!customElements.get(tag)) customElements.define(tag, RStackIdentity); } } // ── Require auth helper (for use by module code) ── export function requireAuth(onAuthenticated: () => void): boolean { if (isAuthenticated()) return true; const el = document.querySelector("rstack-identity") as RStackIdentity | null; if (el) { el.showAuthModal({ onSuccess: onAuthenticated }); } return false; } // ── Styles ── const STYLES = ` :host { display: contents; } .signin-btn { display: flex; align-items: center; gap: 8px; padding: 8px 20px; border-radius: 8px; border: none; font-size: 0.875rem; font-weight: 600; cursor: pointer; transition: all 0.2s; text-decoration: none; } .signin-btn.light { background: linear-gradient(135deg, #06b6d4, #0891b2); color: white; } .signin-btn.dark { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; } .signin-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); } .user { display: flex; align-items: center; gap: 10px; position: relative; cursor: pointer; } .avatar { width: 34px; height: 34px; border-radius: 50%; background: linear-gradient(135deg, #06b6d4, #7c3aed); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.8rem; color: white; } .name { font-size: 0.8rem; max-width: 140px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .user.light .name { color: #64748b; } .user.dark .name { color: #94a3b8; } .dropdown { position: absolute; top: 100%; right: 0; margin-top: 8px; min-width: 200px; border-radius: 10px; overflow: hidden; box-shadow: 0 8px 30px rgba(0,0,0,0.2); display: none; z-index: 100; } .dropdown.open { display: block; } .user.light .dropdown { background: white; border: 1px solid rgba(0,0,0,0.1); } .user.dark .dropdown { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); } .dropdown-item { display: flex; align-items: center; gap: 10px; padding: 12px 16px; font-size: 0.875rem; cursor: pointer; transition: background 0.15s; border: none; background: none; width: 100%; text-align: left; } .user.light .dropdown-item { color: #374151; } .user.light .dropdown-item:hover { background: #f1f5f9; } .user.dark .dropdown-item { color: #e2e8f0; } .user.dark .dropdown-item:hover { background: rgba(255,255,255,0.05); } .dropdown-item--danger { color: #ef4444 !important; } .dropdown-header { padding: 10px 16px 6px; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; } .user.light .dropdown-header { color: #1e293b; } .user.dark .dropdown-header { color: #e2e8f0; } .dropdown-divider { height: 1px; margin: 4px 0; } .user.light .dropdown-divider { background: rgba(0,0,0,0.08); } .user.dark .dropdown-divider { background: rgba(255,255,255,0.08); } `; const MODAL_STYLES = ` .rstack-auth-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; } .auth-modal { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2rem; max-width: 420px; width: 90%; text-align: center; color: white; box-shadow: 0 20px 60px rgba(0,0,0,0.4); animation: slideUp 0.3s; } .auth-modal h2 { font-size: 1.5rem; margin-bottom: 0.5rem; background: linear-gradient(135deg, #06b6d4, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .auth-modal p { color: #94a3b8; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; } .input { width: 100%; padding: 12px 16px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); color: white; font-size: 1rem; margin-bottom: 1rem; outline: none; transition: border-color 0.2s; box-sizing: border-box; } .input:focus { border-color: #06b6d4; } .input::placeholder { color: #64748b; } .actions { display: flex; gap: 12px; margin-top: 0.5rem; } .btn { flex: 1; padding: 12px 20px; border-radius: 8px; border: none; font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s; } .btn--primary { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; } .btn--primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); } .btn--primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .btn--secondary { background: rgba(255,255,255,0.08); color: #94a3b8; border: 1px solid rgba(255,255,255,0.1); } .btn--secondary:hover { background: rgba(255,255,255,0.12); color: white; } .error { color: #ef4444; font-size: 0.85rem; margin-top: 0.5rem; min-height: 1.2em; } .toggle { margin-top: 1rem; font-size: 0.85rem; color: #64748b; } .toggle a { color: #06b6d4; cursor: pointer; text-decoration: none; } .toggle a:hover { text-decoration: underline; } .spinner { display: inline-block; width: 18px; height: 18px; border: 2px solid transparent; border-top-color: currentColor; border-radius: 50%; animation: spin 0.7s linear infinite; vertical-align: middle; margin-right: 6px; } .close-btn { position: absolute; top: 12px; right: 16px; background: none; border: none; color: #64748b; font-size: 1.5rem; cursor: pointer; line-height: 1; padding: 4px 8px; border-radius: 6px; transition: all 0.15s; } .close-btn:hover { color: white; background: rgba(255,255,255,0.1); } .auth-modal { position: relative; } .actions--stack { flex-direction: column; } .btn--outline { background: transparent; color: #94a3b8; border: 1px solid rgba(255,255,255,0.15); padding: 12px 20px; border-radius: 8px; font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s; } .btn--outline:hover { border-color: #06b6d4; color: white; background: rgba(6,182,212,0.08); } .learn-more { margin-top: 1.5rem; font-size: 0.8rem; color: #475569; } .learn-more a { color: #06b6d4; text-decoration: none; } .learn-more a:hover { text-decoration: underline; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } @keyframes spin { to { transform: rotate(360deg); } } `; const SETTINGS_STYLES = ` .info-text { margin-top: 1rem; font-size: 0.8rem; color: #475569; line-height: 1.5; } .btn--success { background: #059669 !important; color: white; cursor: default; } .btn--small { padding: 10px 16px; flex: none; } .input-row { display: flex; gap: 8px; align-items: stretch; } .input--inline { flex: 1; margin-bottom: 0; } .contact-list { margin-top: 12px; display: flex; flex-direction: column; gap: 6px; } .contact-item { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: 8px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); font-size: 0.9rem; color: #e2e8f0; } .contact-remove { background: none; border: none; color: #64748b; font-size: 1.2rem; cursor: pointer; padding: 2px 6px; border-radius: 4px; line-height: 1; } .contact-remove:hover { color: #ef4444; background: rgba(239,68,68,0.1); } .threshold-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 0.85rem; color: #94a3b8; } .threshold-row label { white-space: nowrap; } .threshold-row select { padding: 6px 10px; border-radius: 6px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); color: white; font-size: 0.85rem; } .threshold-hint { color: #64748b; font-size: 0.8rem; } `;