feat(encryptid): passkey-first login UX — zero-typing sign-in

Replace username input with primary passkey button for unscoped WebAuthn
(browser shows all stored passkeys). Email magic link as hidden fallback,
auto-revealed on NotAllowedError. Applied to both login-button.ts web
component and server-rendered auth.rspace.online page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 18:57:24 -07:00
parent 5915daf8a0
commit c8bd527e55
1 changed files with 91 additions and 15 deletions

View File

@ -360,6 +360,41 @@ const styles = `
color: var(--eid-text-secondary); color: var(--eid-text-secondary);
opacity: 0.7; opacity: 0.7;
} }
.email-fallback-link {
display: block;
margin-top: 10px;
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;
}
.email-fallback-link:hover {
color: var(--eid-primary);
text-decoration: underline;
}
.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;
}
`; `;
// ============================================================================ // ============================================================================
@ -383,6 +418,8 @@ export class EncryptIDLoginButton extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private loading: boolean = false; private loading: boolean = false;
private showDropdown: boolean = false; private showDropdown: boolean = false;
private showEmailFallback: boolean = false;
private emailSent: boolean = false;
private capabilities: WebAuthnCapabilities | null = null; private capabilities: WebAuthnCapabilities | null = null;
// Configurable attributes // Configurable attributes
@ -484,15 +521,23 @@ export class EncryptIDLoginButton extends HTMLElement {
const accounts = getKnownAccounts(); const accounts = getKnownAccounts();
// No known accounts → username input + sign-in button // No known accounts → passkey-first button + email fallback
if (accounts.length === 0) { 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 ` return `
<div class="username-form"> <div class="username-form">
<input class="username-input" type="text" placeholder="Username or email" autocomplete="username webauthn" /> <button class="login-btn ${sizeClass} ${variantClass}" data-action="passkey-login">
<button class="login-btn ${sizeClass} ${variantClass}" data-action="username-login">
${PASSKEY_ICON} ${PASSKEY_ICON}
<span>${this.label}</span> <span>${this.label}</span>
</button> </button>
${this.showEmailFallback ? '' : `<button class="email-fallback-link" data-action="show-email">No passkey? Sign in with email</button>`}
${emailSection}
</div> </div>
`; `;
} }
@ -585,17 +630,27 @@ export class EncryptIDLoginButton extends HTMLElement {
}); });
}); });
} else { } else {
// Username input form (no known accounts) // Passkey-first button (no known accounts → unscoped auth)
const usernameInput = this.shadow.querySelector('.username-input') as HTMLInputElement; this.shadow.querySelector('[data-action="passkey-login"]')?.addEventListener('click', () => {
const usernameLoginBtn = this.shadow.querySelector('[data-action="username-login"]'); this.handleLogin();
if (usernameInput && usernameLoginBtn) { });
const doLogin = () => {
const val = usernameInput.value.trim(); // "No passkey? Sign in with email" toggle
this.handleLogin(val || undefined); this.shadow.querySelector('[data-action="show-email"]')?.addEventListener('click', () => {
}; this.showEmailFallback = true;
usernameLoginBtn.addEventListener('click', doLogin); this.render();
usernameInput.addEventListener('keydown', (e) => { });
if ((e as KeyboardEvent).key === 'Enter') doLogin();
// Send magic link button
this.shadow.querySelector('[data-action="send-magic-link"]')?.addEventListener('click', () => {
this.sendMagicLink();
});
// Email input enter key
const emailInput = this.shadow.querySelector('[data-email-input]') as HTMLInputElement;
if (emailInput) {
emailInput.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Enter') this.sendMagicLink();
}); });
} }
@ -707,8 +762,9 @@ export class EncryptIDLoginButton extends HTMLElement {
})); }));
} catch (error: any) { } catch (error: any) {
// If no credential found, offer to register // If no credential found, auto-show email fallback + dispatch event
if (error.name === 'NotAllowedError' || error.message?.includes('No credential')) { if (error.name === 'NotAllowedError' || error.message?.includes('No credential')) {
this.showEmailFallback = true;
this.dispatchEvent(new CustomEvent('login-register-needed', { this.dispatchEvent(new CustomEvent('login-register-needed', {
bubbles: true, bubbles: true,
})); }));
@ -724,6 +780,26 @@ export class EncryptIDLoginButton extends HTMLElement {
} }
} }
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) { private async handleDropdownAction(action: string) {
this.showDropdown = false; this.showDropdown = false;