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);
|
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;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue