Compare commits

..

8 Commits

Author SHA1 Message Date
Adithya Krishna
86788f4248 feat: added password validation (#469)
This PR Fixes #464
2024-01-30 11:42:52 +11:00
hallidayo
76c203aae6 feat: dateformat and timezone customization (#506) 2023-12-27 10:50:40 +11:00
18feb06
bb5611ad40 feat: added undo button while drawing signature (#480) 2023-12-16 13:20:59 +11:00
Aditya Deshlahre
b152dbe25e chore: add lint-staged task for dependency changes (#548) 2023-12-09 11:30:15 +11:00
Lucas Smith
3db67c1212 chore: use minio as s3 storage for document during development (#588) 2023-12-08 20:49:08 +11:00
Lucas Smith
dc8224ee9f fix: update container name 2023-12-08 20:48:25 +11:00
Ephraim Atta-Duncan
852fc7ed7a chore: use database as default for example env 2023-11-16 07:59:28 +00:00
Ephraim Atta-Duncan
1d632200d7 chore: use minio as s3 storage for document during development 2023-10-23 00:55:40 +00:00
60 changed files with 695 additions and 261 deletions

View File

@@ -23,21 +23,21 @@ NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/
# [[E2E Tests]] # [[E2E Tests]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User" E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com" E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password" E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# [[STORAGE]] # [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 # OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
NEXT_PUBLIC_UPLOAD_TRANSPORT="database" NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers. # OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
NEXT_PRIVATE_UPLOAD_ENDPOINT= NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002"
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1. # OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
NEXT_PRIVATE_UPLOAD_REGION= NEXT_PRIVATE_UPLOAD_REGION="unknown"
# REQUIRED: Defines the bucket to use for the S3 storage transport. # REQUIRED: Defines the bucket to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_BUCKET= NEXT_PRIVATE_UPLOAD_BUCKET="documenso"
# OPTIONAL: Defines the access key ID to use for the S3 storage transport. # OPTIONAL: Defines the access key ID to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID= NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID="documenso"
# OPTIONAL: Defines the secret access key to use for the S3 storage transport. # OPTIONAL: Defines the secret access key to use for the S3 storage transport.
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY= NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password"
# [[SMTP]] # [[SMTP]]
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels # OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels

View File

@@ -141,11 +141,13 @@ npm run d
1. **App** - http://localhost:3000 1. **App** - http://localhost:3000
2. **Incoming Mail Access** - http://localhost:9000 2. **Incoming Mail Access** - http://localhost:9000
3. **Database Connection Details** 3. **Database Connection Details**
- **Port**: 54320 - **Port**: 54320
- **Connection**: Use your favorite database client to connect using the provided port. - **Connection**: Use your favorite database client to connect using the provided port.
4. **S3 Storage Dashboard** - http://localhost:9001
## Developer Setup ## Developer Setup
### Manual Setup ### Manual Setup

View File

@@ -8,18 +8,19 @@ import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { base64 } from '@documenso/lib/universal/base64'; import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putFile } from '@documenso/lib/universal/upload/put-file';
import { DocumentDataType, Field, Prisma, Recipient } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields'; import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types'; import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature'; import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types'; import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
import { import {
DocumentFlowFormContainer, DocumentFlowFormContainer,
DocumentFlowFormContainerHeader, DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root'; } from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';

View File

@@ -10,6 +10,7 @@ import { z } from 'zod';
import { mailer } from '@documenso/email/mailer'; import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render'; import { render } from '@documenso/email/render';
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed'; import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email'; import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf'; import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { alphaid } from '@documenso/lib/universal/id'; import { alphaid } from '@documenso/lib/universal/id';
@@ -215,7 +216,7 @@ const mapField = (
signer: TCreateSinglePlayerDocumentSchema['signer'], signer: TCreateSinglePlayerDocumentSchema['signer'],
) => { ) => {
const customText = match(field.type) const customText = match(field.type)
.with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a')) .with(FieldType.DATE, () => DateTime.now().toFormat(DEFAULT_DOCUMENT_DATE_FORMAT))
.with(FieldType.EMAIL, () => signer.email) .with(FieldType.EMAIL, () => signer.email)
.with(FieldType.NAME, () => signer.name) .with(FieldType.NAME, () => signer.name)
.otherwise(() => ''); .otherwise(() => '');

View File

@@ -1,4 +1,4 @@
import { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
@@ -7,7 +7,8 @@ import { buffer } from 'micro';
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf'; import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf'; import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
import { redis } from '@documenso/lib/server-only/redis'; import { redis } from '@documenso/lib/server-only/redis';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { updateFile } from '@documenso/lib/universal/upload/update-file'; import { updateFile } from '@documenso/lib/universal/upload/update-file';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';

View File

@@ -9,7 +9,6 @@ import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema'; import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox';
import { import {
Form, Form,
FormControl, FormControl,
@@ -19,6 +18,7 @@ import {
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true }); const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
@@ -117,7 +117,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<FormLabel className="text-muted-foreground">Roles</FormLabel> <FormLabel className="text-muted-foreground">Roles</FormLabel>
<FormControl> <FormControl>
<Combobox <MultiSelectCombobox
listValues={roles} listValues={roles}
onChange={(values: string[]) => onChange(values)} onChange={(values: string[]) => onChange(values)}
/> />

View File

@@ -4,21 +4,21 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { DocumentData, Field, Recipient, User } from '@documenso/prisma/client'; import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields'; import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types'; import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers'; import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types'; import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject'; import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types'; import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { import {
DocumentFlowFormContainer, DocumentFlowFormContainer,
DocumentFlowFormContainerHeader, DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root'; } from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -117,14 +117,16 @@ export const EditDocumentForm = ({
}; };
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message } = data.email; const { subject, message, timezone, dateFormat } = data.meta;
try { try {
await completeDocument({ await completeDocument({
documentId: document.id, documentId: document.id,
email: { meta: {
subject, subject,
message, message,
timezone,
dateFormat,
}, },
}); });

View File

@@ -23,6 +23,7 @@ export type UploadDocumentProps = {
export const UploadDocument = ({ className }: UploadDocumentProps) => { export const UploadDocument = ({ className }: UploadDocumentProps) => {
const router = useRouter(); const router = useRouter();
const { data: session } = useSession(); const { data: session } = useSession();
const { toast } = useToast(); const { toast } = useToast();

View File

@@ -6,28 +6,37 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { Recipient } from '@documenso/prisma/client'; import {
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; DEFAULT_DOCUMENT_DATE_FORMAT,
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
export type DateFieldProps = { export type DateFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
recipient: Recipient; recipient: Recipient;
dateFormat?: string | null;
timezone?: string | null;
}; };
export const DateField = ({ field, recipient }: DateFieldProps) => { export const DateField = ({
field,
recipient,
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
}: DateFieldProps) => {
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { dateFormat } = useRequiredSigningContext();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(); trpc.field.signFieldWithToken.useMutation();
@@ -38,12 +47,18 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone);
const isDifferentTimeZone = field.inserted && localDateString !== field.customText;
const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`;
const onSign = async () => { const onSign = async () => {
try { try {
await signFieldWithToken({ await signFieldWithToken({
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
value: dateFormat, value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
}); });
startTransition(() => router.refresh()); startTransition(() => router.refresh());
@@ -78,7 +93,13 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
}; };
return ( return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}> <SigningFieldContainer
field={field}
onSign={onSign}
onRemove={onRemove}
type="Date"
tooltipText={isDifferentTimeZone ? tooltipText : undefined}
>
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md"> <div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" /> <Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
@@ -90,7 +111,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
)} )}
{field.inserted && ( {field.inserted && (
<p className="text-muted-foreground text-sm duration-200">{field.customText}</p> <p className="text-muted-foreground text-sm duration-200">{localDateString}</p>
)} )}
</SigningFieldContainer> </SigningFieldContainer>
); );

View File

@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -79,7 +79,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
}; };
return ( return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}> <SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md"> <div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" /> <Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />

View File

@@ -9,24 +9,15 @@ import { useForm } from 'react-hook-form';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token'; import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { Document, Field, Recipient } from '@documenso/prisma/client'; import type { Document, Field, Recipient } from '@documenso/prisma/client';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { DATE_FORMATS } from '~/helpers/constants';
import { useRequiredSigningContext } from './provider'; import { useRequiredSigningContext } from './provider';
import { SignDialog } from './sign-dialog'; import { SignDialog } from './sign-dialog';
@@ -40,13 +31,10 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
const router = useRouter(); const router = useRouter();
const { data: session } = useSession(); const { data: session } = useSession();
const { fullName, signature, setFullName, setSignature, dateFormat, setDateFormat } = const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
useRequiredSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const hasDateField = fields.find((field) => field.type === 'DATE');
const { const {
handleSubmit, handleSubmit,
formState: { isSubmitting }, formState: { isSubmitting },
@@ -94,7 +82,11 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
disabled={isSubmitting} disabled={isSubmitting}
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')} className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
> >
<div className={cn('flex flex-1 flex-col')}> <div
className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)}
>
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3> <h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
@@ -117,30 +109,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
/> />
</div> </div>
{hasDateField && (
<div>
<Label htmlFor="date-format">Date Format</Label>
<Select
onValueChange={(value) => {
setDateFormat(value);
}}
defaultValue={dateFormat}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div> <div>
<Label htmlFor="Signature">Signature</Label> <Label htmlFor="Signature">Signature</Label>

View File

@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
@@ -98,7 +98,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
}; };
return ( return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}> <SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Name">
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md"> <div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" /> <Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />

View File

@@ -2,9 +2,12 @@ import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
@@ -40,6 +43,8 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
viewedDocument({ token }).catch(() => null), viewedDocument({ token }).catch(() => null),
]); ]);
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
if (!document || !document.documentData || !recipient) { if (!document || !document.documentData || !recipient) {
return notFound(); return notFound();
} }
@@ -97,7 +102,13 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
<NameField key={field.id} field={field} recipient={recipient} /> <NameField key={field.id} field={field} recipient={recipient} />
)) ))
.with(FieldType.DATE, () => ( .with(FieldType.DATE, () => (
<DateField key={field.id} field={field} recipient={recipient} /> <DateField
key={field.id}
field={field}
recipient={recipient}
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
/>
)) ))
.with(FieldType.EMAIL, () => ( .with(FieldType.EMAIL, () => (
<EmailField key={field.id} field={field} recipient={recipient} /> <EmailField key={field.id} field={field} recipient={recipient} />

View File

@@ -9,8 +9,6 @@ export type SigningContextValue = {
setEmail: (_value: string) => void; setEmail: (_value: string) => void;
signature: string | null; signature: string | null;
setSignature: (_value: string | null) => void; setSignature: (_value: string | null) => void;
dateFormat: string;
setDateFormat: (_value: string) => void;
}; };
const SigningContext = createContext<SigningContextValue | null>(null); const SigningContext = createContext<SigningContextValue | null>(null);
@@ -33,7 +31,6 @@ export interface SigningProviderProps {
fullName?: string | null; fullName?: string | null;
email?: string | null; email?: string | null;
signature?: string | null; signature?: string | null;
dateFormat?: string | null;
children: React.ReactNode; children: React.ReactNode;
} }
@@ -41,13 +38,11 @@ export const SigningProvider = ({
fullName: initialFullName, fullName: initialFullName,
email: initialEmail, email: initialEmail,
signature: initialSignature, signature: initialSignature,
dateFormat: initialDateFormat,
children, children,
}: SigningProviderProps) => { }: SigningProviderProps) => {
const [fullName, setFullName] = useState(initialFullName || ''); const [fullName, setFullName] = useState(initialFullName || '');
const [email, setEmail] = useState(initialEmail || ''); const [email, setEmail] = useState(initialEmail || '');
const [signature, setSignature] = useState(initialSignature || null); const [signature, setSignature] = useState(initialSignature || null);
const [dateFormat, setDateFormat] = useState(initialDateFormat || 'yyyy-MM-dd hh:mm a');
return ( return (
<SigningContext.Provider <SigningContext.Provider
@@ -58,8 +53,6 @@ export const SigningProvider = ({
setEmail, setEmail,
signature, signature,
setSignature, setSignature,
dateFormat,
setDateFormat,
}} }}
> >
{children} {children}

View File

@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
@@ -121,7 +121,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
}; };
return ( return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}> <SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md"> <div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" /> <Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />

View File

@@ -2,8 +2,9 @@
import React from 'react'; import React from 'react';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field'; import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type SignatureFieldProps = { export type SignatureFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
@@ -11,6 +12,8 @@ export type SignatureFieldProps = {
children: React.ReactNode; children: React.ReactNode;
onSign?: () => Promise<void> | void; onSign?: () => Promise<void> | void;
onRemove?: () => Promise<void> | void; onRemove?: () => Promise<void> | void;
type?: 'Date' | 'Email' | 'Name' | 'Signature';
tooltipText?: string | null;
}; };
export const SigningFieldContainer = ({ export const SigningFieldContainer = ({
@@ -19,6 +22,8 @@ export const SigningFieldContainer = ({
onSign, onSign,
onRemove, onRemove,
children, children,
type,
tooltipText,
}: SignatureFieldProps) => { }: SignatureFieldProps) => {
const onSignFieldClick = async () => { const onSignFieldClick = async () => {
if (field.inserted) { if (field.inserted) {
@@ -46,7 +51,22 @@ export const SigningFieldContainer = ({
/> />
)} )}
{field.inserted && !loading && ( {type === 'Date' && field.inserted && !loading && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
</TooltipTrigger>
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
</Tooltip>
)}
{type !== 'Date' && field.inserted && !loading && (
<button <button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100" className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick} onClick={onRemoveSignedFieldClick}

View File

@@ -1,9 +1,9 @@
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { CheckCircle2, Clock, File } from 'lucide-react'; import { CheckCircle2, Clock, File } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { SignatureIcon } from '@documenso/ui/icons/signature'; import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';

View File

@@ -1,8 +1,10 @@
'use client'; 'use client';
import { HTMLAttributes, useEffect, useState } from 'react'; import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import { DateTime, DateTimeFormatOptions } from 'luxon'; import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import { useLocale } from '@documenso/lib/client-only/providers/locale'; import { useLocale } from '@documenso/lib/client-only/providers/locale';

View File

@@ -9,16 +9,18 @@ export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
documentId: number; documentId: number;
}; };
export const completeDocument = async ({ documentId, email }: CompleteDocumentActionInput) => { export const completeDocument = async ({ documentId, meta }: CompleteDocumentActionInput) => {
'use server'; 'use server';
const { user } = await getRequiredServerComponentSession(); const { user } = await getRequiredServerComponentSession();
if (email.message || email.subject) { if (meta.message || meta.subject || meta.dateFormat || meta.timezone) {
await upsertDocumentMeta({ await upsertDocumentMeta({
documentId, documentId,
subject: email.subject, subject: meta.subject,
message: email.message, message: meta.message,
timezone: meta.timezone,
dateFormat: meta.dateFormat,
}); });
} }

View File

@@ -10,6 +10,7 @@ import { z } from 'zod';
import { User } from '@documenso/prisma/client'; import { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
@@ -20,9 +21,9 @@ import { FormErrorMessage } from '../form/form-error-message';
export const ZPasswordFormSchema = z export const ZPasswordFormSchema = z
.object({ .object({
currentPassword: z.string().min(6).max(72), currentPassword: ZCurrentPasswordSchema,
password: z.string().min(6).max(72), password: ZPasswordSchema,
repeatedPassword: z.string().min(6).max(72), repeatedPassword: ZPasswordSchema,
}) })
.refine((data) => data.password === data.repeatedPassword, { .refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match', message: 'Passwords do not match',

View File

@@ -11,6 +11,7 @@ import { z } from 'zod';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
@@ -20,8 +21,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZResetPasswordFormSchema = z export const ZResetPasswordFormSchema = z
.object({ .object({
password: z.string().min(6).max(72), password: ZPasswordSchema,
repeatedPassword: z.string().min(6).max(72), repeatedPassword: ZPasswordSchema,
}) })
.refine((data) => data.password === data.repeatedPassword, { .refine((data) => data.password === data.repeatedPassword, {
path: ['repeatedPassword'], path: ['repeatedPassword'],

View File

@@ -9,6 +9,7 @@ import { FcGoogle } from 'react-icons/fc';
import { z } from 'zod'; import { z } from 'zod';
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes'; import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
@@ -32,7 +33,7 @@ const LOGIN_REDIRECT_PATH = '/documents';
export const ZSignInFormSchema = z.object({ export const ZSignInFormSchema = z.object({
email: z.string().email().min(1), email: z.string().email().min(1),
password: z.string().min(6).max(72), password: ZCurrentPasswordSchema,
totpCode: z.string().trim().optional(), totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(), backupCode: z.string().trim().optional(),
}); });

View File

@@ -10,6 +10,7 @@ import { z } from 'zod';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
@@ -18,12 +19,22 @@ import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZSignUpFormSchema = z.object({ export const ZSignUpFormSchema = z
.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().email().min(1), email: z.string().email().min(1),
password: z.string().min(6).max(72), password: ZPasswordSchema,
signature: z.string().min(1, { message: 'We need your signature to sign documents' }), signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
}); })
.refine(
(data) => {
const { name, email, password } = data;
return !password.includes(name) && !password.includes(email.split('@')[0]);
},
{
message: 'Password should not be common or based on personal information',
},
);
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>; export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;

View File

@@ -1,17 +0,0 @@
export const DATE_FORMATS = [
{
key: 'YYYYMMDD',
label: 'YYYY-MM-DD',
value: 'yyyy-MM-dd hh:mm a',
},
{
key: 'DDMMYYYY',
label: 'DD/MM/YYYY',
value: 'dd/MM/yyyy hh:mm a',
},
{
key: 'MMDDYYYY',
label: 'MM/DD/YYYY',
value: 'MM/dd/yyyy hh:mm a',
},
];

View File

@@ -17,3 +17,20 @@ services:
- 9000:9000 - 9000:9000
- 2500:2500 - 2500:2500
- 1100:1100 - 1100:1100
minio:
image: minio/minio
container_name: minio
ports:
- 9002:9002
- 9001:9001
volumes:
- minio:/data
environment:
MINIO_ROOT_USER: documenso
MINIO_ROOT_PASSWORD: password
entrypoint: sh
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
volumes:
minio:

View File

@@ -1,3 +1,4 @@
module.exports = { module.exports = {
'**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}': ['prettier --write'], '**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}': ['prettier --write'],
'**/*/package.json': ['npm run precommit'],
}; };

6
package-lock.json generated
View File

@@ -6983,6 +6983,11 @@
"isomorphic-fetch": "^3.0.0" "isomorphic-fetch": "^3.0.0"
} }
}, },
"node_modules/@vvo/tzdb": {
"version": "6.117.0",
"resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.117.0.tgz",
"integrity": "sha512-vZkfoag1kHqItK/zebxT0Fkt3R/zscjgD+Ib7kaAdum0Sz9psXDfVHPW1Benv91d02zPWlLIvZtjBmzX4a+6fw=="
},
"node_modules/abbrev": { "node_modules/abbrev": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -20189,6 +20194,7 @@
"@scure/base": "^1.1.3", "@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^2.2.1",
"@upstash/redis": "^1.20.6", "@upstash/redis": "^1.20.6",
"@vvo/tzdb": "^6.117.0",
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"nanoid": "^4.0.2", "nanoid": "^4.0.2",

View File

@@ -21,7 +21,8 @@
"prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma", "prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
"prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma", "prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma",
"with:env": "dotenv -e .env -e .env.local --", "with:env": "dotenv -e .env -e .env.local --",
"reset:hard": "npm run clean && npm i && npm run prisma:generate" "reset:hard": "npm run clean && npm i && npm run prisma:generate",
"precommit": "npm install && git add package.json package-lock.json"
}, },
"engines": { "engines": {
"npm": ">=8.6.0", "npm": ">=8.6.0",

View File

@@ -14,10 +14,8 @@ import {
import config from '@documenso/tailwind-config'; import config from '@documenso/tailwind-config';
import { import type { TemplateDocumentInviteProps } from '../template-components/template-document-invite';
TemplateDocumentInvite, import { TemplateDocumentInvite } from '../template-components/template-document-invite';
TemplateDocumentInviteProps,
} from '../template-components/template-document-invite';
import { TemplateFooter } from '../template-components/template-footer'; import { TemplateFooter } from '../template-components/template-footer';
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & { export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {

View File

@@ -1,4 +1,5 @@
import { ReadStatus, Recipient, SendStatus, SigningStatus } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client';
export const getRecipientType = (recipient: Recipient) => { export const getRecipientType = (recipient: Recipient) => {
if ( if (

View File

@@ -0,0 +1,71 @@
import { DateTime } from 'luxon';
import { DEFAULT_DOCUMENT_TIME_ZONE } from './time-zones';
export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a';
export const DATE_FORMATS = [
{
key: 'YYYYMMDD',
label: 'YYYY-MM-DD',
value: DEFAULT_DOCUMENT_DATE_FORMAT,
},
{
key: 'DDMMYYYY',
label: 'DD/MM/YYYY',
value: 'dd/MM/yyyy hh:mm a',
},
{
key: 'MMDDYYYY',
label: 'MM/DD/YYYY',
value: 'MM/dd/yyyy hh:mm a',
},
{
key: 'YYYYMMDDHHmm',
label: 'YYYY-MM-DD HH:mm',
value: 'yyyy-MM-dd HH:mm',
},
{
key: 'YYMMDD',
label: 'YY-MM-DD',
value: 'yy-MM-dd hh:mm a',
},
{
key: 'YYYYMMDDhhmmss',
label: 'YYYY-MM-DD HH:mm:ss',
value: 'yyyy-MM-dd HH:mm:ss',
},
{
key: 'MonthDateYear',
label: 'Month Date, Year',
value: 'MMMM dd, yyyy hh:mm a',
},
{
key: 'DayMonthYear',
label: 'Day, Month Year',
value: 'EEEE, MMMM dd, yyyy hh:mm a',
},
{
key: 'ISO8601',
label: 'ISO 8601',
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
},
];
export const convertToLocalSystemFormat = (
customText: string,
dateFormat: string | null = DEFAULT_DOCUMENT_DATE_FORMAT,
timeZone: string | null = DEFAULT_DOCUMENT_TIME_ZONE,
): string => {
const parsedDate = DateTime.fromFormat(customText, dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, {
zone: timeZone ?? DEFAULT_DOCUMENT_TIME_ZONE,
});
if (!parsedDate.isValid) {
return 'Invalid date';
}
const formattedDate = parsedDate.toLocal().toFormat(dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
return formattedDate;
};

View File

@@ -0,0 +1,44 @@
import { rawTimeZones, timeZonesNames } from '@vvo/tzdb';
export const TIME_ZONE_DATA = rawTimeZones;
export const DEFAULT_DOCUMENT_TIME_ZONE = 'Etc/UTC';
export type TimeZone = {
name: string;
rawOffsetInMinutes: number;
};
export const minutesToHours = (minutes: number): string => {
const hours = Math.abs(Math.floor(minutes / 60));
const min = Math.abs(minutes % 60);
const sign = minutes >= 0 ? '+' : '-';
return `${sign}${String(hours).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
};
const getGMTOffsets = (timezones: TimeZone[]): string[] => {
const gmtOffsets: string[] = [];
for (const timezone of timezones) {
const offsetValue = minutesToHours(timezone.rawOffsetInMinutes);
const gmtText = `(${offsetValue})`;
gmtOffsets.push(`${timezone.name} ${gmtText}`);
}
return gmtOffsets;
};
export const splitTimeZone = (input: string | null): string => {
if (input === null) {
return '';
}
const [timeZone] = input.split('(');
return timeZone.trim();
};
export const TIME_ZONES_FULL = getGMTOffsets(TIME_ZONE_DATA);
export const TIME_ZONES = ['Etc/UTC', ...timeZonesNames];

View File

@@ -1,9 +1,10 @@
import { PrismaAdapter } from '@next-auth/prisma-adapter'; import { PrismaAdapter } from '@next-auth/prisma-adapter';
import { compare } from 'bcrypt'; import { compare } from 'bcrypt';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { AuthOptions, Session, User } from 'next-auth'; import type { AuthOptions, Session, User } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials'; import CredentialsProvider from 'next-auth/providers/credentials';
import GoogleProvider, { GoogleProfile } from 'next-auth/providers/google'; import type { GoogleProfile } from 'next-auth/providers/google';
import GoogleProvider from 'next-auth/providers/google';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';

View File

@@ -30,6 +30,7 @@
"@scure/base": "^1.1.3", "@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^2.2.1",
"@upstash/redis": "^1.20.6", "@upstash/redis": "^1.20.6",
"@vvo/tzdb": "^6.117.0",
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"nanoid": "^4.0.2", "nanoid": "^4.0.2",

View File

@@ -6,11 +6,15 @@ export type CreateDocumentMetaOptions = {
documentId: number; documentId: number;
subject: string; subject: string;
message: string; message: string;
timezone: string;
dateFormat: string;
}; };
export const upsertDocumentMeta = async ({ export const upsertDocumentMeta = async ({
subject, subject,
message, message,
timezone,
dateFormat,
documentId, documentId,
}: CreateDocumentMetaOptions) => { }: CreateDocumentMetaOptions) => {
return await prisma.documentMeta.upsert({ return await prisma.documentMeta.upsert({
@@ -20,11 +24,15 @@ export const upsertDocumentMeta = async ({
create: { create: {
subject, subject,
message, message,
dateFormat,
timezone,
documentId, documentId,
}, },
update: { update: {
subject, subject,
message, message,
dateFormat,
timezone,
}, },
}); });
}; };

View File

@@ -25,6 +25,8 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI
select: { select: {
message: true, message: true,
subject: true, subject: true,
dateFormat: true,
timezone: true,
}, },
}, },
}, },

View File

@@ -0,0 +1,13 @@
import { prisma } from '@documenso/prisma';
export interface GetDocumentMetaByDocumentIdOptions {
id: number;
}
export const getDocumentMetaByDocumentId = async ({ id }: GetDocumentMetaByDocumentIdOptions) => {
return await prisma.documentMeta.findFirstOrThrow({
where: {
documentId: id,
},
});
};

View File

@@ -1,5 +1,6 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { SigningStatus, User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
import { SigningStatus } from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';

View File

@@ -1,6 +1,6 @@
'use server'; 'use server';
import { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';

View File

@@ -5,6 +5,9 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
export type SignFieldWithTokenOptions = { export type SignFieldWithTokenOptions = {
token: string; token: string;
fieldId: number; fieldId: number;
@@ -50,6 +53,12 @@ export const signFieldWithToken = async ({
throw new Error(`Field ${fieldId} has no recipientId`); throw new Error(`Field ${fieldId} has no recipientId`);
} }
const documentMeta = await prisma.documentMeta.findFirst({
where: {
documentId: document.id,
},
});
const isSignatureField = const isSignatureField =
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE; field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
@@ -58,7 +67,9 @@ export const signFieldWithToken = async ({
const typedSignature = isSignatureField && !isBase64 ? value : undefined; const typedSignature = isSignatureField && !isBase64 ? value : undefined;
if (field.type === FieldType.DATE) { if (field.type === FieldType.DATE) {
customText = DateTime.now().toFormat(value); customText = DateTime.now()
.setZone(documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
.toFormat(documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
} }
await prisma.field.update({ await prisma.field.update({

View File

@@ -1,4 +1,4 @@
import { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
export const recipientInitials = (text: string) => export const recipientInitials = (text: string) =>
text text

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "dateFormat" TEXT DEFAULT 'yyyy-MM-dd hh:mm a',
ADD COLUMN "timezone" TEXT DEFAULT 'Etc/UTC';

View File

@@ -159,6 +159,8 @@ model DocumentMeta {
id String @id @default(cuid()) id String @id @default(cuid())
subject String? subject String?
message String? message String?
timezone String? @db.Text @default("Etc/UTC")
dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a")
documentId Int @unique documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
} }

View File

@@ -1,4 +1,4 @@
import { Document, DocumentData, DocumentMeta } from '@documenso/prisma/client'; import type { Document, DocumentData, DocumentMeta } from '@documenso/prisma/client';
export type DocumentWithData = Document & { export type DocumentWithData = Document & {
documentData?: DocumentData | null; documentData?: DocumentData | null;

View File

@@ -1,4 +1,4 @@
import { Document, DocumentData, Recipient } from '@documenso/prisma/client'; import type { Document, DocumentData, Recipient } from '@documenso/prisma/client';
export type DocumentWithRecipients = Document & { export type DocumentWithRecipients = Document & {
Recipient: Recipient[]; Recipient: Recipient[];

View File

@@ -1,4 +1,4 @@
import { Field, Signature } from '@documenso/prisma/client'; import type { Field, Signature } from '@documenso/prisma/client';
export type FieldWithSignature = Field & { export type FieldWithSignature = Field & {
Signature?: Signature | null; Signature?: Signature | null;

View File

@@ -1,9 +1,25 @@
import { z } from 'zod'; import { z } from 'zod';
export const ZCurrentPasswordSchema = z
.string()
.min(6, { message: 'Must be at least 6 characters in length' })
.max(72);
export const ZPasswordSchema = z
.string()
.regex(new RegExp('.*[A-Z].*'), { message: 'One uppercase character' })
.regex(new RegExp('.*[a-z].*'), { message: 'One lowercase character' })
.regex(new RegExp('.*\\d.*'), { message: 'One number' })
.regex(new RegExp('.*[`~<>?,./!@#$%^&*()\\-_+="\'|{}\\[\\];:\\\\].*'), {
message: 'One special character is required',
})
.min(8, { message: 'Must be at least 8 characters in length' })
.max(72, { message: 'Cannot be more than 72 characters in length' });
export const ZSignUpMutationSchema = z.object({ export const ZSignUpMutationSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
email: z.string().email(), email: z.string().email(),
password: z.string().min(6), password: ZPasswordSchema,
signature: z.string().min(1, { message: 'A signature is required.' }), signature: z.string().min(1, { message: 'A signature is required.' }),
}); });

View File

@@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { ZCurrentPasswordSchema, ZPasswordSchema } from '../auth-router/schema';
export const ZRetrieveUserByIdQuerySchema = z.object({ export const ZRetrieveUserByIdQuerySchema = z.object({
id: z.number().min(1), id: z.number().min(1),
}); });
@@ -10,8 +12,8 @@ export const ZUpdateProfileMutationSchema = z.object({
}); });
export const ZUpdatePasswordMutationSchema = z.object({ export const ZUpdatePasswordMutationSchema = z.object({
currentPassword: z.string().min(6), currentPassword: ZCurrentPasswordSchema,
password: z.string().min(6), password: ZPasswordSchema,
}); });
export const ZForgotPasswordFormSchema = z.object({ export const ZForgotPasswordFormSchema = z.object({
@@ -19,7 +21,7 @@ export const ZForgotPasswordFormSchema = z.object({
}); });
export const ZResetPasswordFormSchema = z.object({ export const ZResetPasswordFormSchema = z.object({
password: z.string().min(6), password: ZPasswordSchema,
token: z.string().min(1), token: z.string().min(1),
}); });

View File

@@ -3,7 +3,7 @@ import SuperJSON from 'superjson';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { TrpcContext } from './context'; import type { TrpcContext } from './context';
const t = initTRPC.context<TrpcContext>().create({ const t = initTRPC.context<TrpcContext>().create({
transformer: SuperJSON, transformer: SuperJSON,

View File

@@ -1,4 +1,5 @@
import { ClassValue, clsx } from 'clsx'; import type { ClassValue } from 'clsx';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {

View File

@@ -1,8 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react'; import { Check, ChevronDown } from 'lucide-react';
import { Role } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@@ -15,34 +14,31 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
type ComboboxProps = { type ComboboxProps = {
listValues: string[]; className?: string;
onChange: (_values: string[]) => void; options: string[];
value: string | null;
onChange: (_value: string | null) => void;
placeholder?: string;
disabled?: boolean;
}; };
const Combobox = ({ listValues, onChange }: ComboboxProps) => { const Combobox = ({
className,
options,
value,
onChange,
disabled = false,
placeholder,
}: ComboboxProps) => {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
const dbRoles = Object.values(Role);
React.useEffect(() => { const onOptionSelected = (newValue: string) => {
setSelectedValues(listValues); onChange(newValue === value ? null : newValue);
}, [listValues]);
const allRoles = [...new Set([...dbRoles, ...selectedValues])];
const handleSelect = (currentValue: string) => {
let newSelectedValues;
if (selectedValues.includes(currentValue)) {
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
} else {
newSelectedValues = [...selectedValues, currentValue];
}
setSelectedValues(newSelectedValues);
onChange(newSelectedValues);
setOpen(false); setOpen(false);
}; };
const placeholderValue = placeholder ?? 'Select an option';
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -50,26 +46,28 @@ const Combobox = ({ listValues, onChange }: ComboboxProps) => {
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className="w-[200px] justify-between" className={cn('my-2 w-full justify-between', className)}
disabled={disabled}
> >
{selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'} {value ? value : placeholderValue}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<PopoverContent className="p-0" side="bottom" align="start">
<Command> <Command>
<CommandInput placeholder={selectedValues.join(', ')} /> <CommandInput placeholder={value || placeholderValue} />
<CommandEmpty>No value found.</CommandEmpty> <CommandEmpty>No value found.</CommandEmpty>
<CommandGroup>
{allRoles.map((value: string, i: number) => ( <CommandGroup className="max-h-[250px] overflow-y-auto">
<CommandItem key={i} onSelect={() => handleSelect(value)}> {options.map((option, index) => (
<CommandItem key={index} onSelect={() => onOptionSelected(option)}>
<Check <Check
className={cn( className={cn('mr-2 h-4 w-4', option === value ? 'opacity-100' : 'opacity-0')}
'mr-2 h-4 w-4',
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
)}
/> />
{value}
{option}
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>

View File

@@ -2,7 +2,7 @@
import * as React from 'react'; import * as React from 'react';
import { DialogProps } from '@radix-ui/react-dialog'; import type { DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk'; import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';

View File

@@ -1,4 +1,4 @@
import { Table } from '@tanstack/react-table'; import type { Table } from '@tanstack/react-table';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';

View File

@@ -11,7 +11,8 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { FieldType, SendStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -25,7 +26,7 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { TAddFieldsFormSchema } from './add-fields.types'; import type { TAddFieldsFormSchema } from './add-fields.types';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
@@ -33,7 +34,8 @@ import {
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from './document-flow-root'; } from './document-flow-root';
import { FieldItem } from './field-item'; import { FieldItem } from './field-item';
import { DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types'; import type { DocumentFlowStep } from './types';
import { FRIENDLY_FIELD_TYPE } from './types';
const fontCaveat = Caveat({ const fontCaveat = Caveat({
weight: ['500'], weight: ['500'],

View File

@@ -7,21 +7,23 @@ import { DateTime } from 'luxon';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { Field, FieldType } from '@documenso/prisma/client'; import type { Field } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { FieldType } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types'; import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter, DocumentFlowFormContainerFooter,
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from '@documenso/ui/primitives/document-flow/document-flow-root'; } from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
@@ -135,7 +137,7 @@ export const AddSignatureFormPartial = ({
return match(field.type) return match(field.type)
.with(FieldType.DATE, () => ({ .with(FieldType.DATE, () => ({
...field, ...field,
customText: DateTime.now().toFormat('yyyy-MM-dd hh:mm a'), customText: DateTime.now().toFormat(DEFAULT_DOCUMENT_DATE_FORMAT),
inserted: true, inserted: true,
})) }))
.with(FieldType.EMAIL, () => ({ .with(FieldType.EMAIL, () => ({

View File

@@ -9,21 +9,23 @@ import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { Field, Recipient, SendStatus } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { SendStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { TAddSignersFormSchema, ZAddSignersFormSchema } from './add-signers.types'; import type { TAddSignersFormSchema } from './add-signers.types';
import { ZAddSignersFormSchema } from './add-signers.types';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter, DocumentFlowFormContainerFooter,
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from './document-flow-root'; } from './document-flow-root';
import { DocumentFlowStep } from './types'; import type { DocumentFlowStep } from './types';
export type AddSignersFormProps = { export type AddSignersFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;

View File

@@ -1,22 +1,41 @@
'use client'; 'use client';
import { useForm } from 'react-hook-form'; import { useEffect } from 'react';
import { DocumentStatus, Field, Recipient } from '@documenso/prisma/client'; import { Controller, useForm } from 'react-hook-form';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Textarea } from '@documenso/ui/primitives/textarea'; import { Textarea } from '@documenso/ui/primitives/textarea';
import { TAddSubjectFormSchema } from './add-subject.types'; import { Combobox } from '../combobox';
import type { TAddSubjectFormSchema } from './add-subject.types';
import { import {
DocumentFlowFormContainerActions, DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter, DocumentFlowFormContainerFooter,
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from './document-flow-root'; } from './document-flow-root';
import { DocumentFlowStep } from './types'; import type { DocumentFlowStep } from './types';
export type AddSubjectFormProps = { export type AddSubjectFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
@@ -29,27 +48,46 @@ export type AddSubjectFormProps = {
export const AddSubjectFormPartial = ({ export const AddSubjectFormPartial = ({
documentFlow, documentFlow,
recipients: _recipients, recipients: recipients,
fields: _fields, fields: fields,
document, document,
numberOfSteps, numberOfSteps,
onSubmit, onSubmit,
}: AddSubjectFormProps) => { }: AddSubjectFormProps) => {
const { const {
control,
register, register,
handleSubmit, handleSubmit,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting, touchedFields },
getValues,
setValue,
} = useForm<TAddSubjectFormSchema>({ } = useForm<TAddSubjectFormSchema>({
defaultValues: { defaultValues: {
email: { meta: {
subject: document.documentMeta?.subject ?? '', subject: document.documentMeta?.subject ?? '',
message: document.documentMeta?.message ?? '', message: document.documentMeta?.message ?? '',
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
}, },
}, },
}); });
const onFormSubmit = handleSubmit(onSubmit); const onFormSubmit = handleSubmit(onSubmit);
const hasDateField = fields.find((field) => field.type === 'DATE');
const documentHasBeenSent = recipients.some(
(recipient) => recipient.sendStatus === SendStatus.SENT,
);
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
if (!touchedFields.meta?.timezone && !documentHasBeenSent) {
setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [documentHasBeenSent, setValue, touchedFields.meta?.timezone]);
return ( return (
<> <>
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
@@ -65,10 +103,10 @@ export const AddSubjectFormPartial = ({
// placeholder="Subject" // placeholder="Subject"
className="bg-background mt-2" className="bg-background mt-2"
disabled={isSubmitting} disabled={isSubmitting}
{...register('email.subject')} {...register('meta.subject')}
/> />
<FormErrorMessage className="mt-2" error={errors.email?.subject} /> <FormErrorMessage className="mt-2" error={errors.meta?.subject} />
</div> </div>
<div> <div>
@@ -80,14 +118,12 @@ export const AddSubjectFormPartial = ({
id="message" id="message"
className="bg-background mt-2 h-32 resize-none" className="bg-background mt-2 h-32 resize-none"
disabled={isSubmitting} disabled={isSubmitting}
{...register('email.message')} {...register('meta.message')}
/> />
<FormErrorMessage <FormErrorMessage
className="mt-2" className="mt-2"
error={ error={typeof errors.meta?.message !== 'string' ? errors.meta?.message : undefined}
typeof errors.email?.message !== 'string' ? errors.email?.message : undefined
}
/> />
</div> </div>
@@ -117,6 +153,67 @@ export const AddSubjectFormPartial = ({
</li> </li>
</ul> </ul>
</div> </div>
<Accordion type="multiple" className="mt-8 border-none">
<AccordionItem value="advanced-options" className="border-none">
<AccordionTrigger className="mb-2 border-b text-left hover:no-underline">
Advanced Options
</AccordionTrigger>
<AccordionContent className="text-muted-foreground -mx-1 flex max-w-prose flex-col px-1 text-sm leading-relaxed">
{hasDateField && (
<div className="mt-2 flex flex-col">
<Label htmlFor="date-format">
Date Format <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name={`meta.dateFormat`}
disabled={documentHasBeenSent}
render={({ field: { value, onChange, disabled } }) => (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="bg-background mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
)}
{hasDateField && (
<div className="mt-4 flex flex-col">
<Label htmlFor="time-zone">
Time Zone <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name={`meta.timezone`}
render={({ field: { value, onChange } }) => (
<Combobox
className="bg-background"
options={TIME_ZONES}
value={value}
onChange={(value) => value && onChange(value)}
disabled={documentHasBeenSent}
/>
)}
/>
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</div> </div>
</div> </div>
</DocumentFlowFormContainerContent> </DocumentFlowFormContainerContent>

View File

@@ -1,9 +1,14 @@
import { z } from 'zod'; import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
export const ZAddSubjectFormSchema = z.object({ export const ZAddSubjectFormSchema = z.object({
email: z.object({ meta: z.object({
subject: z.string(), subject: z.string(),
message: z.string(), message: z.string(),
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
}), }),
}); });

View File

@@ -0,0 +1,82 @@
import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Role } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
type ComboboxProps = {
listValues: string[];
onChange: (_values: string[]) => void;
};
const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
const [open, setOpen] = React.useState(false);
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
const dbRoles = Object.values(Role);
React.useEffect(() => {
setSelectedValues(listValues);
}, [listValues]);
const allRoles = [...new Set([...dbRoles, ...selectedValues])];
const handleSelect = (currentValue: string) => {
let newSelectedValues;
if (selectedValues.includes(currentValue)) {
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
} else {
newSelectedValues = [...selectedValues, currentValue];
}
setSelectedValues(newSelectedValues);
onChange(newSelectedValues);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
{selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={selectedValues.join(', ')} />
<CommandEmpty>No value found.</CommandEmpty>
<CommandGroup>
{allRoles.map((value: string, i: number) => (
<CommandItem key={i} onSelect={() => handleSelect(value)}>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
)}
/>
{value}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
};
export { MultiSelectCombobox };

View File

@@ -11,6 +11,7 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { Undo2 } from 'lucide-react';
import { StrokeOptions, getStroke } from 'perfect-freehand'; import { StrokeOptions, getStroke } from 'perfect-freehand';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -35,7 +36,8 @@ export const SignaturePad = ({
const $el = useRef<HTMLCanvasElement>(null); const $el = useRef<HTMLCanvasElement>(null);
const [isPressed, setIsPressed] = useState(false); const [isPressed, setIsPressed] = useState(false);
const [points, setPoints] = useState<Point[]>([]); const [lines, setLines] = useState<Point[][]>([]);
const [currentLine, setCurrentLine] = useState<Point[]>([]);
const perfectFreehandOptions = useMemo(() => { const perfectFreehandOptions = useMemo(() => {
const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10; const size = $el.current ? Math.min($el.current.height, $el.current.width) * 0.03 : 10;
@@ -60,26 +62,7 @@ export const SignaturePad = ({
const point = Point.fromEvent(event, DPI, $el.current); const point = Point.fromEvent(event, DPI, $el.current);
const newPoints = [...points, point]; setCurrentLine([point]);
setPoints(newPoints);
if ($el.current) {
const ctx = $el.current.getContext('2d');
if (ctx) {
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
const pathData = new Path2D(
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)),
);
ctx.fill(pathData);
}
}
}; };
const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => { const onMouseMove = (event: MouseEvent | PointerEvent | TouchEvent) => {
@@ -93,31 +76,36 @@ export const SignaturePad = ({
const point = Point.fromEvent(event, DPI, $el.current); const point = Point.fromEvent(event, DPI, $el.current);
if (point.distanceTo(points[points.length - 1]) > 5) { if (point.distanceTo(currentLine[currentLine.length - 1]) > 5) {
const newPoints = [...points, point]; setCurrentLine([...currentLine, point]);
setPoints(newPoints);
// Update the canvas here to draw the lines
if ($el.current) { if ($el.current) {
const ctx = $el.current.getContext('2d'); const ctx = $el.current.getContext('2d');
if (ctx) { if (ctx) {
ctx.restore(); ctx.restore();
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingQuality = 'high';
lines.forEach((line) => {
const pathData = new Path2D( const pathData = new Path2D(
getSvgPathFromStroke(getStroke(points, perfectFreehandOptions)), getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)),
); );
ctx.fill(pathData);
});
const pathData = new Path2D(
getSvgPathFromStroke(getStroke([...currentLine, point], perfectFreehandOptions)),
);
ctx.fill(pathData); ctx.fill(pathData);
} }
} }
} }
}; };
const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addPoint = true) => { const onMouseUp = (event: MouseEvent | PointerEvent | TouchEvent, addLine = true) => {
if (event.cancelable) { if (event.cancelable) {
event.preventDefault(); event.preventDefault();
} }
@@ -126,15 +114,16 @@ export const SignaturePad = ({
const point = Point.fromEvent(event, DPI, $el.current); const point = Point.fromEvent(event, DPI, $el.current);
const newPoints = [...points]; const newLines = [...lines];
if (addPoint) { if (addLine && currentLine.length > 0) {
newPoints.push(point); newLines.push([...currentLine, point]);
setCurrentLine([]);
setPoints(newPoints);
} }
if ($el.current && newPoints.length > 0) { setLines(newLines);
if ($el.current && newLines.length > 0) {
const ctx = $el.current.getContext('2d'); const ctx = $el.current.getContext('2d');
if (ctx) { if (ctx) {
@@ -143,19 +132,18 @@ export const SignaturePad = ({
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingQuality = 'high';
newLines.forEach((line) => {
const pathData = new Path2D( const pathData = new Path2D(
getSvgPathFromStroke(getStroke(newPoints, perfectFreehandOptions)), getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)),
); );
ctx.fill(pathData); ctx.fill(pathData);
});
onChange?.($el.current.toDataURL());
ctx.save(); ctx.save();
} }
onChange?.($el.current.toDataURL());
} }
setPoints([]);
}; };
const onMouseEnter = (event: MouseEvent | PointerEvent | TouchEvent) => { const onMouseEnter = (event: MouseEvent | PointerEvent | TouchEvent) => {
@@ -185,7 +173,29 @@ export const SignaturePad = ({
onChange?.(null); onChange?.(null);
setPoints([]); setLines([]);
setCurrentLine([]);
};
const onUndoClick = () => {
if (lines.length === 0) {
return;
}
const newLines = [...lines];
newLines.pop(); // Remove the last line
setLines(newLines);
// Clear the canvas
if ($el.current) {
const ctx = $el.current.getContext('2d');
ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
newLines.forEach((line) => {
const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)));
ctx?.fill(pathData);
});
}
}; };
useEffect(() => { useEffect(() => {
@@ -225,15 +235,29 @@ export const SignaturePad = ({
{...props} {...props}
/> />
<div className="absolute bottom-4 right-4"> <div className="absolute bottom-4 right-4 flex gap-2">
<button <button
type="button" type="button"
className="focus-visible:ring-ring ring-offset-background text-muted-foreground rounded-full p-0 text-xs focus-visible:outline-none focus-visible:ring-2" className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground rounded-full p-0 text-xs focus-visible:outline-none focus-visible:ring-2"
onClick={() => onClearClick()} onClick={() => onClearClick()}
> >
Clear Signature Clear Signature
</button> </button>
</div> </div>
{lines.length > 0 && (
<div className="absolute bottom-4 left-4 flex gap-2">
<button
type="button"
title="undo"
className="focus-visible:ring-ring ring-offset-background text-muted-foreground/60 hover:text-muted-foreground rounded-full p-0 text-xs focus-visible:outline-none focus-visible:ring-2"
onClick={() => onUndoClick()}
>
<Undo2 className="h-4 w-4" />
<span className="sr-only">Undo</span>
</button>
</div>
)}
</div> </div>
); );
}; };