rspace-online/src/encryptid/ui/login-button.ts

605 lines
15 KiB
TypeScript

/**
* 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 = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="passkey-icon">
<circle cx="12" cy="10" r="3"/>
<path d="M12 13v8"/>
<path d="M9 18h6"/>
<circle cx="12" cy="10" r="7"/>
</svg>
`;
// ============================================================================
// 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 = `
<style>${styles}</style>
<div class="login-container">
${isLoggedIn && this.showUser ? this.renderUserInfo(did!, authLevel) : this.renderLoginButton()}
${this.showDropdown ? this.renderDropdown() : ''}
</div>
`;
this.attachEventListeners();
}
private renderLoginButton(): string {
const sizeClass = this.size === 'medium' ? '' : this.size;
const variantClass = this.variant === 'primary' ? '' : this.variant;
return `
<button class="login-btn ${sizeClass} ${variantClass}" ${this.loading ? 'disabled' : ''}>
${this.loading
? '<div class="loading-spinner"></div>'
: PASSKEY_ICON
}
<span>${this.loading ? 'Authenticating...' : this.label}</span>
</button>
`;
}
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 `
<div class="user-info" style="cursor: pointer;">
<div class="user-avatar">${initial}</div>
<div class="user-details">
<div class="user-did">${shortDID}</div>
<span class="auth-level ${levelName}">${levelName}</span>
</div>
</div>
`;
}
private renderDropdown(): string {
return `
<div class="dropdown">
<div class="dropdown-item" data-action="profile">
👤 Profile
</div>
<div class="dropdown-item" data-action="recovery">
🛡️ Recovery Settings
</div>
<div class="dropdown-item" data-action="upgrade">
🔐 Upgrade Auth Level
</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item danger" data-action="logout">
🚪 Sign Out
</div>
</div>
`;
}
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<void> {
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);