mirror of
https://github.com/documenso/documenso.git
synced 2025-11-11 04:52:41 +10:00
Compare commits
4 Commits
fix/templa
...
feat/docum
| Author | SHA1 | Date | |
|---|---|---|---|
| 35410e100e | |||
| e52af336b4 | |||
| 04e3e1eeb9 | |||
| 43810c4357 |
@ -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,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>
|
||||
);
|
||||
};
|
||||
@ -3,7 +3,7 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, Recipient, Signature } from '@prisma/client';
|
||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
|
||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
@ -48,7 +48,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
||||
documentData: DocumentData;
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | null;
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhiteLabelling?: boolean;
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { DocumentMeta } from '@prisma/client';
|
||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
||||
import { type Field, FieldType } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@ -33,7 +33,7 @@ import { DocumentSigningTextField } from '~/components/general/document-signing/
|
||||
export type EmbedDocumentFieldsProps = {
|
||||
fields: Field[];
|
||||
metadata?: Pick<
|
||||
DocumentMeta,
|
||||
DocumentMeta | TemplateMeta,
|
||||
| 'timezone'
|
||||
| 'dateFormat'
|
||||
| 'typedSignatureEnabled'
|
||||
|
||||
@ -3,7 +3,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta } from '@prisma/client';
|
||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
||||
import {
|
||||
type DocumentData,
|
||||
type Field,
|
||||
@ -50,7 +50,7 @@ export type EmbedSignDocumentClientPageProps = {
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
completedFields: DocumentField[];
|
||||
metadata?: DocumentMeta | null;
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
isCompleted?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhitelabelling?: boolean;
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@ -6,9 +6,9 @@ import { RecipientRole } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
@ -20,6 +20,8 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||
|
||||
@ -51,6 +53,7 @@ export const DocumentSigningAuth2FA = ({
|
||||
}: DocumentSigningAuth2FAProps) => {
|
||||
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
|
||||
useRequiredDocumentSigningAuthContext();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<T2FAAuthFormSchema>({
|
||||
resolver: zodResolver(Z2FAAuthFormSchema),
|
||||
@ -60,27 +63,104 @@ export const DocumentSigningAuth2FA = ({
|
||||
});
|
||||
|
||||
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false);
|
||||
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||
const [isEmailCodeSent, setIsEmailCodeSent] = useState(false);
|
||||
const [isEmailCodeSending, setIsEmailCodeSending] = useState(false);
|
||||
const [canResendEmail, setCanResendEmail] = useState(true);
|
||||
const [resendCountdown, setResendCountdown] = useState(0);
|
||||
const countdownTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [verificationMethod, setVerificationMethod] = useState<'app' | 'email'>(
|
||||
user?.twoFactorEnabled ? 'app' : 'email',
|
||||
);
|
||||
const emailSendInitiatedRef = useRef(false);
|
||||
|
||||
const sendVerificationMutation = trpc.auth.sendEmailVerification.useMutation({
|
||||
onSuccess: () => {
|
||||
setIsEmailCodeSent(true);
|
||||
setCanResendEmail(false);
|
||||
setResendCountdown(60);
|
||||
|
||||
countdownTimerRef.current = setInterval(() => {
|
||||
setResendCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
if (countdownTimerRef.current) {
|
||||
clearInterval(countdownTimerRef.current);
|
||||
}
|
||||
setCanResendEmail(true);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
toast({
|
||||
title: 'Verification code sent',
|
||||
description: `A verification code has been sent to ${recipient.email}`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to send verification code', error);
|
||||
toast({
|
||||
title: 'Failed to send verification code',
|
||||
description: 'Please try again or contact support',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsEmailCodeSending(false);
|
||||
},
|
||||
});
|
||||
|
||||
const verifyCodeMutation = trpc.auth.verifyEmailCode.useMutation();
|
||||
|
||||
const sendEmailVerificationCode = async () => {
|
||||
try {
|
||||
setIsEmailCodeSending(true);
|
||||
await sendVerificationMutation.mutateAsync({
|
||||
recipientId: recipient.id,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to send verification code',
|
||||
description: 'Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (countdownTimerRef.current) {
|
||||
clearInterval(countdownTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
|
||||
try {
|
||||
setIsCurrentlyAuthenticating(true);
|
||||
|
||||
if (verificationMethod === 'email') {
|
||||
await verifyCodeMutation.mutateAsync({
|
||||
code: token,
|
||||
recipientId: recipient.id,
|
||||
});
|
||||
}
|
||||
|
||||
await onReauthFormSubmit({
|
||||
type: DocumentAuth.TWO_FACTOR_AUTH,
|
||||
token,
|
||||
});
|
||||
|
||||
setIsCurrentlyAuthenticating(false);
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setIsCurrentlyAuthenticating(false);
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
setFormErrorCode(error.code);
|
||||
|
||||
// Todo: Alert.
|
||||
toast({
|
||||
title: 'Unauthorized',
|
||||
description: 'We were unable to verify your details.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -90,21 +170,46 @@ export const DocumentSigningAuth2FA = ({
|
||||
});
|
||||
|
||||
setIs2FASetupSuccessful(false);
|
||||
setFormErrorCode(null);
|
||||
setIsEmailCodeSent(false);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
if (open && !user?.twoFactorEnabled) {
|
||||
setVerificationMethod('email');
|
||||
}
|
||||
}, [open, user?.twoFactorEnabled, form]);
|
||||
|
||||
if (!user?.twoFactorEnabled && !is2FASetupSuccessful) {
|
||||
useEffect(() => {
|
||||
if (!open || verificationMethod !== 'email') {
|
||||
emailSendInitiatedRef.current = false;
|
||||
}
|
||||
}, [open, verificationMethod]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && verificationMethod === 'email' && !isEmailCodeSent && !isEmailCodeSending) {
|
||||
if (!emailSendInitiatedRef.current) {
|
||||
emailSendInitiatedRef.current = true;
|
||||
void sendEmailVerificationCode();
|
||||
}
|
||||
}
|
||||
}, [open, verificationMethod, isEmailCodeSent, isEmailCodeSending]);
|
||||
|
||||
if (verificationMethod === 'app' && !user?.twoFactorEnabled && !is2FASetupSuccessful) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs
|
||||
value={verificationMethod}
|
||||
onValueChange={(val) => setVerificationMethod(val as 'app' | 'email')}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="app">Authenticator App</TabsTrigger>
|
||||
<TabsTrigger value="email">Email Verification</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? (
|
||||
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
|
||||
) : (
|
||||
// Todo: Translate
|
||||
`You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`
|
||||
)}
|
||||
</p>
|
||||
@ -129,59 +234,106 @@ export const DocumentSigningAuth2FA = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>2FA token</FormLabel>
|
||||
<div className="space-y-4">
|
||||
{user?.twoFactorEnabled && (
|
||||
<Tabs
|
||||
value={verificationMethod}
|
||||
onValueChange={(val) => setVerificationMethod(val as 'app' | 'email')}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="app">Authenticator App</TabsTrigger>
|
||||
<TabsTrigger value="email">Email Verification</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{formErrorCode && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>
|
||||
<Trans>Unauthorized</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
We were unable to verify your details. Please try again or contact support
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{verificationMethod === 'email' && (
|
||||
<Alert variant="secondary">
|
||||
<AlertDescription>
|
||||
{isEmailCodeSent ? (
|
||||
<p>
|
||||
<Trans>
|
||||
A verification code has been sent to {recipient.email}. Please enter it below to
|
||||
continue.
|
||||
</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<Trans>
|
||||
We'll send a verification code to {recipient.email} to verify your identity.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
{verificationMethod === 'app' ? (
|
||||
<Trans>2FA token</Trans>
|
||||
) : (
|
||||
<Trans>Verification code</Trans>
|
||||
)}
|
||||
</FormLabel>
|
||||
|
||||
<Button type="submit" loading={isCurrentlyAuthenticating}>
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
<FormControl>
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{verificationMethod === 'email' && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
disabled={isEmailCodeSending || !canResendEmail}
|
||||
onClick={() => void sendEmailVerificationCode()}
|
||||
>
|
||||
{isEmailCodeSending ? (
|
||||
<Trans>Sending...</Trans>
|
||||
) : !canResendEmail ? (
|
||||
<Trans>Resend code ({resendCountdown}s)</Trans>
|
||||
) : (
|
||||
<Trans>Resend code</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isCurrentlyAuthenticating}>
|
||||
<Trans>{actionTarget === 'DOCUMENT' ? 'Sign Document' : 'Sign Field'}</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -43,6 +43,7 @@ export const DocumentSigningAuthDialog = ({
|
||||
title,
|
||||
description,
|
||||
availableAuthTypes,
|
||||
actionTarget,
|
||||
open,
|
||||
onOpenChange,
|
||||
onReauthFormSubmit,
|
||||
@ -107,15 +108,32 @@ export const DocumentSigningAuthDialog = ({
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<span>{title || <Trans>Sign field</Trans>}</span>
|
||||
<span>
|
||||
{title ||
|
||||
(actionTarget === 'DOCUMENT' ? (
|
||||
<Trans>Sign document</Trans>
|
||||
) : (
|
||||
<Trans>Sign field</Trans>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(!selectedAuthType || validAuthTypes.length === 1) &&
|
||||
(title || <Trans>Sign field</Trans>)}
|
||||
(title ||
|
||||
(actionTarget === 'DOCUMENT' ? (
|
||||
<Trans>Sign document</Trans>
|
||||
) : (
|
||||
<Trans>Sign field</Trans>
|
||||
)))}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{description || <Trans>Reauthentication is required to sign this field</Trans>}
|
||||
{description || (
|
||||
<Trans>
|
||||
Reauthentication is required to sign this{' '}
|
||||
{actionTarget === 'DOCUMENT' ? 'document' : 'field'}
|
||||
</Trans>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@ -180,6 +198,7 @@ export const DocumentSigningAuthDialog = ({
|
||||
))
|
||||
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
|
||||
<DocumentSigningAuth2FA
|
||||
actionTarget={actionTarget === 'DOCUMENT' ? 'DOCUMENT' : 'FIELD'}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
|
||||
@ -42,6 +42,7 @@ export type DocumentSigningAuthContextValue = {
|
||||
setPreferredPasskeyId: (_value: string | null) => void;
|
||||
user?: SessionUser | null;
|
||||
refetchPasskeys: () => Promise<void>;
|
||||
isEnterprise: boolean;
|
||||
};
|
||||
|
||||
const DocumentSigningAuthContext = createContext<DocumentSigningAuthContextValue | null>(null);
|
||||
@ -65,6 +66,7 @@ export interface DocumentSigningAuthProviderProps {
|
||||
recipient: Recipient;
|
||||
user?: SessionUser | null;
|
||||
children: React.ReactNode;
|
||||
isEnterprise: boolean;
|
||||
}
|
||||
|
||||
export const DocumentSigningAuthProvider = ({
|
||||
@ -72,6 +74,7 @@ export const DocumentSigningAuthProvider = ({
|
||||
recipient: initialRecipient,
|
||||
user,
|
||||
children,
|
||||
isEnterprise,
|
||||
}: DocumentSigningAuthProviderProps) => {
|
||||
const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions);
|
||||
const [recipient, setRecipient] = useState(initialRecipient);
|
||||
@ -144,8 +147,13 @@ export const DocumentSigningAuthProvider = ({
|
||||
}, [derivedRecipientActionAuth, user, recipient]);
|
||||
|
||||
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
|
||||
// Directly run callback if no auth required.
|
||||
if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) {
|
||||
// Determine if authentication is required based on enterprise status and action target.
|
||||
const requiresAuthTrigger = isEnterprise
|
||||
? derivedRecipientActionAuth && options.actionTarget === FieldType.SIGNATURE
|
||||
: derivedRecipientActionAuth && options.actionTarget === 'DOCUMENT';
|
||||
|
||||
// Directly run callback if no auth trigger is needed.
|
||||
if (!requiresAuthTrigger) {
|
||||
await options.onReauthFormSubmit();
|
||||
return;
|
||||
}
|
||||
@ -205,6 +213,7 @@ export const DocumentSigningAuthProvider = ({
|
||||
preferredPasskeyId,
|
||||
setPreferredPasskeyId,
|
||||
refetchPasskeys,
|
||||
isEnterprise,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@ -225,6 +234,8 @@ export const DocumentSigningAuthProvider = ({
|
||||
type ExecuteActionAuthProcedureOptions = Omit<
|
||||
DocumentSigningAuthDialogProps,
|
||||
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' | 'availableAuthTypes'
|
||||
>;
|
||||
> & {
|
||||
actionTarget: FieldType | 'DOCUMENT';
|
||||
};
|
||||
|
||||
DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider';
|
||||
|
||||
@ -27,6 +27,7 @@ import {
|
||||
AssistantConfirmationDialog,
|
||||
type NextSigner,
|
||||
} from '../../dialogs/assistant-confirmation-dialog';
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||
|
||||
@ -38,6 +39,7 @@ export type DocumentSigningFormProps = {
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
setSelectedSignerId?: (id: number | null) => void;
|
||||
isEnterprise: boolean;
|
||||
};
|
||||
|
||||
export const DocumentSigningForm = ({
|
||||
@ -48,6 +50,7 @@ export const DocumentSigningForm = ({
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
setSelectedSignerId,
|
||||
isEnterprise,
|
||||
}: DocumentSigningFormProps) => {
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
@ -61,6 +64,7 @@ export const DocumentSigningForm = ({
|
||||
const assistantSignersId = useId();
|
||||
|
||||
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
||||
@ -113,11 +117,16 @@ export const DocumentSigningForm = ({
|
||||
setIsAssistantSubmitting(true);
|
||||
|
||||
try {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
await executeActionAuthProcedure({
|
||||
actionTarget: 'DOCUMENT',
|
||||
onReauthFormSubmit: async (authOptions) => {
|
||||
await completeDocument(authOptions, nextSigner);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while completing the document. Please try again.',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while completing the document. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@ -207,7 +216,12 @@ export const DocumentSigningForm = ({
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
await executeActionAuthProcedure({
|
||||
actionTarget: 'DOCUMENT',
|
||||
onReauthFormSubmit: async (authOptions) => {
|
||||
await completeDocument(authOptions, nextSigner);
|
||||
},
|
||||
});
|
||||
}}
|
||||
role={recipient.role}
|
||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||
@ -367,7 +381,12 @@ export const DocumentSigningForm = ({
|
||||
fieldsValidated={fieldsValidated}
|
||||
disabled={!isRecipientsTurn}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
await executeActionAuthProcedure({
|
||||
actionTarget: 'DOCUMENT',
|
||||
onReauthFormSubmit: async (authOptions) => {
|
||||
await completeDocument(authOptions, nextSigner);
|
||||
},
|
||||
});
|
||||
}}
|
||||
role={recipient.role}
|
||||
allowDictateNextSigner={
|
||||
|
||||
@ -50,6 +50,7 @@ export type DocumentSigningPageViewProps = {
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
includeSenderDetails: boolean;
|
||||
isEnterprise: boolean;
|
||||
};
|
||||
|
||||
export const DocumentSigningPageView = ({
|
||||
@ -60,6 +61,7 @@ export const DocumentSigningPageView = ({
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
includeSenderDetails,
|
||||
isEnterprise,
|
||||
}: DocumentSigningPageViewProps) => {
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
@ -208,6 +210,7 @@ export const DocumentSigningPageView = ({
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
setSelectedSignerId={setSelectedSignerId}
|
||||
isEnterprise={isEnterprise}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -157,7 +157,7 @@ export const TemplateEditForm = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while updating the document settings.`),
|
||||
description: _(msg`An error occurred while updating the template settings.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { Role, Subscription } from '@prisma/client';
|
||||
import type { Document, Role, Subscription } from '@prisma/client';
|
||||
import { Edit, Loader } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
@ -20,7 +20,7 @@ type UserData = {
|
||||
email: string;
|
||||
roles: Role[];
|
||||
subscriptions?: SubscriptionLite[] | null;
|
||||
documentCount: number;
|
||||
documents: DocumentLite[];
|
||||
};
|
||||
|
||||
type SubscriptionLite = Pick<
|
||||
@ -28,6 +28,8 @@ type SubscriptionLite = Pick<
|
||||
'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd'
|
||||
>;
|
||||
|
||||
type DocumentLite = Pick<Document, 'id'>;
|
||||
|
||||
type AdminDashboardUsersTableProps = {
|
||||
users: UserData[];
|
||||
totalPages: number;
|
||||
@ -72,7 +74,10 @@ export const AdminDashboardUsersTable = ({
|
||||
},
|
||||
{
|
||||
header: _(msg`Documents`),
|
||||
accessorKey: 'documentCount',
|
||||
accessorKey: 'documents',
|
||||
cell: ({ row }) => {
|
||||
return <div>{row.original.documents?.length}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
|
||||
@ -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,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>
|
||||
);
|
||||
}
|
||||
@ -9,10 +9,10 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
|
||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Templates');
|
||||
|
||||
@ -94,6 +94,7 @@ export default function DirectTemplatePage() {
|
||||
documentAuthOptions={template.authOptions}
|
||||
recipient={directTemplateRecipient}
|
||||
user={user}
|
||||
isEnterprise={false}
|
||||
>
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1
|
||||
|
||||
@ -19,6 +19,7 @@ import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get
|
||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { isUserEnterprise } from '@documenso/lib/server-only/user/is-user-enterprise';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
|
||||
@ -41,6 +42,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const isEnterprise = user?.id
|
||||
? await isUserEnterprise({ userId: user.id }).catch(() => false)
|
||||
: false;
|
||||
|
||||
const [document, recipient, fields, completedFields] = await Promise.all([
|
||||
getDocumentAndSenderByToken({
|
||||
token,
|
||||
@ -116,6 +121,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
isDocumentAccessValid: false,
|
||||
recipientEmail: recipient.email,
|
||||
recipientHasAccount,
|
||||
isEnterprise,
|
||||
} as const);
|
||||
}
|
||||
|
||||
@ -153,6 +159,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
recipientSignature,
|
||||
isRecipientsTurn,
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
isEnterprise,
|
||||
} as const);
|
||||
}
|
||||
|
||||
@ -181,6 +188,7 @@ export default function SigningPage() {
|
||||
allRecipients,
|
||||
includeSenderDetails,
|
||||
recipientWithFields,
|
||||
isEnterprise,
|
||||
} = data;
|
||||
|
||||
if (document.deletedAt || document.status === DocumentStatus.REJECTED) {
|
||||
@ -246,6 +254,7 @@ export default function SigningPage() {
|
||||
documentAuthOptions={document.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
isEnterprise={isEnterprise}
|
||||
>
|
||||
<DocumentSigningPageView
|
||||
recipient={recipientWithFields}
|
||||
@ -255,6 +264,7 @@ export default function SigningPage() {
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
includeSenderDetails={includeSenderDetails}
|
||||
isEnterprise={isEnterprise}
|
||||
/>
|
||||
</DocumentSigningAuthProvider>
|
||||
</DocumentSigningProvider>
|
||||
|
||||
@ -88,6 +88,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
|
||||
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
|
||||
|
||||
const isEnterpriseDocument = Boolean(organisationClaim);
|
||||
|
||||
return superLoaderJson({
|
||||
token,
|
||||
user,
|
||||
@ -96,12 +98,21 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
fields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
isEnterpriseDocument,
|
||||
});
|
||||
}
|
||||
|
||||
export default function EmbedDirectTemplatePage() {
|
||||
const { token, user, template, recipient, fields, hidePoweredBy, allowEmbedSigningWhitelabel } =
|
||||
useSuperLoaderData<typeof loader>();
|
||||
const {
|
||||
token,
|
||||
user,
|
||||
template,
|
||||
recipient,
|
||||
fields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
isEnterpriseDocument,
|
||||
} = useSuperLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<DocumentSigningProvider
|
||||
@ -116,6 +127,7 @@ export default function EmbedDirectTemplatePage() {
|
||||
documentAuthOptions={template.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
isEnterprise={isEnterpriseDocument}
|
||||
>
|
||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||
<EmbedDirectTemplateClientPage
|
||||
|
||||
@ -109,6 +109,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
})
|
||||
: [];
|
||||
|
||||
const isEnterpriseDocument = Boolean(organisationClaim);
|
||||
|
||||
return superLoaderJson({
|
||||
token,
|
||||
user,
|
||||
@ -119,6 +121,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
completedFields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
isEnterpriseDocument,
|
||||
});
|
||||
}
|
||||
|
||||
@ -133,6 +136,7 @@ export default function EmbedSignDocumentPage() {
|
||||
completedFields,
|
||||
hidePoweredBy,
|
||||
allowEmbedSigningWhitelabel,
|
||||
isEnterpriseDocument,
|
||||
} = useSuperLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
@ -148,6 +152,7 @@ export default function EmbedSignDocumentPage() {
|
||||
documentAuthOptions={document.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
isEnterprise={isEnterpriseDocument}
|
||||
>
|
||||
<EmbedSignDocumentClientPage
|
||||
token={token}
|
||||
|
||||
@ -48,6 +48,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
user,
|
||||
hidePoweredBy: false,
|
||||
allowWhitelabelling: false,
|
||||
isEnterprise: false,
|
||||
});
|
||||
}
|
||||
|
||||
@ -55,17 +56,19 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
|
||||
const allowWhitelabelling = organisationClaim.flags.embedSigningWhiteLabel;
|
||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||
const isEnterprise = Boolean(organisationClaim.flags.cfr21);
|
||||
|
||||
return superLoaderJson({
|
||||
envelopes,
|
||||
user,
|
||||
hidePoweredBy,
|
||||
allowWhitelabelling,
|
||||
isEnterprise,
|
||||
});
|
||||
}
|
||||
|
||||
export default function MultisignPage() {
|
||||
const { envelopes, user, hidePoweredBy, allowWhitelabelling } =
|
||||
const { envelopes, user, hidePoweredBy, allowWhitelabelling, isEnterprise } =
|
||||
useSuperLoaderData<typeof loader>();
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
@ -264,6 +267,7 @@ export default function MultisignPage() {
|
||||
documentAuthOptions={selectedDocument.authOptions}
|
||||
recipient={selectedRecipient}
|
||||
user={user}
|
||||
isEnterprise={isEnterprise}
|
||||
>
|
||||
<DocumentSigningRecipientProvider recipient={selectedRecipient} targetSigner={null}>
|
||||
<MultiSignDocumentSigningView
|
||||
|
||||
@ -101,5 +101,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "1.12.2-rc.4"
|
||||
"version": "1.12.2-rc.3"
|
||||
}
|
||||
|
||||
60
package-lock.json
generated
60
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.12.2-rc.4",
|
||||
"version": "1.12.2-rc.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.12.2-rc.4",
|
||||
"version": "1.12.2-rc.3",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@ -89,7 +89,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "1.12.2-rc.4",
|
||||
"version": "1.12.2-rc.3",
|
||||
"dependencies": {
|
||||
"@documenso/api": "*",
|
||||
"@documenso/assets": "*",
|
||||
@ -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,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.12.2-rc.4",
|
||||
"version": "1.12.2-rc.3",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
|
||||
@ -554,12 +554,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
status: 200,
|
||||
body: {
|
||||
...template,
|
||||
templateMeta: template.templateMeta
|
||||
? {
|
||||
...template.templateMeta,
|
||||
templateId: template.id,
|
||||
}
|
||||
: null,
|
||||
Field: template.fields.map((field) => ({
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : null,
|
||||
|
||||
@ -27,8 +27,8 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
|
||||
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
|
||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
|
||||
// Action auth should NOT be visible.
|
||||
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
|
||||
// Action auth should now be visible for all users
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
|
||||
|
||||
// Save the settings by going to the next step.
|
||||
|
||||
|
||||
@ -25,8 +25,8 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
|
||||
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
|
||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
|
||||
// Action auth should NOT be visible.
|
||||
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
|
||||
// Action auth should now be visible for all users
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
|
||||
|
||||
// Save the settings by going to the next step.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
@ -144,11 +144,10 @@ test('[TEMPLATES]: use template', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
|
||||
// Enter template values.
|
||||
// Get input with Email label placeholder.
|
||||
await page.getByLabel('Email').click();
|
||||
await page.getByLabel('Email').fill(teamMemberUser.email);
|
||||
await page.getByLabel('Name').click();
|
||||
await page.getByLabel('Name').fill('name');
|
||||
await page.getByPlaceholder('recipient.1@documenso.com').click();
|
||||
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
|
||||
await page.getByPlaceholder('Recipient 1').click();
|
||||
await page.getByPlaceholder('Recipient 1').fill('name');
|
||||
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
await page.waitForURL(/\/t\/.+\/documents/);
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export type TemplateVerificationCodeProps = {
|
||||
verificationCode: string;
|
||||
assetBaseUrl: string;
|
||||
};
|
||||
|
||||
export const TemplateVerificationCode = ({
|
||||
verificationCode,
|
||||
assetBaseUrl,
|
||||
}: TemplateVerificationCodeProps) => {
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
<Trans>Your verification code</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Trans>Please use the code below to verify your identity for document signing.</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-6 text-center text-3xl font-bold tracking-widest">
|
||||
{verificationCode}
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-sm text-slate-400">
|
||||
<Trans>
|
||||
If you did not request this code, you can ignore this email. The code will expire after
|
||||
10 minutes.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateVerificationCode;
|
||||
62
packages/email/templates/verification-code.tsx
Normal file
62
packages/email/templates/verification-code.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import type { TemplateVerificationCodeProps } from '../template-components/template-verification-code';
|
||||
import { TemplateVerificationCode } from '../template-components/template-verification-code';
|
||||
|
||||
export type VerificationCodeTemplateProps = Partial<TemplateVerificationCodeProps>;
|
||||
|
||||
export const VerificationCodeTemplate = ({
|
||||
verificationCode = '000000',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: VerificationCodeTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Your verification code for document signing`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateVerificationCode
|
||||
verificationCode={verificationCode}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationCodeTemplate;
|
||||
@ -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",
|
||||
|
||||
@ -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') ?? '',
|
||||
});
|
||||
120
packages/lib/server-only/2fa/send-email-verification.ts
Normal file
120
packages/lib/server-only/2fa/send-email-verification.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { randomInt } from 'crypto';
|
||||
|
||||
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { VerificationCodeTemplate } from '@documenso/email/templates/verification-code';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
|
||||
const ExtendedAuthErrorCode = {
|
||||
...AuthenticationErrorCode,
|
||||
InternalError: 'INTERNAL_ERROR',
|
||||
VerificationNotFound: 'VERIFICATION_NOT_FOUND',
|
||||
VerificationExpired: 'VERIFICATION_EXPIRED',
|
||||
};
|
||||
|
||||
const VERIFICATION_CODE_EXPIRY = 10 * 60 * 1000;
|
||||
|
||||
export type SendEmailVerificationOptions = {
|
||||
userId: number;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const sendEmailVerification = async ({ userId, email }: SendEmailVerificationOptions) => {
|
||||
try {
|
||||
const verificationCode = randomInt(100000, 1000000).toString();
|
||||
const i18n = await getI18nInstance();
|
||||
|
||||
await prisma.userTwoFactorEmailVerification.upsert({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
verificationCode,
|
||||
expiresAt: new Date(Date.now() + VERIFICATION_CODE_EXPIRY),
|
||||
},
|
||||
update: {
|
||||
verificationCode,
|
||||
expiresAt: new Date(Date.now() + VERIFICATION_CODE_EXPIRY),
|
||||
},
|
||||
});
|
||||
|
||||
const template = createElement(VerificationCodeTemplate, {
|
||||
verificationCode,
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: 'en' }),
|
||||
renderEmailWithI18N(template, { lang: 'en', plainText: true }),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: i18n._(msg`Your verification code for document signing`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error sending email verification', error);
|
||||
throw new AppError(ExtendedAuthErrorCode.InternalError);
|
||||
}
|
||||
};
|
||||
|
||||
export type VerifyEmailCodeOptions = {
|
||||
userId: number;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export const verifyEmailCode = async ({ userId, code }: VerifyEmailCodeOptions) => {
|
||||
try {
|
||||
const verification = await prisma.userTwoFactorEmailVerification.findUnique({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification) {
|
||||
throw new AppError(ExtendedAuthErrorCode.VerificationNotFound);
|
||||
}
|
||||
|
||||
if (verification.expiresAt < new Date()) {
|
||||
throw new AppError(ExtendedAuthErrorCode.VerificationExpired);
|
||||
}
|
||||
|
||||
if (verification.verificationCode !== code) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode);
|
||||
}
|
||||
|
||||
await prisma.userTwoFactorEmailVerification.delete({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error verifying email code', error);
|
||||
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(ExtendedAuthErrorCode.InternalError);
|
||||
}
|
||||
};
|
||||
@ -1,4 +1,4 @@
|
||||
import type { DocumentMeta, DocumentVisibility } from '@prisma/client';
|
||||
import type { DocumentVisibility, TemplateMeta } from '@prisma/client';
|
||||
import {
|
||||
DocumentSource,
|
||||
RecipientRole,
|
||||
@ -46,7 +46,7 @@ export type CreateDocumentOptions = {
|
||||
formValues?: TDocumentFormValues;
|
||||
recipients: TCreateDocumentV2Request['recipients'];
|
||||
};
|
||||
meta?: Partial<Omit<DocumentMeta, 'id' | 'templateId'>>;
|
||||
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
|
||||
@ -1,32 +1,39 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type GetDocumentByIdOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumentByIdOptions) => {
|
||||
const { documentWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
validatedUserId: userId,
|
||||
unvalidatedTeamId: teamId,
|
||||
export const getDocumentById = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
folderId,
|
||||
}: GetDocumentByIdOptions) => {
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: documentWhereInput,
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
...documentWhereInput,
|
||||
folderId,
|
||||
},
|
||||
include: {
|
||||
documents: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
user: {
|
||||
select: {
|
||||
@ -49,7 +56,7 @@ export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumen
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document could not be found',
|
||||
});
|
||||
@ -57,3 +64,93 @@ export const getDocumentById = async ({ documentId, userId, teamId }: GetDocumen
|
||||
|
||||
return document;
|
||||
};
|
||||
|
||||
export type GetDocumentWhereInputOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the where input for a given Prisma document query.
|
||||
*
|
||||
* This will return a query that allows a user to get a document if they have valid access to it.
|
||||
*/
|
||||
export const getDocumentWhereInput = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
}: GetDocumentWhereInputOptions) => {
|
||||
const team = await getTeamById({ teamId, userId });
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const teamVisibilityFilters = match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
])
|
||||
.with(TeamMemberRole.MANAGER, () => [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
])
|
||||
.otherwise(() => [DocumentVisibility.EVERYONE]);
|
||||
|
||||
const documentOrInput: Prisma.DocumentWhereInput[] = [
|
||||
// Allow access if they own the document.
|
||||
{
|
||||
userId,
|
||||
},
|
||||
// Or, if they belong to the team that the document is associated with.
|
||||
{
|
||||
visibility: {
|
||||
in: teamVisibilityFilters,
|
||||
},
|
||||
teamId,
|
||||
},
|
||||
// Or, if they are a recipient of the document.
|
||||
{
|
||||
status: {
|
||||
not: DocumentStatus.DRAFT,
|
||||
},
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Allow access to documents sent to or from the team email.
|
||||
if (team.teamEmail) {
|
||||
documentOrInput.push(
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
user: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const documentWhereInput: Prisma.DocumentWhereUniqueInput = {
|
||||
id: documentId,
|
||||
OR: documentOrInput,
|
||||
};
|
||||
|
||||
return {
|
||||
documentWhereInput,
|
||||
team,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,5 +1,4 @@
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@ -119,13 +118,6 @@ export const updateDocument = async ({
|
||||
const newGlobalActionAuth =
|
||||
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (newGlobalActionAuth.length > 0 && !document.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
|
||||
const isTitleSame = data.title === undefined || data.title === document.title;
|
||||
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
|
||||
const isGlobalAccessSame =
|
||||
|
||||
@ -1,157 +0,0 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { buildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type GetEnvelopeByIdOptions = {
|
||||
id: EnvelopeIdOptions;
|
||||
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const getEnvelopeById = async ({ id, userId, teamId }: GetEnvelopeByIdOptions) => {
|
||||
const { documentWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
validatedUserId: userId,
|
||||
unvalidatedTeamId: teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.envelope.findFirst({
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
documents: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
recipients: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document could not be found',
|
||||
});
|
||||
}
|
||||
|
||||
return document;
|
||||
};
|
||||
|
||||
export type GetEnvelopeWhereInputOptions = {
|
||||
id: EnvelopeIdOptions;
|
||||
|
||||
/**
|
||||
* The user ID who has been authenticated.
|
||||
*/
|
||||
validatedUserId: number;
|
||||
|
||||
/**
|
||||
* The unknown teamId from the request.
|
||||
*/
|
||||
unvalidatedTeamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the where input for a given Prisma envelope query.
|
||||
*
|
||||
* This will return a query that allows a user to get a document if they have valid access to it.
|
||||
*/
|
||||
export const getEnvelopeWhereInput = async ({
|
||||
id,
|
||||
validatedUserId,
|
||||
unvalidatedTeamId,
|
||||
}: GetEnvelopeWhereInputOptions) => {
|
||||
const team = await getTeamById({ teamId: unvalidatedTeamId, userId: validatedUserId });
|
||||
|
||||
const teamVisibilityFilters = match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
])
|
||||
.with(TeamMemberRole.MANAGER, () => [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
])
|
||||
.otherwise(() => [DocumentVisibility.EVERYONE]);
|
||||
|
||||
const documentOrInput: Prisma.EnvelopeWhereInput[] = [
|
||||
// Allow access if they own the document.
|
||||
{
|
||||
userId: validatedUserId,
|
||||
},
|
||||
// Or, if they belong to the team that the document is associated with.
|
||||
{
|
||||
visibility: {
|
||||
in: teamVisibilityFilters,
|
||||
},
|
||||
teamId: team.id,
|
||||
},
|
||||
// Or, if they are a recipient of the document.
|
||||
// ????????????? should recipients be able to do X?
|
||||
// {
|
||||
// status: {
|
||||
// not: DocumentStatus.DRAFT,
|
||||
// },
|
||||
// recipients: {
|
||||
// some: {
|
||||
// email: user.email,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
];
|
||||
|
||||
// Allow access to documents sent to or from the team email.
|
||||
if (team.teamEmail) {
|
||||
documentOrInput.push(
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
user: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const documentWhereInput: Prisma.EnvelopeWhereUniqueInput = {
|
||||
...buildEnvelopeIdQuery(id),
|
||||
OR: documentOrInput,
|
||||
};
|
||||
|
||||
return {
|
||||
documentWhereInput,
|
||||
team,
|
||||
};
|
||||
};
|
||||
@ -15,7 +15,7 @@ import { AUTO_SIGNABLE_FIELD_TYPES } from '../../constants/autosign';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import type { TRecipientActionAuth, TRecipientActionAuthTypes } from '../../types/document-auth';
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
@ -25,7 +25,9 @@ import {
|
||||
} from '../../types/field-meta';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||
import { isUserEnterprise } from '../user/is-user-enterprise';
|
||||
|
||||
export type SignFieldWithTokenOptions = {
|
||||
token: string;
|
||||
@ -171,13 +173,25 @@ export const signFieldWithToken = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const derivedRecipientActionAuth = await validateFieldAuth({
|
||||
documentAuthOptions: document.authOptions,
|
||||
recipient,
|
||||
field,
|
||||
userId,
|
||||
authOptions,
|
||||
});
|
||||
const isEnterprise = userId ? await isUserEnterprise({ userId }) : false;
|
||||
let requiredAuthType: TRecipientActionAuthTypes | null = null;
|
||||
|
||||
if (isEnterprise) {
|
||||
const authType = await validateFieldAuth({
|
||||
documentAuthOptions: document.authOptions,
|
||||
recipient,
|
||||
field,
|
||||
userId,
|
||||
authOptions,
|
||||
});
|
||||
requiredAuthType = authType ?? null;
|
||||
} else {
|
||||
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
requiredAuthType = derivedRecipientActionAuth.length > 0 ? derivedRecipientActionAuth[0] : null;
|
||||
}
|
||||
|
||||
const documentMeta = await prisma.documentMeta.findFirst({
|
||||
where: {
|
||||
@ -311,9 +325,9 @@ export const signFieldWithToken = async ({
|
||||
}),
|
||||
)
|
||||
.exhaustive(),
|
||||
fieldSecurity: derivedRecipientActionAuth
|
||||
fieldSecurity: requiredAuthType
|
||||
? {
|
||||
type: derivedRecipientActionAuth,
|
||||
type: requiredAuthType,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
|
||||
@ -37,3 +37,23 @@ export const getOrganisationClaimByTeamId = async ({ teamId }: { teamId: number
|
||||
|
||||
return organisationClaim;
|
||||
};
|
||||
|
||||
export const getOrganisationClaimByUserId = async ({ userId }: { userId: number }) => {
|
||||
const organisationClaim = await prisma.organisationClaim.findFirst({
|
||||
where: {
|
||||
organisation: {
|
||||
members: {
|
||||
some: {
|
||||
userId: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisationClaim) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
return organisationClaim;
|
||||
};
|
||||
|
||||
@ -134,9 +134,6 @@ export const setDocumentRecipients = async ({
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
);
|
||||
|
||||
const canPersistedRecipientBeModified =
|
||||
existing && canRecipientBeModified(existing, document.fields);
|
||||
|
||||
if (
|
||||
existing &&
|
||||
hasRecipientBeenChanged(existing, recipient) &&
|
||||
@ -150,7 +147,6 @@ export const setDocumentRecipients = async ({
|
||||
return {
|
||||
...recipient,
|
||||
_persisted: existing,
|
||||
canPersistedRecipientBeModified,
|
||||
};
|
||||
});
|
||||
|
||||
@ -166,13 +162,6 @@ export const setDocumentRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (recipient._persisted && !recipient.canPersistedRecipientBeModified) {
|
||||
return {
|
||||
...recipient._persisted,
|
||||
clientId: recipient.clientId,
|
||||
};
|
||||
}
|
||||
|
||||
const upsertedRecipient = await tx.recipient.upsert({
|
||||
where: {
|
||||
id: recipient._persisted?.id ?? -1,
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
@ -102,7 +104,10 @@ export const updateDocumentRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (!canRecipientBeModified(originalRecipient, document.fields)) {
|
||||
if (
|
||||
hasRecipientBeenChanged(originalRecipient, recipient) &&
|
||||
!canRecipientBeModified(originalRecipient, document.fields)
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot modify a recipient who has already interacted with the document',
|
||||
});
|
||||
@ -198,6 +203,9 @@ export const updateDocumentRecipients = async ({
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* If you change this you MUST update the `hasRecipientBeenChanged` function.
|
||||
*/
|
||||
type RecipientData = {
|
||||
id: number;
|
||||
email?: string;
|
||||
@ -207,3 +215,19 @@ type RecipientData = {
|
||||
accessAuth?: TRecipientAccessAuthTypes[];
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
};
|
||||
|
||||
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
|
||||
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
||||
|
||||
const newRecipientAccessAuth = newRecipientData.accessAuth || null;
|
||||
const newRecipientActionAuth = newRecipientData.actionAuth || null;
|
||||
|
||||
return (
|
||||
recipient.email !== newRecipientData.email ||
|
||||
recipient.name !== newRecipientData.name ||
|
||||
recipient.role !== newRecipientData.role ||
|
||||
recipient.signingOrder !== newRecipientData.signingOrder ||
|
||||
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
|
||||
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { DocumentMeta, DocumentVisibility, Template } from '@prisma/client';
|
||||
import type { DocumentVisibility, Template, TemplateMeta } from '@prisma/client';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@ -26,7 +26,7 @@ export type CreateTemplateOptions = {
|
||||
publicDescription?: string;
|
||||
type?: Template['type'];
|
||||
};
|
||||
meta?: Partial<Omit<DocumentMeta, 'id' | 'templateId'>>;
|
||||
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
|
||||
};
|
||||
|
||||
export const ZCreateTemplateResponseSchema = TemplateSchema;
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import type { DocumentMeta, DocumentVisibility, Template } from '@prisma/client';
|
||||
import type { DocumentVisibility, Template, TemplateMeta } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||
import { DocumentAuth } from '../../types/document-auth';
|
||||
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { isUserEnterprise } from '../user/is-user-enterprise';
|
||||
|
||||
export type UpdateTemplateOptions = {
|
||||
userId: number;
|
||||
@ -22,7 +24,7 @@ export type UpdateTemplateOptions = {
|
||||
type?: Template['type'];
|
||||
useLegacyFieldInsertion?: boolean;
|
||||
};
|
||||
meta?: Partial<Omit<DocumentMeta, 'id' | 'templateId'>>;
|
||||
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
|
||||
};
|
||||
|
||||
export const updateTemplate = async ({
|
||||
@ -76,10 +78,21 @@ export const updateTemplate = async ({
|
||||
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (newGlobalActionAuth.length > 0 && !template.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
// Only ACCOUNT and PASSKEY require enterprise permissions
|
||||
if (
|
||||
newGlobalActionAuth &&
|
||||
(newGlobalActionAuth.includes(DocumentAuth.ACCOUNT) ||
|
||||
newGlobalActionAuth.includes(DocumentAuth.PASSKEY))
|
||||
) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set this action auth type',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const authOptions = createDocumentAuthOptions({
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@ -25,10 +25,9 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
||||
const serviceAccount = await deletedAccountServiceAccount();
|
||||
|
||||
// TODO: Send out cancellations for all pending docs
|
||||
await prisma.envelope.updateMany({
|
||||
await prisma.document.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: {
|
||||
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
|
||||
},
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { EnvelopeType, Prisma } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@ -34,20 +34,12 @@ export const findUsers = async ({
|
||||
|
||||
const [users, count] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
select: {
|
||||
_count: {
|
||||
include: {
|
||||
documents: {
|
||||
select: {
|
||||
envelopes: {
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
roles: true,
|
||||
},
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
@ -59,10 +51,7 @@ export const findUsers = async ({
|
||||
]);
|
||||
|
||||
return {
|
||||
users: users.map((user) => ({
|
||||
...user,
|
||||
documentCount: user._count.envelopes,
|
||||
})),
|
||||
users,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
};
|
||||
};
|
||||
|
||||
14
packages/lib/server-only/user/is-user-enterprise.ts
Normal file
14
packages/lib/server-only/user/is-user-enterprise.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { getOrganisationClaimByUserId } from '../organisation/get-organisation-claims';
|
||||
|
||||
/**
|
||||
* Check if a user has enterprise features enabled (cfr21 flag).
|
||||
*/
|
||||
export const isUserEnterprise = async ({ userId }: { userId: number }): Promise<boolean> => {
|
||||
try {
|
||||
const organisationClaim = await getOrganisationClaimByUserId({ userId });
|
||||
return Boolean(organisationClaim.flags.cfr21);
|
||||
} catch {
|
||||
// If we can't find the organisation claim, assume non-enterprise
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -82,6 +82,16 @@ export const ZDocumentActionAuthTypesSchema = z
|
||||
'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.',
|
||||
);
|
||||
|
||||
/**
|
||||
* The non-enterprise document action auth methods.
|
||||
*
|
||||
* Only includes options available to non-enterprise users.
|
||||
*/
|
||||
export const ZNonEnterpriseDocumentActionAuthTypesSchema = z.enum([
|
||||
DocumentAuth.TWO_FACTOR_AUTH,
|
||||
DocumentAuth.EXPLICIT_NONE,
|
||||
]);
|
||||
|
||||
/**
|
||||
* The recipient access auth methods.
|
||||
*
|
||||
@ -118,6 +128,7 @@ export const ZRecipientActionAuthTypesSchema = z
|
||||
|
||||
export const DocumentAccessAuth = ZDocumentAccessAuthTypesSchema.Enum;
|
||||
export const DocumentActionAuth = ZDocumentActionAuthTypesSchema.Enum;
|
||||
export const NonEnterpriseDocumentActionAuth = ZNonEnterpriseDocumentActionAuthTypesSchema.Enum;
|
||||
export const RecipientAccessAuth = ZRecipientAccessAuthTypesSchema.Enum;
|
||||
export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum;
|
||||
|
||||
@ -201,6 +212,9 @@ export type TDocumentAccessAuth = z.infer<typeof ZDocumentAccessAuthSchema>;
|
||||
export type TDocumentAccessAuthTypes = z.infer<typeof ZDocumentAccessAuthTypesSchema>;
|
||||
export type TDocumentActionAuth = z.infer<typeof ZDocumentActionAuthSchema>;
|
||||
export type TDocumentActionAuthTypes = z.infer<typeof ZDocumentActionAuthTypesSchema>;
|
||||
export type TNonEnterpriseDocumentActionAuthTypes = z.infer<
|
||||
typeof ZNonEnterpriseDocumentActionAuthTypesSchema
|
||||
>;
|
||||
export type TRecipientAccessAuth = z.infer<typeof ZRecipientAccessAuthSchema>;
|
||||
export type TRecipientAccessAuthTypes = z.infer<typeof ZRecipientAccessAuthTypesSchema>;
|
||||
export type TRecipientActionAuth = z.infer<typeof ZRecipientActionAuthSchema>;
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
||||
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
||||
import { DocumentSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentSchema';
|
||||
import { FolderSchema } from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
|
||||
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
||||
import { LegacyDocumentSchema } from '@documenso/prisma/types/document-legacy-schema';
|
||||
|
||||
import { ZFieldSchema } from './field';
|
||||
import { ZRecipientLiteSchema } from './recipient';
|
||||
@ -15,7 +15,7 @@ import { ZRecipientLiteSchema } from './recipient';
|
||||
*
|
||||
* Mainly used for returning a single document from the API.
|
||||
*/
|
||||
export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
||||
export const ZDocumentSchema = DocumentSchema.pick({
|
||||
visibility: true,
|
||||
status: true,
|
||||
source: true,
|
||||
@ -31,12 +31,9 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
||||
completedAt: true,
|
||||
deletedAt: true,
|
||||
teamId: true,
|
||||
templateId: true,
|
||||
folderId: true,
|
||||
}).extend({
|
||||
// Which "Template" the document was created from. Legacy field for backwards compatibility.
|
||||
// The actual field is now called `createdFromDocumentId`.
|
||||
templateId: z.number().optional(),
|
||||
|
||||
// Todo: Maybe we want to alter this a bit since this returns a lot of data.
|
||||
documentData: DocumentDataSchema.pick({
|
||||
type: true,
|
||||
@ -85,7 +82,7 @@ export type TDocument = z.infer<typeof ZDocumentSchema>;
|
||||
/**
|
||||
* A lite version of the document response schema without relations.
|
||||
*/
|
||||
export const ZDocumentLiteSchema = LegacyDocumentSchema.pick({
|
||||
export const ZDocumentLiteSchema = DocumentSchema.pick({
|
||||
visibility: true,
|
||||
status: true,
|
||||
source: true,
|
||||
@ -101,12 +98,9 @@ export const ZDocumentLiteSchema = LegacyDocumentSchema.pick({
|
||||
completedAt: true,
|
||||
deletedAt: true,
|
||||
teamId: true,
|
||||
templateId: true,
|
||||
folderId: true,
|
||||
useLegacyFieldInsertion: true,
|
||||
}).extend({
|
||||
// Which "Template" the document was created from. Legacy field for backwards compatibility.
|
||||
// The actual field is now called `createdFromDocumentId`.
|
||||
templateId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TDocumentLite = z.infer<typeof ZDocumentLiteSchema>;
|
||||
@ -114,7 +108,7 @@ export type TDocumentLite = z.infer<typeof ZDocumentLiteSchema>;
|
||||
/**
|
||||
* A version of the document response schema when returning multiple documents at once from a single API endpoint.
|
||||
*/
|
||||
export const ZDocumentManySchema = LegacyDocumentSchema.pick({
|
||||
export const ZDocumentManySchema = DocumentSchema.pick({
|
||||
visibility: true,
|
||||
status: true,
|
||||
source: true,
|
||||
@ -130,13 +124,10 @@ export const ZDocumentManySchema = LegacyDocumentSchema.pick({
|
||||
completedAt: true,
|
||||
deletedAt: true,
|
||||
teamId: true,
|
||||
templateId: true,
|
||||
folderId: true,
|
||||
useLegacyFieldInsertion: true,
|
||||
}).extend({
|
||||
// Which "Template" the document was created from. Legacy field for backwards compatibility.
|
||||
// The actual field is now called `createdFromDocumentId`.
|
||||
templateId: z.number().optional(),
|
||||
|
||||
user: UserSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
|
||||
@ -18,6 +18,8 @@ export const ZFieldSchema = FieldSchema.pick({
|
||||
type: true,
|
||||
id: true,
|
||||
secondaryId: true,
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
recipientId: true,
|
||||
page: true,
|
||||
positionX: true,
|
||||
@ -27,10 +29,6 @@ export const ZFieldSchema = FieldSchema.pick({
|
||||
customText: true,
|
||||
inserted: true,
|
||||
fieldMeta: true,
|
||||
}).extend({
|
||||
// Todo: Decide whether to make these two IDs backwards compatible.
|
||||
documentId: z.number().optional(),
|
||||
templateId: z.number().optional(),
|
||||
});
|
||||
|
||||
export const ZFieldPageNumberSchema = z
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema';
|
||||
import { TeamSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
||||
@ -17,6 +15,8 @@ export const ZRecipientSchema = RecipientSchema.pick({
|
||||
signingStatus: true,
|
||||
sendStatus: true,
|
||||
id: true,
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
token: true,
|
||||
@ -28,10 +28,6 @@ export const ZRecipientSchema = RecipientSchema.pick({
|
||||
rejectionReason: true,
|
||||
}).extend({
|
||||
fields: ZFieldSchema.array(),
|
||||
|
||||
// Todo: Decide whether to make these two IDs backwards compatible.
|
||||
documentId: z.number().optional(),
|
||||
templateId: z.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@ -43,6 +39,8 @@ export const ZRecipientLiteSchema = RecipientSchema.pick({
|
||||
signingStatus: true,
|
||||
sendStatus: true,
|
||||
id: true,
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
token: true,
|
||||
@ -52,10 +50,6 @@ export const ZRecipientLiteSchema = RecipientSchema.pick({
|
||||
authOptions: true,
|
||||
signingOrder: true,
|
||||
rejectionReason: true,
|
||||
}).extend({
|
||||
// Todo: Decide whether to make these two IDs backwards compatible.
|
||||
documentId: z.number().optional(),
|
||||
templateId: z.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@ -67,6 +61,8 @@ export const ZRecipientManySchema = RecipientSchema.pick({
|
||||
signingStatus: true,
|
||||
sendStatus: true,
|
||||
id: true,
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
token: true,
|
||||
@ -87,8 +83,4 @@ export const ZRecipientManySchema = RecipientSchema.pick({
|
||||
id: true,
|
||||
url: true,
|
||||
}).nullable(),
|
||||
|
||||
// Todo: Decide whether to make these two IDs backwards compatible.
|
||||
documentId: z.number().optional(),
|
||||
templateId: z.number().optional(),
|
||||
});
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
||||
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
||||
import { FolderSchema } from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
|
||||
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateDirectLinkSchema';
|
||||
import { TemplateMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateMetaSchema';
|
||||
import { TemplateSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateSchema';
|
||||
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
||||
import { TemplateSchema } from '@documenso/prisma/types/template-legacy-schema';
|
||||
|
||||
import { ZFieldSchema } from './field';
|
||||
import { ZRecipientLiteSchema } from './recipient';
|
||||
@ -39,7 +39,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
|
||||
data: true,
|
||||
initialData: true,
|
||||
}),
|
||||
templateMeta: DocumentMetaSchema.pick({
|
||||
templateMeta: TemplateMetaSchema.pick({
|
||||
id: true,
|
||||
subject: true,
|
||||
message: true,
|
||||
@ -51,17 +51,13 @@ export const ZTemplateSchema = TemplateSchema.pick({
|
||||
drawSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
distributionMethod: true,
|
||||
templateId: true,
|
||||
redirectUrl: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
emailId: true,
|
||||
emailReplyTo: true,
|
||||
})
|
||||
.extend({
|
||||
// Legacy field for backwards compatibility. Needs to refer to the Envelope `secondaryTemplateId`.
|
||||
templateId: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
}).nullable(),
|
||||
directLink: TemplateDirectLinkSchema.nullable(),
|
||||
user: UserSchema.pick({
|
||||
id: true,
|
||||
@ -133,7 +129,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({
|
||||
}).nullable(),
|
||||
fields: ZFieldSchema.array(),
|
||||
recipients: ZRecipientLiteSchema.array(),
|
||||
templateMeta: DocumentMetaSchema.pick({
|
||||
templateMeta: TemplateMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
}).nullable(),
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import type { DocumentMeta, Envelope, OrganisationGlobalSettings } from '@prisma/client';
|
||||
import type {
|
||||
Document,
|
||||
DocumentMeta,
|
||||
OrganisationGlobalSettings,
|
||||
TemplateMeta,
|
||||
} from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentSigningOrder, DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../constants/time-zones';
|
||||
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
|
||||
|
||||
export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | DocumentStatus) => {
|
||||
export const isDocumentCompleted = (document: Pick<Document, 'status'> | DocumentStatus) => {
|
||||
const status = typeof document === 'string' ? document : document.status;
|
||||
|
||||
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
|
||||
@ -24,7 +29,7 @@ export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | Documen
|
||||
*/
|
||||
export const extractDerivedDocumentMeta = (
|
||||
settings: Omit<OrganisationGlobalSettings, 'id'>,
|
||||
overrideMeta: Partial<DocumentMeta> | undefined | null,
|
||||
overrideMeta: Partial<DocumentMeta | TemplateMeta> | undefined | null,
|
||||
) => {
|
||||
const meta = overrideMeta ?? {};
|
||||
|
||||
@ -53,5 +58,5 @@ export const extractDerivedDocumentMeta = (
|
||||
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
|
||||
emailSettings:
|
||||
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||
} satisfies Omit<DocumentMeta, 'id' | 'envelopeId'>;
|
||||
} satisfies Omit<DocumentMeta, 'id' | 'documentId'>;
|
||||
};
|
||||
|
||||
@ -1,92 +0,0 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
|
||||
const envelopeDocumentPrefixId = 'document';
|
||||
const envelopeTemplatePrefixId = 'template';
|
||||
const envelopePrefixId = 'envelope';
|
||||
|
||||
const ZDocumentIdSchema = z.string().regex(/^document_\d+$/);
|
||||
const ZTemplateIdSchema = z.string().regex(/^template_\d+$/);
|
||||
const ZEnvelopeIdSchema = z.string().regex(/^envelope_\d+$/);
|
||||
|
||||
export type EnvelopeIdOptions =
|
||||
| {
|
||||
type: 'envelopeId';
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: 'documentId';
|
||||
id: string | number;
|
||||
}
|
||||
| {
|
||||
type: 'templateId';
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses an unknown document or template ID.
|
||||
*
|
||||
* @param id
|
||||
* @param type
|
||||
* @returns
|
||||
*/
|
||||
export const buildEnvelopeIdQuery = (options: EnvelopeIdOptions) => {
|
||||
return match(options)
|
||||
.with({ type: 'envelopeId' }, (value) => {
|
||||
const parsed = ZEnvelopeIdSchema.safeParse(value.id);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid envelope ID',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
};
|
||||
})
|
||||
.with({ type: 'documentId' }, (value) => ({
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
secondaryId: parseDocumentIdToEnvelopeSecondaryId(value.id),
|
||||
}))
|
||||
.with({ type: 'templateId' }, (value) => ({
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
secondaryId: parseTemplateIdToEnvelopeSecondaryId(value.id),
|
||||
}))
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
export const parseDocumentIdToEnvelopeSecondaryId = (documentId: string | number) => {
|
||||
if (typeof documentId === 'number') {
|
||||
return `${envelopeDocumentPrefixId}_${documentId}`;
|
||||
}
|
||||
|
||||
const parsed = ZDocumentIdSchema.safeParse(documentId);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid document ID',
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
export const parseTemplateIdToEnvelopeSecondaryId = (templateId: string | number) => {
|
||||
if (typeof templateId === 'number') {
|
||||
return `${envelopeTemplatePrefixId}_${templateId}`;
|
||||
}
|
||||
|
||||
const parsed = ZTemplateIdSchema.safeParse(templateId);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Invalid template ID',
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
};
|
||||
@ -1,4 +1,4 @@
|
||||
import { type Recipient } from '@prisma/client';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserTwoFactorEmailVerification" (
|
||||
"userId" INTEGER NOT NULL,
|
||||
"verificationCode" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "UserTwoFactorEmailVerification_pkey" PRIMARY KEY ("userId")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserTwoFactorEmailVerification" ADD CONSTRAINT "UserTwoFactorEmailVerification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -1,57 +0,0 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TemplateMeta" DROP CONSTRAINT "TemplateMeta_templateId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentMeta" ADD COLUMN "templateId" INTEGER,
|
||||
ALTER COLUMN "documentId" DROP NOT NULL;
|
||||
|
||||
-- [CUSTOM_CHANGE] Migrate existing TemplateMeta to DocumentMeta
|
||||
INSERT INTO "DocumentMeta" (
|
||||
"id",
|
||||
"subject",
|
||||
"message",
|
||||
"timezone",
|
||||
"password",
|
||||
"dateFormat",
|
||||
"redirectUrl",
|
||||
"signingOrder",
|
||||
"allowDictateNextSigner",
|
||||
"typedSignatureEnabled",
|
||||
"uploadSignatureEnabled",
|
||||
"drawSignatureEnabled",
|
||||
"language",
|
||||
"distributionMethod",
|
||||
"emailSettings",
|
||||
"emailReplyTo",
|
||||
"emailId",
|
||||
"templateId"
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid()::text, -- Generate new CUID-like IDs to avoid collisions
|
||||
"subject",
|
||||
"message",
|
||||
"timezone",
|
||||
"password",
|
||||
"dateFormat",
|
||||
"redirectUrl",
|
||||
"signingOrder",
|
||||
"allowDictateNextSigner",
|
||||
"typedSignatureEnabled",
|
||||
"uploadSignatureEnabled",
|
||||
"drawSignatureEnabled",
|
||||
"language",
|
||||
"distributionMethod",
|
||||
"emailSettings",
|
||||
"emailReplyTo",
|
||||
"emailId",
|
||||
"templateId"
|
||||
FROM "TemplateMeta";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "TemplateMeta";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DocumentMeta_templateId_key" ON "DocumentMeta"("templateId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentMeta" ADD CONSTRAINT "DocumentMeta_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -1,261 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `authOptions` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `completedAt` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `createdAt` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `deletedAt` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `externalId` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `folderId` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `formValues` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `source` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `status` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `teamId` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `templateId` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `updatedAt` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `useLegacyFieldInsertion` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `userId` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `visibility` on the `Document` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `documentId` on the `DocumentAuditLog` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `documentId` on the `DocumentMeta` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `templateId` on the `DocumentMeta` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `documentId` on the `DocumentShareLink` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `documentId` on the `Field` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `templateId` on the `Field` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `documentId` on the `Recipient` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `templateId` on the `Recipient` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `templateId` on the `TemplateDirectLink` table. All the data in the column will be lost.
|
||||
- You are about to drop the `Template` table. If the table is not empty, all the data it contains will be lost.
|
||||
- A unique constraint covering the columns `[envelopeId,email]` on the table `DocumentShareLink` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[envelopeId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[envelopeId]` on the table `TemplateDirectLink` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `envelopeId` to the `DocumentAuditLog` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `envelopeId` to the `DocumentShareLink` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `envelopeId` to the `Field` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `envelopeId` to the `Recipient` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `envelopeId` to the `TemplateDirectLink` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `type` to the `TemplateDirectLink` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EnvelopeType" AS ENUM ('DOCUMENT', 'TEMPLATE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TemplateDirectLinkType" AS ENUM ('PUBLIC', 'PRIVATE');
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Document" DROP CONSTRAINT "Document_folderId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Document" DROP CONSTRAINT "Document_teamId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Document" DROP CONSTRAINT "Document_templateId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Document" DROP CONSTRAINT "Document_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "DocumentAuditLog" DROP CONSTRAINT "DocumentAuditLog_documentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "DocumentMeta" DROP CONSTRAINT "DocumentMeta_documentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "DocumentMeta" DROP CONSTRAINT "DocumentMeta_templateId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "DocumentShareLink" DROP CONSTRAINT "DocumentShareLink_documentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Field" DROP CONSTRAINT "Field_documentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Field" DROP CONSTRAINT "Field_templateId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Recipient" DROP CONSTRAINT "Recipient_documentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Recipient" DROP CONSTRAINT "Recipient_templateId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Template" DROP CONSTRAINT "Template_folderId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Template" DROP CONSTRAINT "Template_teamId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Template" DROP CONSTRAINT "Template_templateDocumentDataId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Template" DROP CONSTRAINT "Template_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TemplateDirectLink" DROP CONSTRAINT "TemplateDirectLink_templateId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Document_folderId_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Document_status_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Document_userId_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "DocumentMeta_documentId_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "DocumentMeta_templateId_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "DocumentShareLink_documentId_email_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Field_documentId_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Field_templateId_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_documentId_email_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_documentId_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_templateId_email_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Recipient_templateId_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "TemplateDirectLink_templateId_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Document" DROP COLUMN "authOptions",
|
||||
DROP COLUMN "completedAt",
|
||||
DROP COLUMN "createdAt",
|
||||
DROP COLUMN "deletedAt",
|
||||
DROP COLUMN "externalId",
|
||||
DROP COLUMN "folderId",
|
||||
DROP COLUMN "formValues",
|
||||
DROP COLUMN "source",
|
||||
DROP COLUMN "status",
|
||||
DROP COLUMN "teamId",
|
||||
DROP COLUMN "templateId",
|
||||
DROP COLUMN "updatedAt",
|
||||
DROP COLUMN "useLegacyFieldInsertion",
|
||||
DROP COLUMN "userId",
|
||||
DROP COLUMN "visibility";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentAuditLog" DROP COLUMN "documentId",
|
||||
ADD COLUMN "envelopeId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentMeta" DROP COLUMN "documentId",
|
||||
DROP COLUMN "templateId",
|
||||
ADD COLUMN "envelopeId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentShareLink" DROP COLUMN "documentId",
|
||||
ADD COLUMN "envelopeId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Field" DROP COLUMN "documentId",
|
||||
DROP COLUMN "templateId",
|
||||
ADD COLUMN "envelopeId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Recipient" DROP COLUMN "documentId",
|
||||
DROP COLUMN "templateId",
|
||||
ADD COLUMN "envelopeId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TemplateDirectLink" DROP COLUMN "templateId",
|
||||
ADD COLUMN "envelopeId" TEXT NOT NULL,
|
||||
ADD COLUMN "type" "TemplateDirectLinkType" NOT NULL;
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Template";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "TemplateType";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Envelope" (
|
||||
"id" TEXT NOT NULL,
|
||||
"secondaryId" TEXT NOT NULL,
|
||||
"externalId" TEXT,
|
||||
"type" "EnvelopeType" NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
"title" TEXT NOT NULL,
|
||||
"status" "DocumentStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"source" "DocumentSource" NOT NULL,
|
||||
"useLegacyFieldInsertion" BOOLEAN NOT NULL DEFAULT false,
|
||||
"authOptions" JSONB,
|
||||
"formValues" JSONB,
|
||||
"visibility" "DocumentVisibility" NOT NULL DEFAULT 'EVERYONE',
|
||||
"publicTitle" TEXT NOT NULL DEFAULT '',
|
||||
"publicDescription" TEXT NOT NULL DEFAULT '',
|
||||
"templateId" INTEGER,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"teamId" INTEGER NOT NULL,
|
||||
"folderId" TEXT,
|
||||
"documentMetaId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Envelope_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Envelope_secondaryId_key" ON "Envelope"("secondaryId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Envelope_documentMetaId_key" ON "Envelope"("documentMetaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DocumentShareLink_envelopeId_email_key" ON "DocumentShareLink"("envelopeId", "email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Field_envelopeId_idx" ON "Field"("envelopeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Recipient_envelopeId_idx" ON "Recipient"("envelopeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Recipient_envelopeId_email_key" ON "Recipient"("envelopeId", "email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TemplateDirectLink_envelopeId_key" ON "TemplateDirectLink"("envelopeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Envelope" ADD CONSTRAINT "Envelope_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Envelope" ADD CONSTRAINT "Envelope_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Envelope" ADD CONSTRAINT "Envelope_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Envelope" ADD CONSTRAINT "Envelope_documentMetaId_fkey" FOREIGN KEY ("documentMetaId") REFERENCES "DocumentMeta"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentAuditLog" ADD CONSTRAINT "DocumentAuditLog_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Field" ADD CONSTRAINT "Field_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentShareLink" ADD CONSTRAINT "DocumentShareLink_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TemplateDirectLink" ADD CONSTRAINT "TemplateDirectLink_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -1,11 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `envelopeId` to the `Document` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Document" ADD COLUMN "envelopeId" TEXT NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -59,12 +59,14 @@ model User {
|
||||
ownedOrganisations Organisation[]
|
||||
organisationMember OrganisationMember[]
|
||||
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorBackupCodes String?
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorBackupCodes String?
|
||||
twoFactorEmailVerification UserTwoFactorEmailVerification?
|
||||
|
||||
folders Folder[]
|
||||
envelopes Envelope[]
|
||||
documents Document[]
|
||||
templates Template[]
|
||||
|
||||
verificationTokens VerificationToken[]
|
||||
apiTokens ApiToken[]
|
||||
@ -347,7 +349,8 @@ model Folder {
|
||||
pinned Boolean @default(false)
|
||||
parentId String?
|
||||
parent Folder? @relation("FolderToFolder", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
envelopes Envelope[]
|
||||
documents Document[]
|
||||
templates Template[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
subfolders Folder[] @relation("FolderToFolder")
|
||||
@ -360,79 +363,53 @@ model Folder {
|
||||
@@index([type])
|
||||
}
|
||||
|
||||
enum EnvelopeType {
|
||||
DOCUMENT
|
||||
TEMPLATE
|
||||
}
|
||||
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';", "import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';"])
|
||||
model Document {
|
||||
id Int @id @default(autoincrement())
|
||||
qrToken String? /// @zod.string.describe("The token for viewing the document using the QR code on the certificate.")
|
||||
externalId String? /// @zod.string.describe("A custom external ID you can use to identify the document.")
|
||||
|
||||
model Envelope {
|
||||
id String @id @default(cuid())
|
||||
secondaryId String @unique
|
||||
externalId String? /// @zod.string.describe("A custom external ID you can use to identify the document.")
|
||||
|
||||
type EnvelopeType
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
completedAt DateTime?
|
||||
deletedAt DateTime?
|
||||
|
||||
title String
|
||||
status DocumentStatus @default(DRAFT)
|
||||
source DocumentSource
|
||||
|
||||
useLegacyFieldInsertion Boolean @default(false)
|
||||
|
||||
documents Document[]
|
||||
recipients Recipient[]
|
||||
fields Field[]
|
||||
shareLinks DocumentShareLink[]
|
||||
auditLogs DocumentAuditLog[]
|
||||
|
||||
// Envelope settings
|
||||
authOptions Json? /// [DocumentAuthOptions] @zod.custom.use(ZDocumentAuthOptionsSchema)
|
||||
formValues Json? /// [DocumentFormValues] @zod.custom.use(ZDocumentFormValuesSchema)
|
||||
visibility DocumentVisibility @default(EVERYONE)
|
||||
|
||||
// Template specific fields.
|
||||
publicTitle String @default("")
|
||||
publicDescription String @default("")
|
||||
directLink TemplateDirectLink?
|
||||
templateId Int? // Todo: Migrate from templateId -> This @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
||||
|
||||
// Relations
|
||||
userId Int /// @zod.number.describe("The ID of the user that created this document.")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
teamId Int
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId String?
|
||||
|
||||
documentMetaId String @unique
|
||||
documentMeta DocumentMeta @relation(fields: [documentMetaId], references: [id])
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';", "import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';"])
|
||||
model Document {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
qrToken String? /// @zod.string.describe("The token for viewing the document using the QR code on the certificate.")
|
||||
title String
|
||||
|
||||
authOptions Json? /// [DocumentAuthOptions] @zod.custom.use(ZDocumentAuthOptionsSchema)
|
||||
formValues Json? /// [DocumentFormValues] @zod.custom.use(ZDocumentFormValuesSchema)
|
||||
visibility DocumentVisibility @default(EVERYONE)
|
||||
title String
|
||||
status DocumentStatus @default(DRAFT)
|
||||
recipients Recipient[]
|
||||
fields Field[]
|
||||
shareLinks DocumentShareLink[]
|
||||
documentDataId String
|
||||
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
|
||||
documentMeta DocumentMeta?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
completedAt DateTime?
|
||||
deletedAt DateTime?
|
||||
templateId Int?
|
||||
source DocumentSource
|
||||
|
||||
envelopeId String
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
useLegacyFieldInsertion Boolean @default(false)
|
||||
|
||||
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
|
||||
template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull)
|
||||
|
||||
auditLogs DocumentAuditLog[]
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId String?
|
||||
|
||||
@@unique([documentDataId])
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([folderId])
|
||||
}
|
||||
|
||||
model DocumentAuditLog {
|
||||
id String @id @default(cuid())
|
||||
envelopeId String
|
||||
documentId Int
|
||||
createdAt DateTime @default(now())
|
||||
type String
|
||||
data Json
|
||||
@ -444,7 +421,7 @@ model DocumentAuditLog {
|
||||
userAgent String?
|
||||
ipAddress String?
|
||||
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum DocumentDataType {
|
||||
@ -464,6 +441,7 @@ model DocumentData {
|
||||
data String
|
||||
initialData String
|
||||
document Document?
|
||||
template Template?
|
||||
}
|
||||
|
||||
enum DocumentDistributionMethod {
|
||||
@ -479,6 +457,8 @@ model DocumentMeta {
|
||||
timezone String? @default("Etc/UTC") @db.Text
|
||||
password String?
|
||||
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
||||
documentId Int @unique
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
redirectUrl String?
|
||||
signingOrder DocumentSigningOrder @default(PARALLEL)
|
||||
allowDictateNextSigner Boolean @default(false)
|
||||
@ -493,9 +473,6 @@ model DocumentMeta {
|
||||
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
|
||||
emailReplyTo String?
|
||||
emailId String?
|
||||
|
||||
envelopeId String?
|
||||
envelope Envelope?
|
||||
}
|
||||
|
||||
enum ReadStatus {
|
||||
@ -525,7 +502,8 @@ enum RecipientRole {
|
||||
/// @zod.import(["import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
|
||||
model Recipient {
|
||||
id Int @id @default(autoincrement())
|
||||
envelopeId String
|
||||
documentId Int?
|
||||
templateId Int?
|
||||
email String @db.VarChar(255)
|
||||
name String @default("") @db.VarChar(255)
|
||||
token String
|
||||
@ -539,12 +517,15 @@ model Recipient {
|
||||
readStatus ReadStatus @default(NOT_OPENED)
|
||||
signingStatus SigningStatus @default(NOT_SIGNED)
|
||||
sendStatus SendStatus @default(NOT_SENT)
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
fields Field[]
|
||||
signatures Signature[]
|
||||
|
||||
@@unique([envelopeId, email])
|
||||
@@index([envelopeId])
|
||||
@@unique([documentId, email])
|
||||
@@unique([templateId, email])
|
||||
@@index([documentId])
|
||||
@@index([templateId])
|
||||
@@index([token])
|
||||
}
|
||||
|
||||
@ -566,7 +547,8 @@ enum FieldType {
|
||||
model Field {
|
||||
id Int @id @default(autoincrement())
|
||||
secondaryId String @unique @default(cuid())
|
||||
envelopeId String
|
||||
documentId Int?
|
||||
templateId Int?
|
||||
recipientId Int
|
||||
type FieldType
|
||||
page Int /// @zod.number.describe("The page number of the field on the document. Starts from 1.")
|
||||
@ -576,12 +558,14 @@ model Field {
|
||||
height Decimal @default(-1)
|
||||
customText String
|
||||
inserted Boolean
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
signature Signature?
|
||||
fieldMeta Json? /// [FieldMeta] @zod.custom.use(ZFieldMetaNotOptionalSchema)
|
||||
|
||||
@@index([envelopeId])
|
||||
@@index([documentId])
|
||||
@@index([templateId])
|
||||
@@index([recipientId])
|
||||
}
|
||||
|
||||
@ -603,13 +587,13 @@ model DocumentShareLink {
|
||||
id Int @id @default(autoincrement())
|
||||
email String
|
||||
slug String @unique
|
||||
envelopeId String
|
||||
documentId Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([envelopeId, email])
|
||||
@@unique([documentId, email])
|
||||
}
|
||||
|
||||
enum OrganisationType {
|
||||
@ -820,7 +804,8 @@ model Team {
|
||||
|
||||
profile TeamProfile?
|
||||
|
||||
envelopes Envelope[]
|
||||
documents Document[]
|
||||
templates Template[]
|
||||
folders Folder[]
|
||||
apiTokens ApiToken[]
|
||||
webhooks Webhook[]
|
||||
@ -853,26 +838,84 @@ model TeamEmailVerification {
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
// TODO: USE THIS
|
||||
// TODO: USE THIS
|
||||
// TODO: USE THIS
|
||||
// TODO: USE THIS
|
||||
enum TemplateDirectLinkType {
|
||||
enum TemplateType {
|
||||
PUBLIC
|
||||
PRIVATE
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
|
||||
model TemplateMeta {
|
||||
id String @id @default(cuid())
|
||||
subject String?
|
||||
message String?
|
||||
timezone String? @default("Etc/UTC") @db.Text
|
||||
password String?
|
||||
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
||||
signingOrder DocumentSigningOrder? @default(PARALLEL)
|
||||
allowDictateNextSigner Boolean @default(false)
|
||||
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
||||
|
||||
typedSignatureEnabled Boolean @default(true)
|
||||
uploadSignatureEnabled Boolean @default(true)
|
||||
drawSignatureEnabled Boolean @default(true)
|
||||
|
||||
templateId Int @unique
|
||||
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
redirectUrl String?
|
||||
language String @default("en")
|
||||
|
||||
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
|
||||
emailReplyTo String?
|
||||
emailId String?
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
|
||||
model Template {
|
||||
id Int @id @default(autoincrement())
|
||||
externalId String?
|
||||
type TemplateType @default(PRIVATE)
|
||||
title String
|
||||
visibility DocumentVisibility @default(EVERYONE)
|
||||
authOptions Json? /// [DocumentAuthOptions] @zod.custom.use(ZDocumentAuthOptionsSchema)
|
||||
templateMeta TemplateMeta?
|
||||
templateDocumentDataId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
publicTitle String @default("")
|
||||
publicDescription String @default("")
|
||||
|
||||
useLegacyFieldInsertion Boolean @default(false)
|
||||
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
teamId Int
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
|
||||
|
||||
recipients Recipient[]
|
||||
fields Field[]
|
||||
directLink TemplateDirectLink?
|
||||
documents Document[]
|
||||
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId String?
|
||||
|
||||
@@unique([templateDocumentDataId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model TemplateDirectLink {
|
||||
id String @id @unique @default(cuid())
|
||||
envelopeId String @unique
|
||||
token String @unique
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @unique @default(cuid())
|
||||
templateId Int @unique
|
||||
token String @unique
|
||||
createdAt DateTime @default(now())
|
||||
enabled Boolean
|
||||
type TemplateDirectLinkType
|
||||
|
||||
directTemplateRecipientId Int
|
||||
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model SiteSettings {
|
||||
@ -943,6 +986,15 @@ model AvatarImage {
|
||||
organisation Organisation[]
|
||||
}
|
||||
|
||||
model UserTwoFactorEmailVerification {
|
||||
userId Int @id
|
||||
verificationCode String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum EmailDomainStatus {
|
||||
PENDING
|
||||
ACTIVE
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Legacy Document schema to confirm backwards API compatibility since
|
||||
* we migrated Documents to Envelopes.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
|
||||
|
||||
import DocumentStatusSchema from '../generated/zod/inputTypeSchemas/DocumentStatusSchema';
|
||||
import DocumentVisibilitySchema from '../generated/zod/inputTypeSchemas/DocumentVisibilitySchema';
|
||||
|
||||
const DocumentSourceSchema = z.enum(['DOCUMENT', 'TEMPLATE', 'TEMPLATE_DIRECT_LINK']);
|
||||
const DocumentTypeSchema = z.enum(['DOCUMENT', 'PUBLIC_TEMPLATE', 'PRIVATE_TEMPLATE']);
|
||||
|
||||
/////////////////////////////////////////
|
||||
// DOCUMENT SCHEMA
|
||||
/////////////////////////////////////////
|
||||
|
||||
export const LegacyDocumentSchema = z.object({
|
||||
type: DocumentTypeSchema,
|
||||
visibility: DocumentVisibilitySchema,
|
||||
status: DocumentStatusSchema,
|
||||
source: DocumentSourceSchema,
|
||||
id: z.number(),
|
||||
qrToken: z
|
||||
.string()
|
||||
.describe('The token for viewing the document using the QR code on the certificate.')
|
||||
.nullable(),
|
||||
externalId: z
|
||||
.string()
|
||||
.describe('A custom external ID you can use to identify the document.')
|
||||
.nullable(),
|
||||
secondaryDocumentId: z.number(),
|
||||
secondaryTemplateId: z.number(),
|
||||
publicTitle: z.string(),
|
||||
publicDescription: z.string(),
|
||||
createdFromDocumentId: z.number().nullable(),
|
||||
userId: z.number().describe('The ID of the user that created this document.'),
|
||||
teamId: z.number(),
|
||||
/**
|
||||
* [DocumentAuthOptions]
|
||||
*/
|
||||
authOptions: ZDocumentAuthOptionsSchema.nullable(),
|
||||
/**
|
||||
* [DocumentFormValues]
|
||||
*/
|
||||
formValues: ZDocumentFormValuesSchema.nullable(),
|
||||
title: z.string(),
|
||||
documentDataId: z.string(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
completedAt: z.coerce.date().nullable(),
|
||||
deletedAt: z.coerce.date().nullable(),
|
||||
useLegacyFieldInsertion: z.boolean(),
|
||||
folderId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type Document = z.infer<typeof LegacyDocumentSchema>;
|
||||
@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Legacy Template schema to confirm backwards API compatibility since
|
||||
* we removed the "Template" prisma schema model.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
|
||||
import { DocumentVisibilitySchema } from '../generated/zod/inputTypeSchemas/DocumentVisibilitySchema';
|
||||
|
||||
export const TemplateTypeSchema = z.enum(['PUBLIC', 'PRIVATE']);
|
||||
|
||||
export type TemplateTypeType = `${z.infer<typeof TemplateTypeSchema>}`;
|
||||
|
||||
export const TemplateSchema = z.object({
|
||||
type: TemplateTypeSchema,
|
||||
visibility: DocumentVisibilitySchema,
|
||||
id: z.number(),
|
||||
externalId: z.string().nullable(),
|
||||
title: z.string(),
|
||||
/**
|
||||
* [DocumentAuthOptions]
|
||||
*/
|
||||
authOptions: ZDocumentAuthOptionsSchema.nullable(),
|
||||
templateDocumentDataId: z.string(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
publicTitle: z.string(),
|
||||
publicDescription: z.string(),
|
||||
useLegacyFieldInsertion: z.boolean(),
|
||||
userId: z.number(),
|
||||
teamId: z.number(),
|
||||
folderId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type Template = z.infer<typeof TemplateSchema>;
|
||||
@ -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,5 +1,10 @@
|
||||
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import {
|
||||
sendEmailVerification,
|
||||
verifyEmailCode,
|
||||
} from '@documenso/lib/server-only/2fa/send-email-verification';
|
||||
import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
|
||||
import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
|
||||
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options';
|
||||
@ -8,6 +13,7 @@ import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey';
|
||||
import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
|
||||
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import {
|
||||
@ -15,7 +21,9 @@ import {
|
||||
ZCreatePasskeyMutationSchema,
|
||||
ZDeletePasskeyMutationSchema,
|
||||
ZFindPasskeysQuerySchema,
|
||||
ZSendEmailVerificationMutationSchema,
|
||||
ZUpdatePasskeyMutationSchema,
|
||||
ZVerifyEmailCodeMutationSchema,
|
||||
} from './schema';
|
||||
|
||||
export const authRouter = router({
|
||||
@ -110,4 +118,68 @@ export const authRouter = router({
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
});
|
||||
}),
|
||||
|
||||
// Email verification for document signing
|
||||
sendEmailVerification: authenticatedProcedure
|
||||
.input(ZSendEmailVerificationMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { recipientId } = input;
|
||||
const userId = ctx.user.id;
|
||||
let email = ctx.user.email;
|
||||
|
||||
// If recipientId is provided, fetch that recipient's details
|
||||
if (recipientId) {
|
||||
const recipient = await prisma.recipient.findUnique({
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError('NOT_FOUND', {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
email = recipient.email;
|
||||
}
|
||||
|
||||
return sendEmailVerification({
|
||||
userId,
|
||||
email,
|
||||
});
|
||||
}),
|
||||
|
||||
verifyEmailCode: authenticatedProcedure
|
||||
.input(ZVerifyEmailCodeMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { code, recipientId } = input;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
// If recipientId is provided, check that the user has access to it
|
||||
if (recipientId) {
|
||||
const recipient = await prisma.recipient.findUnique({
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError('NOT_FOUND', {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return verifyEmailCode({
|
||||
userId,
|
||||
code,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@ -71,3 +71,18 @@ export const ZFindPasskeysQuerySchema = ZFindSearchParamsSchema.extend({
|
||||
});
|
||||
|
||||
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
|
||||
|
||||
export const ZSendEmailVerificationMutationSchema = z.object({
|
||||
recipientId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TSendEmailVerificationMutationSchema = z.infer<
|
||||
typeof ZSendEmailVerificationMutationSchema
|
||||
>;
|
||||
|
||||
export const ZVerifyEmailCodeMutationSchema = z.object({
|
||||
code: z.string().min(6).max(6),
|
||||
recipientId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TVerifyEmailCodeMutationSchema = z.infer<typeof ZVerifyEmailCodeMutationSchema>;
|
||||
|
||||
@ -94,7 +94,7 @@ export const createEmbeddingTemplateRoute = procedure
|
||||
emailSettings: meta.emailSettings,
|
||||
};
|
||||
|
||||
await prisma.documentMeta.upsert({
|
||||
await prisma.templateMeta.upsert({
|
||||
where: {
|
||||
templateId: template.id,
|
||||
},
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -141,7 +141,6 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path })
|
||||
return await next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
teamId: ctx.teamId || -1,
|
||||
logger: trpcSessionLogger,
|
||||
user: ctx.user,
|
||||
session: ctx.session,
|
||||
|
||||
@ -4,70 +4,70 @@ import { Trans } from '@lingui/react/macro';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
|
||||
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||
import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth';
|
||||
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
|
||||
import {
|
||||
DocumentActionAuth,
|
||||
DocumentAuth,
|
||||
NonEnterpriseDocumentActionAuth,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
export interface DocumentGlobalAuthActionSelectProps {
|
||||
value?: string[];
|
||||
defaultValue?: string[];
|
||||
onValueChange?: (value: string[]) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
isDocumentEnterprise?: boolean;
|
||||
}
|
||||
|
||||
export const DocumentGlobalAuthActionSelect = ({
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
isDocumentEnterprise,
|
||||
}: DocumentGlobalAuthActionSelectProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
// Convert auth types to MultiSelect options
|
||||
const authOptions: Option[] = [
|
||||
{
|
||||
value: '-1',
|
||||
label: _(msg`No restrictions`),
|
||||
},
|
||||
...Object.values(DocumentActionAuth)
|
||||
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
|
||||
.map((authType) => ({
|
||||
value: authType,
|
||||
label: DOCUMENT_AUTH_TYPES[authType].value,
|
||||
})),
|
||||
];
|
||||
const authTypes = isDocumentEnterprise
|
||||
? Object.values(DocumentActionAuth).filter((auth) => auth !== DocumentAuth.ACCOUNT)
|
||||
: Object.values(NonEnterpriseDocumentActionAuth).filter(
|
||||
(auth) => auth !== DocumentAuth.EXPLICIT_NONE,
|
||||
);
|
||||
|
||||
// Convert string array to Option array for MultiSelect
|
||||
const selectedOptions =
|
||||
(value
|
||||
?.map((val) => authOptions.find((option) => option.value === val))
|
||||
.filter(Boolean) as Option[]) || [];
|
||||
const selectedValue = value?.[0] || '';
|
||||
|
||||
// Convert default value to Option array
|
||||
const defaultOptions =
|
||||
(defaultValue
|
||||
?.map((val) => authOptions.find((option) => option.value === val))
|
||||
.filter(Boolean) as Option[]) || [];
|
||||
|
||||
const handleChange = (options: Option[]) => {
|
||||
const values = options.map((option) => option.value);
|
||||
onValueChange?.(values);
|
||||
const handleChange = (newValue: string) => {
|
||||
if (newValue === '-1') {
|
||||
onValueChange?.([]);
|
||||
} else {
|
||||
onValueChange?.([newValue]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
value={selectedOptions}
|
||||
defaultOptions={defaultOptions}
|
||||
options={authOptions}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder || _(msg`Select authentication methods`)}
|
||||
className="bg-background text-muted-foreground"
|
||||
hideClearAllButton={false}
|
||||
data-testid="documentActionSelectValue"
|
||||
/>
|
||||
<Select value={selectedValue || '-1'} onValueChange={handleChange} disabled={disabled}>
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="documentActionSelectValue"
|
||||
>
|
||||
<SelectValue placeholder={placeholder || _(msg`Select authentication method`)} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="-1">{_(msg`No restrictions`)}</SelectItem>
|
||||
{authTypes.map((authType) => (
|
||||
<SelectItem key={authType} value={authType}>
|
||||
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
@ -86,14 +86,14 @@ export const DocumentGlobalAuthActionTooltip = () => (
|
||||
|
||||
<p>
|
||||
<Trans>
|
||||
The authentication methods required for recipients to sign the signature field.
|
||||
The authentication method required for recipients to sign the signature field.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>
|
||||
These can be overriden by setting the authentication requirements directly on each
|
||||
recipient in the next step. Multiple methods can be selected.
|
||||
This can be overridden by setting the authentication requirements directly on each
|
||||
recipient in the next step.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, Field, Recipient } from '@prisma/client';
|
||||
import type { DocumentMeta, Field, Recipient, TemplateMeta } from '@prisma/client';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { Clock, EyeOffIcon } from 'lucide-react';
|
||||
|
||||
@ -36,7 +36,7 @@ const getRecipientDisplayText = (recipient: { name: string; email: string }) =>
|
||||
|
||||
export type DocumentReadOnlyFieldsProps = {
|
||||
fields: DocumentField[];
|
||||
documentMeta?: Pick<DocumentMeta, 'dateFormat'>;
|
||||
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
|
||||
|
||||
showFieldStatus?: boolean;
|
||||
|
||||
|
||||
@ -294,28 +294,27 @@ export const AddSettingsFormPartial = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{organisation.organisationClaim.flags.cfr21 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="globalActionAuth"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Recipient action authentication</Trans>
|
||||
<DocumentGlobalAuthActionTooltip />
|
||||
</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="globalActionAuth"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Recipient action authentication</Trans>
|
||||
<DocumentGlobalAuthActionTooltip />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<DocumentGlobalAuthActionSelect
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormControl>
|
||||
<DocumentGlobalAuthActionSelect
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
isDocumentEnterprise={organisation?.organisationClaim?.flags?.cfr21 || false}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Accordion type="multiple" className="mt-6">
|
||||
<AccordionItem value="advanced-options" className="border-none">
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { DocumentMeta, Signature } from '@prisma/client';
|
||||
import type { DocumentMeta, Signature, TemplateMeta } from '@prisma/client';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
@ -27,7 +27,7 @@ type FieldIconProps = {
|
||||
fieldMeta?: TFieldMetaSchema | null;
|
||||
signature?: Signature | null;
|
||||
};
|
||||
documentMeta?: Pick<DocumentMeta, 'dateFormat'>;
|
||||
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -382,28 +382,27 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{organisation.organisationClaim.flags.cfr21 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="globalActionAuth"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Recipient action authentication</Trans>
|
||||
<DocumentGlobalAuthActionTooltip />
|
||||
</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="globalActionAuth"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Recipient action authentication</Trans>
|
||||
<DocumentGlobalAuthActionTooltip />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<DocumentGlobalAuthActionSelect
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormControl>
|
||||
<DocumentGlobalAuthActionSelect
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onValueChange={field.onChange}
|
||||
isDocumentEnterprise={organisation.organisationClaim.flags.cfr21}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{distributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||
<Accordion type="multiple">
|
||||
|
||||
@ -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",
|
||||
|
||||
Reference in New Issue
Block a user