From efe36152281462ba50389bd8438e9693fc86aff4 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 15 Apr 2026 19:23:28 -0400 Subject: [PATCH] fix(auth,rcred): passkey autofill for mobile + rcred write access - Add conditional mediation to sign-in modal so mobile browsers show saved passkeys with usernames in the autofill area (desktop parity) - Add publicWrite to rcred module so recompute route's own auth runs instead of being blocked by the global write-access middleware Co-Authored-By: Claude Opus 4.6 --- lib/rspace-header.ts | 97 ++++++++++++++++++++++++++++++++++++++++++++ modules/rcred/mod.ts | 1 + 2 files changed, 98 insertions(+) diff --git a/lib/rspace-header.ts b/lib/rspace-header.ts index f8323cb8..27866069 100644 --- a/lib/rspace-header.ts +++ b/lib/rspace-header.ts @@ -459,6 +459,24 @@ const HEADER_STYLES = ` min-height: 1.2em; } + .rspace-auth-modal__divider { + display: flex; + align-items: center; + gap: 12px; + margin: 1rem 0; + font-size: 0.8rem; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + } + .rspace-auth-modal__divider::before, + .rspace-auth-modal__divider::after { + content: ''; + flex: 1; + height: 1px; + background: rgba(255,255,255,0.1); + } + .rspace-auth-modal__toggle { margin-top: 1rem; font-size: 0.85rem; @@ -515,6 +533,7 @@ interface AuthModalCallbacks { let activeModal: HTMLElement | null = null; let headerRenderFn: (() => void) | null = null; +let conditionalAbort: AbortController | null = null; /** * Show the EncryptID auth modal for sign-in or registration. @@ -528,8 +547,73 @@ export function showAuthModal(callbacks?: Partial): void { let mode: AuthMode = 'signin'; function render() { + abortConditional(); overlay.innerHTML = mode === 'signin' ? renderSignIn() : renderRegister(); attachModalListeners(); + if (mode === 'signin') startConditional(); + } + + function abortConditional() { + if (conditionalAbort) { + conditionalAbort.abort(); + conditionalAbort = null; + } + } + + /** Start conditional mediation (passkey autofill) for mobile/desktop parity */ + async function startConditional() { + // Check if conditional mediation is supported + // @ts-ignore + if (!window.PublicKeyCredential?.isConditionalMediationAvailable) return; + // @ts-ignore + const available = await PublicKeyCredential.isConditionalMediationAvailable(); + if (!available) return; + + try { + const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (!startRes.ok) return; + const { options: serverOptions } = await startRes.json(); + + conditionalAbort = new AbortController(); + const credential = await navigator.credentials.get({ + // @ts-ignore - conditional mediation + mediation: 'conditional', + signal: conditionalAbort.signal, + publicKey: { + challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)), + rpId: serverOptions.rpId || 'rspace.online', + userVerification: 'required', + timeout: 120000, + }, + }) as PublicKeyCredential; + if (!credential) return; + + // User selected a passkey via autofill — complete auth + const errorEl = overlay.querySelector('#auth-error') as HTMLElement; + const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement; + if (btn) { btn.disabled = true; btn.innerHTML = ' Authenticating...'; } + + const completeRes = await fetch(`${ENCRYPTID_URL}/api/auth/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: serverOptions.challenge, + credential: { credentialId: bufferToBase64url(credential.rawId) }, + }), + }); + const data = await completeRes.json(); + if (!completeRes.ok || !data.success) throw new Error(data.error || 'Authentication failed'); + + storeSession(data.token, data.username || '', data.did || ''); + closeModal(); + callbacks?.onSuccess?.(); + } catch (err: any) { + if (err.name === 'AbortError') return; // user switched to register or closed modal + } } function renderSignIn(): string { @@ -540,6 +624,15 @@ export function showAuthModal(callbacks?: Partial): void { Use your passkey to sign in instantly. No passwords needed — just your fingerprint, face, or device PIN.

+ +
or