From c3457cf98f5916eacd397836686e263380b882cf Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 11 Apr 2026 08:02:36 -0400 Subject: [PATCH] feat(encryptid): show registered usernames in login modal instead of text input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- shared/components/rstack-identity.ts | 114 +++++++++++++++++++++++++-- 1 file changed, 108 insertions(+), 6 deletions(-) diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 1e2e7176..1eb7104e 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -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(); + 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 = () => ` - + 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, "&").replace(//g, ">").replace(/"/g, """); + return ``; + }).join(""); + + return ` +

Sign up / Sign in

-

Secure, passwordless authentication powered by passkeys.

- +

${showPicker ? "Choose an account on this device:" : "Secure, passwordless authentication powered by passkeys."}

+ ${showPicker ? ` + + + ` : ` + + `}
- + ${showPicker ? '' : ``}
Powered by EncryptID
- `; + `;}; const registerHTML = () => ` @@ -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 = ' Authenticating...'; } + if (pickerBtn) { pickerBtn.style.opacity = "0.6"; pickerBtn.style.pointerEvents = "none"; const arrow = pickerBtn.querySelector(".account-pick-arrow"); if (arrow) arrow.innerHTML = ''; } 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(); });