From da48f6faf69a17e60e348ddfe4d9cb00b353f877 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 16 Feb 2026 17:50:48 -0700 Subject: [PATCH] feat: add EncryptID auth header and gate community creation behind sign-in Adds a persistent header bar with sign-in/sign-up across landing and canvas pages. The "Create Community Space" form now requires EncryptID authentication, showing a passkey auth modal if the user isn't signed in. Auth tokens are sent with the community creation API call. EncryptID WebAuthn modules are lazy-loaded only when auth is triggered. Co-Authored-By: Claude Opus 4.6 --- lib/rspace-header.ts | 780 +++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 3 +- vite.config.ts | 1 + website/canvas.html | 8 +- website/index.html | 37 +- 5 files changed, 821 insertions(+), 8 deletions(-) create mode 100644 lib/rspace-header.ts diff --git a/lib/rspace-header.ts b/lib/rspace-header.ts new file mode 100644 index 0000000..9f57ed7 --- /dev/null +++ b/lib/rspace-header.ts @@ -0,0 +1,780 @@ +/** + * 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; +} diff --git a/tsconfig.json b/tsconfig.json index 77166bd..a3aab77 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,8 @@ "baseUrl": ".", "paths": { "@lib": ["lib"], - "@lib/*": ["lib/*"] + "@lib/*": ["lib/*"], + "@encryptid/*": ["src/encryptid/*"] } }, "include": ["**/*.ts", "vite.config.ts"], diff --git a/vite.config.ts b/vite.config.ts index 67d8b42..3996254 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ resolve: { alias: { "@lib": resolve(__dirname, "./lib"), + "@encryptid": resolve(__dirname, "./src/encryptid"), }, }, build: { diff --git a/website/canvas.html b/website/canvas.html index 4582c5a..cf38e95 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -22,7 +22,7 @@ #toolbar { position: fixed; - top: 16px; + top: 72px; left: 50%; transform: translateX(-50%); display: flex; @@ -55,7 +55,7 @@ #community-info { position: fixed; - top: 16px; + top: 72px; left: 16px; padding: 8px 16px; background: white; @@ -271,6 +271,10 @@ PresenceManager, generatePeerId } from "@lib"; + import { mountHeader } from "@lib/rspace-header"; + + // Mount the header (light theme for canvas) + mountHeader({ theme: "light", showBrand: true }); // Register custom elements FolkShape.define(); diff --git a/website/index.html b/website/index.html index 71802c0..78f8c73 100644 --- a/website/index.html +++ b/website/index.html @@ -20,6 +20,7 @@ display: flex; flex-direction: column; align-items: center; + padding-top: 56px; } .hero { @@ -27,7 +28,7 @@ flex-direction: column; align-items: center; justify-content: center; - min-height: 100vh; + min-height: calc(100vh - 56px); width: 100%; } @@ -511,7 +512,12 @@ -