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

3300 lines
139 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 = ""; // same-origin — avoids cross-origin issues on Safari
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 {
// Notify server so all other browser sessions get revoked
try {
const stored = localStorage.getItem(SESSION_KEY);
if (stored) {
const session = JSON.parse(stored) as SessionState;
if (session.accessToken) {
fetch(`${ENCRYPTID_URL}/api/session/logout`, {
method: "POST",
headers: { Authorization: `Bearer ${session.accessToken}` },
}).catch(() => { /* best-effort */ });
}
}
} catch { /* ignore */ }
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";
const KNOWN_ACCOUNTS_KEY = "encryptid-known-accounts";
interface KnownPersona {
username: string;
did: string;
}
function getKnownPersonas(): KnownPersona[] {
try {
return JSON.parse(localStorage.getItem(PERSONAS_KEY) || "[]");
} catch { return []; }
}
/** Merge usernames from all localStorage sources into a deduplicated list. */
function getAllKnownUsernames(): string[] {
const seen = new Set<string>();
const result: string[] = [];
const add = (u: string) => { if (u && !seen.has(u)) { seen.add(u); result.push(u); } };
// 1. Current session username (always first if logged in)
try {
const raw = localStorage.getItem(SESSION_KEY);
if (raw) {
const session = JSON.parse(raw) as SessionState;
if (session?.claims?.username) add(session.claims.username);
}
} catch { /* ignore */ }
// 2. Cached username shortcut
try { add(localStorage.getItem("rspace-username") || ""); } catch { /* ignore */ }
// 3. Persona list (has DID, most reliable historical source)
for (const p of getKnownPersonas()) add(p.username);
// 4. encryptid login-button known accounts
try {
const accounts: { username: string }[] = JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || "[]");
for (const a of accounts) add(a.username);
} catch { /* ignore */ }
return result;
}
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));
// Also sync to encryptid-known-accounts for login-button compat
try {
const accounts: { username: string; displayName?: string }[] = JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || "[]");
if (!accounts.some(a => a.username === username)) {
accounts.unshift({ username });
localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts));
}
} catch { /* ignore */ }
}
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;
const currentSpace = _getCurrentSpace();
if (currentSpace !== "demo") {
// User is on a specific space (their own or someone else's).
// Provision their personal space silently in the background.
// Don't redirect — they chose to be here. Just reload so the
// authenticated session takes effect (access gates, CRDT sync).
autoProvisionSpace(token);
window.location.reload();
return;
}
// On demo/landing — provision personal space and redirect to dashboard
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;
window.location.replace(_navUrl(data.slug, "rspace"));
})
.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 _isBareDomain(): boolean {
const h = window.location.host.split(":")[0];
return h === "rspace.online" || h === "www.rspace.online";
}
function _getCurrentSpace(): string {
if (_isSubdomain()) return window.location.host.split(":")[0].split(".")[0];
if (_isBareDomain()) return "demo";
return window.location.pathname.split("/").filter(Boolean)[0] || "demo";
}
function _getCurrentModule(): string {
const parts = window.location.pathname.split("/").filter(Boolean);
if (_isSubdomain() || _isBareDomain()) return parts[0] || "rspace";
return parts[1] || "rspace";
}
function _navUrl(space: string, moduleId: string): string {
const h = window.location.host.split(":")[0].split(".");
const proto = window.location.protocol;
const BASE = "rspace.online";
const onSub = h.length >= 3 && h.slice(-2).join(".") === BASE && !_RESERVED.includes(h[0]);
if (onSub) {
if (h[0] === space) return "/" + moduleId;
if (_RESERVED.includes(space)) return proto + "//" + BASE + "/" + moduleId;
return proto + "//" + space + "." + BASE + "/" + moduleId;
}
const host = window.location.host.split(":")[0];
if (host === BASE || host === "www." + BASE || host.endsWith("." + BASE)) {
if (space === "demo" || _RESERVED.includes(space)) return "/" + moduleId;
return proto + "//" + space + "." + BASE + "/" + 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);
}
// Validate session with server — detects logout from another browser session
this.#validateSessionWithServer();
// Nudge users without a second device to link one
this.#checkDeviceNudge();
// Show recovery alert dot on avatar if not set up
this.#checkRecoveryBadge();
// Propagate login/logout across tabs via storage events
window.addEventListener("storage", this.#onStorageChange);
}
disconnectedCallback() {
window.removeEventListener("storage", this.#onStorageChange);
}
async #validateSessionWithServer() {
const session = getSession();
if (!session?.accessToken) return;
// Throttle: skip if validated within the last 5 minutes
const VALIDATE_KEY = "eid_last_validated";
const VALIDATE_INTERVAL = 5 * 60 * 1000;
const lastValidated = parseInt(localStorage.getItem(VALIDATE_KEY) || "0", 10);
if (Date.now() - lastValidated < VALIDATE_INTERVAL) return;
try {
const res = await fetch(`${ENCRYPTID_URL}/api/session/verify`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
if (!res.ok) {
// Session revoked — clear locally and re-render
localStorage.removeItem(VALIDATE_KEY);
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem("rspace-username");
_removeSessionCookie();
resetDocBridge();
this.#render();
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "revoked" } }));
} else {
localStorage.setItem(VALIDATE_KEY, String(Date.now()));
}
} catch { /* network error — let token expire naturally */ }
}
async #checkRecoveryBadge() {
const session = getSession();
if (!session?.accessToken) return;
// Cache: only re-check every 30 minutes
const CACHE_KEY = "eid_recovery_status";
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
try {
const c = JSON.parse(cached);
if (Date.now() - c.ts < 30 * 60 * 1000) {
if (!c.socialRecovery) this.#showRecoveryDot();
if (!c.email || !c.multiDevice || !c.socialRecovery) this.#showAccountDot();
return;
}
} catch { /* stale cache */ }
}
try {
const res = await fetch(`${ENCRYPTID_URL}/api/account/status`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (!res.ok) return;
const status = await res.json();
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), email: !!status.email, multiDevice: !!status.multiDevice, socialRecovery: !!status.socialRecovery }));
if (!status.socialRecovery) this.#showRecoveryDot();
if (!status.email || !status.multiDevice || !status.socialRecovery) this.#showAccountDot();
} catch { /* offline */ }
}
#showRecoveryDot() {
const wrap = this.#shadow.querySelector(".avatar-wrap");
if (!wrap || wrap.querySelector(".recovery-alert-dot")) return;
const dot = document.createElement("span");
dot.className = "recovery-alert-dot";
dot.title = "Social recovery not set up";
wrap.appendChild(dot);
}
#showAccountDot() {
const btn = this.#shadow.querySelector('[data-action="my-account"]');
if (!btn || btn.querySelector(".acct-alert-dot")) return;
const dot = document.createElement("span");
dot.className = "acct-alert-dot";
btn.appendChild(dot);
}
async #checkDeviceNudge() {
const session = getSession();
if (!session?.accessToken) return;
const NUDGE_KEY = "eid_device_nudge_dismissed";
const DONE_KEY = "eid_device_nudge_done";
// Permanently suppress if multi-device already confirmed
if (localStorage.getItem(DONE_KEY) === "1") return;
// Don't nag if dismissed within the last 7 days
const dismissed = localStorage.getItem(NUDGE_KEY);
if (dismissed && Date.now() - parseInt(dismissed, 10) < 7 * 24 * 60 * 60 * 1000) return;
// Wait a moment so it doesn't compete with page load
await new Promise(r => setTimeout(r, 3000));
// Fetch account status
try {
const res = await fetch(`${ENCRYPTID_URL}/api/account/status`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (!res.ok) return;
const status = await res.json();
if (status.multiDevice) {
// Permanently mark as done — never nudge again
localStorage.setItem(DONE_KEY, "1");
return;
}
// Show a toast nudge with QR code
const toast = document.createElement("div");
toast.className = "eid-device-nudge";
let linkUrl = "";
let linkError = "";
const renderNudge = () => {
const qrHTML = linkUrl
? `<div class="eid-nudge-qr">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(linkUrl)}&format=png&margin=6" width="160" height="160" alt="Scan to link device" />
</div>
<div class="eid-nudge-link-row">
<input class="eid-nudge-link-input" type="text" readonly value="${linkUrl}" />
<button class="eid-nudge-btn eid-nudge-btn--copy" data-action="copy">Copy</button>
</div>
<div class="eid-nudge-expire">Expires in 10 minutes</div>`
: linkError
? `<div class="eid-nudge-error">${linkError}</div>`
: `<div class="eid-nudge-loading"><span class="eid-nudge-spinner"></span> Generating link…</div>`;
toast.innerHTML = `
<style>
.eid-device-nudge {
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999;
background: var(--rs-bg-surface, #1e1e2e); border: 1px solid rgba(6,182,212,0.4);
border-radius: 14px; padding: 1rem 1.25rem; max-width: min(340px, calc(100vw - 3rem));
box-shadow: 0 8px 32px rgba(0,0,0,0.4); animation: eid-nudge-in 0.4s ease-out;
color: var(--rs-text-primary, #e2e8f0); font-family: system-ui, sans-serif;
}
.eid-nudge-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.eid-nudge-icon { font-size: 1.5rem; }
.eid-nudge-title { font-weight: 600; font-size: 0.95rem; }
.eid-nudge-body { font-size: 0.85rem; color: var(--rs-text-secondary, #94a3b8); line-height: 1.5; margin-bottom: 0.75rem; }
.eid-nudge-qr { text-align: center; margin-bottom: 0.5rem; }
.eid-nudge-qr img { border-radius: 10px; background: #fff; padding: 4px; }
.eid-nudge-link-row { display: flex; gap: 6px; margin-bottom: 0.4rem; }
.eid-nudge-link-input {
flex: 1; font-size: 0.72rem; padding: 5px 8px; border-radius: 6px;
border: 1px solid var(--rs-border, #334155); background: var(--rs-input-bg, #0f172a);
color: var(--rs-text-secondary, #94a3b8); min-width: 0; outline: none;
}
.eid-nudge-btn--copy {
padding: 5px 10px; border-radius: 6px; border: 1px solid var(--rs-border, #334155);
background: transparent; color: var(--rs-text-secondary, #94a3b8);
font-size: 0.75rem; font-weight: 600; cursor: pointer; white-space: nowrap;
}
.eid-nudge-btn--copy:hover { color: var(--rs-text-primary, #e2e8f0); }
.eid-nudge-expire { font-size: 0.75rem; color: var(--rs-text-muted, #64748b); text-align: center; margin-bottom: 0.5rem; }
.eid-nudge-loading { text-align: center; padding: 1.5rem 0; color: var(--rs-text-secondary, #94a3b8); font-size: 0.85rem; }
.eid-nudge-error { text-align: center; padding: 0.75rem 0; color: #f87171; font-size: 0.85rem; }
.eid-nudge-spinner {
display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(148,163,184,0.3);
border-top-color: #94a3b8; border-radius: 50%; animation: eid-spin 0.6s linear infinite;
vertical-align: middle; margin-right: 6px;
}
.eid-nudge-actions { display: flex; gap: 8px; }
.eid-nudge-btn {
padding: 8px 14px; border-radius: 8px; border: none; font-size: 0.85rem;
font-weight: 600; cursor: pointer; transition: all 0.2s;
}
.eid-nudge-btn--dismiss {
background: transparent; color: var(--rs-text-muted, #64748b);
border: 1px solid var(--rs-border, #334155); flex: 1; text-align: center;
}
.eid-nudge-btn--dismiss:hover { color: var(--rs-text-secondary, #94a3b8); }
@keyframes eid-nudge-in { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@keyframes eid-spin { to { transform: rotate(360deg); } }
</style>
<div class="eid-nudge-header">
<span class="eid-nudge-icon">📱</span>
<span class="eid-nudge-title">Link a second device</span>
</div>
<div class="eid-nudge-body">
Scan this QR code on your phone or tablet to add a passkey backup.
</div>
${qrHTML}
<div class="eid-nudge-actions">
<button class="eid-nudge-btn eid-nudge-btn--dismiss" data-action="later">Dismiss</button>
</div>
`;
toast.querySelector('[data-action="later"]')?.addEventListener("click", () => {
localStorage.setItem(NUDGE_KEY, String(Date.now()));
toast.style.animation = "eid-nudge-in 0.3s ease-in reverse forwards";
setTimeout(() => toast.remove(), 300);
});
toast.querySelector('[data-action="copy"]')?.addEventListener("click", () => {
navigator.clipboard.writeText(linkUrl).catch(() => {});
const btn = toast.querySelector('[data-action="copy"]') as HTMLButtonElement;
if (btn) { btn.textContent = "Copied!"; setTimeout(() => { btn.textContent = "Copy"; }, 2000); }
});
};
document.body.appendChild(toast);
renderNudge(); // show loading state
// Generate device link
try {
const linkRes = await fetch(`${ENCRYPTID_URL}/api/device-link/start`, {
method: "POST",
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
const linkData = await linkRes.json();
if (!linkRes.ok || linkData.error) throw new Error(linkData.error || "Failed");
linkUrl = linkData.linkUrl;
} catch {
linkError = "Could not generate link. Try My Account → Devices.";
}
renderNudge(); // show QR or error
} catch { /* offline */ }
}
#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, detail: { reason: e.newValue ? "signin" : "signout" } }));
// Session cleared from another tab — reload to show logged-out state
if (!e.newValue) window.location.reload();
}
}
};
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, detail: { reason: "refresh" } }));
}
} 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.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true, detail: { reason: "signout" } }));
return;
} 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, detail: { reason: "persona-switch" } }));
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();
};
let showManualInput = false;
const signinHTML = () => {
const knownUsers = usernameHint ? [] : getAllKnownUsernames();
const showPicker = knownUsers.length > 0 && !showManualInput;
const accountButtons = knownUsers.map(u => {
const initial = u[0]?.toUpperCase() || "?";
const escaped = u.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
return `<button class="account-pick-btn" data-pick-username="${escaped}">
<span class="account-pick-avatar">${initial}</span>
<span class="account-pick-name">${escaped}</span>
<span class="account-pick-arrow">→</span>
</button>`;
}).join("");
return `
<style>${MODAL_STYLES}
.account-picker { display: flex; flex-direction: column; gap: 8px; margin-bottom: 1rem; }
.account-pick-btn {
display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 16px;
background: var(--rs-bg-hover); border: 1px solid var(--rs-border); border-radius: 10px;
color: var(--rs-text-primary); cursor: pointer; transition: all 0.2s;
font-size: 0.95rem; font-family: inherit; text-align: left;
}
.account-pick-btn:hover { border-color: #06b6d4; background: rgba(6,182,212,0.08); transform: translateY(-1px); }
.account-pick-avatar {
width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0;
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.85rem;
}
.account-pick-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.account-pick-arrow { color: var(--rs-text-muted); font-size: 1.1rem; }
.alt-link { display: block; text-align: center; margin-top: 8px; font-size: 0.85rem; color: var(--rs-text-muted); cursor: pointer; background: none; border: none; font-family: inherit; width: 100%; padding: 4px; }
.alt-link:hover { color: #06b6d4; text-decoration: underline; }
</style>
<div class="auth-modal">
<button class="close-btn" data-action="cancel">&times;</button>
<h2>Sign up / Sign in</h2>
<p>${showPicker ? "Choose an account on this device:" : "Secure, passwordless authentication powered by passkeys."}</p>
${showPicker ? `
<div class="account-picker">${accountButtons}</div>
<button class="alt-link" data-action="show-manual">Use a different account</button>
` : `
<input class="input" id="auth-signin-username" type="text" placeholder="Username or email" autocomplete="username webauthn" maxlength="64" />
`}
<div class="actions actions--stack">
${showPicker ? '' : `
<button class="btn btn--primary" data-action="signin">🔑 Sign In with Passkey</button>
<button class="btn btn--outline" data-action="send-magic-link">📧 Send Magic Link</button>
`}
<button class="btn btn--outline" data-action="switch-register">🔐 Create New Account</button>
</div>
<div id="magic-link-msg" style="display:none;margin-top:0.5rem;font-size:0.85rem;text-align:center"></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" />
<input class="input" id="auth-email" type="email" placeholder="Email (optional — for account recovery)" autocomplete="email" maxlength="128" style="margin-top:-0.5rem" />
<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;
const usernameInput = overlay.querySelector("#auth-signin-username") as HTMLInputElement | null;
const enteredUsername = usernameInput?.value.trim() || "";
const loginUsername = usernameHint || enteredUsername;
if (errEl) errEl.textContent = "";
if (!loginUsername) {
if (errEl) errEl.textContent = "Please enter your username or email.";
usernameInput?.focus();
return;
}
// Show loading state on either the signin button or the clicked picker button
const pickerBtn = overlay.querySelector(`[data-pick-username="${CSS.escape(loginUsername)}"]`) as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Authenticating...'; }
if (pickerBtn) { pickerBtn.style.opacity = "0.6"; pickerBtn.style.pointerEvents = "none"; const arrow = pickerBtn.querySelector(".account-pick-arrow"); if (arrow) arrow.innerHTML = '<span class="spinner" style="width:14px;height:14px;border-width:2px"></span>'; }
try {
// Send as email or username depending on input format
const isEmail = loginUsername.includes("@");
const authBody = isEmail ? { email: loginUsername } : { username: loginUsername };
const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(authBody),
});
if (!startRes.ok) throw new Error("Failed to start authentication");
const { options: serverOptions, userFound } = await startRes.json();
if (!userFound) {
throw new Error("No account found for that username. Try creating a new account.");
}
// Build allowCredentials from server response to scope to this user's passkeys
const pubKeyOpts: any = {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rpId: serverOptions.rpId || "rspace.online",
userVerification: "required",
timeout: 60000,
};
if (serverOptions.allowCredentials?.length) {
pubKeyOpts.allowCredentials = serverOptions.allowCredentials.map((c: any) => ({
type: c.type,
id: new Uint8Array(base64urlToBuffer(c.id)),
...(c.transports?.length ? { transports: c.transports } : {}),
}));
}
const credential = (await navigator.credentials.get({
publicKey: pubKeyOpts,
})) 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, detail: { reason: "signin" } }));
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"; }
if (pickerBtn) { pickerBtn.style.opacity = ""; pickerBtn.style.pointerEvents = ""; const arrow = pickerBtn.querySelector(".account-pick-arrow"); if (arrow) arrow.innerHTML = "→"; }
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 handleMagicLink = async () => {
const input = overlay.querySelector("#auth-signin-username") as HTMLInputElement | null;
const msgEl = overlay.querySelector("#magic-link-msg") as HTMLElement | null;
const errEl = overlay.querySelector("#auth-error") as HTMLElement | null;
const value = input?.value.trim() || "";
if (!value || !value.includes("@")) {
if (errEl) errEl.textContent = "Enter an email address to receive a magic link.";
input?.focus();
return;
}
if (errEl) errEl.textContent = "";
const btn = overlay.querySelector('[data-action="send-magic-link"]') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Sending...'; }
try {
await fetch(`${ENCRYPTID_URL}/api/auth/magic-link`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: value }),
});
if (msgEl) { msgEl.style.display = ""; msgEl.style.color = "#22c55e"; msgEl.textContent = "Login link sent! Check your inbox."; }
} catch {
if (msgEl) { msgEl.style.display = ""; msgEl.style.color = "#f87171"; msgEl.textContent = "Failed to send. Try again."; }
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = "📧 Send Magic Link"; }
}
};
const handleRegister = async () => {
const usernameInput = overlay.querySelector("#auth-username") as HTMLInputElement;
const emailInput = overlay.querySelector("#auth-email") as HTMLInputElement | null;
const errEl = overlay.querySelector("#auth-error") as HTMLElement;
const btn = overlay.querySelector('[data-action="register"]') as HTMLButtonElement;
const username = usernameInput.value.trim();
const email = emailInput?.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: { 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,
...(email ? { email } : {}),
}),
});
const data = await completeRes.json().catch(() => null);
if (!data || !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, detail: { reason: "signin" } }));
callbacks?.onSuccess?.();
// Show post-signup prompt recommending second device before redirecting
this.#showPostSignupPrompt(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="send-magic-link"]')?.addEventListener("click", handleMagicLink);
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";
showManualInput = false;
render();
});
overlay.querySelector('[data-action="show-manual"]')?.addEventListener("click", () => {
showManualInput = true;
render();
setTimeout(() => (overlay.querySelector("#auth-signin-username") as HTMLInputElement)?.focus(), 50);
});
// Account picker buttons — click triggers sign-in with that username
overlay.querySelectorAll("[data-pick-username]").forEach(btn => {
btn.addEventListener("click", () => {
const picked = (btn as HTMLElement).dataset.pickUsername || "";
if (!picked) return;
// Populate a hidden username for handleSignIn, then trigger it
const input = overlay.querySelector("#auth-signin-username") as HTMLInputElement | null;
if (input) {
input.value = picked;
} else {
// No input in DOM (picker mode) — create a temp hidden one for handleSignIn
const tmp = document.createElement("input");
tmp.id = "auth-signin-username";
tmp.type = "hidden";
tmp.value = picked;
overlay.querySelector(".auth-modal")?.appendChild(tmp);
}
handleSignIn();
});
});
overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") {
// Tab to email field if empty, otherwise register
const emailInput = overlay.querySelector("#auth-email") as HTMLInputElement | null;
if (emailInput && !emailInput.value.trim()) { emailInput.focus(); } else { handleRegister(); }
}
});
overlay.querySelector("#auth-email")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") handleRegister();
});
overlay.querySelector("#auth-signin-username")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") handleSignIn();
});
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();
} else {
// Focus the username input
setTimeout(() => (overlay.querySelector("#auth-signin-username") as HTMLInputElement)?.focus(), 50);
}
}
// ── Post-signup onboarding prompt ──
#showPostSignupPrompt(token: string, username: string): void {
const overlay = document.createElement("div");
overlay.className = "rstack-auth-overlay";
const goToSpace = () => {
overlay.remove();
autoResolveSpace(token, username);
};
let step: "welcome" | "linking" | "done" = "welcome";
let linkUrl = "";
const render = () => {
if (step === "welcome") {
const qrHint = /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent)
? "You can link your laptop or another phone."
: "Grab your phone or tablet and scan the code.";
overlay.innerHTML = `
<style>${MODAL_STYLES}${ONBOARDING_STYLES}</style>
<div class="auth-modal onboarding-modal">
<h2>Welcome to rSpace!</h2>
<p style="margin-bottom:0.75rem">Your account <strong>${username}</strong> is ready.</p>
<div class="onboarding-card onboarding-card--primary">
<div class="onboarding-card-icon">📱</div>
<div class="onboarding-card-content">
<div class="onboarding-card-title">Link a Second Device</div>
<div class="onboarding-card-desc">
Add a passkey on your phone or tablet so you can sign in from anywhere.
This also serves as a backup if you lose access to this device.
</div>
<div class="onboarding-card-hint">${qrHint}</div>
</div>
</div>
<button class="btn btn--primary" style="width:100%;margin-top:0.75rem" data-action="link-device">📱 Link Another Device</button>
<button class="btn btn--outline" style="width:100%;margin-top:0.5rem" data-action="skip">Continue to my space</button>
<p class="onboarding-later-hint">You can always link devices later from My Account.</p>
</div>`;
} else if (step === "linking") {
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(linkUrl)}&format=png&margin=8`;
overlay.innerHTML = `
<style>${MODAL_STYLES}${ONBOARDING_STYLES}</style>
<div class="auth-modal onboarding-modal">
<h2>Link Another Device</h2>
<p style="margin-bottom:0.5rem">Scan this QR code on your phone or tablet to add a passkey.</p>
<div class="qr-container">
<img src="${qrSrc}" width="200" height="200" alt="QR Code" style="border-radius:12px;background:#fff;padding:4px" />
</div>
<div class="link-copy-row">
<input class="input" id="onboarding-link-url" type="text" readonly value="${linkUrl}" style="font-size:0.8rem;margin-bottom:0" />
<button class="btn btn--secondary btn--small" data-action="copy-link" style="flex:none;white-space:nowrap">Copy</button>
</div>
<p class="onboarding-expire-hint">Link expires in 10 minutes</p>
<div class="actions" style="margin-top:1rem">
<button class="btn btn--secondary" data-action="back">Back</button>
<button class="btn btn--primary" data-action="done">Continue to my space</button>
</div>
</div>`;
} else {
overlay.innerHTML = `
<style>${MODAL_STYLES}${ONBOARDING_STYLES}</style>
<div class="auth-modal onboarding-modal">
<div style="font-size:2.5rem;margin-bottom:0.5rem">✅</div>
<h2>Device Linked!</h2>
<p>You can now sign in from your other device too.</p>
<button class="btn btn--primary" style="width:100%;margin-top:1rem" data-action="done">Continue to my space</button>
</div>`;
}
attachListeners();
};
const attachListeners = () => {
overlay.querySelector('[data-action="skip"]')?.addEventListener("click", goToSpace);
overlay.querySelector('[data-action="done"]')?.addEventListener("click", goToSpace);
overlay.querySelector('[data-action="back"]')?.addEventListener("click", () => {
step = "welcome";
render();
});
overlay.querySelector('[data-action="copy-link"]')?.addEventListener("click", () => {
const input = overlay.querySelector("#onboarding-link-url") as HTMLInputElement;
navigator.clipboard.writeText(input.value).catch(() => {});
const btn = overlay.querySelector('[data-action="copy-link"]') as HTMLButtonElement;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = "Copy"; }, 2000);
});
overlay.querySelector('[data-action="link-device"]')?.addEventListener("click", async () => {
const btn = overlay.querySelector('[data-action="link-device"]') as HTMLButtonElement;
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Generating link...';
try {
const res = await fetch(`${ENCRYPTID_URL}/api/device-link/start`, {
method: "POST",
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || "Failed to generate link");
linkUrl = data.linkUrl;
step = "linking";
render();
} catch (e: any) {
btn.disabled = false;
btn.innerHTML = "📱 Link Another Device";
const errEl = document.createElement("div");
errEl.className = "error";
errEl.textContent = e.message;
btn.parentElement?.appendChild(errEl);
}
});
};
document.body.appendChild(overlay);
render();
}
// ── Account modal (consolidated) ──
showAccountModal(options?: { openSection?: string }): 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 = options?.openSection ?? null;
// Account completion status
let acctStatus: { email: boolean; emailAddress?: string | null; multiDevice: boolean; socialRecovery: boolean; guardianCount: number; acceptedGuardianCount?: number; credentialCount: number; lastDrillAt?: number | null } | null = null;
// Lazy-loaded data
let guardians: { id: string; name: string; email?: string; status: string }[] = [];
let guardiansThreshold = 2;
let guardiansLoaded = false;
let guardiansLoading = false;
// Drill state
let activeDrillId: string | null = null;
let drillPollingTimer: ReturnType<typeof setInterval> | null = null;
let drillStatus: { approvals: Array<{ guardianId: string; approved: boolean }>; approvalCount: number; threshold: number; status: string } | null = null;
let showWalkthrough = false;
let walkthroughStep = 0;
let devices: { credentialId: string; label: string | null; createdAt: number; lastUsed?: number; transports?: string[] }[] = [];
let devicesLoaded = false;
let devicesLoading = false;
// Connections data
let connectionsLoaded = false;
let connectionsLoading = false;
let connectionsStatus: Record<string, { connected: boolean; email?: string; workspaceName?: string; teamName?: string; connectedAt?: number; services?: string[]; calendarSources?: number; type?: string; note?: string }> = {};
let sharingConfig: Record<string, { spaces: string[] }> = {};
let userSpaces: Array<{ slug: string; name: string }> = [];
let emailStep: "input" | "verify" = "input";
let emailAddr = "";
const close = () => {
if (drillPollingTimer) { clearInterval(drillPollingTimer); drillPollingTimer = null; }
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()}
<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()}
${renderConnectionsSection()}
<div class="error" id="acct-error"></div>
</div>
${showWalkthrough ? renderWalkthrough() : ""}
`;
attachListeners();
};
const WALKTHROUGH_STEPS = [
{ icon: "🔒", iconClass: "red", title: "Account Locked", body: "Imagine you lost access to all your devices. Your passkeys are gone, and you can't sign in." },
{ icon: "📡", iconClass: "amber", title: "Guardians Notified", body: "You request account recovery. Your trusted guardians each receive a notification asking them to verify your identity." },
{ icon: "✅", iconClass: "amber", title: "Identity Verified", body: "Each guardian confirms it's really you — through a phone call, video chat, or meeting in person. They click 'Approve' on their end." },
{ icon: "🔑", iconClass: "green", title: "Key Assembled", body: "When 2 of your 3 guardians approve, their trust combines to unlock your recovery. No single guardian can do this alone." },
{ icon: "🛡️", iconClass: "green", title: "Account Recovered", body: "You register a new passkey on your new device and regain full access. Your data and identity are restored." },
];
const renderWalkthrough = () => {
const step = WALKTHROUGH_STEPS[walkthroughStep];
const dots = WALKTHROUGH_STEPS.map((_, i) =>
`<div class="walkthrough-dot${i < walkthroughStep ? " done" : i === walkthroughStep ? " active" : ""}"></div>`
).join("");
const isLast = walkthroughStep >= WALKTHROUGH_STEPS.length - 1;
return `
<div class="walkthrough-overlay">
<div class="walkthrough-card">
<div class="walkthrough-icon ${step.iconClass}">${step.icon}</div>
<div class="walkthrough-title">${step.title}</div>
<div class="walkthrough-body">${step.body}</div>
<div class="walkthrough-progress">${dots}</div>
<div class="walkthrough-nav">
<button class="walkthrough-btn walkthrough-btn--skip" data-action="walkthrough-skip">${isLast ? "Close" : "Skip"}</button>
${!isLast ? `<button class="walkthrough-btn walkthrough-btn--next" data-action="walkthrough-next">Next</button>` : `<button class="walkthrough-btn walkthrough-btn--next" data-action="walkthrough-skip">Got It</button>`}
</div>
</div>
</div>`;
};
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;
let body = "";
if (isOpen) {
if (devicesLoading) {
body = `<div class="account-section-body"><div style="text-align:center;padding:1rem;color:var(--rs-text-secondary)"><span class="spinner"></span> Loading devices...</div></div>`;
} else {
const fmtDate = (ts?: number) => ts ? new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : "Never";
const transportBadge = (t: string) => `<span style="display:inline-block;background:var(--rs-bg-secondary);border-radius:4px;padding:1px 5px;font-size:0.7rem;color:var(--rs-text-muted)">${t}</span>`;
const deviceListHTML = devices.length > 0
? `<div class="contact-list">${devices.map(d => `
<div class="contact-item" style="flex-wrap:wrap;gap:6px">
<div style="display:flex;align-items:center;gap:8px;min-width:0;flex:1">
<span style="font-size:1.1rem">🔑</span>
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;flex:1">
<span style="font-weight:500">${(d.label || "Unnamed device").replace(/</g, "&lt;")}</span>
<span style="font-size:0.7rem;color:var(--rs-text-muted)">Added ${fmtDate(d.createdAt)} · Last used ${fmtDate(d.lastUsed)}</span>
${d.transports?.length ? `<div style="display:flex;gap:3px;flex-wrap:wrap">${d.transports.map(transportBadge).join("")}</div>` : ""}
</div>
</div>
<div style="display:flex;gap:4px;align-items:center">
<button class="btn btn--small" data-rename-credential="${d.credentialId}" title="Rename">✏️</button>
<button class="btn btn--small btn--danger" data-remove-credential="${d.credentialId}" title="Remove"${devices.length <= 1 ? " disabled" : ""}>&times;</button>
</div>
</div>
`).join("")}</div>` : "";
body = `
<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.</p>
${deviceListHTML}
<div class="actions actions--stack" style="margin-top:8px">
<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 renderGuardianPiecesSVG = (slots: Array<{ status: "empty" | "pending" | "accepted" }>, assembled: boolean) => {
const pieceClass = (s: string) => s === "accepted" ? "piece-accepted glow" : s === "pending" ? "piece-pending" : "piece-empty";
return `<div class="guardian-viz">
<svg width="200" height="60" viewBox="0 0 200 60">
<defs>
<linearGradient id="keyGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#34d399" />
<stop offset="100%" style="stop-color:#06b6d4" />
</linearGradient>
</defs>
${/* Piece 1 (left) */""}
<g transform="translate(15,10)">
<path class="${pieceClass(slots[0]?.status || "empty")}" d="M0,0 h25 c0,5 8,5 8,0 h7 v40 h-40 z" />
<text x="12" y="25" fill="white" font-size="10" text-anchor="middle" font-weight="600">1</text>
</g>
${/* Piece 2 (center) */""}
<g transform="translate(70,10)">
<path class="${pieceClass(slots[1]?.status || "empty")}" d="M0,0 h25 c0,5 8,5 8,0 h7 v40 h-7 c0,-5 -8,-5 -8,0 h-25 v-40 z" />
<text x="16" y="25" fill="white" font-size="10" text-anchor="middle" font-weight="600">2</text>
</g>
${/* Piece 3 (right) */""}
<g transform="translate(125,10)">
<path class="${pieceClass(slots[2]?.status || "empty")}" d="M0,0 h40 v40 h-40 v0 c0,-5 -8,-5 -8,0 v0 z" />
<text x="16" y="25" fill="white" font-size="10" text-anchor="middle" font-weight="600">3</text>
</g>
${/* Key icon (right side) */""}
<g transform="translate(175,15)">
<path class="${assembled ? "key-assembled" : "key-outline"}" d="M0,15 a10,10 0 1 1 12,0 l0,3 h8 v5 h-3 v3 h3 v5 h-8 l0,0 h-12 z" />
</g>
</svg>
</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 acceptedCount = guardians.filter(g => g.status === "accepted").length;
const recoveryActive = acceptedCount >= 2;
// Build puzzle piece slots
const slots: Array<{ status: "empty" | "pending" | "accepted" }> = [];
for (let i = 0; i < 3; i++) {
if (i < guardians.length) {
slots.push({ status: guardians[i].status === "accepted" ? "accepted" : "pending" });
} else {
slots.push({ status: "empty" });
}
}
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>` : "";
// Recovery active banner
const activeBanner = recoveryActive ? `
<div class="recovery-active-banner">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 1l2.5 5 5.5.8-4 3.9.9 5.3L8 13.3l-4.9 2.7.9-5.3-4-3.9 5.5-.8z" fill="#34d399"/></svg>
Recovery active — ${guardiansThreshold} of ${guardians.length} guardians can recover your account
</div>` : "";
// Walkthrough link (show after 2+ guardians)
const walkthroughLink = guardians.length >= 2 ? `
<div style="margin-top:8px;text-align:center">
<a style="color:#06b6d4;font-size:0.78rem;cursor:pointer;text-decoration:none" data-action="preview-recovery">Preview how recovery works</a>
</div>` : "";
// Drill section (only when recovery active)
let drillHTML = "";
if (recoveryActive) {
const lastDrill = acctStatus?.lastDrillAt;
const lastDrillStr = lastDrill ? new Date(lastDrill).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : null;
if (activeDrillId && drillStatus) {
// Live drill view
const drillSlots: Array<{ status: "empty" | "pending" | "accepted" }> = [];
for (const g of guardians) {
if (g.status !== "accepted") continue;
const approval = drillStatus.approvals.find(a => a.guardianId === g.id);
drillSlots.push({ status: approval?.approved ? "accepted" : "pending" });
}
while (drillSlots.length < 3) drillSlots.push({ status: "empty" });
const drillComplete = drillStatus.status === "approved" || drillStatus.status === "completed";
drillHTML = `<div class="drill-section">
<div class="drill-live">
<div class="drill-live-title">${drillComplete ? "Drill Complete!" : "Recovery Drill in Progress..."}</div>
${renderGuardianPiecesSVG(drillSlots, drillComplete)}
<div style="text-align:center;font-size:0.78rem;color:var(--rs-text-secondary)">
${drillStatus.approvalCount}/${drillStatus.threshold} guardians approved
</div>
${drillComplete ? `<div class="drill-success">Your recovery setup is working!</div>` : `<div style="text-align:center;font-size:0.72rem;color:var(--rs-text-muted);margin-top:4px">Checking every 5 seconds...</div>`}
</div>
</div>`;
} else {
drillHTML = `<div class="drill-section">
<button class="drill-btn" data-action="start-drill">🧪 Run Recovery Drill</button>
${lastDrillStr ? `<div class="drill-timestamp">Last drill: ${lastDrillStr}</div>` : `<div class="drill-timestamp">No drill run yet — test your setup!</div>`}
</div>`;
}
}
const infoHTML = !recoveryActive
? `<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:var(--rs-text-muted)">Recovery guardians can also help override account freezes and flow restrictions in emergencies.</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>
${activeBanner}
${renderGuardianPiecesSVG(slots, recoveryActive)}
${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}
${walkthroughLink}
${drillHTML}
<div class="error" id="recovery-error"></div>
</div>`;
}
}
const done = acctStatus ? acctStatus.socialRecovery : null;
const urgentClass = done === false ? " recovery-urgent" : "";
return `
<div class="account-section${isOpen ? " open" : ""}${done === false ? " section--warning" : ""}">
<div class="account-section-header" data-section="recovery">
<span>${done === null ? "" : done ? '<span class="status-dot done" title="Complete"></span>' : `<span class="status-dot pending${urgentClass}" title="Not yet set up"></span>`} 🛡️ Social Recovery</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 CONN_PLATFORMS: Array<{ id: string; name: string; icon: string; oauthKey?: string; comingSoon?: boolean; fileBased?: boolean; description?: string }> = [
{ id: "google", name: "Google", icon: "G", oauthKey: "google", description: "Docs, Drive, Calendar" },
{ id: "notion", name: "Notion", icon: "N", oauthKey: "notion", description: "Pages import/export" },
{ id: "clickup", name: "ClickUp", icon: "\u2713", oauthKey: "clickup", description: "Task sync" },
{ id: "obsidian", name: "Obsidian", icon: "\uD83D\uDCDD", oauthKey: "obsidian", fileBased: true, description: "Upload vault ZIP in rDocs or rNotes" },
{ id: "logseq", name: "Logseq", icon: "\uD83D\uDCD3", oauthKey: "logseq", fileBased: true, description: "Upload vault ZIP in rDocs or rNotes" },
{ id: "telegram", name: "Telegram", icon: "\u2708", comingSoon: true },
{ id: "discord", name: "Discord", icon: "\uD83C\uDFAE", comingSoon: true },
{ id: "github", name: "GitHub", icon: "\uD83D\uDC19", comingSoon: true },
{ id: "slack", name: "Slack", icon: "#", comingSoon: true },
{ id: "twitter", name: "X / Twitter", icon: "X", comingSoon: true },
{ id: "bluesky", name: "Bluesky", icon: "\uD83E\uDD8B", comingSoon: true },
{ id: "linear", name: "Linear", icon: "Lin", comingSoon: true },
];
const renderConnectionsSection = () => {
const isOpen = openSection === "connections";
const oauthConnected = Object.entries(connectionsStatus).filter(([k, s]) => s.connected && s.type !== 'file').length;
const connectedCount = oauthConnected;
const anyConnected = connectedCount > 0;
let body = "";
if (isOpen) {
if (connectionsLoading) {
body = `<div class="account-section-body"><div style="text-align:center;padding:1rem;color:var(--rs-text-secondary)"><span class="spinner"></span> Loading connections...</div></div>`;
} else {
const username = getUsername() || "";
let cards = "";
for (const p of CONN_PLATFORMS) {
const info = p.oauthKey ? connectionsStatus[p.oauthKey] : undefined;
const connected = info?.connected ?? false;
const cardClass = p.comingSoon ? "conn-card conn-card--soon" : p.fileBased ? "conn-card conn-card--active" : connected ? "conn-card conn-card--active" : "conn-card";
let badge = "";
let meta = "";
let action = "";
let sharing = "";
if (p.comingSoon) {
badge = `<span class="conn-badge conn-badge--soon">Coming Soon</span>`;
} else if (p.fileBased) {
badge = `<span class="conn-badge conn-badge--connected">Available</span>`;
meta = `<div class="conn-meta">${p.description || 'File-based import'}</div>`;
} else if (connected) {
badge = `<span class="conn-badge conn-badge--connected">Connected</span>`;
// Build meta line with account info + services
const metaParts: string[] = [];
if (info?.email) metaParts.push(info.email);
else if (info?.workspaceName) metaParts.push(info.workspaceName);
else if (info?.teamName) metaParts.push(info.teamName);
if (info?.services?.length) metaParts.push(info.services.join(', '));
const calSrc = info?.calendarSources ?? 0;
if (calSrc > 0) metaParts.push(`${calSrc} calendar${calSrc > 1 ? 's' : ''} synced`);
if (metaParts.length > 0) meta = `<div class="conn-meta">${metaParts.join(' · ')}</div>`;
action = `<button class="conn-btn conn-btn--disconnect" data-provider="${p.oauthKey}">Disconnect</button>`;
// Sharing: which spaces to share data into
const sharedSpaces = sharingConfig[p.oauthKey!]?.spaces || [];
if (userSpaces.length > 0) {
const spaceChecks = userSpaces.map(s => {
const checked = sharedSpaces.includes(s.slug) ? "checked" : "";
return `<label class="conn-share-opt"><input type="checkbox" class="conn-share-cb" data-provider="${p.oauthKey}" data-space="${s.slug}" ${checked} />${s.name}</label>`;
}).join("");
sharing = `<div class="conn-share-section"><div class="conn-share-label">Share to:</div>${spaceChecks}</div>`;
}
} else {
badge = `<span class="conn-badge conn-badge--disconnected">Not connected</span>`;
if (p.description) meta = `<div class="conn-meta" style="opacity:0.6">${p.description}</div>`;
action = `<button class="conn-btn conn-btn--connect" data-provider="${p.oauthKey}" data-space="${username}">Connect</button>`;
}
cards += `
<div class="${cardClass}" data-platform="${p.id}">
<div class="conn-card-header">
<div class="conn-card-icon">${p.icon}</div>
<div class="conn-card-info">
<div class="conn-card-name">${p.name}</div>
${badge}
${meta}
</div>
${action}
</div>
${sharing}
</div>`;
}
body = `<div class="account-section-body"><div class="conn-list">${cards}</div></div>`;
}
}
return `
<div class="account-section${isOpen ? " open" : ""}">
<div class="account-section-header" data-section="connections">
<span>${anyConnected ? '<span class="status-dot done"></span>' : '<span class="status-dot pending"></span>'} 🔗 Connections${anyConnected ? ` <span style="font-size:0.75rem;color:var(--rs-text-muted)">(${connectedCount})</span>` : ""}</span>
<span class="section-arrow">${isOpen ? "▾" : "▸"}</span>
</div>
${body}
</div>`;
};
const loadConnections = async () => {
if (connectionsLoaded || connectionsLoading) return;
connectionsLoading = true;
render();
const username = getUsername();
if (!username) { connectionsLoading = false; return; }
try {
const token = getAccessToken();
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
const [statusRes, sharingRes, spacesRes] = await Promise.all([
fetch(`/api/oauth/status?space=${encodeURIComponent(username)}`, { headers }),
fetch(`/api/oauth/sharing?space=${encodeURIComponent(username)}`, { headers }),
fetch("/api/spaces", { headers }),
]);
if (statusRes.ok) connectionsStatus = await statusRes.json();
if (sharingRes.ok) sharingConfig = await sharingRes.json();
if (spacesRes.ok) {
const data = await spacesRes.json();
userSpaces = (data.spaces || [])
.filter((s: any) => s.role === "owner" || s.role === "admin" || s.role === "member")
.map((s: any) => ({ slug: s.slug, name: s.name }));
}
} catch { /* offline */ }
connectionsLoaded = true;
connectionsLoading = false;
render();
};
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 loadDevices = async () => {
if (devicesLoaded || devicesLoading) return;
devicesLoading = true;
render();
try {
const res = await fetch(`${ENCRYPTID_URL}/api/user/credentials`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (res.ok) {
const data = await res.json();
devices = (data.credentials || []).map((c: any) => ({
credentialId: c.credentialId,
label: c.label || null,
createdAt: c.createdAt,
lastUsed: c.lastUsed,
transports: c.transports,
}));
}
} catch { /* offline */ }
devicesLoaded = true;
devicesLoading = 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 === "device") loadDevices();
if (openSection === "connections") loadConnections();
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: { 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; }
localStorage.removeItem("eid_device_nudge_dismissed");
devicesLoaded = false;
loadDevices();
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 });
const accepted = guardians.filter(g => g.status === "accepted").length;
if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.acceptedGuardianCount = accepted; acctStatus.socialRecovery = accepted >= 2; }
localStorage.removeItem("eid_recovery_status");
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);
const acceptedAfter = guardians.filter(g => g.status === "accepted").length;
if (acctStatus) { acctStatus.guardianCount = guardians.length; acctStatus.acceptedGuardianCount = acceptedAfter; acctStatus.socialRecovery = acceptedAfter >= 2; }
localStorage.removeItem("eid_recovery_status");
render();
} catch (e: any) {
if (err) err.textContent = e.message;
}
});
});
// Recovery: start drill
overlay.querySelector('[data-action="start-drill"]')?.addEventListener("click", async () => {
const btn = overlay.querySelector('[data-action="start-drill"]') as HTMLButtonElement;
const err = overlay.querySelector("#recovery-error") as HTMLElement;
if (!confirm("This will send a test notification to your guardians. They'll need to verify your identity and approve. Continue?")) return;
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Starting drill...';
try {
const res = await fetch(`${ENCRYPTID_URL}/api/recovery/drill/initiate`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to start drill");
activeDrillId = data.drillId;
drillStatus = { approvals: data.guardians.map((g: any) => ({ guardianId: g.id, approved: false })), approvalCount: 0, threshold: 2, status: "pending" };
// Start polling
drillPollingTimer = setInterval(async () => {
if (!activeDrillId) return;
try {
const pollRes = await fetch(`${ENCRYPTID_URL}/api/recovery/drill/${activeDrillId}/status`, {
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (pollRes.ok) {
const pollData = await pollRes.json();
drillStatus = pollData;
render();
if (pollData.status === "approved" || pollData.status === "completed") {
if (drillPollingTimer) clearInterval(drillPollingTimer);
drillPollingTimer = null;
// Complete the drill
await fetch(`${ENCRYPTID_URL}/api/recovery/drill/${activeDrillId}/complete`, {
method: "POST",
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (acctStatus) acctStatus.lastDrillAt = Date.now();
// Clear recovery status cache so badge refreshes
localStorage.removeItem("eid_recovery_status");
render();
}
}
} catch { /* offline */ }
}, 5000);
render();
} catch (e: any) {
btn.disabled = false; btn.innerHTML = "🧪 Run Recovery Drill";
if (err) err.textContent = e.message;
}
});
// Recovery: preview walkthrough
overlay.querySelector('[data-action="preview-recovery"]')?.addEventListener("click", () => {
showWalkthrough = true;
walkthroughStep = 0;
render();
});
// Walkthrough navigation
overlay.querySelector('[data-action="walkthrough-next"]')?.addEventListener("click", () => {
walkthroughStep++;
if (walkthroughStep >= 5) { showWalkthrough = false; walkthroughStep = 0; }
render();
});
overlay.querySelector('[data-action="walkthrough-skip"]')?.addEventListener("click", () => {
showWalkthrough = false; walkthroughStep = 0; render();
});
// Device: rename credential
overlay.querySelectorAll("[data-rename-credential]").forEach(el => {
el.addEventListener("click", async () => {
const id = (el as HTMLElement).dataset.renameCredential!;
const device = devices.find(d => d.credentialId === id);
const newLabel = prompt("Enter a label for this device:", device?.label || "");
if (!newLabel || !newLabel.trim()) return;
try {
const res = await fetch(`${ENCRYPTID_URL}/api/user/credentials/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
body: JSON.stringify({ label: newLabel.trim() }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Failed to rename");
if (device) device.label = newLabel.trim();
render();
} catch (e: any) {
const err = overlay.querySelector("#device-error") as HTMLElement;
if (err) err.textContent = e.message;
}
});
});
// Device: remove credential
overlay.querySelectorAll("[data-remove-credential]").forEach(el => {
el.addEventListener("click", async () => {
const id = (el as HTMLElement).dataset.removeCredential!;
if (!confirm("Remove this passkey? You won't be able to sign in with it anymore.")) return;
try {
const res = await fetch(`${ENCRYPTID_URL}/api/user/credentials/${encodeURIComponent(id)}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${getAccessToken()}` },
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "Failed to remove");
devices = devices.filter(d => d.credentialId !== id);
if (acctStatus) { acctStatus.credentialCount = devices.length; acctStatus.multiDevice = devices.length > 1; }
render();
} catch (e: any) {
const err = overlay.querySelector("#device-error") as HTMLElement;
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);
}
});
});
// Connections: connect buttons
overlay.querySelectorAll(".conn-btn--connect").forEach(btn => {
btn.addEventListener("click", () => {
const provider = (btn as HTMLElement).dataset.provider!;
const space = (btn as HTMLElement).dataset.space!;
const popup = window.open(`/api/oauth/${provider}/authorize?space=${encodeURIComponent(space)}`, "_blank", "width=600,height=700");
const poll = setInterval(() => {
if (!popup || popup.closed) {
clearInterval(poll);
connectionsLoaded = false;
loadConnections();
}
}, 1500);
});
});
// Connections: disconnect buttons
overlay.querySelectorAll(".conn-btn--disconnect").forEach(btn => {
btn.addEventListener("click", async () => {
const provider = (btn as HTMLElement).dataset.provider!;
const el = btn as HTMLButtonElement;
el.disabled = true;
el.textContent = "...";
const username = getUsername() || "";
try {
const token = getAccessToken();
await fetch(`/api/oauth/${provider}/disconnect?space=${encodeURIComponent(username)}`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
} catch { /* best-effort */ }
connectionsLoaded = false;
loadConnections();
});
});
// Connections: sharing checkboxes
overlay.querySelectorAll<HTMLInputElement>(".conn-share-cb").forEach(cb => {
cb.addEventListener("change", async () => {
const provider = cb.dataset.provider!;
const spaceSlug = cb.dataset.space!;
const current = sharingConfig[provider]?.spaces || [];
const updated = cb.checked
? [...new Set([...current, spaceSlug])]
: current.filter(s => s !== spaceSlug);
// Update local state immediately
if (!sharingConfig[provider]) sharingConfig[provider] = { spaces: [] };
sharingConfig[provider].spaces = updated;
// Persist to server
const username = getUsername() || "";
const token = getAccessToken();
try {
await fetch("/api/oauth/sharing", {
method: "POST",
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ space: username, provider, sharedSpaces: updated }),
});
} catch { /* best-effort */ }
});
});
};
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: 10002;
}
.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;
}
/* Recovery alert dot on avatar */
.recovery-alert-dot {
position: absolute; bottom: -1px; right: -1px;
width: 10px; height: 10px; border-radius: 50%;
background: #f87171; border: 2px solid var(--rs-bg-surface, #1e1e2e);
animation: recovery-pulse 2s ease-in-out infinite;
}
@keyframes recovery-pulse {
0%, 100% { box-shadow: 0 0 4px rgba(248,113,113,0.4); }
50% { box-shadow: 0 0 10px rgba(248,113,113,0.8); }
}
/* Account alert dot on "My Account" dropdown item */
.acct-alert-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #f87171; margin-left: auto; flex-shrink: 0;
box-shadow: 0 0 6px rgba(248,113,113,0.6);
animation: recovery-pulse 2s ease-in-out infinite;
}
/* 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%;
max-height: 90dvh; overflow-y: auto;
text-align: center; color: var(--rs-text-primary); box-shadow: var(--rs-shadow-lg);
animation: slideUp 0.3s;
}
@media (max-width: 480px) { .auth-modal { padding: 1.25rem; } }
.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, .close-btn:active { 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;
}
/* Connections section */
.conn-list { display: flex; flex-direction: column; gap: 8px; }
.conn-card {
border: 1px solid var(--rs-border, #262626); border-radius: 10px;
padding: 10px 14px; transition: border-color 0.2s;
}
.conn-card:hover { border-color: var(--rs-text-muted, #525252); }
.conn-card--active { border-color: rgba(52,211,153,0.3); }
.conn-card--soon { opacity: 0.4; pointer-events: none; }
.conn-card-header { display: flex; align-items: center; gap: 10px; }
.conn-card-icon {
width: 30px; height: 30px; border-radius: 8px; display: flex; align-items: center; justify-content: center;
background: var(--rs-bg-surface, #0a0a0a); border: 1px solid var(--rs-border, #262626);
font-size: 0.85rem; font-weight: 700; color: var(--rs-text-primary); flex-shrink: 0;
}
.conn-card-info { flex: 1; min-width: 0; }
.conn-card-name { font-size: 0.82rem; font-weight: 600; }
.conn-badge {
display: inline-block; font-size: 0.62rem; font-weight: 600; padding: 2px 7px; border-radius: 6px;
text-transform: uppercase; letter-spacing: 0.04em; margin-top: 2px;
}
.conn-badge--connected { background: rgba(52,211,153,0.15); color: #34d399; }
.conn-badge--disconnected { background: var(--rs-bg-surface, #0a0a0a); color: var(--rs-text-muted, #525252); }
.conn-badge--soon { background: transparent; color: var(--rs-text-muted, #525252); border: 1px solid var(--rs-border, #262626); }
.conn-meta { font-size: 0.72rem; color: var(--rs-text-muted, #525252); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.conn-btn {
padding: 5px 12px; border-radius: 6px; border: none; font-size: 0.72rem; font-weight: 600;
cursor: pointer; transition: opacity 0.15s; flex-shrink: 0; white-space: nowrap;
}
.conn-btn:hover { opacity: 0.85; }
.conn-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.conn-btn--connect { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; }
.conn-btn--disconnect { background: rgba(239,68,68,0.12); color: #f87171; }
.conn-share-section {
margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--rs-border, #262626);
}
.conn-share-label { font-size: 0.68rem; font-weight: 600; color: var(--rs-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
.conn-share-opt {
display: inline-flex; align-items: center; gap: 4px; font-size: 0.78rem; color: var(--rs-text-primary);
margin-right: 12px; margin-bottom: 2px; cursor: pointer;
}
.conn-share-opt input[type="checkbox"] { margin: 0; accent-color: #06b6d4; }
/* Guardian puzzle pieces SVG visualization */
.guardian-viz { display: flex; align-items: center; justify-content: center; gap: 8px; margin: 12px 0; }
.guardian-viz svg { transition: all 0.5s ease; }
.piece-empty { fill: #334155; opacity: 0.4; }
.piece-pending { fill: #fbbf24; opacity: 0.7; }
.piece-accepted { fill: #34d399; }
.piece-accepted.glow { filter: drop-shadow(0 0 6px rgba(52,211,153,0.6)); }
.key-outline { fill: none; stroke: #334155; stroke-width: 2; stroke-dasharray: 4 2; opacity: 0.4; }
.key-assembled { fill: url(#keyGrad); stroke: #34d399; stroke-width: 2; stroke-dasharray: none; opacity: 1; filter: drop-shadow(0 0 8px rgba(52,211,153,0.5)); }
@keyframes piece-slide { from { transform: translateX(0); } to { transform: translateX(var(--slide-x, 0px)) translateY(var(--slide-y, 0px)); } }
@keyframes key-glow { 0% { filter: drop-shadow(0 0 4px rgba(52,211,153,0.3)); } 100% { filter: drop-shadow(0 0 12px rgba(52,211,153,0.7)); } }
/* Recovery active banner */
.recovery-active-banner {
display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: 8px;
background: rgba(52,211,153,0.08); border: 1px solid rgba(52,211,153,0.2);
color: #34d399; font-size: 0.82rem; font-weight: 500; margin-bottom: 12px;
}
.recovery-active-banner svg { flex-shrink: 0; }
/* Drill section */
.drill-section { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--rs-border); }
.drill-btn {
padding: 8px 16px; border-radius: 8px; border: 1px solid rgba(251,191,36,0.3);
background: rgba(251,191,36,0.08); color: #fbbf24; font-size: 0.82rem; font-weight: 600;
cursor: pointer; transition: all 0.2s; width: 100%;
}
.drill-btn:hover { background: rgba(251,191,36,0.15); border-color: rgba(251,191,36,0.5); }
.drill-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.drill-timestamp { font-size: 0.72rem; color: var(--rs-text-muted); margin-top: 6px; }
.drill-live { padding: 12px; border-radius: 8px; background: var(--rs-bg-hover); border: 1px solid var(--rs-border); margin-top: 8px; }
.drill-live-title { font-size: 0.82rem; font-weight: 600; color: #fbbf24; margin-bottom: 8px; }
.drill-success { color: #34d399; font-weight: 600; font-size: 0.85rem; text-align: center; margin: 12px 0; }
/* Solo walkthrough overlay */
.walkthrough-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center; z-index: 10001;
animation: fadeIn 0.3s;
}
.walkthrough-card {
background: var(--rs-bg-surface, #1e1e2e); border: 1px solid var(--rs-border, #334155);
border-radius: 16px; padding: 2rem; max-width: 400px; width: 90%;
text-align: center; animation: slideUp 0.3s;
}
.walkthrough-icon { font-size: 3rem; margin-bottom: 0.5rem; transition: all 0.5s ease; }
.walkthrough-icon.red { filter: drop-shadow(0 0 12px rgba(239,68,68,0.6)); }
.walkthrough-icon.green { filter: drop-shadow(0 0 12px rgba(52,211,153,0.6)); }
.walkthrough-icon.amber { filter: drop-shadow(0 0 12px rgba(251,191,36,0.6)); }
.walkthrough-title {
font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem;
background: linear-gradient(135deg, #06b6d4, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.walkthrough-body { color: var(--rs-text-secondary, #94a3b8); font-size: 0.85rem; line-height: 1.6; margin-bottom: 1.5rem; }
.walkthrough-progress { display: flex; gap: 6px; justify-content: center; margin-bottom: 1rem; }
.walkthrough-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--rs-border, #334155); transition: background 0.3s; }
.walkthrough-dot.active { background: #06b6d4; }
.walkthrough-dot.done { background: #34d399; }
.walkthrough-nav { display: flex; gap: 8px; justify-content: center; }
.walkthrough-btn {
padding: 8px 20px; border-radius: 8px; border: none; font-size: 0.85rem; font-weight: 600;
cursor: pointer; transition: all 0.2s;
}
.walkthrough-btn--next { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; }
.walkthrough-btn--next:hover { transform: translateY(-1px); }
.walkthrough-btn--skip { background: transparent; color: var(--rs-text-muted, #64748b); border: 1px solid var(--rs-border, #334155); }
.walkthrough-btn--skip:hover { color: var(--rs-text-primary); }
/* Recovery section urgent pulsing */
.status-dot.pending.recovery-urgent { animation: recovery-pulse 2s ease-in-out infinite; }
@keyframes recovery-pulse { 0%,100% { box-shadow: 0 0 4px rgba(248,113,113,0.4); } 50% { box-shadow: 0 0 10px rgba(248,113,113,0.8); } }
`;
const ONBOARDING_STYLES = `
.onboarding-modal { max-width: 440px; }
.onboarding-card {
border: 1px solid var(--rs-border); border-radius: 12px;
padding: 1rem 1.25rem; display: flex; gap: 1rem; align-items: flex-start;
text-align: left; margin-top: 0.75rem; transition: border-color 0.2s;
}
.onboarding-card--primary {
border-color: rgba(6,182,212,0.4);
background: linear-gradient(135deg, rgba(6,182,212,0.06), rgba(124,58,237,0.06));
}
.onboarding-card-icon { font-size: 2rem; flex-shrink: 0; margin-top: 2px; }
.onboarding-card-content { flex: 1; min-width: 0; }
.onboarding-card-title {
font-size: 1rem; font-weight: 600; color: var(--rs-text-primary); margin-bottom: 4px;
}
.onboarding-card-desc {
font-size: 0.85rem; color: var(--rs-text-secondary); line-height: 1.5;
}
.onboarding-card-hint {
font-size: 0.8rem; color: #06b6d4; margin-top: 6px; font-weight: 500;
}
.onboarding-later-hint {
font-size: 0.75rem; color: var(--rs-text-muted); margin-top: 0.5rem;
}
.qr-container { text-align: center; margin: 0.75rem 0; }
.link-copy-row { display: flex; gap: 8px; align-items: stretch; }
.onboarding-expire-hint {
font-size: 0.75rem; color: var(--rs-text-muted); text-align: center; margin-top: 0.5rem;
}
`;
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); }
`;