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

1137 lines
33 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,
base64urlToBuffer,
bufferToBase64url,
detectCapabilities,
startConditionalUI,
WebAuthnCapabilities,
} from '../webauthn';
import { getKeyManager, EncryptIDKeyManager } 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';
// ============================================================================
// KNOWN ACCOUNTS (localStorage)
// ============================================================================
const KNOWN_ACCOUNTS_KEY = 'encryptid-known-accounts';
const ENCRYPTID_AUTH = 'https://auth.rspace.online';
interface KnownAccount {
username: string;
displayName?: string;
}
function getKnownAccounts(): KnownAccount[] {
try {
return JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || '[]');
} catch { return []; }
}
function addKnownAccount(account: KnownAccount): void {
const accounts = getKnownAccounts().filter(a => a.username !== account.username);
accounts.unshift(account); // most recent first
localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts));
}
function removeKnownAccount(username: string): void {
const accounts = getKnownAccounts().filter(a => a.username !== username);
localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts));
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ============================================================================
// 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;
}
.account-list {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 200px;
}
.account-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: var(--eid-bg);
border: 1px solid transparent;
border-radius: var(--eid-radius);
color: var(--eid-text);
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
font-family: inherit;
text-align: left;
width: 100%;
box-sizing: border-box;
}
.account-item:hover {
background: var(--eid-bg-hover);
border-color: var(--eid-primary);
}
.account-item .account-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--eid-primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.75rem;
color: white;
flex-shrink: 0;
}
.account-item .account-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alt-action {
display: block;
margin-top: 8px;
padding: 0;
background: none;
border: none;
color: var(--eid-text-secondary);
font-size: 0.8rem;
cursor: pointer;
text-align: center;
width: 100%;
font-family: inherit;
}
.alt-action:hover {
color: var(--eid-primary);
text-decoration: underline;
}
.username-form {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 200px;
}
.username-input {
padding: 10px 14px;
background: var(--eid-bg);
border: 1px solid var(--eid-text-secondary);
border-radius: var(--eid-radius);
color: var(--eid-text);
font-size: 0.95rem;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.username-input:focus {
border-color: var(--eid-primary);
}
.username-input::placeholder {
color: var(--eid-text-secondary);
opacity: 0.7;
}
.email-fallback-section {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255,255,255,0.1);
}
.email-sent-msg {
font-size: 0.85rem;
color: #22c55e;
text-align: center;
padding: 8px;
}
.error-msg {
font-size: 0.85rem;
color: #f87171;
background: rgba(248,113,113,0.1);
border: 1px solid rgba(248,113,113,0.25);
border-radius: var(--eid-radius);
padding: 10px 14px;
margin-bottom: 8px;
line-height: 1.45;
}
.compat-note {
font-size: 0.85rem;
color: #fbbf24;
background: rgba(251,191,36,0.1);
border: 1px solid rgba(251,191,36,0.25);
border-radius: var(--eid-radius);
padding: 10px 14px;
margin-bottom: 8px;
line-height: 1.45;
text-align: center;
}
.passphrase-overlay {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
}
.passphrase-modal {
background: var(--eid-bg, #0f172a);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 12px;
padding: 24px;
max-width: 380px;
width: 90vw;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
`;
// ============================================================================
// 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 showEmailFallback: boolean = false;
private emailSent: boolean = false;
private errorMessage: string = '';
private showPassphrasePrompt: boolean = false;
private passphraseResolver: ((passphrase: string) => void) | null = null;
private capabilities: WebAuthnCapabilities | null = null;
// Configurable attributes
static get observedAttributes() {
return ['size', 'variant', 'label', 'show-user'];
}
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
private _onSessionRevoked = () => {
// Another browser session logged out — clear local state and re-render
const keyManager = getKeyManager();
keyManager.clear();
resetVaultManager();
resetDocBridge();
this.dispatchEvent(new CustomEvent('logout', { bubbles: true }));
this.render();
};
async connectedCallback() {
// Listen for cross-session logout revocation
window.addEventListener('encryptid:session-revoked', this._onSessionRevoked);
// 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');
}
disconnectedCallback() {
window.removeEventListener('encryptid:session-revoked', this._onSessionRevoked);
}
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.showPassphrasePrompt ? this.renderPassphraseModal() : ''}
`;
this.attachEventListeners();
}
private renderLoginButton(): string {
const sizeClass = this.size === 'medium' ? '' : this.size;
const variantClass = this.variant === 'primary' ? '' : this.variant;
if (this.loading) {
return `
<button class="login-btn ${sizeClass} ${variantClass}" disabled>
<div class="loading-spinner"></div>
<span>Authenticating...</span>
</button>
`;
}
// WebAuthn unavailable — show email-only login
if (this.capabilities?.webauthn === false) {
return this.renderEmailOnlyFallback(sizeClass, variantClass);
}
const errorDiv = this.errorMessage
? `<div class="error-msg">${escapeHtml(this.errorMessage)}</div>`
: '';
const accounts = getKnownAccounts();
// No known accounts → passkey-first button + email fallback
if (accounts.length === 0) {
const emailSection = this.showEmailFallback ? (this.emailSent
? `<div class="email-fallback-section"><div class="email-sent-msg">Login link sent! Check your inbox.</div></div>`
: `<div class="email-fallback-section">
<input class="username-input" type="email" placeholder="you@example.com" data-email-input />
<button class="login-btn small ${variantClass}" data-action="send-magic-link">Send magic link</button>
</div>`) : '';
return `
<div class="username-form">
${errorDiv}
<button class="login-btn ${sizeClass} ${variantClass}" data-action="passkey-login">
${PASSKEY_ICON}
<span>${this.label}</span>
</button>
${this.showEmailFallback ? '' : `<button class="alt-action" data-action="show-email">No passkey? Sign in with email</button>`}
${emailSection}
</div>
`;
}
// 1 known account → "Sign in as [username]" + alt link
if (accounts.length === 1) {
const name = escapeHtml(accounts[0].displayName || accounts[0].username);
return `
${errorDiv}
<button class="login-btn ${sizeClass} ${variantClass}" data-username="${escapeHtml(accounts[0].username)}">
${PASSKEY_ICON}
<span>Sign in as ${name}</span>
</button>
<button class="alt-action" data-action="different-account">Use a different account</button>
`;
}
// Multiple accounts → picker list
const items = accounts.map(a => {
const name = escapeHtml(a.displayName || a.username);
const initial = escapeHtml((a.displayName || a.username).slice(0, 2).toUpperCase());
return `
<button class="account-item" data-username="${escapeHtml(a.username)}">
<span class="account-avatar">${initial}</span>
<span class="account-name">${name}</span>
</button>
`;
}).join('');
return `
${errorDiv}
<div class="account-list">
${items}
</div>
<button class="alt-action" data-action="different-account">Use a different account</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>
`;
}
/**
* Show passphrase modal and return the entered passphrase.
* Resolves when user submits; returns empty string if dismissed.
*/
private promptPassphrase(): Promise<string> {
return new Promise((resolve) => {
this.passphraseResolver = resolve;
this.showPassphrasePrompt = true;
this.loading = false; // allow modal interaction
this.render();
});
}
private renderPassphraseModal(): string {
return `
<div class="passphrase-overlay">
<div class="passphrase-modal">
<div class="compat-note" style="margin-bottom:12px">
Your browser doesn't support hardware encryption.<br>
Enter your encryption passphrase to unlock your documents.
</div>
<input class="username-input" type="password" placeholder="Encryption passphrase" data-passphrase-input autocomplete="off" />
<button class="login-btn small" data-action="submit-passphrase" style="width:100%;justify-content:center;margin-top:8px">Unlock</button>
<div style="font-size:0.75rem;color:var(--eid-text-secondary);text-align:center;margin-top:8px">
This passphrase encrypts your documents. It's never sent to any server.
</div>
</div>
</div>`;
}
private renderEmailOnlyFallback(sizeClass: string, variantClass: string): string {
if (this.emailSent) {
return `
<div class="username-form">
<div class="compat-note">Passkeys are not available in this browser.</div>
<div class="email-fallback-section"><div class="email-sent-msg">Login link sent! Check your inbox.</div></div>
</div>`;
}
return `
<div class="username-form">
<div class="compat-note">Passkeys are not available in this browser.</div>
<div class="email-fallback-section">
<input class="username-input" type="email" placeholder="you@example.com" data-email-input />
<button class="login-btn small ${variantClass}" data-action="send-magic-link">Sign in with email</button>
</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 {
// Passkey-first button (no known accounts → unscoped auth)
this.shadow.querySelector('[data-action="passkey-login"]')?.addEventListener('click', () => {
this.handleLogin();
});
// "No passkey? Sign in with email" toggle
this.shadow.querySelector('[data-action="show-email"]')?.addEventListener('click', () => {
this.showEmailFallback = true;
this.render();
});
// Send magic link button + enter key
this.shadow.querySelector('[data-action="send-magic-link"]')?.addEventListener('click', () => {
this.sendMagicLink();
});
const emailInput = this.shadow.querySelector('[data-email-input]') as HTMLInputElement;
emailInput?.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Enter') this.sendMagicLink();
});
// Login button with scoped username (1 known account)
const loginBtn = this.shadow.querySelector('.login-btn:not([data-action])');
if (loginBtn) {
loginBtn.addEventListener('click', () => {
const username = (loginBtn as HTMLElement).dataset.username;
this.handleLogin(username);
});
}
// Account list items (multiple accounts)
this.shadow.querySelectorAll('.account-item').forEach(item => {
item.addEventListener('click', () => {
const username = (item as HTMLElement).dataset.username;
if (username) this.handleLogin(username);
});
});
// "Use a different account" → unscoped auth
this.shadow.querySelector('[data-action="different-account"]')?.addEventListener('click', () => {
this.handleLogin();
});
}
// Passphrase modal submit
this.shadow.querySelector('[data-action="submit-passphrase"]')?.addEventListener('click', () => {
const input = this.shadow.querySelector('[data-passphrase-input]') as HTMLInputElement;
const passphrase = input?.value || '';
if (passphrase && this.passphraseResolver) {
this.showPassphrasePrompt = false;
this.passphraseResolver(passphrase);
this.passphraseResolver = null;
}
});
const ppInput = this.shadow.querySelector('[data-passphrase-input]') as HTMLInputElement;
ppInput?.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Enter') {
this.shadow.querySelector<HTMLButtonElement>('[data-action="submit-passphrase"]')?.click();
}
});
// Auto-focus passphrase input when modal is shown
ppInput?.focus();
}
/**
* Fetch scoped credentials from the auth server for a given username.
* Returns PublicKeyCredentialDescriptor[] with transports, or undefined to fall back to unscoped.
*/
private async fetchScopedCredentials(username: string): Promise<PublicKeyCredentialDescriptor[] | undefined> {
try {
const res = await fetch(`${ENCRYPTID_AUTH}/api/auth/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
if (!res.ok) return undefined;
const body = await res.json().catch(() => null);
if (!body) return undefined;
const { options, userFound } = body;
if (!userFound || !options.allowCredentials?.length) return undefined;
return options.allowCredentials.map((c: any) => ({
type: 'public-key' as const,
id: base64urlToBuffer(c.id),
...(c.transports?.length ? { transports: c.transports } : {}),
}));
} catch {
return undefined;
}
}
private async handleLogin(username?: string) {
if (this.loading) return;
this.errorMessage = '';
this.loading = true;
this.render();
try {
// If a username was selected, scope the passkey prompt to that user's credentials
let scopedCredentials: PublicKeyCredentialDescriptor[] | undefined;
if (username) {
scopedCredentials = await this.fetchScopedCredentials(username);
// If user not found on server, fall back to unscoped (still let them try)
}
// Authenticate — scoped if we have credentials, unscoped otherwise
const result = await authenticatePasskey(scopedCredentials);
// 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 first (need JWT for salt endpoint)
const sessionManager = getSessionManager();
await sessionManager.createSession(result, keys.did, {
encrypt: true,
sign: true,
wallet: !!keys.eoaAddress,
});
// Non-PRF path: passphrase-based encryption for Firefox and other browsers
if (!result.prfOutput) {
const session = sessionManager.getSession();
if (session?.token) {
try {
const saltRes = await fetch(`${ENCRYPTID_AUTH}/api/account/passphrase-salt`, {
headers: { 'Authorization': `Bearer ${session.token}` },
});
if (saltRes.ok) {
const { salt } = await saltRes.json();
// Prompt for passphrase
const passphrase = await this.promptPassphrase();
if (passphrase) {
const saltBytes = base64urlToBuffer(salt);
await keyManager.initFromPassphrase(passphrase, new Uint8Array(saltBytes));
// Get raw key material for DocBridge
const rawKey = await keyManager.getKeyMaterial();
await getDocBridge().initFromKey(new Uint8Array(rawKey));
// Load vault
const docCrypto = getDocBridge().getDocCrypto();
if (docCrypto) {
getVaultManager(docCrypto).load()
.then(() => syncWalletsOnLogin(docCrypto))
.catch(err => console.warn('Vault load failed:', err));
}
}
}
// 404 = user doesn't have a passphrase salt yet (new registration, will be set up)
} catch (e) {
console.warn('Passphrase salt fetch failed:', e);
}
}
}
// Remember this account for next time
// Use the username from JWT claims if available, otherwise the one selected
const session = sessionManager.getSession();
const resolvedUsername = session?.claims.username || username || keys.did.slice(0, 16);
addKnownAccount({ username: resolvedUsername });
// Dispatch success event
this.dispatchEvent(new CustomEvent('login-success', {
detail: {
did: keys.did,
credentialId: result.credentialId,
prfAvailable: !!result.prfOutput,
},
bubbles: true,
}));
} catch (error: any) {
const name = error?.name || '';
switch (name) {
case 'AbortError':
// Conditional UI abort — silent, expected
break;
case 'NotAllowedError':
this.errorMessage = 'Passkey dismissed. Try again, or sign in with email.';
this.showEmailFallback = true;
this.dispatchEvent(new CustomEvent('login-register-needed', { bubbles: true }));
break;
case 'NotSupportedError':
this.errorMessage = 'Passkeys are not supported on this browser. Sign in with email.';
this.showEmailFallback = true;
break;
case 'InvalidStateError':
this.errorMessage = 'This passkey is already registered. Try signing in instead.';
break;
case 'SecurityError':
this.errorMessage = 'Security error: this page cannot use your passkey.';
break;
default:
this.errorMessage = error?.message || 'Authentication failed. Please try again.';
this.dispatchEvent(new CustomEvent('login-error', {
detail: { error: error?.message },
bubbles: true,
}));
break;
}
} finally {
this.loading = false;
this.render();
}
}
private async sendMagicLink() {
const emailInput = this.shadow.querySelector('[data-email-input]') as HTMLInputElement;
const email = emailInput?.value.trim();
if (!email || !email.includes('@')) {
emailInput?.focus();
return;
}
try {
await fetch(`${ENCRYPTID_AUTH}/api/auth/magic-link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
this.emailSent = true;
this.render();
} catch (error: any) {
console.error('Magic link failed:', error);
}
}
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();
// Keep known account in localStorage so the picker shows on next login
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,
});
// Remember this account
const sess = sessionManager.getSession();
if (sess?.claims.username) {
addKnownAccount({ username: sess.claims.username });
}
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);
// Remember this account
addKnownAccount({ username, displayName });
this.dispatchEvent(new CustomEvent('register-success', {
detail: {
credentialId: credential.credentialId,
prfSupported: credential.prfSupported,
},
bubbles: true,
}));
// Auto-login after registration
await this.handleLogin(username);
// If PRF not supported, set up passphrase-based encryption
if (!credential.prfSupported) {
const session = getSessionManager().getSession();
if (session?.token) {
const passphrase = await this.promptPassphrase();
if (passphrase) {
const salt = EncryptIDKeyManager.generateSalt();
const saltB64 = bufferToBase64url(salt);
// Store salt server-side
await fetch(`${ENCRYPTID_AUTH}/api/account/passphrase-salt`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.token}`,
},
body: JSON.stringify({ salt: saltB64 }),
});
// Init encryption from passphrase
const keyManager = getKeyManager();
await keyManager.initFromPassphrase(passphrase, salt);
const rawKey = await keyManager.getKeyMaterial();
await getDocBridge().initFromKey(new Uint8Array(rawKey));
}
}
}
} 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);