diff --git a/apps/remix/app/components/(dashboard)/common/command-menu.tsx b/apps/remix/app/components/(dashboard)/common/command-menu.tsx index b0f97fd57..d697bc295 100644 --- a/apps/remix/app/components/(dashboard)/common/command-menu.tsx +++ b/apps/remix/app/components/(dashboard)/common/command-menu.tsx @@ -4,9 +4,9 @@ import type { MessageDescriptor } from '@lingui/core'; import { Trans, msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react'; -import { useTheme } from 'next-themes'; import { useHotkeys } from 'react-hotkeys-hook'; import { useNavigate } from 'react-router'; +import { Theme, useTheme } from 'remix-themes'; import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n'; import { @@ -31,7 +31,6 @@ import { CommandList, CommandShortcut, } from '@documenso/ui/primitives/command'; -import { THEMES_TYPE } from '@documenso/ui/primitives/constants'; import { useToast } from '@documenso/ui/primitives/use-toast'; const DOCUMENTS_PAGES = [ @@ -74,7 +73,6 @@ export type CommandMenuProps = { export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const { _ } = useLingui(); - const { setTheme } = useTheme(); const navigate = useNavigate(); @@ -224,7 +222,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { )} - {currentPage === 'theme' && } + {currentPage === 'theme' && } {currentPage === 'language' && } @@ -253,19 +251,18 @@ const Commands = ({ )); }; -const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => { +const ThemeCommands = () => { const { _ } = useLingui(); - const THEMES = useMemo( - () => [ - { label: msg`Light Mode`, theme: THEMES_TYPE.LIGHT, icon: Sun }, - { label: msg`Dark Mode`, theme: THEMES_TYPE.DARK, icon: Moon }, - { label: msg`System Theme`, theme: THEMES_TYPE.SYSTEM, icon: Monitor }, - ], - [], - ); + const [, setTheme] = useTheme(); - return THEMES.map((theme) => ( + const themes = [ + { label: msg`Light Mode`, theme: Theme.LIGHT, icon: Sun }, + { label: msg`Dark Mode`, theme: Theme.DARK, icon: Moon }, + { label: msg`System Theme`, theme: null, icon: Monitor }, + ] as const; + + return themes.map((theme) => ( setTheme(theme.theme)} diff --git a/apps/remix/app/components/(teams)/forms/update-team-form.tsx b/apps/remix/app/components/(teams)/forms/update-team-form.tsx index 0d3a75f67..fa26195a6 100644 --- a/apps/remix/app/components/(teams)/forms/update-team-form.tsx +++ b/apps/remix/app/components/(teams)/forms/update-team-form.tsx @@ -6,7 +6,7 @@ import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import type { z } from 'zod'; -import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; import { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema'; @@ -72,7 +72,7 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr }); if (url !== teamUrl) { - await navigate(`${WEBAPP_BASE_URL}/t/${url}/settings`); + await navigate(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${url}/settings`); } } catch (err) { const error = AppError.parseError(err); @@ -130,7 +130,7 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr {!form.formState.errors.url && ( {field.value ? ( - `${WEBAPP_BASE_URL}/t/${field.value}` + `${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}` ) : ( A unique URL to identify your team )} diff --git a/apps/remix/app/components/(teams)/tables/current-user-teams-data-table.tsx b/apps/remix/app/components/(teams)/tables/current-user-teams-data-table.tsx index cd4c16ffb..ad0550101 100644 --- a/apps/remix/app/components/(teams)/tables/current-user-teams-data-table.tsx +++ b/apps/remix/app/components/(teams)/tables/current-user-teams-data-table.tsx @@ -6,7 +6,7 @@ import { useSearchParams } from 'react-router'; import { Link } from 'react-router'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; @@ -69,7 +69,7 @@ export const CurrentUserTeamsDataTable = () => { primaryText={ {row.original.name} } - secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`} + secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/t/${row.original.url}`} /> ), diff --git a/apps/remix/app/components/(teams)/tables/pending-user-teams-data-table.tsx b/apps/remix/app/components/(teams)/tables/pending-user-teams-data-table.tsx index 02bcd3794..8238d4f00 100644 --- a/apps/remix/app/components/(teams)/tables/pending-user-teams-data-table.tsx +++ b/apps/remix/app/components/(teams)/tables/pending-user-teams-data-table.tsx @@ -5,7 +5,7 @@ import { useLingui } from '@lingui/react'; import { useSearchParams } from 'react-router'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; import { trpc } from '@documenso/trpc/react'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; @@ -66,7 +66,7 @@ export const PendingUserTeamsDataTable = () => { primaryText={ {row.original.name} } - secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`} + secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/t/${row.original.url}`} /> ), }, diff --git a/apps/remix/app/components/dialogs/team-create-dialog.tsx b/apps/remix/app/components/dialogs/team-create-dialog.tsx index 7b386d897..8531a2daa 100644 --- a/apps/remix/app/components/dialogs/team-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-create-dialog.tsx @@ -10,7 +10,7 @@ import { useNavigate } from 'react-router'; import type { z } from 'zod'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; import { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema'; @@ -199,7 +199,7 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) = {!form.formState.errors.teamUrl && ( {field.value ? ( - `${WEBAPP_BASE_URL}/t/${field.value}` + `${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}` ) : ( A unique URL to identify your team )} diff --git a/apps/remix/app/components/general/claim-account.tsx b/apps/remix/app/components/general/claim-account.tsx new file mode 100644 index 000000000..ae84ea227 --- /dev/null +++ b/apps/remix/app/components/general/claim-account.tsx @@ -0,0 +1,161 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; +import { z } from 'zod'; + +import { authClient } from '@documenso/auth/client'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { signupErrorMessages } from '~/components/forms/signup'; + +export type ClaimAccountProps = { + defaultName: string; + defaultEmail: string; + trigger?: React.ReactNode; +}; + +export const ZClaimAccountFormSchema = z + .object({ + name: z + .string() + .trim() + .min(1, { message: msg`Please enter a valid name.`.id }), + email: z.string().email().min(1), + password: ZPasswordSchema, + }) + .refine( + (data) => { + const { name, email, password } = data; + return !password.includes(name) && !password.includes(email.split('@')[0]); + }, + { + message: msg`Password should not be common or based on personal information`.id, + path: ['password'], + }, + ); + +export type TClaimAccountFormSchema = z.infer; + +export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const analytics = useAnalytics(); + const navigate = useNavigate(); + + const form = useForm({ + values: { + name: defaultName ?? '', + email: defaultEmail, + password: '', + }, + resolver: zodResolver(ZClaimAccountFormSchema), + }); + + const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => { + try { + await authClient.emailPassword.signUp({ name, email, password }); + + await navigate(`/unverified-account`); + + toast({ + title: _(msg`Registration Successful`), + description: _( + msg`You have successfully registered. Please verify your account by clicking on the link you received in the email.`, + ), + duration: 5000, + }); + + analytics.capture('App: User Claim Account', { + email, + timestamp: new Date().toISOString(), + }); + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST; + + toast({ + title: _(msg`An error occurred`), + description: _(errorMessage), + variant: 'destructive', + }); + } + }; + + return ( +
+
+ +
+ ( + + + Name + + + + + + + )} + /> + ( + + + Email address + + + + + + + )} + /> + ( + + + Set a password + + + + + + + )} + /> + + +
+
+ +
+ ); +}; diff --git a/apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx new file mode 100644 index 000000000..d24d49ec6 --- /dev/null +++ b/apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx @@ -0,0 +1,160 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Recipient } from '@prisma/client'; +import type { Field } from '@prisma/client'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; +import type { TTemplate } from '@documenso/lib/types/template'; +import { + DocumentFlowFormContainerActions, + DocumentFlowFormContainerContent, + DocumentFlowFormContainerFooter, + DocumentFlowFormContainerHeader, + DocumentFlowFormContainerStep, +} from '@documenso/ui/primitives/document-flow/document-flow-root'; +import { ShowFieldItem } from '@documenso/ui/primitives/document-flow/show-field-item'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useStep } from '@documenso/ui/primitives/stepper'; + +import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider'; + +const ZDirectTemplateConfigureFormSchema = z.object({ + email: z.string().email('Email is invalid'), +}); + +export type TDirectTemplateConfigureFormSchema = z.infer; + +export type DirectTemplateConfigureFormProps = { + flowStep: DocumentFlowStep; + isDocumentPdfLoaded: boolean; + template: Omit; + directTemplateRecipient: Recipient & { fields: Field[] }; + initialEmail?: string; + onSubmit: (_data: TDirectTemplateConfigureFormSchema) => void; +}; + +export const DirectTemplateConfigureForm = ({ + flowStep, + isDocumentPdfLoaded, + template, + directTemplateRecipient, + initialEmail, + onSubmit, +}: DirectTemplateConfigureFormProps) => { + const { _ } = useLingui(); + + const { user } = useOptionalSession(); + + const { recipients } = template; + const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext(); + + const recipientsWithBlankDirectRecipientEmail = recipients.map((recipient) => { + if (recipient.id === directTemplateRecipient.id) { + return { + ...recipient, + email: '', + }; + } + + return recipient; + }); + + const form = useForm({ + resolver: zodResolver( + ZDirectTemplateConfigureFormSchema.superRefine((items, ctx) => { + if (template.recipients.map((recipient) => recipient.email).includes(items.email)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: _(msg`Email cannot already exist in the template`), + path: ['email'], + }); + } + }), + ), + defaultValues: { + email: initialEmail || '', + }, + }); + + const { stepIndex, currentStep, totalSteps, previousStep } = useStep(); + + return ( + <> + + + + {isDocumentPdfLoaded && + directTemplateRecipient.fields.map((field, index) => ( + + ))} + +
+
+ ( + + + Email + + + + + + + {!fieldState.error && ( +

+ Enter your email address to receive the completed document. +

+ )} + + +
+ )} + /> +
+
+
+ + + + + + + + ); +}; diff --git a/apps/remix/app/components/general/direct-template/direct-template-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-page.tsx new file mode 100644 index 000000000..018747b23 --- /dev/null +++ b/apps/remix/app/components/general/direct-template/direct-template-page.tsx @@ -0,0 +1,177 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Field } from '@prisma/client'; +import { type Recipient } from '@prisma/client'; +import { useNavigate, useSearchParams } from 'react-router'; + +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import type { TTemplate } from '@documenso/lib/types/template'; +import { trpc } from '@documenso/trpc/react'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { Stepper } from '@documenso/ui/primitives/stepper'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider'; +import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider'; + +import { + DirectTemplateConfigureForm, + type TDirectTemplateConfigureFormSchema, +} from './direct-template-configure-form'; +import { + type DirectTemplateLocalField, + DirectTemplateSigningForm, +} from './direct-template-signing-form'; + +export type DirectTemplatePageViewProps = { + template: Omit; + directTemplateToken: string; + directTemplateRecipient: Recipient & { fields: Field[] }; +}; + +type DirectTemplateStep = 'configure' | 'sign'; +const DirectTemplateSteps: DirectTemplateStep[] = ['configure', 'sign']; + +export const DirectTemplatePageView = ({ + template, + directTemplateRecipient, + directTemplateToken, +}: DirectTemplatePageViewProps) => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const { email, fullName, setEmail } = useRequiredDocumentSigningContext(); + const { recipient, setRecipient } = useRequiredDocumentSigningAuthContext(); + + const [step, setStep] = useState('configure'); + const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); + + const recipientActionVerb = _( + RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role].actionVerb, + ); + + const directTemplateFlow: Record = { + configure: { + title: msg`General`, + description: msg`Preview and configure template.`, + stepIndex: 1, + }, + sign: { + title: msg`${recipientActionVerb} document`, + description: msg`${recipientActionVerb} the document to complete the process.`, + stepIndex: 2, + }, + }; + + const { mutateAsync: createDocumentFromDirectTemplate } = + trpc.template.createDocumentFromDirectTemplate.useMutation(); + + /** + * Set the email into a temporary recipient so it can be used for reauth and signing email fields. + */ + const onConfigureDirectTemplateSubmit = ({ email }: TDirectTemplateConfigureFormSchema) => { + setEmail(email); + + setRecipient({ + ...recipient, + email, + }); + + setStep('sign'); + }; + + const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => { + try { + let directTemplateExternalId = searchParams?.get('externalId') || undefined; + + if (directTemplateExternalId) { + directTemplateExternalId = decodeURIComponent(directTemplateExternalId); + } + + const { token } = await createDocumentFromDirectTemplate({ + directTemplateToken, + directTemplateExternalId, + directRecipientName: fullName, + directRecipientEmail: recipient.email, + templateUpdatedAt: template.updatedAt, + signedFieldValues: fields.map((field) => { + if (!field.signedValue) { + throw new Error('Invalid configuration'); + } + + return field.signedValue; + }), + }); + + const redirectUrl = template.templateMeta?.redirectUrl; + + await (redirectUrl ? navigate(redirectUrl) : navigate(`/sign/${token}/complete`)); + } catch (err) { + toast({ + title: _(msg`Something went wrong`), + description: _( + msg`We were unable to submit this document at this time. Please try again later.`, + ), + variant: 'destructive', + }); + + throw err; + } + }; + + const currentDocumentFlow = directTemplateFlow[step]; + + return ( +
+ + + setIsDocumentPdfLoaded(true)} + /> + + + +
+ e.preventDefault()} + > + setStep(DirectTemplateSteps[step - 1])} + > + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-auth-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-auth-page.tsx new file mode 100644 index 000000000..43cc2f57a --- /dev/null +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-auth-page.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; + +import { authClient } from '@documenso/auth/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export const DirectTemplateAuthPageView = () => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [isSigningOut, setIsSigningOut] = useState(false); + + const handleChangeAccount = async () => { + try { + setIsSigningOut(true); + + await authClient.signOut(); + } catch { + toast({ + title: _(msg`Something went wrong`), + description: _(msg`We were unable to log you out at this time.`), + duration: 10000, + variant: 'destructive', + }); + } + + setIsSigningOut(false); + }; + + return ( +
+
+

+ Authentication required +

+ +

+ You need to be logged in to view this page. +

+ + +
+
+ ); +}; diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx new file mode 100644 index 000000000..5456ddc78 --- /dev/null +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -0,0 +1,388 @@ +import { useMemo, useState } from 'react'; + +import { Trans } from '@lingui/macro'; +import type { Field, Recipient, Signature } from '@prisma/client'; +import { FieldType } from '@prisma/client'; +import { DateTime } from 'luxon'; +import { match } from 'ts-pattern'; + +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import { + ZCheckboxFieldMeta, + ZDropdownFieldMeta, + ZNumberFieldMeta, + ZRadioFieldMeta, + ZTextFieldMeta, +} from '@documenso/lib/types/field-meta'; +import type { TTemplate } from '@documenso/lib/types/template'; +import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + DocumentFlowFormContainerContent, + DocumentFlowFormContainerFooter, + DocumentFlowFormContainerHeader, + DocumentFlowFormContainerStep, +} from '@documenso/ui/primitives/document-flow/document-flow-root'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { ElementVisible } from '@documenso/ui/primitives/element-visible'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { useStep } from '@documenso/ui/primitives/stepper'; + +import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field'; +import { DocumentSigningCompleteDialog } from '~/components/general/document-signing/document-signing-complete-dialog'; +import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field'; +import { DocumentSigningDropdownField } from '~/components/general/document-signing/document-signing-dropdown-field'; +import { DocumentSigningEmailField } from '~/components/general/document-signing/document-signing-email-field'; +import { DocumentSigningInitialsField } from '~/components/general/document-signing/document-signing-initials-field'; +import { DocumentSigningNameField } from '~/components/general/document-signing/document-signing-name-field'; +import { DocumentSigningNumberField } from '~/components/general/document-signing/document-signing-number-field'; +import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider'; +import { DocumentSigningRadioField } from '~/components/general/document-signing/document-signing-radio-field'; +import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field'; +import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field'; + +export type DirectTemplateSigningFormProps = { + flowStep: DocumentFlowStep; + directRecipient: Recipient; + directRecipientFields: Field[]; + template: Omit; + onSubmit: (_data: DirectTemplateLocalField[]) => Promise; +}; + +export type DirectTemplateLocalField = Field & { + signedValue?: TSignFieldWithTokenMutationSchema; + signature?: Signature; +}; + +export const DirectTemplateSigningForm = ({ + flowStep, + directRecipient, + directRecipientFields, + template, + onSubmit, +}: DirectTemplateSigningFormProps) => { + const { fullName, signature, signatureValid, setFullName, setSignature } = + useRequiredDocumentSigningContext(); + + const [localFields, setLocalFields] = useState(directRecipientFields); + const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { currentStep, totalSteps, previousStep } = useStep(); + + const onSignField = (value: TSignFieldWithTokenMutationSchema) => { + setLocalFields( + localFields.map((field) => { + if (field.id !== value.fieldId) { + return field; + } + + const tempField: DirectTemplateLocalField = { + ...field, + customText: value.value, + inserted: true, + signedValue: value, + }; + + if (field.type === FieldType.SIGNATURE) { + tempField.signature = { + id: 1, + created: new Date(), + recipientId: 1, + fieldId: 1, + signatureImageAsBase64: value.value.startsWith('data:') ? value.value : null, + typedSignature: value.value.startsWith('data:') ? null : value.value, + } satisfies Signature; + } + + if (field.type === FieldType.DATE) { + tempField.customText = DateTime.now() + .setZone(template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE) + .toFormat(template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT); + } + return tempField; + }), + ); + }; + + const onUnsignField = (value: TRemovedSignedFieldWithTokenMutationSchema) => { + setLocalFields( + localFields.map((field) => { + if (field.id !== value.fieldId) { + return field; + } + + return { + ...field, + customText: '', + inserted: false, + signedValue: undefined, + signature: undefined, + }; + }), + ); + }; + + const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE); + + const uninsertedFields = useMemo(() => { + return sortFieldsByPosition(localFields.filter((field) => !field.inserted)); + }, [localFields]); + + const fieldsValidated = () => { + setValidateUninsertedFields(true); + validateFieldsInserted(localFields); + }; + + const handleSubmit = async () => { + setValidateUninsertedFields(true); + + if (hasSignatureField && !signatureValid) { + return; + } + + const isFieldsValid = validateFieldsInserted(localFields); + + if (!isFieldsValid) { + return; + } + + setIsSubmitting(true); + + try { + await onSubmit(localFields); + } catch { + setIsSubmitting(false); + } + + // Do not reset to false since we do a redirect. + }; + + return ( + <> + + + + + {validateUninsertedFields && uninsertedFields[0] && ( + + Click to insert field + + )} + + {localFields.map((field) => + match(field.type) + .with(FieldType.SIGNATURE, () => ( + + )) + .with(FieldType.INITIALS, () => ( + + )) + .with(FieldType.NAME, () => ( + + )) + .with(FieldType.DATE, () => ( + + )) + .with(FieldType.EMAIL, () => ( + + )) + .with(FieldType.TEXT, () => { + const parsedFieldMeta = field.fieldMeta + ? ZTextFieldMeta.parse(field.fieldMeta) + : null; + + return ( + + ); + }) + .with(FieldType.NUMBER, () => { + const parsedFieldMeta = field.fieldMeta + ? ZNumberFieldMeta.parse(field.fieldMeta) + : null; + + return ( + + ); + }) + .with(FieldType.DROPDOWN, () => { + const parsedFieldMeta = field.fieldMeta + ? ZDropdownFieldMeta.parse(field.fieldMeta) + : null; + + return ( + + ); + }) + .with(FieldType.RADIO, () => { + const parsedFieldMeta = field.fieldMeta + ? ZRadioFieldMeta.parse(field.fieldMeta) + : null; + + return ( + + ); + }) + .with(FieldType.CHECKBOX, () => { + const parsedFieldMeta = field.fieldMeta + ? ZCheckboxFieldMeta.parse(field.fieldMeta) + : null; + + return ( + + ); + }) + .otherwise(() => null), + )} + + +
+
+
+ + + setFullName(e.target.value.trimStart())} + /> +
+ +
+ + + + + { + setSignature(value); + }} + /> + + +
+
+
+
+ + + + +
+ + + +
+
+ + ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx new file mode 100644 index 000000000..e4dafebd5 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx @@ -0,0 +1,190 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans } from '@lingui/macro'; +import { RecipientRole } from '@prisma/client'; +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 { 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 { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; + +import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog'; + +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; + +export type DocumentSigningAuth2FAProps = { + actionTarget?: 'FIELD' | 'DOCUMENT'; + actionVerb?: string; + open: boolean; + onOpenChange: (value: boolean) => void; + onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise | void; +}; + +const Z2FAAuthFormSchema = z.object({ + token: z + .string() + .min(4, { message: 'Token must at least 4 characters long' }) + .max(10, { message: 'Token must be at most 10 characters long' }), +}); + +type T2FAAuthFormSchema = z.infer; + +export const DocumentSigningAuth2FA = ({ + actionTarget = 'FIELD', + actionVerb = 'sign', + onReauthFormSubmit, + open, + onOpenChange, +}: DocumentSigningAuth2FAProps) => { + const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = + useRequiredDocumentSigningAuthContext(); + + const form = useForm({ + resolver: zodResolver(Z2FAAuthFormSchema), + defaultValues: { + token: '', + }, + }); + + const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false); + const [formErrorCode, setFormErrorCode] = useState(null); + + const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => { + try { + setIsCurrentlyAuthenticating(true); + + await onReauthFormSubmit({ + type: DocumentAuth.TWO_FACTOR_AUTH, + token, + }); + + setIsCurrentlyAuthenticating(false); + + onOpenChange(false); + } catch (err) { + setIsCurrentlyAuthenticating(false); + + const error = AppError.parseError(err); + setFormErrorCode(error.code); + + // Todo: Alert. + } + }; + + useEffect(() => { + form.reset({ + token: '', + }); + + setIs2FASetupSuccessful(false); + setFormErrorCode(null); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + if (!user?.twoFactorEnabled && !is2FASetupSuccessful) { + return ( +
+ + +

+ {recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? ( + You need to setup 2FA to mark this document as viewed. + ) : ( + // Todo: Translate + `You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.` + )} +

+ + {user?.identityProvider === 'DOCUMENSO' && ( +

+ + By enabling 2FA, you will be required to enter a code from your authenticator app + every time you sign in. + +

+ )} +
+
+ + + + + setIs2FASetupSuccessful(true)} /> + +
+ ); + } + + return ( +
+ +
+
+ ( + + 2FA token + + + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + + + + + + )} + /> + + {formErrorCode && ( + + + Unauthorized + + + + We were unable to verify your details. Please try again or contact support + + + + )} + + + + + + +
+
+
+ + ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx new file mode 100644 index 000000000..806a05ed4 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react'; + +import { Trans } from '@lingui/macro'; +import { RecipientRole } from '@prisma/client'; +import { useNavigate } from 'react-router'; + +import { authClient } from '@documenso/auth/client'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { DialogFooter } from '@documenso/ui/primitives/dialog'; + +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; + +export type DocumentSigningAuthAccountProps = { + actionTarget?: 'FIELD' | 'DOCUMENT'; + actionVerb?: string; + onOpenChange: (value: boolean) => void; +}; + +export const DocumentSigningAuthAccount = ({ + actionTarget = 'FIELD', + actionVerb = 'sign', + onOpenChange, +}: DocumentSigningAuthAccountProps) => { + const { recipient } = useRequiredDocumentSigningAuthContext(); + + const navigate = useNavigate(); + + const [isSigningOut, setIsSigningOut] = useState(false); + + const handleChangeAccount = async (email: string) => { + try { + setIsSigningOut(true); + + // Todo + await authClient.signOut(); + // { + // // redirect: false, + // // Todo: Redirect to signin like below + // } + + navigate(`/signin#email=${email}`); + } catch { + setIsSigningOut(false); + + // Todo: Alert. + } + }; + + return ( +
+ + + {actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? ( + + + To mark this document as viewed, you need to be logged in as{' '} + {recipient.email} + + + ) : ( + + {/* Todo: Translate */} + To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged + in as {recipient.email} + + )} + + + + + + + + +
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx new file mode 100644 index 000000000..677a88a86 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx @@ -0,0 +1,91 @@ +import { Trans } from '@lingui/macro'; +import type { FieldType } from '@prisma/client'; +import { P, match } from 'ts-pattern'; + +import { + DocumentAuth, + type TRecipientActionAuth, + type TRecipientActionAuthTypes, +} from '@documenso/lib/types/document-auth'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; + +import { DocumentSigningAuth2FA } from './document-signing-auth-2fa'; +import { DocumentSigningAuthAccount } from './document-signing-auth-account'; +import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey'; +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; + +export type DocumentSigningAuthDialogProps = { + title?: string; + documentAuthType: TRecipientActionAuthTypes; + description?: string; + actionTarget: FieldType | 'DOCUMENT'; + open: boolean; + onOpenChange: (value: boolean) => void; + + /** + * The callback to run when the reauth form is filled out. + */ + onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise | void; +}; + +export const DocumentSigningAuthDialog = ({ + title, + description, + documentAuthType, + open, + onOpenChange, + onReauthFormSubmit, +}: DocumentSigningAuthDialogProps) => { + const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext(); + + const handleOnOpenChange = (value: boolean) => { + if (isCurrentlyAuthenticating) { + return; + } + + onOpenChange(value); + }; + + return ( + + + + {title || Sign field} + + + {description || Reauthentication is required to sign this field} + + + + {match({ documentAuthType, user }) + .with( + { documentAuthType: DocumentAuth.ACCOUNT }, + { user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in. + () => , + ) + .with({ documentAuthType: DocumentAuth.PASSKEY }, () => ( + + )) + .with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => ( + + )) + .with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null) + .exhaustive()} + + + ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx new file mode 100644 index 000000000..09b97e99a --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { useNavigate } from 'react-router'; + +import { authClient } from '@documenso/auth/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DocumentSigningAuthPageViewProps = { + email: string; + emailHasAccount?: boolean; +}; + +export const DocumentSigningAuthPageView = ({ + email, + emailHasAccount, +}: DocumentSigningAuthPageViewProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const navigate = useNavigate(); + + const [isSigningOut, setIsSigningOut] = useState(false); + + const handleChangeAccount = async (email: string) => { + try { + setIsSigningOut(true); + + // Todo: Redirect false + await authClient.signOut(); + + navigate(emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`); + } catch { + toast({ + title: _(msg`Something went wrong`), + description: _(msg`We were unable to log you out at this time.`), + duration: 10000, + variant: 'destructive', + }); + } + + setIsSigningOut(false); + }; + + return ( +
+
+

+ Authentication required +

+ +

+ + You need to be logged in as {email} to view this page. + +

+ + +
+
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx new file mode 100644 index 000000000..bafe8edff --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx @@ -0,0 +1,264 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { RecipientRole } from '@prisma/client'; +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 { 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 { CreatePasskeyDialog } from '~/components/dialogs/create-passkey-dialog'; + +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; + +export type DocumentSigningAuthPasskeyProps = { + actionTarget?: 'FIELD' | 'DOCUMENT'; + actionVerb?: string; + open: boolean; + onOpenChange: (value: boolean) => void; + onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise | void; +}; + +const ZPasskeyAuthFormSchema = z.object({ + passkeyId: z.string(), +}); + +type TPasskeyAuthFormSchema = z.infer; + +export const DocumentSigningAuthPasskey = ({ + actionTarget = 'FIELD', + actionVerb = 'sign', + onReauthFormSubmit, + open, + onOpenChange, +}: DocumentSigningAuthPasskeyProps) => { + const { _ } = useLingui(); + + const { + recipient, + passkeyData, + preferredPasskeyId, + setPreferredPasskeyId, + isCurrentlyAuthenticating, + setIsCurrentlyAuthenticating, + refetchPasskeys, + } = useRequiredDocumentSigningAuthContext(); + + const form = useForm({ + resolver: zodResolver(ZPasskeyAuthFormSchema), + defaultValues: { + passkeyId: preferredPasskeyId || '', + }, + }); + + const { mutateAsync: createPasskeyAuthenticationOptions } = + trpc.auth.createPasskeyAuthenticationOptions.useMutation(); + + const [formErrorCode, setFormErrorCode] = useState(null); + + const onFormSubmit = async ({ passkeyId }: TPasskeyAuthFormSchema) => { + try { + setPreferredPasskeyId(passkeyId); + setIsCurrentlyAuthenticating(true); + + const { options, tokenReference } = await createPasskeyAuthenticationOptions({ + preferredPasskeyId: passkeyId, + }); + + 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({ + passkeyId: preferredPasskeyId || '', + }); + + setFormErrorCode(null); + }, [open, form, preferredPasskeyId]); + + if (!browserSupportsWebAuthn()) { + return ( +
+ + + {/* Todo: Translate */} + Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '} + this {actionTarget.toLowerCase()}. + + + + + + +
+ ); + } + + if (passkeyData.isInitialLoading || (passkeyData.isError && passkeyData.passkeys.length === 0)) { + return ( +
+ +
+ ); + } + + if (passkeyData.isError) { + return ( +
+ + + Something went wrong while loading your passkeys. + + + + + + + + +
+ ); + } + + if (passkeyData.passkeys.length === 0) { + return ( +
+ + + {/* Todo: Translate */} + {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 ( +
+ +
+
+ ( + + Passkey + + + + + + + + )} + /> + + {formErrorCode && ( + + Unauthorized + + We were unable to verify your details. Please try again or contact support + + + )} + + + + + + +
+
+
+ + ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx new file mode 100644 index 000000000..ba16fca90 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx @@ -0,0 +1,222 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; + +import { type Document, FieldType, type Passkey, type Recipient, type User } from '@prisma/client'; +import { match } from 'ts-pattern'; + +import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth'; +import type { + TDocumentAuthOptions, + TRecipientAccessAuthTypes, + TRecipientActionAuthTypes, + TRecipientAuthOptions, +} from '@documenso/lib/types/document-auth'; +import { DocumentAuth } from '@documenso/lib/types/document-auth'; +import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { trpc } from '@documenso/trpc/react'; + +import type { DocumentSigningAuthDialogProps } from './document-signing-auth-dialog'; +import { DocumentSigningAuthDialog } from './document-signing-auth-dialog'; + +type PasskeyData = { + passkeys: Omit[]; + isInitialLoading: boolean; + isRefetching: boolean; + isError: boolean; +}; + +export type DocumentSigningAuthContextValue = { + executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise; + documentAuthOptions: Document['authOptions']; + documentAuthOption: TDocumentAuthOptions; + setDocumentAuthOptions: (_value: Document['authOptions']) => void; + recipient: Recipient; + recipientAuthOption: TRecipientAuthOptions; + setRecipient: (_value: Recipient) => void; + 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; + refetchPasskeys: () => Promise; +}; + +const DocumentSigningAuthContext = createContext(null); + +export const useDocumentSigningAuthContext = () => { + return useContext(DocumentSigningAuthContext); +}; + +export const useRequiredDocumentSigningAuthContext = () => { + const context = useDocumentSigningAuthContext(); + + if (!context) { + throw new Error('Document signing auth context is required'); + } + + return context; +}; + +export interface DocumentSigningAuthProviderProps { + documentAuthOptions: Document['authOptions']; + recipient: Recipient; + user?: User | null; + children: React.ReactNode; +} + +export const DocumentSigningAuthProvider = ({ + documentAuthOptions: initialDocumentAuthOptions, + recipient: initialRecipient, + user, + children, +}: DocumentSigningAuthProviderProps) => { + const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions); + const [recipient, setRecipient] = useState(initialRecipient); + + const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false); + const [preferredPasskeyId, setPreferredPasskeyId] = useState(null); + + const { + documentAuthOption, + recipientAuthOption, + derivedRecipientAccessAuth, + derivedRecipientActionAuth, + } = useMemo( + () => + extractDocumentAuthMethods({ + documentAuth: documentAuthOptions, + recipientAuth: recipient.authOptions, + }), + [documentAuthOptions, recipient], + ); + + const passkeyQuery = trpc.auth.findPasskeys.useQuery( + { + perPage: MAXIMUM_PASSKEYS, + }, + { + placeholderData: (previousData) => previousData, + enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY, + }, + ); + + const passkeyData: PasskeyData = { + passkeys: passkeyQuery.data?.data || [], + isInitialLoading: passkeyQuery.isInitialLoading, + isRefetching: passkeyQuery.isRefetching, + isError: passkeyQuery.isError, + }; + + const [documentAuthDialogPayload, setDocumentAuthDialogPayload] = + useState(null); + + /** + * The pre calculated auth payload if the current user is authenticated correctly + * for the `derivedRecipientActionAuth`. + * + * Will be `null` if the user still requires authentication, or if they don't need + * authentication. + */ + const preCalculatedActionAuthOptions = match(derivedRecipientActionAuth) + .with(DocumentAuth.ACCOUNT, () => { + if (recipient.email !== user?.email) { + return null; + } + + return { + type: DocumentAuth.ACCOUNT, + }; + }) + .with(DocumentAuth.EXPLICIT_NONE, () => ({ + type: DocumentAuth.EXPLICIT_NONE, + })) + .with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null) + .exhaustive(); + + const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { + // Directly run callback if no auth required. + if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) { + await options.onReauthFormSubmit(); + return; + } + + // Run callback with precalculated auth options if available. + if (preCalculatedActionAuthOptions) { + setDocumentAuthDialogPayload(null); + await options.onReauthFormSubmit(preCalculatedActionAuthOptions); + return; + } + + // Request the required auth from the user. + setDocumentAuthDialogPayload({ + ...options, + }); + }; + + useEffect(() => { + const { passkeys } = passkeyData; + + if (!preferredPasskeyId && passkeys.length > 0) { + setPreferredPasskeyId(passkeys[0].id); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [passkeyData.passkeys]); + + // Assume that a user must be logged in for any auth requirements. + const isAuthRedirectRequired = Boolean( + derivedRecipientActionAuth && + derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE && + user?.email !== recipient.email, + ); + + const refetchPasskeys = async () => { + await passkeyQuery.refetch(); + }; + + return ( + + {children} + + {documentAuthDialogPayload && derivedRecipientActionAuth && ( + setDocumentAuthDialogPayload(null)} + onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit} + actionTarget={documentAuthDialogPayload.actionTarget} + documentAuthType={derivedRecipientActionAuth} + /> + )} + + ); +}; + +type ExecuteActionAuthProcedureOptions = Omit< + DocumentSigningAuthDialogProps, + 'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' +>; + +DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider'; diff --git a/apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx b/apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx new file mode 100644 index 000000000..8ef04e408 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx @@ -0,0 +1,232 @@ +import { useState, useTransition } from 'react'; + +import { Plural, Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Field, Recipient } from '@prisma/client'; +import { FieldType } from '@prisma/client'; +import { useForm } from 'react-hook-form'; +import { P, match } from 'ts-pattern'; + +import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once'; +import { DocumentAuth } from '@documenso/lib/types/document-auth'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types'; +import { Form } from '@documenso/ui/primitives/form/form'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure'; + +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; +import { useRequiredDocumentSigningContext } from './document-signing-provider'; + +const AUTO_SIGNABLE_FIELD_TYPES: string[] = [ + FieldType.NAME, + FieldType.INITIALS, + FieldType.EMAIL, + FieldType.DATE, +]; + +// The action auth types that are not allowed to be auto signed +// +// Reasoning: If the action auth is a passkey or 2FA, it's likely that the owner of the document +// intends on having the user manually sign due to the additional security measures employed for +// other field types. +const NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES: string[] = [ + DocumentAuth.PASSKEY, + DocumentAuth.TWO_FACTOR_AUTH, +]; + +// The threshold for the number of fields that could be autosigned before displaying the dialog +// +// Reasoning: If there aren't that many fields, it's likely going to be easier to manually sign each one +// while for larger documents with many fields it will be beneficial to sign away the boilerplate fields. +const AUTO_SIGN_THRESHOLD = 5; + +export type DocumentSigningAutoSignProps = { + recipient: Pick; + fields: Field[]; +}; + +export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAutoSignProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const { email, fullName } = useRequiredDocumentSigningContext(); + const { derivedRecipientActionAuth } = useRequiredDocumentSigningAuthContext(); + + const [open, setOpen] = useState(false); + const [isPending, startTransition] = useTransition(); + + const form = useForm(); + + const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation(); + + const autoSignableFields = fields.filter((field) => { + if (field.inserted) { + return false; + } + + if (!AUTO_SIGNABLE_FIELD_TYPES.includes(field.type)) { + return false; + } + + if (field.type === FieldType.NAME && !fullName) { + return false; + } + + if (field.type === FieldType.INITIALS && !fullName) { + return false; + } + + if (field.type === FieldType.EMAIL && !email) { + return false; + } + + return true; + }); + + const actionAuthAllowsAutoSign = !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes( + derivedRecipientActionAuth ?? '', + ); + + const onSubmit = async () => { + const results = await Promise.allSettled( + autoSignableFields.map(async (field) => { + const value = match(field.type) + .with(FieldType.NAME, () => fullName) + .with(FieldType.INITIALS, () => extractInitials(fullName)) + .with(FieldType.EMAIL, () => email) + .with(FieldType.DATE, () => new Date().toISOString()) + .otherwise(() => ''); + + const authOptions = match(derivedRecipientActionAuth) + .with(DocumentAuth.ACCOUNT, () => ({ + type: DocumentAuth.ACCOUNT, + })) + .with(DocumentAuth.EXPLICIT_NONE, () => ({ + type: DocumentAuth.EXPLICIT_NONE, + })) + .with(null, () => undefined) + .with( + P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH), + // This is a bit dirty, but the sentinel value used here is incredibly short-lived. + () => 'NOT_SUPPORTED' as const, + ) + .exhaustive(); + + if (authOptions === 'NOT_SUPPORTED') { + throw new Error('Action auth is not supported for auto signing'); + } + + if (!value) { + throw new Error('No value to sign'); + } + + return await signFieldWithToken({ + token: recipient.token, + fieldId: field.id, + value, + isBase64: false, + authOptions, + }); + }), + ); + + if (results.some((result) => result.status === 'rejected')) { + toast({ + title: _(msg`Error`), + description: _( + msg`An error occurred while auto-signing the document, some fields may not be signed. Please review and manually sign any remaining fields.`, + ), + duration: 5000, + variant: 'destructive', + }); + } + + startTransition(() => { + // Todo + // router.refresh(); + + setOpen(false); + }); + }; + + unsafe_useEffectOnce(() => { + if (actionAuthAllowsAutoSign && autoSignableFields.length > AUTO_SIGN_THRESHOLD) { + setOpen(true); + } + }); + + return ( + + + + Automatically sign fields + + +
+

+ + When you sign a document, we can automatically fill in and sign the following fields + using information that has already been provided. You can also manually sign or remove + any automatically signed fields afterwards if you desire. + +

+ +
    + {AUTO_SIGNABLE_FIELD_TYPES.map((fieldType) => ( +
  • + {_(FRIENDLY_FIELD_TYPE[fieldType as FieldType])} + + ( + f.type === fieldType).length} + one="1 matching field" + other="# matching fields" + /> + ) + +
  • + ))} +
+
+ + + +
+ + + + + + +
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx new file mode 100644 index 000000000..452bf86de --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx @@ -0,0 +1,303 @@ +import { useEffect, useMemo, useState, useTransition } from 'react'; + +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Recipient } from '@prisma/client'; +import { Loader } from 'lucide-react'; + +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta'; +import { fromCheckboxValue, toCheckboxValue } from '@documenso/lib/universal/field-checkbox'; +import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; +import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants'; +import { Label } from '@documenso/ui/primitives/label'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; +import { DocumentSigningFieldContainer } from './document-signing-field-container'; + +export type DocumentSigningCheckboxFieldProps = { + field: FieldWithSignatureAndFieldMeta; + recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; +}; + +export const DocumentSigningCheckboxField = ({ + field, + recipient, + onSignField, + onUnsignField, +}: DocumentSigningCheckboxFieldProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [isPending, startTransition] = useTransition(); + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); + + const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta); + + const values = parsedFieldMeta.values?.map((item) => ({ + ...item, + value: item.value.length > 0 ? item.value : `empty-value-${item.id}`, + })); + + const [checkedValues, setCheckedValues] = useState( + values + ?.map((item) => + item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '', + ) + .filter(Boolean) || [], + ); + + const isReadOnly = parsedFieldMeta.readOnly; + + const checkboxValidationRule = parsedFieldMeta.validationRule; + const checkboxValidationLength = parsedFieldMeta.validationLength; + const validationSign = checkboxValidationSigns.find( + (sign) => sign.label === checkboxValidationRule, + ); + + const isLengthConditionMet = useMemo(() => { + if (!validationSign) return true; + return ( + (validationSign.value === '>=' && checkedValues.length >= (checkboxValidationLength || 0)) || + (validationSign.value === '=' && checkedValues.length === (checkboxValidationLength || 0)) || + (validationSign.value === '<=' && checkedValues.length <= (checkboxValidationLength || 0)) + ); + }, [checkedValues, validationSign, checkboxValidationLength]); + + const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const { + mutateAsync: removeSignedFieldWithToken, + isPending: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const shouldAutoSignField = + (!field.inserted && checkedValues.length > 0 && isLengthConditionMet) || + (!field.inserted && isReadOnly && isLengthConditionMet); + + const onSign = async (authOptions?: TRecipientActionAuth) => { + try { + const payload: TSignFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + value: toCheckboxValue(checkedValues), + isBase64: true, + authOptions, + }; + + if (onSignField) { + await onSignField(payload); + } else { + await signFieldWithToken(payload); + } + + // Todo + // startTransition(() => router.refresh()); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while signing the document.`), + variant: 'destructive', + }); + } + }; + + const onRemove = async (fieldType?: string) => { + try { + const payload: TRemovedSignedFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + }; + + if (onUnsignField) { + await onUnsignField(payload); + } else { + await removeSignedFieldWithToken(payload); + } + + if (fieldType === 'Checkbox') { + setCheckedValues([]); + } + + // Todo + // startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while removing the signature.`), + variant: 'destructive', + }); + } + }; + + const handleCheckboxChange = (value: string, itemId: number) => { + const updatedValue = value || `empty-value-${itemId}`; + const updatedValues = checkedValues.includes(updatedValue) + ? checkedValues.filter((v) => v !== updatedValue) + : [...checkedValues, updatedValue]; + + setCheckedValues(updatedValues); + }; + + const handleCheckboxOptionClick = async (item: { + id: number; + checked: boolean; + value: string; + }) => { + let updatedValues: string[] = []; + + try { + const isChecked = checkedValues.includes( + item.value.length > 0 ? item.value : `empty-value-${item.id}`, + ); + + if (!isChecked) { + updatedValues = [ + ...checkedValues, + item.value.length > 0 ? item.value : `empty-value-${item.id}`, + ]; + + await removeSignedFieldWithToken({ + token: recipient.token, + fieldId: field.id, + }); + + if (isLengthConditionMet) { + await signFieldWithToken({ + token: recipient.token, + fieldId: field.id, + value: toCheckboxValue(checkedValues), + isBase64: true, + }); + } + } else { + updatedValues = checkedValues.filter( + (v) => v !== item.value && v !== `empty-value-${item.id}`, + ); + + await removeSignedFieldWithToken({ + token: recipient.token, + fieldId: field.id, + }); + } + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while updating the signature.`), + variant: 'destructive', + }); + } finally { + setCheckedValues(updatedValues); + + // Todo + // startTransition(() => router.refresh()); + } + }; + + useEffect(() => { + if (shouldAutoSignField) { + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions), + actionTarget: field.type, + }); + } + }, [checkedValues, isLengthConditionMet, field.inserted]); + + const parsedCheckedValues = useMemo( + () => fromCheckboxValue(field.customText), + [field.customText], + ); + + return ( + + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( + <> + {!isLengthConditionMet && ( + + {validationSign?.label} {checkboxValidationLength} + + )} +
+ {values?.map((item: { id: number; value: string; checked: boolean }, index: number) => { + const itemValue = item.value || `empty-value-${item.id}`; + + return ( +
+ handleCheckboxChange(item.value, item.id)} + /> + +
+ ); + })} +
+ + )} + + {field.inserted && ( +
+ {values?.map((item: { id: number; value: string; checked: boolean }, index: number) => { + const itemValue = item.value || `empty-value-${item.id}`; + + return ( +
+ void handleCheckboxOptionClick(item)} + /> + +
+ ); + })} +
+ )} +
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx new file mode 100644 index 000000000..e0f0eae11 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -0,0 +1,150 @@ +import { useMemo, useState } from 'react'; + +import { Trans } from '@lingui/macro'; +import type { Field } from '@prisma/client'; +import { RecipientRole } from '@prisma/client'; + +import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; + +import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure'; + +export type DocumentSigningCompleteDialogProps = { + isSubmitting: boolean; + documentTitle: string; + fields: Field[]; + fieldsValidated: () => void | Promise; + onSignatureComplete: () => void | Promise; + role: RecipientRole; + disabled?: boolean; +}; + +export const DocumentSigningCompleteDialog = ({ + isSubmitting, + documentTitle, + fields, + fieldsValidated, + onSignatureComplete, + role, + disabled = false, +}: DocumentSigningCompleteDialogProps) => { + const [showDialog, setShowDialog] = useState(false); + + const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]); + + const handleOpenChange = (open: boolean) => { + if (isSubmitting || !isComplete) { + return; + } + + setShowDialog(open); + }; + + return ( + + + + + + + +
+ {role === RecipientRole.VIEWER && Complete Viewing} + {role === RecipientRole.SIGNER && Complete Signing} + {role === RecipientRole.APPROVER && Complete Approval} +
+
+ +
+ {role === RecipientRole.VIEWER && ( + + + + You are about to complete viewing " + + {documentTitle} + + ". + +
Are you sure? +
+
+ )} + {role === RecipientRole.SIGNER && ( + + + + You are about to complete signing " + + {documentTitle} + + ". + +
Are you sure? +
+
+ )} + {role === RecipientRole.APPROVER && ( + + + + You are about to complete approving{' '} + + "{documentTitle}" + + . + +
Are you sure? +
+
+ )} +
+ + + + +
+ + + +
+
+
+
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx new file mode 100644 index 000000000..fb4d92843 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx @@ -0,0 +1,155 @@ +import { useTransition } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Recipient } from '@prisma/client'; +import { Loader } from 'lucide-react'; + +import { + DEFAULT_DOCUMENT_DATE_FORMAT, + convertToLocalSystemFormat, +} from '@documenso/lib/constants/date-formats'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { DocumentSigningFieldContainer } from './document-signing-field-container'; + +export type DocumentSigningDateFieldProps = { + field: FieldWithSignature; + recipient: Recipient; + dateFormat?: string | null; + timezone?: string | null; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; +}; + +export const DocumentSigningDateField = ({ + field, + recipient, + dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT, + timezone = DEFAULT_DOCUMENT_TIME_ZONE, + onSignField, + onUnsignField, +}: DocumentSigningDateFieldProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [isPending, startTransition] = useTransition(); + + const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const { + mutateAsync: removeSignedFieldWithToken, + isPending: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + + const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone); + + const isDifferentTime = field.inserted && localDateString !== field.customText; + + const tooltipText = _( + msg`"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`, + ); + + const onSign = async (authOptions?: TRecipientActionAuth) => { + try { + const payload: TSignFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, + authOptions, + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); + + // Todo + // startTransition(() => router.refresh()); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while signing the document.`), + variant: 'destructive', + }); + } + }; + + const onRemove = async () => { + try { + const payload: TRemovedSignedFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); + + // Todo + // startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while removing the signature.`), + variant: 'destructive', + }); + } + }; + + return ( + + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( +

+ Date +

+ )} + + {field.inserted && ( +

+ {localDateString} +

+ )} +
+ ); +}; diff --git a/apps/remix/app/components/general/signing-disclosure.tsx b/apps/remix/app/components/general/document-signing/document-signing-disclosure.tsx similarity index 79% rename from apps/remix/app/components/general/signing-disclosure.tsx rename to apps/remix/app/components/general/document-signing/document-signing-disclosure.tsx index 264de63e7..e81d00603 100644 --- a/apps/remix/app/components/general/signing-disclosure.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-disclosure.tsx @@ -5,9 +5,12 @@ import { Link } from 'react-router'; import { cn } from '@documenso/ui/lib/utils'; -export type SigningDisclosureProps = HTMLAttributes; +export type DocumentSigningDisclosureProps = HTMLAttributes; -export const SigningDisclosure = ({ className, ...props }: SigningDisclosureProps) => { +export const DocumentSigningDisclosure = ({ + className, + ...props +}: DocumentSigningDisclosureProps) => { return (

@@ -21,7 +24,7 @@ export const SigningDisclosure = ({ className, ...props }: SigningDisclosureProp Read the full{' '} signature disclosure diff --git a/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx new file mode 100644 index 000000000..f839224f7 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx @@ -0,0 +1,215 @@ +import { useEffect, useState, useTransition } from 'react'; + +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Recipient } from '@prisma/client'; +import { Loader } from 'lucide-react'; + +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZDropdownFieldMeta } from '@documenso/lib/types/field-meta'; +import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; +import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; +import { DocumentSigningFieldContainer } from './document-signing-field-container'; + +export type DocumentSigningDropdownFieldProps = { + field: FieldWithSignatureAndFieldMeta; + recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; +}; + +export const DocumentSigningDropdownField = ({ + field, + recipient, + onSignField, + onUnsignField, +}: DocumentSigningDropdownFieldProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [isPending, startTransition] = useTransition(); + + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); + + const parsedFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta); + const isReadOnly = parsedFieldMeta?.readOnly; + const defaultValue = parsedFieldMeta?.defaultValue; + const [localChoice, setLocalChoice] = useState(parsedFieldMeta.defaultValue ?? ''); + + const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const { + mutateAsync: removeSignedFieldWithToken, + isPending: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const shouldAutoSignField = + (!field.inserted && localChoice) || (!field.inserted && isReadOnly && defaultValue); + + const onSign = async (authOptions?: TRecipientActionAuth) => { + try { + if (!localChoice) { + return; + } + + const payload: TSignFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + value: localChoice, + isBase64: true, + authOptions, + }; + + if (onSignField) { + await onSignField(payload); + } else { + await signFieldWithToken(payload); + } + + setLocalChoice(''); + + // Todo + // startTransition(() => router.refresh()); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while signing the document.`), + variant: 'destructive', + }); + } + }; + + const onPreSign = () => { + return true; + }; + + const onRemove = async () => { + try { + const payload: TRemovedSignedFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } else { + await removeSignedFieldWithToken(payload); + } + + setLocalChoice(''); + + // Todo + // startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while removing the signature.`), + variant: 'destructive', + }); + } + }; + + const handleSelectItem = (val: string) => { + setLocalChoice(val); + }; + + useEffect(() => { + if (!field.inserted && localChoice) { + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions), + actionTarget: field.type, + }); + } + }, [localChoice]); + + useEffect(() => { + if (shouldAutoSignField) { + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions), + actionTarget: field.type, + }); + } + }, []); + + return ( +

+ + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( +

+ +

+ )} + + {field.inserted && ( +

+ {field.customText} +

+ )} +
+
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx new file mode 100644 index 000000000..2e5f20bba --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx @@ -0,0 +1,138 @@ +import { useTransition } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Recipient } from '@prisma/client'; +import { Loader } from 'lucide-react'; + +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useRequiredDocumentSigningContext } from './document-signing-provider'; + +export type DocumentSigningEmailFieldProps = { + field: FieldWithSignature; + recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; +}; + +export const DocumentSigningEmailField = ({ + field, + recipient, + onSignField, + onUnsignField, +}: DocumentSigningEmailFieldProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const { email: providedEmail } = useRequiredDocumentSigningContext(); + + const [isPending, startTransition] = useTransition(); + + const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const { + mutateAsync: removeSignedFieldWithToken, + isPending: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + + const onSign = async (authOptions?: TRecipientActionAuth) => { + try { + const value = providedEmail ?? ''; + + const payload: TSignFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + value, + isBase64: false, + authOptions, + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); + + // Todo + // startTransition(() => router.refresh()); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while signing the document.`), + variant: 'destructive', + }); + } + }; + + const onRemove = async () => { + try { + const payload: TRemovedSignedFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); + + // Todo + // startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while removing the signature.`), + variant: 'destructive', + }); + } + }; + + return ( + + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( +

+ Email +

+ )} + + {field.inserted && ( +

+ {field.customText} +

+ )} +
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx b/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx new file mode 100644 index 000000000..801ec2ad0 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx @@ -0,0 +1,187 @@ +import React from 'react'; + +import { Trans } from '@lingui/macro'; +import { FieldType } from '@prisma/client'; +import { X } from 'lucide-react'; + +import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; +import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { FieldRootContainer } from '@documenso/ui/components/field/field'; +import { cn } from '@documenso/ui/lib/utils'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; + +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; + +export type DocumentSigningFieldContainerProps = { + field: FieldWithSignature; + loading?: boolean; + children: React.ReactNode; + + /** + * A function that is called before the field requires to be signed, or reauthed. + * + * Example, you may want to show a dialog prior to signing where they can enter a value. + * + * Once that action is complete, you will need to call `executeActionAuthProcedure` to proceed + * regardless if it requires reauth or not. + * + * If the function returns true, we will proceed with the signing process. Otherwise if + * false is returned we will not proceed. + */ + onPreSign?: () => Promise | boolean; + + /** + * The function required to be executed to insert the field. + * + * The auth values will be passed in if available. + */ + onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise | void; + onRemove?: (fieldType?: string) => Promise | void; + type?: + | 'Date' + | 'Initials' + | 'Email' + | 'Name' + | 'Signature' + | 'Radio' + | 'Dropdown' + | 'Number' + | 'Checkbox'; + tooltipText?: string | null; +}; + +export const DocumentSigningFieldContainer = ({ + field, + loading, + onPreSign, + onSign, + onRemove, + children, + type, + tooltipText, +}: DocumentSigningFieldContainerProps) => { + const { executeActionAuthProcedure, isAuthRedirectRequired } = + useRequiredDocumentSigningAuthContext(); + + const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined; + const readOnlyField = parsedFieldMeta?.readOnly || false; + + const handleInsertField = async () => { + if (field.inserted || !onSign) { + return; + } + + // Bypass reauth for non signature fields. + if (field.type !== FieldType.SIGNATURE) { + const presignResult = await onPreSign?.(); + + if (presignResult === false) { + return; + } + + await onSign(); + return; + } + + if (isAuthRedirectRequired) { + await executeActionAuthProcedure({ + onReauthFormSubmit: () => { + // Do nothing since the user should be redirected. + }, + actionTarget: field.type, + }); + + return; + } + + // Handle any presign requirements, and halt if required. + if (onPreSign) { + const preSignResult = await onPreSign(); + + if (preSignResult === false) { + return; + } + } + + await executeActionAuthProcedure({ + onReauthFormSubmit: onSign, + actionTarget: field.type, + }); + }; + + const onRemoveSignedFieldClick = async () => { + if (!field.inserted) { + return; + } + + await onRemove?.(); + }; + + const onClearCheckBoxValues = async (fieldType?: string) => { + if (!field.inserted) { + return; + } + + await onRemove?.(fieldType); + }; + + return ( +
+ + {!field.inserted && !loading && !readOnlyField && ( + + )} + + {type === 'Date' && field.inserted && !loading && !readOnlyField && ( + + + + + + {tooltipText && {tooltipText}} + + )} + + {type === 'Checkbox' && field.inserted && !loading && !readOnlyField && ( + + )} + + {type !== 'Date' && type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && ( + + )} + + {children} + +
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-form.tsx b/apps/remix/app/components/general/document-signing/document-signing-form.tsx new file mode 100644 index 000000000..3c53e3e36 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -0,0 +1,264 @@ +import { useMemo, useState } from 'react'; + +import { Trans } from '@lingui/macro'; +import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; + +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; +import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; +import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; +import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; +import { trpc } from '@documenso/trpc/react'; +import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; + +import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; +import { useRequiredDocumentSigningContext } from './document-signing-provider'; + +export type DocumentSigningFormProps = { + document: DocumentAndSender; + recipient: Recipient; + fields: Field[]; + redirectUrl?: string | null; + isRecipientsTurn: boolean; +}; + +export const DocumentSigningForm = ({ + document, + recipient, + fields, + redirectUrl, + isRecipientsTurn, +}: DocumentSigningFormProps) => { + const navigate = useNavigate(); + const analytics = useAnalytics(); + + const { user } = useOptionalSession(); + + const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } = + useRequiredDocumentSigningContext(); + + const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); + + const { mutateAsync: completeDocumentWithToken } = + trpc.recipient.completeDocumentWithToken.useMutation(); + + const { handleSubmit, formState } = useForm(); + + // Keep the loading state going if successful since the redirect may take some time. + const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful; + + const fieldsRequiringValidation = useMemo( + () => fields.filter(isFieldUnsignedAndRequired), + [fields], + ); + + const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); + + const uninsertedFields = useMemo(() => { + return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted)); + }, [fields]); + + const fieldsValidated = () => { + setValidateUninsertedFields(true); + validateFieldsInserted(fieldsRequiringValidation); + }; + + const onFormSubmit = async () => { + setValidateUninsertedFields(true); + + const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation); + + if (hasSignatureField && !signatureValid) { + return; + } + + if (!isFieldsValid) { + return; + } + + await completeDocument(); + + // Reauth is currently not required for completing the document. + // await executeActionAuthProcedure({ + // onReauthFormSubmit: completeDocument, + // actionTarget: 'DOCUMENT', + // }); + }; + + const completeDocument = async (authOptions?: TRecipientActionAuth) => { + await completeDocumentWithToken({ + token: recipient.token, + documentId: document.id, + authOptions, + }); + + analytics.capture('App: Recipient has completed signing', { + signerId: recipient.id, + documentId: document.id, + timestamp: new Date().toISOString(), + }); + + redirectUrl ? navigate(redirectUrl) : navigate(`/sign/${recipient.token}/complete`); + }; + + return ( +
+ {validateUninsertedFields && uninsertedFields[0] && ( + + Click to insert field + + )} + +
+
+

+ {recipient.role === RecipientRole.VIEWER && View Document} + {recipient.role === RecipientRole.SIGNER && Sign Document} + {recipient.role === RecipientRole.APPROVER && Approve Document} +

+ + {recipient.role === RecipientRole.VIEWER ? ( + <> +

+ Please mark as viewed to complete +

+ +
+ +
+
+
+ + + +
+
+ + ) : ( + <> +

+ Please review the document before signing. +

+ +
+ +
+
+
+ + + setFullName(e.target.value.trimStart())} + /> +
+ +
+ + + + + { + setSignatureValid(isValid); + }} + onChange={(value) => { + if (signatureValid) { + setSignature(value); + } + }} + allowTypedSignature={document.documentMeta?.typedSignatureEnabled} + /> + + + + {hasSignatureField && !signatureValid && ( +
+ + Signature is too small. Please provide a more complete signature. + +
+ )} +
+
+ +
+ + + +
+
+ + )} +
+
+
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx new file mode 100644 index 000000000..85471b86f --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx @@ -0,0 +1,144 @@ +import { useTransition } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Recipient } from '@prisma/client'; +import { Loader } from 'lucide-react'; + +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useRequiredDocumentSigningContext } from './document-signing-provider'; + +export type DocumentSigningInitialsFieldProps = { + field: FieldWithSignature; + recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; +}; + +export const DocumentSigningInitialsField = ({ + field, + recipient, + onSignField, + onUnsignField, +}: DocumentSigningInitialsFieldProps) => { + const { toast } = useToast(); + const { _ } = useLingui(); + + const { fullName } = useRequiredDocumentSigningContext(); + const initials = extractInitials(fullName); + + const [isPending, startTransition] = useTransition(); + + const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const { + mutateAsync: removeSignedFieldWithToken, + isPending: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + + const onSign = async (authOptions?: TRecipientActionAuth) => { + try { + const value = initials ?? ''; + + const payload: TSignFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + value, + isBase64: false, + authOptions, + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); + + // Tod + // startTransition(() => router.refresh()); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while signing the document.`), + variant: 'destructive', + }); + } + }; + + const onRemove = async () => { + try { + const payload: TRemovedSignedFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); + + // startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while removing the field.`), + variant: 'destructive', + }); + } + }; + + return ( + + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( +

+ Initials +

+ )} + + {field.inserted && ( +

+ {field.customText} +

+ )} +
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx new file mode 100644 index 000000000..bc35c3234 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx @@ -0,0 +1,231 @@ +import { useState, useTransition } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { type Recipient } from '@prisma/client'; +import { Loader } from 'lucide-react'; + +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { Button } from '@documenso/ui/primitives/button'; +import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; +import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useRequiredDocumentSigningContext } from './document-signing-provider'; + +export type DocumentSigningNameFieldProps = { + field: FieldWithSignature; + recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; +}; + +export const DocumentSigningNameField = ({ + field, + recipient, + onSignField, + onUnsignField, +}: DocumentSigningNameFieldProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const { fullName: providedFullName, setFullName: setProvidedFullName } = + useRequiredDocumentSigningContext(); + + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); + + const [isPending, startTransition] = useTransition(); + + const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const { + mutateAsync: removeSignedFieldWithToken, + isPending: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + + const [showFullNameModal, setShowFullNameModal] = useState(false); + const [localFullName, setLocalFullName] = useState(''); + + const onPreSign = () => { + if (!providedFullName) { + setShowFullNameModal(true); + return false; + } + + return true; + }; + + /** + * When the user clicks the sign button in the dialog where they enter their full name. + */ + const onDialogSignClick = () => { + setShowFullNameModal(false); + setProvidedFullName(localFullName); + + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localFullName), + actionTarget: field.type, + }); + }; + + const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => { + try { + const value = name || providedFullName; + + if (!value) { + setShowFullNameModal(true); + return; + } + + const payload: TSignFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + value, + isBase64: false, + authOptions, + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); + + // startTransition(() => router.refresh()); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while signing the document.`), + variant: 'destructive', + }); + } + }; + + const onRemove = async () => { + try { + const payload: TRemovedSignedFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); + + // startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while removing the signature.`), + variant: 'destructive', + }); + } + }; + + return ( + + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( +

+ Name +

+ )} + + {field.inserted && ( +

+ {field.customText} +

+ )} + + + + + + Sign as +
+ {recipient.name}
({recipient.email})
+
+
+
+ +
+ + + setLocalFullName(e.target.value.trimStart())} + /> +
+ + +
+ + + +
+
+
+
+
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx new file mode 100644 index 000000000..421f4253f --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx @@ -0,0 +1,347 @@ +import { useEffect, useState, useTransition } from 'react'; + +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Recipient } from '@prisma/client'; +import { Hash, Loader } from 'lucide-react'; + +import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number'; +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZNumberFieldMeta } from '@documenso/lib/types/field-meta'; +import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; +import { DocumentSigningFieldContainer } from './document-signing-field-container'; + +type ValidationErrors = { + isNumber: string[]; + required: string[]; + minValue: string[]; + maxValue: string[]; + numberFormat: string[]; +}; + +export type DocumentSigningNumberFieldProps = { + field: FieldWithSignature; + recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; +}; + +export const DocumentSigningNumberField = ({ + field, + recipient, + onSignField, + onUnsignField, +}: DocumentSigningNumberFieldProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [isPending, startTransition] = useTransition(); + const [showRadioModal, setShowRadioModal] = useState(false); + + const parsedFieldMeta = field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null; + const isReadOnly = parsedFieldMeta?.readOnly; + const defaultValue = parsedFieldMeta?.value; + const [localNumber, setLocalNumber] = useState( + parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0', + ); + + const initialErrors: ValidationErrors = { + isNumber: [], + required: [], + minValue: [], + maxValue: [], + numberFormat: [], + }; + + const [errors, setErrors] = useState(initialErrors); + + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); + + const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const { + mutateAsync: removeSignedFieldWithToken, + isPending: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + + const handleNumberChange = (e: React.ChangeEvent) => { + const text = e.target.value; + setLocalNumber(text); + + if (parsedFieldMeta) { + const validationErrors = validateNumberField(text, parsedFieldMeta, true); + setErrors({ + isNumber: validationErrors.filter((error) => error.includes('valid number')), + required: validationErrors.filter((error) => error.includes('required')), + minValue: validationErrors.filter((error) => error.includes('minimum value')), + maxValue: validationErrors.filter((error) => error.includes('maximum value')), + numberFormat: validationErrors.filter((error) => error.includes('number format')), + }); + } else { + const validationErrors = validateNumberField(text); + setErrors((prevErrors) => ({ + ...prevErrors, + isNumber: validationErrors.filter((error) => error.includes('valid number')), + })); + } + }; + + const onDialogSignClick = () => { + setShowRadioModal(false); + + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions), + actionTarget: field.type, + }); + }; + + const onSign = async (authOptions?: TRecipientActionAuth) => { + try { + if (!localNumber || Object.values(errors).some((error) => error.length > 0)) { + return; + } + + const payload: TSignFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + value: localNumber, + isBase64: true, + authOptions, + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); + + setLocalNumber(''); + + // Todo + // startTransition(() => router.refresh()); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while signing the document.`), + variant: 'destructive', + }); + } + }; + + const onPreSign = () => { + setShowRadioModal(true); + + if (localNumber && parsedFieldMeta) { + const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true); + setErrors({ + isNumber: validationErrors.filter((error) => error.includes('valid number')), + required: validationErrors.filter((error) => error.includes('required')), + minValue: validationErrors.filter((error) => error.includes('minimum value')), + maxValue: validationErrors.filter((error) => error.includes('maximum value')), + numberFormat: validationErrors.filter((error) => error.includes('number format')), + }); + } + + return false; + }; + + const onRemove = async () => { + try { + const payload: TRemovedSignedFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); + + setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta?.value) : ''); + + // Todo + // startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while removing the signature.`), + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!showRadioModal) { + setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0'); + setErrors(initialErrors); + } + }, [showRadioModal]); + + useEffect(() => { + if ( + (!field.inserted && defaultValue && localNumber) || + (!field.inserted && isReadOnly && defaultValue) + ) { + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions), + actionTarget: field.type, + }); + } + }, []); + + let fieldDisplayName = 'Number'; + + if (parsedFieldMeta?.label) { + fieldDisplayName = + parsedFieldMeta.label.length > 10 + ? parsedFieldMeta.label.substring(0, 10) + '...' + : parsedFieldMeta.label; + } + + const userInputHasErrors = Object.values(errors).some((error) => error.length > 0); + + return ( + + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( +

+ + {' '} + {fieldDisplayName} + +

+ )} + + {field.inserted && ( +

+ {field.customText} +

+ )} + + + + + {parsedFieldMeta?.label ? parsedFieldMeta?.label : Number} + + +
+ +
+ + {userInputHasErrors && ( +
+ {errors.isNumber?.map((error, index) => ( +

+ {error} +

+ ))} + {errors.required?.map((error, index) => ( +

+ {error} +

+ ))} + {errors.minValue?.map((error, index) => ( +

+ {error} +

+ ))} + {errors.maxValue?.map((error, index) => ( +

+ {error} +

+ ))} + {errors.numberFormat?.map((error, index) => ( +

+ {error} +

+ ))} +
+ )} + + +
+ + + +
+
+
+
+
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx new file mode 100644 index 000000000..1d4807f69 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx @@ -0,0 +1,245 @@ +import { Trans } from '@lingui/macro'; +import type { Field, Recipient } from '@prisma/client'; +import { FieldType, RecipientRole } from '@prisma/client'; +import { match } from 'ts-pattern'; + +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; +import { + ZCheckboxFieldMeta, + ZDropdownFieldMeta, + ZNumberFieldMeta, + ZRadioFieldMeta, + ZTextFieldMeta, +} from '@documenso/lib/types/field-meta'; +import type { CompletedField } from '@documenso/lib/types/fields'; +import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { ElementVisible } from '@documenso/ui/primitives/element-visible'; +import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; + +import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields'; +import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign'; +import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field'; +import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field'; +import { DocumentSigningDropdownField } from '~/components/general/document-signing/document-signing-dropdown-field'; +import { DocumentSigningEmailField } from '~/components/general/document-signing/document-signing-email-field'; +import { DocumentSigningForm } from '~/components/general/document-signing/document-signing-form'; +import { DocumentSigningInitialsField } from '~/components/general/document-signing/document-signing-initials-field'; +import { DocumentSigningNameField } from '~/components/general/document-signing/document-signing-name-field'; +import { DocumentSigningNumberField } from '~/components/general/document-signing/document-signing-number-field'; +import { DocumentSigningRadioField } from '~/components/general/document-signing/document-signing-radio-field'; +import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog'; +import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field'; +import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field'; + +export type SigningPageViewProps = { + document: DocumentAndSender; + recipient: Recipient; + fields: Field[]; + completedFields: CompletedField[]; + isRecipientsTurn: boolean; +}; + +export const DocumentSigningPageView = ({ + document, + recipient, + fields, + completedFields, + isRecipientsTurn, +}: SigningPageViewProps) => { + const { documentData, documentMeta } = document; + + const shouldUseTeamDetails = + document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false; + + let senderName = document.user.name ?? ''; + let senderEmail = `(${document.user.email})`; + + if (shouldUseTeamDetails) { + senderName = document.team?.name ?? ''; + senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : ''; + } + + return ( +
+

+ {document.title} +

+ +
+
+ + {senderName} {senderEmail} + {' '} + + {match(recipient.role) + .with(RecipientRole.VIEWER, () => + document.teamId && !shouldUseTeamDetails ? ( + + on behalf of "{document.team?.name}" has invited you to view this document + + ) : ( + has invited you to view this document + ), + ) + .with(RecipientRole.SIGNER, () => + document.teamId && !shouldUseTeamDetails ? ( + + on behalf of "{document.team?.name}" has invited you to sign this document + + ) : ( + has invited you to sign this document + ), + ) + .with(RecipientRole.APPROVER, () => + document.teamId && !shouldUseTeamDetails ? ( + + on behalf of "{document.team?.name}" has invited you to approve this document + + ) : ( + has invited you to approve this document + ), + ) + .otherwise(() => null)} + +
+ + +
+ +
+ + + + + + +
+ +
+
+ + + + + + + {fields.map((field) => + match(field.type) + .with(FieldType.SIGNATURE, () => ( + + )) + .with(FieldType.INITIALS, () => ( + + )) + .with(FieldType.NAME, () => ( + + )) + .with(FieldType.DATE, () => ( + + )) + .with(FieldType.EMAIL, () => ( + + )) + .with(FieldType.TEXT, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null, + }; + return ( + + ); + }) + .with(FieldType.NUMBER, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null, + }; + return ( + + ); + }) + .with(FieldType.RADIO, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null, + }; + return ( + + ); + }) + .with(FieldType.CHECKBOX, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null, + }; + return ( + + ); + }) + .with(FieldType.DROPDOWN, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null, + }; + return ( + + ); + }) + .otherwise(() => null), + )} + +
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-provider.tsx b/apps/remix/app/components/general/document-signing/document-signing-provider.tsx new file mode 100644 index 000000000..ca231949d --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-provider.tsx @@ -0,0 +1,72 @@ +import { createContext, useContext, useEffect, useState } from 'react'; + +export type DocumentSigningContextValue = { + fullName: string; + setFullName: (_value: string) => void; + email: string; + setEmail: (_value: string) => void; + signature: string | null; + setSignature: (_value: string | null) => void; + signatureValid: boolean; + setSignatureValid: (_valid: boolean) => void; +}; + +const DocumentSigningContext = createContext(null); + +export const useDocumentSigningContext = () => { + return useContext(DocumentSigningContext); +}; + +export const useRequiredDocumentSigningContext = () => { + const context = useDocumentSigningContext(); + + if (!context) { + throw new Error('Signing context is required'); + } + + return context; +}; + +export interface DocumentSigningProviderProps { + fullName?: string | null; + email?: string | null; + signature?: string | null; + children: React.ReactNode; +} + +export const DocumentSigningProvider = ({ + fullName: initialFullName, + email: initialEmail, + signature: initialSignature, + children, +}: DocumentSigningProviderProps) => { + const [fullName, setFullName] = useState(initialFullName || ''); + const [email, setEmail] = useState(initialEmail || ''); + const [signature, setSignature] = useState(initialSignature || null); + const [signatureValid, setSignatureValid] = useState(true); + + useEffect(() => { + if (initialSignature) { + setSignature(initialSignature); + } + }, [initialSignature]); + + return ( + + {children} + + ); +}; + +DocumentSigningProvider.displayName = 'DocumentSigningProvider'; diff --git a/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx new file mode 100644 index 000000000..777fe61a0 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx @@ -0,0 +1,197 @@ +import { useEffect, useState, useTransition } from 'react'; + +import { msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import type { Recipient } from '@prisma/client'; +import { Loader } from 'lucide-react'; + +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta'; +import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; +import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { Label } from '@documenso/ui/primitives/label'; +import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; +import { DocumentSigningFieldContainer } from './document-signing-field-container'; + +export type DocumentSigningRadioFieldProps = { + field: FieldWithSignatureAndFieldMeta; + recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; +}; + +export const DocumentSigningRadioField = ({ + field, + recipient, + onSignField, + onUnsignField, +}: DocumentSigningRadioFieldProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [isPending, startTransition] = useTransition(); + + const parsedFieldMeta = ZRadioFieldMeta.parse(field.fieldMeta); + const values = parsedFieldMeta.values?.map((item) => ({ + ...item, + value: item.value.length > 0 ? item.value : `empty-value-${item.id}`, + })); + const checkedItem = values?.find((item) => item.checked); + const defaultValue = !field.inserted && !!checkedItem ? checkedItem.value : ''; + + const [selectedOption, setSelectedOption] = useState(defaultValue); + + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); + + const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const { + mutateAsync: removeSignedFieldWithToken, + isPending: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const shouldAutoSignField = + (!field.inserted && selectedOption) || + (!field.inserted && defaultValue) || + (!field.inserted && parsedFieldMeta.readOnly && defaultValue); + + const onSign = async (authOptions?: TRecipientActionAuth) => { + try { + if (!selectedOption) { + return; + } + + const payload: TSignFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + value: selectedOption, + isBase64: true, + authOptions, + }; + + if (onSignField) { + await onSignField(payload); + } else { + await signFieldWithToken(payload); + } + + setSelectedOption(''); + + // Todo + // startTransition(() => router.refresh()); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while signing the document.`), + variant: 'destructive', + }); + } + }; + + const onRemove = async () => { + try { + const payload: TRemovedSignedFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + }; + + if (onUnsignField) { + await onUnsignField(payload); + } else { + await removeSignedFieldWithToken(payload); + } + + setSelectedOption(''); + + // Todo + // startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while removing the signature.`), + variant: 'destructive', + }); + } + }; + + const handleSelectItem = (selectedOption: string) => { + setSelectedOption(selectedOption); + }; + + useEffect(() => { + if (shouldAutoSignField) { + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions), + actionTarget: field.type, + }); + } + }, [selectedOption, field]); + + return ( + + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( + handleSelectItem(value)} className="z-10"> + {values?.map((item, index) => ( +
+ + + +
+ ))} +
+ )} + + {field.inserted && ( + + {values?.map((item, index) => ( +
+ + +
+ ))} +
+ )} +
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx new file mode 100644 index 000000000..fedf8f71f --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx @@ -0,0 +1,167 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import type { Document } from '@prisma/client'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; +import { useSearchParams } from 'react-router'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +const ZRejectDocumentFormSchema = z.object({ + reason: z + .string() + .min(5, msg`Please provide a reason`) + .max(500, msg`Reason must be less than 500 characters`), +}); + +type TRejectDocumentFormSchema = z.infer; + +export interface DocumentSigningRejectDialogProps { + document: Pick; + token: string; +} + +export function DocumentSigningRejectDialog({ document, token }: DocumentSigningRejectDialogProps) { + const { toast } = useToast(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + const [isOpen, setIsOpen] = useState(false); + + const { mutateAsync: rejectDocumentWithToken } = + trpc.recipient.rejectDocumentWithToken.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZRejectDocumentFormSchema), + defaultValues: { + reason: '', + }, + }); + + const onRejectDocument = async ({ reason }: TRejectDocumentFormSchema) => { + try { + await rejectDocumentWithToken({ + documentId: document.id, + token, + reason, + }); + + toast({ + title: 'Document rejected', + description: 'The document has been successfully rejected.', + duration: 5000, + }); + + await navigate(`/sign/${token}/rejected`); + + setIsOpen(false); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while rejecting the document. Please try again.', + variant: 'destructive', + duration: 5000, + }); + } + }; + + useEffect(() => { + if (searchParams?.get('reject') === 'true') { + setIsOpen(true); + } + }, []); + + useEffect(() => { + if (!isOpen) { + form.reset(); + } + }, [isOpen]); + + return ( + + + + + + + + + Reject Document + + + + + Are you sure you want to reject this document? This action cannot be undone. + + + + +
+ + ( + + +