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="success-msg" class="success"></div>
<!-- Sign-in mode (default) passkey-first -->
<div id="signin-fields">
<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>
<!-- Sign-in mode (default) dynamically rendered by renderSigninAccounts() -->
<div id="signin-fields"></div>
<!-- Registration stepper (hidden until register tab) -->
<div id="register-stepper" style="display:none">
@ -7422,6 +7409,85 @@ app.get('/', (c) => {
} from '/dist/index.js';
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';
// Registration stepper state
@ -7692,19 +7758,19 @@ app.get('/', (c) => {
}
};
// handleAuth — always unscoped passkey picker (browser shows all stored passkeys)
window.handleAuth = async () => {
// handleAuth — optionally scoped to a known username, unscoped if omitted
window.handleAuth = async (username) => {
const btn = document.getElementById('auth-btn');
btn.disabled = true;
btn.textContent = 'Waiting for passkey...';
if (btn) { btn.disabled = true; btn.textContent = 'Waiting for passkey...'; }
hideMessages();
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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
body: JSON.stringify(authBody),
});
if (!startRes.ok) throw new Error('Failed to start authentication');
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);
} catch (err) {
if (err.name === 'NotAllowedError') {
showError('No passkey found on this device. Use email to sign in.');
// Auto-show email fallback
document.getElementById('email-fallback-section').style.display = 'block';
document.getElementById('show-email-fallback').style.display = 'none';
const fb = document.getElementById('email-fallback-section');
const link = document.getElementById('show-email-fallback');
if (fb) fb.style.display = 'block';
if (link) link.style.display = 'none';
} else {
showError(err.message || 'Authentication failed');
}
btn.innerHTML = '&#128273; Sign in with Passkey';
btn.disabled = false;
if (btn) { btn.innerHTML = '&#128273; Sign in with Passkey'; 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 () => {
renderSigninAccounts();
const params = new URLSearchParams(location.search);
if (params.get('tab') === 'register') switchTab('register');

View File

@ -360,6 +360,22 @@ const styles = `
color: var(--eid-text-secondary);
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 loading: boolean = false;
private showDropdown: boolean = false;
private showEmailFallback: boolean = false;
private emailSent: boolean = false;
private capabilities: WebAuthnCapabilities | null = null;
// Configurable attributes
@ -484,15 +502,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="alt-action" data-action="show-email">No passkey? Sign in with email</button>`}
${emailSection}
</div>
`;
}
@ -585,23 +611,29 @@ 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 + 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)
const loginBtn = this.shadow.querySelector('.login-btn:not([data-action])');
if (loginBtn && !usernameInput) {
if (loginBtn) {
loginBtn.addEventListener('click', () => {
const username = (loginBtn as HTMLElement).dataset.username;
this.handleLogin(username);
@ -707,8 +739,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 +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) {
this.showDropdown = false;