From 3282481ad7276fb9fcccbb55715e01bb425eb6d8 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sun, 17 Mar 2024 23:12:25 +0800 Subject: [PATCH] fix: add no passkey flow --- .../passkeys/create-passkey-dialog.tsx | 4 +- .../[token]/document-action-auth-passkey.tsx | 187 +++++++++++------- .../sign/[token]/document-auth-provider.tsx | 63 +++--- 3 files changed, 146 insertions(+), 108 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx b/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx index c07d638c0..f6db55e10 100644 --- a/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx +++ b/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx @@ -38,6 +38,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type CreatePasskeyDialogProps = { trigger?: React.ReactNode; + onSuccess?: () => void; } & Omit; const ZCreatePasskeyFormSchema = z.object({ @@ -48,7 +49,7 @@ type TCreatePasskeyFormSchema = z.infer; const parser = new UAParser(); -export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogProps) => { +export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => { const [open, setOpen] = useState(false); const [formError, setFormError] = useState(null); @@ -84,6 +85,7 @@ export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogPr duration: 5000, }); + onSuccess?.(); setOpen(false); } catch (err) { if (err.name === 'NotAllowedError') { diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx index 070dbc635..ad8199b84 100644 --- a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-passkey.tsx @@ -2,11 +2,13 @@ import { useEffect, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'; +import { Loader } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { AppError } from '@documenso/lib/errors/app-error'; import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { RecipientRole } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; @@ -27,6 +29,8 @@ import { SelectValue, } from '@documenso/ui/primitives/select'; +import { CreatePasskeyDialog } from '~/app/(dashboard)/settings/security/passkeys/create-passkey-dialog'; + import { useRequiredDocumentAuthContext } from './document-auth-provider'; export type DocumentActionAuthPasskeyProps = { @@ -38,7 +42,7 @@ export type DocumentActionAuthPasskeyProps = { }; const ZPasskeyAuthFormSchema = z.object({ - preferredPasskeyId: z.string(), + passkeyId: z.string(), }); type TPasskeyAuthFormSchema = z.infer; @@ -51,17 +55,19 @@ export const DocumentActionAuthPasskey = ({ onOpenChange, }: DocumentActionAuthPasskeyProps) => { const { + recipient, passkeyData, preferredPasskeyId, setPreferredPasskeyId, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating, + refetchPasskeys, } = useRequiredDocumentAuthContext(); - const form = useForm({ + const form = useForm({ resolver: zodResolver(ZPasskeyAuthFormSchema), defaultValues: { - preferredPasskeyId: preferredPasskeyId ?? '', + passkeyId: preferredPasskeyId || '', }, }); @@ -70,13 +76,13 @@ export const DocumentActionAuthPasskey = ({ const [formErrorCode, setFormErrorCode] = useState(null); - const onFormSubmit = async (values: TPasskeyAuthFormSchema) => { + const onFormSubmit = async ({ passkeyId }: TPasskeyAuthFormSchema) => { try { - setPreferredPasskeyId(values.preferredPasskeyId); + setPreferredPasskeyId(passkeyId); setIsCurrentlyAuthenticating(true); const { options, tokenReference } = await createPasskeyAuthenticationOptions({ - preferredPasskeyId: values.preferredPasskeyId, + preferredPasskeyId: passkeyId, }); const authenticationResponse = await startAuthentication(options); @@ -106,7 +112,7 @@ export const DocumentActionAuthPasskey = ({ useEffect(() => { form.reset({ - preferredPasskeyId: preferredPasskeyId ?? '', + passkeyId: preferredPasskeyId || '', }); setFormErrorCode(null); @@ -131,80 +137,117 @@ export const DocumentActionAuthPasskey = ({ ); } + if ( + passkeyData.isInitialLoading || + (passkeyData.isRefetching && passkeyData.passkeys.length === 0) + ) { + return ( +
+ +
+ ); + } + + if (passkeyData.isError) { + return ( +
+ + Something went wrong while loading your passkeys. + + + + + + + +
+ ); + } + + if (passkeyData.passkeys.length === 0) { + return ( +
+ + + {recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' + ? 'You need to setup a passkey to mark this document as viewed.' + : `You need to setup a passkey to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`} + + + + + + + refetchPasskeys()} + trigger={} + /> + +
+ ); + } + return (
- {passkeyData.passkeys.length === 0 && ( -
- +
+ ( + + Passkey + + + + + + + + )} + /> + + {formErrorCode && ( + + Unauthorized - You need to setup a passkey to {actionVerb.toLowerCase()} this{' '} - {actionTarget.toLowerCase()}. + We were unable to verify your details. Please try again or contact support + )} - - + + - {/* Todo */} - - -
- )} - - {passkeyData.passkeys.length > 0 && ( -
- ( - - Passkey - - - - - - - - )} - /> - - {formErrorCode && ( - - Unauthorized - - We were unable to verify your details. Please try again or contact support - - - )} - - - - - - -
- )} + + +
diff --git a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx index ef771f19e..430bd4a18 100644 --- a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx @@ -1,6 +1,6 @@ 'use client'; -import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { createContext, useCallback, useContext, useMemo, useState } from 'react'; import { match } from 'ts-pattern'; @@ -22,9 +22,9 @@ import { DocumentActionAuthDialog } from './document-action-auth-dialog'; type PasskeyData = { passkeys: Omit[]; - isLoading: boolean; isInitialLoading: boolean; - isLoadingError: boolean; + isRefetching: boolean; + isError: boolean; }; export type DocumentAuthContextValue = { @@ -44,6 +44,7 @@ export type DocumentAuthContextValue = { preferredPasskeyId: string | null; setPreferredPasskeyId: (_value: string | null) => void; user?: User | null; + refetchPasskeys: () => Promise; }; const DocumentAuthContext = createContext(null); @@ -81,23 +82,6 @@ export const DocumentAuthProvider = ({ const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false); const [preferredPasskeyId, setPreferredPasskeyId] = useState(null); - const passkeyQuery = trpc.auth.findPasskeys.useQuery( - { - perPage: MAXIMUM_PASSKEYS, - }, - { - keepPreviousData: true, - enabled: false, - }, - ); - - const passkeyData: PasskeyData = { - passkeys: passkeyQuery.data?.data || [], - isLoading: passkeyQuery.isLoading, - isInitialLoading: passkeyQuery.isInitialLoading, - isLoadingError: passkeyQuery.isLoadingError, - }; - const { documentAuthOption, recipientAuthOption, @@ -112,23 +96,31 @@ export const DocumentAuthProvider = ({ [document, recipient], ); - /** - * By default, select the first passkey since it's pre sorted by most recently used. - */ - useEffect(() => { - if (!preferredPasskeyId && passkeyQuery.data && passkeyQuery.data.data.length > 0) { - setPreferredPasskeyId(passkeyQuery.data.data[0].id); - } - }, [passkeyQuery.data, preferredPasskeyId]); + const passkeyQuery = trpc.auth.findPasskeys.useQuery( + { + perPage: MAXIMUM_PASSKEYS, + }, + { + keepPreviousData: true, + enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY, + }, + ); - /** - * Only fetch passkeys if required. - */ - useEffect(() => { - if (derivedRecipientActionAuth === DocumentAuth.PASSKEY) { - void passkeyQuery.refetch(); + const passkeyData: PasskeyData = { + passkeys: passkeyQuery.data?.data || [], + isInitialLoading: passkeyQuery.isInitialLoading, + isRefetching: passkeyQuery.isRefetching, + isError: passkeyQuery.isError, + }; + + const refetchPasskeys = useCallback(async () => { + const { data } = await passkeyQuery.refetch(); + + if (!preferredPasskeyId && data && data.data.length > 0) { + setPreferredPasskeyId(data.data[0].id); } - }, [derivedRecipientActionAuth, passkeyQuery]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [preferredPasskeyId]); const [documentAuthDialogPayload, setDocumentAuthDialogPayload] = useState(null); @@ -200,6 +192,7 @@ export const DocumentAuthProvider = ({ passkeyData, preferredPasskeyId, setPreferredPasskeyId, + refetchPasskeys, }} > {children}