feat(encryptid): known accounts picker + passkey-first for both UIs

- login-button.ts: no-known-accounts state shows passkey-first button
  (unscoped WebAuthn) with email magic link fallback, auto-revealed on
  NotAllowedError. Fix stale usernameInput ref.
- server.ts (auth.rspace.online): add localStorage known accounts system.
  Returning users see their stored usernames as clickable buttons.
  handleAuth() accepts optional username for scoped auth. Saves account
  after successful login. renderSigninAccounts() called on page init.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-25 11:29:44 -07:00
parent 423d2612af
commit b3fb51c39a
2 changed files with 169 additions and 44 deletions

View File

@ -7141,21 +7141,8 @@ app.get('/', (c) => {
<div id="error-msg" class="error"></div> <div id="error-msg" class="error"></div>
<div id="success-msg" class="success"></div> <div id="success-msg" class="success"></div>
<!-- Sign-in mode (default) passkey-first --> <!-- Sign-in mode (default) dynamically rendered by renderSigninAccounts() -->
<div id="signin-fields"> <div id="signin-fields"></div>
<button class="btn-primary" id="auth-btn" onclick="handleAuth()" style="font-size:1.1rem;padding:0.9rem">&#128273; Sign in with Passkey</button>
<div style="text-align:center;margin-top:0.75rem">
<a href="#" id="show-email-fallback" onclick="toggleEmailFallback(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">No passkey on this device? Sign in with email</a>
</div>
<div id="email-fallback-section" style="display:none;margin-top:0.75rem;border-top:1px solid rgba(255,255,255,0.08);padding-top:0.75rem">
<div class="form-group">
<label for="signin-email">Email address</label>
<input id="signin-email" type="email" placeholder="you@example.com" autocomplete="email" />
</div>
<button class="btn-secondary" id="magic-link-btn" onclick="sendMagicLink()">Send magic link</button>
<div id="magic-link-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
</div>
</div>
<!-- Registration stepper (hidden until register tab) --> <!-- Registration stepper (hidden until register tab) -->
<div id="register-stepper" style="display:none"> <div id="register-stepper" style="display:none">
@ -7422,6 +7409,85 @@ app.get('/', (c) => {
} from '/dist/index.js'; } from '/dist/index.js';
const TOKEN_KEY = 'encryptid_token'; const TOKEN_KEY = 'encryptid_token';
const KNOWN_ACCOUNTS_KEY = 'encryptid-known-accounts';
function getKnownAccounts() {
try { return JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || '[]'); }
catch { return []; }
}
function addKnownAccount(username, displayName) {
const accounts = getKnownAccounts().filter(a => a.username !== username);
accounts.unshift({ username, displayName: displayName || username });
localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts));
}
// Render known accounts into sign-in fields on load
function renderSigninAccounts() {
const container = document.getElementById('signin-fields');
const accounts = getKnownAccounts();
if (accounts.length === 0) {
// No known accounts — passkey-first button
container.innerHTML = \`
<button class="btn-primary" id="auth-btn" onclick="handleAuth()" style="font-size:1.1rem;padding:0.9rem">&#128273; Sign in with Passkey</button>
<div style="text-align:center;margin-top:0.75rem">
<a href="#" id="show-email-fallback" onclick="toggleEmailFallback(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">No passkey on this device? Sign in with email</a>
</div>
<div id="email-fallback-section" style="display:none;margin-top:0.75rem;border-top:1px solid rgba(255,255,255,0.08);padding-top:0.75rem">
<div class="form-group">
<label for="signin-email">Email address</label>
<input id="signin-email" type="email" placeholder="you@example.com" autocomplete="email" />
</div>
<button class="btn-secondary" id="magic-link-btn" onclick="sendMagicLink()">Send magic link</button>
<div id="magic-link-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
</div>\`;
} else if (accounts.length === 1) {
const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const name = esc(accounts[0].displayName || accounts[0].username);
container.innerHTML = \`
<button class="btn-primary" id="auth-btn" onclick="handleAuth('\${esc(accounts[0].username)}')" style="font-size:1.1rem;padding:0.9rem">&#128273; Sign in as \${name}</button>
<div style="text-align:center;margin-top:0.5rem">
<a href="#" onclick="handleAuth(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">Use a different account</a>
</div>
<div style="text-align:center;margin-top:0.25rem">
<a href="#" id="show-email-fallback" onclick="toggleEmailFallback(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">Sign in with email instead</a>
</div>
<div id="email-fallback-section" style="display:none;margin-top:0.75rem;border-top:1px solid rgba(255,255,255,0.08);padding-top:0.75rem">
<div class="form-group">
<label for="signin-email">Email address</label>
<input id="signin-email" type="email" placeholder="you@example.com" autocomplete="email" />
</div>
<button class="btn-secondary" id="magic-link-btn" onclick="sendMagicLink()">Send magic link</button>
<div id="magic-link-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
</div>\`;
} else {
const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const items = accounts.map(a => {
const name = esc(a.displayName || a.username);
const initial = esc((a.displayName || a.username).slice(0, 2).toUpperCase());
return \`<button class="btn-secondary" onclick="handleAuth('\${esc(a.username)}')" style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem">
<span style="width:28px;height:28px;border-radius:50%;background:#7c3aed;display:flex;align-items:center;justify-content:center;font-weight:600;font-size:0.75rem;color:#fff;flex-shrink:0">\${initial}</span>
<span>\${name}</span>
</button>\`;
}).join('');
container.innerHTML = \`
<div style="display:flex;flex-direction:column;gap:0.5rem">\${items}</div>
<div style="text-align:center;margin-top:0.5rem">
<a href="#" onclick="handleAuth(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">Use a different account</a>
</div>
<div style="text-align:center;margin-top:0.25rem">
<a href="#" id="show-email-fallback" onclick="toggleEmailFallback(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">Sign in with email instead</a>
</div>
<div id="email-fallback-section" style="display:none;margin-top:0.75rem;border-top:1px solid rgba(255,255,255,0.08);padding-top:0.75rem">
<div class="form-group">
<label for="signin-email">Email address</label>
<input id="signin-email" type="email" placeholder="you@example.com" autocomplete="email" />
</div>
<button class="btn-secondary" id="magic-link-btn" onclick="sendMagicLink()">Send magic link</button>
<div id="magic-link-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
</div>\`;
}
}
let currentMode = 'signin'; let currentMode = 'signin';
// Registration stepper state // Registration stepper state
@ -7692,19 +7758,19 @@ app.get('/', (c) => {
} }
}; };
// handleAuth — always unscoped passkey picker (browser shows all stored passkeys) // handleAuth — optionally scoped to a known username, unscoped if omitted
window.handleAuth = async () => { window.handleAuth = async (username) => {
const btn = document.getElementById('auth-btn'); const btn = document.getElementById('auth-btn');
btn.disabled = true; if (btn) { btn.disabled = true; btn.textContent = 'Waiting for passkey...'; }
btn.textContent = 'Waiting for passkey...';
hideMessages(); hideMessages();
try { try {
// Unscoped auth — let the browser show all available passkeys // If username provided, scope credentials to that user; otherwise unscoped
const authBody = username ? { username } : {};
const startRes = await fetch('/api/auth/start', { const startRes = await fetch('/api/auth/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}), body: JSON.stringify(authBody),
}); });
if (!startRes.ok) throw new Error('Failed to start authentication'); if (!startRes.ok) throw new Error('Failed to start authentication');
const { options: serverOptions, prfSalt } = await startRes.json(); const { options: serverOptions, prfSalt } = await startRes.json();
@ -7782,18 +7848,22 @@ app.get('/', (c) => {
} }
} }
// Remember this account for next login
if (data.username) addKnownAccount(data.username, data.username);
showProfile(data.token, data.username, data.did); showProfile(data.token, data.username, data.did);
} catch (err) { } catch (err) {
if (err.name === 'NotAllowedError') { if (err.name === 'NotAllowedError') {
showError('No passkey found on this device. Use email to sign in.'); showError('No passkey found on this device. Use email to sign in.');
// Auto-show email fallback // Auto-show email fallback
document.getElementById('email-fallback-section').style.display = 'block'; const fb = document.getElementById('email-fallback-section');
document.getElementById('show-email-fallback').style.display = 'none'; const link = document.getElementById('show-email-fallback');
if (fb) fb.style.display = 'block';
if (link) link.style.display = 'none';
} else { } else {
showError(err.message || 'Authentication failed'); showError(err.message || 'Authentication failed');
} }
btn.innerHTML = '&#128273; Sign in with Passkey'; if (btn) { btn.innerHTML = '&#128273; Sign in with Passkey'; btn.disabled = false; }
btn.disabled = false;
} }
}; };
@ -8334,8 +8404,10 @@ app.get('/', (c) => {
} }
} }
// On page load: handle query params and check for existing token // On page load: render known accounts + handle query params + check token
(async () => { (async () => {
renderSigninAccounts();
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
if (params.get('tab') === 'register') switchTab('register'); if (params.get('tab') === 'register') switchTab('register');

View File

@ -360,6 +360,22 @@ const styles = `
color: var(--eid-text-secondary); color: var(--eid-text-secondary);
opacity: 0.7; 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;
}
`; `;
// ============================================================================ // ============================================================================
@ -383,6 +399,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 +502,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="alt-action" data-action="show-email">No passkey? Sign in with email</button>`}
${emailSection}
</div> </div>
`; `;
} }
@ -585,23 +611,29 @@ 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 + 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) // Login button with scoped username (1 known account)
const loginBtn = this.shadow.querySelector('.login-btn:not([data-action])'); const loginBtn = this.shadow.querySelector('.login-btn:not([data-action])');
if (loginBtn && !usernameInput) { if (loginBtn) {
loginBtn.addEventListener('click', () => { loginBtn.addEventListener('click', () => {
const username = (loginBtn as HTMLElement).dataset.username; const username = (loginBtn as HTMLElement).dataset.username;
this.handleLogin(username); this.handleLogin(username);
@ -707,8 +739,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 +757,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;