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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-15 19:23:28 -04:00
parent 932e550c66
commit efe3615228
2 changed files with 98 additions and 0 deletions

View File

@ -459,6 +459,24 @@ const HEADER_STYLES = `
min-height: 1.2em; 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 { .rspace-auth-modal__toggle {
margin-top: 1rem; margin-top: 1rem;
font-size: 0.85rem; font-size: 0.85rem;
@ -515,6 +533,7 @@ interface AuthModalCallbacks {
let activeModal: HTMLElement | null = null; let activeModal: HTMLElement | null = null;
let headerRenderFn: (() => void) | null = null; let headerRenderFn: (() => void) | null = null;
let conditionalAbort: AbortController | null = null;
/** /**
* Show the EncryptID auth modal for sign-in or registration. * Show the EncryptID auth modal for sign-in or registration.
@ -528,8 +547,73 @@ export function showAuthModal(callbacks?: Partial<AuthModalCallbacks>): void {
let mode: AuthMode = 'signin'; let mode: AuthMode = 'signin';
function render() { function render() {
abortConditional();
overlay.innerHTML = mode === 'signin' ? renderSignIn() : renderRegister(); overlay.innerHTML = mode === 'signin' ? renderSignIn() : renderRegister();
attachModalListeners(); 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 = '<span class="rspace-auth-modal__spinner"></span> 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 { function renderSignIn(): string {
@ -540,6 +624,15 @@ export function showAuthModal(callbacks?: Partial<AuthModalCallbacks>): void {
Use your passkey to sign in instantly. No passwords needed Use your passkey to sign in instantly. No passwords needed
just your fingerprint, face, or device PIN. just your fingerprint, face, or device PIN.
</p> </p>
<input
class="rspace-auth-modal__input"
id="auth-signin-username"
type="text"
placeholder="Select a saved passkey..."
autocomplete="username webauthn"
readonly
/>
<div class="rspace-auth-modal__divider">or</div>
<div class="rspace-auth-modal__actions"> <div class="rspace-auth-modal__actions">
<button class="rspace-auth-modal__btn rspace-auth-modal__btn--secondary" data-action="cancel">Cancel</button> <button class="rspace-auth-modal__btn rspace-auth-modal__btn--secondary" data-action="cancel">Cancel</button>
<button class="rspace-auth-modal__btn rspace-auth-modal__btn--primary" data-action="signin"> <button class="rspace-auth-modal__btn rspace-auth-modal__btn--primary" data-action="signin">
@ -585,6 +678,9 @@ export function showAuthModal(callbacks?: Partial<AuthModalCallbacks>): void {
} }
async function handleSignIn() { async function handleSignIn() {
// Abort conditional mediation before starting modal flow
abortConditional();
const errorEl = overlay.querySelector('#auth-error') as HTMLElement; const errorEl = overlay.querySelector('#auth-error') as HTMLElement;
const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement; const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement;
errorEl.textContent = ''; errorEl.textContent = '';
@ -768,6 +864,7 @@ export function showAuthModal(callbacks?: Partial<AuthModalCallbacks>): void {
} }
function closeModal() { function closeModal() {
abortConditional();
activeModal?.remove(); activeModal?.remove();
activeModal = null; activeModal = null;
} }

View File

@ -76,6 +76,7 @@ export const credModule: RSpaceModule = {
description: 'Contribution recognition — Cred scores + Grain distribution via CredRank', description: 'Contribution recognition — Cred scores + Grain distribution via CredRank',
routes, routes,
publicWrite: true, // recompute route handles its own auth
scoping: { defaultScope: 'space', userConfigurable: false }, scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [graphSchema, scoresSchema, configSchema], docSchemas: [graphSchema, scoresSchema, configSchema],