From a83c714f5a0ebb593f3eaaa4d3c8aa03ea9e5478 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Mar 2026 14:44:39 -0700 Subject: [PATCH] fix(auth): username-first flow in rstack-identity sign-in modal The actual login UI lives in rstack-identity.ts, not login-button.ts. Added username input to the sign-in modal, pass allowCredentials from server to WebAuthn so the browser auto-selects the matching passkey. Shows "No account found" if username not recognized. Enter key support and auto-focus on the username field. Co-Authored-By: Claude Opus 4.6 --- shared/components/rstack-identity.ts | 45 +++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 26c3c2f..7c082c0 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -640,6 +640,7 @@ export class RStackIdentity extends HTMLElement {

Sign up / Sign in

Secure, passwordless authentication powered by passkeys.

+
@@ -672,25 +673,47 @@ export class RStackIdentity extends HTMLElement { const handleSignIn = async () => { const errEl = overlay.querySelector("#auth-error") as HTMLElement | null; const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement | null; + const usernameInput = overlay.querySelector("#auth-signin-username") as HTMLInputElement | null; + const enteredUsername = usernameInput?.value.trim() || ""; + const loginUsername = usernameHint || enteredUsername; if (errEl) errEl.textContent = ""; + if (!loginUsername) { + if (errEl) errEl.textContent = "Please enter your username or email."; + usernameInput?.focus(); + return; + } if (btn) { btn.disabled = true; btn.innerHTML = ' Authenticating...'; } try { const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(usernameHint ? { username: usernameHint } : {}), + body: JSON.stringify({ username: loginUsername }), }); if (!startRes.ok) throw new Error("Failed to start authentication"); - const { options: serverOptions } = await startRes.json(); + const { options: serverOptions, userFound } = await startRes.json(); + + if (!userFound) { + throw new Error("No account found for that username. Try creating a new account."); + } + + // Build allowCredentials from server response to scope to this user's passkeys + const pubKeyOpts: any = { + challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)), + rpId: serverOptions.rpId || "rspace.online", + userVerification: "required", + timeout: 60000, + }; + if (serverOptions.allowCredentials?.length) { + pubKeyOpts.allowCredentials = serverOptions.allowCredentials.map((c: any) => ({ + type: c.type, + id: new Uint8Array(base64urlToBuffer(c.id)), + ...(c.transports?.length ? { transports: c.transports } : {}), + })); + } const credential = (await navigator.credentials.get({ - publicKey: { - challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)), - rpId: serverOptions.rpId || "rspace.online", - userVerification: "required", - timeout: 60000, - }, + publicKey: pubKeyOpts, })) as PublicKeyCredential; if (!credential) throw new Error("Authentication failed"); @@ -815,6 +838,9 @@ export class RStackIdentity extends HTMLElement { overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter") handleRegister(); }); + overlay.querySelector("#auth-signin-username")?.addEventListener("keydown", (e) => { + if ((e as KeyboardEvent).key === "Enter") handleSignIn(); + }); overlay.addEventListener("click", (e) => { if (e.target === overlay) { close(); @@ -829,6 +855,9 @@ export class RStackIdentity extends HTMLElement { // If switching persona, auto-trigger sign-in immediately if (usernameHint) { handleSignIn(); + } else { + // Focus the username input + setTimeout(() => (overlay.querySelector("#auth-signin-username") as HTMLInputElement)?.focus(), 50); } }