/** * rSpace Header Component * * Shared header bar with EncryptID sign-in/sign-up for all rSpace pages. * Reads session from localStorage and provides auth state to the page. */ const SESSION_KEY = 'encryptid_session'; const ENCRYPTID_URL = 'https://encryptid.jeffemmett.com'; interface SessionState { accessToken: string; claims: { sub: string; // DID exp: number; eid: { authLevel: number; capabilities: { encrypt: boolean; sign: boolean; wallet: boolean; }; }; }; } // ============================================================================ // SESSION HELPERS // ============================================================================ function getSession(): SessionState | null { try { const stored = localStorage.getItem(SESSION_KEY); if (!stored) return null; const session = JSON.parse(stored) as SessionState; const now = Math.floor(Date.now() / 1000); if (now >= session.claims.exp) { localStorage.removeItem(SESSION_KEY); return null; } return session; } catch { return null; } } function clearSession(): void { localStorage.removeItem(SESSION_KEY); } export function isAuthenticated(): boolean { return getSession() !== null; } export function getAccessToken(): string | null { return getSession()?.accessToken ?? null; } export function getUserDID(): string | null { return getSession()?.claims.sub ?? null; } // ============================================================================ // HEADER STYLES // ============================================================================ const HEADER_STYLES = ` .rspace-header { position: fixed; top: 0; left: 0; right: 0; height: 56px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; z-index: 9999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); } .rspace-header--dark { background: rgba(15, 23, 42, 0.85); border-bottom: 1px solid rgba(255, 255, 255, 0.08); } .rspace-header--light { background: rgba(255, 255, 255, 0.9); border-bottom: 1px solid rgba(0, 0, 0, 0.08); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } .rspace-header__brand { display: flex; align-items: center; gap: 10px; text-decoration: none; font-size: 1.25rem; font-weight: 700; } .rspace-header--dark .rspace-header__brand { color: white; } .rspace-header--light .rspace-header__brand { color: #0f172a; } .rspace-header__brand-gradient { background: linear-gradient(135deg, #14b8a6, #22d3ee); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .rspace-header__right { display: flex; align-items: center; gap: 12px; } .rspace-header__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; } .rspace-header--dark .rspace-header__signin-btn { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; } .rspace-header--dark .rspace-header__signin-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6, 182, 212, 0.3); } .rspace-header--light .rspace-header__signin-btn { background: linear-gradient(135deg, #06b6d4, #0891b2); color: white; } .rspace-header--light .rspace-header__signin-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6, 182, 212, 0.3); } .rspace-header__user { display: flex; align-items: center; gap: 10px; position: relative; cursor: pointer; } .rspace-header__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; } .rspace-header__user-did { font-size: 0.8rem; max-width: 140px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .rspace-header--dark .rspace-header__user-did { color: #94a3b8; } .rspace-header--light .rspace-header__user-did { color: #64748b; } .rspace-header__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; } .rspace-header__dropdown.open { display: block; } .rspace-header--dark .rspace-header__dropdown { background: #1e293b; border: 1px solid rgba(255, 255, 255, 0.1); } .rspace-header--light .rspace-header__dropdown { background: white; border: 1px solid rgba(0, 0, 0, 0.1); } .rspace-header__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; } .rspace-header--dark .rspace-header__dropdown-item { color: #e2e8f0; } .rspace-header--dark .rspace-header__dropdown-item:hover { background: rgba(255, 255, 255, 0.05); } .rspace-header--light .rspace-header__dropdown-item { color: #374151; } .rspace-header--light .rspace-header__dropdown-item:hover { background: #f1f5f9; } .rspace-header__dropdown-item--danger { color: #ef4444 !important; } .rspace-header__dropdown-divider { height: 1px; margin: 4px 0; } .rspace-header--dark .rspace-header__dropdown-divider { background: rgba(255, 255, 255, 0.08); } .rspace-header--light .rspace-header__dropdown-divider { background: rgba(0, 0, 0, 0.08); } /* Auth modal overlay */ .rspace-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; } .rspace-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; } .rspace-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; } .rspace-auth-modal p { color: #94a3b8; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; } .rspace-auth-modal__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; } .rspace-auth-modal__input:focus { border-color: #06b6d4; } .rspace-auth-modal__input::placeholder { color: #64748b; } .rspace-auth-modal__actions { display: flex; gap: 12px; margin-top: 0.5rem; } .rspace-auth-modal__btn { flex: 1; padding: 12px 20px; border-radius: 8px; border: none; font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s; } .rspace-auth-modal__btn--primary { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; } .rspace-auth-modal__btn--primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6, 182, 212, 0.3); } .rspace-auth-modal__btn--primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .rspace-auth-modal__btn--secondary { background: rgba(255, 255, 255, 0.08); color: #94a3b8; border: 1px solid rgba(255, 255, 255, 0.1); } .rspace-auth-modal__btn--secondary:hover { background: rgba(255, 255, 255, 0.12); color: white; } .rspace-auth-modal__error { color: #ef4444; font-size: 0.85rem; margin-top: 0.5rem; min-height: 1.2em; } .rspace-auth-modal__toggle { margin-top: 1rem; font-size: 0.85rem; color: #64748b; } .rspace-auth-modal__toggle a { color: #06b6d4; cursor: pointer; text-decoration: none; } .rspace-auth-modal__toggle a:hover { text-decoration: underline; } .rspace-auth-modal__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); } } `; // ============================================================================ // AUTH MODAL // ============================================================================ type AuthMode = 'signin' | 'register'; interface AuthModalCallbacks { onSuccess: () => void; onCancel: () => void; } let activeModal: HTMLElement | null = null; /** * Show the EncryptID auth modal for sign-in or registration. * Uses WebAuthn passkeys via the EncryptID server. */ export function showAuthModal(callbacks?: Partial): void { if (activeModal) return; const overlay = document.createElement('div'); overlay.className = 'rspace-auth-overlay'; let mode: AuthMode = 'signin'; function render() { overlay.innerHTML = mode === 'signin' ? renderSignIn() : renderRegister(); attachModalListeners(); } function renderSignIn(): string { return `

Sign in with EncryptID

Use your passkey to sign in instantly. No passwords needed — just your fingerprint, face, or device PIN.

Don't have an account? Create one
`; } function renderRegister(): string { return `

Create your EncryptID

Set up a secure, passwordless identity. Your passkey is stored on your device — we never see your private keys.

Already have an account? Sign in
`; } async function handleSignIn() { const errorEl = overlay.querySelector('#auth-error') as HTMLElement; const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement; errorEl.textContent = ''; btn.disabled = true; btn.innerHTML = ' Authenticating...'; try { // Dynamic import to avoid loading WebAuthn unless needed const { authenticatePasskey } = await import('@encryptid/webauthn'); const { getKeyManager } = await import('@encryptid/key-derivation'); const { getSessionManager } = await import('@encryptid/session'); const result = await authenticatePasskey(); const keyManager = getKeyManager(); if (result.prfOutput) { await keyManager.initFromPRF(result.prfOutput); } const keys = await keyManager.getKeys(); const sessionManager = getSessionManager(); await sessionManager.createSession(result, keys.did, { encrypt: true, sign: true, wallet: false, }); closeModal(); callbacks?.onSuccess?.(); } catch (err: any) { btn.disabled = false; btn.innerHTML = '🔑 Sign In with Passkey'; if (err.name === 'NotAllowedError') { errorEl.textContent = 'Authentication was cancelled or no passkey found.'; } else { errorEl.textContent = err.message || 'Authentication failed.'; } } } async function handleRegister() { const usernameInput = overlay.querySelector('#auth-username') as HTMLInputElement; const errorEl = overlay.querySelector('#auth-error') as HTMLElement; const btn = overlay.querySelector('[data-action="register"]') as HTMLButtonElement; const username = usernameInput.value.trim(); if (!username) { errorEl.textContent = 'Please enter a username.'; usernameInput.focus(); return; } errorEl.textContent = ''; btn.disabled = true; btn.innerHTML = ' Creating passkey...'; try { const { registerPasskey, authenticatePasskey } = await import('@encryptid/webauthn'); const { getKeyManager } = await import('@encryptid/key-derivation'); const { getSessionManager } = await import('@encryptid/session'); await registerPasskey(username, username); // Auto sign-in after registration const result = await authenticatePasskey(); const keyManager = getKeyManager(); if (result.prfOutput) { await keyManager.initFromPRF(result.prfOutput); } const keys = await keyManager.getKeys(); const sessionManager = getSessionManager(); await sessionManager.createSession(result, keys.did, { encrypt: true, sign: true, wallet: false, }); closeModal(); callbacks?.onSuccess?.(); } catch (err: any) { btn.disabled = false; btn.innerHTML = '🔐 Create Passkey'; if (err.name === 'NotAllowedError') { errorEl.textContent = 'Passkey creation was cancelled.'; } else { errorEl.textContent = err.message || 'Registration failed.'; } } } function attachModalListeners() { overlay.querySelector('[data-action="cancel"]')?.addEventListener('click', () => { closeModal(); 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(); // Focus the username input setTimeout(() => (overlay.querySelector('#auth-username') as HTMLInputElement)?.focus(), 50); }); overlay.querySelector('[data-action="switch-signin"]')?.addEventListener('click', () => { mode = 'signin'; render(); }); // Handle Enter key in username input overlay.querySelector('#auth-username')?.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') handleRegister(); }); // Close on overlay background click overlay.addEventListener('click', (e) => { if (e.target === overlay) { closeModal(); callbacks?.onCancel?.(); } }); } function closeModal() { activeModal?.remove(); activeModal = null; } activeModal = overlay; document.body.appendChild(overlay); render(); } // ============================================================================ // HEADER COMPONENT // ============================================================================ export interface HeaderOptions { /** 'dark' for dark pages (landing), 'light' for canvas */ theme: 'dark' | 'light'; /** Show the rSpace brand link on the left */ showBrand?: boolean; /** Called after successful sign-in/out to refresh page state */ onAuthChange?: () => void; } /** * Mount the rSpace header bar at the top of the page. * Handles sign-in/sign-up with EncryptID and shows user state. */ export function mountHeader(options: HeaderOptions): void { const { theme, showBrand = true, onAuthChange } = options; // Inject styles if (!document.getElementById('rspace-header-styles')) { const style = document.createElement('style'); style.id = 'rspace-header-styles'; style.textContent = HEADER_STYLES; document.head.appendChild(style); } // Create header element const header = document.createElement('header'); header.className = `rspace-header rspace-header--${theme}`; header.id = 'rspace-header'; function renderHeader() { const session = getSession(); const isLoggedIn = session !== null; const brandHTML = showBrand ? ` rSpace ` : '
'; if (isLoggedIn) { const did = session!.claims.sub; const shortDID = did.length > 24 ? did.slice(0, 16) + '...' + did.slice(-6) : did; const initial = did.slice(8, 10).toUpperCase(); header.innerHTML = ` ${brandHTML}
${initial}
${shortDID}
`; } else { header.innerHTML = ` ${brandHTML}
`; } attachHeaderListeners(); } function attachHeaderListeners() { // Sign in button document.getElementById('header-signin-btn')?.addEventListener('click', () => { showAuthModal({ onSuccess: () => { renderHeader(); onAuthChange?.(); }, }); }); // User toggle dropdown const userToggle = document.getElementById('header-user-toggle'); const dropdown = document.getElementById('header-dropdown'); if (userToggle && dropdown) { userToggle.addEventListener('click', (e) => { e.stopPropagation(); dropdown.classList.toggle('open'); }); document.addEventListener('click', () => { dropdown.classList.remove('open'); }); } // Dropdown actions header.querySelectorAll('[data-action]').forEach((el) => { el.addEventListener('click', (e) => { e.stopPropagation(); const action = (el as HTMLElement).dataset.action; switch (action) { case 'signout': clearSession(); renderHeader(); onAuthChange?.(); break; case 'profile': window.open(ENCRYPTID_URL, '_blank'); break; case 'recovery': window.open(`${ENCRYPTID_URL}/recover`, '_blank'); break; } document.getElementById('header-dropdown')?.classList.remove('open'); }); }); } // Mount to DOM document.body.prepend(header); renderHeader(); } /** * Require authentication before proceeding. * Returns true if user is authenticated, or shows auth modal and returns false. * Use the callback to continue after successful auth. */ export function requireAuth(onAuthenticated: () => void): boolean { if (isAuthenticated()) { return true; } showAuthModal({ onSuccess: onAuthenticated }); return false; }