mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 01:01:49 +10:00
fix: add no passkey flow
This commit is contained in:
@ -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') {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
Reference in New Issue
Block a user