Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion apps/web/src/app/auth/signin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ import {
GoogleOneTap,
MagicLinkForm,
PasskeyLoginButton,
useConditionalPasskeyUI,
ExternalAuthWaiting,
} from "@/components/auth";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuthCSRF } from "@/hooks/useAuthCSRF";
import { useOAuthSignIn } from "@/hooks/useOAuthSignIn";
import { isOnPrem } from "@/lib/deployment-mode";
Expand Down Expand Up @@ -89,6 +92,19 @@ function SignInForm() {
}
}, [searchParams]);

// Conditional UI: passkey autofill on the email input (cloud only)
const { isAvailable: conditionalUIAvailable, startConditionalUI } = useConditionalPasskeyUI(
onPrem ? '' : (csrfToken ?? ''),
{
refreshToken,
onSuccess: (redirectUrl) => { window.location.href = redirectUrl; },
}
);

useEffect(() => {
if (csrfToken && !onPrem) startConditionalUI();
Comment thread
2witstudios marked this conversation as resolved.
}, [csrfToken, startConditionalUI, onPrem]);

// On-prem: passkey + magic link sign-in (no OAuth)
if (onPrem) {
return (
Expand Down Expand Up @@ -147,6 +163,28 @@ function SignInForm() {
</p>
</motion.div>

{/* Email input — anchors conditional UI (passkey autofill).
Only rendered when the browser supports conditional mediation,
otherwise it's a non-functional dead-end. */}
{conditionalUIAvailable && (
<motion.div
className="mb-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15, duration: 0.3 }}
>
<div className="grid gap-1.5">
<Label htmlFor="signin-email">Email</Label>
<Input
id="signin-email"
type="email"
placeholder="you@example.com"
autoComplete="email webauthn"
/>
</div>
</motion.div>
)}

{/* OAuth buttons */}
{isWaitingForExternalAuth ? (
<ExternalAuthWaiting provider={waitingProvider} onCancel={cancelExternalAuth} />
Expand All @@ -162,7 +200,7 @@ function SignInForm() {

<AuthDivider delay={0.3} />

{/* Passkey login */}
{/* Passkey login — fallback for browsers without conditional mediation */}
{csrfToken && (
<motion.div
initial={{ opacity: 0, y: 10 }}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/auth/MagicLinkForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ export function MagicLinkForm() {
onChange={(e) => setEmail(e.target.value)}
className="pl-10"
disabled={formState === 'sending'}
autoComplete="email"
autoComplete="email webauthn"
autoFocus
/>
</div>
Expand Down
104 changes: 1 addition & 103 deletions apps/web/src/components/auth/PasskeyLoginButton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { startAuthentication } from '@simplewebauthn/browser';
import { Button } from '@/components/ui/button';
import { Fingerprint, Loader2 } from 'lucide-react';
Expand Down Expand Up @@ -160,105 +160,3 @@ export function PasskeyLoginButton({
);
}

/**
* Hook for conditional UI support (passkey autofill).
* Call this on page load to start conditional UI in the background.
*/
export function useConditionalPasskeyUI(
csrfToken: string,
onSuccess?: (redirectUrl: string) => void
) {
const [isAvailable, setIsAvailable] = useState(false);
const [isAuthenticating, setIsAuthenticating] = useState(false);

useEffect(() => {
const checkAvailability = async () => {
if (typeof window === 'undefined') return;

// Check if conditional mediation is available
const available = await (
window.PublicKeyCredential?.isConditionalMediationAvailable?.() ??
Promise.resolve(false)
);

setIsAvailable(available);
};

checkAvailability();
}, []);

const startConditionalUI = useCallback(async () => {
if (!isAvailable || !csrfToken) return;

try {
const platformFields = await getDevicePlatformFields();

const optionsRes = await fetch('/api/auth/passkey/authenticate/options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ csrfToken }),
});

if (!optionsRes.ok) return;

const { options } = await optionsRes.json();

setIsAuthenticating(true);

const authResponse = await startAuthentication({
optionsJSON: options,
useBrowserAutofill: true,
});

const verifyRes = await fetch('/api/auth/passkey/authenticate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
response: authResponse,
expectedChallenge: options.challenge,
csrfToken,
...platformFields,
}),
});

if (!verifyRes.ok) {
const error = await verifyRes.json();
toast.error(error.error || 'Authentication failed');
return;
}

const verifyData = await verifyRes.json();

persistCsrfToken();
useAuthStore.getState().setAuthFailedPermanently(false);

toast.success('Signed in successfully');

if (await handleDesktopAuthResponse(verifyData)) return;

if (onSuccess) {
onSuccess(verifyData.redirectUrl);
} else {
window.location.href = verifyData.redirectUrl;
}
} catch (err) {
// Conditional UI was cancelled or failed - this is expected behavior
// Don't show error toast for AbortError (user cancelled)
if (err instanceof Error && err.name !== 'AbortError') {
console.debug('Conditional UI authentication failed:', err.message);
}
} finally {
setIsAuthenticating(false);
}
}, [isAvailable, csrfToken, onSuccess]);

return {
isAvailable,
isAuthenticating,
startConditionalUI,
};
}
Loading
Loading