fix: add no passkey flow

This commit is contained in:
David Nguyen
2024-03-17 23:12:25 +08:00
parent 1ed18059fb
commit 3282481ad7
3 changed files with 146 additions and 108 deletions

View File

@ -38,6 +38,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type CreatePasskeyDialogProps = { export type CreatePasskeyDialogProps = {
trigger?: React.ReactNode; trigger?: React.ReactNode;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>; } & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreatePasskeyFormSchema = z.object({ const ZCreatePasskeyFormSchema = z.object({
@ -48,7 +49,7 @@ type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
const parser = new UAParser(); const parser = new UAParser();
export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogProps) => { export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
@ -84,6 +85,7 @@ export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogPr
duration: 5000, duration: 5000,
}); });
onSuccess?.();
setOpen(false); setOpen(false);
} catch (err) { } catch (err) {
if (err.name === 'NotAllowedError') { if (err.name === 'NotAllowedError') {

View File

@ -2,11 +2,13 @@ import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'; import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -27,6 +29,8 @@ import {
SelectValue, SelectValue,
} from '@documenso/ui/primitives/select'; } from '@documenso/ui/primitives/select';
import { CreatePasskeyDialog } from '~/app/(dashboard)/settings/security/passkeys/create-passkey-dialog';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
export type DocumentActionAuthPasskeyProps = { export type DocumentActionAuthPasskeyProps = {
@ -38,7 +42,7 @@ export type DocumentActionAuthPasskeyProps = {
}; };
const ZPasskeyAuthFormSchema = z.object({ const ZPasskeyAuthFormSchema = z.object({
preferredPasskeyId: z.string(), passkeyId: z.string(),
}); });
type TPasskeyAuthFormSchema = z.infer<typeof ZPasskeyAuthFormSchema>; type TPasskeyAuthFormSchema = z.infer<typeof ZPasskeyAuthFormSchema>;
@ -51,17 +55,19 @@ export const DocumentActionAuthPasskey = ({
onOpenChange, onOpenChange,
}: DocumentActionAuthPasskeyProps) => { }: DocumentActionAuthPasskeyProps) => {
const { const {
recipient,
passkeyData, passkeyData,
preferredPasskeyId, preferredPasskeyId,
setPreferredPasskeyId, setPreferredPasskeyId,
isCurrentlyAuthenticating, isCurrentlyAuthenticating,
setIsCurrentlyAuthenticating, setIsCurrentlyAuthenticating,
refetchPasskeys,
} = useRequiredDocumentAuthContext(); } = useRequiredDocumentAuthContext();
const form = useForm({ const form = useForm<TPasskeyAuthFormSchema>({
resolver: zodResolver(ZPasskeyAuthFormSchema), resolver: zodResolver(ZPasskeyAuthFormSchema),
defaultValues: { defaultValues: {
preferredPasskeyId: preferredPasskeyId ?? '', passkeyId: preferredPasskeyId || '',
}, },
}); });
@ -70,13 +76,13 @@ export const DocumentActionAuthPasskey = ({
const [formErrorCode, setFormErrorCode] = useState<string | null>(null); const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
const onFormSubmit = async (values: TPasskeyAuthFormSchema) => { const onFormSubmit = async ({ passkeyId }: TPasskeyAuthFormSchema) => {
try { try {
setPreferredPasskeyId(values.preferredPasskeyId); setPreferredPasskeyId(passkeyId);
setIsCurrentlyAuthenticating(true); setIsCurrentlyAuthenticating(true);
const { options, tokenReference } = await createPasskeyAuthenticationOptions({ const { options, tokenReference } = await createPasskeyAuthenticationOptions({
preferredPasskeyId: values.preferredPasskeyId, preferredPasskeyId: passkeyId,
}); });
const authenticationResponse = await startAuthentication(options); const authenticationResponse = await startAuthentication(options);
@ -106,7 +112,7 @@ export const DocumentActionAuthPasskey = ({
useEffect(() => { useEffect(() => {
form.reset({ form.reset({
preferredPasskeyId: preferredPasskeyId ?? '', passkeyId: preferredPasskeyId || '',
}); });
setFormErrorCode(null); setFormErrorCode(null);
@ -131,16 +137,45 @@ export const DocumentActionAuthPasskey = ({
); );
} }
if (
passkeyData.isInitialLoading ||
(passkeyData.isRefetching && passkeyData.passkeys.length === 0)
) {
return (
<div className="flex h-28 items-center justify-center">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
if (passkeyData.isError) {
return (
<div className="h-28 space-y-4">
<Alert variant="destructive">
<AlertDescription>Something went wrong while loading your passkeys.</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="button" onClick={() => void refetchPasskeys()}>
Retry
</Button>
</DialogFooter>
</div>
);
}
if (passkeyData.passkeys.length === 0) {
return ( return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
{passkeyData.passkeys.length === 0 && (
<div className="space-y-4"> <div className="space-y-4">
<Alert> <Alert>
<AlertDescription> <AlertDescription>
You need to setup a passkey to {actionVerb.toLowerCase()} this{' '} {recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
{actionTarget.toLowerCase()}. ? 'You need to setup a passkey to mark this document as viewed.'
: `You need to setup a passkey to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@ -149,17 +184,23 @@ export const DocumentActionAuthPasskey = ({
Cancel Cancel
</Button> </Button>
{/* Todo */} <CreatePasskeyDialog
<Button asChild>Setup</Button> onSuccess={async () => refetchPasskeys()}
trigger={<Button>Setup</Button>}
/>
</DialogFooter> </DialogFooter>
</div> </div>
)} );
}
{passkeyData.passkeys.length > 0 && ( return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4"> <div className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="preferredPasskeyId" name="passkeyId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel required>Passkey</FormLabel> <FormLabel required>Passkey</FormLabel>
@ -167,7 +208,10 @@ export const DocumentActionAuthPasskey = ({
<FormControl> <FormControl>
<Select {...field} onValueChange={field.onChange}> <Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground"> <SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentAccessSelectValue" /> <SelectValue
data-testid="documentAccessSelectValue"
placeholder="Select passkey"
/>
</SelectTrigger> </SelectTrigger>
<SelectContent position="popper"> <SelectContent position="popper">
@ -204,7 +248,6 @@ export const DocumentActionAuthPasskey = ({
</Button> </Button>
</DialogFooter> </DialogFooter>
</div> </div>
)}
</fieldset> </fieldset>
</form> </form>
</Form> </Form>

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -22,9 +22,9 @@ import { DocumentActionAuthDialog } from './document-action-auth-dialog';
type PasskeyData = { type PasskeyData = {
passkeys: Omit<Passkey, 'credentialId' | 'credentialPublicKey'>[]; passkeys: Omit<Passkey, 'credentialId' | 'credentialPublicKey'>[];
isLoading: boolean;
isInitialLoading: boolean; isInitialLoading: boolean;
isLoadingError: boolean; isRefetching: boolean;
isError: boolean;
}; };
export type DocumentAuthContextValue = { export type DocumentAuthContextValue = {
@ -44,6 +44,7 @@ export type DocumentAuthContextValue = {
preferredPasskeyId: string | null; preferredPasskeyId: string | null;
setPreferredPasskeyId: (_value: string | null) => void; setPreferredPasskeyId: (_value: string | null) => void;
user?: User | null; user?: User | null;
refetchPasskeys: () => Promise<void>;
}; };
const DocumentAuthContext = createContext<DocumentAuthContextValue | null>(null); const DocumentAuthContext = createContext<DocumentAuthContextValue | null>(null);
@ -81,23 +82,6 @@ export const DocumentAuthProvider = ({
const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false); const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
const [preferredPasskeyId, setPreferredPasskeyId] = useState<string | null>(null); const [preferredPasskeyId, setPreferredPasskeyId] = useState<string | null>(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 { const {
documentAuthOption, documentAuthOption,
recipientAuthOption, recipientAuthOption,
@ -112,23 +96,31 @@ export const DocumentAuthProvider = ({
[document, recipient], [document, recipient],
); );
/** const passkeyQuery = trpc.auth.findPasskeys.useQuery(
* By default, select the first passkey since it's pre sorted by most recently used. {
*/ perPage: MAXIMUM_PASSKEYS,
useEffect(() => { },
if (!preferredPasskeyId && passkeyQuery.data && passkeyQuery.data.data.length > 0) { {
setPreferredPasskeyId(passkeyQuery.data.data[0].id); keepPreviousData: true,
} enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY,
}, [passkeyQuery.data, preferredPasskeyId]); },
);
/** const passkeyData: PasskeyData = {
* Only fetch passkeys if required. passkeys: passkeyQuery.data?.data || [],
*/ isInitialLoading: passkeyQuery.isInitialLoading,
useEffect(() => { isRefetching: passkeyQuery.isRefetching,
if (derivedRecipientActionAuth === DocumentAuth.PASSKEY) { isError: passkeyQuery.isError,
void passkeyQuery.refetch(); };
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] = const [documentAuthDialogPayload, setDocumentAuthDialogPayload] =
useState<ExecuteActionAuthProcedureOptions | null>(null); useState<ExecuteActionAuthProcedureOptions | null>(null);
@ -200,6 +192,7 @@ export const DocumentAuthProvider = ({
passkeyData, passkeyData,
preferredPasskeyId, preferredPasskeyId,
setPreferredPasskeyId, setPreferredPasskeyId,
refetchPasskeys,
}} }}
> >
{children} {children}