diff --git a/src/encryptid/ui/login-button.ts b/src/encryptid/ui/login-button.ts
index a94172a..0f7b9fa 100644
--- a/src/encryptid/ui/login-button.ts
+++ b/src/encryptid/ui/login-button.ts
@@ -18,6 +18,39 @@ import { getDocBridge, resetDocBridge } from '../../../shared/local-first/encryp
import { getVaultManager, resetVaultManager } from '../vault';
import { syncWalletsOnLogin } from '../wallet-sync';
+// ============================================================================
+// KNOWN ACCOUNTS (localStorage)
+// ============================================================================
+
+const KNOWN_ACCOUNTS_KEY = 'encryptid-known-accounts';
+const ENCRYPTID_AUTH = 'https://auth.rspace.online';
+
+interface KnownAccount {
+ username: string;
+ displayName?: string;
+}
+
+function getKnownAccounts(): KnownAccount[] {
+ try {
+ return JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || '[]');
+ } catch { return []; }
+}
+
+function addKnownAccount(account: KnownAccount): void {
+ const accounts = getKnownAccounts().filter(a => a.username !== account.username);
+ accounts.unshift(account); // most recent first
+ localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts));
+}
+
+function removeKnownAccount(username: string): void {
+ const accounts = getKnownAccounts().filter(a => a.username !== username);
+ localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts));
+}
+
+function escapeHtml(s: string): string {
+ return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+}
+
// ============================================================================
// STYLES
// ============================================================================
@@ -227,6 +260,77 @@ const styles = `
.hidden {
display: none;
}
+
+ .account-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 200px;
+ }
+
+ .account-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 16px;
+ background: var(--eid-bg);
+ border: 1px solid transparent;
+ border-radius: var(--eid-radius);
+ color: var(--eid-text);
+ cursor: pointer;
+ transition: all 0.2s;
+ font-size: 0.9rem;
+ font-family: inherit;
+ text-align: left;
+ width: 100%;
+ box-sizing: border-box;
+ }
+
+ .account-item:hover {
+ background: var(--eid-bg-hover);
+ border-color: var(--eid-primary);
+ }
+
+ .account-item .account-avatar {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--eid-primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ font-size: 0.75rem;
+ color: white;
+ flex-shrink: 0;
+ }
+
+ .account-item .account-name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .alt-action {
+ display: block;
+ margin-top: 8px;
+ 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;
+ }
+
+ .alt-action:hover {
+ color: var(--eid-primary);
+ text-decoration: underline;
+ }
`;
// ============================================================================
@@ -340,14 +444,56 @@ export class EncryptIDLoginButton extends HTMLElement {
const sizeClass = this.size === 'medium' ? '' : this.size;
const variantClass = this.variant === 'primary' ? '' : this.variant;
+ if (this.loading) {
+ return `
+
+ `;
+ }
+
+ const accounts = getKnownAccounts();
+
+ // No known accounts → generic passkey button
+ if (accounts.length === 0) {
+ return `
+
+ `;
+ }
+
+ // 1 known account → "Sign in as [username]" + alt link
+ if (accounts.length === 1) {
+ const name = escapeHtml(accounts[0].displayName || accounts[0].username);
+ return `
+
+
+ `;
+ }
+
+ // Multiple accounts → picker list
+ const items = accounts.map(a => {
+ const name = escapeHtml(a.displayName || a.username);
+ const initial = escapeHtml((a.displayName || a.username).slice(0, 2).toUpperCase());
+ return `
+
+ `;
+ }).join('');
+
return `
-
+
+ ${items}
+
+
`;
}
@@ -407,22 +553,66 @@ export class EncryptIDLoginButton extends HTMLElement {
});
});
} else {
- // Login button click
- this.shadow.querySelector('.login-btn')?.addEventListener('click', () => {
+ // Login button with scoped username
+ const loginBtn = this.shadow.querySelector('.login-btn');
+ if (loginBtn) {
+ loginBtn.addEventListener('click', () => {
+ const username = (loginBtn as HTMLElement).dataset.username;
+ this.handleLogin(username);
+ });
+ }
+
+ // Account list items (multiple accounts)
+ this.shadow.querySelectorAll('.account-item').forEach(item => {
+ item.addEventListener('click', () => {
+ const username = (item as HTMLElement).dataset.username;
+ if (username) this.handleLogin(username);
+ });
+ });
+
+ // "Use a different account" → unscoped auth
+ this.shadow.querySelector('[data-action="different-account"]')?.addEventListener('click', () => {
this.handleLogin();
});
}
}
- private async handleLogin() {
+ /**
+ * Fetch scoped credential IDs from the auth server for a given username.
+ * Returns the credential ID array, or undefined to fall back to unscoped.
+ */
+ private async fetchScopedCredentials(username: string): Promise {
+ try {
+ const res = await fetch(`${ENCRYPTID_AUTH}/api/auth/start`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username }),
+ });
+ if (!res.ok) return undefined;
+ const { options, userFound } = await res.json();
+ if (!userFound || !options.allowCredentials?.length) return undefined;
+ return options.allowCredentials.map((c: any) => c.id);
+ } catch {
+ return undefined;
+ }
+ }
+
+ private async handleLogin(username?: string) {
if (this.loading) return;
this.loading = true;
this.render();
try {
- // Try to authenticate with existing passkey
- const result = await authenticatePasskey();
+ // If a username was selected, scope the passkey prompt to that user's credentials
+ let credentialIds: string[] | undefined;
+ if (username) {
+ credentialIds = await this.fetchScopedCredentials(username);
+ // If user not found on server, fall back to unscoped (still let them try)
+ }
+
+ // Authenticate — scoped if we have credential IDs, unscoped otherwise
+ const result = await authenticatePasskey(credentialIds);
// Initialize key manager with PRF output
const keyManager = getKeyManager();
@@ -450,6 +640,12 @@ export class EncryptIDLoginButton extends HTMLElement {
wallet: !!keys.eoaAddress,
});
+ // Remember this account for next time
+ // Use the username from JWT claims if available, otherwise the one selected
+ const session = sessionManager.getSession();
+ const resolvedUsername = session?.claims.username || username || keys.did.slice(0, 16);
+ addKnownAccount({ username: resolvedUsername });
+
// Dispatch success event
this.dispatchEvent(new CustomEvent('login-success', {
detail: {
@@ -526,6 +722,13 @@ export class EncryptIDLoginButton extends HTMLElement {
private handleLogout() {
const sessionManager = getSessionManager();
+
+ // Remove this account from known accounts list
+ const session = sessionManager.getSession();
+ if (session?.claims.username) {
+ removeKnownAccount(session.claims.username);
+ }
+
sessionManager.clearSession();
const keyManager = getKeyManager();
@@ -567,6 +770,12 @@ export class EncryptIDLoginButton extends HTMLElement {
wallet: !!keys.eoaAddress,
});
+ // Remember this account
+ const sess = sessionManager.getSession();
+ if (sess?.claims.username) {
+ addKnownAccount({ username: sess.claims.username });
+ }
+
this.dispatchEvent(new CustomEvent('login-success', {
detail: {
did: keys.did,
@@ -595,6 +804,9 @@ export class EncryptIDLoginButton extends HTMLElement {
try {
const credential = await registerPasskey(username, displayName);
+ // Remember this account
+ addKnownAccount({ username, displayName });
+
this.dispatchEvent(new CustomEvent('register-success', {
detail: {
credentialId: credential.credentialId,
@@ -604,7 +816,7 @@ export class EncryptIDLoginButton extends HTMLElement {
}));
// Auto-login after registration
- await this.handleLogin();
+ await this.handleLogin(username);
} catch (error: any) {
this.dispatchEvent(new CustomEvent('register-error', {
detail: { error: error.message },
diff --git a/src/encryptid/webauthn.ts b/src/encryptid/webauthn.ts
index a346752..9891a11 100644
--- a/src/encryptid/webauthn.ts
+++ b/src/encryptid/webauthn.ts
@@ -252,7 +252,7 @@ export async function registerPasskey(
* (if the authenticator supports PRF).
*/
export async function authenticatePasskey(
- credentialId?: string, // Optional: specify credential, or let user choose
+ credentialIds?: string | string[], // Optional: one or more credential IDs to scope, or let user choose
config: Partial = {}
): Promise {
// Abort any pending conditional UI to prevent "request already pending" error
@@ -272,12 +272,14 @@ export async function authenticatePasskey(
const prfSalt = await generatePRFSalt('master-key');
// Build allowed credentials list
- const allowCredentials: PublicKeyCredentialDescriptor[] | undefined = credentialId
- ? [{
- type: 'public-key',
- id: new Uint8Array(base64urlToBuffer(credentialId)),
- }]
- : undefined; // undefined = let user choose from available passkeys
+ let allowCredentials: PublicKeyCredentialDescriptor[] | undefined;
+ if (credentialIds) {
+ const ids = Array.isArray(credentialIds) ? credentialIds : [credentialIds];
+ allowCredentials = ids.map(id => ({
+ type: 'public-key' as const,
+ id: new Uint8Array(base64urlToBuffer(id)),
+ }));
+ }
// Build authentication options
const getOptions: CredentialRequestOptions = {