/** * EncryptID Login Button Component * * A customizable login button that handles WebAuthn authentication * and key derivation. Can be embedded in any r-ecosystem app. */ import { registerPasskey, authenticatePasskey, detectCapabilities, startConditionalUI, WebAuthnCapabilities, } from '../webauthn'; import { getKeyManager } from '../key-derivation'; import { getSessionManager, AuthLevel } from '../session'; import { getDocBridge, resetDocBridge } from '../../../shared/local-first/encryptid-bridge'; import { getVaultManager, resetVaultManager } from '../vault'; import { syncWalletsOnLogin } from '../wallet-sync'; // ============================================================================ // STYLES // ============================================================================ const styles = ` :host { --eid-primary: #06b6d4; --eid-primary-hover: #0891b2; --eid-bg: #0f172a; --eid-bg-hover: #1e293b; --eid-text: #f1f5f9; --eid-text-secondary: #94a3b8; --eid-radius: 8px; --eid-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3); display: inline-block; font-family: system-ui, -apple-system, sans-serif; } .login-container { position: relative; } .login-btn { display: flex; align-items: center; gap: 12px; padding: 12px 24px; background: var(--eid-primary); color: white; border: none; border-radius: var(--eid-radius); font-size: 1rem; font-weight: 500; cursor: pointer; transition: all 0.2s; box-shadow: var(--eid-shadow); } .login-btn:hover { background: var(--eid-primary-hover); transform: translateY(-1px); } .login-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } .login-btn.outline { background: transparent; border: 2px solid var(--eid-primary); color: var(--eid-primary); } .login-btn.outline:hover { background: var(--eid-primary); color: white; } .login-btn.small { padding: 8px 16px; font-size: 0.875rem; } .login-btn.large { padding: 16px 32px; font-size: 1.125rem; } .passkey-icon { width: 24px; height: 24px; } .login-btn.small .passkey-icon { width: 18px; height: 18px; } .login-btn.large .passkey-icon { width: 28px; height: 28px; } .user-info { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: var(--eid-bg); border-radius: var(--eid-radius); color: var(--eid-text); } .user-avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--eid-primary); display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.875rem; } .user-details { flex: 1; min-width: 0; } .user-did { font-size: 0.75rem; color: var(--eid-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; } .auth-level { font-size: 0.625rem; padding: 2px 6px; border-radius: 4px; background: var(--eid-bg-hover); } .auth-level.elevated { background: rgba(34, 197, 94, 0.2); color: #22c55e; } .auth-level.standard { background: rgba(234, 179, 8, 0.2); color: #eab308; } .logout-btn { padding: 6px 12px; background: transparent; border: 1px solid var(--eid-text-secondary); border-radius: var(--eid-radius); color: var(--eid-text-secondary); font-size: 0.75rem; cursor: pointer; transition: all 0.2s; } .logout-btn:hover { border-color: #ef4444; color: #ef4444; } .dropdown { position: absolute; top: 100%; right: 0; margin-top: 8px; background: var(--eid-bg); border-radius: var(--eid-radius); box-shadow: var(--eid-shadow); min-width: 200px; z-index: 100; overflow: hidden; } .dropdown-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; color: var(--eid-text); text-decoration: none; cursor: pointer; transition: background 0.2s; } .dropdown-item:hover { background: var(--eid-bg-hover); } .dropdown-item.danger { color: #ef4444; } .dropdown-divider { height: 1px; background: #334155; margin: 4px 0; } .loading-spinner { width: 20px; height: 20px; border: 2px solid transparent; border-top-color: currentColor; border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .hidden { display: none; } `; // ============================================================================ // PASSKEY SVG ICON // ============================================================================ const PASSKEY_ICON = ` `; // ============================================================================ // WEB COMPONENT // ============================================================================ export class EncryptIDLoginButton extends HTMLElement { private shadow: ShadowRoot; private loading: boolean = false; private showDropdown: boolean = false; private capabilities: WebAuthnCapabilities | null = null; // Configurable attributes static get observedAttributes() { return ['size', 'variant', 'label', 'show-user']; } constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); } async connectedCallback() { // Detect capabilities this.capabilities = await detectCapabilities(); // Start conditional UI if available if (this.capabilities.conditionalUI) { this.startConditionalAuth(); } this.render(); // Close dropdown on outside click document.addEventListener('click', (e) => { if (!this.contains(e.target as Node)) { this.showDropdown = false; this.render(); } }); } attributeChangedCallback() { this.render(); } private get size(): 'small' | 'medium' | 'large' { return (this.getAttribute('size') as any) || 'medium'; } private get variant(): 'primary' | 'outline' { return (this.getAttribute('variant') as any) || 'primary'; } private get label(): string { return this.getAttribute('label') || 'Sign in with Passkey'; } private get showUser(): boolean { return this.hasAttribute('show-user'); } private render() { const session = getSessionManager(); const isLoggedIn = session.isValid(); const did = session.getDID(); const authLevel = session.getAuthLevel(); this.shadow.innerHTML = `
${isLoggedIn && this.showUser ? this.renderUserInfo(did!, authLevel) : this.renderLoginButton()} ${this.showDropdown ? this.renderDropdown() : ''}
`; this.attachEventListeners(); } private renderLoginButton(): string { const sizeClass = this.size === 'medium' ? '' : this.size; const variantClass = this.variant === 'primary' ? '' : this.variant; return ` `; } private renderUserInfo(did: string, authLevel: AuthLevel): string { const shortDID = did.slice(0, 20) + '...' + did.slice(-8); const initial = did.slice(8, 10).toUpperCase(); const levelName = AuthLevel[authLevel].toLowerCase(); return `
${initial}
${shortDID}
${levelName}
`; } private renderDropdown(): string { return ` `; } private attachEventListeners() { const session = getSessionManager(); const isLoggedIn = session.isValid(); if (isLoggedIn && this.showUser) { // User info click - toggle dropdown this.shadow.querySelector('.user-info')?.addEventListener('click', () => { this.showDropdown = !this.showDropdown; this.render(); }); // Dropdown actions this.shadow.querySelectorAll('.dropdown-item').forEach(item => { item.addEventListener('click', (e) => { e.stopPropagation(); const action = (item as HTMLElement).dataset.action; this.handleDropdownAction(action!); }); }); } else { // Login button click this.shadow.querySelector('.login-btn')?.addEventListener('click', () => { this.handleLogin(); }); } } private async handleLogin() { if (this.loading) return; this.loading = true; this.render(); try { // Try to authenticate with existing passkey const result = await authenticatePasskey(); // Initialize key manager with PRF output const keyManager = getKeyManager(); if (result.prfOutput) { await keyManager.initFromPRF(result.prfOutput); // Initialize doc encryption bridge for local-first encrypted storage await getDocBridge().initFromAuth(result.prfOutput); // Load encrypted account vault in background const docCrypto = getDocBridge().getDocCrypto(); if (docCrypto) { getVaultManager(docCrypto).load() .then(() => syncWalletsOnLogin(docCrypto)) .catch(err => console.warn('Vault load failed:', err)); } } // Get derived keys const keys = await keyManager.getKeys(); // Create session const sessionManager = getSessionManager(); await sessionManager.createSession(result, keys.did, { encrypt: true, sign: true, wallet: !!keys.eoaAddress, }); // Dispatch success event this.dispatchEvent(new CustomEvent('login-success', { detail: { did: keys.did, credentialId: result.credentialId, prfAvailable: !!result.prfOutput, }, bubbles: true, })); } catch (error: any) { // If no credential found, offer to register if (error.name === 'NotAllowedError' || error.message?.includes('No credential')) { this.dispatchEvent(new CustomEvent('login-register-needed', { bubbles: true, })); } else { this.dispatchEvent(new CustomEvent('login-error', { detail: { error: error.message }, bubbles: true, })); } } finally { this.loading = false; this.render(); } } private async handleDropdownAction(action: string) { this.showDropdown = false; switch (action) { case 'profile': this.dispatchEvent(new CustomEvent('navigate', { detail: { path: '/profile' }, bubbles: true, })); break; case 'recovery': this.dispatchEvent(new CustomEvent('navigate', { detail: { path: '/recovery' }, bubbles: true, })); break; case 'upgrade': await this.handleUpgradeAuth(); break; case 'logout': this.handleLogout(); break; } this.render(); } private async handleUpgradeAuth() { try { const result = await authenticatePasskey(); const sessionManager = getSessionManager(); sessionManager.upgradeAuthLevel(AuthLevel.ELEVATED); this.dispatchEvent(new CustomEvent('auth-upgraded', { detail: { level: AuthLevel.ELEVATED }, bubbles: true, })); } catch (error) { console.error('Failed to upgrade auth:', error); } } private handleLogout() { const sessionManager = getSessionManager(); sessionManager.clearSession(); const keyManager = getKeyManager(); keyManager.clear(); // Clear vault and doc encryption bridge key material resetVaultManager(); resetDocBridge(); this.dispatchEvent(new CustomEvent('logout', { bubbles: true })); this.render(); } private async startConditionalAuth() { // Start listening for conditional UI (passkey autofill) try { const result = await startConditionalUI(); if (result) { // User selected a passkey from autofill const keyManager = getKeyManager(); if (result.prfOutput) { await keyManager.initFromPRF(result.prfOutput); await getDocBridge().initFromAuth(result.prfOutput); // Load encrypted account vault in background const docCrypto = getDocBridge().getDocCrypto(); if (docCrypto) { getVaultManager(docCrypto).load() .then(() => syncWalletsOnLogin(docCrypto)) .catch(err => console.warn('Vault load failed:', err)); } } const keys = await keyManager.getKeys(); const sessionManager = getSessionManager(); await sessionManager.createSession(result, keys.did, { encrypt: true, sign: true, wallet: !!keys.eoaAddress, }); this.dispatchEvent(new CustomEvent('login-success', { detail: { did: keys.did, credentialId: result.credentialId, prfAvailable: !!result.prfOutput, viaConditionalUI: true, }, bubbles: true, })); this.render(); } } catch (error) { // Conditional UI cancelled or not available console.log('Conditional UI not completed:', error); } } /** * Public method to trigger registration flow */ async register(username: string, displayName: string): Promise { this.loading = true; this.render(); try { const credential = await registerPasskey(username, displayName); this.dispatchEvent(new CustomEvent('register-success', { detail: { credentialId: credential.credentialId, prfSupported: credential.prfSupported, }, bubbles: true, })); // Auto-login after registration await this.handleLogin(); } catch (error: any) { this.dispatchEvent(new CustomEvent('register-error', { detail: { error: error.message }, bubbles: true, })); } finally { this.loading = false; this.render(); } } } // Register the custom element customElements.define('encryptid-login', EncryptIDLoginButton);