605 lines
15 KiB
TypeScript
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);
|