1137 lines
33 KiB
TypeScript
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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);
|