mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 01:01:49 +10:00
feat: initial reauth passkeys
This commit is contained in:
@ -0,0 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
|
||||
export type DocumentActionAuthAccountProps = {
|
||||
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||
actionVerb?: string;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const DocumentActionAuthAccount = ({
|
||||
actionTarget = 'FIELD',
|
||||
actionVerb = 'sign',
|
||||
onOpenChange,
|
||||
}: DocumentActionAuthAccountProps) => {
|
||||
const { recipient } = useRequiredDocumentAuthContext();
|
||||
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
|
||||
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
|
||||
|
||||
const handleChangeAccount = async (email: string) => {
|
||||
try {
|
||||
setIsSigningOut(true);
|
||||
|
||||
const encryptedEmail = await encryptSecondaryData({
|
||||
data: email,
|
||||
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
|
||||
});
|
||||
|
||||
await signOut({
|
||||
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`,
|
||||
});
|
||||
} catch {
|
||||
setIsSigningOut(false);
|
||||
|
||||
// Todo: Alert.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset disabled={isSigningOut} className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
||||
<span>
|
||||
To mark this document as viewed, you need to be logged in as{' '}
|
||||
<strong>{recipient.email}</strong>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
|
||||
in as <strong>{recipient.email}</strong>
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={async () => handleChangeAccount(recipient.email)}
|
||||
loading={isSigningOut}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
@ -1,13 +1,6 @@
|
||||
/**
|
||||
* Note: This file has some commented out stuff for password auth which is no longer possible.
|
||||
*
|
||||
* Leaving it here until after we add passkeys and 2FA since it can be reused.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { match } from 'ts-pattern';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import {
|
||||
@ -15,18 +8,16 @@ import {
|
||||
type TRecipientActionAuth,
|
||||
type TRecipientActionAuthTypes,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import { DocumentActionAuthAccount } from './document-action-auth-account';
|
||||
import { DocumentActionAuthPasskey } from './document-action-auth-passkey';
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
|
||||
export type DocumentActionAuthDialogProps = {
|
||||
@ -34,7 +25,6 @@ export type DocumentActionAuthDialogProps = {
|
||||
documentAuthType: TRecipientActionAuthTypes;
|
||||
description?: string;
|
||||
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||
isSubmitting?: boolean;
|
||||
open: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
|
||||
@ -44,204 +34,76 @@ export type DocumentActionAuthDialogProps = {
|
||||
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||
};
|
||||
|
||||
// const ZReauthFormSchema = z.object({
|
||||
// password: ZCurrentPasswordSchema,
|
||||
// });
|
||||
// type TReauthFormSchema = z.infer<typeof ZReauthFormSchema>;
|
||||
|
||||
export const DocumentActionAuthDialog = ({
|
||||
title,
|
||||
description,
|
||||
documentAuthType,
|
||||
actionTarget = 'FIELD',
|
||||
// onReauthFormSubmit,
|
||||
isSubmitting,
|
||||
open,
|
||||
onOpenChange,
|
||||
onReauthFormSubmit,
|
||||
}: DocumentActionAuthDialogProps) => {
|
||||
const { recipient } = useRequiredDocumentAuthContext();
|
||||
|
||||
// const form = useForm({
|
||||
// resolver: zodResolver(ZReauthFormSchema),
|
||||
// defaultValues: {
|
||||
// password: '',
|
||||
// },
|
||||
// });
|
||||
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
|
||||
const isLoading = isSigningOut || isSubmitting; // || form.formState.isSubmitting;
|
||||
|
||||
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
|
||||
|
||||
// const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||
// const onFormSubmit = async (_values: TReauthFormSchema) => {
|
||||
// const documentAuthValue: TRecipientActionAuth = match(documentAuthType)
|
||||
// // Todo: Add passkey.
|
||||
// // .with(DocumentAuthType.PASSKEY, (type) => ({
|
||||
// // type,
|
||||
// // value,
|
||||
// // }))
|
||||
// .otherwise((type) => ({
|
||||
// type,
|
||||
// }));
|
||||
|
||||
// try {
|
||||
// await onReauthFormSubmit(documentAuthValue);
|
||||
|
||||
// onOpenChange(false);
|
||||
// } catch (e) {
|
||||
// const error = AppError.parseError(e);
|
||||
// setFormErrorCode(error.code);
|
||||
|
||||
// // Suppress unauthorized errors since it's handled in this component.
|
||||
// if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// throw error;
|
||||
// }
|
||||
// };
|
||||
|
||||
const handleChangeAccount = async (email: string) => {
|
||||
try {
|
||||
setIsSigningOut(true);
|
||||
|
||||
const encryptedEmail = await encryptSecondaryData({
|
||||
data: email,
|
||||
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
|
||||
});
|
||||
|
||||
await signOut({
|
||||
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`,
|
||||
});
|
||||
} catch {
|
||||
setIsSigningOut(false);
|
||||
|
||||
// Todo: Alert.
|
||||
}
|
||||
};
|
||||
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentAuthContext();
|
||||
|
||||
const handleOnOpenChange = (value: boolean) => {
|
||||
if (isLoading) {
|
||||
if (isCurrentlyAuthenticating) {
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenChange(value);
|
||||
};
|
||||
|
||||
// useEffect(() => {
|
||||
// form.reset();
|
||||
// setFormErrorCode(null);
|
||||
// }, [open, form]);
|
||||
const actionVerb =
|
||||
actionTarget === 'DOCUMENT' ? RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb : 'Sign';
|
||||
|
||||
const defaultRecipientActionVerb = RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb;
|
||||
const defaultTitleDescription = useMemo(() => {
|
||||
if (recipient.role === 'VIEWER' && actionTarget === 'DOCUMENT') {
|
||||
return {
|
||||
title: 'Mark document as viewed',
|
||||
description: 'Reauthentication is required to mark this document as viewed.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${actionVerb} ${actionTarget.toLowerCase()}`,
|
||||
description: `Reauthentication is required to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}`,
|
||||
};
|
||||
}, [recipient.role, actionVerb, actionTarget]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOnOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{title || `${defaultRecipientActionVerb} ${actionTarget.toLowerCase()}`}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{title || defaultTitleDescription.title}</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{description ||
|
||||
`Reauthentication is required to ${defaultRecipientActionVerb.toLowerCase()} the ${actionTarget.toLowerCase()}`}
|
||||
{description || defaultTitleDescription.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{match(documentAuthType)
|
||||
.with(DocumentAuth.ACCOUNT, () => (
|
||||
<fieldset disabled={isSigningOut} className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
To {defaultRecipientActionVerb.toLowerCase()} this {actionTarget.toLowerCase()},
|
||||
you need to be logged in as <strong>{recipient.email}</strong>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={async () => handleChangeAccount(recipient.email)}
|
||||
loading={isSigningOut}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
{match({ documentAuthType, user })
|
||||
.with(
|
||||
{ documentAuthType: DocumentAuth.ACCOUNT },
|
||||
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auths requires them to be logged in.
|
||||
() => (
|
||||
<DocumentActionAuthAccount
|
||||
actionVerb={actionVerb}
|
||||
actionTarget={actionTarget}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
),
|
||||
)
|
||||
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
||||
<DocumentActionAuthPasskey
|
||||
actionTarget={actionTarget}
|
||||
actionVerb={actionVerb}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
/>
|
||||
))
|
||||
.with(DocumentAuth.EXPLICIT_NONE, () => null)
|
||||
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
|
||||
.exhaustive()}
|
||||
|
||||
{/* <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex h-full flex-col space-y-4" disabled={isLoading}>
|
||||
<FormItem>
|
||||
<FormLabel required>Email</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input className="bg-background" value={recipient.email} disabled />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>Password</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<PasswordInput className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{formErrorCode && (
|
||||
<Alert variant="destructive">
|
||||
{match(formErrorCode)
|
||||
.with(AppErrorCode.UNAUTHORIZED, () => (
|
||||
<>
|
||||
<AlertTitle>Unauthorized</AlertTitle>
|
||||
<AlertDescription>
|
||||
We were unable to verify your details. Please ensure the details are
|
||||
correct
|
||||
</AlertDescription>
|
||||
</>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<>
|
||||
<AlertTitle>Something went wrong</AlertTitle>
|
||||
<AlertDescription>
|
||||
We were unable to sign this field at this time. Please try again or
|
||||
contact support.
|
||||
</AlertDescription>
|
||||
</>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isLoading}>
|
||||
Sign field
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form> */}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -0,0 +1,212 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||
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 { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
|
||||
export type DocumentActionAuthPasskeyProps = {
|
||||
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||
actionVerb?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const ZPasskeyAuthFormSchema = z.object({
|
||||
preferredPasskeyId: z.string(),
|
||||
});
|
||||
|
||||
type TPasskeyAuthFormSchema = z.infer<typeof ZPasskeyAuthFormSchema>;
|
||||
|
||||
export const DocumentActionAuthPasskey = ({
|
||||
actionTarget = 'FIELD',
|
||||
actionVerb = 'sign',
|
||||
onReauthFormSubmit,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DocumentActionAuthPasskeyProps) => {
|
||||
const {
|
||||
passkeyData,
|
||||
preferredPasskeyId,
|
||||
setPreferredPasskeyId,
|
||||
isCurrentlyAuthenticating,
|
||||
setIsCurrentlyAuthenticating,
|
||||
} = useRequiredDocumentAuthContext();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZPasskeyAuthFormSchema),
|
||||
defaultValues: {
|
||||
preferredPasskeyId: preferredPasskeyId ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createPasskeyAuthenticationOptions } =
|
||||
trpc.auth.createPasskeyAuthenticationOptions.useMutation();
|
||||
|
||||
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||
|
||||
const onFormSubmit = async (values: TPasskeyAuthFormSchema) => {
|
||||
try {
|
||||
setPreferredPasskeyId(values.preferredPasskeyId);
|
||||
setIsCurrentlyAuthenticating(true);
|
||||
|
||||
const { options, tokenReference } = await createPasskeyAuthenticationOptions({
|
||||
preferredPasskeyId: values.preferredPasskeyId,
|
||||
});
|
||||
|
||||
const authenticationResponse = await startAuthentication(options);
|
||||
|
||||
await onReauthFormSubmit({
|
||||
type: DocumentAuth.PASSKEY,
|
||||
authenticationResponse,
|
||||
tokenReference,
|
||||
});
|
||||
|
||||
setIsCurrentlyAuthenticating(false);
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setIsCurrentlyAuthenticating(false);
|
||||
|
||||
if (err.name === 'NotAllowedError') {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
setFormErrorCode(error.code);
|
||||
|
||||
// Todo: Alert.
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
preferredPasskeyId: preferredPasskeyId ?? '',
|
||||
});
|
||||
|
||||
setFormErrorCode(null);
|
||||
}, [open, form, preferredPasskeyId]);
|
||||
|
||||
if (!browserSupportsWebAuthn()) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '}
|
||||
this {actionTarget.toLowerCase()}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||
{passkeyData.passkeys.length === 0 && (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useMemo, useState } from 'react';
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||
import type {
|
||||
TDocumentAuthOptions,
|
||||
@ -13,11 +14,19 @@ import type {
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { DocumentAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
import type { Document, Passkey, Recipient, User } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
|
||||
import type { DocumentActionAuthDialogProps } from './document-action-auth-dialog';
|
||||
import { DocumentActionAuthDialog } from './document-action-auth-dialog';
|
||||
|
||||
type PasskeyData = {
|
||||
passkeys: Omit<Passkey, 'credentialId' | 'credentialPublicKey'>[];
|
||||
isLoading: boolean;
|
||||
isInitialLoading: boolean;
|
||||
isLoadingError: boolean;
|
||||
};
|
||||
|
||||
export type DocumentAuthContextValue = {
|
||||
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
|
||||
document: Document;
|
||||
@ -29,6 +38,11 @@ export type DocumentAuthContextValue = {
|
||||
derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null;
|
||||
derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
|
||||
isAuthRedirectRequired: boolean;
|
||||
isCurrentlyAuthenticating: boolean;
|
||||
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
||||
passkeyData: PasskeyData;
|
||||
preferredPasskeyId: string | null;
|
||||
setPreferredPasskeyId: (_value: string | null) => void;
|
||||
user?: User | null;
|
||||
};
|
||||
|
||||
@ -64,6 +78,26 @@ export const DocumentAuthProvider = ({
|
||||
const [document, setDocument] = useState(initialDocument);
|
||||
const [recipient, setRecipient] = useState(initialRecipient);
|
||||
|
||||
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,
|
||||
@ -78,6 +112,24 @@ 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]);
|
||||
|
||||
/**
|
||||
* Only fetch passkeys if required.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (derivedRecipientActionAuth === DocumentAuth.PASSKEY) {
|
||||
void passkeyQuery.refetch();
|
||||
}
|
||||
}, [derivedRecipientActionAuth, passkeyQuery]);
|
||||
|
||||
const [documentAuthDialogPayload, setDocumentAuthDialogPayload] =
|
||||
useState<ExecuteActionAuthProcedureOptions | null>(null);
|
||||
|
||||
@ -101,7 +153,7 @@ export const DocumentAuthProvider = ({
|
||||
.with(DocumentAuth.EXPLICIT_NONE, () => ({
|
||||
type: DocumentAuth.EXPLICIT_NONE,
|
||||
}))
|
||||
.with(null, () => null)
|
||||
.with(DocumentAuth.PASSKEY, null, () => null)
|
||||
.exhaustive();
|
||||
|
||||
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
|
||||
@ -111,7 +163,7 @@ export const DocumentAuthProvider = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Run callback with precalculated auth options if avaliable.
|
||||
// Run callback with precalculated auth options if available.
|
||||
if (preCalculatedActionAuthOptions) {
|
||||
setDocumentAuthDialogPayload(null);
|
||||
await options.onReauthFormSubmit(preCalculatedActionAuthOptions);
|
||||
@ -143,6 +195,11 @@ export const DocumentAuthProvider = ({
|
||||
derivedRecipientAccessAuth,
|
||||
derivedRecipientActionAuth,
|
||||
isAuthRedirectRequired,
|
||||
isCurrentlyAuthenticating,
|
||||
setIsCurrentlyAuthenticating,
|
||||
passkeyData,
|
||||
preferredPasskeyId,
|
||||
setPreferredPasskeyId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -30,7 +30,7 @@ export type SignatureFieldProps = {
|
||||
/**
|
||||
* The function required to be executed to insert the field.
|
||||
*
|
||||
* The auth values will be passed in if avaliable.
|
||||
* The auth values will be passed in if available.
|
||||
*/
|
||||
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
|
||||
onRemove?: () => Promise<void> | void;
|
||||
|
||||
@ -124,7 +124,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
};
|
||||
|
||||
const onSignInWithPasskey = async () => {
|
||||
if (!browserSupportsWebAuthn) {
|
||||
if (!browserSupportsWebAuthn()) {
|
||||
toast({
|
||||
title: 'Not supported',
|
||||
description: 'Passkeys are not supported on this browser',
|
||||
|
||||
Reference in New Issue
Block a user