/** * — Custom element for EncryptID sign-in/sign-out. * * Renders either a "Sign In" button or the user avatar + dropdown. * Contains the full WebAuthn auth modal (sign-in + register). * Refactored from lib/rspace-header.ts into a standalone web component. */ const SESSION_KEY = "encryptid_session"; const ENCRYPTID_URL = "https://auth.rspace.online"; interface SessionState { accessToken: string; claims: { sub: string; exp: number; username?: string; did?: string; eid: { authLevel: number; capabilities: { encrypt: boolean; sign: boolean; wallet: boolean }; }; }; } // ── Session helpers (exported for use by other code) ── export function getSession(): SessionState | null { try { const stored = localStorage.getItem(SESSION_KEY); if (!stored) return null; const session = JSON.parse(stored) as SessionState; if (Math.floor(Date.now() / 1000) >= session.claims.exp) { localStorage.removeItem(SESSION_KEY); localStorage.removeItem("rspace-username"); return null; } return session; } catch { return null; } } export function clearSession(): void { localStorage.removeItem(SESSION_KEY); localStorage.removeItem("rspace-username"); } export function isAuthenticated(): boolean { return getSession() !== null; } export function getAccessToken(): string | null { return getSession()?.accessToken ?? null; } export function getUserDID(): string | null { return getSession()?.claims.did ?? getSession()?.claims.sub ?? null; } export function getUsername(): string | null { return getSession()?.claims.username ?? null; } // ── Helpers ── function base64urlToBuffer(b64url: string): ArrayBuffer { const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); const pad = "=".repeat((4 - (b64.length % 4)) % 4); const bin = atob(b64 + pad); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes.buffer; } function bufferToBase64url(buf: ArrayBuffer): string { const bytes = new Uint8Array(buf); let bin = ""; for (let i = 0; i < bytes.byteLength; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } function parseJWT(token: string): Record { const parts = token.split("."); if (parts.length < 2) return {}; try { const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); const pad = "=".repeat((4 - (b64.length % 4)) % 4); return JSON.parse(atob(b64 + pad)); } catch { return {}; } } function storeSession(token: string, username: string, did: string): void { const payload = parseJWT(token) as Record; const session: SessionState = { accessToken: token, claims: { sub: payload.sub || "", exp: payload.exp || 0, username, did, eid: payload.eid || { authLevel: 3, capabilities: { encrypt: true, sign: true, wallet: false } }, }, }; localStorage.setItem(SESSION_KEY, JSON.stringify(session)); if (username) localStorage.setItem("rspace-username", username); } // ── The custom element ── export class RStackIdentity extends HTMLElement { #shadow: ShadowRoot; constructor() { super(); this.#shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.#render(); } #render() { const session = getSession(); const theme = this.closest("[data-theme]")?.getAttribute("data-theme") || "light"; if (session) { const username = session.claims.username || ""; const did = session.claims.did || session.claims.sub; const displayName = username || (did.length > 24 ? did.slice(0, 16) + "..." + did.slice(-6) : did); const initial = username ? username[0].toUpperCase() : did.slice(8, 10).toUpperCase(); this.#shadow.innerHTML = `
${initial}
${displayName}
`; const toggle = this.#shadow.getElementById("user-toggle")!; const dropdown = this.#shadow.getElementById("dropdown")!; toggle.addEventListener("click", (e) => { e.stopPropagation(); dropdown.classList.toggle("open"); }); document.addEventListener("click", () => dropdown.classList.remove("open")); this.#shadow.querySelectorAll("[data-action]").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); const action = (el as HTMLElement).dataset.action; dropdown.classList.remove("open"); if (action === "signout") { clearSession(); this.#render(); this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); } else if (action === "profile") { window.open(ENCRYPTID_URL, "_blank"); } else if (action === "recovery") { window.open(`${ENCRYPTID_URL}/recover`, "_blank"); } }); }); } else { this.#shadow.innerHTML = ` `; this.#shadow.getElementById("signin-btn")!.addEventListener("click", () => { this.showAuthModal(); }); } } /** Public method: show the auth modal programmatically */ showAuthModal(callbacks?: { onSuccess?: () => void; onCancel?: () => void }): void { if (document.querySelector(".rstack-auth-overlay")) return; const overlay = document.createElement("div"); overlay.className = "rstack-auth-overlay"; let mode: "signin" | "register" = "signin"; const render = () => { overlay.innerHTML = mode === "signin" ? signinHTML() : registerHTML(); attachListeners(); }; const signinHTML = () => `

Sign in with EncryptID

Use your passkey to sign in. No passwords needed.

Don't have an account? Create one
`; const registerHTML = () => `

Create your EncryptID

Set up a secure, passwordless identity.

Already have an account? Sign in
`; const close = () => { overlay.remove(); }; const handleSignIn = async () => { const errEl = overlay.querySelector("#auth-error") as HTMLElement; const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement; errEl.textContent = ""; btn.disabled = true; btn.innerHTML = ' Authenticating...'; try { const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); if (!startRes.ok) throw new Error("Failed to start authentication"); const { options: serverOptions } = await startRes.json(); const credential = (await navigator.credentials.get({ publicKey: { challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)), rpId: serverOptions.rpId || "rspace.online", userVerification: "required", timeout: 60000, }, })) as PublicKeyCredential; if (!credential) throw new Error("Authentication failed"); const completeRes = await fetch(`${ENCRYPTID_URL}/api/auth/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ challenge: serverOptions.challenge, credential: { credentialId: bufferToBase64url(credential.rawId) }, }), }); const data = await completeRes.json(); if (!completeRes.ok || !data.success) throw new Error(data.error || "Authentication failed"); storeSession(data.token, data.username || "", data.did || ""); close(); this.#render(); this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); callbacks?.onSuccess?.(); } catch (err: any) { btn.disabled = false; btn.innerHTML = "🔑 Sign In with Passkey"; errEl.textContent = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed."; } }; const handleRegister = async () => { const usernameInput = overlay.querySelector("#auth-username") as HTMLInputElement; const errEl = overlay.querySelector("#auth-error") as HTMLElement; const btn = overlay.querySelector('[data-action="register"]') as HTMLButtonElement; const username = usernameInput.value.trim(); if (!username) { errEl.textContent = "Please enter a username."; usernameInput.focus(); return; } errEl.textContent = ""; btn.disabled = true; btn.innerHTML = ' Creating passkey...'; try { const startRes = await fetch(`${ENCRYPTID_URL}/api/register/start`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, displayName: username }), }); if (!startRes.ok) throw new Error("Failed to start registration"); const { options: serverOptions, userId } = await startRes.json(); const credential = (await navigator.credentials.create({ publicKey: { challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)), rp: { id: serverOptions.rp?.id || "rspace.online", name: serverOptions.rp?.name || "EncryptID" }, user: { id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)), name: username, displayName: username }, pubKeyCredParams: serverOptions.pubKeyCredParams || [ { alg: -7, type: "public-key" as const }, { alg: -257, type: "public-key" as const }, ], authenticatorSelection: { residentKey: "required", requireResidentKey: true, userVerification: "required" }, attestation: "none", timeout: 60000, }, })) as PublicKeyCredential; if (!credential) throw new Error("Failed to create credential"); const response = credential.response as AuthenticatorAttestationResponse; const publicKey = response.getPublicKey?.(); const completeRes = await fetch(`${ENCRYPTID_URL}/api/register/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ challenge: serverOptions.challenge, credential: { credentialId: bufferToBase64url(credential.rawId), publicKey: publicKey ? bufferToBase64url(publicKey) : "", transports: response.getTransports?.() || [], }, userId, username, }), }); const data = await completeRes.json(); if (!completeRes.ok || !data.success) throw new Error(data.error || "Registration failed"); storeSession(data.token, username, data.did || ""); close(); this.#render(); this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true })); callbacks?.onSuccess?.(); } catch (err: any) { btn.disabled = false; btn.innerHTML = "🔐 Create Passkey"; errEl.textContent = err.name === "NotAllowedError" ? "Passkey creation was cancelled." : err.message || "Registration failed."; } }; const attachListeners = () => { overlay.querySelector('[data-action="cancel"]')?.addEventListener("click", () => { close(); callbacks?.onCancel?.(); }); overlay.querySelector('[data-action="signin"]')?.addEventListener("click", handleSignIn); overlay.querySelector('[data-action="register"]')?.addEventListener("click", handleRegister); overlay.querySelector('[data-action="switch-register"]')?.addEventListener("click", () => { mode = "register"; render(); setTimeout(() => (overlay.querySelector("#auth-username") as HTMLInputElement)?.focus(), 50); }); overlay.querySelector('[data-action="switch-signin"]')?.addEventListener("click", () => { mode = "signin"; render(); }); overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter") handleRegister(); }); overlay.addEventListener("click", (e) => { if (e.target === overlay) { close(); callbacks?.onCancel?.(); } }); }; document.body.appendChild(overlay); render(); } static define(tag = "rstack-identity") { if (!customElements.get(tag)) customElements.define(tag, RStackIdentity); } } // ── Require auth helper (for use by module code) ── export function requireAuth(onAuthenticated: () => void): boolean { if (isAuthenticated()) return true; const el = document.querySelector("rstack-identity") as RStackIdentity | null; if (el) { el.showAuthModal({ onSuccess: onAuthenticated }); } return false; } // ── Styles ── const STYLES = ` :host { display: contents; } .signin-btn { display: flex; align-items: center; gap: 8px; padding: 8px 20px; border-radius: 8px; border: none; font-size: 0.875rem; font-weight: 600; cursor: pointer; transition: all 0.2s; text-decoration: none; } .signin-btn.light { background: linear-gradient(135deg, #06b6d4, #0891b2); color: white; } .signin-btn.dark { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; } .signin-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); } .user { display: flex; align-items: center; gap: 10px; position: relative; cursor: pointer; } .avatar { width: 34px; height: 34px; border-radius: 50%; background: linear-gradient(135deg, #06b6d4, #7c3aed); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.8rem; color: white; } .name { font-size: 0.8rem; max-width: 140px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .user.light .name { color: #64748b; } .user.dark .name { color: #94a3b8; } .dropdown { position: absolute; top: 100%; right: 0; margin-top: 8px; min-width: 200px; border-radius: 10px; overflow: hidden; box-shadow: 0 8px 30px rgba(0,0,0,0.2); display: none; z-index: 100; } .dropdown.open { display: block; } .user.light .dropdown { background: white; border: 1px solid rgba(0,0,0,0.1); } .user.dark .dropdown { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); } .dropdown-item { display: flex; align-items: center; gap: 10px; padding: 12px 16px; font-size: 0.875rem; cursor: pointer; transition: background 0.15s; border: none; background: none; width: 100%; text-align: left; } .user.light .dropdown-item { color: #374151; } .user.light .dropdown-item:hover { background: #f1f5f9; } .user.dark .dropdown-item { color: #e2e8f0; } .user.dark .dropdown-item:hover { background: rgba(255,255,255,0.05); } .dropdown-item--danger { color: #ef4444 !important; } .dropdown-divider { height: 1px; margin: 4px 0; } .user.light .dropdown-divider { background: rgba(0,0,0,0.08); } .user.dark .dropdown-divider { background: rgba(255,255,255,0.08); } `; const MODAL_STYLES = ` .rstack-auth-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 10000; animation: fadeIn 0.2s; } .auth-modal { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2rem; max-width: 420px; width: 90%; text-align: center; color: white; box-shadow: 0 20px 60px rgba(0,0,0,0.4); animation: slideUp 0.3s; } .auth-modal h2 { font-size: 1.5rem; margin-bottom: 0.5rem; background: linear-gradient(135deg, #06b6d4, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .auth-modal p { color: #94a3b8; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; } .input { width: 100%; padding: 12px 16px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); color: white; font-size: 1rem; margin-bottom: 1rem; outline: none; transition: border-color 0.2s; box-sizing: border-box; } .input:focus { border-color: #06b6d4; } .input::placeholder { color: #64748b; } .actions { display: flex; gap: 12px; margin-top: 0.5rem; } .btn { flex: 1; padding: 12px 20px; border-radius: 8px; border: none; font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s; } .btn--primary { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; } .btn--primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); } .btn--primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .btn--secondary { background: rgba(255,255,255,0.08); color: #94a3b8; border: 1px solid rgba(255,255,255,0.1); } .btn--secondary:hover { background: rgba(255,255,255,0.12); color: white; } .error { color: #ef4444; font-size: 0.85rem; margin-top: 0.5rem; min-height: 1.2em; } .toggle { margin-top: 1rem; font-size: 0.85rem; color: #64748b; } .toggle a { color: #06b6d4; cursor: pointer; text-decoration: none; } .toggle a:hover { text-decoration: underline; } .spinner { display: inline-block; width: 18px; height: 18px; border: 2px solid transparent; border-top-color: currentColor; border-radius: 50%; animation: spin 0.7s linear infinite; vertical-align: middle; margin-right: 6px; } @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); } } `;