rspace-online/shared/components/rstack-identity.ts

2195 lines
86 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <rstack-identity> — 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.
*/
import { rspaceNavUrl, getCurrentModule, getCurrentSpace } from "../url-helpers";
import { resetDocBridge, isEncryptedBackupEnabled, setEncryptedBackupEnabled } from "../local-first/encryptid-bridge";
import { getShortcuts, setShortcut, removeShortcut } from "../shortcut-config";
const SESSION_KEY = "encryptid_session";
const ENCRYPTID_URL = "https://auth.rspace.online";
const COOKIE_NAME = "eid_token";
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 days (matches server sessionDuration)
interface SessionState {
accessToken: string;
claims: {
sub: string;
exp: number;
username?: string;
did?: string;
eid: {
walletAddress?: string;
authLevel: number;
capabilities: { encrypt: boolean; sign: boolean; wallet: boolean };
};
};
}
// ── EIP-6963 browser wallet discovery ──
interface _EIP6963Provider {
info: { uuid: string; name: string; icon: string; rdns: string };
provider: { request: (args: { method: string; params?: unknown[] }) => Promise<unknown> };
}
class _WalletDiscovery {
providers: _EIP6963Provider[] = [];
#cb: (() => void) | null = null;
start(onChange: () => void) {
this.#cb = onChange;
window.addEventListener("eip6963:announceProvider", ((e: CustomEvent) => {
const detail = e.detail as _EIP6963Provider;
if (!this.providers.some((p) => p.info.uuid === detail.info.uuid)) {
this.providers.push(detail);
this.#cb?.();
}
}) as EventListener);
window.dispatchEvent(new Event("eip6963:requestProvider"));
}
stop() { this.#cb = null; }
}
// ── Cross-subdomain cookie helpers ──
function _isRspace(): boolean {
return window.location.hostname === "rspace.online" || window.location.hostname.endsWith(".rspace.online");
}
function _setSessionCookie(token: string): void {
try {
const domain = _isRspace() ? "; domain=.rspace.online" : "";
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(token)}; path=/${domain}; max-age=${COOKIE_MAX_AGE}; SameSite=Lax${secure}`;
} catch { /* ignore */ }
}
function _getSessionCookie(): string | null {
try {
const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE_NAME}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
} catch { return null; }
}
function _removeSessionCookie(): void {
try {
const domain = _isRspace() ? "; domain=.rspace.online" : "";
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${COOKIE_NAME}=; path=/${domain}; max-age=0; SameSite=Lax${secure}`;
} catch { /* ignore */ }
}
// ── Early cookie→localStorage sync ──
// Runs at module load time so code that directly reads localStorage
// (e.g. folk-canvas WebSocket auth, sync.ts, shell inline scripts)
// sees the session even on a new subdomain.
(function _syncCookieToLocalStorage() {
try {
if (localStorage.getItem(SESSION_KEY)) return; // already have it
const cookieToken = _getSessionCookie();
if (!cookieToken) return;
const payload = parseJWT(cookieToken);
if (!payload.exp || Math.floor(Date.now() / 1000) >= (payload.exp as number)) return;
const session: SessionState = {
accessToken: cookieToken,
claims: {
sub: (payload.sub as string) || "",
exp: (payload.exp as number) || 0,
username: (payload.username as string) || "",
did: (payload.did as string) || "",
eid: (payload.eid as any) || { authLevel: 1, capabilities: { encrypt: true, sign: true, wallet: false } },
},
};
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
if (session.claims.username) localStorage.setItem("rspace-username", session.claims.username);
} catch { /* ignore */ }
})();
// ── Session helpers (exported for use by other code) ──
export function getSession(): SessionState | null {
try {
// 1. Try localStorage first (fast path, same origin)
const stored = localStorage.getItem(SESSION_KEY);
if (stored) {
const session = JSON.parse(stored) as SessionState;
if (Math.floor(Date.now() / 1000) < session.claims.exp) {
return session;
}
// Expired in localStorage — clear it but don't give up yet
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem("rspace-username");
}
// 2. Fall back to cross-subdomain cookie (handles navigation between subdomains)
const cookieToken = _getSessionCookie();
if (cookieToken) {
const payload = parseJWT(cookieToken);
if (payload.exp && Math.floor(Date.now() / 1000) < (payload.exp as number)) {
// Valid token in cookie — restore to localStorage for fast access
const session: SessionState = {
accessToken: cookieToken,
claims: {
sub: (payload.sub as string) || "",
exp: (payload.exp as number) || 0,
username: (payload.username as string) || "",
did: (payload.did as string) || "",
eid: (payload.eid as any) || { authLevel: 1, capabilities: { encrypt: true, sign: true, wallet: false } },
},
};
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
if (session.claims.username) localStorage.setItem("rspace-username", session.claims.username);
return session;
}
// Cookie token expired too — don't clear it yet; #refreshIfNeeded will try server refresh
}
return null;
} catch {
return null;
}
}
export function clearSession(): void {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem("rspace-username");
_removeSessionCookie();
}
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<string, unknown> {
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<string, any>;
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);
_setSessionCookie(token);
if (username && did) addKnownPersona(username, did);
}
// ── Persona helpers (client-side multi-account) ──
const PERSONAS_KEY = "rspace-known-personas";
interface KnownPersona {
username: string;
did: string;
}
function getKnownPersonas(): KnownPersona[] {
try {
return JSON.parse(localStorage.getItem(PERSONAS_KEY) || "[]");
} catch { return []; }
}
function addKnownPersona(username: string, did: string): void {
const personas = getKnownPersonas();
const idx = personas.findIndex(p => p.did === did);
if (idx >= 0) {
personas[idx] = { username, did };
} else {
personas.push({ username, did });
}
localStorage.setItem(PERSONAS_KEY, JSON.stringify(personas));
}
function removeKnownPersona(did: string): void {
const personas = getKnownPersonas().filter(p => p.did !== did);
localStorage.setItem(PERSONAS_KEY, JSON.stringify(personas));
}
// ── Auto-space resolution after auth ──
function autoResolveSpace(token: string, username: string): void {
if (!username) return;
// Detect current space
const currentSpace = _getCurrentSpace();
if (currentSpace !== "demo") {
// User followed a link to a specific space (e.g., orgname.rspace.online).
// Provision their personal space silently, then reload to apply the
// authenticated session (clears access gates, reconnects CRDT sync, etc.).
autoProvisionSpace(token);
window.location.reload();
return;
}
// On demo/landing — provision personal space and redirect there
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(() => {});
}
// ── Silent provisioning (no redirect) — ensures user's space exists ──
function autoProvisionSpace(token: string): void {
fetch("/api/spaces/auto-provision", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}).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] || "rspace") : (parts[1] || "rspace");
}
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.#refreshIfNeeded();
this.#render();
// If a session already exists on page load, provision the
// user's personal space in the background (but do NOT redirect —
// the user intentionally navigated to this space).
const session = getSession();
if (session?.accessToken && session.claims.username) {
autoProvisionSpace(session.accessToken);
}
// Propagate login/logout across tabs via storage events
window.addEventListener("storage", this.#onStorageChange);
}
disconnectedCallback() {
window.removeEventListener("storage", this.#onStorageChange);
}
#onStorageChange = (e: StorageEvent) => {
if (e.key === "encryptid_session" || e.key === PERSONAS_KEY) {
this.#render();
if (e.key === "encryptid_session") {
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
}
}
};
async #refreshIfNeeded() {
let session = getSession();
let token = session?.accessToken ?? null;
let username = session?.claims.username || "";
let did = session?.claims.did || "";
// If no valid session in localStorage/cookie, check for an expired cookie token
// we can try to refresh (server accepts expired tokens for refresh)
if (!session) {
const cookieToken = _getSessionCookie();
if (!cookieToken) return; // no token at all — truly logged out
const payload = parseJWT(cookieToken);
token = cookieToken;
username = (payload.username as string) || "";
did = (payload.did as string) || "";
}
// If session is valid with >7 days remaining, no refresh needed
if (session) {
const remaining = session.claims.exp - Math.floor(Date.now() / 1000);
if (remaining >= 7 * 24 * 60 * 60) return;
}
// Token is expired or nearing expiry — try server refresh
try {
const res = await fetch(`${ENCRYPTID_URL}/api/session/refresh`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
// Server rejected — token is truly dead, clean up
if (!session) { _removeSessionCookie(); }
return;
}
const refreshData = await res.json();
const newToken = refreshData.token;
if (newToken) {
const payload = parseJWT(newToken);
storeSession(newToken, (payload.username as string) || refreshData.username || username, (payload.did as string) || did);
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
}
} catch { /* offline — keep whatever we have */ }
}
#render() {
const session = getSession();
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();
const currentDid = session.claims.did || "";
const otherPersonas = getKnownPersonas().filter(p => p.did !== currentDid);
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<div class="user" id="user-toggle">
<div class="avatar-wrap">
<div class="avatar">${initial}</div>
</div>
<span class="name">${displayName}</span>
<div class="dropdown" id="dropdown">
<div class="dropdown-header">${displayName}</div>
${otherPersonas.length > 0 ? `
<div class="dropdown-divider"></div>
<div class="dropdown-label">Switch Persona</div>
${otherPersonas.map(p => `
<div class="persona-row">
<button class="dropdown-item persona-item" data-action="switch-persona" data-did="${p.did}">
<div class="persona-avatar">${p.username[0].toUpperCase()}</div>
<span>${p.username}</span>
</button>
<button class="persona-remove" data-action="remove-persona" data-did="${p.did}">&times;</button>
</div>
`).join("")}
` : ""}
<div class="dropdown-divider"></div>
<button class="dropdown-item" data-action="add-persona"> Add Persona</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item" data-action="my-account">👤 My Account</button>
<button class="dropdown-item" data-action="my-spaces">🌐 My Spaces</button>
<button class="dropdown-item" data-action="my-wallets">💰 My Wallets</button>
<div class="dropdown-divider mobile-only"></div>
<button class="dropdown-item mobile-only" data-action="notifications">🔔 Notifications</button>
<button class="dropdown-item mobile-only" data-action="share">📤 Share</button>
<button class="dropdown-item mobile-only" data-action="settings">⚙️ Space Settings</button>
<div class="dropdown-divider"></div>
<div class="dropdown-theme-row">
<span class="theme-icon">☀️</span>
<label class="theme-toggle">
<input type="checkbox" id="dropdown-theme-toggle" ${(localStorage.getItem("canvas-theme") || "dark") === "dark" ? "checked" : ""} />
<span class="theme-slider"></span>
</label>
<span class="theme-icon">🌙</span>
</div>
<div class="dropdown-canvas-row">
<span class="canvas-label">Canvas</span>
<div class="canvas-options" id="canvas-bg-options">
<button class="canvas-opt${(localStorage.getItem("canvas-bg") || "grid") === "grid" ? " active" : ""}" data-bg="grid">Grid</button>
<button class="canvas-opt${localStorage.getItem("canvas-bg") === "dot" ? " active" : ""}" data-bg="dot">Dot</button>
<button class="canvas-opt${localStorage.getItem("canvas-bg") === "blank" ? " active" : ""}" data-bg="blank">Blank</button>
</div>
</div>
<div class="dropdown-divider"></div>
<button class="dropdown-item dropdown-item--danger" data-action="signout">🚪 Sign Out</button>
</div>
</div>
`;
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();
resetDocBridge();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
} else if (action === "my-account") {
this.showAccountModal();
} else if (action === "my-spaces") {
this.#showSpacesModal();
} else if (action === "my-wallets") {
this.#showWalletsModal();
} else if (action === "notifications") {
(document.querySelector("rstack-notification-bell") as any)?.toggle();
} else if (action === "share") {
if (navigator.share) {
navigator.share({ url: location.href, title: document.title }).catch(() => {});
} else {
(document.querySelector("rstack-share-panel") as any)?.toggle();
}
} else if (action === "settings") {
(document.getElementById("settings-btn") as HTMLElement)?.click();
} else if (action === "switch-persona") {
const targetDid = (el as HTMLElement).dataset.did || "";
const persona = getKnownPersonas().find(p => p.did === targetDid);
if (!persona) return;
clearSession();
resetDocBridge();
this.showAuthModal({ onSuccess: () => {}, onCancel: () => { this.#render(); } }, persona.username);
} else if (action === "add-persona") {
clearSession();
resetDocBridge();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
this.showAuthModal();
} else if (action === "remove-persona") {
const targetDid = (el as HTMLElement).dataset.did || "";
removeKnownPersona(targetDid);
this.#render();
}
});
});
const dropdownTheme = this.#shadow.getElementById("dropdown-theme-toggle") as HTMLInputElement;
if (dropdownTheme) {
dropdownTheme.addEventListener("click", (e) => e.stopPropagation());
dropdownTheme.addEventListener("change", () => {
const newTheme = dropdownTheme.checked ? "dark" : "light";
localStorage.setItem("canvas-theme", newTheme);
document.documentElement.setAttribute("data-theme", newTheme);
this.dispatchEvent(new CustomEvent("theme-change", { bubbles: true, composed: true, detail: { theme: newTheme } }));
});
}
// Canvas background style selector
const canvasBgOptions = this.#shadow.getElementById("canvas-bg-options");
if (canvasBgOptions) {
canvasBgOptions.addEventListener("click", (e) => {
e.stopPropagation();
const btn = (e.target as HTMLElement).closest("[data-bg]") as HTMLElement | null;
if (!btn) return;
const bg = btn.dataset.bg!;
localStorage.setItem("canvas-bg", bg);
document.documentElement.setAttribute("data-canvas-bg", bg);
canvasBgOptions.querySelectorAll(".canvas-opt").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
window.dispatchEvent(new Event("canvas-bg-change"));
});
}
} else {
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<button class="signin-btn" id="signin-btn">🔑 Sign In</button>
`;
this.#shadow.getElementById("signin-btn")!.addEventListener("click", () => {
this.showAuthModal();
});
}
}
/** Public: check if user has an active session */
isSignedIn(): boolean {
return getSession() !== null;
}
/** Public method: show the auth modal programmatically.
* Pass usernameHint to auto-trigger passkey sign-in for a specific persona. */
showAuthModal(callbacks?: { onSuccess?: () => void; onCancel?: () => void }, usernameHint?: string): 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 = () => `
<style>${MODAL_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="cancel">&times;</button>
<h2>Sign up / Sign in</h2>
<p>Secure, passwordless authentication powered by passkeys.</p>
<div class="actions actions--stack">
<button class="btn btn--primary" data-action="signin">🔑 Sign In with Passkey</button>
<button class="btn btn--outline" data-action="switch-register">🔐 Create New Account</button>
</div>
<div class="error" id="auth-error"></div>
<div class="learn-more">Powered by <a href="https://ridentity.online" target="_blank" rel="noopener">EncryptID</a></div>
</div>
`;
const registerHTML = () => `
<style>${MODAL_STYLES}</style>
<div class="auth-modal">
<button class="close-btn" data-action="cancel">&times;</button>
<h2>Create your EncryptID</h2>
<p>Set up a secure, passwordless identity.</p>
<input class="input" id="auth-username" type="text" placeholder="Choose a username" autocomplete="username webauthn" maxlength="32" />
<div class="actions">
<button class="btn btn--secondary" data-action="switch-signin">Back</button>
<button class="btn btn--primary" data-action="register">🔐 Create Passkey</button>
</div>
<div class="error" id="auth-error"></div>
<div class="learn-more">Powered by <a href="https://ridentity.online" target="_blank" rel="noopener">EncryptID</a></div>
</div>
`;
const close = () => {
overlay.remove();
};
const handleSignIn = async () => {
const errEl = overlay.querySelector("#auth-error") as HTMLElement | null;
const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement | null;
if (errEl) errEl.textContent = "";
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Authenticating...'; }
try {
const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(usernameHint ? { username: usernameHint } : {}),
});
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) {
if (btn) { btn.disabled = false; btn.innerHTML = "🔑 Sign In with Passkey"; }
const msg = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed.";
if (errEl) errEl.textContent = msg;
// If auto-triggered persona switch was cancelled, close modal and restore previous state
if (usernameHint) { close(); this.#render(); callbacks?.onCancel?.(); }
}
};
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 = '<span class="spinner"></span> 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: { authenticatorAttachment: "platform", 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();
// If switching persona, auto-trigger sign-in immediately
if (usernameHint) {
handleSignIn();
}
}
// ── Account modal (consolidated) ──
showAccountModal(): void {
if (document.querySelector(".rstack-account-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-account-overlay";
const session = getSession();
if (!session) return;
let openSection: string | null = null;
// Account completion status
let acctStatus: { email: boolean; emailAddress?: string | null; multiDevice: boolean; socialRecovery: boolean; guardianCount: number; credentialCount: number } | 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();
// Load account completion status
const loadStatus = async () => {
try {
const res = await fetch(`${ENCRYPTID_URL}/api/account/status`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (res.ok) {
acctStatus = await res.json();
render();
}
} catch { /* offline */ }
};
loadStatus();
const statusDot = (done: boolean | null) => {
if (done === null) return ''; // still loading
return done
? '<span class="status-dot done" title="Complete"></span>'
: '<span class="status-dot pending" title="Not yet set up"></span>';
};
const render = () => {
const backupEnabled = isEncryptedBackupEnabled();
const currentTheme = localStorage.getItem("canvas-theme") || "dark";
const isDark = currentTheme === "dark";
const emailDone = acctStatus ? acctStatus.email : null;
const deviceDone = acctStatus ? acctStatus.multiDevice : null;
const recoveryDone = acctStatus ? acctStatus.socialRecovery : null;
overlay.innerHTML = `
<style>${MODAL_STYLES}${SETTINGS_STYLES}${ACCOUNT_MODAL_STYLES}</style>
<div class="account-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>My Account</h2>
${renderEmailSection()}
${renderDeviceSection()}
${renderRecoverySection()}
${renderAddressSection()}
<div class="account-section account-section--inline${!backupEnabled ? " section--warning" : ""}">
<div class="account-section-header">
<span>${statusDot(backupEnabled)} 🔒 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"
: '<span style="color:#f87171">Local only — you are responsible for your own data</span>'
}</div>
</div>
${renderShortcutsSection()}
<div class="error" id="acct-error"></div>
</div>
`;
attachListeners();
};
const renderEmailSection = () => {
const isOpen = openSection === "email";
const done = acctStatus ? acctStatus.email : null;
let body = "";
if (isOpen) {
if (emailStep === "input") {
body = `
<div class="account-section-body">
<p style="color:var(--rs-text-secondary);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:var(--rs-text-secondary);font-size:0.85rem;margin:0 0 12px">Enter the 6-digit code sent to <strong>${emailAddr.replace(/</g, "&lt;")}</strong></p>
<input class="input" id="acct-code" type="text" placeholder="000000" maxlength="6" inputmode="numeric" />
<div class="actions">
<button class="btn btn--secondary" data-action="email-back">Back</button>
<button class="btn btn--primary" data-action="verify-email">Verify</button>
</div>
<div class="error" id="email-error"></div>
</div>`;
}
}
const emailDisplay = !isOpen && done && acctStatus?.emailAddress
? `<span style="color:var(--rs-text-secondary);font-size:0.85rem;margin-left:8px">${acctStatus.emailAddress}</span>`
: "";
return `
<div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}">
<div class="account-section-header" data-section="email">
<span>${statusDot(done)} ✉️ Email${emailDisplay}</span>
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
</div>
${body}
</div>`;
};
const renderDeviceSection = () => {
const isOpen = openSection === "device";
const done = acctStatus ? acctStatus.multiDevice : null;
const body = isOpen ? `
<div class="account-section-body">
<p style="color:var(--rs-text-secondary);font-size:0.85rem;margin:0 0 12px">Register an additional passkey for backup access.${acctStatus ? ` <span style="color:var(--rs-text-muted)">(${acctStatus.credentialCount} passkey${acctStatus.credentialCount !== 1 ? "s" : ""} registered)</span>` : ""}</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" : ""}${done === false ? " section--warning" : ""}">
<div class="account-section-header" data-section="device">
<span>${statusDot(done)} 📱 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:var(--rs-text-secondary)"><span class="spinner"></span> Loading guardians...</div></div>`;
} else {
const guardiansHTML = guardians.length > 0
? `<div class="contact-list">${guardians.map(g => `
<div class="contact-item">
<div style="display:flex;align-items:center;gap:8px;min-width:0;flex:1">
<span class="guardian-piece">🧩</span>
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;flex:1">
<span>${g.name.replace(/</g, "&lt;")}${g.email ? ` <span style="opacity:0.5;font-size:0.8rem">${g.email.replace(/</g, "&lt;")}</span>` : ""}</span>
<span style="font-size:0.7rem;color:${g.status === "accepted" ? "#34d399" : "#fbbf24"}">${g.status === "accepted" ? "Accepted" : "Pending invite"}</span>
</div>
</div>
<button class="contact-remove" data-remove-guardian="${g.id}">&times;</button>
</div>
`).join("")}</div>` : "";
const infoHTML = guardians.length < 2
? `<div class="info-text">Add at least 2 trusted guardians to enable social recovery. Threshold: ${guardiansThreshold} of ${Math.max(guardians.length, 2)} needed to recover.</div>`
: `<div class="info-text" style="color:#34d399">Social recovery is active. ${guardiansThreshold} of ${guardians.length} guardians needed to recover your account.</div>`;
body = `
<div class="account-section-body">
<p style="color:var(--rs-text-secondary);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>`;
}
}
const done = acctStatus ? acctStatus.socialRecovery : null;
return `
<div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}">
<div class="account-section-header" data-section="recovery">
<span>${statusDot(done)} 🛡️ 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:var(--rs-text-secondary)"><span class="spinner"></span> Loading addresses...</div></div>`;
} else {
const listHTML = addresses.length > 0
? `<div class="contact-list">${addresses.map(a => `
<div class="contact-item">
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;flex:1;font-size:0.85rem">
<span>${a.street.replace(/</g, "&lt;")}</span>
<span style="color:var(--rs-text-secondary)">${a.city.replace(/</g, "&lt;")}, ${a.state.replace(/</g, "&lt;")} ${a.zip.replace(/</g, "&lt;")} ${a.country.replace(/</g, "&lt;")}</span>
</div>
<button class="contact-remove" data-remove-address="${a.id}">&times;</button>
</div>
`).join("")}</div>` : "";
body = `
<div class="account-section-body">
<div class="address-form">
<input class="input" id="acct-street" type="text" placeholder="Street address" />
<div class="address-row">
<input class="input" id="acct-city" type="text" placeholder="City" />
<input class="input" id="acct-state" type="text" placeholder="State" />
</div>
<div class="address-row">
<input class="input" id="acct-zip" type="text" placeholder="ZIP / Postal code" />
<input class="input" id="acct-country" type="text" placeholder="Country" />
</div>
<button class="btn btn--primary" data-action="save-address" style="align-self:flex-start">Save Address</button>
</div>
${listHTML}
<div class="error" id="address-error"></div>
</div>`;
}
}
return `
<div class="account-section${isOpen ? " open" : ""}">
<div class="account-section-header" data-section="address">
<span>🏠 Postal Address</span>
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
</div>
${body}
</div>`;
};
const renderShortcutsSection = () => {
const isOpen = openSection === "shortcuts";
let body = "";
if (isOpen) {
const shortcuts = getShortcuts();
const modules: Array<{ id: string; name: string; icon: string; hidden?: boolean }> = (window as any).__rspaceAllModules || (window as any).__rspaceModuleList || [];
const slotRows = Array.from({ length: 9 }, (_, i) => {
const slot = String(i + 1);
const current = shortcuts[slot] || "";
const options = modules
.filter(m => !m.hidden)
.map(m => `<option value="${m.id}"${m.id === current ? " selected" : ""}>${m.icon} ${m.name}</option>`)
.join("");
return `
<div class="shortcut-slot">
<span class="slot-num">${slot}</span>
<select class="slot-select" data-slot="${slot}">
<option value="">None</option>
${options}
</select>
</div>`;
}).join("");
body = `
<div class="account-section-body">
<div class="shortcut-grid">${slotRows}</div>
<p class="shortcut-hint">Ctrl+19 (PWA) · Alt+19 (browser) · Swipe header on mobile</p>
</div>`;
}
return `
<div class="account-section${isOpen ? " open" : ""}">
<div class="account-section-header" data-section="shortcuts">
<span>⌨️ rApp Shortcuts</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("#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; }
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> 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");
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="email-back"]')?.addEventListener("click", () => { emailStep = "input"; render(); });
// 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...';
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");
if (acctStatus) { acctStatus.email = true; acctStatus.emailAddress = emailAddr; }
openSection = null; render();
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;
}
});
// 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("#acct-code")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="verify-email"]') as HTMLElement)?.click();
});
// 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 || "";
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: { authenticatorAttachment: "platform", 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");
if (acctStatus) { acctStatus.credentialCount++; acctStatus.multiDevice = acctStatus.credentialCount > 1; }
btn.innerHTML = "Device Registered";
btn.className = "btn btn--success";
render();
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;
}
});
// Recovery: add guardian
overlay.querySelector('[data-action="add-guardian"]')?.addEventListener("click", async () => {
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();
if (!name) { err.textContent = "Enter a guardian name."; nameInput.focus(); return; }
err.textContent = "";
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span>';
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 });
if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.socialRecovery = guardians.length >= 2; }
render();
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("#acct-guardian-name")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-guardian"]') as HTMLElement)?.click();
});
overlay.querySelector("#acct-guardian-email")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") (overlay.querySelector('[data-action="add-guardian"]') as HTMLElement)?.click();
});
// Recovery: remove guardian
overlay.querySelectorAll("[data-remove-guardian]").forEach(el => {
el.addEventListener("click", async () => {
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",
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (!res.ok) throw new Error("Failed to remove guardian");
guardians = guardians.filter(g => g.id !== id);
if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.socialRecovery = guardians.length >= 2; }
render();
} catch (e: any) {
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);
render();
this.dispatchEvent(new CustomEvent("backup-toggle", { bubbles: true, composed: true, detail: { enabled } }));
});
}
// Shortcut slot selects
overlay.querySelectorAll<HTMLSelectElement>(".slot-select").forEach(sel => {
sel.addEventListener("change", () => {
const slot = sel.dataset.slot!;
if (sel.value) {
setShortcut(slot, sel.value);
} else {
removeShortcut(slot);
}
});
});
};
document.body.appendChild(overlay);
render();
}
// ── Wallets modal ──
#showWalletsModal(): void {
if (document.querySelector(".rstack-wallets-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-wallets-overlay";
const session = getSession();
const username = session?.claims.username || "";
const did = session?.claims.did || session?.claims.sub || "";
const initial = username ? username[0].toUpperCase() : did.slice(8, 10).toUpperCase();
const truncDid = did.length > 32 ? did.slice(0, 16) + "..." + did.slice(-8) : did;
const discovery = new _WalletDiscovery();
const connectedAddrs = new Map<string, string>(); // uuid → address
const close = () => { discovery.stop(); overlay.remove(); };
const render = () => {
const browserSection = discovery.providers.length
? discovery.providers.map((p) => {
const addr = connectedAddrs.get(p.info.uuid);
return `
<div class="wallet-provider-card" data-uuid="${p.info.uuid}">
<img class="wallet-provider-icon" src="${p.info.icon}" alt="${p.info.name}" />
<div class="wallet-provider-info">
<div class="wallet-provider-name">${p.info.name}</div>
${addr
? `<div class="wallet-provider-addr">${addr.slice(0, 6)}...${addr.slice(-4)}</div>`
: `<button class="wallet-connect-btn" data-connect="${p.info.uuid}">Connect</button>`
}
</div>
</div>`;
}).join("")
: `<p class="wallet-empty">No browser wallets detected.<br><span style="font-size:0.75rem;color:var(--rs-text-muted)">Install MetaMask, Rainbow, or another EIP-6963 wallet.</span></p>`;
overlay.innerHTML = `
<style>${MODAL_STYLES}${WALLETS_STYLES}</style>
<div class="wallets-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>My Wallets</h2>
<div class="wallet-section-label">rIdentity Wallet</div>
<div class="wallet-identity-card">
<div class="wallet-identity-avatar">${initial}</div>
<div class="wallet-identity-info">
<div class="wallet-identity-name">${username || "Anonymous"}</div>
<div class="wallet-identity-did" title="${did}">${truncDid}</div>
</div>
<span class="wallet-badge">Passkey</span>
</div>
<div class="wallet-section-label">Browser Wallets</div>
<div class="wallet-providers">${browserSection}</div>
<div class="wallet-footer">
<button class="wallet-open-btn" data-action="open-rwallet">Open rWallet →</button>
</div>
</div>
`;
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-action="open-rwallet"]')?.addEventListener("click", () => {
close();
const space = _getCurrentSpace();
window.location.href = _navUrl(space, "rwallet");
});
overlay.querySelectorAll("[data-connect]").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.stopPropagation();
const uuid = (btn as HTMLElement).dataset.connect!;
const provider = discovery.providers.find((p) => p.info.uuid === uuid);
if (!provider) return;
(btn as HTMLButtonElement).textContent = "Connecting...";
(btn as HTMLButtonElement).disabled = true;
try {
const accounts = await provider.provider.request({ method: "eth_requestAccounts" }) as string[];
if (accounts?.[0]) {
connectedAddrs.set(uuid, accounts[0]);
render();
}
} catch {
(btn as HTMLButtonElement).textContent = "Rejected";
setTimeout(() => { (btn as HTMLButtonElement).textContent = "Connect"; (btn as HTMLButtonElement).disabled = false; }, 2000);
}
});
});
};
document.body.appendChild(overlay);
discovery.start(render);
render();
}
// ── Spaces modal ──
#showSpacesModal(): void {
if (document.querySelector(".rstack-spaces-overlay")) return;
const overlay = document.createElement("div");
overlay.className = "rstack-spaces-overlay";
const close = () => overlay.remove();
const renderLoading = () => {
overlay.innerHTML = `
<style>${MODAL_STYLES}${SPACES_STYLES}</style>
<div class="spaces-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>My Spaces</h2>
<p>Loading your spaces...</p>
<div style="text-align:center;padding:2rem 0"><span class="spinner"></span></div>
</div>
`;
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
};
const visInfo = (v: string) =>
v === "private" ? { icon: "🔒", cls: "vis-private", label: "private" }
: v === "permissioned" ? { icon: "🔑", cls: "vis-permissioned", label: "permissioned" }
: { icon: "👁", cls: "vis-public", label: "public" };
const displayName = (s: any) => {
const v = s.visibility || "public";
if (v === "private") {
const username = getUsername();
return username ? `${username}'s Space` : "My Space";
}
return (s.name || s.slug).replace(/</g, "&lt;");
};
const renderSpaces = (spaces: any[]) => {
const yourSpaces = spaces.filter((s) => s.role);
const publicSpaces = spaces.filter((s) => !s.role && s.accessible);
const cardHTML = (s: any) => {
const vis = visInfo(s.visibility || "public");
return `
<button class="space-card ${vis.cls}" data-slug="${s.slug}">
<div class="space-card-initial">${(s.name || s.slug).charAt(0).toUpperCase()}</div>
<div class="space-card-name">${displayName(s)}</div>
<div class="space-card-meta">
<span class="space-vis ${vis.cls}">${vis.icon} ${vis.label}</span>
${s.role ? `<span class="space-role">${s.role}</span>` : ""}
</div>
</button>
`;};
const yourSection = yourSpaces.length
? `<div class="spaces-section-label">Your Spaces</div>
<div class="spaces-grid">${yourSpaces.map(cardHTML).join("")}</div>`
: "";
const publicSection = publicSpaces.length
? `<div class="spaces-section-label">Public Spaces</div>
<div class="spaces-grid">${publicSpaces.map(cardHTML).join("")}</div>`
: "";
const emptyState = !yourSpaces.length && !publicSpaces.length
? `<p style="color:var(--rs-text-secondary);text-align:center;padding:1rem 0">No spaces yet. Create one to get started!</p>`
: "";
overlay.innerHTML = `
<style>${MODAL_STYLES}${SPACES_STYLES}</style>
<div class="spaces-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>My Spaces</h2>
${emptyState}
${yourSection}
${publicSection}
<div class="spaces-grid" style="margin-top:12px">
<button class="space-card space-card--create" data-action="create-space">
<div class="space-card-initial">+</div>
<div class="space-card-name">Create New Space</div>
</button>
</div>
</div>
`;
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
overlay.querySelectorAll("[data-slug]").forEach((el) => {
el.addEventListener("click", () => {
const slug = (el as HTMLElement).dataset.slug!;
close();
window.location.href = rspaceNavUrl(slug, getCurrentModule());
});
});
overlay.querySelector('[data-action="create-space"]')?.addEventListener("click", () => {
close();
window.location.href = "/create-space";
});
};
document.body.appendChild(overlay);
renderLoading();
const token = getAccessToken();
fetch("/api/spaces", {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((data) => renderSpaces(data.spaces || []))
.catch(() => {
overlay.innerHTML = `
<style>${MODAL_STYLES}${SPACES_STYLES}</style>
<div class="spaces-modal">
<button class="close-btn" data-action="close">&times;</button>
<h2>My Spaces</h2>
<p style="color:#ef4444">Failed to load spaces. Please try again.</p>
</div>
`;
overlay.querySelector('[data-action="close"]')?.addEventListener("click", close);
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
});
}
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 {
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;
}
.name { color: var(--rs-text-muted); }
.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; }
.dropdown.open { display: block; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); }
.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;
}
.dropdown-item { color: var(--rs-text-primary); }
.dropdown-item:hover { background: var(--rs-bg-hover); }
.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;
}
.dropdown-header { color: var(--rs-text-primary); }
.dropdown-divider { height: 1px; margin: 4px 0; }
.dropdown-divider { background: var(--rs-border-subtle); }
/* Mobile-only items — hidden on desktop, shown on mobile */
.mobile-only { display: none; }
@media (max-width: 640px) {
.mobile-only { display: flex; }
.mobile-only.dropdown-divider { display: block; }
}
/* Theme toggle in dropdown */
.dropdown-theme-row {
display: flex; align-items: center; justify-content: center;
gap: 10px; padding: 8px 16px;
}
.theme-icon { font-size: 0.9rem; line-height: 1; }
.theme-toggle {
position: relative; width: 40px; height: 22px;
display: inline-block; flex-shrink: 0;
}
.theme-toggle input { opacity: 0; width: 0; height: 0; }
.theme-slider {
position: absolute; inset: 0; border-radius: 11px;
background: #fbbf24; cursor: pointer; transition: background 0.25s;
}
.theme-slider::before {
content: ""; position: absolute;
width: 18px; height: 18px; border-radius: 50%;
left: 2px; bottom: 2px; background: white;
transition: transform 0.25s;
}
.theme-toggle input:checked + .theme-slider { background: #6366f1; }
.theme-toggle input:checked + .theme-slider::before { transform: translateX(18px); }
/* Canvas background selector in dropdown */
.dropdown-canvas-row {
display: flex; align-items: center; justify-content: center;
gap: 8px; padding: 6px 16px;
}
.canvas-label {
font-size: 0.7rem; font-weight: 600; opacity: 0.5;
text-transform: uppercase; letter-spacing: 0.04em;
}
.canvas-options { display: flex; gap: 4px; }
.canvas-opt {
font-size: 0.65rem; font-weight: 600; padding: 3px 8px;
border: 1px solid var(--rs-border); border-radius: 6px;
background: transparent; color: var(--rs-text-secondary);
cursor: pointer; transition: all 0.15s;
}
.canvas-opt:hover { background: var(--rs-bg-hover); }
.canvas-opt.active {
background: var(--rs-accent); color: white;
border-color: var(--rs-accent);
}
/* Avatar wrapper + notification badge */
.avatar-wrap { position: relative; }
.notif-badge {
position: absolute; top: -4px; right: -4px;
min-width: 16px; height: 16px; border-radius: 8px;
background: #ef4444; color: white; font-size: 0.6rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
padding: 0 4px; border: 2px solid var(--rs-bg-surface); line-height: 1;
}
/* Persona switcher in dropdown */
.dropdown-label {
padding: 6px 16px; font-size: 0.7rem; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--rs-text-secondary); font-weight: 600;
}
.persona-row {
display: flex; align-items: center; padding-right: 8px;
}
.persona-row .persona-item { flex: 1; }
.persona-avatar {
width: 24px; height: 24px; border-radius: 50%;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.65rem; color: white; flex-shrink: 0;
}
.persona-remove {
opacity: 0.4; cursor: pointer; font-size: 0.75rem;
padding: 2px 6px; border-radius: 4px; border: none;
background: none; color: var(--rs-text-secondary); flex-shrink: 0;
}
.persona-remove:hover { opacity: 1; background: var(--rs-bg-hover); }
/* Notification items in dropdown */
.dropdown-section-label {
padding: 8px 16px 4px; font-size: 0.65rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5;
}
.notif-item {
padding: 10px 16px; border-left: 3px solid #fbbf24;
}
.notif-text { font-size: 0.8rem; line-height: 1.4; color: var(--rs-text-primary); }
.notif-msg {
font-size: 0.75rem; color: var(--rs-text-secondary); font-style: italic;
margin-top: 4px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; max-width: 240px;
}
.notif-actions { display: flex; gap: 8px; margin-top: 8px; }
.notif-btn {
padding: 4px 12px; border-radius: 6px; border: none;
font-size: 0.75rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s;
}
.notif-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.notif-btn--approve { background: #059669; color: white; }
.notif-btn--approve:hover:not(:disabled) { opacity: 0.85; }
.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); }
/* Toggle switch */
.toggle-row {
display: flex; align-items: center;
justify-content: space-between; cursor: default;
}
.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: var(--rs-border); 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 MODAL_STYLES = `
.rstack-auth-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
-webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); display: flex; align-items: center;
justify-content: center; z-index: 10000; animation: fadeIn 0.2s;
}
.auth-modal {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
border-radius: 16px; padding: 2rem; max-width: 420px; width: 90%;
text-align: center; color: var(--rs-text-primary); box-shadow: var(--rs-shadow-lg);
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: var(--rs-text-secondary); font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; }
.input {
width: 100%; padding: 12px 16px; border-radius: 8px;
border: 1px solid var(--rs-input-border); background: var(--rs-input-bg);
color: var(--rs-input-text); 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: var(--rs-text-muted); }
.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: var(--rs-btn-secondary-bg); color: var(--rs-text-secondary); border: 1px solid var(--rs-border); }
.btn--secondary:hover { background: var(--rs-bg-hover); color: var(--rs-text-primary); }
.error { color: #ef4444; font-size: 0.85rem; margin-top: 0.5rem; min-height: 1.2em; }
.toggle { margin-top: 1rem; font-size: 0.85rem; color: var(--rs-text-muted); }
.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: var(--rs-text-muted); font-size: 1.5rem;
cursor: pointer; line-height: 1; padding: 4px 8px; border-radius: 6px;
transition: all 0.15s;
}
.close-btn:hover { color: var(--rs-text-primary); background: var(--rs-bg-hover); }
.auth-modal { position: relative; }
.actions--stack { flex-direction: column; }
.btn--outline {
background: transparent; color: var(--rs-text-secondary);
border: 1px solid var(--rs-border);
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: var(--rs-text-primary); background: rgba(6,182,212,0.08); }
.learn-more { margin-top: 1.5rem; font-size: 0.8rem; color: var(--rs-text-muted); }
.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: var(--rs-text-muted); 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: var(--rs-bg-hover);
border: 1px solid var(--rs-border); font-size: 0.9rem; color: var(--rs-text-primary);
}
.contact-remove {
background: none; border: none; color: var(--rs-text-muted); 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: var(--rs-text-secondary);
}
.threshold-row label { white-space: nowrap; }
.threshold-row select {
padding: 6px 10px; border-radius: 6px; background: var(--rs-btn-secondary-bg);
border: 1px solid var(--rs-border); color: var(--rs-text-primary); font-size: 0.85rem;
}
.threshold-hint { color: var(--rs-text-muted); font-size: 0.8rem; }
`;
const ACCOUNT_MODAL_STYLES = `
.rstack-account-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
-webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); display: flex; align-items: center;
justify-content: center; z-index: 10000; animation: fadeIn 0.2s;
}
.account-modal {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
border-radius: 16px; padding: 2rem; max-width: 520px; width: 92%;
max-height: 85vh; overflow-y: auto; color: var(--rs-text-primary); position: relative;
box-shadow: var(--rs-shadow-lg); 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 var(--rs-btn-secondary-bg); border-radius: 10px;
margin-bottom: 8px; overflow: hidden; transition: border-color 0.2s;
}
.account-section:hover { border-color: var(--rs-border); }
.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: var(--rs-bg-hover); }
.section-arrow { font-size: 0.7rem; color: var(--rs-text-muted); transition: transform 0.2s; }
.account-section-body {
padding: 0 16px 16px; animation: fadeIn 0.15s;
}
.account-section--inline {
border: 1px solid var(--rs-btn-secondary-bg); 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: var(--rs-text-muted); line-height: 1.4;
}
.guardian-piece { font-size: 1.1rem; flex-shrink: 0; }
.status-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
margin-right: 6px; vertical-align: middle; flex-shrink: 0;
}
.status-dot.done { background: #34d399; box-shadow: 0 0 4px rgba(52,211,153,0.4); }
.status-dot.pending { background: #f87171; box-shadow: 0 0 4px rgba(248,113,113,0.4); }
.section--warning { border-color: rgba(248,113,113,0.3) !important; }
.section--warning .account-section-header span:first-child { color: #fca5a5; }
.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: var(--rs-border); 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); }
.shortcut-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
}
.shortcut-slot {
display: flex; align-items: center; gap: 6px;
}
.slot-num {
width: 20px; height: 20px; border-radius: 4px; display: flex; align-items: center;
justify-content: center; font-size: 0.75rem; font-weight: 600; flex-shrink: 0;
background: var(--rs-btn-secondary-bg); color: var(--rs-text-secondary);
}
.slot-select {
flex: 1; min-width: 0; padding: 4px 6px; border-radius: 6px; font-size: 0.75rem;
background: var(--rs-btn-secondary-bg); border: 1px solid var(--rs-border);
color: var(--rs-text-primary); cursor: pointer;
}
.shortcut-hint {
margin: 8px 0 0; font-size: 0.7rem; color: var(--rs-text-muted); text-align: center;
}
`;
const SPACES_STYLES = `
.rstack-spaces-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
-webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); display: flex; align-items: center;
justify-content: center; z-index: 10000; animation: fadeIn 0.2s;
}
.spaces-modal {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
border-radius: 16px; padding: 2rem; max-width: 720px; width: 92%;
max-height: 85vh; overflow-y: auto; color: var(--rs-text-primary); position: relative;
box-shadow: var(--rs-shadow-lg); animation: slideUp 0.3s;
}
.spaces-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;
}
.spaces-section-label {
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--rs-text-muted); margin: 1rem 0 0.5rem;
}
.spaces-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.space-card {
display: flex; flex-direction: column; align-items: center; gap: 8px;
padding: 20px 12px; border-radius: 12px; cursor: pointer;
background: var(--rs-bg-hover); border: 1px solid var(--rs-btn-secondary-bg);
transition: all 0.2s; text-align: center; color: var(--rs-text-primary); font-family: inherit;
font-size: inherit;
}
.space-card:hover {
background: var(--rs-btn-secondary-bg); border-color: rgba(6,182,212,0.4);
transform: translateY(-2px); box-shadow: var(--rs-shadow-md);
}
.space-card-initial {
width: 48px; height: 48px; border-radius: 50%;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem; font-weight: 700; color: white;
}
.space-card-name {
font-size: 0.9rem; font-weight: 600; max-width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.space-card-meta {
display: flex; gap: 6px; flex-wrap: wrap; justify-content: center;
}
.space-vis {
font-size: 0.7rem; color: var(--rs-text-secondary); background: var(--rs-bg-hover);
padding: 2px 8px; border-radius: 10px;
}
.space-vis.vis-public { background: rgba(52,211,153,0.15); color: #34d399; }
.space-vis.vis-private { background: rgba(248,113,113,0.15); color: #f87171; }
.space-vis.vis-permissioned { background: rgba(251,191,36,0.15); color: #fbbf24; }
.space-card.vis-public { border-color: rgba(52,211,153,0.3); }
.space-card.vis-private { border-color: rgba(248,113,113,0.3); }
.space-card.vis-permissioned { border-color: rgba(251,191,36,0.3); }
.space-role {
font-size: 0.7rem; color: #06b6d4; background: rgba(6,182,212,0.1);
padding: 2px 8px; border-radius: 10px; font-weight: 600;
}
.space-card--create {
border-style: dashed; border-color: var(--rs-border);
}
.space-card--create .space-card-initial {
background: var(--rs-btn-secondary-bg); font-size: 1.5rem;
}
.space-card--create:hover .space-card-initial {
background: linear-gradient(135deg, #06b6d4, #7c3aed);
}
`;
const WALLETS_STYLES = `
.rstack-wallets-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
-webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); display: flex; align-items: center;
justify-content: center; z-index: 10000; animation: fadeIn 0.2s;
}
.wallets-modal {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
border-radius: 16px; padding: 2rem; max-width: 480px; width: 92%;
max-height: 85vh; overflow-y: auto; color: var(--rs-text-primary); position: relative;
box-shadow: var(--rs-shadow-lg); animation: slideUp 0.3s;
}
.wallets-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;
}
.wallet-section-label {
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--rs-text-muted); margin: 1.25rem 0 0.5rem;
}
.wallet-identity-card {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; border-radius: 12px;
background: var(--rs-bg-hover); border: 1px solid rgba(6,182,212,0.3);
}
.wallet-identity-avatar {
width: 42px; height: 42px; border-radius: 50%; flex-shrink: 0;
background: linear-gradient(135deg, #06b6d4, #7c3aed);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.9rem; color: white;
}
.wallet-identity-info { flex: 1; min-width: 0; }
.wallet-identity-name {
font-weight: 600; font-size: 0.95rem; color: var(--rs-text-primary);
}
.wallet-identity-did {
font-size: 0.75rem; color: var(--rs-text-muted); font-family: monospace;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.wallet-badge {
font-size: 0.7rem; font-weight: 600; color: #06b6d4;
background: rgba(6,182,212,0.12); padding: 3px 10px; border-radius: 10px;
white-space: nowrap;
}
.wallet-providers { display: flex; flex-direction: column; gap: 8px; }
.wallet-provider-card {
display: flex; align-items: center; gap: 12px;
padding: 12px 14px; border-radius: 10px;
background: var(--rs-bg-hover); border: 1px solid var(--rs-border);
transition: border-color 0.2s;
}
.wallet-provider-card:hover { border-color: rgba(6,182,212,0.4); }
.wallet-provider-icon {
width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0;
}
.wallet-provider-info { flex: 1; min-width: 0; }
.wallet-provider-name {
font-weight: 600; font-size: 0.875rem; color: var(--rs-text-primary);
}
.wallet-provider-addr {
font-size: 0.75rem; color: #34d399; font-family: monospace;
}
.wallet-connect-btn {
font-size: 0.75rem; font-weight: 600; padding: 4px 14px;
border-radius: 8px; border: 1px solid rgba(6,182,212,0.4);
background: rgba(6,182,212,0.1); color: #06b6d4; cursor: pointer;
transition: all 0.15s; font-family: inherit;
}
.wallet-connect-btn:hover { background: rgba(6,182,212,0.2); }
.wallet-connect-btn:disabled { opacity: 0.6; cursor: default; }
.wallet-empty {
text-align: center; padding: 1rem 0;
color: var(--rs-text-secondary); font-size: 0.85rem; line-height: 1.6;
}
.wallet-footer {
margin-top: 1.5rem; padding-top: 1rem;
border-top: 1px solid var(--rs-border); text-align: center;
}
.wallet-open-btn {
font-size: 0.875rem; font-weight: 600; padding: 8px 24px;
border-radius: 10px; border: none; cursor: pointer;
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
transition: all 0.2s; font-family: inherit;
}
.wallet-open-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); }
`;