Compare commits

..

1 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan 357017314d feat: add digitized signatures 2025-08-19 08:48:04 +00:00
79 changed files with 1076 additions and 1315 deletions
-2
View File
@@ -136,5 +136,3 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# OPTIONAL: The file to save the logger output to. Will disable stdout if provided.
NEXT_PRIVATE_LOGGER_FILE_PATH=
# [[PLAIN SUPPORT]]
NEXT_PRIVATE_PLAIN_API_KEY=
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@@ -96,17 +96,16 @@ export const AdminOrganisationCreateDialog = ({
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
useEffect(() => {
form.reset();
}, [open, form]);
return (
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -1,159 +0,0 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminUserResetTwoFactorDialogProps = {
className?: string;
user: User;
};
export const AdminUserResetTwoFactorDialog = ({
className,
user,
}: AdminUserResetTwoFactorDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const [email, setEmail] = useState('');
const [open, setOpen] = useState(false);
const { mutateAsync: resetTwoFactor, isPending: isResettingTwoFactor } =
trpc.admin.user.resetTwoFactor.useMutation();
const onResetTwoFactor = async () => {
try {
await resetTwoFactor({
userId: user.id,
});
toast({
title: _(msg`2FA Reset`),
description: _(msg`The user's two factor authentication has been reset successfully.`),
duration: 5000,
});
await revalidate();
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(AppErrorCode.NOT_FOUND, () => msg`User not found.`)
.with(
AppErrorCode.UNAUTHORIZED,
() => msg`You are not authorized to reset two factor authentcation for this user.`,
)
.otherwise(
() => msg`An error occurred while resetting two factor authentication for the user.`,
);
toast({
title: _(msg`Error`),
description: _(errorMessage),
variant: 'destructive',
duration: 7500,
});
}
};
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
if (!newOpen) {
setEmail('');
}
};
return (
<div className={className}>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="neutral"
>
<div>
<AlertTitle>Reset Two Factor Authentication</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
Reset the users two factor authentication. This action is irreversible and will
disable two factor authentication for the user.
</Trans>
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="destructive">
<Trans>Reset 2FA</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Reset Two Factor Authentication</Trans>
</DialogTitle>
</DialogHeader>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>
This action is irreversible. Please ensure you have informed the user before
proceeding.
</Trans>
</AlertDescription>
</Alert>
<div>
<DialogDescription>
<Trans>
To confirm, please enter the accounts email address <br />({user.email}).
</Trans>
</DialogDescription>
<Input
className="mt-2"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<DialogFooter>
<Button
variant="destructive"
disabled={email !== user.email}
onClick={onResetTwoFactor}
loading={isResettingTwoFactor}
>
<Trans>Reset 2FA</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Alert>
</div>
);
};
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -73,23 +73,20 @@ export const DocumentDeleteDialog = ({
},
});
useEffect(() => {
if (open) {
setInputValue('');
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
}
}, [open, status]);
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === _(deleteMessage));
};
const handleOpenChange = (value: boolean) => {
if (value) {
setInputValue('');
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
}
if (!isPending) {
onOpenChange(value);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -83,6 +83,15 @@ export const DocumentMoveToFolderDialog = ({
const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation();
useEffect(() => {
if (!open) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId });
}
}, [open, currentFolderId, form]);
const onSubmit = async (data: TMoveDocumentFormSchema) => {
try {
await moveDocumentToFolder({
@@ -136,22 +145,12 @@ export const DocumentMoveToFolderDialog = ({
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId });
}
onOpenChange(value);
};
const filteredFolders = folders?.data.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -80,15 +80,14 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
useEffect(() => {
if (!isCreateFolderOpen) {
form.reset();
}
setIsCreateFolderOpen(value);
};
}, [isCreateFolderOpen, form]);
return (
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={handleOpenChange}>
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button
@@ -1,3 +1,5 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
@@ -90,15 +92,14 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
useEffect(() => {
if (!isOpen) {
form.reset();
}
onOpenChange(value);
};
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@@ -97,13 +97,12 @@ export const FolderMoveDialog = ({
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
useEffect(() => {
if (!isOpen) {
form.reset();
setSearchTerm('');
}
onOpenChange(value);
};
}, [isOpen, form]);
// Filter out the current folder, only show folders of the same type, and filter by search term
const filteredFolders = foldersData?.filter(
@@ -114,7 +113,7 @@ export const FolderMoveDialog = ({
);
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -1,3 +1,5 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
@@ -69,6 +71,15 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
},
});
useEffect(() => {
if (folder) {
form.reset({
name: folder.name,
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
});
}
}, [folder, form]);
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
if (!folder) {
return;
@@ -99,18 +110,8 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
}
};
const handleOpenChange = (value: boolean) => {
if (value && folder) {
form.reset({
name: folder.name,
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
});
}
onOpenChange(value);
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -76,7 +76,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
const [selectedPriceId, setSelectedPriceId] = useState<string>('');
const [open, setOpen] = useState(actionSearchParam === 'add-organisation');
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(ZCreateOrganisationFormSchema),
@@ -91,19 +91,6 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
enabled: IS_BILLING_ENABLED(),
});
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
if (actionSearchParam === 'add-organisation') {
updateSearchParams({ action: null });
}
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
try {
const response = await createOrganisation({
@@ -139,6 +126,17 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
}
};
useEffect(() => {
if (actionSearchParam === 'add-organisation') {
setOpen(true);
updateSearchParams({ action: null });
}
}, [actionSearchParam, open]);
useEffect(() => {
form.reset();
}, [open, form]);
const isIndividualPlan = (priceId: string) => {
return (
plansData?.plans[INTERNAL_CLAIM_ID.INDIVIDUAL]?.monthlyPrice?.id === priceId ||
@@ -147,7 +145,11 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
};
return (
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -312,16 +314,13 @@ const BillingPlanForm = ({
};
},
);
}, [plans, t]);
}, [plans]);
useEffect(() => {
if (value === '' && !canCreateFreeOrganisation && dynamicPlans.length > 0) {
const defaultValue = dynamicPlans[0][billingPeriod]?.id ?? '';
if (defaultValue) {
onChange(defaultValue);
}
if (value === '' && !canCreateFreeOrganisation) {
onChange(dynamicPlans[0][billingPeriod]?.id ?? '');
}
}, [canCreateFreeOrganisation, dynamicPlans, billingPeriod, onChange, value]);
}, [value]);
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
const plan = dynamicPlans.find(
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -93,19 +93,14 @@ export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogPr
}
};
const handleOpenChange = (value: boolean) => {
if (form.formState.isSubmitting) {
return;
}
if (!value) {
useEffect(() => {
if (!open) {
form.reset();
}
setOpen(value);
};
}, [open, form]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive">
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@@ -73,6 +73,13 @@ export const OrganisationEmailCreateDialog = ({
const { mutateAsync: createOrganisationEmail, isPending } =
trpc.enterprise.organisation.email.create.useMutation();
// Reset state when dialog closes
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
const onFormSubmit = async (data: TCreateOrganisationEmailFormSchema) => {
try {
await createOrganisationEmail({
@@ -107,17 +114,8 @@ export const OrganisationEmailCreateDialog = ({
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
}
if (!isPending) {
setOpen(value);
}
};
return (
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<Dialog {...props} open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -36,12 +36,6 @@ export const OrganisationEmailDeleteDialog = ({
const organisation = useCurrentOrganisation();
const handleOpenChange = (value: boolean) => {
if (!isDeleting) {
setOpen(value);
}
};
const { mutateAsync: deleteEmail, isPending: isDeleting } =
trpc.enterprise.organisation.email.delete.useMutation({
onSuccess: () => {
@@ -64,7 +58,7 @@ export const OrganisationEmailDeleteDialog = ({
});
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="secondary">
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@@ -75,6 +75,14 @@ export const OrganisationEmailDomainCreateDialog = ({
const { mutateAsync: createOrganisationEmail } =
trpc.enterprise.organisation.emailDomain.create.useMutation();
// Reset state when dialog closes
useEffect(() => {
if (!open) {
form.reset();
setStep('domain');
}
}, [open, form]);
const onFormSubmit = async ({ domain }: TCreateOrganisationEmailDomainFormSchema) => {
try {
const { records } = await createOrganisationEmail({
@@ -110,18 +118,12 @@ export const OrganisationEmailDomainCreateDialog = ({
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
setStep('domain');
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
return (
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@@ -87,20 +87,23 @@ export const OrganisationEmailUpdateDialog = ({
}
};
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset({
emailName: organisationEmail.emailName,
// replyTo: organisationEmail.replyTo ?? undefined,
});
useEffect(() => {
if (!open) {
return;
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
form.reset({
emailName: organisationEmail.emailName,
// replyTo: organisationEmail.replyTo ?? undefined,
});
}, [open, form]);
return (
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger}
</DialogTrigger>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@@ -117,17 +117,16 @@ export const OrganisationGroupCreateDialog = ({
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
useEffect(() => {
form.reset();
}, [open, form]);
return (
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -193,6 +193,13 @@ export const OrganisationMemberInviteDialog = ({
return 'form';
}, [fullOrganisation]);
useEffect(() => {
if (!open) {
form.reset();
setInvitationType('INDIVIDUAL');
}
}, [open, form]);
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) {
return;
@@ -260,18 +267,12 @@ export const OrganisationMemberInviteDialog = ({
});
};
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
setInvitationType('INDIVIDUAL');
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
return (
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -106,27 +106,32 @@ export const OrganisationMemberUpdateDialog = ({
}
};
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset();
if (
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
) {
setOpen(false);
toast({
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
variant: 'destructive',
});
return;
}
useEffect(() => {
if (!open) {
return;
}
if (!form.formState.isSubmitting) {
setOpen(value);
form.reset();
if (
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
) {
setOpen(false);
toast({
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
variant: 'destructive',
});
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentUserOrganisationRole, organisationMemberRole, form, toast]);
return (
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -120,21 +120,24 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
return passkeyName;
};
const handleDialogOpenChange = (value: boolean) => {
if (!value) {
useEffect(() => {
if (!open) {
const defaultPasskeyName = extractDefaultPasskeyName();
form.reset({
passkeyName: defaultPasskeyName,
});
setFormError(null);
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
}, [open, form]);
return (
<Dialog {...props} open={open} onOpenChange={handleDialogOpenChange}>
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button variant="secondary" loading={isPending}>
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -66,14 +66,14 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
const updateSearchParams = useUpdateSearchParams();
const organisation = useCurrentOrganisation();
const actionSearchParam = searchParams?.get('action');
const shouldOpenDialog = actionSearchParam === 'add-team';
const [open, setOpen] = useState(shouldOpenDialog);
const [open, setOpen] = useState(false);
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.id,
});
const actionSearchParam = searchParams?.get('action');
const form = useForm({
resolver: zodResolver(ZCreateTeamFormSchema),
defaultValues: {
@@ -85,18 +85,6 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
const { mutateAsync: createTeam } = trpc.team.create.useMutation();
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
if (shouldOpenDialog) {
updateSearchParams({ action: null });
}
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => {
try {
await createTeam({
@@ -162,8 +150,23 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
return 'form';
}, [fullOrganisation]);
useEffect(() => {
if (actionSearchParam === 'add-team') {
setOpen(true);
updateSearchParams({ action: null });
}
}, [actionSearchParam, open]);
useEffect(() => {
form.reset();
}, [open, form]);
return (
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -114,17 +114,14 @@ export const TeamDeleteDialog = ({
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
useEffect(() => {
if (!open) {
form.reset();
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
}, [open, form]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive">
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -103,17 +103,18 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
useEffect(() => {
if (!open) {
form.reset();
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
}, [open, form]);
return (
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button variant="outline" loading={isPending} className="bg-background">
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -92,17 +92,18 @@ export const TeamEmailUpdateDialog = ({
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
useEffect(() => {
if (!open) {
form.reset();
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
}, [open, form]);
return (
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="outline" className="bg-background">
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -107,7 +107,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
duration: 5000,
});
handleClose();
setOpen(false);
} catch {
toast({
title: t`An unknown error occurred`,
@@ -117,23 +117,17 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
}
};
const handleClose = () => {
setOpen(false);
form.reset();
setStep('SELECT');
};
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
handleClose();
useEffect(() => {
if (!open) {
form.reset();
setStep('SELECT');
}
};
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={handleOpenChange}
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
// Since it would be annoying to redo the whole process.
>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -106,17 +106,22 @@ export const TeamGroupUpdateDialog = ({
}
};
const handleOpenChange = (value: boolean) => {
if (!form.formState.isSubmitting) {
if (value) {
form.reset();
}
setOpen(value);
useEffect(() => {
if (!open) {
return;
}
};
form.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, team.currentTeamRole, teamGroupRole, form, toast]);
return (
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -119,17 +119,20 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
useEffect(() => {
if (!open) {
form.reset();
setStep('SELECT');
}
// Disable automatic onOpenChange events to prevent dialog from closing if user 'accidentally' clicks the overlay.
// Since it would be annoying to redo the whole process, we handle open state manually
};
}, [open, form]);
return (
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<Dialog
{...props}
open={open}
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
// Since it would be annoying to redo the whole process.
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
<Button variant="secondary" onClick={() => setOpen(true)}>
<Trans>Add members</Trans>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -106,25 +106,30 @@ export const TeamMemberUpdateDialog = ({
}
};
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset();
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
setOpen(false);
toast({
title: _(msg`You cannot modify a team member who has a higher role than you.`),
variant: 'destructive',
});
return;
}
useEffect(() => {
if (!open) {
return;
}
if (!form.formState.isSubmitting) {
setOpen(value);
form.reset();
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
setOpen(false);
toast({
title: _(msg`You cannot modify a team member who has a higher role than you.`),
variant: 'destructive',
});
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentUserTeamRole, memberTeamRole, form, toast]);
return (
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -186,20 +186,16 @@ export const TemplateDirectLinkDialog = ({
const isLoading =
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
const handleOpenChange = (value: boolean) => {
if (!isLoading) {
if (value) {
resetCreateTemplateDirectLink();
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
setSelectedRecipientId(null);
}
useEffect(() => {
resetCreateTemplateDirectLink();
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
setSelectedRecipientId(null);
onOpenChange(value);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<fieldset disabled={isLoading} className="relative">
<AnimateGenericFadeInOut motionKey={currentStep}>
{match({ token, currentStep })
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -85,6 +85,15 @@ export function TemplateMoveToFolderDialog({
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
useEffect(() => {
if (!isOpen) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId ?? null });
}
}, [isOpen, currentFolderId, form]);
const onSubmit = async (data: TMoveTemplateFormSchema) => {
try {
await moveTemplateToFolder({
@@ -128,22 +137,12 @@ export function TemplateMoveToFolderDialog({
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId ?? null });
}
onOpenChange(value);
};
const filteredFolders = folders?.data?.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={isOpen} onOpenChange={handleOpenChange}>
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -203,17 +203,14 @@ export function TemplateUseDialog({
name: 'recipients',
});
const handleOpenChange = (value: boolean) => {
if (form.formState.isSubmitting) return;
if (!value) {
useEffect(() => {
if (!open) {
form.reset();
}
setOpen(value);
};
}, [open, form]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" className="bg-background">
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -95,17 +95,17 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
useEffect(() => {
if (!isOpen) {
form.reset();
}
if (!form.formState.isSubmitting) {
setIsOpen(value);
}
};
}, [isOpen, form]);
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<Dialog
open={isOpen}
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
>
<DialogTrigger asChild={true}>
{children ?? (
<Button className="mr-4" variant="destructive">
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -88,17 +88,14 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
useEffect(() => {
if (!open) {
form.reset();
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
}, [open, form]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{children ?? (
<Button className="mr-4" variant="destructive">
@@ -1,10 +1,12 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
export const EmbedDocumentWaitingForTurn = () => {
const [hasPostedMessage, setHasPostedMessage] = useState(false);
useEffect(() => {
if (window.parent) {
if (window.parent && !hasPostedMessage) {
window.parent.postMessage(
{
action: 'document-waiting-for-turn',
@@ -13,7 +15,13 @@ export const EmbedDocumentWaitingForTurn = () => {
'*',
);
}
}, []);
setHasPostedMessage(true);
}, [hasPostedMessage]);
if (!hasPostedMessage) {
return null;
}
return (
<div className="embed--WaitingForTurn relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -136,19 +136,18 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
setIsOpen(true);
};
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
useEffect(() => {
enable2FAForm.reset();
if (!open) {
enable2FAForm.reset();
if (recoveryCodes && recoveryCodes.length > 0) {
setRecoveryCodes(null);
}
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
setRecoveryCodes(null);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild={true}>
<Button
className="flex-shrink-0"
+14 -11
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { MessageDescriptor } from '@lingui/core';
@@ -116,18 +116,9 @@ export const SignInForm = ({
const { mutateAsync: createPasskeySigninOptions } =
trpc.auth.createPasskeySigninOptions.useMutation();
const emailFromHash = useMemo(() => {
if (typeof window === 'undefined') {
return null;
}
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
return params.get('email');
}, []);
const form = useForm<TSignInFormSchema>({
values: {
email: emailFromHash ?? initialEmail ?? '',
email: initialEmail ?? '',
password: '',
totpCode: '',
backupCode: '',
@@ -296,6 +287,18 @@ export const SignInForm = ({
}
};
useEffect(() => {
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
const email = params.get('email');
if (email) {
form.setValue('email', email);
}
}, [form]);
return (
<Form {...form}>
<form
+14 -11
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { MessageDescriptor } from '@lingui/core';
@@ -84,19 +84,10 @@ export const SignUpForm = ({
const utmSrc = searchParams.get('utm_source') ?? null;
const emailFromHash = useMemo(() => {
if (typeof window === 'undefined') {
return null;
}
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
return params.get('email');
}, []);
const form = useForm<TSignUpFormSchema>({
values: {
name: '',
email: emailFromHash ?? initialEmail ?? '',
email: initialEmail ?? '',
password: '',
signature: '',
},
@@ -171,6 +162,18 @@ export const SignUpForm = ({
}
};
useEffect(() => {
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
const email = params.get('email');
if (email) {
form.setValue('email', email);
}
}, [form]);
return (
<div className={cn('flex justify-center gap-x-12', className)}>
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
@@ -1,138 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
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 { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZSupportTicketSchema = z.object({
subject: z.string().min(3, 'Subject is required'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
type TSupportTicket = z.infer<typeof ZSupportTicketSchema>;
export type SupportTicketFormProps = {
organisationId: string;
teamId?: string | null;
onSuccess?: () => void;
onClose?: () => void;
};
export const SupportTicketForm = ({
organisationId,
teamId,
onSuccess,
onClose,
}: SupportTicketFormProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { mutateAsync: submitSupportTicket, isPending } =
trpc.profile.submitSupportTicket.useMutation();
const form = useForm<TSupportTicket>({
resolver: zodResolver(ZSupportTicketSchema),
defaultValues: {
subject: '',
message: '',
},
});
const isLoading = form.formState.isLoading || isPending;
const onSubmit = async (data: TSupportTicket) => {
const { subject, message } = data;
try {
await submitSupportTicket({
subject,
message,
organisationId,
teamId,
});
toast({
title: t`Support ticket created`,
description: t`Your support request has been submitted. We'll get back to you soon!`,
});
if (onSuccess) {
onSuccess();
}
form.reset();
} catch (err) {
toast({
title: t`Failed to create support ticket`,
description: t`An error occurred. Please try again later.`,
variant: 'destructive',
});
}
};
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={isLoading} className="flex flex-col gap-4">
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Subject</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Message</Trans>
</FormLabel>
<FormControl>
<Textarea rows={5} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-2 flex flex-row gap-2">
<Button type="submit" size="sm" loading={isLoading}>
<Trans>Submit</Trans>
</Button>
{onClose && (
<Button variant="outline" size="sm" type="button" onClick={onClose}>
<Trans>Close</Trans>
</Button>
)}
</div>
</fieldset>
</form>
</Form>
</>
);
};
@@ -9,6 +9,7 @@ import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { P, match } from 'ts-pattern';
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
import { AUTO_SIGNABLE_FIELD_TYPES } from '@documenso/lib/constants/autosign';
import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
@@ -60,6 +61,12 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
const { email, fullName } = useRequiredDocumentSigningContext();
const { derivedRecipientActionAuth } = useRequiredDocumentSigningAuthContext();
const [open, setOpen] = useState(false);
const form = useForm();
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
const autoSignableFields = fields.filter((field) => {
if (field.inserted) {
return false;
@@ -88,14 +95,6 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
(actionAuth) => !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(actionAuth),
);
const [open, setOpen] = useState(() => {
return actionAuthAllowsAutoSign && autoSignableFields.length > AUTO_SIGN_THRESHOLD;
});
const form = useForm();
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
const onSubmit = async () => {
const results = await Promise.allSettled(
autoSignableFields.map(async (field) => {
@@ -153,6 +152,12 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
await revalidate();
};
unsafe_useEffectOnce(() => {
if (actionAuthAllowsAutoSign && autoSignableFields.length > AUTO_SIGN_THRESHOLD) {
setOpen(true);
}
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
@@ -36,6 +36,7 @@ export type DocumentSigningSignatureFieldProps = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
keyboardSignatureEnabled?: boolean;
};
export const DocumentSigningSignatureField = ({
@@ -45,6 +46,7 @@ export const DocumentSigningSignatureField = ({
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
keyboardSignatureEnabled,
}: DocumentSigningSignatureFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@@ -283,6 +285,7 @@ export const DocumentSigningSignatureField = ({
typedSignatureEnabled={typedSignatureEnabled}
uploadSignatureEnabled={uploadSignatureEnabled}
drawSignatureEnabled={drawSignatureEnabled}
keyboardSignatureEnabled={keyboardSignatureEnabled}
/>
<DocumentSigningDisclosure />
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -80,16 +80,20 @@ export const DocumentSigningTextField = ({
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
const shouldAutoSignField = useMemo(
() =>
(!field.inserted && parsedFieldMeta?.text) ||
(!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly),
[field.inserted, parsedFieldMeta?.text, parsedFieldMeta?.readOnly],
);
const shouldAutoSignField =
(!field.inserted && parsedFieldMeta?.text) ||
(!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly);
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
const [localText, setLocalCustomText] = useState(parsedFieldMeta?.text ?? '');
useEffect(() => {
if (!showCustomTextModal) {
setLocalCustomText(parsedFieldMeta?.text ?? '');
setErrors(initialErrors);
}
}, [showCustomTextModal]);
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value;
setLocalCustomText(text);
@@ -212,12 +216,14 @@ export const DocumentSigningTextField = ({
}
};
if (shouldAutoSignField) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
useEffect(() => {
if (shouldAutoSignField) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, []);
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
@@ -312,8 +318,7 @@ export const DocumentSigningTextField = ({
className="flex-1"
onClick={() => {
setShowCustomTextModal(false);
setLocalCustomText(parsedFieldMeta?.text ?? '');
setErrors(initialErrors);
setLocalCustomText('');
}}
>
<Trans>Cancel</Trans>
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -26,13 +26,16 @@ export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string })
setSearchParams(params);
},
[searchParams, setSearchParams],
[searchParams],
);
const currentQuery = searchParams?.get('query') ?? '';
if (currentQuery !== debouncedSearchTerm) {
handleSearch(debouncedSearchTerm);
}
useEffect(() => {
const currentQueryParam = searchParams.get('query') || '';
if (debouncedSearchTerm !== currentQueryParam) {
handleSearch(debouncedSearchTerm);
}
}, [debouncedSearchTerm, searchParams]);
return (
<Input
@@ -321,19 +321,6 @@ export const OrgMenuSwitcher = () => {
<Trans>Language</Trans>
</DropdownMenuItem>
{currentOrganisation && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link
to={{
pathname: `/o/${currentOrganisation.url}/support`,
search: currentTeam ? `?team=${currentTeam.id}` : '',
}}
>
<Trans>Support</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-muted-foreground hover:!text-muted-foreground px-4 py-2"
onSelect={async () => authClient.signOut()}
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -22,34 +22,11 @@ export type VerifyEmailBannerProps = {
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
const shouldShowDialog = () => {
try {
const emailVerificationDialogLastShown = localStorage.getItem(
'emailVerificationDialogLastShown',
);
if (emailVerificationDialogLastShown) {
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
if (Date.now() - lastShownTimestamp < ONE_DAY) {
return false;
}
}
// Update the timestamp when showing the dialog
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
return true;
} catch {
// In case localStorage is not available (SSR, incognito mode, etc.)
return false;
}
};
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(shouldShowDialog);
const [isOpen, setIsOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
@@ -85,6 +62,27 @@ export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
setIsPending(false);
};
useEffect(() => {
// Check localStorage to see if we've recently automatically displayed the dialog
// if it was within the past 24 hours, don't show it again
// otherwise, show it again and update the localStorage timestamp
const emailVerificationDialogLastShown = localStorage.getItem(
'emailVerificationDialogLastShown',
);
if (emailVerificationDialogLastShown) {
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
if (Date.now() - lastShownTimestamp < ONE_DAY) {
return;
}
}
setIsOpen(true);
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
}, []);
return (
<>
<div className="bg-yellow-200 dark:bg-yellow-400">
@@ -27,7 +27,6 @@ import { AdminOrganisationCreateDialog } from '~/components/dialogs/admin-organi
import { AdminUserDeleteDialog } from '~/components/dialogs/admin-user-delete-dialog';
import { AdminUserDisableDialog } from '~/components/dialogs/admin-user-disable-dialog';
import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-dialog';
import { AdminUserResetTwoFactorDialog } from '~/components/dialogs/admin-user-reset-two-factor-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
@@ -220,11 +219,10 @@ const AdminUserPage = ({ user }: { user: User }) => {
/>
</div>
<div className="mt-16 flex flex-col gap-4">
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
<div className="mt-16 flex flex-col items-center gap-4">
{user && <AdminUserDeleteDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
{user && <AdminUserDeleteDialog user={user} />}
</div>
</div>
);
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -29,29 +29,22 @@ export default function TeamsSettingsMembersPage() {
/**
* Handle debouncing the search query.
*/
const handleSearchQueryChange = useCallback(
(newQuery: string) => {
const params = new URLSearchParams(searchParams?.toString());
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
if (newQuery.trim()) {
params.set('query', newQuery);
} else {
params.delete('query');
}
params.set('query', debouncedSearchQuery);
if (params.toString() === searchParams?.toString()) {
return;
}
if (debouncedSearchQuery === '') {
params.delete('query');
}
setSearchParams(params);
},
[searchParams, setSearchParams],
);
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
const currentParamQuery = searchParams?.get('query') ?? '';
if (currentParamQuery !== debouncedSearchQuery) {
handleSearchQueryChange(debouncedSearchQuery);
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useEffect, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { useSearchParams } from 'react-router';
@@ -20,28 +20,21 @@ export default function OrganisationSettingsTeamsPage() {
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
const handleSearchQueryChange = useCallback(
(newQuery: string) => {
const params = new URLSearchParams(searchParams?.toString());
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
if (newQuery.trim()) {
params.set('query', newQuery);
} else {
params.delete('query');
}
params.set('query', debouncedSearchQuery);
setSearchParams(params);
},
[searchParams, setSearchParams],
);
if (debouncedSearchQuery === '') {
params.delete('query');
}
const currentParamQuery = searchParams?.get('query') ?? '';
if (currentParamQuery !== debouncedSearchQuery) {
handleSearchQueryChange(debouncedSearchQuery);
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
@@ -1,125 +0,0 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { BookIcon, HelpCircleIcon, Link2Icon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { Button } from '@documenso/ui/primitives/button';
import { SupportTicketForm } from '~/components/forms/support-ticket-form';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Support');
}
export default function SupportPage() {
const [showForm, setShowForm] = useState(false);
const { user } = useSession();
const organisation = useCurrentOrganisation();
const [searchParams] = useSearchParams();
const teamId = searchParams.get('team');
const subscriptionStatus = organisation.subscription?.status;
const handleSuccess = () => {
setShowForm(false);
};
const handleCloseForm = () => {
setShowForm(false);
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<div className="mb-8">
<h1 className="flex flex-row items-center gap-2 text-3xl font-bold">
<HelpCircleIcon className="text-muted-foreground h-8 w-8" />
<Trans>Support</Trans>
</h1>
<p className="text-muted-foreground mt-2">
<Trans>Your current plan includes the following support channels:</Trans>
</p>
<div className="mt-6 flex flex-col gap-4">
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<BookIcon className="text-muted-foreground h-5 w-5" />
<Link
to="https://docs.documenso.com"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
<Trans>Documentation</Trans>
</Link>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>Read our documentation to get started with Documenso.</Trans>
</p>
</div>
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Link2Icon className="text-muted-foreground h-5 w-5" />
<Link
to="https://documen.so/discord"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
<Trans>Discord</Trans>
</Link>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>
Join our community on{' '}
<Link
to="https://documen.so/discord"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
Discord
</Link>{' '}
for community support and discussion.
</Trans>
</p>
</div>
{organisation && IS_BILLING_ENABLED() && subscriptionStatus && (
<>
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Link2Icon className="text-muted-foreground h-5 w-5" />
<Trans>Contact us</Trans>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>We'll get back to you as soon as possible via email.</Trans>
</p>
<div className="mt-4">
{!showForm ? (
<Button variant="outline" size="sm" onClick={() => setShowForm(true)}>
<Trans>Create a support ticket</Trans>
</Button>
) : (
<SupportTicketForm
organisationId={organisation.id}
teamId={teamId}
onSuccess={handleSuccess}
onClose={handleCloseForm}
/>
)}
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}
@@ -1,3 +1,5 @@
import { useEffect } from 'react';
import { Outlet, useNavigate } from 'react-router';
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
@@ -28,8 +30,13 @@ export default function Layout() {
const currentOrganisation = organisations[0];
const team = currentOrganisation?.teams[0] || null;
useEffect(() => {
if (!isPersonalLayoutMode || !team) {
void navigate('/settings/profile');
}
}, []);
if (!isPersonalLayoutMode || !team) {
void navigate('/settings/profile');
return null;
}
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { FolderType, OrganisationType } from '@prisma/client';
@@ -12,7 +12,10 @@ import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/schema';
import {
type TFindDocumentsInternalResponse,
ZFindDocumentsInternalRequestSchema,
} from '@documenso/trpc/server/document-router/schema';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
@@ -52,6 +55,15 @@ export default function DocumentsPage() {
const [isMovingDocument, setIsMovingDocument] = useState(false);
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
});
const findDocumentSearchParams = useMemo(
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
[searchParams],
@@ -62,19 +74,6 @@ export default function DocumentsPage() {
folderId,
});
const stats = useMemo(
() =>
data?.stats ?? {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
},
[data?.stats],
);
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
@@ -105,6 +104,12 @@ export default function DocumentsPage() {
return path;
};
useEffect(() => {
if (data?.stats) {
setStats(data.stats);
}
}, [data?.stats]);
return (
<DocumentDropZoneWrapper>
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useEffect, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { useLocation, useSearchParams } from 'react-router';
@@ -19,33 +19,26 @@ export default function TeamsSettingsMembersPage() {
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
const handleSearchQueryChange = useCallback(
(newQuery: string) => {
const params = new URLSearchParams(searchParams?.toString());
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
if (newQuery.trim()) {
params.set('query', newQuery);
} else {
params.delete('query');
}
params.set('query', debouncedSearchQuery);
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
if (debouncedSearchQuery === '') {
params.delete('query');
}
setSearchParams(params);
},
[searchParams, setSearchParams],
);
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
const currentParamQuery = searchParams?.get('query') ?? '';
if (currentParamQuery !== debouncedSearchQuery) {
handleSearchQueryChange(debouncedSearchQuery);
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
@@ -130,8 +130,12 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
}
};
useEffect(() => {
setIsPublicProfileVisible(profile.enabled);
}, [profile.enabled]);
return (
<div key={team.id} className="max-w-2xl">
<div className="max-w-2xl">
<SettingsHeader
title={t`Public Profile`}
subtitle={t`You can choose to enable or disable the profile for public view.`}
@@ -38,48 +38,33 @@ export default function VerifyEmailPage({ loaderData }: Route.ComponentProps) {
const [state, setState] = useState<keyof typeof EMAIL_VERIFICATION_STATE | null>(null);
const [isLoading, setIsLoading] = useState(false);
const verifyToken = async () => {
setIsLoading(true);
try {
const response = await authClient.emailPassword.verifyEmail({
token,
});
await refreshSession();
setState(response.state);
} catch (err) {
console.error(err);
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to verify your email at this time.`),
});
await navigate('/verify-email');
}
setIsLoading(false);
};
useEffect(() => {
let ignore = false;
const verify = async () => {
try {
setIsLoading(true);
const response = await authClient.emailPassword.verifyEmail({
token,
});
if (ignore) {
return;
}
await refreshSession();
setState(response.state);
} catch (err) {
if (ignore) {
return;
}
console.error(err);
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to verify your email at this time.`),
});
await navigate('/verify-email');
} finally {
if (!ignore) {
setIsLoading(false);
}
}
};
void verify();
return () => {
ignore = true;
};
void verifyToken();
}, []);
if (isLoading || state === null) {
+4 -50
View File
@@ -3522,15 +3522,6 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@graphql-typed-document-node/core": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
"integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
"license": "MIT",
"peerDependencies": {
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.3.tgz",
@@ -11835,20 +11826,6 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@team-plain/typescript-sdk": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/@team-plain/typescript-sdk/-/typescript-sdk-5.9.0.tgz",
"integrity": "sha512-AHSXyt1kDt74m9YKZBCRCd6cQjB8QjUNr9cehtR2QHzZ/8yXJPzawPJDqOQ3ms5KvwuYrBx2qT3e6C/zrQ5UtA==",
"license": "MIT",
"dependencies": {
"@graphql-typed-document-node/core": "^3.2.0",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"graphql": "^16.6.0",
"lodash.get": "^4.4.2",
"zod": "3.22.4"
}
},
"node_modules/@theguild/remark-mermaid": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@theguild/remark-mermaid/-/remark-mermaid-0.0.5.tgz",
@@ -13258,6 +13235,7 @@
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -13270,23 +13248,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@@ -18810,6 +18771,7 @@
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"dev": true,
"funding": [
{
"type": "github",
@@ -19885,15 +19847,6 @@
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"license": "MIT"
},
"node_modules/graphql": {
"version": "16.11.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz",
"integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==",
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
@@ -22376,6 +22329,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
@@ -30616,6 +30570,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -36628,7 +36583,6 @@
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@team-plain/typescript-sdk": "^5.9.0",
"@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13",
@@ -1,23 +1,23 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
export const useElementBounds = (elementOrSelector: HTMLElement | string, withScroll = false) => {
const [forceRecalc, setForceRecalc] = useState(0);
const [bounds, setBounds] = useState({
top: 0,
left: 0,
height: 0,
width: 0,
});
const bounds = useMemo(() => {
const calculateBounds = useCallback(() => {
const $el =
typeof elementOrSelector === 'string'
? document.querySelector<HTMLElement>(elementOrSelector)
: elementOrSelector;
if (!$el) {
return {
top: 0,
left: 0,
height: 0,
width: 0,
};
throw new Error('Element not found');
}
if (withScroll) {
@@ -32,11 +32,15 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
width,
height,
};
}, [elementOrSelector, withScroll, forceRecalc]);
}, [elementOrSelector, withScroll]);
useEffect(() => {
setBounds(calculateBounds());
}, []);
useEffect(() => {
const onResize = () => {
setForceRecalc((prev) => prev + 1);
setBounds(calculateBounds());
};
window.addEventListener('resize', onResize);
@@ -57,7 +61,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
}
const observer = new ResizeObserver(() => {
setForceRecalc((prev) => prev + 1);
setBounds(calculateBounds());
});
observer.observe($el);
@@ -65,7 +69,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => {
observer.disconnect();
};
}, [elementOrSelector]);
}, []);
return bounds;
};
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import { useMemo } from 'react';
import { RefObject, useEffect, useState } from 'react';
/**
* Calculate the width and height of a text element.
@@ -64,7 +64,13 @@ export function useElementScaleSize(
fontSize: number,
fontFamily: string,
) {
return useMemo(() => {
return calculateTextScaleSize(container, text, `${fontSize}px`, fontFamily);
}, [container, text, fontSize, fontFamily]);
const [scalingFactor, setScalingFactor] = useState(1);
useEffect(() => {
const scaleSize = calculateTextScaleSize(container, text, `${fontSize}px`, fontFamily);
setScalingFactor(scaleSize);
}, [text, container, fontFamily, fontSize]);
return scalingFactor;
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { Field } from '@prisma/client';
@@ -6,20 +6,20 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
export const useFieldPageCoords = (field: Field) => {
const [forceRecalc, setForceRecalc] = useState(0);
const [coords, setCoords] = useState({
x: 0,
y: 0,
height: 0,
width: 0,
});
const coords = useMemo(() => {
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return {
x: 0,
y: 0,
height: 0,
width: 0,
};
return;
}
const { top, left, height, width } = getBoundingClientRect($page);
@@ -31,17 +31,21 @@ export const useFieldPageCoords = (field: Field) => {
const fieldHeight = (Number(field.height) / 100) * height;
const fieldWidth = (Number(field.width) / 100) * width;
return {
setCoords({
x: fieldX,
y: fieldY,
height: fieldHeight,
width: fieldWidth,
};
}, [field.height, field.page, field.positionX, field.positionY, field.width, forceRecalc]);
});
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
useEffect(() => {
calculateCoords();
}, [calculateCoords]);
useEffect(() => {
const onResize = () => {
setForceRecalc((prev) => prev + 1);
calculateCoords();
};
window.addEventListener('resize', onResize);
@@ -49,7 +53,7 @@ export const useFieldPageCoords = (field: Field) => {
return () => {
window.removeEventListener('resize', onResize);
};
}, []);
}, [calculateCoords]);
useEffect(() => {
const $page = document.querySelector<HTMLElement>(
@@ -61,7 +65,7 @@ export const useFieldPageCoords = (field: Field) => {
}
const observer = new ResizeObserver(() => {
setForceRecalc((prev) => prev + 1);
calculateCoords();
});
observer.observe($page);
@@ -69,7 +73,7 @@ export const useFieldPageCoords = (field: Field) => {
return () => {
observer.disconnect();
};
}, [field.page]);
}, [calculateCoords, field.page]);
return coords;
};
@@ -1,17 +1,11 @@
import { useSyncExternalStore } from 'react';
const subscribe = () => {
return () => {};
};
const getSnapshot = () => {
return true;
};
const getServerSnapshot = () => {
return false;
};
import { useEffect, useState } from 'react';
export const useIsMounted = () => {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
return isMounted;
};
+7
View File
@@ -69,4 +69,11 @@ export const DOCUMENT_SIGNATURE_TYPES = {
}),
value: DocumentSignatureType.UPLOAD,
},
[DocumentSignatureType.KEYBOARD]: {
label: msg({
message: `Keyboard`,
context: `Keyboard signatute type`,
}),
value: DocumentSignatureType.KEYBOARD,
},
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;
-1
View File
@@ -33,7 +33,6 @@
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@team-plain/typescript-sdk": "^5.9.0",
"@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13",
-7
View File
@@ -1,7 +0,0 @@
import { PlainClient } from '@team-plain/typescript-sdk';
import { env } from '@documenso/lib/utils/env';
export const plainClient = new PlainClient({
apiKey: env('NEXT_PRIVATE_PLAIN_API_KEY') ?? '',
});
@@ -1,72 +0,0 @@
import { plainClient } from '@documenso/lib/plain/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { getTeamById } from '../team/get-team';
type SubmitSupportTicketOptions = {
subject: string;
message: string;
userId: number;
organisationId: string;
teamId?: number | null;
};
export const submitSupportTicket = async ({
subject,
message,
userId,
organisationId,
teamId,
}: SubmitSupportTicketOptions) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
const team = teamId
? await getTeamById({
userId,
teamId,
})
: null;
const customMessage = `
Organisation: ${organisation.name} (${organisation.id})
Team: ${team ? `${team.name} (${team.id})` : 'No team provided'}
${message}`;
const res = await plainClient.createThread({
title: subject,
customerIdentifier: { emailAddress: user.email },
components: [{ componentText: { text: customMessage } }],
});
if (res.error) {
throw new Error(res.error.message);
}
return res;
};
+13 -1
View File
@@ -18,6 +18,7 @@ export enum DocumentSignatureType {
DRAW = 'draw',
TYPE = 'type',
UPLOAD = 'upload',
KEYBOARD = 'keyboard',
}
export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => {
@@ -83,10 +84,16 @@ export const extractTeamSignatureSettings = (
typedSignatureEnabled: boolean | null;
drawSignatureEnabled: boolean | null;
uploadSignatureEnabled: boolean | null;
keyboardSignatureEnabled?: boolean | null;
} | null,
) => {
if (!settings) {
return [DocumentSignatureType.TYPE, DocumentSignatureType.UPLOAD, DocumentSignatureType.DRAW];
return [
DocumentSignatureType.TYPE,
DocumentSignatureType.UPLOAD,
DocumentSignatureType.DRAW,
DocumentSignatureType.KEYBOARD,
];
}
const signatureTypes: DocumentSignatureType[] = [];
@@ -103,6 +110,10 @@ export const extractTeamSignatureSettings = (
signatureTypes.push(DocumentSignatureType.UPLOAD);
}
if (settings.keyboardSignatureEnabled !== false) {
signatureTypes.push(DocumentSignatureType.KEYBOARD);
}
return signatureTypes;
};
@@ -175,6 +186,7 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
typedSignatureEnabled: null,
uploadSignatureEnabled: null,
drawSignatureEnabled: null,
keyboardSignatureEnabled: null,
brandingEnabled: null,
brandingLogo: null,
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "keyboardSignatureEnabled" BOOLEAN;
+1
View File
@@ -776,6 +776,7 @@ model TeamGlobalSettings {
typedSignatureEnabled Boolean?
uploadSignatureEnabled Boolean?
drawSignatureEnabled Boolean?
keyboardSignatureEnabled Boolean?
emailId String?
email OrganisationEmail? @relation(fields: [emailId], references: [id])
@@ -1,50 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZResetTwoFactorRequestSchema,
ZResetTwoFactorResponseSchema,
} from './reset-two-factor-authentication.types';
export const resetTwoFactorRoute = adminProcedure
.input(ZResetTwoFactorRequestSchema)
.output(ZResetTwoFactorResponseSchema)
.mutation(async ({ input, ctx }) => {
const { userId } = input;
ctx.logger.info({
input: {
userId,
},
});
return await resetTwoFactor({ userId });
});
export type ResetTwoFactorOptions = {
userId: number;
};
export const resetTwoFactor = async ({ userId }: ResetTwoFactorOptions) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'User not found' });
}
await prisma.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: false,
twoFactorBackupCodes: null,
twoFactorSecret: null,
},
});
};
@@ -1,10 +0,0 @@
import { z } from 'zod';
export const ZResetTwoFactorRequestSchema = z.object({
userId: z.number(),
});
export const ZResetTwoFactorResponseSchema = z.void();
export type TResetTwoFactorRequest = z.infer<typeof ZResetTwoFactorRequestSchema>;
export type TResetTwoFactorResponse = z.infer<typeof ZResetTwoFactorResponseSchema>;
@@ -21,7 +21,6 @@ import { deleteSubscriptionClaimRoute } from './delete-subscription-claim';
import { findAdminOrganisationsRoute } from './find-admin-organisations';
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
import { getAdminOrganisationRoute } from './get-admin-organisation';
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import {
ZAdminDeleteDocumentMutationSchema,
ZAdminDeleteUserMutationSchema,
@@ -52,9 +51,6 @@ export const adminRouter = router({
stripe: {
createCustomer: createStripeCustomerRoute,
},
user: {
resetTwoFactor: resetTwoFactorRoute,
},
// Todo: migrate old routes
findDocuments: adminProcedure.input(ZAdminFindDocumentsQuerySchema).query(async ({ input }) => {
@@ -1,10 +1,8 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { SetAvatarImageOptions } from '@documenso/lib/server-only/profile/set-avatar-image';
import { setAvatarImage } from '@documenso/lib/server-only/profile/set-avatar-image';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { submitSupportTicket } from '@documenso/lib/server-only/user/submit-support-ticket';
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
import { adminProcedure, authenticatedProcedure, router } from '../trpc';
@@ -12,7 +10,6 @@ import {
ZFindUserSecurityAuditLogsSchema,
ZRetrieveUserByIdQuerySchema,
ZSetProfileImageMutationSchema,
ZSubmitSupportTicketMutationSchema,
ZUpdateProfileMutationSchema,
} from './schema';
@@ -94,28 +91,4 @@ export const profileRouter = router({
requestMetadata: ctx.metadata,
});
}),
submitSupportTicket: authenticatedProcedure
.input(ZSubmitSupportTicketMutationSchema)
.mutation(async ({ input, ctx }) => {
const { subject, message, organisationId, teamId } = input;
const userId = ctx.user.id;
const parsedTeamId = teamId ? Number(teamId) : null;
if (Number.isNaN(parsedTeamId)) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid team ID provided',
});
}
return await submitSupportTicket({
subject,
message,
userId,
organisationId,
teamId: parsedTeamId,
});
}),
});
@@ -27,12 +27,3 @@ export const ZSetProfileImageMutationSchema = z.object({
});
export type TSetProfileImageMutationSchema = z.infer<typeof ZSetProfileImageMutationSchema>;
export const ZSubmitSupportTicketMutationSchema = z.object({
organisationId: z.string(),
teamId: z.string().min(1).nullish(),
subject: z.string().min(3, 'Subject is required'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
export type TSupportTicketRequest = z.infer<typeof ZSubmitSupportTicketMutationSchema>;
+9 -21
View File
@@ -1,23 +1,11 @@
import { useSyncExternalStore } from 'react';
import { useEffect, useState } from 'react';
const subscribe = () => {
return () => {};
};
const getSnapshot = () => {
return true;
};
const getServerSnapshot = () => {
return false;
};
export const ClientOnly = ({
children,
}: {
children: React.ReactNode;
}): React.ReactElement | null => {
const isClient = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return isClient ? <>{children}</> : null;
export const ClientOnly = async ({ children }: { children: React.ReactNode }) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return mounted ? children : null;
};
@@ -18,6 +18,7 @@ import {
} from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { prop, sortBy } from 'remeda';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
@@ -102,10 +103,6 @@ export const AddFieldsFormPartial = ({
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
const handleMissingSignatureDialogOpenChange = (value: boolean) => {
setIsMissingSignatureDialogVisible(value);
};
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
const { currentStep, totalSteps, previousStep } = useStep();
const canRenderBackButtonAsRemove =
@@ -514,24 +511,18 @@ export const AddFieldsFormPartial = ({
};
}, []);
const defaultSelectedSigner = useMemo(() => {
useEffect(() => {
const recipientsByRoleToDisplay = recipients.filter(
(recipient) =>
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
);
return (
setSelectedSigner(
recipientsByRoleToDisplay.find((r) => r.sendStatus !== SendStatus.SENT) ??
recipientsByRoleToDisplay[0]
recipientsByRoleToDisplay[0],
);
}, [recipients]);
if (selectedSigner && !recipients.find((r) => r.id === selectedSigner.id)) {
setSelectedSigner(defaultSelectedSigner);
} else if (!selectedSigner && defaultSelectedSigner) {
setSelectedSigner(defaultSelectedSigner);
}
const recipientsByRole = useMemo(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
CC: [],
@@ -548,6 +539,29 @@ export const AddFieldsFormPartial = ({
return recipientsByRole;
}, [recipients]);
const recipientsByRoleToDisplay = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][])
.filter(
([role]) =>
role !== RecipientRole.CC &&
role !== RecipientRole.VIEWER &&
role !== RecipientRole.ASSISTANT,
)
.map(
([role, roleRecipients]) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
[
role,
sortBy(
roleRecipients,
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
[prop('id'), 'asc'],
),
] as [RecipientRole, Recipient[]],
);
}, [recipientsByRole]);
const handleAdvancedSettings = () => {
setShowAdvancedSettings((prev) => !prev);
};
@@ -981,7 +995,7 @@ export const AddFieldsFormPartial = ({
<MissingSignatureFieldDialog
isOpen={isMissingSignatureDialogVisible}
onOpenChange={handleMissingSignatureDialogOpenChange}
onOpenChange={(value) => setIsMissingSignatureDialogVisible(value)}
/>
</>
)}
@@ -1,4 +1,4 @@
import { forwardRef, useMemo, useState } from 'react';
import { forwardRef, useEffect, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
@@ -160,32 +160,22 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
const defaultState: FieldMeta = getDefaultState(field.type);
const initialFieldState = useMemo(() => {
if (fieldMeta && typeof fieldMeta === 'object') {
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldMeta);
return {
...defaultState,
...parsedFieldMeta,
};
}
const [fieldState, setFieldState] = useState(() => {
const savedState = localStorage.getItem(localStorageKey);
return savedState ? { ...defaultState, ...JSON.parse(savedState) } : defaultState;
}, [fieldMeta, defaultState, localStorageKey]);
});
const [fieldState, setFieldState] = useState(initialFieldState);
useEffect(() => {
if (fieldMeta && typeof fieldMeta === 'object') {
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldMeta);
if (fieldMeta && typeof fieldMeta === 'object') {
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldMeta);
const expectedState = {
...defaultState,
...parsedFieldMeta,
};
if (JSON.stringify(fieldState) !== JSON.stringify(expectedState)) {
setFieldState(expectedState);
setFieldState({
...defaultState,
...parsedFieldMeta,
});
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fieldMeta]);
const handleFieldChange = (
key: FieldMetaKeys,
@@ -0,0 +1,207 @@
export enum KeyboardLayout {
QWERTY = 'QWERTY',
}
export type CurveType =
| 'linear'
| 'simple-curve'
| 'quadratic-bezier'
| 'cubic-bezier'
| 'catmull-rom';
export enum StrokeStyle {
SOLID = 'solid',
GRADIENT = 'gradient',
}
export interface StrokeConfig {
style: StrokeStyle;
color: string;
gradientStart: string;
gradientEnd: string;
width: number;
}
export interface Point {
x: number;
y: number;
}
export const getKeyboardLayout = (
layout: KeyboardLayout,
includeNumbers: boolean = false,
): Record<string, Point> => {
const qwertyLayout: Record<string, Point> = {
Q: { x: 0, y: includeNumbers ? 1 : 0 },
W: { x: 1, y: includeNumbers ? 1 : 0 },
E: { x: 2, y: includeNumbers ? 1 : 0 },
R: { x: 3, y: includeNumbers ? 1 : 0 },
T: { x: 4, y: includeNumbers ? 1 : 0 },
Y: { x: 5, y: includeNumbers ? 1 : 0 },
U: { x: 6, y: includeNumbers ? 1 : 0 },
I: { x: 7, y: includeNumbers ? 1 : 0 },
O: { x: 8, y: includeNumbers ? 1 : 0 },
P: { x: 9, y: includeNumbers ? 1 : 0 },
A: { x: 0.5, y: includeNumbers ? 2 : 1 },
S: { x: 1.5, y: includeNumbers ? 2 : 1 },
D: { x: 2.5, y: includeNumbers ? 2 : 1 },
F: { x: 3.5, y: includeNumbers ? 2 : 1 },
G: { x: 4.5, y: includeNumbers ? 2 : 1 },
H: { x: 5.5, y: includeNumbers ? 2 : 1 },
J: { x: 6.5, y: includeNumbers ? 2 : 1 },
K: { x: 7.5, y: includeNumbers ? 2 : 1 },
L: { x: 8.5, y: includeNumbers ? 2 : 1 },
Z: { x: 1, y: includeNumbers ? 3 : 2 },
X: { x: 2, y: includeNumbers ? 3 : 2 },
C: { x: 3, y: includeNumbers ? 3 : 2 },
V: { x: 4, y: includeNumbers ? 3 : 2 },
B: { x: 5, y: includeNumbers ? 3 : 2 },
N: { x: 6, y: includeNumbers ? 3 : 2 },
M: { x: 7, y: includeNumbers ? 3 : 2 },
};
if (includeNumbers) {
const numberRow = {
'1': { x: 0, y: 0 },
'2': { x: 1, y: 0 },
'3': { x: 2, y: 0 },
'4': { x: 3, y: 0 },
'5': { x: 4, y: 0 },
'6': { x: 5, y: 0 },
'7': { x: 6, y: 0 },
'8': { x: 7, y: 0 },
'9': { x: 8, y: 0 },
'0': { x: 9, y: 0 },
};
return { ...numberRow, ...qwertyLayout };
}
return qwertyLayout;
};
export const generatePath = (points: Point[], curveType: CurveType): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
switch (curveType) {
case 'linear':
return generateLinearPath(points);
case 'simple-curve':
return generateSimpleCurvePath(points);
case 'quadratic-bezier':
return generateQuadraticBezierPath(points);
case 'cubic-bezier':
return generateCubicBezierPath(points);
case 'catmull-rom':
return generateCatmullRomPath(points);
default:
return generateLinearPath(points);
}
};
const generateLinearPath = (points: Point[]): string => {
if (points.length === 0) return '';
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
path += ` L ${points[i].x} ${points[i].y}`;
}
return path;
};
const generateSimpleCurvePath = (points: Point[]): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const midX = (prev.x + curr.x) / 2;
const midY = (prev.y + curr.y) / 2;
if (i === 1) {
path += ` Q ${prev.x} ${prev.y} ${midX} ${midY}`;
} else {
path += ` T ${midX} ${midY}`;
}
}
const lastPoint = points[points.length - 1];
path += ` T ${lastPoint.x} ${lastPoint.y}`;
return path;
};
const generateQuadraticBezierPath = (points: Point[]): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const controlX = prev.x + (curr.x - prev.x) * 0.5;
const controlY = prev.y - Math.abs(curr.x - prev.x) * 0.3;
path += ` Q ${controlX} ${controlY} ${curr.x} ${curr.y}`;
}
return path;
};
const generateCubicBezierPath = (points: Point[]): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const dx = curr.x - prev.x;
const dy = curr.y - prev.y;
const cp1x = prev.x + dx * 0.25;
const cp1y = prev.y + dy * 0.25 - Math.abs(dx) * 0.2;
const cp2x = prev.x + dx * 0.75;
const cp2y = prev.y + dy * 0.75 - Math.abs(dx) * 0.2;
path += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${curr.x} ${curr.y}`;
}
return path;
};
const generateCatmullRomPath = (points: Point[]): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
if (points.length === 2) return generateLinearPath(points);
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const p0 = i === 1 ? points[0] : points[i - 2];
const p1 = points[i - 1];
const p2 = points[i];
const p3 = i === points.length - 1 ? points[i] : points[i + 1];
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
path += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}`;
}
return path;
};
@@ -1,12 +1,12 @@
import type { MouseEvent, PointerEvent, RefObject, TouchEvent } from 'react';
import { useMemo, useRef, useState } from 'react';
import { useLayoutEffect } from 'react';
import { Trans } from '@lingui/react/macro';
import { Undo2 } from 'lucide-react';
import type { StrokeOptions } from 'perfect-freehand';
import { getStroke } from 'perfect-freehand';
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
import {
SIGNATURE_CANVAS_DPI,
SIGNATURE_MIN_COVERAGE_THRESHOLD,
@@ -247,7 +247,7 @@ export const SignaturePadDraw = ({
onChange?.($el.current.toDataURL());
};
useLayoutEffect(() => {
unsafe_useEffectOnce(() => {
if ($el.current) {
$el.current.width = $el.current.clientWidth * SIGNATURE_CANVAS_DPI;
$el.current.height = $el.current.clientHeight * SIGNATURE_CANVAS_DPI;
@@ -270,7 +270,7 @@ export const SignaturePadDraw = ({
img.src = value;
}
}, [value]);
});
return (
<div className={cn('h-full w-full', className)}>
@@ -0,0 +1,146 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '../../lib/utils';
import { KeyboardLayout, StrokeStyle, generatePath, getKeyboardLayout } from './keyboard-utils';
export type SignaturePadKeyboardProps = {
className?: string;
onChange: (_value: string) => void;
};
export const SignaturePadKeyboard = ({ className, onChange }: SignaturePadKeyboardProps) => {
const [name, setName] = useState('');
const [currentKeyboardLayout] = useState<KeyboardLayout>(KeyboardLayout.QWERTY);
const curveType = 'linear';
const includeNumbers = false;
const strokeConfig = {
style: StrokeStyle.SOLID,
color: '#000000',
gradientStart: '#ff6b6b',
gradientEnd: '#4ecdc4',
width: 3,
};
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isInputFocused = document.activeElement === inputRef.current;
const isAnyInputFocused = document.activeElement?.tagName === 'INPUT';
if (!isInputFocused && !isAnyInputFocused) {
const regex = includeNumbers ? /^[a-zA-Z0-9]$/ : /^[a-zA-Z]$/;
if (regex.test(e.key) || e.key === 'Backspace') {
inputRef.current?.focus();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [includeNumbers]);
// Generate signature path
const signaturePath = useMemo(() => {
if (!name) return '';
const points = [];
const currentLayout = getKeyboardLayout(currentKeyboardLayout, includeNumbers);
for (const char of name.toUpperCase()) {
if (char in currentLayout) {
const { x, y } = currentLayout[char];
const yOffset = includeNumbers ? 100 : 40;
points.push({ x: x * 60 + 28, y: y * 60 + yOffset });
}
}
if (points.length === 0) return '';
return generatePath(points, curveType);
}, [name, currentKeyboardLayout, curveType, includeNumbers]);
// Update parent component when signature changes
useEffect(() => {
if (signaturePath && name) {
// Convert SVG to data URL for consistency with other signature types
const svgData = generateSVGDataURL(signaturePath);
onChange(svgData);
} else {
onChange('');
}
}, [signaturePath, name, onChange]);
const generateSVGDataURL = (path: string): string => {
const height = includeNumbers ? 260 : 200;
const gradients =
strokeConfig.style === StrokeStyle.GRADIENT
? `<linearGradient id="pathGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:${strokeConfig.gradientStart};stop-opacity:1" />
<stop offset="100%" style="stop-color:${strokeConfig.gradientEnd};stop-opacity:1" />
</linearGradient>`
: '';
const strokeColor =
strokeConfig.style === StrokeStyle.SOLID ? strokeConfig.color : 'url(#pathGradient)';
const svgContent = `<svg width="650" height="${height}" xmlns="http://www.w3.org/2000/svg">
<defs>${gradients}</defs>
<path d="${path}" stroke="${strokeColor}" stroke-width="${strokeConfig.width}" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
};
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center', className)}>
<input
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
className="sr-only"
autoFocus
/>
<div className="relative w-full max-w-lg">
<svg
className="pointer-events-none w-full"
viewBox="0 0 650 200"
preserveAspectRatio="xMidYMid meet"
style={{ height: '150px' }}
>
<defs>
{strokeConfig.style === StrokeStyle.GRADIENT && (
<linearGradient id="pathGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={strokeConfig.gradientStart} stopOpacity={1} />
<stop offset="100%" stopColor={strokeConfig.gradientEnd} stopOpacity={1} />
</linearGradient>
)}
</defs>
{signaturePath && (
<path
d={signaturePath}
stroke={
strokeConfig.style === StrokeStyle.SOLID ? strokeConfig.color : 'url(#pathGradient)'
}
strokeWidth={strokeConfig.width}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
</div>
<div className="mt-4 w-full max-w-lg">
<div className="text-muted-foreground/70 font-mono text-xs">{name}</div>
</div>
</div>
);
};
@@ -1,9 +1,10 @@
import { useLayoutEffect, useRef } from 'react';
import { useRef } from 'react';
import { Trans } from '@lingui/react/macro';
import { motion } from 'framer-motion';
import { UploadCloudIcon } from 'lucide-react';
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures';
import { cn } from '../../lib/utils';
@@ -96,7 +97,7 @@ export const SignaturePadUpload = ({
}
};
useLayoutEffect(() => {
unsafe_useEffectOnce(() => {
// Todo: Not really sure if this is required for uploaded images.
if ($el.current) {
$el.current.width = $el.current.clientWidth * SIGNATURE_CANVAS_DPI;
@@ -120,7 +121,7 @@ export const SignaturePadUpload = ({
img.src = value;
}
}, [value]);
});
return (
<div className={cn('relative h-full w-full', className)}>
@@ -1,7 +1,7 @@
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
import { Keyboard, KeyboardIcon, UploadCloudIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
@@ -10,6 +10,7 @@ import { isBase64Image } from '@documenso/lib/constants/signatures';
import { SignatureIcon } from '../../icons/signature';
import { cn } from '../../lib/utils';
import { SignaturePadDraw } from './signature-pad-draw';
import { SignaturePadKeyboard } from './signature-pad-keyboard';
import { SignaturePadType } from './signature-pad-type';
import { SignaturePadUpload } from './signature-pad-upload';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './signature-tabs';
@@ -28,6 +29,7 @@ export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChang
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
keyboardSignatureEnabled?: boolean;
onValidityChange?: (isValid: boolean) => void;
};
@@ -39,10 +41,12 @@ export const SignaturePad = ({
typedSignatureEnabled = true,
uploadSignatureEnabled = true,
drawSignatureEnabled = true,
keyboardSignatureEnabled = true,
}: SignaturePadProps) => {
const [imageSignature, setImageSignature] = useState(isBase64Image(value) ? value : '');
const [drawSignature, setDrawSignature] = useState(isBase64Image(value) ? value : '');
const [typedSignature, setTypedSignature] = useState(isBase64Image(value) ? '' : value);
const [keyboardSignature, setKeyboardSignature] = useState(isBase64Image(value) ? value : '');
/**
* This is cooked.
@@ -51,7 +55,7 @@ export const SignaturePad = ({
* the first enabled tab.
*/
const [tab, setTab] = useState(
((): 'draw' | 'text' | 'image' => {
((): 'draw' | 'text' | 'image' | 'keyboard' => {
// First passthrough to check to see if there's a signature for a given tab.
if (drawSignatureEnabled && drawSignature) {
return 'draw';
@@ -65,6 +69,10 @@ export const SignaturePad = ({
return 'image';
}
if (keyboardSignatureEnabled && keyboardSignature) {
return 'keyboard';
}
// Second passthrough to just select the first avaliable tab.
if (drawSignatureEnabled) {
return 'draw';
@@ -78,6 +86,10 @@ export const SignaturePad = ({
return 'image';
}
if (keyboardSignatureEnabled) {
return 'keyboard';
}
throw new Error('No signature enabled');
})(),
);
@@ -109,7 +121,16 @@ export const SignaturePad = ({
});
};
const onTabChange = (value: 'draw' | 'text' | 'image') => {
const onKeyboardSignatureChange = (value: string) => {
setKeyboardSignature(value);
onChange?.({
type: DocumentSignatureType.KEYBOARD,
value,
});
};
const onTabChange = (value: 'draw' | 'text' | 'image' | 'keyboard') => {
if (disabled) {
return;
}
@@ -126,10 +147,18 @@ export const SignaturePad = ({
.with('image', () => {
onImageSignatureChange(imageSignature);
})
.with('keyboard', () => {
onKeyboardSignatureChange(keyboardSignature);
})
.exhaustive();
};
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled) {
if (
!drawSignatureEnabled &&
!typedSignatureEnabled &&
!uploadSignatureEnabled &&
!keyboardSignatureEnabled
) {
return null;
}
@@ -140,7 +169,7 @@ export const SignaturePad = ({
'pointer-events-none': disabled,
})}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onValueChange={(value) => onTabChange(value as 'draw' | 'text' | 'image')}
onValueChange={(value) => onTabChange(value as 'draw' | 'text' | 'image' | 'keyboard')}
>
<TabsList>
{drawSignatureEnabled && (
@@ -163,6 +192,13 @@ export const SignaturePad = ({
Upload
</TabsTrigger>
)}
{keyboardSignatureEnabled && (
<TabsTrigger value="keyboard">
<Keyboard className="mr-2 size-4" />
Keyboard
</TabsTrigger>
)}
</TabsList>
<TabsContent
@@ -194,6 +230,13 @@ export const SignaturePad = ({
>
<SignaturePadUpload value={imageSignature} onChange={onImageSignatureChange} />
</TabsContent>
<TabsContent
value="keyboard"
className="border-border aspect-signature-pad dark:bg-background relative flex items-center justify-center rounded-md border bg-neutral-50 text-center"
>
<SignaturePadKeyboard value={keyboardSignature} onChange={onKeyboardSignatureChange} />
</TabsContent>
</Tabs>
);
};
@@ -1,3 +1,5 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
@@ -14,7 +16,7 @@ import {
DOCUMENT_SIGNATURE_TYPES,
} from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import type { TTemplate } from '@documenso/lib/types/template';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
@@ -111,8 +113,7 @@ export const AddTemplateSettingsFormPartial = ({
meta: {
subject: template.templateMeta?.subject ?? '',
message: template.templateMeta?.message ?? '',
timezone:
template.templateMeta?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
timezone: template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
dateFormat: (template.templateMeta?.dateFormat ??
DEFAULT_DOCUMENT_DATE_FORMAT) as TDocumentMetaDateFormat,
@@ -151,6 +152,14 @@ export const AddTemplateSettingsFormPartial = ({
)
.otherwise(() => false);
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
if (!form.formState.touchedFields.meta?.timezone && !template.templateMeta?.timezone) {
form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [form, form.setValue, form.formState.touchedFields.meta?.timezone]);
return (
<>
<DocumentFlowFormContainerHeader
-1
View File
@@ -47,7 +47,6 @@
"NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
"NEXT_PUBLIC_DISABLE_SIGNUP",
"NEXT_PRIVATE_PLAIN_API_KEY",
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
"NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_DIRECT_DATABASE_URL",