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 = {
trigger?: React.ReactNode;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreatePasskeyFormSchema = z.object({
@ -48,7 +49,7 @@ type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
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<string | null>(null);
@ -84,6 +85,7 @@ export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogPr
duration: 5000,
});
onSuccess?.();
setOpen(false);
} catch (err) {
if (err.name === 'NotAllowedError') {

View File

@ -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<typeof ZPasskeyAuthFormSchema>;
@ -51,17 +55,19 @@ export const DocumentActionAuthPasskey = ({
onOpenChange,
}: DocumentActionAuthPasskeyProps) => {
const {
recipient,
passkeyData,
preferredPasskeyId,
setPreferredPasskeyId,
isCurrentlyAuthenticating,
setIsCurrentlyAuthenticating,
refetchPasskeys,
} = useRequiredDocumentAuthContext();
const form = useForm({
const form = useForm<TPasskeyAuthFormSchema>({
resolver: zodResolver(ZPasskeyAuthFormSchema),
defaultValues: {
preferredPasskeyId: preferredPasskeyId ?? '',
passkeyId: preferredPasskeyId || '',
},
});
@ -70,13 +76,13 @@ export const DocumentActionAuthPasskey = ({
const [formErrorCode, setFormErrorCode] = useState<string | null>(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 (
<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 (
<div className="space-y-4">
<Alert>
<AlertDescription>
{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()}.`}
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<CreatePasskeyDialog
onSuccess={async () => refetchPasskeys()}
trigger={<Button>Setup</Button>}
/>
</DialogFooter>
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
{passkeyData.passkeys.length === 0 && (
<div className="space-y-4">
<Alert>
<div className="space-y-4">
<FormField
control={form.control}
name="passkeyId"
render={({ field }) => (
<FormItem>
<FormLabel required>Passkey</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue
data-testid="documentAccessSelectValue"
placeholder="Select passkey"
/>
</SelectTrigger>
<SelectContent position="popper">
{passkeyData.passkeys.map((passkey) => (
<SelectItem key={passkey.id} value={passkey.id}>
{passkey.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{formErrorCode && (
<Alert variant="destructive">
<AlertTitle>Unauthorized</AlertTitle>
<AlertDescription>
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
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
{/* Todo */}
<Button asChild>Setup</Button>
</DialogFooter>
</div>
)}
{passkeyData.passkeys.length > 0 && (
<div className="space-y-4">
<FormField
control={form.control}
name="preferredPasskeyId"
render={({ field }) => (
<FormItem>
<FormLabel required>Passkey</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentAccessSelectValue" />
</SelectTrigger>
<SelectContent position="popper">
{passkeyData.passkeys.map((passkey) => (
<SelectItem key={passkey.id} value={passkey.id}>
{passkey.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{formErrorCode && (
<Alert variant="destructive">
<AlertTitle>Unauthorized</AlertTitle>
<AlertDescription>
We were unable to verify your details. Please try again or contact support
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
Sign
</Button>
</DialogFooter>
</div>
)}
<Button type="submit" loading={isCurrentlyAuthenticating}>
Sign
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>

View File

@ -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<Passkey, 'credentialId' | 'credentialPublicKey'>[];
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<void>;
};
const DocumentAuthContext = createContext<DocumentAuthContextValue | null>(null);
@ -81,23 +82,6 @@ export const DocumentAuthProvider = ({
const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
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 {
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<ExecuteActionAuthProcedureOptions | null>(null);
@ -200,6 +192,7 @@ export const DocumentAuthProvider = ({
passkeyData,
preferredPasskeyId,
setPreferredPasskeyId,
refetchPasskeys,
}}
>
{children}