feat(encryptid): show registered usernames in login modal instead of text input

Display known accounts as clickable buttons in the sign-in modal so users
pick their username rather than typing it — prevents accidental new passkey
creation from typos. Falls back to manual input via "Use a different account".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-11 08:02:36 -04:00
parent c2c0dadebe
commit c3457cf98f
1 changed files with 108 additions and 6 deletions

View File

@ -243,6 +243,7 @@ function storeSession(token: string, username: string, did: string): void {
// ── Persona helpers (client-side multi-account) ──
const PERSONAS_KEY = "rspace-known-personas";
const KNOWN_ACCOUNTS_KEY = "encryptid-known-accounts";
interface KnownPersona {
username: string;
@ -255,6 +256,30 @@ function getKnownPersonas(): KnownPersona[] {
} catch { return []; }
}
/** Merge personas from both localStorage keys into a deduplicated list by username. */
function getAllKnownUsernames(): string[] {
const seen = new Set<string>();
const result: string[] = [];
// Primary: persona list (has DID, most reliable)
for (const p of getKnownPersonas()) {
if (p.username && !seen.has(p.username)) {
seen.add(p.username);
result.push(p.username);
}
}
// Secondary: encryptid login-button known accounts
try {
const accounts: { username: string }[] = JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || "[]");
for (const a of accounts) {
if (a.username && !seen.has(a.username)) {
seen.add(a.username);
result.push(a.username);
}
}
} catch { /* ignore */ }
return result;
}
function addKnownPersona(username: string, did: string): void {
const personas = getKnownPersonas();
const idx = personas.findIndex(p => p.did === did);
@ -264,6 +289,14 @@ function addKnownPersona(username: string, did: string): void {
personas.push({ username, did });
}
localStorage.setItem(PERSONAS_KEY, JSON.stringify(personas));
// Also sync to encryptid-known-accounts for login-button compat
try {
const accounts: { username: string; displayName?: string }[] = JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || "[]");
if (!accounts.some(a => a.username === username)) {
accounts.unshift({ username });
localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts));
}
} catch { /* ignore */ }
}
function removeKnownPersona(did: string): void {
@ -790,21 +823,60 @@ export class RStackIdentity extends HTMLElement {
attachListeners();
};
const signinHTML = () => `
<style>${MODAL_STYLES}</style>
let showManualInput = false;
const signinHTML = () => {
const knownUsers = usernameHint ? [] : getAllKnownUsernames();
const showPicker = knownUsers.length > 0 && !showManualInput;
const accountButtons = knownUsers.map(u => {
const initial = u[0]?.toUpperCase() || "?";
const escaped = u.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
return `<button class="account-pick-btn" data-pick-username="${escaped}">
<span class="account-pick-avatar">${initial}</span>
<span class="account-pick-name">${escaped}</span>
<span class="account-pick-arrow"></span>
</button>`;
}).join("");
return `
<style>${MODAL_STYLES}
.account-picker { display: flex; flex-direction: column; gap: 8px; margin-bottom: 1rem; }
.account-pick-btn {
display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 16px;
background: var(--rs-bg-hover); border: 1px solid var(--rs-border); border-radius: 10px;
color: var(--rs-text-primary); cursor: pointer; transition: all 0.2s;
font-size: 0.95rem; font-family: inherit; text-align: left;
}
.account-pick-btn:hover { border-color: #06b6d4; background: rgba(6,182,212,0.08); transform: translateY(-1px); }
.account-pick-avatar {
width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0;
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.85rem;
}
.account-pick-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.account-pick-arrow { color: var(--rs-text-muted); font-size: 1.1rem; }
.alt-link { display: block; text-align: center; margin-top: 8px; font-size: 0.85rem; color: var(--rs-text-muted); cursor: pointer; background: none; border: none; font-family: inherit; width: 100%; padding: 4px; }
.alt-link:hover { color: #06b6d4; text-decoration: underline; }
</style>
<div class="auth-modal">
<button class="close-btn" data-action="cancel">&times;</button>
<h2>Sign up / Sign in</h2>
<p>Secure, passwordless authentication powered by passkeys.</p>
<p>${showPicker ? "Choose an account on this device:" : "Secure, passwordless authentication powered by passkeys."}</p>
${showPicker ? `
<div class="account-picker">${accountButtons}</div>
<button class="alt-link" data-action="show-manual">Use a different account</button>
` : `
<input class="input" id="auth-signin-username" type="text" placeholder="Username or email" autocomplete="username webauthn" maxlength="64" />
`}
<div class="actions actions--stack">
<button class="btn btn--primary" data-action="signin">🔑 Sign In with Passkey</button>
${showPicker ? '' : `<button class="btn btn--primary" data-action="signin">🔑 Sign In with Passkey</button>`}
<button class="btn btn--outline" data-action="switch-register">🔐 Create New Account</button>
</div>
<div class="error" id="auth-error"></div>
<div class="learn-more">Powered by <a href="https://ridentity.online" target="_blank" rel="noopener">EncryptID</a></div>
</div>
`;
`;};
const registerHTML = () => `
<style>${MODAL_STYLES}</style>
@ -838,7 +910,10 @@ export class RStackIdentity extends HTMLElement {
usernameInput?.focus();
return;
}
// Show loading state on either the signin button or the clicked picker button
const pickerBtn = overlay.querySelector(`[data-pick-username="${CSS.escape(loginUsername)}"]`) as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Authenticating...'; }
if (pickerBtn) { pickerBtn.style.opacity = "0.6"; pickerBtn.style.pointerEvents = "none"; const arrow = pickerBtn.querySelector(".account-pick-arrow"); if (arrow) arrow.innerHTML = '<span class="spinner" style="width:14px;height:14px;border-width:2px"></span>'; }
try {
const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
@ -893,6 +968,7 @@ export class RStackIdentity extends HTMLElement {
autoResolveSpace(data.token, data.username || "");
} catch (err: any) {
if (btn) { btn.disabled = false; btn.innerHTML = "🔑 Sign In with Passkey"; }
if (pickerBtn) { pickerBtn.style.opacity = ""; pickerBtn.style.pointerEvents = ""; const arrow = pickerBtn.querySelector(".account-pick-arrow"); if (arrow) arrow.innerHTML = "→"; }
const msg = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed.";
if (errEl) errEl.textContent = msg;
// If auto-triggered persona switch was cancelled, close modal and restore previous state
@ -989,8 +1065,34 @@ export class RStackIdentity extends HTMLElement {
});
overlay.querySelector('[data-action="switch-signin"]')?.addEventListener("click", () => {
mode = "signin";
showManualInput = false;
render();
});
overlay.querySelector('[data-action="show-manual"]')?.addEventListener("click", () => {
showManualInput = true;
render();
setTimeout(() => (overlay.querySelector("#auth-signin-username") as HTMLInputElement)?.focus(), 50);
});
// Account picker buttons — click triggers sign-in with that username
overlay.querySelectorAll("[data-pick-username]").forEach(btn => {
btn.addEventListener("click", () => {
const picked = (btn as HTMLElement).dataset.pickUsername || "";
if (!picked) return;
// Populate a hidden username for handleSignIn, then trigger it
const input = overlay.querySelector("#auth-signin-username") as HTMLInputElement | null;
if (input) {
input.value = picked;
} else {
// No input in DOM (picker mode) — create a temp hidden one for handleSignIn
const tmp = document.createElement("input");
tmp.id = "auth-signin-username";
tmp.type = "hidden";
tmp.value = picked;
overlay.querySelector(".auth-modal")?.appendChild(tmp);
}
handleSignIn();
});
});
overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") handleRegister();
});