diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts
index e85def4..253767a 100644
--- a/src/encryptid/server.ts
+++ b/src/encryptid/server.ts
@@ -7141,21 +7141,8 @@ app.get('/', (c) => {
@@ -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 = \`
+
+
+
+
+
+
+
+
+
+
\`;
+ } else if (accounts.length === 1) {
+ const esc = s => s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
+ const name = esc(accounts[0].displayName || accounts[0].username);
+ container.innerHTML = \`
+
+
+
+
+
+
+
+
+
+
+
\`;
+ } else {
+ const esc = s => s.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 \`
\`;
+ }).join('');
+ container.innerHTML = \`
+
\${items}
+
+
+
+
+
+
+
+
+
+
\`;
+ }
+ }
+
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');
diff --git a/src/encryptid/ui/login-button.ts b/src/encryptid/ui/login-button.ts
index 1e987fc..a77caf9 100644
--- a/src/encryptid/ui/login-button.ts
+++ b/src/encryptid/ui/login-button.ts
@@ -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
+ ? `
Login link sent! Check your inbox.
`
+ : `
+
+
+
`) : '';
+
return `
-
-
`;
}
@@ -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;