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:
parent
5915daf8a0
commit
c8bd527e55
|
|
@ -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
|
||||
? `<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">
|
||||
<input class="username-input" type="text" placeholder="Username or email" autocomplete="username webauthn" />
|
||||
<button class="login-btn ${sizeClass} ${variantClass}" data-action="username-login">
|
||||
<button class="login-btn ${sizeClass} ${variantClass}" data-action="passkey-login">
|
||||
${PASSKEY_ICON}
|
||||
<span>${this.label}</span>
|
||||
</button>
|
||||
${this.showEmailFallback ? '' : `<button class="email-fallback-link" data-action="show-email">No passkey? Sign in with email</button>`}
|
||||
${emailSection}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue