11import { getFormProps , getInputProps , useForm } from '@conform-to/react'
22import { getZodConstraint , parseWithZod } from '@conform-to/zod'
33import { type SEOHandle } from '@nasa-gcn/remix-seo'
4- import { data , Form , Link , useSearchParams } from 'react-router'
4+ import { startAuthentication } from '@simplewebauthn/browser'
5+ import { useOptimistic , useState , useTransition } from 'react'
6+ import { data , Form , Link , useNavigate , useSearchParams } from 'react-router'
57import { HoneypotInputs } from 'remix-utils/honeypot/react'
68import { z } from 'zod'
79import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
810import { CheckboxField , ErrorList , Field } from '#app/components/forms.tsx'
911import { Spacer } from '#app/components/spacer.tsx'
12+ import { Icon } from '#app/components/ui/icon.tsx'
1013import { StatusButton } from '#app/components/ui/status-button.tsx'
1114import { login , requireAnonymous } from '#app/utils/auth.server.ts'
1215import {
1316 ProviderConnectionForm ,
1417 providerNames ,
1518} from '#app/utils/connections.tsx'
1619import { checkHoneypot } from '#app/utils/honeypot.server.ts'
17- import { useIsPending } from '#app/utils/misc.tsx'
20+ import { getErrorMessage , useIsPending } from '#app/utils/misc.tsx'
1821import { PasswordSchema , UsernameSchema } from '#app/utils/user-validation.ts'
1922import { type Route } from './+types/login.ts'
2023import { handleNewSession } from './login.server.ts'
@@ -30,6 +33,10 @@ const LoginFormSchema = z.object({
3033 remember : z . boolean ( ) . optional ( ) ,
3134} )
3235
36+ const AuthenticationOptionsSchema = z . object ( {
37+ options : z . object ( { challenge : z . string ( ) } ) ,
38+ } ) satisfies z . ZodType < { options : PublicKeyCredentialRequestOptionsJSON } >
39+
3340export async function loader ( { request } : Route . LoaderArgs ) {
3441 await requireAnonymous ( request )
3542 return { }
@@ -165,7 +172,15 @@ export default function LoginPage({ actionData }: Route.ComponentProps) {
165172 </ StatusButton >
166173 </ div >
167174 </ Form >
168- < ul className = "mt-5 flex flex-col gap-5 border-b-2 border-t-2 border-border py-3" >
175+ < hr className = "my-4" />
176+ < div className = "flex flex-col gap-5" >
177+ < PasskeyLogin
178+ redirectTo = { redirectTo }
179+ remember = { fields . remember . value === 'on' }
180+ />
181+ </ div >
182+ < hr className = "my-4" />
183+ < ul className = "flex flex-col gap-5" >
169184 { providerNames . map ( ( providerName ) => (
170185 < li key = { providerName } >
171186 < ProviderConnectionForm
@@ -195,6 +210,94 @@ export default function LoginPage({ actionData }: Route.ComponentProps) {
195210 )
196211}
197212
213+ const VerificationResponseSchema = z . discriminatedUnion ( 'status' , [
214+ z . object ( {
215+ status : z . literal ( 'success' ) ,
216+ location : z . string ( ) ,
217+ } ) ,
218+ z . object ( {
219+ status : z . literal ( 'error' ) ,
220+ error : z . string ( ) ,
221+ } ) ,
222+ ] )
223+
224+ function PasskeyLogin ( {
225+ redirectTo,
226+ remember,
227+ } : {
228+ redirectTo : string | null
229+ remember : boolean
230+ } ) {
231+ const [ isPending ] = useTransition ( )
232+ const [ error , setError ] = useState < string | null > ( null )
233+ const [ passkeyMessage , setPasskeyMessage ] = useOptimistic < string | null > (
234+ 'Login with a passkey' ,
235+ )
236+ const navigate = useNavigate ( )
237+
238+ async function handlePasskeyLogin ( ) {
239+ try {
240+ setPasskeyMessage ( 'Generating Authentication Options' )
241+ // Get authentication options from the server
242+ const optionsResponse = await fetch ( '/webauthn/authentication' )
243+ const json = await optionsResponse . json ( )
244+ const { options } = AuthenticationOptionsSchema . parse ( json )
245+
246+ setPasskeyMessage ( 'Requesting your authorization' )
247+ const authResponse = await startAuthentication ( { optionsJSON : options } )
248+ setPasskeyMessage ( 'Verifying your passkey' )
249+
250+ // Verify the authentication with the server
251+ const verificationResponse = await fetch ( '/webauthn/authentication' , {
252+ method : 'POST' ,
253+ headers : { 'Content-Type' : 'application/json' } ,
254+ body : JSON . stringify ( { authResponse, remember, redirectTo } ) ,
255+ } )
256+
257+ const verificationJson = await verificationResponse . json ( ) . catch ( ( ) => ( {
258+ status : 'error' ,
259+ error : 'Unknown error' ,
260+ } ) )
261+
262+ const parsedResult =
263+ VerificationResponseSchema . safeParse ( verificationJson )
264+ if ( ! parsedResult . success ) {
265+ throw new Error ( parsedResult . error . message )
266+ } else if ( parsedResult . data . status === 'error' ) {
267+ throw new Error ( parsedResult . data . error )
268+ }
269+ const { location } = parsedResult . data
270+
271+ setPasskeyMessage ( "You're logged in! Navigating..." )
272+ await navigate ( location ?? '/' )
273+ } catch ( e ) {
274+ const errorMessage = getErrorMessage ( e )
275+ setError ( `Failed to authenticate with passkey: ${ errorMessage } ` )
276+ }
277+ }
278+
279+ return (
280+ < form action = { handlePasskeyLogin } >
281+ < StatusButton
282+ id = "passkey-login-button"
283+ aria-describedby = "passkey-login-button-error"
284+ className = "w-full"
285+ status = { isPending ? 'pending' : error ? 'error' : 'idle' }
286+ type = "submit"
287+ disabled = { isPending }
288+ >
289+ < span className = "inline-flex items-center gap-1.5" >
290+ < Icon name = "passkey" />
291+ < span > { passkeyMessage } </ span >
292+ </ span >
293+ </ StatusButton >
294+ < div className = "mt-2" >
295+ < ErrorList errors = { [ error ] } id = "passkey-login-button-error" />
296+ </ div >
297+ </ form >
298+ )
299+ }
300+
198301export const meta : Route . MetaFunction = ( ) => {
199302 return [ { title : 'Login to Epic Notes' } ]
200303}
0 commit comments