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:
parent
423d2612af
commit
b3fb51c39a
|
|
@ -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">🔑 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">🔑 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
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">🔑 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
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 = '🔑 Sign in with Passkey';
|
||||
btn.disabled = false;
|
||||
if (btn) { btn.innerHTML = '🔑 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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue