From c8bd527e554f12d5b443d7e411d41086ded0f62f Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Mar 2026 18:57:24 -0700 Subject: [PATCH] =?UTF-8?q?feat(encryptid):=20passkey-first=20login=20UX?= =?UTF-8?q?=20=E2=80=94=20zero-typing=20sign-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/encryptid/ui/login-button.ts | 106 ++++++++++++++++++++++++++----- 1 file changed, 91 insertions(+), 15 deletions(-) diff --git a/src/encryptid/ui/login-button.ts b/src/encryptid/ui/login-button.ts index 1e987fc..67451bf 100644 --- a/src/encryptid/ui/login-button.ts +++ b/src/encryptid/ui/login-button.ts @@ -360,6 +360,41 @@ const styles = ` color: var(--eid-text-secondary); 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 loading: boolean = false; private showDropdown: boolean = false; + private showEmailFallback: boolean = false; + private emailSent: boolean = false; private capabilities: WebAuthnCapabilities | null = null; // Configurable attributes @@ -484,15 +521,23 @@ export class EncryptIDLoginButton extends HTMLElement { const accounts = getKnownAccounts(); - // No known accounts → username input + sign-in button + // No known accounts → passkey-first button + email fallback if (accounts.length === 0) { + const emailSection = this.showEmailFallback ? (this.emailSent + ? `` + : ``) : ''; + return `
- - + ${this.showEmailFallback ? '' : ``} + ${emailSection}
`; } @@ -585,17 +630,27 @@ export class EncryptIDLoginButton extends HTMLElement { }); }); } else { - // Username input form (no known accounts) - const usernameInput = this.shadow.querySelector('.username-input') as HTMLInputElement; - const usernameLoginBtn = this.shadow.querySelector('[data-action="username-login"]'); - if (usernameInput && usernameLoginBtn) { - const doLogin = () => { - const val = usernameInput.value.trim(); - this.handleLogin(val || undefined); - }; - usernameLoginBtn.addEventListener('click', doLogin); - usernameInput.addEventListener('keydown', (e) => { - if ((e as KeyboardEvent).key === 'Enter') doLogin(); + // 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 + 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) { - // 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')) { + this.showEmailFallback = true; this.dispatchEvent(new CustomEvent('login-register-needed', { 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) { this.showDropdown = false;