From 4ff8592e8fc156a51ec0d56b69dd6c78dc5f5e8e Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Wed, 29 Nov 2023 22:11:55 +0530 Subject: [PATCH 001/156] feat: add password input component --- packages/ui/primitives/password-input.tsx | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 packages/ui/primitives/password-input.tsx diff --git a/packages/ui/primitives/password-input.tsx b/packages/ui/primitives/password-input.tsx new file mode 100644 index 000000000..85249056d --- /dev/null +++ b/packages/ui/primitives/password-input.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; + +import { Eye, EyeOff } from 'lucide-react'; + +import { cn } from '../lib/utils'; +import { Button } from './button'; +import { Input, InputProps } from './input'; + +const PasswordInput = React.forwardRef( + ({ className, ...props }, ref) => { + const [showPassword, setShowPassword] = React.useState(false); + + return ( +
+ + + +
+ ); + }, +); + +PasswordInput.displayName = 'PasswordInput'; + +export { PasswordInput }; From 318dfcafc3a9fa1b8a0db923ea8c403813df55d6 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Wed, 29 Nov 2023 22:31:24 +0530 Subject: [PATCH 002/156] refactor: signup page --- apps/web/src/components/forms/signup.tsx | 189 +++++++++++------------ 1 file changed, 89 insertions(+), 100 deletions(-) diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index 11068ac68..fb70e4c2f 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -1,20 +1,24 @@ 'use client'; -import { useState } from 'react'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Eye, EyeOff } from 'lucide-react'; import { signIn } from 'next-auth/react'; -import { Controller, useForm } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -36,14 +40,8 @@ export type SignUpFormProps = { export const SignUpForm = ({ className }: SignUpFormProps) => { const { toast } = useToast(); - const [showPassword, setShowPassword] = useState(false); - const { - control, - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ values: { name: '', email: '', @@ -53,6 +51,8 @@ export const SignUpForm = ({ className }: SignUpFormProps) => { resolver: zodResolver(ZSignUpFormSchema), }); + const isSubmitting = form.formState.isSubmitting; + const { mutateAsync: signup } = trpc.auth.signup.useMutation(); const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => { @@ -83,93 +83,82 @@ export const SignUpForm = ({ className }: SignUpFormProps) => { }; return ( -
-
- - - - - {errors.name && {errors.name.message}} -
- -
- - - - - {errors.email && {errors.email.message}} -
- -
- - -
- - - -
- -
- -
- - -
- ( - onChange(v ?? '')} - /> - )} - /> -
- - -
- - -
+ ( + + Name + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Password + + + + + + )} + /> + + ( + + Sign Here + + onChange(v ?? '')} + /> + + + + + )} + /> + + + + ); }; From 62809e9506f0046fffe713968ebdb2f426396341 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Wed, 29 Nov 2023 22:31:42 +0530 Subject: [PATCH 003/156] refactor: signin page --- apps/web/src/components/forms/signin.tsx | 150 +++++++++++------------ 1 file changed, 69 insertions(+), 81 deletions(-) diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index abdc1efe6..b93a2cd46 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -1,9 +1,6 @@ 'use client'; -import { useState } from 'react'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Eye, EyeOff } from 'lucide-react'; import { signIn } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { FcGoogle } from 'react-icons/fc'; @@ -12,9 +9,16 @@ import { z } from 'zod'; import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; const ERROR_MESSAGES = { @@ -39,13 +43,8 @@ export type SignInFormProps = { export const SignInForm = ({ className }: SignInFormProps) => { const { toast } = useToast(); - const [showPassword, setShowPassword] = useState(false); - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ values: { email: '', password: '', @@ -53,6 +52,8 @@ export const SignInForm = ({ className }: SignInFormProps) => { resolver: zodResolver(ZSignInFormSchema), }); + const isSubmitting = form.formState.isSubmitting; + const onFormSubmit = async ({ email, password }: TSignInFormSchema) => { try { const result = await signIn('credentials', { @@ -99,80 +100,67 @@ export const SignInForm = ({ className }: SignInFormProps) => { }; return ( -
-
- + + + ( + + Email + + + + + + )} + /> - + ( + + Password + + + + + + )} + /> - -
+ -
- - -
- - - +
+
+ Or continue with +
- -
- - - -
-
- Or continue with -
-
- - - + + + ); }; From dc56c2abf2dba0a85b9dd43b2aaa22e159c0546c Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Wed, 29 Nov 2023 22:32:42 +0530 Subject: [PATCH 004/156] refactor: password form --- apps/web/src/components/forms/password.tsx | 191 +++++++-------------- 1 file changed, 65 insertions(+), 126 deletions(-) diff --git a/apps/web/src/components/forms/password.tsx b/apps/web/src/components/forms/password.tsx index 47cba1e88..c262719e3 100644 --- a/apps/web/src/components/forms/password.tsx +++ b/apps/web/src/components/forms/password.tsx @@ -1,9 +1,7 @@ 'use client'; -import { useState } from 'react'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { Eye, EyeOff, Loader } from 'lucide-react'; +import { Loader } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -12,12 +10,17 @@ import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { Input } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { FormErrorMessage } from '../form/form-error-message'; - export const ZPasswordFormSchema = z .object({ currentPassword: z @@ -48,16 +51,7 @@ export type PasswordFormProps = { export const PasswordForm = ({ className }: PasswordFormProps) => { const { toast } = useToast(); - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [showCurrentPassword, setShowCurrentPassword] = useState(false); - - const { - register, - handleSubmit, - reset, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ values: { currentPassword: '', password: '', @@ -66,6 +60,8 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { resolver: zodResolver(ZPasswordFormSchema), }); + const isSubmitting = form.formState.isSubmitting; + const { mutateAsync: updatePassword } = trpc.profile.updatePassword.useMutation(); const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => { @@ -75,7 +71,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { password, }); - reset(); + form.reset(); toast({ title: 'Password updated', @@ -101,117 +97,60 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { }; return ( -
-
- + + + ( + + Current Password + + + + + + )} + /> -
- + ( + + Password + + + + + + )} + /> -
- - -
-
- - -
- - - -
- - -
- -
- - -
- - - -
- - -
- -
- -
-
+ + ); }; From 1e29dfd823755ecb241057d61be5e1836e3386ec Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Wed, 29 Nov 2023 22:33:04 +0530 Subject: [PATCH 005/156] refactor: reset password form --- .../src/components/forms/reset-password.tsx | 140 ++++++------------ 1 file changed, 49 insertions(+), 91 deletions(-) diff --git a/apps/web/src/components/forms/reset-password.tsx b/apps/web/src/components/forms/reset-password.tsx index 47f423d76..e29686999 100644 --- a/apps/web/src/components/forms/reset-password.tsx +++ b/apps/web/src/components/forms/reset-password.tsx @@ -1,11 +1,8 @@ 'use client'; -import { useState } from 'react'; - import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Eye, EyeOff } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -13,9 +10,15 @@ import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; -import { Input } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; export const ZResetPasswordFormSchema = z @@ -40,15 +43,7 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) const { toast } = useToast(); - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - - const { - register, - reset, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ values: { password: '', repeatedPassword: '', @@ -56,6 +51,8 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) resolver: zodResolver(ZResetPasswordFormSchema), }); + const isSubmitting = form.formState.isSubmitting; + const { mutateAsync: resetPassword } = trpc.profile.resetPassword.useMutation(); const onFormSubmit = async ({ password }: Omit) => { @@ -65,7 +62,7 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) token, }); - reset(); + form.reset(); toast({ title: 'Password updated', @@ -93,81 +90,42 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) }; return ( -
-
- + + + ( + + Password + + + + + + )} + /> + ( + + Repeat Password + + + + + + )} + /> -
- - - -
- - -
- -
- - -
- - - -
- - -
- - -
+ + + ); }; From 0b2dce22389b796d7ce9155c04a9edab34946e39 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Wed, 29 Nov 2023 22:37:33 +0530 Subject: [PATCH 006/156] fix: type --- packages/ui/primitives/password-input.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ui/primitives/password-input.tsx b/packages/ui/primitives/password-input.tsx index 85249056d..502344a02 100644 --- a/packages/ui/primitives/password-input.tsx +++ b/packages/ui/primitives/password-input.tsx @@ -6,14 +6,13 @@ import { cn } from '../lib/utils'; import { Button } from './button'; import { Input, InputProps } from './input'; -const PasswordInput = React.forwardRef( +const PasswordInput = React.forwardRef>( ({ className, ...props }, ref) => { const [showPassword, setShowPassword] = React.useState(false); return (
Date: Thu, 30 Nov 2023 15:20:06 +0530 Subject: [PATCH 007/156] feat: add loading text prop --- packages/ui/primitives/button.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/ui/primitives/button.tsx b/packages/ui/primitives/button.tsx index 31df69dee..9ee3324c6 100644 --- a/packages/ui/primitives/button.tsx +++ b/packages/ui/primitives/button.tsx @@ -53,18 +53,23 @@ export interface ButtonProps * Will display the loading spinner and disable the button. */ loading?: boolean; + + /** + * The label to show in the button when `isLoading` is true + */ + loadingText?: string; } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, loading, ...props }, ref) => { + ({ className, variant, size, asChild = false, loadingText, loading, ...props }, ref) => { if (asChild) { return ( ); } - const showLoader = loading === true; - const isDisabled = props.disabled || showLoader; + const isLoading = loading === true; + const isDisabled = props.disabled || isLoading; return ( ); }, From 6bbeaa084ce464b64174e47d940c843724a9af99 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Thu, 30 Nov 2023 15:55:29 +0530 Subject: [PATCH 008/156] refactor: forms --- .../src/components/forms/forgot-password.tsx | 63 ++++++---- apps/web/src/components/forms/password.tsx | 84 ++++++------- apps/web/src/components/forms/profile.tsx | 115 ++++++++--------- .../src/components/forms/reset-password.tsx | 59 ++++----- apps/web/src/components/forms/signin.tsx | 58 ++++----- apps/web/src/components/forms/signup.tsx | 118 +++++++++--------- 6 files changed, 259 insertions(+), 238 deletions(-) diff --git a/apps/web/src/components/forms/forgot-password.tsx b/apps/web/src/components/forms/forgot-password.tsx index 141f3780f..55e313c33 100644 --- a/apps/web/src/components/forms/forgot-password.tsx +++ b/apps/web/src/components/forms/forgot-password.tsx @@ -9,9 +9,15 @@ import { z } from 'zod'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; import { useToast } from '@documenso/ui/primitives/use-toast'; export const ZForgotPasswordFormSchema = z.object({ @@ -28,18 +34,15 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => { const router = useRouter(); const { toast } = useToast(); - const { - register, - handleSubmit, - reset, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ values: { email: '', }, resolver: zodResolver(ZForgotPasswordFormSchema), }); + const isSubmitting = form.formState.isSubmitting; + const { mutateAsync: forgotPassword } = trpc.profile.forgotPassword.useMutation(); const onFormSubmit = async ({ email }: TForgotPasswordFormSchema) => { @@ -52,29 +55,37 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => { duration: 5000, }); - reset(); + form.reset(); router.push('/check-email'); }; return ( -
-
- + + +
+ ( + + Email + + + + + + )} + /> +
- - - -
- - -
+ + + ); }; diff --git a/apps/web/src/components/forms/password.tsx b/apps/web/src/components/forms/password.tsx index c262719e3..b6b41264c 100644 --- a/apps/web/src/components/forms/password.tsx +++ b/apps/web/src/components/forms/password.tsx @@ -1,7 +1,6 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Loader } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -102,51 +101,52 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)} > - ( - - Current Password - - - - - - )} - /> +
+ ( + + Current Password + + + + + + )} + /> - ( - - Password - - - - - - )} - /> + ( + + Password + + + + + + )} + /> - ( - - Repeat Password - - - - - - )} - /> + ( + + Repeat Password + + + + + + )} + /> +
-
diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 6f611bed9..dc6e3c4d5 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -3,8 +3,7 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Loader } from 'lucide-react'; -import { Controller, useForm } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { User } from '@documenso/prisma/client'; @@ -12,13 +11,19 @@ import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; 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 { Label } from '@documenso/ui/primitives/label'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { FormErrorMessage } from '../form/form-error-message'; - export const ZProfileFormSchema = z.object({ name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), signature: z.string().min(1, 'Signature Pad cannot be empty'), @@ -36,12 +41,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { const { toast } = useToast(); - const { - register, - control, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ values: { name: user.name ?? '', signature: user.signature || '', @@ -49,6 +49,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { resolver: zodResolver(ZProfileFormSchema), }); + const isSubmitting = form.formState.isSubmitting; + const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => { @@ -84,56 +86,57 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { }; return ( -
-
- - - - - -
- -
- - - -
- -
- - -
- ( - onChange(v ?? '')} - /> + + +
+ ( + + Full Name + + + + + )} /> - -
-
-
- -
-
+ + ); }; diff --git a/apps/web/src/components/forms/reset-password.tsx b/apps/web/src/components/forms/reset-password.tsx index e29686999..e7c701667 100644 --- a/apps/web/src/components/forms/reset-password.tsx +++ b/apps/web/src/components/forms/reset-password.tsx @@ -95,35 +95,38 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)} > - ( - - Password - - - - - - )} - /> - ( - - Repeat Password - - - - - - )} - /> +
+ ( + + Password + + + + + + )} + /> -
+ + diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index b93a2cd46..bc58e68e1 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -105,42 +105,44 @@ export const SignInForm = ({ className }: SignInFormProps) => { className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)} > - ( - - Email - - - - - - )} - /> +
+ ( + + Email + + + + + + )} + /> - ( - - Password - - - - - - )} - /> + ( + + Password + + + + + + )} + /> +
diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index fb70e4c2f..ad803d9c1 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -88,75 +88,77 @@ export const SignUpForm = ({ className }: SignUpFormProps) => { className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)} > - ( - - Name - - - - - - )} - /> +
+ ( + + Name + + + + + + )} + /> - ( - - Email - - - - - - )} - /> + ( + + Email + + + + + + )} + /> - ( - - Password - - - - - - )} - /> + ( + + Password + + + + + + )} + /> - ( - - Sign Here - - onChange(v ?? '')} - /> - + ( + + Sign Here + + onChange(v ?? '')} + /> + - - - )} - /> + + + )} + /> +
From 4733f1e84bdb5e625a91627c7d72312e668a0a2a Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 2 Dec 2023 17:46:16 +0530 Subject: [PATCH 009/156] refactor: password input component --- packages/ui/primitives/input.tsx | 39 +------------------------------- 1 file changed, 1 insertion(+), 38 deletions(-) diff --git a/packages/ui/primitives/input.tsx b/packages/ui/primitives/input.tsx index ac739c984..1a5fba1bb 100644 --- a/packages/ui/primitives/input.tsx +++ b/packages/ui/primitives/input.tsx @@ -1,9 +1,6 @@ import * as React from 'react'; -import { Eye, EyeOff } from 'lucide-react'; - import { cn } from '../lib/utils'; -import { Button } from './button'; export type InputProps = React.InputHTMLAttributes; @@ -28,38 +25,4 @@ const Input = React.forwardRef( Input.displayName = 'Input'; -const PasswordInput = React.forwardRef( - ({ className, ...props }, ref) => { - const [showPassword, setShowPassword] = React.useState(false); - - return ( -
- - - -
- ); - }, -); - -PasswordInput.displayName = 'Input'; - -export { Input, PasswordInput }; +export { Input }; From a9068336571c8ba6cdb22e3802ab2692e96c2ad6 Mon Sep 17 00:00:00 2001 From: nafees nazik Date: Sat, 2 Dec 2023 17:54:19 +0530 Subject: [PATCH 010/156] feat: use password input component --- .../2fa/disable-authenticator-app-dialog.tsx | 67 ++++++++++--------- .../2fa/enable-authenticator-app-dialog.tsx | 4 +- .../forms/2fa/view-recovery-codes-dialog.tsx | 5 +- 3 files changed, 40 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx index eac574181..eafed5500 100644 --- a/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx +++ b/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx @@ -23,6 +23,7 @@ import { FormMessage, } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; +import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; export const ZDisableTwoFactorAuthenticationForm = z.object({ @@ -107,38 +108,42 @@ export const DisableAuthenticatorAppDialog = ({ )} className="flex flex-col gap-y-4" > - ( - - Password - - - - - - )} - /> +
+ ( + + Password + + + + + + )} + /> - ( - - Backup Code - - - - - - )} - /> + ( + + Backup Code + + + + + + )} + /> +
+
+ ( + + Email + + + + + + )} + /> -
-
- Or continue with -
-
+ ( + + Password + + + + + + )} + /> +
- + +
+
+ Or continue with +
+
+ + + { Two-Factor Authentication -
- {twoFactorAuthenticationMethod === 'totp' && ( -
- - - +
+ {twoFactorAuthenticationMethod === 'totp' && ( + ( + + Authentication Token + + + + + + )} /> + )} - -
- )} - - {twoFactorAuthenticationMethod === 'backup' && ( -
- - - ( + + Backup Code + + + + + + )} /> - - -
- )} + )} +
-
- + ); }; From 38ad3a19229797de45591b7a4033e3cc530448f3 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 7 Dec 2023 14:52:12 +0000 Subject: [PATCH 012/156] refactor: download function to be reusable --- .../documents/data-table-action-button.tsx | 56 ++++++------------- .../documents/data-table-action-dropdown.tsx | 18 +----- packages/lib/client-only/download-pdf.ts | 37 ++++++++++++ packages/prisma/types/document-with-data.ts | 2 +- .../document/document-download-button.tsx | 43 +++----------- packages/ui/primitives/use-toast.ts | 3 +- 6 files changed, 66 insertions(+), 93 deletions(-) create mode 100644 packages/lib/client-only/download-pdf.ts diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index 54a8f6184..51f3f7c58 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -6,13 +6,12 @@ import { Download, Edit, Pencil } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { match } from 'ts-pattern'; -import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { downloadFile } from '@documenso/lib/client-only/download-pdf'; import type { Document, Recipient, User } from '@documenso/prisma/client'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { Button } from '@documenso/ui/primitives/button'; -import { useToast } from '@documenso/ui/primitives/use-toast'; export type DataTableActionButtonProps = { row: Document & { @@ -23,7 +22,6 @@ export type DataTableActionButtonProps = { export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const { data: session } = useSession(); - const { toast } = useToast(); if (!session) { return null; @@ -39,47 +37,25 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const onDownloadClick = async () => { - try { - let document: DocumentWithData | null = null; + let document: DocumentWithData | null = null; - if (!recipient) { - document = await trpcClient.document.getDocumentById.query({ - id: row.id, - }); - } else { - document = await trpcClient.document.getDocumentByToken.query({ - token: recipient.token, - }); - } - - const documentData = document?.documentData; - - if (!documentData) { - return; - } - - const documentBytes = await getFile(documentData); - - const blob = new Blob([documentBytes], { - type: 'application/pdf', + if (!recipient) { + document = await trpcClient.document.getDocumentById.query({ + id: row.id, }); - - const link = window.document.createElement('a'); - const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title; - - link.href = window.URL.createObjectURL(blob); - link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; - - link.click(); - - window.URL.revokeObjectURL(link.href); - } catch (error) { - toast({ - title: 'Something went wrong', - description: 'An error occurred while trying to download file.', - variant: 'destructive', + } else { + document = await trpcClient.document.getDocumentByToken.query({ + token: recipient.token, }); } + + const documentData = document?.documentData; + + if (!documentData) { + return; + } + + await downloadFile({ documentData, fileName: row.title }); }; return match({ diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index 9c3532f88..e9a713d62 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -17,7 +17,7 @@ import { } from 'lucide-react'; import { useSession } from 'next-auth/react'; -import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { downloadFile } from '@documenso/lib/client-only/download-pdf'; import type { Document, Recipient, User } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; @@ -81,21 +81,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = return; } - const documentBytes = await getFile(documentData); - - const blob = new Blob([documentBytes], { - type: 'application/pdf', - }); - - const link = window.document.createElement('a'); - const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title; - - link.href = window.URL.createObjectURL(blob); - link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; - - link.click(); - - window.URL.revokeObjectURL(link.href); + await downloadFile({ documentData, fileName: row.title }); }; const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); diff --git a/packages/lib/client-only/download-pdf.ts b/packages/lib/client-only/download-pdf.ts new file mode 100644 index 000000000..af304f983 --- /dev/null +++ b/packages/lib/client-only/download-pdf.ts @@ -0,0 +1,37 @@ +import type { DocumentData } from '@documenso/prisma/client'; +import { toast } from '@documenso/ui/primitives/use-toast'; + +import { getFile } from '../universal/upload/get-file'; + +type DownloadPDFProps = { + documentData: DocumentData; + fileName?: string; +}; + +export const downloadFile = async ({ documentData, fileName }: DownloadPDFProps) => { + try { + const bytes = await getFile(documentData); + + const blob = new Blob([bytes], { + type: 'application/pdf', + }); + + const link = window.document.createElement('a'); + const baseTitle = fileName?.includes('.pdf') ? fileName.split('.pdf')[0] : fileName; + + link.href = window.URL.createObjectURL(blob); + link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; + + link.click(); + + window.URL.revokeObjectURL(link.href); + } catch (err) { + console.error(err); + + toast({ + title: 'Something went wrong', + description: 'An error occurred while downloading your document.', + variant: 'destructive', + }); + } +}; diff --git a/packages/prisma/types/document-with-data.ts b/packages/prisma/types/document-with-data.ts index d8dd8a888..461d13e6c 100644 --- a/packages/prisma/types/document-with-data.ts +++ b/packages/prisma/types/document-with-data.ts @@ -1,4 +1,4 @@ -import { Document, DocumentData, DocumentMeta } from '@documenso/prisma/client'; +import type { Document, DocumentData, DocumentMeta } from '@documenso/prisma/client'; export type DocumentWithData = Document & { documentData?: DocumentData | null; diff --git a/packages/ui/components/document/document-download-button.tsx b/packages/ui/components/document/document-download-button.tsx index a2a35e490..9471611ff 100644 --- a/packages/ui/components/document/document-download-button.tsx +++ b/packages/ui/components/document/document-download-button.tsx @@ -5,11 +5,10 @@ import { useState } from 'react'; import { Download } from 'lucide-react'; -import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { downloadFile } from '@documenso/lib/client-only/download-pdf'; import type { DocumentData } from '@documenso/prisma/client'; import { Button } from '../../primitives/button'; -import { useToast } from '../../primitives/use-toast'; export type DownloadButtonProps = HTMLAttributes & { disabled?: boolean; @@ -24,44 +23,18 @@ export const DocumentDownloadButton = ({ disabled, ...props }: DownloadButtonProps) => { - const { toast } = useToast(); - const [isLoading, setIsLoading] = useState(false); const onDownloadClick = async () => { - try { - setIsLoading(true); + setIsLoading(true); - if (!documentData) { - return; - } - - const bytes = await getFile(documentData); - - const blob = new Blob([bytes], { - type: 'application/pdf', - }); - - const link = window.document.createElement('a'); - const baseTitle = fileName?.includes('.pdf') ? fileName.split('.pdf')[0] : fileName; - - link.href = window.URL.createObjectURL(blob); - link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; - - link.click(); - - window.URL.revokeObjectURL(link.href); - } catch (err) { - console.error(err); - - toast({ - title: 'Error', - description: 'An error occurred while downloading your document.', - variant: 'destructive', - }); - } finally { - setIsLoading(false); + if (!documentData) { + return; } + + await downloadFile({ documentData, fileName }).then(() => { + setIsLoading(false); + }); }; return ( diff --git a/packages/ui/primitives/use-toast.ts b/packages/ui/primitives/use-toast.ts index 6524baf30..27f96aa29 100644 --- a/packages/ui/primitives/use-toast.ts +++ b/packages/ui/primitives/use-toast.ts @@ -1,7 +1,8 @@ // Inspired by react-hot-toast library import * as React from 'react'; -import { ToastActionElement, type ToastProps } from './toast'; +import type { ToastActionElement } from './toast'; +import { type ToastProps } from './toast'; const TOAST_LIMIT = 1; const TOAST_REMOVE_DELAY = 1000000; From 31a9127c9e77295e65edb00858a4effa76a7e030 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Fri, 6 Oct 2023 22:54:24 +0000 Subject: [PATCH 013/156] feat: templates --- .../app/(marketing)/singleplayer/client.tsx | 1 + .../single-player-mode-success.tsx | 5 +- .../documents/data-table-action-dropdown.tsx | 3 +- .../templates/[id]/edit-template.tsx | 152 +++++ .../app/(dashboard)/templates/[id]/page.tsx | 81 +++ .../templates/data-table-action-dropdown.tsx | 79 +++ .../templates/data-table-templates.tsx | 136 +++++ .../templates/data-table-title.tsx | 26 + .../templates/delete-template-dialog.tsx | 84 +++ .../templates/duplicate-template-dialog.tsx | 89 +++ .../app/(dashboard)/templates/empty-state.tsx | 17 + .../templates/new-template-dialog.tsx | 217 +++++++ .../src/app/(dashboard)/templates/page.tsx | 49 ++ .../(dashboard)/layout/desktop-nav.tsx | 43 +- .../(dashboard)/layout/profile-dropdown.tsx | 8 + .../components/formatter/template-type.tsx | 50 ++ .../add-template-fields.action.ts | 32 ++ .../add-template-placeholders.action.ts | 28 + .../server-only/admin/get-recipients-stats.ts | 7 +- .../server-only/document/find-documents.ts | 4 +- .../field/get-fields-for-template.ts | 22 + .../field/remove-signed-field-with-token.ts | 4 + .../field/set-fields-for-template.ts | 116 ++++ .../field/sign-field-with-token.ts | 4 + .../recipient/get-recipients-for-template.ts | 25 + .../recipient/set-recipients-for-template.ts | 98 ++++ .../template/create-document-from-template.ts | 78 +++ .../server-only/template/create-template.ts | 20 + .../server-only/template/delete-template.ts | 12 + .../template/duplicate-template.ts | 75 +++ .../template/get-template-by-id.ts | 18 + .../lib/server-only/template/get-templates.ts | 37 ++ .../20231007013737_templates/migration.sql | 52 ++ .../migration.sql | 15 + .../migration.sql | 14 + .../migration.sql | 8 + .../migration.sql | 8 + .../migration.sql | 9 + .../migration.sql | 51 ++ .../20231017042227_fix_typo/migration.sql | 23 + .../migration.sql | 8 + .../migration.sql | 8 + .../migration.sql | 5 + .../migration.sql | 10 + .../migration.sql | 54 ++ packages/prisma/schema.prisma | 53 +- packages/trpc/server/router.ts | 2 + .../trpc/server/template-router/router.ts | 94 +++ .../trpc/server/template-router/schema.ts | 26 + packages/ui/primitives/document-dropzone.tsx | 13 +- packages/ui/primitives/document-flow/types.ts | 2 +- .../template-flow/add-template-fields.tsx | 539 ++++++++++++++++++ .../add-template-fields.types.ts | 23 + .../add-template-placeholder-recipients.tsx | 205 +++++++ ...d-template-placeholder-recipients.types.ts | 26 + 55 files changed, 2834 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/[id]/page.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/data-table-templates.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/data-table-title.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/empty-state.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/page.tsx create mode 100644 apps/web/src/components/formatter/template-type.tsx create mode 100644 apps/web/src/components/forms/edit-template/add-template-fields.action.ts create mode 100644 apps/web/src/components/forms/edit-template/add-template-placeholders.action.ts create mode 100644 packages/lib/server-only/field/get-fields-for-template.ts create mode 100644 packages/lib/server-only/field/set-fields-for-template.ts create mode 100644 packages/lib/server-only/recipient/get-recipients-for-template.ts create mode 100644 packages/lib/server-only/recipient/set-recipients-for-template.ts create mode 100644 packages/lib/server-only/template/create-document-from-template.ts create mode 100644 packages/lib/server-only/template/create-template.ts create mode 100644 packages/lib/server-only/template/delete-template.ts create mode 100644 packages/lib/server-only/template/duplicate-template.ts create mode 100644 packages/lib/server-only/template/get-template-by-id.ts create mode 100644 packages/lib/server-only/template/get-templates.ts create mode 100644 packages/prisma/migrations/20231007013737_templates/migration.sql create mode 100644 packages/prisma/migrations/20231007014431_templates_type/migration.sql create mode 100644 packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql create mode 100644 packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql create mode 100644 packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql create mode 100644 packages/prisma/migrations/20231007211915_template_created_date/migration.sql create mode 100644 packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql create mode 100644 packages/prisma/migrations/20231017042227_fix_typo/migration.sql create mode 100644 packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql create mode 100644 packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql create mode 100644 packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql create mode 100644 packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql create mode 100644 packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql create mode 100644 packages/trpc/server/template-router/router.ts create mode 100644 packages/trpc/server/template-router/schema.ts create mode 100644 packages/ui/primitives/template-flow/add-template-fields.tsx create mode 100644 packages/ui/primitives/template-flow/add-template-fields.types.ts create mode 100644 packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx create mode 100644 packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index b7654c7cf..71f6963a2 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -151,6 +151,7 @@ export const SinglePlayerClient = () => { email: '', name: '', token: '', + templateToken: '', expired: null, signedAt: null, readStatus: 'OPENED', diff --git a/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx b/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx index aa423e522..1af71c775 100644 --- a/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx +++ b/apps/marketing/src/components/(marketing)/single-player-mode/single-player-mode-success.tsx @@ -6,8 +6,9 @@ import Link from 'next/link'; import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; -import { DocumentStatus, Signature } from '@documenso/prisma/client'; -import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; +import type { Signature } from '@documenso/prisma/client'; +import { DocumentStatus } from '@documenso/prisma/client'; +import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; import DocumentDialog from '@documenso/ui/components/document/document-dialog'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index 9c3532f88..f1cbcc147 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -99,6 +99,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = }; const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); + return ( @@ -127,7 +128,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = Download - setDuplicateDialogOpen(true)}> + Duplicate diff --git a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx new file mode 100644 index 000000000..b4d20b60d --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + DocumentFlowFormContainer, + DocumentFlowFormContainerHeader, +} from '@documenso/ui/primitives/document-flow/document-flow-root'; +import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields'; +import { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; +import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients'; +import { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { addTemplateFields } from '~/components/forms/edit-template/add-template-fields.action'; +import { addTemplatePlaceholders } from '~/components/forms/edit-template/add-template-placeholders.action'; + +export type EditTemplateFormProps = { + className?: string; + user: User; + template: Template; + recipients: Recipient[]; + fields: Field[]; + documentData: DocumentData; +}; + +type EditTemplateStep = 'signers' | 'fields'; + +export const EditTemplateForm = ({ + className, + template, + recipients, + fields, + user: _user, + documentData, +}: EditTemplateFormProps) => { + const { toast } = useToast(); + const router = useRouter(); + + const [step, setStep] = useState('signers'); + + const documentFlow: Record = { + signers: { + title: 'Add Placeholders', + description: 'Add all relevant placeholders for each recipient.', + stepIndex: 1, + }, + fields: { + title: 'Add Fields', + description: 'Add all relevant fields for each recipient.', + stepIndex: 2, + onBackStep: () => setStep('signers'), + }, + }; + + const currentDocumentFlow = documentFlow[step]; + + const onAddTemplatePlaceholderFormSubmit = async ( + data: TAddTemplatePlacholderRecipientsFormSchema, + ) => { + try { + await addTemplatePlaceholders({ + templateId: template.id, + signers: data.signers, + }); + + router.refresh(); + + setStep('fields'); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while adding signers.', + variant: 'destructive', + }); + } + }; + + const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => { + try { + await addTemplateFields({ + templateId: template.id, + fields: data.fields, + }); + + toast({ + title: 'Template saved', + description: 'Your templates has been saved successfully.', + duration: 5000, + }); + + router.push('/templates'); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while adding signers.', + variant: 'destructive', + }); + } + }; + + return ( +
+ + + + + + +
+ e.preventDefault()}> + + + {step === 'signers' && ( + + )} + + {step === 'fields' && ( + + )} + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/[id]/page.tsx b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx new file mode 100644 index 000000000..b8c645c80 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +import { ChevronLeft } from 'lucide-react'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template'; +import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template'; +import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; + +import { TemplateType } from '~/components/formatter/template-type'; + +import { EditTemplateForm } from './edit-template'; + +export type TemplatePageProps = { + params: { + id: string; + }; +}; + +export default async function TemplatePage({ params }: TemplatePageProps) { + const { id } = params; + + const templateId = Number(id); + + if (!templateId || Number.isNaN(templateId)) { + redirect('/documents'); + } + + const { user } = await getRequiredServerComponentSession(); + + const template = await getTemplateById({ + id: templateId, + userId: user.id, + }).catch(() => null); + + if (!template || !template.templateDocumentData) { + redirect('/documents'); + } + + const { templateDocumentData } = template; + + const [templateRecipients, templateFields] = await Promise.all([ + await getRecipientsForTemplate({ + templateId, + userId: user.id, + }), + await getFieldsForTemplate({ + templateId, + userId: user.id, + }), + ]); + + return ( +
+ + + Templates + + +

+ {template.title} +

+ +
+ +
+ + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx new file mode 100644 index 000000000..15ad9b58b --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState } from 'react'; + +import Link from 'next/link'; + +import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react'; +import { useSession } from 'next-auth/react'; + +import { Template } from '@documenso/prisma/client'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; + +import { DeleteTemplateDialog } from './delete-template-dialog'; +import { DuplicateTemplateDialog } from './duplicate-template-dialog'; + +export type DataTableActionDropdownProps = { + row: Template; +}; + +export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { + const { data: session } = useSession(); + + const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); + + if (!session) { + return null; + } + + const isOwner = row.userId === session.user.id; + + return ( + + + + + + + Action + + + + + Edit + + + + {/* onDuplicateButtonClick(row.id)}> */} + setDuplicateDialogOpen(true)}> + + Duplicate + + + setDeleteDialogOpen(true)}> + + Delete + + + + + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx new file mode 100644 index 000000000..3cc8102e7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Loader, Plus } from 'lucide-react'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { Template } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { LocaleDate } from '~/components/formatter/locale-date'; +import { TemplateType } from '~/components/formatter/template-type'; + +import { DataTableActionDropdown } from './data-table-action-dropdown'; +import { DataTableTitle } from './data-table-title'; + +type TemplatesDataTableProps = { + templates: Template[]; + perPage: number; + page: number; + totalPages: number; +}; + +export const TemplatesDataTable = ({ + templates, + perPage, + page, + totalPages, +}: TemplatesDataTableProps) => { + const [isPending, startTransition] = useTransition(); + const updateSearchParams = useUpdateSearchParams(); + + const router = useRouter(); + + const { toast } = useToast(); + const [loadingStates, setLoadingStates] = useState<{ [key: string]: boolean }>({}); + + const { mutateAsync: createDocumentFromTemplate } = + trpc.template.createDocumentFromTemplate.useMutation(); + + const onPaginationChange = (page: number, perPage: number) => { + startTransition(() => { + updateSearchParams({ + page, + perPage, + }); + }); + }; + + const onUseButtonClick = async (templateId: number) => { + try { + const { id } = await createDocumentFromTemplate({ + templateId, + }); + toast({ + title: 'Document created', + description: 'Your document has been created from the template successfully.', + duration: 5000, + }); + router.push(`/documents/${id}`); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while creating document from template.', + variant: 'destructive', + }); + } + }; + + return ( +
+ , + }, + { + header: 'Title', + cell: ({ row }) => , + }, + { + header: 'Type', + accessorKey: 'type', + cell: ({ row }) => , + }, + { + header: 'Actions', + accessorKey: 'actions', + cell: ({ row }) => { + const isRowLoading = loadingStates[row.original.id]; + + return ( +
+ + +
+ ); + }, + }, + ]} + data={templates} + perPage={perPage} + currentPage={page} + totalPages={totalPages} + onPaginationChange={onPaginationChange} + > + {(table) => } +
+ + {isPending && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-title.tsx b/apps/web/src/app/(dashboard)/templates/data-table-title.tsx new file mode 100644 index 000000000..31e1011be --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/data-table-title.tsx @@ -0,0 +1,26 @@ +import Link from 'next/link'; + +import { useSession } from 'next-auth/react'; + +import { Template } from '@documenso/prisma/client'; + +export type DataTableTitleProps = { + row: Template; +}; + +export const DataTableTitle = ({ row }: DataTableTitleProps) => { + const { data: session } = useSession(); + + if (!session) { + return null; + } + + return ( + + {row.title} + + ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx new file mode 100644 index 000000000..ed7db1e72 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx @@ -0,0 +1,84 @@ +import { useRouter } from 'next/navigation'; + +import { trpc as trpcReact } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +type DeleteTemplateDialogProps = { + id: number; + open: boolean; + onOpenChange: (_open: boolean) => void; +}; + +export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const { mutateAsync: deleteDocument, isLoading } = trpcReact.template.deleteTemplate.useMutation({ + onSuccess: () => { + router.refresh(); + + toast({ + title: 'Template deleted', + description: 'Your document has been successfully deleted.', + duration: 5000, + }); + + onOpenChange(false); + }, + }); + + const onDraftDelete = async () => { + try { + await deleteDocument({ id }); + } catch { + toast({ + title: 'Something went wrong', + description: 'This template could not be deleted at this time. Please try again.', + variant: 'destructive', + duration: 7500, + }); + } + }; + + return ( + !isLoading && onOpenChange(value)}> + + + Do you want to delete this template? + + + Please note that this action is irreversible. Once confirmed, your template will be + permanently deleted. + + + + +
+ + + +
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx new file mode 100644 index 000000000..5c3118035 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx @@ -0,0 +1,89 @@ +import { useRouter } from 'next/navigation'; + +import { trpc as trpcReact } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +type DuplicateTemplateDialogProps = { + id: number; + open: boolean; + onOpenChange: (_open: boolean) => void; +}; + +export const DuplicateTemplateDialog = ({ + id, + open, + onOpenChange, +}: DuplicateTemplateDialogProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const { mutateAsync: duplicateTemplate, isLoading } = + trpcReact.template.duplicateTemplate.useMutation({ + onSuccess: () => { + router.refresh(); + + toast({ + title: 'Template duplicated', + description: 'Your template has been duplicated successfully.', + duration: 5000, + }); + + onOpenChange(false); + }, + }); + + const onDuplicate = async () => { + try { + await duplicateTemplate({ + templateId: id, + }); + + router.refresh(); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while duplicating template.', + variant: 'destructive', + }); + } + }; + + return ( + !isLoading && onOpenChange(value)}> + + + Do you want to duplicate this template? + + Your template will be duplicated. + + + +
+ + + +
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/empty-state.tsx b/apps/web/src/app/(dashboard)/templates/empty-state.tsx new file mode 100644 index 000000000..b928d8a83 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/empty-state.tsx @@ -0,0 +1,17 @@ +import { Bird } from 'lucide-react'; + +export const EmptyTemplateState = () => { + return ( +
+ + +
+

We're all empty

+ +

+ You have not yet created any templates. To create a template please upload one. +

+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx new file mode 100644 index 000000000..7de1355a7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx @@ -0,0 +1,217 @@ +'use client'; + +import React, { useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { FilePlus, X } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import * as z from 'zod'; + +import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; +import { base64 } from '@documenso/lib/universal/base64'; +import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +const ZCreateTemplateFormSchema = z.object({ + name: z.string(), +}); + +type TCreateTemplateFormSchema = z.infer; + +export const NewTemplateDialog = () => { + const router = useRouter(); + const { toast } = useToast(); + const form = useForm({ + resolver: zodResolver(ZCreateTemplateFormSchema), + defaultValues: { + name: '', + }, + }); + + const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } = + trpc.template.createTemplate.useMutation(); + + const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false); + const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>(); + + const onFileDrop = async (file: File) => { + try { + const arrayBuffer = await file.arrayBuffer(); + const base64String = base64.encode(new Uint8Array(arrayBuffer)); + + setUploadedFile({ + file, + fileBase64: `data:application/pdf;base64,${base64String}`, + }); + + if (!form.getValues('name')) { + form.setValue('name', file.name); + } + } catch { + toast({ + title: 'Something went wrong', + description: 'Please try again later.', + variant: 'destructive', + }); + } + }; + + const onSubmit = async (values: TCreateTemplateFormSchema) => { + if (!uploadedFile) { + return; + } + + const file: File = uploadedFile.file; + + try { + const { type, data } = await putFile(file); + + const { id: templateDocumentDataId } = await createDocumentData({ + type, + data, + }); + + const { id } = await createTemplate({ + title: values.name ? values.name : file.name, + templateDocumentDataId, + }); + + toast({ + title: 'Template document uploaded', + description: + 'Your document has been uploaded successfully. You will be redirected to the template page.', + duration: 5000, + }); + + setShowNewTemplateDialog(false); + + void router.push(`/templates/${id}`); + } catch { + toast({ + title: 'Something went wrong', + description: 'Please try again later.', + variant: 'destructive', + }); + } + }; + + const resetForm = () => { + if (form.getValues('name') === uploadedFile?.file.name) { + form.reset(); + } + + setUploadedFile(null); + }; + + return ( + + + + + + + New Template + + +
+ + ( + + Name your template + + + + + + Leave this empty if you would like to use your document's name for the + template + + + + + )} + /> + +
+ +
+ {uploadedFile ? ( + + +
resetForm()} + className="absolute right-2 top-2 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none" + > + + Remove Template +
+ +
+
+
+
+
+

+ Uploaded Document +

+ + + {uploadedFile.file.name} + + + + ) : ( + + )} +
+
+ +
+ +
+ + + + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/page.tsx b/apps/web/src/app/(dashboard)/templates/page.tsx new file mode 100644 index 000000000..bc6a90b12 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/page.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { getTemplates } from '@documenso/lib/server-only/template/get-templates'; + +import { TemplatesDataTable } from './data-table-templates'; +import { EmptyTemplateState } from './empty-state'; +import { NewTemplateDialog } from './new-template-dialog'; + +type TemplatesPageProps = { + searchParams?: { + page?: number; + perPage?: number; + }; +}; + +export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) { + const { user } = await getRequiredServerComponentSession(); + const page = Number(searchParams.page) || 1; + const perPage = Number(searchParams.perPage) || 10; + + const { templates, totalPages } = await getTemplates({ + userId: user.id, + page: page, + perPage: perPage, + }); + + return ( +
+
+

Templates

+ +
+ +
+ {templates.length > 0 ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx index 76077cb04..bb3384d0a 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx @@ -3,6 +3,9 @@ import type { HTMLAttributes } from 'react'; import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + import { Search } from 'lucide-react'; import { cn } from '@documenso/ui/lib/utils'; @@ -10,10 +13,22 @@ import { Button } from '@documenso/ui/primitives/button'; import { CommandMenu } from '../common/command-menu'; +const navigationLinks = [ + { + href: '/documents', + label: 'Documents', + }, + { + href: '/templates', + label: 'Templates', + }, +]; + export type DesktopNavProps = HTMLAttributes; export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { - // const pathname = usePathname(); + const pathname = usePathname(); + const [open, setOpen] = useState(false); const [modifierKey, setModifierKey] = useState(() => 'Ctrl'); @@ -48,18 +63,20 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
- {/* We have no other subpaths rn */} - {/* - Documents - */} + {navigationLinks.map(({ href, label }) => ( + + {label} + + ))}
); }; diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx index e488ba6e9..2dcbb9864 100644 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { CreditCard, + FileSpreadsheet, Lock, LogOut, User as LucideUser, @@ -106,6 +107,13 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { )} + + + + + Templates + + diff --git a/apps/web/src/components/formatter/template-type.tsx b/apps/web/src/components/formatter/template-type.tsx new file mode 100644 index 000000000..a7f10105e --- /dev/null +++ b/apps/web/src/components/formatter/template-type.tsx @@ -0,0 +1,50 @@ +import { HTMLAttributes } from 'react'; + +import { Globe, Lock } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react/dist/lucide-react'; + +import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; + +type TemplateTypeIcon = { + label: string; + icon?: LucideIcon; + color: string; +}; + +type TemplateTypes = (typeof TemplateTypePrisma)[keyof typeof TemplateTypePrisma]; + +const TEMPLATE_TYPES: Record = { + PRIVATE: { + label: 'Private', + icon: Lock, + color: 'text-blue-600 dark:text-blue-300', + }, + PUBLIC: { + label: 'Public', + icon: Globe, + color: 'text-green-500 dark:text-green-300', + }, +}; + +export type TemplateTypeProps = HTMLAttributes & { + type: TemplateTypes; + inheritColor?: boolean; +}; + +export const TemplateType = ({ className, type, inheritColor, ...props }: TemplateTypeProps) => { + const { label, icon: Icon, color } = TEMPLATE_TYPES[type]; + + return ( + + {Icon && ( + + )} + {label} + + ); +}; diff --git a/apps/web/src/components/forms/edit-template/add-template-fields.action.ts b/apps/web/src/components/forms/edit-template/add-template-fields.action.ts new file mode 100644 index 000000000..2ee7ee825 --- /dev/null +++ b/apps/web/src/components/forms/edit-template/add-template-fields.action.ts @@ -0,0 +1,32 @@ +'use server'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template'; +import { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; + +export type AddTemplateFieldsActionInput = TAddTemplateFieldsFormSchema & { + templateId: number; +}; + +export const addTemplateFields = async ({ templateId, fields }: AddTemplateFieldsActionInput) => { + 'use server'; + + const { user } = await getRequiredServerComponentSession(); + + await setFieldsForTemplate({ + userId: user.id, + templateId, + fields: fields.map((field) => ({ + id: field.nativeId, + signerEmail: field.signerEmail, + signerId: field.signerId, + signerToken: field.signerToken, + type: field.type, + pageNumber: field.pageNumber, + pageX: field.pageX, + pageY: field.pageY, + pageWidth: field.pageWidth, + pageHeight: field.pageHeight, + })), + }); +}; diff --git a/apps/web/src/components/forms/edit-template/add-template-placeholders.action.ts b/apps/web/src/components/forms/edit-template/add-template-placeholders.action.ts new file mode 100644 index 000000000..b2183eed1 --- /dev/null +++ b/apps/web/src/components/forms/edit-template/add-template-placeholders.action.ts @@ -0,0 +1,28 @@ +'use server'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { setRecipientsForTemplate } from '@documenso/lib/server-only/recipient/set-recipients-for-template'; +import { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types'; + +export type AddTemplatePlaceholdersActionInput = TAddTemplatePlacholderRecipientsFormSchema & { + templateId: number; +}; + +export const addTemplatePlaceholders = async ({ + templateId, + signers, +}: AddTemplatePlaceholdersActionInput) => { + 'use server'; + + const { user } = await getRequiredServerComponentSession(); + + await setRecipientsForTemplate({ + userId: user.id, + templateId, + recipients: signers.map((signer) => ({ + id: signer.nativeId!, + email: signer.email, + name: signer.name!, + })), + }); +}; diff --git a/packages/lib/server-only/admin/get-recipients-stats.ts b/packages/lib/server-only/admin/get-recipients-stats.ts index f24d0b5a2..b6663e988 100644 --- a/packages/lib/server-only/admin/get-recipients-stats.ts +++ b/packages/lib/server-only/admin/get-recipients-stats.ts @@ -19,9 +19,10 @@ export const getRecipientsStats = async () => { results.forEach((result) => { const { readStatus, signingStatus, sendStatus, _count } = result; - stats[readStatus] += _count; - stats[signingStatus] += _count; - stats[sendStatus] += _count; + stats[readStatus as keyof typeof stats] += _count; + stats.TOTAL_RECIPIENTS += _count; + stats[signingStatus as keyof typeof stats] += _count; + stats[sendStatus as keyof typeof stats] += _count; stats.TOTAL_RECIPIENTS += _count; }); diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index a27458a55..18600ebe6 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -8,7 +8,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen import type { FindResultSet } from '../../types/find-result-set'; -export interface FindDocumentsOptions { +export type FindDocumentsOptions = { userId: number; term?: string; status?: ExtendedDocumentStatus; @@ -19,7 +19,7 @@ export interface FindDocumentsOptions { direction: 'asc' | 'desc'; }; period?: '' | '7d' | '14d' | '30d'; -} +}; export const findDocuments = async ({ userId, diff --git a/packages/lib/server-only/field/get-fields-for-template.ts b/packages/lib/server-only/field/get-fields-for-template.ts new file mode 100644 index 000000000..c174d7eff --- /dev/null +++ b/packages/lib/server-only/field/get-fields-for-template.ts @@ -0,0 +1,22 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetFieldsForTemplateOptions { + templateId: number; + userId: number; +} + +export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForTemplateOptions) => { + const fields = await prisma.field.findMany({ + where: { + templateId, + Template: { + userId, + }, + }, + orderBy: { + id: 'asc', + }, + }); + + return fields; +}; diff --git a/packages/lib/server-only/field/remove-signed-field-with-token.ts b/packages/lib/server-only/field/remove-signed-field-with-token.ts index 4a28e7627..ee472ec9f 100644 --- a/packages/lib/server-only/field/remove-signed-field-with-token.ts +++ b/packages/lib/server-only/field/remove-signed-field-with-token.ts @@ -27,6 +27,10 @@ export const removeSignedFieldWithToken = async ({ const { Document: document, Recipient: recipient } = field; + if (!document) { + throw new Error(`Document not found for field ${field.id}`); + } + if (document.status === DocumentStatus.COMPLETED) { throw new Error(`Document ${document.id} has already been completed`); } diff --git a/packages/lib/server-only/field/set-fields-for-template.ts b/packages/lib/server-only/field/set-fields-for-template.ts new file mode 100644 index 000000000..6e2e39afc --- /dev/null +++ b/packages/lib/server-only/field/set-fields-for-template.ts @@ -0,0 +1,116 @@ +import { prisma } from '@documenso/prisma'; +import { FieldType } from '@documenso/prisma/client'; + +export type Field = { + id?: number | null; + type: FieldType; + signerEmail: string; + signerId?: number; + pageNumber: number; + pageX: number; + pageY: number; + pageWidth: number; + pageHeight: number; +}; + +export type SetFieldsForTemplateOptions = { + userId: number; + templateId: number; + fields: Field[]; +}; + +export const setFieldsForTemplate = async ({ + userId, + templateId, + fields, +}: SetFieldsForTemplateOptions) => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + userId, + }, + }); + + if (!template) { + throw new Error('Document not found'); + } + + const existingFields = await prisma.field.findMany({ + where: { + templateId, + }, + include: { + Recipient: true, + }, + }); + + const removedFields = existingFields.filter( + (existingField) => + !fields.find( + (field) => + field.id === existingField.id || field.signerEmail === existingField.Recipient?.email, + ), + ); + + const linkedFields = fields.map((field) => { + const existing = existingFields.find((existingField) => existingField.id === field.id); + + return { + ...field, + _persisted: existing, + }; + }); + + const persistedFields = await prisma.$transaction( + // Disabling as wrapping promises here causes type issues + // eslint-disable-next-line @typescript-eslint/promise-function-async + linkedFields.map((field) => + prisma.field.upsert({ + where: { + id: field._persisted?.id ?? -1, + templateId, + }, + update: { + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + width: field.pageWidth, + height: field.pageHeight, + }, + create: { + type: field.type, + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + width: field.pageWidth, + height: field.pageHeight, + customText: '', + inserted: false, + Template: { + connect: { + id: templateId, + }, + }, + Recipient: { + connect: { + id: field.signerId, + email: field.signerEmail, + }, + }, + }, + }), + ), + ); + + if (removedFields.length > 0) { + await prisma.field.deleteMany({ + where: { + id: { + in: removedFields.map((field) => field.id), + }, + }, + }); + } + + return persistedFields; +}; diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts index 6640a6a07..59fab71e5 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -33,6 +33,10 @@ export const signFieldWithToken = async ({ const { Document: document, Recipient: recipient } = field; + if (!document) { + throw new Error(`Document not found for field ${field.id}`); + } + if (document.status === DocumentStatus.COMPLETED) { throw new Error(`Document ${document.id} has already been completed`); } diff --git a/packages/lib/server-only/recipient/get-recipients-for-template.ts b/packages/lib/server-only/recipient/get-recipients-for-template.ts new file mode 100644 index 000000000..ab6f860eb --- /dev/null +++ b/packages/lib/server-only/recipient/get-recipients-for-template.ts @@ -0,0 +1,25 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetRecipientsForTemplateOptions { + templateId: number; + userId: number; +} + +export const getRecipientsForTemplate = async ({ + templateId, + userId, +}: GetRecipientsForTemplateOptions) => { + const recipients = await prisma.recipient.findMany({ + where: { + templateId, + Template: { + userId, + }, + }, + orderBy: { + id: 'asc', + }, + }); + + return recipients; +}; diff --git a/packages/lib/server-only/recipient/set-recipients-for-template.ts b/packages/lib/server-only/recipient/set-recipients-for-template.ts new file mode 100644 index 000000000..2145d47b4 --- /dev/null +++ b/packages/lib/server-only/recipient/set-recipients-for-template.ts @@ -0,0 +1,98 @@ +import { prisma } from '@documenso/prisma'; + +import { nanoid } from '../../universal/id'; + +export type SetRecipientsForTemplateOptions = { + userId: number; + templateId: number; + recipients: { + id?: number; + email: string; + name: string; + }[]; +}; + +export const setRecipientsForTemplate = async ({ + userId, + templateId, + recipients, +}: SetRecipientsForTemplateOptions) => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + userId, + }, + }); + + if (!template) { + throw new Error('Template not found'); + } + + const normalizedRecipients = recipients.map((recipient) => ({ + ...recipient, + email: recipient.email.toLowerCase(), + })); + + const existingRecipients = await prisma.recipient.findMany({ + where: { + templateId, + }, + }); + + const removedRecipients = existingRecipients.filter( + (existingRecipient) => + !normalizedRecipients.find( + (recipient) => + recipient.id === existingRecipient.id || recipient.email === existingRecipient.email, + ), + ); + + const linkedRecipients = normalizedRecipients.map((recipient) => { + const existing = existingRecipients.find( + (existingRecipient) => + existingRecipient.id === recipient.id || existingRecipient.email === recipient.email, + ); + + return { + ...recipient, + _persisted: existing, + }; + }); + + const persistedRecipients = await prisma.$transaction( + // Disabling as wrapping promises here causes type issues + // eslint-disable-next-line @typescript-eslint/promise-function-async + linkedRecipients.map((recipient) => + prisma.recipient.upsert({ + where: { + id: recipient._persisted?.id ?? -1, + templateId, + }, + update: { + name: recipient.name, + email: recipient.email, + templateId, + }, + create: { + name: recipient.name, + email: recipient.email, + token: nanoid(), + templateToken: nanoid(), + templateId, + }, + }), + ), + ); + + if (removedRecipients.length > 0) { + await prisma.recipient.deleteMany({ + where: { + id: { + in: removedRecipients.map((recipient) => recipient.id), + }, + }, + }); + } + + return persistedRecipients; +}; diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts new file mode 100644 index 000000000..b0589821f --- /dev/null +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -0,0 +1,78 @@ +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { TCreateDocumentFromTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema'; + +export type CreateDocumentFromTemplateOptions = TCreateDocumentFromTemplateMutationSchema & { + userId: number; +}; + +export const createDocumentFromTemplate = async ({ + templateId, + userId, +}: CreateDocumentFromTemplateOptions) => { + const template = await prisma.template.findUnique({ + where: { id: templateId, userId }, + include: { + Recipient: true, + Field: true, + templateDocumentData: true, + }, + }); + + if (!template) { + throw new Error('Template not found.'); + } + + const documentData = await prisma.documentData.create({ + data: { + type: template.templateDocumentData.type, + data: template.templateDocumentData.data, + initialData: template.templateDocumentData.initialData, + }, + }); + + const document = await prisma.document.create({ + data: { + userId, + title: template.title, + documentDataId: documentData.id, + Recipient: { + create: template.Recipient.map((recipient) => ({ + email: recipient.email, + name: recipient.name, + token: nanoid(), + templateToken: recipient.templateToken, + })), + }, + }, + + include: { + Recipient: true, + }, + }); + + await prisma.field.createMany({ + data: template.Field.map((field) => { + const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId); + + const documentRecipient = document.Recipient.find( + (doc) => doc.templateToken === recipient?.templateToken, + ); + + return { + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + customText: field.customText, + inserted: field.inserted, + documentId: document.id, + recipientId: documentRecipient?.id || null, + }; + }), + }); + + return document; +}; diff --git a/packages/lib/server-only/template/create-template.ts b/packages/lib/server-only/template/create-template.ts new file mode 100644 index 000000000..d00526a64 --- /dev/null +++ b/packages/lib/server-only/template/create-template.ts @@ -0,0 +1,20 @@ +import { prisma } from '@documenso/prisma'; +import { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema'; + +export type CreateTemplateOptions = TCreateTemplateMutationSchema & { + userId: number; +}; + +export const createTemplate = async ({ + title, + userId, + templateDocumentDataId, +}: CreateTemplateOptions) => { + return await prisma.template.create({ + data: { + title, + userId, + templateDocumentDataId, + }, + }); +}; diff --git a/packages/lib/server-only/template/delete-template.ts b/packages/lib/server-only/template/delete-template.ts new file mode 100644 index 000000000..f693bcec0 --- /dev/null +++ b/packages/lib/server-only/template/delete-template.ts @@ -0,0 +1,12 @@ +'use server'; + +import { prisma } from '@documenso/prisma'; + +export type DeleteTemplateOptions = { + id: number; + userId: number; +}; + +export const deleteTemplate = async ({ id, userId }: DeleteTemplateOptions) => { + return await prisma.template.delete({ where: { id, userId } }); +}; diff --git a/packages/lib/server-only/template/duplicate-template.ts b/packages/lib/server-only/template/duplicate-template.ts new file mode 100644 index 000000000..14806707b --- /dev/null +++ b/packages/lib/server-only/template/duplicate-template.ts @@ -0,0 +1,75 @@ +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema'; + +export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & { + userId: number; +}; + +export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplateOptions) => { + const template = await prisma.template.findUnique({ + where: { id: templateId, userId }, + include: { + Recipient: true, + Field: true, + templateDocumentData: true, + }, + }); + + if (!template) { + throw new Error('Template not found.'); + } + + const documentData = await prisma.documentData.create({ + data: { + type: template.templateDocumentData.type, + data: template.templateDocumentData.data, + initialData: template.templateDocumentData.initialData, + }, + }); + + const duplicatedTemplate = await prisma.template.create({ + data: { + userId, + title: template.title + ' (copy)', + templateDocumentDataId: documentData.id, + Recipient: { + create: template.Recipient.map((recipient) => ({ + email: recipient.email, + name: recipient.name, + token: nanoid(), + templateToken: recipient.templateToken, + })), + }, + }, + + include: { + Recipient: true, + }, + }); + + await prisma.field.createMany({ + data: template.Field.map((field) => { + const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId); + + const duplicatedTemplateRecipient = duplicatedTemplate.Recipient.find( + (doc) => doc.templateToken === recipient?.templateToken, + ); + + return { + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + customText: field.customText, + inserted: field.inserted, + templateId: duplicatedTemplate.id, + recipientId: duplicatedTemplateRecipient?.id || null, + }; + }), + }); + + return duplicatedTemplate; +}; diff --git a/packages/lib/server-only/template/get-template-by-id.ts b/packages/lib/server-only/template/get-template-by-id.ts new file mode 100644 index 000000000..56f959a9b --- /dev/null +++ b/packages/lib/server-only/template/get-template-by-id.ts @@ -0,0 +1,18 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetTemplateByIdOptions { + id: number; + userId: number; +} + +export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) => { + return await prisma.template.findFirstOrThrow({ + where: { + id, + userId, + }, + include: { + templateDocumentData: true, + }, + }); +}; diff --git a/packages/lib/server-only/template/get-templates.ts b/packages/lib/server-only/template/get-templates.ts new file mode 100644 index 000000000..60de7cd89 --- /dev/null +++ b/packages/lib/server-only/template/get-templates.ts @@ -0,0 +1,37 @@ +import { prisma } from '@documenso/prisma'; + +export type GetTemplatesOptions = { + userId: number; + page: number; + perPage: number; +}; + +export const getTemplates = async ({ userId, page = 1, perPage = 10 }: GetTemplatesOptions) => { + const [templates, count] = await Promise.all([ + await prisma.template.findMany({ + where: { + userId, + }, + include: { + templateDocumentData: true, + Field: true, + }, + skip: Math.max(page - 1, 0) * perPage, + orderBy: { + createdAt: 'desc', + }, + }), + await prisma.template.count({ + where: { + User: { + id: userId, + }, + }, + }), + ]); + + return { + templates, + totalPages: Math.ceil(count / perPage), + }; +}; diff --git a/packages/prisma/migrations/20231007013737_templates/migration.sql b/packages/prisma/migrations/20231007013737_templates/migration.sql new file mode 100644 index 000000000..e0c1bf4ec --- /dev/null +++ b/packages/prisma/migrations/20231007013737_templates/migration.sql @@ -0,0 +1,52 @@ +-- CreateEnum +CREATE TYPE "TemplateStatus" AS ENUM ('PUBLIC', 'PRIVATE'); + +-- CreateTable +CREATE TABLE "Template" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "status" "TemplateStatus" NOT NULL DEFAULT 'PRIVATE', + "templateDataId" TEXT NOT NULL, + + CONSTRAINT "Template_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TemplateData" ( + "id" TEXT NOT NULL, + "type" "DocumentDataType" NOT NULL, + "data" TEXT NOT NULL, + "initialData" TEXT NOT NULL, + + CONSTRAINT "TemplateData_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TemplateField" ( + "id" SERIAL NOT NULL, + "templateId" INTEGER NOT NULL, + "type" "FieldType" NOT NULL, + "page" INTEGER NOT NULL, + "positionX" DECIMAL(65,30) NOT NULL DEFAULT 0, + "positionY" DECIMAL(65,30) NOT NULL DEFAULT 0, + "width" DECIMAL(65,30) NOT NULL DEFAULT -1, + "height" DECIMAL(65,30) NOT NULL DEFAULT -1, + "customText" TEXT NOT NULL, + "inserted" BOOLEAN NOT NULL, + + CONSTRAINT "TemplateField_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Template_templateDataId_key" ON "Template"("templateDataId"); + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDataId_fkey" FOREIGN KEY ("templateDataId") REFERENCES "TemplateData"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TemplateField" ADD CONSTRAINT "TemplateField_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231007014431_templates_type/migration.sql b/packages/prisma/migrations/20231007014431_templates_type/migration.sql new file mode 100644 index 000000000..c89e09a61 --- /dev/null +++ b/packages/prisma/migrations/20231007014431_templates_type/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - The `status` column on the `Template` table would be dropped and recreated. This will lead to data loss if there is data in the column. + +*/ +-- CreateEnum +CREATE TYPE "TemplateType" AS ENUM ('PUBLIC', 'PRIVATE'); + +-- AlterTable +ALTER TABLE "Template" DROP COLUMN "status", +ADD COLUMN "status" "TemplateType" NOT NULL DEFAULT 'PRIVATE'; + +-- DropEnum +DROP TYPE "TemplateStatus"; diff --git a/packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql b/packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql new file mode 100644 index 000000000..629f292fc --- /dev/null +++ b/packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the `TemplateData` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Template" DROP CONSTRAINT "Template_templateDataId_fkey"; + +-- DropTable +DROP TABLE "TemplateData"; + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDataId_fkey" FOREIGN KEY ("templateDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql b/packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql new file mode 100644 index 000000000..25ace4f72 --- /dev/null +++ b/packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `inserted` on the `TemplateField` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "TemplateField" DROP COLUMN "inserted"; diff --git a/packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql b/packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql new file mode 100644 index 000000000..45a52de3d --- /dev/null +++ b/packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `documentName` to the `Template` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Template" ADD COLUMN "documentName" TEXT NOT NULL; diff --git a/packages/prisma/migrations/20231007211915_template_created_date/migration.sql b/packages/prisma/migrations/20231007211915_template_created_date/migration.sql new file mode 100644 index 000000000..816da092e --- /dev/null +++ b/packages/prisma/migrations/20231007211915_template_created_date/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Added the required column `updatedAt` to the `Template` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Template" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; diff --git a/packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql b/packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql new file mode 100644 index 000000000..b84a567c8 --- /dev/null +++ b/packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql @@ -0,0 +1,51 @@ +/* + Warnings: + + - You are about to drop the column `description` on the `Template` table. All the data in the column will be lost. + - You are about to drop the column `documentName` on the `Template` table. All the data in the column will be lost. + - You are about to drop the column `status` on the `Template` table. All the data in the column will be lost. + - You are about to drop the column `templateDataId` on the `Template` table. All the data in the column will be lost. + - A unique constraint covering the columns `[tempateDocumentDataId]` on the table `Template` will be added. If there are existing duplicate values, this will fail. + - Added the required column `tempateDocumentDataId` to the `Template` table without a default value. This is not possible if the table is not empty. + - Added the required column `inserted` to the `TemplateField` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "Template" DROP CONSTRAINT "Template_templateDataId_fkey"; + +-- DropIndex +DROP INDEX "Template_templateDataId_key"; + +-- AlterTable +ALTER TABLE "Template" DROP COLUMN "description", +DROP COLUMN "documentName", +DROP COLUMN "status", +DROP COLUMN "templateDataId", +ADD COLUMN "tempateDocumentDataId" TEXT NOT NULL, +ADD COLUMN "type" "TemplateType" NOT NULL DEFAULT 'PRIVATE', +ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "TemplateField" ADD COLUMN "inserted" BOOLEAN NOT NULL, +ADD COLUMN "recipientId" INTEGER; + +-- CreateTable +CREATE TABLE "TemplateRecipient" ( + "id" SERIAL NOT NULL, + "templateId" INTEGER NOT NULL, + "placeholder" VARCHAR(255) NOT NULL, + + CONSTRAINT "TemplateRecipient_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Template_tempateDocumentDataId_key" ON "Template"("tempateDocumentDataId"); + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_tempateDocumentDataId_fkey" FOREIGN KEY ("tempateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TemplateRecipient" ADD CONSTRAINT "TemplateRecipient_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TemplateField" ADD CONSTRAINT "TemplateField_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "TemplateRecipient"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231017042227_fix_typo/migration.sql b/packages/prisma/migrations/20231017042227_fix_typo/migration.sql new file mode 100644 index 000000000..ac9eaf10e --- /dev/null +++ b/packages/prisma/migrations/20231017042227_fix_typo/migration.sql @@ -0,0 +1,23 @@ +/* + Warnings: + + - You are about to drop the column `tempateDocumentDataId` on the `Template` table. All the data in the column will be lost. + - A unique constraint covering the columns `[templateDocumentDataId]` on the table `Template` will be added. If there are existing duplicate values, this will fail. + - Added the required column `templateDocumentDataId` to the `Template` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "Template" DROP CONSTRAINT "Template_tempateDocumentDataId_fkey"; + +-- DropIndex +DROP INDEX "Template_tempateDocumentDataId_key"; + +-- AlterTable +ALTER TABLE "Template" DROP COLUMN "tempateDocumentDataId", +ADD COLUMN "templateDocumentDataId" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Template_templateDocumentDataId_key" ON "Template"("templateDocumentDataId"); + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDocumentDataId_fkey" FOREIGN KEY ("templateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql b/packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql new file mode 100644 index 000000000..266333794 --- /dev/null +++ b/packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `email` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "TemplateRecipient" ADD COLUMN "email" VARCHAR(255) NOT NULL; diff --git a/packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql b/packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql new file mode 100644 index 000000000..9ce1fa70a --- /dev/null +++ b/packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `token` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "TemplateRecipient" ADD COLUMN "token" TEXT NOT NULL; diff --git a/packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql b/packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql new file mode 100644 index 000000000..a6b3e7199 --- /dev/null +++ b/packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Recipient" ADD COLUMN "templateToken" TEXT; + +-- AlterTable +ALTER TABLE "TemplateRecipient" ADD COLUMN "templateToken" TEXT; diff --git a/packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql b/packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql new file mode 100644 index 000000000..8b9275c68 --- /dev/null +++ b/packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `placeholder` on the `TemplateRecipient` table. All the data in the column will be lost. + - Added the required column `name` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "TemplateRecipient" DROP COLUMN "placeholder", +ADD COLUMN "name" VARCHAR(255) NOT NULL; diff --git a/packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql b/packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql new file mode 100644 index 000000000..7e2af3ef8 --- /dev/null +++ b/packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql @@ -0,0 +1,54 @@ +/* + Warnings: + + - You are about to drop the `TemplateField` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `TemplateRecipient` table. If the table is not empty, all the data it contains will be lost. + - A unique constraint covering the columns `[templateId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropForeignKey +ALTER TABLE "Field" DROP CONSTRAINT "Field_recipientId_fkey"; + +-- DropForeignKey +ALTER TABLE "TemplateField" DROP CONSTRAINT "TemplateField_recipientId_fkey"; + +-- DropForeignKey +ALTER TABLE "TemplateField" DROP CONSTRAINT "TemplateField_templateId_fkey"; + +-- DropForeignKey +ALTER TABLE "TemplateRecipient" DROP CONSTRAINT "TemplateRecipient_templateId_fkey"; + +-- AlterTable +ALTER TABLE "Field" ADD COLUMN "templateId" INTEGER, +ALTER COLUMN "documentId" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "Recipient" ADD COLUMN "templateId" INTEGER, +ALTER COLUMN "documentId" DROP NOT NULL, +ALTER COLUMN "readStatus" DROP NOT NULL, +ALTER COLUMN "signingStatus" DROP NOT NULL, +ALTER COLUMN "sendStatus" DROP NOT NULL; + +-- DropTable +DROP TABLE "TemplateField"; + +-- DropTable +DROP TABLE "TemplateRecipient"; + +-- CreateIndex +CREATE INDEX "Field_templateId_idx" ON "Field"("templateId"); + +-- CreateIndex +CREATE INDEX "Recipient_templateId_idx" ON "Recipient"("templateId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Recipient_templateId_email_key" ON "Recipient"("templateId", "email"); + +-- AddForeignKey +ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Field" ADD CONSTRAINT "Field_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Field" ADD CONSTRAINT "Field_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 75c175adc..d21c4c637 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -40,6 +40,7 @@ model User { twoFactorEnabled Boolean @default(false) twoFactorBackupCodes String? VerificationToken VerificationToken[] + Template Template[] @@index([email]) } @@ -154,6 +155,7 @@ model DocumentData { data String initialData String Document Document? + Template Template? } model DocumentMeta { @@ -180,22 +182,27 @@ enum SigningStatus { } model Recipient { - id Int @id @default(autoincrement()) - documentId Int - email String @db.VarChar(255) - name String @default("") @db.VarChar(255) + id Int @id @default(autoincrement()) + documentId Int? + templateId Int? + email String @db.VarChar(255) + name String @default("") @db.VarChar(255) token String + templateToken String? expired DateTime? signedAt DateTime? - readStatus ReadStatus @default(NOT_OPENED) - signingStatus SigningStatus @default(NOT_SIGNED) - sendStatus SendStatus @default(NOT_SENT) - Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + readStatus ReadStatus? @default(NOT_OPENED) + signingStatus SigningStatus? @default(NOT_SIGNED) + sendStatus SendStatus? @default(NOT_SENT) + Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) + Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) Field Field[] Signature Signature[] @@unique([documentId, email]) + @@unique([templateId, email]) @@index([documentId]) + @@index([templateId]) @@index([token]) } @@ -210,7 +217,8 @@ enum FieldType { model Field { id Int @id @default(autoincrement()) - documentId Int + documentId Int? + templateId Int? recipientId Int? type FieldType page Int @@ -220,11 +228,13 @@ model Field { height Decimal @default(-1) customText String inserted Boolean - Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) - Recipient Recipient? @relation(fields: [recipientId], 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]) Signature Signature? @@index([documentId]) + @@index([templateId]) @@index([recipientId]) } @@ -254,3 +264,24 @@ model DocumentShareLink { @@unique([documentId, email]) } + +enum TemplateType { + PUBLIC + PRIVATE +} + +model Template { + id Int @id @default(autoincrement()) + userId Int + User User @relation(fields: [userId], references: [id], onDelete: Cascade) + title String + type TemplateType @default(PRIVATE) + templateDocumentDataId String + templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + Recipient Recipient[] + Field Field[] + + @@unique([templateDocumentDataId]) +} diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index bf8a03ce1..77d18e06d 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -6,6 +6,7 @@ import { profileRouter } from './profile-router/router'; import { recipientRouter } from './recipient-router/router'; import { shareLinkRouter } from './share-link-router/router'; import { singleplayerRouter } from './singleplayer-router/router'; +import { templateRouter } from './template-router/router'; import { router } from './trpc'; import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router'; @@ -19,6 +20,7 @@ export const appRouter = router({ shareLink: shareLinkRouter, singleplayer: singleplayerRouter, twoFactorAuthentication: twoFactorAuthenticationRouter, + template: templateRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts new file mode 100644 index 000000000..e18f4cb4a --- /dev/null +++ b/packages/trpc/server/template-router/router.ts @@ -0,0 +1,94 @@ +import { TRPCError } from '@trpc/server'; + +import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; +import { createTemplate } from '@documenso/lib/server-only/template/create-template'; +import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; +import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template'; + +import { authenticatedProcedure, router } from '../trpc'; +import { + ZCreateDocumentFromTemplateMutationSchema, + ZCreateTemplateMutationSchema, + ZDeleteTemplateMutationSchema, + ZDuplicateTemplateMutationSchema, +} from './schema'; + +export const templateRouter = router({ + createTemplate: authenticatedProcedure + .input(ZCreateTemplateMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { title, templateDocumentDataId } = input; + + return await createTemplate({ + title, + userId: ctx.user.id, + templateDocumentDataId, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to create this template. Please try again later.', + }); + } + }), + + createDocumentFromTemplate: authenticatedProcedure + .input(ZCreateDocumentFromTemplateMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { templateId } = input; + + return await createDocumentFromTemplate({ + templateId, + userId: ctx.user.id, + }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to create this document. Please try again later.', + }); + } + }), + + duplicateTemplate: authenticatedProcedure + .input(ZDuplicateTemplateMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { templateId } = input; + + return await duplicateTemplate({ + templateId, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to duplicate the template. Please try again later.', + }); + } + }), + + deleteTemplate: authenticatedProcedure + .input(ZDeleteTemplateMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { id } = input; + + const userId = ctx.user.id; + + return await deleteTemplate({ id, userId }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to delete this template. Please try again later.', + }); + } + }), +}); diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts new file mode 100644 index 000000000..bc7161f74 --- /dev/null +++ b/packages/trpc/server/template-router/schema.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +export const ZCreateTemplateMutationSchema = z.object({ + title: z.string().min(1), + templateDocumentDataId: z.string().min(1), +}); + +export const ZCreateDocumentFromTemplateMutationSchema = z.object({ + templateId: z.number(), +}); + +export const ZDuplicateTemplateMutationSchema = z.object({ + templateId: z.number(), +}); + +export const ZDeleteTemplateMutationSchema = z.object({ + id: z.number().min(1), +}); + +export type TCreateTemplateMutationSchema = z.infer; +export type TCreateDocumentFromTemplateMutationSchema = z.infer< + typeof ZCreateDocumentFromTemplateMutationSchema +>; + +export type TDuplicateTemplateMutationSchema = z.infer; +export type TDeleteTemplateMutationSchema = z.infer; diff --git a/packages/ui/primitives/document-dropzone.tsx b/packages/ui/primitives/document-dropzone.tsx index d81a3a7de..9ae4c2adb 100644 --- a/packages/ui/primitives/document-dropzone.tsx +++ b/packages/ui/primitives/document-dropzone.tsx @@ -75,10 +75,20 @@ const DocumentDropzoneCardCenterVariants: Variants = { }, }; +const DocumentDescription = { + document: { + headline: 'Add a document', + }, + template: { + headline: 'Upload Template Document', + }, +}; + export type DocumentDropzoneProps = { className?: string; disabled?: boolean; onDrop?: (_file: File) => void | Promise; + type?: 'document' | 'template'; [key: string]: unknown; }; @@ -86,6 +96,7 @@ export const DocumentDropzone = ({ className, onDrop, disabled, + type = 'document', ...props }: DocumentDropzoneProps) => { const { getRootProps, getInputProps } = useDropzone({ @@ -157,7 +168,7 @@ export const DocumentDropzone = ({

- Add a document + {DocumentDescription[type].headline}

Drag & drop your document here.

diff --git a/packages/ui/primitives/document-flow/types.ts b/packages/ui/primitives/document-flow/types.ts index 677dc931b..82f5706e6 100644 --- a/packages/ui/primitives/document-flow/types.ts +++ b/packages/ui/primitives/document-flow/types.ts @@ -24,7 +24,7 @@ export const ZDocumentFlowFormSchema = z.object({ formId: z.string().min(1), nativeId: z.number().optional(), type: z.nativeEnum(FieldType), - signerEmail: z.string().min(1), + signerEmail: z.string().min(1).optional(), pageNumber: z.number().min(1), pageX: z.number().min(0), pageY: z.number().min(0), diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx new file mode 100644 index 000000000..138c73ea7 --- /dev/null +++ b/packages/ui/primitives/template-flow/add-template-fields.tsx @@ -0,0 +1,539 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { Caveat } from 'next/font/google'; + +import { ChevronsUpDown } from 'lucide-react'; +import { useFieldArray, useForm } from 'react-hook-form'; + +import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; +import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { nanoid } from '@documenso/lib/universal/id'; +import { Field, FieldType, Recipient } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@documenso/ui/primitives/command'; +import { + DocumentFlowFormContainerActions, + DocumentFlowFormContainerContent, + DocumentFlowFormContainerFooter, + DocumentFlowFormContainerStep, +} from '@documenso/ui/primitives/document-flow/document-flow-root'; +import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item'; +import { + DocumentFlowStep, + FRIENDLY_FIELD_TYPE, +} from '@documenso/ui/primitives/document-flow/types'; +import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; + +// import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import { TAddTemplateFieldsFormSchema } from './add-template-fields.types'; + +const fontCaveat = Caveat({ + weight: ['500'], + subsets: ['latin'], + display: 'swap', + variable: '--font-caveat', +}); + +const DEFAULT_HEIGHT_PERCENT = 5; +const DEFAULT_WIDTH_PERCENT = 15; + +const MIN_HEIGHT_PX = 60; +const MIN_WIDTH_PX = 200; + +export type AddTemplateFieldsFormProps = { + documentFlow: DocumentFlowStep; + hideRecipients?: boolean; + recipients: Recipient[]; + fields: Field[]; + numberOfSteps: number; + onSubmit: (_data: TAddTemplateFieldsFormSchema) => void; +}; + +export const AddTemplateFieldsFormPartial = ({ + documentFlow, + hideRecipients = false, + recipients, + fields, + numberOfSteps, + onSubmit, +}: AddTemplateFieldsFormProps) => { + const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement(); + + const { + control, + handleSubmit, + formState: { isSubmitting }, + } = useForm({ + defaultValues: { + fields: fields.map((field) => ({ + nativeId: field.id, + formId: `${field.id}-${field.templateId}`, + pageNumber: field.page, + type: field.type, + pageX: Number(field.positionX), + pageY: Number(field.positionY), + pageWidth: Number(field.width), + pageHeight: Number(field.height), + signerId: field.recipientId ?? -1, + signerEmail: + recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '', + signerToken: + recipients.find((recipient) => recipient.id === field.recipientId)?.templateToken ?? '', + })), + }, + }); + + const onFormSubmit = handleSubmit(onSubmit); + + const { + append, + remove, + update, + fields: localFields, + } = useFieldArray({ + control, + name: 'fields', + }); + + const [selectedField, setSelectedField] = useState(null); + const [selectedSigner, setSelectedSigner] = useState(null); + const [showRecipientsSelector, setShowRecipientsSelector] = useState(false); + + const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false); + const [coords, setCoords] = useState({ + x: 0, + y: 0, + }); + + const fieldBounds = useRef({ + height: 0, + width: 0, + }); + + const onMouseMove = useCallback( + (event: MouseEvent) => { + setIsFieldWithinBounds( + isWithinPageBounds( + event, + PDF_VIEWER_PAGE_SELECTOR, + fieldBounds.current.width, + fieldBounds.current.height, + ), + ); + + setCoords({ + x: event.clientX - fieldBounds.current.width / 2, + y: event.clientY - fieldBounds.current.height / 2, + }); + }, + [isWithinPageBounds], + ); + + const onMouseClick = useCallback( + (event: MouseEvent) => { + if (!selectedField || !selectedSigner) { + return; + } + + const $page = getPage(event, PDF_VIEWER_PAGE_SELECTOR); + + if ( + !$page || + !isWithinPageBounds( + event, + PDF_VIEWER_PAGE_SELECTOR, + fieldBounds.current.width, + fieldBounds.current.height, + ) + ) { + setSelectedField(null); + return; + } + + const { top, left, height, width } = getBoundingClientRect($page); + + const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10); + + // Calculate x and y as a percentage of the page width and height + let pageX = ((event.pageX - left) / width) * 100; + let pageY = ((event.pageY - top) / height) * 100; + + // Get the bounds as a percentage of the page width and height + const fieldPageWidth = (fieldBounds.current.width / width) * 100; + const fieldPageHeight = (fieldBounds.current.height / height) * 100; + + // And center it based on the bounds + pageX -= fieldPageWidth / 2; + pageY -= fieldPageHeight / 2; + + append({ + formId: nanoid(12), + type: selectedField, + pageNumber, + pageX, + pageY, + pageWidth: fieldPageWidth, + pageHeight: fieldPageHeight, + signerEmail: selectedSigner.email, + signerId: selectedSigner.id, + signerToken: selectedSigner.templateToken ?? '', + }); + + setIsFieldWithinBounds(false); + setSelectedField(null); + }, + [append, isWithinPageBounds, selectedField, selectedSigner, getPage], + ); + + const onFieldResize = useCallback( + (node: HTMLElement, index: number) => { + const field = localFields[index]; + + const $page = window.document.querySelector( + `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, + ); + + if (!$page) { + return; + } + + const { + x: pageX, + y: pageY, + width: pageWidth, + height: pageHeight, + } = getFieldPosition($page, node); + + update(index, { + ...field, + pageX, + pageY, + pageWidth, + pageHeight, + }); + }, + [getFieldPosition, localFields, update], + ); + + const onFieldMove = useCallback( + (node: HTMLElement, index: number) => { + const field = localFields[index]; + + const $page = window.document.querySelector( + `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, + ); + + if (!$page) { + return; + } + + const { x: pageX, y: pageY } = getFieldPosition($page, node); + + update(index, { + ...field, + pageX, + pageY, + }); + }, + [getFieldPosition, localFields, update], + ); + + useEffect(() => { + if (selectedField) { + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseClick); + } + + return () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseClick); + }; + }, [onMouseClick, onMouseMove, selectedField]); + + useEffect(() => { + const observer = new MutationObserver((_mutations) => { + const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR); + + if (!$page) { + return; + } + + const { height, width } = $page.getBoundingClientRect(); + + fieldBounds.current = { + height: Math.max(height * (DEFAULT_HEIGHT_PERCENT / 100), MIN_HEIGHT_PX), + width: Math.max(width * (DEFAULT_WIDTH_PERCENT / 100), MIN_WIDTH_PX), + }; + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + return () => { + observer.disconnect(); + }; + }, []); + + useEffect(() => { + setSelectedSigner(recipients[0]); + }, [recipients]); + + return ( + <> + +
+ {selectedField && ( + + + {FRIENDLY_FIELD_TYPE[selectedField]} + + + )} + + {localFields.map((field, index) => ( + onFieldResize(options, index)} + onMove={(options) => onFieldMove(options, index)} + onRemove={() => remove(index)} + /> + ))} + + {!hideRecipients && ( + + + + + + + + + + + No recipient matching this description was found. + + + + + {recipients.map((recipient, index) => ( + { + setSelectedSigner(recipient); + setShowRecipientsSelector(false); + }} + > + {/* {recipient.sendStatus !== SendStatus.SENT ? ( + + ) : ( + + + + + + This document has already been sent to this recipient. You can no + longer edit this recipient. + + + )} */} + + {recipient.name && ( + + {recipient.name} ({recipient.email}) + + )} + + {!recipient.name && ( + + {recipient.email} + + )} + + ))} + + + + + )} + +
+
+ + + + + + + +
+
+
+
+ + + + + { + documentFlow.onBackStep?.(); + remove(); + }} + onGoNextClick={() => void onFormSubmit()} + /> + + + ); +}; diff --git a/packages/ui/primitives/template-flow/add-template-fields.types.ts b/packages/ui/primitives/template-flow/add-template-fields.types.ts new file mode 100644 index 000000000..4406f82a0 --- /dev/null +++ b/packages/ui/primitives/template-flow/add-template-fields.types.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +import { FieldType } from '@documenso/prisma/client'; + +export const ZAddTemplateFieldsFormSchema = z.object({ + fields: z.array( + z.object({ + formId: z.string().min(1), + nativeId: z.number().optional(), + type: z.nativeEnum(FieldType), + signerEmail: z.string().min(1), + signerToken: z.string(), + signerId: z.number().optional(), + pageNumber: z.number().min(1), + pageX: z.number().min(0), + pageY: z.number().min(0), + pageWidth: z.number().min(0), + pageHeight: z.number().min(0), + }), + ), +}); + +export type TAddTemplateFieldsFormSchema = z.infer; diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx new file mode 100644 index 000000000..498314133 --- /dev/null +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -0,0 +1,205 @@ +'use client'; + +import React, { useId, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Plus, Trash } from 'lucide-react'; +import { Controller, useFieldArray, useForm } from 'react-hook-form'; + +import { nanoid } from '@documenso/lib/universal/id'; +import { Field, Recipient } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; + +import { + DocumentFlowFormContainerActions, + DocumentFlowFormContainerContent, + DocumentFlowFormContainerFooter, + DocumentFlowFormContainerStep, +} from '../document-flow/document-flow-root'; +import { DocumentFlowStep } from '../document-flow/types'; +import { + TAddTemplatePlacholderRecipientsFormSchema, + ZAddTemplatePlacholderRecipientsFormSchema, +} from './add-template-placeholder-recipients.types'; + +export type AddTemplatePlaceholderRecipientsFormProps = { + documentFlow: DocumentFlowStep; + recipients: Recipient[]; + fields: Field[]; + numberOfSteps: number; + onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void; +}; + +export const AddTemplatePlaceholderRecipientsFormPartial = ({ + documentFlow, + numberOfSteps, + recipients, + fields: _fields, + onSubmit, +}: AddTemplatePlaceholderRecipientsFormProps) => { + const initialId = useId(); + const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(1); + + const { + control, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema), + defaultValues: { + signers: + recipients.length > 0 + ? recipients.map((recipient) => ({ + nativeId: recipient.id, + formId: String(recipient.id), + name: recipient.name, + email: recipient.email, + })) + : [ + { + formId: initialId, + name: `John Doe`, + email: `johndoe@documenso.com`, + }, + ], + }, + }); + + const onFormSubmit = handleSubmit(onSubmit); + + const { + append: appendSigner, + fields: signers, + remove: removeSigner, + } = useFieldArray({ + control, + name: 'signers', + }); + + const onAddPlaceholderRecipient = () => { + setPlaceholderRecipientCount(placeholderRecipientCount + 1); + + appendSigner({ + formId: nanoid(12), + name: `John Doe ${placeholderRecipientCount}`, + email: `johndoe${placeholderRecipientCount}@documenso.com`, + }); + }; + + const onRemoveSigner = (index: number) => { + removeSigner(index); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { + onAddPlaceholderRecipient(); + } + }; + + return ( + <> + +
+ + {signers.map((signer, index) => ( + +
+ + + ( + + )} + /> +
+ +
+ + + ( + + )} + /> +
+ +
+ +
+ +
+ + +
+
+ ))} +
+
+ + + +
+ +
+
+ + + + + void onFormSubmit()} + /> + + + ); +}; diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts new file mode 100644 index 000000000..89c197f5e --- /dev/null +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.types.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +export const ZAddTemplatePlacholderRecipientsFormSchema = z + .object({ + signers: z.array( + z.object({ + formId: z.string().min(1), + nativeId: z.number().optional(), + email: z.string().min(1).email(), + name: z.string().optional(), + }), + ), + }) + .refine( + (schema) => { + const emails = schema.signers.map((signer) => signer.email.toLowerCase()); + + return new Set(emails).size === emails.length; + }, + // Dirty hack to handle errors when .root is populated for an array type + { message: 'Signers must have unique emails', path: ['signers__root'] }, + ); + +export type TAddTemplatePlacholderRecipientsFormSchema = z.infer< + typeof ZAddTemplatePlacholderRecipientsFormSchema +>; From 1eeb5fb103f81be2866177cc9aa9b4004b918e1c Mon Sep 17 00:00:00 2001 From: Mythie Date: Thu, 14 Dec 2023 15:28:27 +1100 Subject: [PATCH 014/156] fix: tidy code and base on main --- .../templates/[id]/edit-template.tsx | 27 ++++--- .../app/(dashboard)/templates/[id]/page.tsx | 2 +- .../templates/data-table-action-dropdown.tsx | 2 +- .../templates/data-table-templates.tsx | 4 +- .../templates/new-template-dialog.tsx | 39 ++++++---- .../src/app/(dashboard)/templates/page.tsx | 9 ++- .../(dashboard)/layout/desktop-nav.tsx | 37 +++++---- .../components/(dashboard)/layout/header.tsx | 2 +- .../add-template-fields.action.ts | 4 +- .../add-template-placeholders.action.ts | 4 +- packages/ui/primitives/dialog.tsx | 2 +- .../template-flow/add-template-fields.tsx | 22 +++--- .../add-template-placeholder-recipients.tsx | 78 ++++++++----------- 13 files changed, 121 insertions(+), 111 deletions(-) diff --git a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx index b4d20b60d..920cac247 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx @@ -4,19 +4,20 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client'; +import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { DocumentFlowFormContainer, DocumentFlowFormContainerHeader, } from '@documenso/ui/primitives/document-flow/document-flow-root'; -import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { Stepper } from '@documenso/ui/primitives/stepper'; import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields'; -import { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; +import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients'; -import { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types'; +import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { addTemplateFields } from '~/components/forms/edit-template/add-template-fields.action'; @@ -32,6 +33,7 @@ export type EditTemplateFormProps = { }; type EditTemplateStep = 'signers' | 'fields'; +const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields']; export const EditTemplateForm = ({ className, @@ -56,7 +58,6 @@ export const EditTemplateForm = ({ title: 'Add Fields', description: 'Add all relevant fields for each recipient.', stepIndex: 2, - onBackStep: () => setStep('signers'), }, }; @@ -118,33 +119,35 @@ export const EditTemplateForm = ({
- e.preventDefault()}> + e.preventDefault()} + > - {step === 'signers' && ( + setStep(EditTemplateSteps[step - 1])} + > - )} - {step === 'fields' && ( - )} +
diff --git a/apps/web/src/app/(dashboard)/templates/[id]/page.tsx b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx index b8c645c80..15eaa6f3c 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx @@ -5,7 +5,7 @@ import { redirect } from 'next/navigation'; import { ChevronLeft } from 'lucide-react'; -import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template'; import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx index 15ad9b58b..9f26d632c 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx @@ -7,7 +7,7 @@ import Link from 'next/link'; import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react'; import { useSession } from 'next-auth/react'; -import { Template } from '@documenso/prisma/client'; +import type { Template } from '@documenso/prisma/client'; import { DropdownMenu, DropdownMenuContent, diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx index 3cc8102e7..629204c2a 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -7,7 +7,7 @@ import { useRouter } from 'next/navigation'; import { Loader, Plus } from 'lucide-react'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import { Template } from '@documenso/prisma/client'; +import type { Template } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { DataTable } from '@documenso/ui/primitives/data-table'; @@ -109,7 +109,7 @@ export const TemplatesDataTable = ({ }} > {!isRowLoading && } - Use + Use Template
diff --git a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx index 7de1355a7..19a465001 100644 --- a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx @@ -1,11 +1,12 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; import { FilePlus, X } from 'lucide-react'; +import { useSession } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; @@ -18,7 +19,6 @@ import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Dialog, DialogContent, - DialogDescription, DialogHeader, DialogTitle, DialogTrigger, @@ -45,7 +45,9 @@ type TCreateTemplateFormSchema = z.infer; export const NewTemplateDialog = () => { const router = useRouter(); + const { data: session } = useSession(); const { toast } = useToast(); + const form = useForm({ resolver: zodResolver(ZCreateTemplateFormSchema), defaultValues: { @@ -128,23 +130,29 @@ export const NewTemplateDialog = () => { setUploadedFile(null); }; + useEffect(() => { + if (!showNewTemplateDialog) { + form.reset(); + } + }, [form, showNewTemplateDialog]); + return ( - + New Template - + +
- + {
+
{uploadedFile ? ( -
resetForm()} - className="absolute right-2 top-2 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none" + title="Remove Template" + className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none" > - + Remove Template -
+
+

Uploaded Document

@@ -210,7 +221,7 @@ export const NewTemplateDialog = () => {
- +
); diff --git a/apps/web/src/app/(dashboard)/templates/page.tsx b/apps/web/src/app/(dashboard)/templates/page.tsx index bc6a90b12..f4167e42a 100644 --- a/apps/web/src/app/(dashboard)/templates/page.tsx +++ b/apps/web/src/app/(dashboard)/templates/page.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getTemplates } from '@documenso/lib/server-only/template/get-templates'; import { TemplatesDataTable } from './data-table-templates'; @@ -27,9 +27,12 @@ export default async function TemplatesPage({ searchParams = {} }: TemplatesPage return (
-
+

Templates

- + +
+ +
diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx index bb3384d0a..e04bc2818 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx @@ -41,9 +41,29 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { return (
- - {navigationLinks.map(({ href, label }) => ( - - {label} - - ))}
); }; diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx index 25f260575..cf8873a1a 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/layout/header.tsx @@ -49,7 +49,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => { -
+
{/* -
diff --git a/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx index 5c3118035..be743ff48 100644 --- a/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx @@ -47,8 +47,6 @@ export const DuplicateTemplateDialog = ({ await duplicateTemplate({ templateId: id, }); - - router.refresh(); } catch (err) { toast({ title: 'Error', diff --git a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx index 19a465001..a4aa9bce2 100644 --- a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx @@ -49,10 +49,10 @@ export const NewTemplateDialog = () => { const { toast } = useToast(); const form = useForm({ - resolver: zodResolver(ZCreateTemplateFormSchema), defaultValues: { name: '', }, + resolver: zodResolver(ZCreateTemplateFormSchema), }); const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } = diff --git a/packages/lib/server-only/admin/get-recipients-stats.ts b/packages/lib/server-only/admin/get-recipients-stats.ts index b6663e988..07368b5a1 100644 --- a/packages/lib/server-only/admin/get-recipients-stats.ts +++ b/packages/lib/server-only/admin/get-recipients-stats.ts @@ -19,10 +19,11 @@ export const getRecipientsStats = async () => { results.forEach((result) => { const { readStatus, signingStatus, sendStatus, _count } = result; - stats[readStatus as keyof typeof stats] += _count; - stats.TOTAL_RECIPIENTS += _count; - stats[signingStatus as keyof typeof stats] += _count; - stats[sendStatus as keyof typeof stats] += _count; + + stats[readStatus] += _count; + stats[signingStatus] += _count; + stats[sendStatus] += _count; + stats.TOTAL_RECIPIENTS += _count; }); diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts index 664be3b91..bd14d49b2 100644 --- a/packages/lib/server-only/field/set-fields-for-document.ts +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -1,5 +1,6 @@ import { prisma } from '@documenso/prisma'; -import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client'; +import type { FieldType } from '@documenso/prisma/client'; +import { SendStatus, SigningStatus } from '@documenso/prisma/client'; export interface SetFieldsForDocumentOptions { userId: number; diff --git a/packages/lib/server-only/field/set-fields-for-template.ts b/packages/lib/server-only/field/set-fields-for-template.ts index 6e2e39afc..9431666bf 100644 --- a/packages/lib/server-only/field/set-fields-for-template.ts +++ b/packages/lib/server-only/field/set-fields-for-template.ts @@ -1,5 +1,5 @@ import { prisma } from '@documenso/prisma'; -import { FieldType } from '@documenso/prisma/client'; +import type { FieldType } from '@documenso/prisma/client'; export type Field = { id?: number | null; @@ -32,7 +32,7 @@ export const setFieldsForTemplate = async ({ }); if (!template) { - throw new Error('Document not found'); + throw new Error('Template not found'); } const existingFields = await prisma.field.findMany({ @@ -93,8 +93,10 @@ export const setFieldsForTemplate = async ({ }, Recipient: { connect: { - id: field.signerId, - email: field.signerEmail, + templateId_email: { + templateId, + email: field.signerEmail.toLowerCase(), + }, }, }, }, diff --git a/packages/prisma/migrations/20231221070056_make_recipient_status_columns_non_null_again/migration.sql b/packages/prisma/migrations/20231221070056_make_recipient_status_columns_non_null_again/migration.sql new file mode 100644 index 000000000..d2ebc6405 --- /dev/null +++ b/packages/prisma/migrations/20231221070056_make_recipient_status_columns_non_null_again/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Made the column `readStatus` on table `Recipient` required. This step will fail if there are existing NULL values in that column. + - Made the column `signingStatus` on table `Recipient` required. This step will fail if there are existing NULL values in that column. + - Made the column `sendStatus` on table `Recipient` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Recipient" ALTER COLUMN "readStatus" SET NOT NULL, +ALTER COLUMN "signingStatus" SET NOT NULL, +ALTER COLUMN "sendStatus" SET NOT NULL; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index eb34ae903..67fb182a7 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -189,9 +189,9 @@ model Recipient { token String expired DateTime? signedAt DateTime? - readStatus ReadStatus? @default(NOT_OPENED) - signingStatus SigningStatus? @default(NOT_SIGNED) - sendStatus SendStatus? @default(NOT_SENT) + readStatus ReadStatus @default(NOT_OPENED) + signingStatus SigningStatus @default(NOT_SIGNED) + sendStatus SendStatus @default(NOT_SENT) Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) Field Field[] From 519c645d0670f9ad75d1f3c1ea9a2ece41df3ca5 Mon Sep 17 00:00:00 2001 From: harkiratsm Date: Thu, 21 Dec 2023 15:36:24 +0530 Subject: [PATCH 027/156] fix: hotkeys was overlapping with the browser hotkeys Signed-off-by: harkiratsm --- apps/web/src/components/(dashboard)/common/command-menu.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index 2e352b45a..19a35874e 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -85,7 +85,8 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const currentPage = pages[pages.length - 1]; - const toggleOpen = () => { + const toggleOpen = (e: KeyboardEvent) => { + e.preventDefault(); setIsOpen((isOpen) => !isOpen); onOpenChange?.(!isOpen); From 972c20f906c1c1b56265e8428a6bd1188a0db4ff Mon Sep 17 00:00:00 2001 From: Mythie Date: Thu, 21 Dec 2023 21:20:37 +1100 Subject: [PATCH 028/156] chore: tidy migrations --- .../20231007013737_templates/migration.sql | 52 ------------- .../migration.sql | 15 ---- .../migration.sql | 14 ---- .../migration.sql | 8 -- .../migration.sql | 8 -- .../migration.sql | 9 --- .../migration.sql | 51 ------------- .../20231017042227_fix_typo/migration.sql | 23 ------ .../migration.sql | 8 -- .../migration.sql | 8 -- .../migration.sql | 5 -- .../migration.sql | 10 --- .../migration.sql | 54 -------------- .../migration.sql | 8 -- .../migration.sql | 12 --- .../migration.sql | 73 +++++++++++++++++++ 16 files changed, 73 insertions(+), 285 deletions(-) delete mode 100644 packages/prisma/migrations/20231007013737_templates/migration.sql delete mode 100644 packages/prisma/migrations/20231007014431_templates_type/migration.sql delete mode 100644 packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql delete mode 100644 packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql delete mode 100644 packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql delete mode 100644 packages/prisma/migrations/20231007211915_template_created_date/migration.sql delete mode 100644 packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql delete mode 100644 packages/prisma/migrations/20231017042227_fix_typo/migration.sql delete mode 100644 packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql delete mode 100644 packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql delete mode 100644 packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql delete mode 100644 packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql delete mode 100644 packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql delete mode 100644 packages/prisma/migrations/20231214081915_remove_template_token_column/migration.sql delete mode 100644 packages/prisma/migrations/20231221070056_make_recipient_status_columns_non_null_again/migration.sql create mode 100644 packages/prisma/migrations/20231221101005_add_templates/migration.sql diff --git a/packages/prisma/migrations/20231007013737_templates/migration.sql b/packages/prisma/migrations/20231007013737_templates/migration.sql deleted file mode 100644 index e0c1bf4ec..000000000 --- a/packages/prisma/migrations/20231007013737_templates/migration.sql +++ /dev/null @@ -1,52 +0,0 @@ --- CreateEnum -CREATE TYPE "TemplateStatus" AS ENUM ('PUBLIC', 'PRIVATE'); - --- CreateTable -CREATE TABLE "Template" ( - "id" SERIAL NOT NULL, - "userId" INTEGER NOT NULL, - "title" TEXT NOT NULL, - "description" TEXT, - "status" "TemplateStatus" NOT NULL DEFAULT 'PRIVATE', - "templateDataId" TEXT NOT NULL, - - CONSTRAINT "Template_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "TemplateData" ( - "id" TEXT NOT NULL, - "type" "DocumentDataType" NOT NULL, - "data" TEXT NOT NULL, - "initialData" TEXT NOT NULL, - - CONSTRAINT "TemplateData_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "TemplateField" ( - "id" SERIAL NOT NULL, - "templateId" INTEGER NOT NULL, - "type" "FieldType" NOT NULL, - "page" INTEGER NOT NULL, - "positionX" DECIMAL(65,30) NOT NULL DEFAULT 0, - "positionY" DECIMAL(65,30) NOT NULL DEFAULT 0, - "width" DECIMAL(65,30) NOT NULL DEFAULT -1, - "height" DECIMAL(65,30) NOT NULL DEFAULT -1, - "customText" TEXT NOT NULL, - "inserted" BOOLEAN NOT NULL, - - CONSTRAINT "TemplateField_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "Template_templateDataId_key" ON "Template"("templateDataId"); - --- AddForeignKey -ALTER TABLE "Template" ADD CONSTRAINT "Template_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDataId_fkey" FOREIGN KEY ("templateDataId") REFERENCES "TemplateData"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TemplateField" ADD CONSTRAINT "TemplateField_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231007014431_templates_type/migration.sql b/packages/prisma/migrations/20231007014431_templates_type/migration.sql deleted file mode 100644 index c89e09a61..000000000 --- a/packages/prisma/migrations/20231007014431_templates_type/migration.sql +++ /dev/null @@ -1,15 +0,0 @@ -/* - Warnings: - - - The `status` column on the `Template` table would be dropped and recreated. This will lead to data loss if there is data in the column. - -*/ --- CreateEnum -CREATE TYPE "TemplateType" AS ENUM ('PUBLIC', 'PRIVATE'); - --- AlterTable -ALTER TABLE "Template" DROP COLUMN "status", -ADD COLUMN "status" "TemplateType" NOT NULL DEFAULT 'PRIVATE'; - --- DropEnum -DROP TYPE "TemplateStatus"; diff --git a/packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql b/packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql deleted file mode 100644 index 629f292fc..000000000 --- a/packages/prisma/migrations/20231007021427_reuse_document_data/migration.sql +++ /dev/null @@ -1,14 +0,0 @@ -/* - Warnings: - - - You are about to drop the `TemplateData` table. If the table is not empty, all the data it contains will be lost. - -*/ --- DropForeignKey -ALTER TABLE "Template" DROP CONSTRAINT "Template_templateDataId_fkey"; - --- DropTable -DROP TABLE "TemplateData"; - --- AddForeignKey -ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDataId_fkey" FOREIGN KEY ("templateDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql b/packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql deleted file mode 100644 index 25ace4f72..000000000 --- a/packages/prisma/migrations/20231007033447_remove_inserted_on_template_field/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `inserted` on the `TemplateField` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "TemplateField" DROP COLUMN "inserted"; diff --git a/packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql b/packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql deleted file mode 100644 index 45a52de3d..000000000 --- a/packages/prisma/migrations/20231007080315_document_name_to_template/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - Added the required column `documentName` to the `Template` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE "Template" ADD COLUMN "documentName" TEXT NOT NULL; diff --git a/packages/prisma/migrations/20231007211915_template_created_date/migration.sql b/packages/prisma/migrations/20231007211915_template_created_date/migration.sql deleted file mode 100644 index 816da092e..000000000 --- a/packages/prisma/migrations/20231007211915_template_created_date/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ -/* - Warnings: - - - Added the required column `updatedAt` to the `Template` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE "Template" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, -ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; diff --git a/packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql b/packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql deleted file mode 100644 index b84a567c8..000000000 --- a/packages/prisma/migrations/20231017041643_placeholder_recipients/migration.sql +++ /dev/null @@ -1,51 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `description` on the `Template` table. All the data in the column will be lost. - - You are about to drop the column `documentName` on the `Template` table. All the data in the column will be lost. - - You are about to drop the column `status` on the `Template` table. All the data in the column will be lost. - - You are about to drop the column `templateDataId` on the `Template` table. All the data in the column will be lost. - - A unique constraint covering the columns `[tempateDocumentDataId]` on the table `Template` will be added. If there are existing duplicate values, this will fail. - - Added the required column `tempateDocumentDataId` to the `Template` table without a default value. This is not possible if the table is not empty. - - Added the required column `inserted` to the `TemplateField` table without a default value. This is not possible if the table is not empty. - -*/ --- DropForeignKey -ALTER TABLE "Template" DROP CONSTRAINT "Template_templateDataId_fkey"; - --- DropIndex -DROP INDEX "Template_templateDataId_key"; - --- AlterTable -ALTER TABLE "Template" DROP COLUMN "description", -DROP COLUMN "documentName", -DROP COLUMN "status", -DROP COLUMN "templateDataId", -ADD COLUMN "tempateDocumentDataId" TEXT NOT NULL, -ADD COLUMN "type" "TemplateType" NOT NULL DEFAULT 'PRIVATE', -ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP; - --- AlterTable -ALTER TABLE "TemplateField" ADD COLUMN "inserted" BOOLEAN NOT NULL, -ADD COLUMN "recipientId" INTEGER; - --- CreateTable -CREATE TABLE "TemplateRecipient" ( - "id" SERIAL NOT NULL, - "templateId" INTEGER NOT NULL, - "placeholder" VARCHAR(255) NOT NULL, - - CONSTRAINT "TemplateRecipient_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "Template_tempateDocumentDataId_key" ON "Template"("tempateDocumentDataId"); - --- AddForeignKey -ALTER TABLE "Template" ADD CONSTRAINT "Template_tempateDocumentDataId_fkey" FOREIGN KEY ("tempateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TemplateRecipient" ADD CONSTRAINT "TemplateRecipient_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TemplateField" ADD CONSTRAINT "TemplateField_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "TemplateRecipient"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231017042227_fix_typo/migration.sql b/packages/prisma/migrations/20231017042227_fix_typo/migration.sql deleted file mode 100644 index ac9eaf10e..000000000 --- a/packages/prisma/migrations/20231017042227_fix_typo/migration.sql +++ /dev/null @@ -1,23 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `tempateDocumentDataId` on the `Template` table. All the data in the column will be lost. - - A unique constraint covering the columns `[templateDocumentDataId]` on the table `Template` will be added. If there are existing duplicate values, this will fail. - - Added the required column `templateDocumentDataId` to the `Template` table without a default value. This is not possible if the table is not empty. - -*/ --- DropForeignKey -ALTER TABLE "Template" DROP CONSTRAINT "Template_tempateDocumentDataId_fkey"; - --- DropIndex -DROP INDEX "Template_tempateDocumentDataId_key"; - --- AlterTable -ALTER TABLE "Template" DROP COLUMN "tempateDocumentDataId", -ADD COLUMN "templateDocumentDataId" TEXT NOT NULL; - --- CreateIndex -CREATE UNIQUE INDEX "Template_templateDocumentDataId_key" ON "Template"("templateDocumentDataId"); - --- AddForeignKey -ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDocumentDataId_fkey" FOREIGN KEY ("templateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql b/packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql deleted file mode 100644 index 266333794..000000000 --- a/packages/prisma/migrations/20231019043226_template_recipient_email/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - Added the required column `email` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE "TemplateRecipient" ADD COLUMN "email" VARCHAR(255) NOT NULL; diff --git a/packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql b/packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql deleted file mode 100644 index 9ce1fa70a..000000000 --- a/packages/prisma/migrations/20231020032507_template_recipient_token/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - Added the required column `token` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE "TemplateRecipient" ADD COLUMN "token" TEXT NOT NULL; diff --git a/packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql b/packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql deleted file mode 100644 index a6b3e7199..000000000 --- a/packages/prisma/migrations/20231021193915_template_token_for_recipient/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterTable -ALTER TABLE "Recipient" ADD COLUMN "templateToken" TEXT; - --- AlterTable -ALTER TABLE "TemplateRecipient" ADD COLUMN "templateToken" TEXT; diff --git a/packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql b/packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql deleted file mode 100644 index 8b9275c68..000000000 --- a/packages/prisma/migrations/20231030061522_recipient_name_not_placeholder/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `placeholder` on the `TemplateRecipient` table. All the data in the column will be lost. - - Added the required column `name` to the `TemplateRecipient` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE "TemplateRecipient" DROP COLUMN "placeholder", -ADD COLUMN "name" VARCHAR(255) NOT NULL; diff --git a/packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql b/packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql deleted file mode 100644 index 7e2af3ef8..000000000 --- a/packages/prisma/migrations/20231208090322_remove_template_specific_models/migration.sql +++ /dev/null @@ -1,54 +0,0 @@ -/* - Warnings: - - - You are about to drop the `TemplateField` table. If the table is not empty, all the data it contains will be lost. - - You are about to drop the `TemplateRecipient` table. If the table is not empty, all the data it contains will be lost. - - A unique constraint covering the columns `[templateId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail. - -*/ --- DropForeignKey -ALTER TABLE "Field" DROP CONSTRAINT "Field_recipientId_fkey"; - --- DropForeignKey -ALTER TABLE "TemplateField" DROP CONSTRAINT "TemplateField_recipientId_fkey"; - --- DropForeignKey -ALTER TABLE "TemplateField" DROP CONSTRAINT "TemplateField_templateId_fkey"; - --- DropForeignKey -ALTER TABLE "TemplateRecipient" DROP CONSTRAINT "TemplateRecipient_templateId_fkey"; - --- AlterTable -ALTER TABLE "Field" ADD COLUMN "templateId" INTEGER, -ALTER COLUMN "documentId" DROP NOT NULL; - --- AlterTable -ALTER TABLE "Recipient" ADD COLUMN "templateId" INTEGER, -ALTER COLUMN "documentId" DROP NOT NULL, -ALTER COLUMN "readStatus" DROP NOT NULL, -ALTER COLUMN "signingStatus" DROP NOT NULL, -ALTER COLUMN "sendStatus" DROP NOT NULL; - --- DropTable -DROP TABLE "TemplateField"; - --- DropTable -DROP TABLE "TemplateRecipient"; - --- CreateIndex -CREATE INDEX "Field_templateId_idx" ON "Field"("templateId"); - --- CreateIndex -CREATE INDEX "Recipient_templateId_idx" ON "Recipient"("templateId"); - --- CreateIndex -CREATE UNIQUE INDEX "Recipient_templateId_email_key" ON "Recipient"("templateId", "email"); - --- AddForeignKey -ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Field" ADD CONSTRAINT "Field_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Field" ADD CONSTRAINT "Field_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20231214081915_remove_template_token_column/migration.sql b/packages/prisma/migrations/20231214081915_remove_template_token_column/migration.sql deleted file mode 100644 index 8514a14b7..000000000 --- a/packages/prisma/migrations/20231214081915_remove_template_token_column/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `templateToken` on the `Recipient` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "Recipient" DROP COLUMN "templateToken"; diff --git a/packages/prisma/migrations/20231221070056_make_recipient_status_columns_non_null_again/migration.sql b/packages/prisma/migrations/20231221070056_make_recipient_status_columns_non_null_again/migration.sql deleted file mode 100644 index d2ebc6405..000000000 --- a/packages/prisma/migrations/20231221070056_make_recipient_status_columns_non_null_again/migration.sql +++ /dev/null @@ -1,12 +0,0 @@ -/* - Warnings: - - - Made the column `readStatus` on table `Recipient` required. This step will fail if there are existing NULL values in that column. - - Made the column `signingStatus` on table `Recipient` required. This step will fail if there are existing NULL values in that column. - - Made the column `sendStatus` on table `Recipient` required. This step will fail if there are existing NULL values in that column. - -*/ --- AlterTable -ALTER TABLE "Recipient" ALTER COLUMN "readStatus" SET NOT NULL, -ALTER COLUMN "signingStatus" SET NOT NULL, -ALTER COLUMN "sendStatus" SET NOT NULL; diff --git a/packages/prisma/migrations/20231221101005_add_templates/migration.sql b/packages/prisma/migrations/20231221101005_add_templates/migration.sql new file mode 100644 index 000000000..21b0a2918 --- /dev/null +++ b/packages/prisma/migrations/20231221101005_add_templates/migration.sql @@ -0,0 +1,73 @@ +/* + Warnings: + + - A unique constraint covering the columns `[templateId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "TemplateType" AS ENUM ('PUBLIC', 'PRIVATE'); + +-- DropForeignKey +ALTER TABLE "Field" DROP CONSTRAINT "Field_recipientId_fkey"; + +-- AlterTable +ALTER TABLE "Field" ADD COLUMN "templateId" INTEGER, +ALTER COLUMN "documentId" DROP NOT NULL; + +-- AlterTable +-- Add CHECK constraint to ensure that only one of the two columns is set +ALTER TABLE "Field" ADD CONSTRAINT "Field_templateId_documentId_check" CHECK ( + ("templateId" IS NULL AND "documentId" IS NOT NULL) OR + ("templateId" IS NOT NULL AND "documentId" IS NULL) +); + +-- AlterTable +ALTER TABLE "Recipient" ADD COLUMN "templateId" INTEGER, +ALTER COLUMN "documentId" DROP NOT NULL; + +-- AlterTable +-- Add CHECK constraint to ensure that only one of the two columns is set +ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_templateId_documentId_check" CHECK ( + ("templateId" IS NULL AND "documentId" IS NOT NULL) OR + ("templateId" IS NOT NULL AND "documentId" IS NULL) +); + +-- CreateTable +CREATE TABLE "Template" ( + "id" SERIAL NOT NULL, + "type" "TemplateType" NOT NULL DEFAULT 'PRIVATE', + "title" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "templateDocumentDataId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Template_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Template_templateDocumentDataId_key" ON "Template"("templateDocumentDataId"); + +-- CreateIndex +CREATE INDEX "Field_templateId_idx" ON "Field"("templateId"); + +-- CreateIndex +CREATE INDEX "Recipient_templateId_idx" ON "Recipient"("templateId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Recipient_templateId_email_key" ON "Recipient"("templateId", "email"); + +-- AddForeignKey +ALTER TABLE "Recipient" ADD CONSTRAINT "Recipient_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Field" ADD CONSTRAINT "Field_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Field" ADD CONSTRAINT "Field_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_templateDocumentDataId_fkey" FOREIGN KEY ("templateDocumentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; From 9ad94f986269d1fa85eb44dd6ae1c6e761e4d048 Mon Sep 17 00:00:00 2001 From: Mythie Date: Thu, 21 Dec 2023 21:37:33 +1100 Subject: [PATCH 029/156] fix: updates from review --- .../app/(dashboard)/documents/data-table-action-dropdown.tsx | 2 +- apps/web/src/app/(dashboard)/templates/[id]/page.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index f1cbcc147..b8031b088 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -128,7 +128,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = Download - + setDuplicateDialogOpen(true)}> Duplicate diff --git a/apps/web/src/app/(dashboard)/templates/[id]/page.tsx b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx index 15eaa6f3c..6d234eff2 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx @@ -43,11 +43,11 @@ export default async function TemplatePage({ params }: TemplatePageProps) { const { templateDocumentData } = template; const [templateRecipients, templateFields] = await Promise.all([ - await getRecipientsForTemplate({ + getRecipientsForTemplate({ templateId, userId: user.id, }), - await getFieldsForTemplate({ + getFieldsForTemplate({ templateId, userId: user.id, }), From 1aa0fc3101e2967e225b738326f364e3d98ecc54 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 22 Dec 2023 01:46:41 +0000 Subject: [PATCH 030/156] fix: remove loadingText prop --- .vscode/settings.json | 2 +- apps/web/src/components/forms/forgot-password.tsx | 4 ++-- apps/web/src/components/forms/password.tsx | 6 +++--- apps/web/src/components/forms/profile.tsx | 6 +++--- apps/web/src/components/forms/reset-password.tsx | 4 ++-- apps/web/src/components/forms/signin.tsx | 7 +++---- apps/web/src/components/forms/signup.tsx | 3 +-- packages/ui/primitives/button.tsx | 12 ++++-------- 8 files changed, 19 insertions(+), 25 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 97d5d1948..82aa3c1a3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "typescript.tsdk": "node_modules/typescript/lib", "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"], "javascript.preferences.importModuleSpecifier": "non-relative", diff --git a/apps/web/src/components/forms/forgot-password.tsx b/apps/web/src/components/forms/forgot-password.tsx index 55e313c33..3d9efee42 100644 --- a/apps/web/src/components/forms/forgot-password.tsx +++ b/apps/web/src/components/forms/forgot-password.tsx @@ -82,8 +82,8 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => { /> - diff --git a/apps/web/src/components/forms/password.tsx b/apps/web/src/components/forms/password.tsx index b6b41264c..0eb491537 100644 --- a/apps/web/src/components/forms/password.tsx +++ b/apps/web/src/components/forms/password.tsx @@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { User } from '@documenso/prisma/client'; +import type { User } from '@documenso/prisma/client'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -146,8 +146,8 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
-
diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index dc6e3c4d5..0ce5c7f3d 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -6,7 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { User } from '@documenso/prisma/client'; +import type { User } from '@documenso/prisma/client'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -133,8 +133,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { /> - diff --git a/apps/web/src/components/forms/reset-password.tsx b/apps/web/src/components/forms/reset-password.tsx index e7c701667..354584f6e 100644 --- a/apps/web/src/components/forms/reset-password.tsx +++ b/apps/web/src/components/forms/reset-password.tsx @@ -125,8 +125,8 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) /> - diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 46173a97c..4e671a569 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -199,9 +199,8 @@ export const SignInForm = ({ className }: SignInFormProps) => { size="lg" loading={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90" - loadingText="Signing in..." > - Sign In + {isSubmitting ? 'Signing in...' : 'Sign In'}
@@ -275,8 +274,8 @@ export const SignInForm = ({ className }: SignInFormProps) => { {twoFactorAuthenticationMethod === 'totp' ? 'Use Backup Code' : 'Use Authenticator'} -
diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index 3988314c5..b91b4a9fd 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -162,10 +162,9 @@ export const SignUpForm = ({ className }: SignUpFormProps) => { type="submit" size="lg" loading={isSubmitting} - loadingText="Signing up..." className="dark:bg-documenso dark:hover:opacity-90" > - Sign Up + {isSubmitting ? 'Signing up...' : 'Sign Up'} diff --git a/packages/ui/primitives/button.tsx b/packages/ui/primitives/button.tsx index 9ee3324c6..5754b35a5 100644 --- a/packages/ui/primitives/button.tsx +++ b/packages/ui/primitives/button.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; -import { VariantProps, cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; import { Loader } from 'lucide-react'; import { cn } from '../lib/utils'; @@ -53,15 +54,10 @@ export interface ButtonProps * Will display the loading spinner and disable the button. */ loading?: boolean; - - /** - * The label to show in the button when `isLoading` is true - */ - loadingText?: string; } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, loadingText, loading, ...props }, ref) => { + ({ className, variant, size, asChild = false, loading, ...props }, ref) => { if (asChild) { return ( @@ -79,7 +75,7 @@ const Button = React.forwardRef( disabled={isDisabled} > {isLoading && } - {isLoading && loadingText ? loadingText : props.children} + {props.children} ); }, From 5a5d00fb2e6fe6eba1720ead71f54ac9c4ad2548 Mon Sep 17 00:00:00 2001 From: JA <51177379+ubinatus@users.noreply.github.com> Date: Thu, 21 Dec 2023 22:14:33 -0500 Subject: [PATCH 031/156] fix(webapp): reset delete document dialog (#762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR makes a small but useful tweak to the `DeleteDocumentDialog`. Now, the input field gets cleared whenever the dialog is opened. Here’s what’s changed: 1. **Clear Field After Deleting**: After you delete something and open the dialog again, it won’t show the old, deleted text anymore. It’s clean and ready for the next delete. 2. **Type Again to Confirm**: If you type something but close the dialog without deleting, you’ll have to type it again next time. This way, it makes sure the user really mean to delete something and didn't do it by mistake. Demo Link: See the old vs. new in action here: https://www.loom.com/share/80eca0d3b1994f7cbcab6f222db2dbfe?sid=ebc6135c-345e-4640-b395-daff190a96e7 It’s a small change, but it makes the delete process safer and smoother. --- .../app/(dashboard)/documents/delete-document-dialog.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx index 5b4a84286..0dd238b4e 100644 --- a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; @@ -50,6 +50,13 @@ export const DeleteDocumentDialog = ({ }, }); + useEffect(() => { + if (open) { + setInputValue(''); + setIsDeleteEnabled(status === DocumentStatus.DRAFT); + } + }, [open, status]); + const onDelete = async () => { try { await deleteDocument({ id, status }); From 1c52c7ebcdb98416bc01dbb3bbb7ce37cf4c6946 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 22 Dec 2023 03:43:12 +0000 Subject: [PATCH 032/156] chore: update copy --- .../documents/delete-document-dialog.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx index 7b82f93bc..3914d65de 100644 --- a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx @@ -41,16 +41,10 @@ export const DeleteDocumentDialog = ({ const { mutateAsync: deleteDocument, isLoading } = trpcReact.document.deleteDocument.useMutation({ onSuccess: () => { router.refresh(); - const deletedFileToastDescription = ( -

- Your document {documentTitle} has been - successfully deleted. -

- ); toast({ title: 'Document deleted', - description: deletedFileToastDescription, + description: `"${documentTitle}" has been successfully deleted`, duration: 5000, }); @@ -80,10 +74,7 @@ export const DeleteDocumentDialog = ({ !isLoading && onOpenChange(value)}> - - Do you want to delete the {documentTitle}{' '} - document? - + Are you sure you want to delete "{documentTitle}"? Please note that this action is irreversible. Once confirmed, your document will be From dd568361210f6228a90efa7482d815d13881ae4a Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Fri, 22 Dec 2023 11:44:22 +0530 Subject: [PATCH 033/156] chore: update url --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ffd12d2ac..eff988bd4 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,7 @@ WantedBy=multi-user.target ### Railway -[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p?referralCode=OEa9MT) +[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p) ### Render From 2ae9e2990320b6b7ed008923d59978826a3ce25c Mon Sep 17 00:00:00 2001 From: harkiratsm Date: Fri, 22 Dec 2023 17:24:05 +0530 Subject: [PATCH 034/156] feat: improve the ux for password protected documents Signed-off-by: harkiratsm --- .../primitives/document-password-dialog.tsx | 51 +++++++++++++++++++ packages/ui/primitives/pdf-viewer.tsx | 40 ++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 packages/ui/primitives/document-password-dialog.tsx diff --git a/packages/ui/primitives/document-password-dialog.tsx b/packages/ui/primitives/document-password-dialog.tsx new file mode 100644 index 000000000..da482bae3 --- /dev/null +++ b/packages/ui/primitives/document-password-dialog.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from './dialog'; + +import { Input } from './input'; +import { Button } from './button'; + +type PasswordDialogProps = { + open: boolean; + onOpenChange: (_open: boolean) => void; + setPassword: (_password: string) => void; + handleSubmit: () => void; + isError?: boolean; +} + +export const PasswordDialog = ({ open, onOpenChange, handleSubmit, isError, setPassword }: PasswordDialogProps) => { + return ( + + + + Password Required + + {isError ? ( +

Incorrect password. Please try again.

+ ) : ( +

+ This document is password protected. Please enter the password to view the document. +

+ )} +
+
+ + setPassword(e.target.value)} + /> + + +
+
+ ); +}; diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx index 07cdaf1e2..c4184b17f 100644 --- a/packages/ui/primitives/pdf-viewer.tsx +++ b/packages/ui/primitives/pdf-viewer.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Loader } from 'lucide-react'; -import type { PDFDocumentProxy } from 'pdfjs-dist'; +import { PasswordResponses, type PDFDocumentProxy } from 'pdfjs-dist'; import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf'; import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; import 'react-pdf/dist/esm/Page/TextLayer.css'; @@ -14,6 +14,7 @@ import type { DocumentData } from '@documenso/prisma/client'; import { cn } from '../lib/utils'; import { useToast } from './use-toast'; +import { PasswordDialog } from './document-password-dialog'; export type LoadedPDFDocument = PDFDocumentProxy; @@ -60,6 +61,10 @@ export const PDFViewer = ({ const $el = useRef(null); const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false); + const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); + const [password, setPassword] = useState(null); + const passwordCallbackRef = useRef<((password: string | null) => void) | null>(null); + const [isPasswordError, setIsPasswordError] = useState(false); const [documentBytes, setDocumentBytes] = useState(null); const [width, setWidth] = useState(0); @@ -77,6 +82,14 @@ export const PDFViewer = ({ setNumPages(doc.numPages); onDocumentLoad?.(doc); }; + + const handlePasswordSubmit = () => { + setIsPasswordModalOpen(false); + if (passwordCallbackRef.current) { + passwordCallbackRef.current(password); + passwordCallbackRef.current = null; + } + } const onDocumentPageClick = ( event: React.MouseEvent, @@ -169,11 +182,26 @@ export const PDFViewer = ({
) : ( + <> { + setIsPasswordModalOpen(true); + passwordCallbackRef.current = callback; + switch (reason) { + case PasswordResponses.NEED_PASSWORD: + setIsPasswordError(false); + break; + case PasswordResponses.INCORRECT_PASSWORD: + setIsPasswordError(true); + break; + default: + break; + } + }} onLoadSuccess={(d) => onDocumentLoaded(d)} // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. // Therefore we add some additional custom error handling. @@ -220,7 +248,15 @@ export const PDFViewer = ({
))} - )} + + + )}
); }; From 6d58e60a65b5fc55dd2e8a0e5ea201fd75528ad8 Mon Sep 17 00:00:00 2001 From: sadam Date: Sat, 23 Dec 2023 23:18:32 -0500 Subject: [PATCH 035/156] fix(url): change URL for cloning --- README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 24d932858..733bb1dde 100644 --- a/README.md +++ b/README.md @@ -115,10 +115,12 @@ To run Documenso locally, you will need Want to get up and running quickly? Follow these steps: -1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device. +1. [Fork this repository ](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your github account. + +After forking clone it using the below command ```sh -git clone https://github.com/documenso/documenso +git clone https://github.com//documenso ``` 2. Set up your `.env` file using the recommendations in the `.env.example` file. Alternatively, just run `cp .env.example .env` to get started with our handpicked defaults. @@ -152,10 +154,12 @@ npm run d Follow these steps to setup Documenso on your local machine: -1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device. +1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) it to your github account. + +After forking clone it using the below command ```sh -git clone https://github.com/documenso/documenso +git clone https://github.com//documenso ``` 2. Run `npm i` in the root directory @@ -227,7 +231,7 @@ cp .env.example .env The following environment variables must be set: -* `NEXTAUTH_URL` +* `NEXTAUTH_URL` * `NEXTAUTH_SECRET` * `NEXT_PUBLIC_WEBAPP_URL` * `NEXT_PUBLIC_MARKETING_URL` From 2b25806c335e38c18d258df4504fef98b29ed0bf Mon Sep 17 00:00:00 2001 From: sadam Date: Sat, 23 Dec 2023 23:26:53 -0500 Subject: [PATCH 036/156] fix(url): change URL for cloning --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 733bb1dde..d4ce339c1 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Want to get up and running quickly? Follow these steps: 1. [Fork this repository ](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your github account. -After forking clone it using the below command +After forking clone it using the below command: ```sh git clone https://github.com//documenso @@ -156,7 +156,7 @@ Follow these steps to setup Documenso on your local machine: 1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) it to your github account. -After forking clone it using the below command +After forking clone it using the below command: ```sh git clone https://github.com//documenso From 8d1b960aa87e0e0358489b2b4facfc11515f546d Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 25 Dec 2023 23:16:56 +0000 Subject: [PATCH 037/156] feat: add templates to command menu --- .../components/(dashboard)/common/command-menu.tsx | 14 ++++++++++++++ packages/lib/constants/keyboard-shortcuts.ts | 1 + 2 files changed, 15 insertions(+) diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index 19a35874e..5be89343b 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -11,6 +11,7 @@ import { useHotkeys } from 'react-hotkeys-hook'; import { DOCUMENTS_PAGE_SHORTCUT, SETTINGS_PAGE_SHORTCUT, + TEMPLATES_PAGE_SHORTCUT, } from '@documenso/lib/constants/keyboard-shortcuts'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { @@ -38,6 +39,14 @@ const DOCUMENTS_PAGES = [ { label: 'Inbox documents', path: '/documents?status=INBOX' }, ]; +const TEMPLATES_PAGES = [ + { + label: 'All templates', + path: '/templates', + shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''), + }, +]; + const SETTINGS_PAGES = [ { label: 'Settings', @@ -124,10 +133,12 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]); const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]); + const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]); useHotkeys(['ctrl+k', 'meta+k'], toggleOpen); useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings); useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments); + useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates); const handleKeyDown = (e: React.KeyboardEvent) => { // Escape goes to previous page @@ -174,6 +185,9 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { + + + diff --git a/packages/lib/constants/keyboard-shortcuts.ts b/packages/lib/constants/keyboard-shortcuts.ts index 896b4abf5..34d3a02e6 100644 --- a/packages/lib/constants/keyboard-shortcuts.ts +++ b/packages/lib/constants/keyboard-shortcuts.ts @@ -1,2 +1,3 @@ export const SETTINGS_PAGE_SHORTCUT = 'N+S'; export const DOCUMENTS_PAGE_SHORTCUT = 'N+D'; +export const TEMPLATES_PAGE_SHORTCUT = 'N+T'; From 5a32b5cafd3b5ebe6f2a4fdff0ce85c9a4849dae Mon Sep 17 00:00:00 2001 From: Apoorv Taneja Date: Tue, 26 Dec 2023 05:19:27 +0530 Subject: [PATCH 038/156] fix: added constants for theme variables (#777) fixes: #776 --- .../components/(dashboard)/common/command-menu.tsx | 7 ++++--- packages/ui/primitives/constants.ts | 5 +++++ packages/ui/primitives/theme-switcher.tsx | 14 ++++++++------ 3 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 packages/ui/primitives/constants.ts diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index 19a35874e..fe690329b 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -22,6 +22,7 @@ import { CommandList, CommandShortcut, } from '@documenso/ui/primitives/command'; +import { THEMES_TYPE } from '@documenso/ui/primitives/constants'; const DOCUMENTS_PAGES = [ { @@ -215,9 +216,9 @@ const Commands = ({ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => { const THEMES = useMemo( () => [ - { label: 'Light Mode', theme: 'light', icon: Sun }, - { label: 'Dark Mode', theme: 'dark', icon: Moon }, - { label: 'System Theme', theme: 'system', icon: Monitor }, + { label: 'Light Mode', theme: THEMES_TYPE.LIGHT, icon: Sun }, + { label: 'Dark Mode', theme: THEMES_TYPE.DARK, icon: Moon }, + { label: 'System Theme', theme: THEMES_TYPE.SYSTEM, icon: Monitor }, ], [], ); diff --git a/packages/ui/primitives/constants.ts b/packages/ui/primitives/constants.ts new file mode 100644 index 000000000..9771eb35a --- /dev/null +++ b/packages/ui/primitives/constants.ts @@ -0,0 +1,5 @@ +export const THEMES_TYPE = { + DARK: 'dark', + LIGHT: 'light', + SYSTEM: 'system' +}; \ No newline at end of file diff --git a/packages/ui/primitives/theme-switcher.tsx b/packages/ui/primitives/theme-switcher.tsx index 7aa570749..fcc789404 100644 --- a/packages/ui/primitives/theme-switcher.tsx +++ b/packages/ui/primitives/theme-switcher.tsx @@ -4,6 +4,8 @@ import { useTheme } from 'next-themes'; import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; +import { THEMES_TYPE } from './constants'; + export const ThemeSwitcher = () => { const { theme, setTheme } = useTheme(); const isMounted = useIsMounted(); @@ -12,9 +14,9 @@ export const ThemeSwitcher = () => {
+ + {lines.length > 0 && ( +
+ +
+ )}
); }; From 32633f96d292056a22e855986ba795bfba3e4b5c Mon Sep 17 00:00:00 2001 From: hallidayo <22655069+Hallidayo@users.noreply.github.com> Date: Tue, 26 Dec 2023 23:50:40 +0000 Subject: [PATCH 044/156] feat: dateformat and timezone customization (#506) --- .../src/pages/api/stripe/webhook/index.ts | 5 +- .../app/(dashboard)/admin/users/[id]/page.tsx | 4 +- .../documents/[id]/edit-document.tsx | 8 +- .../(dashboard)/documents/upload-document.tsx | 1 + .../app/(signing)/sign/[token]/date-field.tsx | 36 +++++- .../(signing)/sign/[token]/email-field.tsx | 6 +- .../src/app/(signing)/sign/[token]/form.tsx | 7 +- .../app/(signing)/sign/[token]/name-field.tsx | 6 +- .../src/app/(signing)/sign/[token]/page.tsx | 13 +- .../sign/[token]/signature-field.tsx | 2 +- .../sign/[token]/signing-field-container.tsx | 24 +++- .../components/formatter/document-status.tsx | 4 +- .../src/components/formatter/locale-date.tsx | 6 +- package-lock.json | 6 + packages/lib/client-only/recipient-type.ts | 3 +- packages/lib/constants/date-formats.ts | 71 +++++++++++ packages/lib/constants/time-zones.ts | 44 +++++++ packages/lib/package.json | 1 + .../document-meta/upsert-document-meta.ts | 8 ++ .../document/duplicate-document-by-id.ts | 2 + .../get-document-meta-by-document-id.ts | 13 ++ .../server-only/document/update-document.ts | 2 +- .../field/sign-field-with-token.ts | 13 +- packages/lib/utils/recipient-formatter.ts | 2 +- .../migration.sql | 3 + .../migration.sql | 8 ++ packages/prisma/schema.prisma | 2 + packages/prisma/types/document-with-data.ts | 2 +- .../prisma/types/document-with-recipient.ts | 2 +- packages/prisma/types/field-with-signature.ts | 2 +- .../trpc/server/document-router/router.ts | 10 +- .../trpc/server/document-router/schema.ts | 4 +- packages/trpc/server/trpc.ts | 2 +- packages/ui/lib/utils.ts | 3 +- packages/ui/primitives/combobox.tsx | 71 +++++------ packages/ui/primitives/command.tsx | 2 +- .../document-flow/add-signature.tsx | 5 +- .../primitives/document-flow/add-subject.tsx | 119 ++++++++++++++++-- .../document-flow/add-subject.types.ts | 7 +- .../ui/primitives/multiselect-combobox.tsx | 82 ++++++++++++ 40 files changed, 517 insertions(+), 94 deletions(-) create mode 100644 packages/lib/constants/date-formats.ts create mode 100644 packages/lib/constants/time-zones.ts create mode 100644 packages/lib/server-only/document/get-document-meta-by-document-id.ts create mode 100644 packages/prisma/migrations/20231207134820_add_document_meta_dateformat_timezone/migration.sql create mode 100644 packages/prisma/migrations/20231214081915_remove_template_token_column/migration.sql create mode 100644 packages/ui/primitives/multiselect-combobox.tsx diff --git a/apps/marketing/src/pages/api/stripe/webhook/index.ts b/apps/marketing/src/pages/api/stripe/webhook/index.ts index 2bdcdeb50..a19cffda9 100644 --- a/apps/marketing/src/pages/api/stripe/webhook/index.ts +++ b/apps/marketing/src/pages/api/stripe/webhook/index.ts @@ -1,4 +1,4 @@ -import { NextApiRequest, NextApiResponse } from 'next'; +import type { NextApiRequest, NextApiResponse } from 'next'; import { randomBytes } from 'crypto'; import { buffer } from 'micro'; @@ -6,7 +6,8 @@ import { buffer } from 'micro'; import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf'; import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf'; import { redis } from '@documenso/lib/server-only/redis'; -import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; +import type { Stripe } from '@documenso/lib/server-only/stripe'; +import { stripe } from '@documenso/lib/server-only/stripe'; import { getFile } from '@documenso/lib/universal/upload/get-file'; import { updateFile } from '@documenso/lib/universal/upload/update-file'; import { prisma } from '@documenso/prisma'; diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx index 3baf5d63b..9ae270d28 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -9,7 +9,6 @@ import type { z } from 'zod'; import { trpc } from '@documenso/trpc/react'; import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema'; import { Button } from '@documenso/ui/primitives/button'; -import { Combobox } from '@documenso/ui/primitives/combobox'; import { Form, FormControl, @@ -19,6 +18,7 @@ import { FormMessage, } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; +import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox'; import { useToast } from '@documenso/ui/primitives/use-toast'; const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true }); @@ -117,7 +117,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
Roles - onChange(values)} /> diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index ffce3bd6c..a5dc9e23e 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -4,8 +4,8 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { DocumentStatus } from '@documenso/prisma/client'; import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client'; +import { DocumentStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -145,14 +145,16 @@ export const EditDocumentForm = ({ }; const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { - const { subject, message } = data.email; + const { subject, message, timezone, dateFormat } = data.meta; try { await sendDocument({ documentId: document.id, - email: { + meta: { subject, message, + timezone, + dateFormat, }, }); diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx index 5e93495e3..04ba990d5 100644 --- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx @@ -25,6 +25,7 @@ export type UploadDocumentProps = { export const UploadDocument = ({ className }: UploadDocumentProps) => { const router = useRouter(); const analytics = useAnalytics(); + const { data: session } = useSession(); const { toast } = useToast(); diff --git a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx index 9cff29c64..40d0f945a 100644 --- a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx @@ -6,8 +6,13 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; -import { Recipient } from '@documenso/prisma/client'; -import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { + DEFAULT_DOCUMENT_DATE_FORMAT, + convertToLocalSystemFormat, +} from '@documenso/lib/constants/date-formats'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import type { Recipient } from '@documenso/prisma/client'; +import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -16,9 +21,16 @@ import { SigningFieldContainer } from './signing-field-container'; export type DateFieldProps = { field: FieldWithSignature; recipient: Recipient; + dateFormat?: string | null; + timezone?: string | null; }; -export const DateField = ({ field, recipient }: DateFieldProps) => { +export const DateField = ({ + field, + recipient, + dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT, + timezone = DEFAULT_DOCUMENT_TIME_ZONE, +}: DateFieldProps) => { const router = useRouter(); const { toast } = useToast(); @@ -35,12 +47,18 @@ export const DateField = ({ field, recipient }: DateFieldProps) => { const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone); + + const isDifferentTimeZone = field.inserted && localDateString !== field.customText; + + const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`; + const onSign = async () => { try { await signFieldWithToken({ token: recipient.token, fieldId: field.id, - value: '', + value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, }); startTransition(() => router.refresh()); @@ -75,7 +93,13 @@ export const DateField = ({ field, recipient }: DateFieldProps) => { }; return ( - + {isLoading && (
@@ -87,7 +111,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => { )} {field.inserted && ( -

{field.customText}

+

{localDateString}

)} ); diff --git a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx index f6f790799..4d52ca50a 100644 --- a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx @@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; -import { Recipient } from '@documenso/prisma/client'; -import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import type { Recipient } from '@documenso/prisma/client'; +import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -79,7 +79,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => { }; return ( - + {isLoading && (
diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 29cd77995..4f20a8199 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -34,6 +34,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = const { data: session } = useSession(); const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext(); + const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const { mutateAsync: completeDocumentWithToken } = @@ -92,7 +93,11 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = disabled={isSubmitting} className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')} > -
+

Sign Document

diff --git a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx index bbe18fb8a..6e661e77a 100644 --- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx @@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; -import { Recipient } from '@documenso/prisma/client'; -import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import type { Recipient } from '@documenso/prisma/client'; +import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; @@ -98,7 +98,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { }; return ( - + {isLoading && (

diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 97babb82f..18b81696e 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -2,9 +2,12 @@ import { notFound, redirect } from 'next/navigation'; import { match } from 'ts-pattern'; +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; @@ -42,6 +45,8 @@ export default async function SigningPage({ params: { token } }: SigningPageProp viewedDocument({ token }).catch(() => null), ]); + const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null); + if (!document || !document.documentData || !recipient) { return notFound(); } @@ -111,7 +116,13 @@ export default async function SigningPage({ params: { token } }: SigningPageProp )) .with(FieldType.DATE, () => ( - + )) .with(FieldType.EMAIL, () => ( diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx index ec3e45fe5..220d3084a 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx @@ -127,7 +127,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { }; return ( - + {isLoading && (
diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx index 046e5b3df..b4805fa6b 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx @@ -2,8 +2,9 @@ import React from 'react'; -import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { FieldRootContainer } from '@documenso/ui/components/field/field'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; export type SignatureFieldProps = { field: FieldWithSignature; @@ -11,6 +12,8 @@ export type SignatureFieldProps = { children: React.ReactNode; onSign?: () => Promise | void; onRemove?: () => Promise | void; + type?: 'Date' | 'Email' | 'Name' | 'Signature'; + tooltipText?: string | null; }; export const SigningFieldContainer = ({ @@ -19,6 +22,8 @@ export const SigningFieldContainer = ({ onSign, onRemove, children, + type, + tooltipText, }: SignatureFieldProps) => { const onSignFieldClick = async () => { if (field.inserted) { @@ -46,7 +51,22 @@ export const SigningFieldContainer = ({ /> )} - {field.inserted && !loading && ( + {type === 'Date' && field.inserted && !loading && ( + + + + + + {tooltipText && {tooltipText}} + + )} + + {type !== 'Date' && field.inserted && !loading && ( - + + - + + No value found. - - {allRoles.map((value: string, i: number) => ( - handleSelect(value)}> + + + {options.map((option, index) => ( + onOptionSelected(option)}> - {value} + + {option} ))} diff --git a/packages/ui/primitives/command.tsx b/packages/ui/primitives/command.tsx index 67cd3f487..cbc306c66 100644 --- a/packages/ui/primitives/command.tsx +++ b/packages/ui/primitives/command.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; -import { DialogProps } from '@radix-ui/react-dialog'; +import type { DialogProps } from '@radix-ui/react-dialog'; import { Command as CommandPrimitive } from 'cmdk'; import { Search } from 'lucide-react'; diff --git a/packages/ui/primitives/document-flow/add-signature.tsx b/packages/ui/primitives/document-flow/add-signature.tsx index e4e5d9253..5accdca16 100644 --- a/packages/ui/primitives/document-flow/add-signature.tsx +++ b/packages/ui/primitives/document-flow/add-signature.tsx @@ -7,11 +7,13 @@ import { DateTime } from 'luxon'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import type { Field } from '@documenso/prisma/client'; import { FieldType } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; import { FieldToolTip } from '../../components/field/field-tooltip'; import { cn } from '../../lib/utils'; @@ -34,7 +36,6 @@ import { SinglePlayerModeCustomTextField, SinglePlayerModeSignatureField, } from './single-player-mode-fields'; -import type { DocumentFlowStep } from './types'; export type AddSignatureFormProps = { defaultValues?: TAddSignatureFormSchema; @@ -140,7 +141,7 @@ export const AddSignatureFormPartial = ({ return match(field.type) .with(FieldType.DATE, () => ({ ...field, - customText: DateTime.now().toFormat('yyyy-MM-dd hh:mm a'), + customText: DateTime.now().toFormat(DEFAULT_DOCUMENT_DATE_FORMAT), inserted: true, })) .with(FieldType.EMAIL, () => ({ diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index 881d59c74..d73019732 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -1,11 +1,30 @@ 'use client'; -import { useForm } from 'react-hook-form'; +import { useEffect } from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; +import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import type { Field, Recipient } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client'; +import { SendStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@documenso/ui/primitives/accordion'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { Combobox } from '../combobox'; import { FormErrorMessage } from '../form/form-error-message'; import { Input } from '../input'; import { Label } from '../label'; @@ -31,20 +50,25 @@ export type AddSubjectFormProps = { export const AddSubjectFormPartial = ({ documentFlow, - recipients: _recipients, - fields: _fields, + recipients: recipients, + fields: fields, document, onSubmit, }: AddSubjectFormProps) => { const { + control, register, handleSubmit, - formState: { errors, isSubmitting }, + formState: { errors, isSubmitting, touchedFields }, + getValues, + setValue, } = useForm({ defaultValues: { - email: { + meta: { subject: document.documentMeta?.subject ?? '', message: document.documentMeta?.message ?? '', + timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, + dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, }, }, }); @@ -52,6 +76,20 @@ export const AddSubjectFormPartial = ({ const onFormSubmit = handleSubmit(onSubmit); const { currentStep, totalSteps, previousStep } = useStep(); + const hasDateField = fields.find((field) => field.type === 'DATE'); + + const documentHasBeenSent = recipients.some( + (recipient) => recipient.sendStatus === SendStatus.SENT, + ); + + // We almost always want to set the timezone to the user's local timezone to avoid confusion + // when the document is signed. + useEffect(() => { + if (!touchedFields.meta?.timezone && !documentHasBeenSent) { + setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone); + } + }, [documentHasBeenSent, setValue, touchedFields.meta?.timezone]); + return ( <> - +
@@ -86,14 +124,12 @@ export const AddSubjectFormPartial = ({ id="message" className="bg-background mt-2 h-32 resize-none" disabled={isSubmitting} - {...register('email.message')} + {...register('meta.message')} />
@@ -123,6 +159,67 @@ export const AddSubjectFormPartial = ({
+ + + + + Advanced Options + + + + {hasDateField && ( +
+ + + ( + + )} + /> +
+ )} + + {hasDateField && ( +
+ + + ( + value && onChange(value)} + disabled={documentHasBeenSent} + /> + )} + /> +
+ )} +
+
+
diff --git a/packages/ui/primitives/document-flow/add-subject.types.ts b/packages/ui/primitives/document-flow/add-subject.types.ts index 33e2dedfb..ea14f4c0f 100644 --- a/packages/ui/primitives/document-flow/add-subject.types.ts +++ b/packages/ui/primitives/document-flow/add-subject.types.ts @@ -1,9 +1,14 @@ import { z } from 'zod'; +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; + export const ZAddSubjectFormSchema = z.object({ - email: z.object({ + meta: z.object({ subject: z.string(), message: z.string(), + timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE), + dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT), }), }); diff --git a/packages/ui/primitives/multiselect-combobox.tsx b/packages/ui/primitives/multiselect-combobox.tsx new file mode 100644 index 000000000..bac87ce0b --- /dev/null +++ b/packages/ui/primitives/multiselect-combobox.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; + +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { Role } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@documenso/ui/primitives/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; + +type ComboboxProps = { + listValues: string[]; + onChange: (_values: string[]) => void; +}; + +const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => { + const [open, setOpen] = React.useState(false); + const [selectedValues, setSelectedValues] = React.useState([]); + const dbRoles = Object.values(Role); + + React.useEffect(() => { + setSelectedValues(listValues); + }, [listValues]); + + const allRoles = [...new Set([...dbRoles, ...selectedValues])]; + + const handleSelect = (currentValue: string) => { + let newSelectedValues; + if (selectedValues.includes(currentValue)) { + newSelectedValues = selectedValues.filter((value) => value !== currentValue); + } else { + newSelectedValues = [...selectedValues, currentValue]; + } + + setSelectedValues(newSelectedValues); + onChange(newSelectedValues); + setOpen(false); + }; + + return ( + + + + + + + + No value found. + + {allRoles.map((value: string, i: number) => ( + handleSelect(value)}> + + {value} + + ))} + + + + + ); +}; + +export { MultiSelectCombobox }; From eb84d7ff3cb32269b383f471e485538bab28bccd Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 27 Dec 2023 11:58:02 +1100 Subject: [PATCH 045/156] fix: remove invalid migration --- .../migration.sql | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 packages/prisma/migrations/20231214081915_remove_template_token_column/migration.sql diff --git a/packages/prisma/migrations/20231214081915_remove_template_token_column/migration.sql b/packages/prisma/migrations/20231214081915_remove_template_token_column/migration.sql deleted file mode 100644 index 8514a14b7..000000000 --- a/packages/prisma/migrations/20231214081915_remove_template_token_column/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `templateToken` on the `Recipient` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "Recipient" DROP COLUMN "templateToken"; From d8eff192febf8358e2dbb3069236368ccecdd1b8 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 27 Dec 2023 14:04:22 +1100 Subject: [PATCH 046/156] fix: update date format and add missing default --- .../app/(signing)/sign/[token]/date-field.tsx | 4 ++-- packages/lib/constants/date-formats.ts | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx index 40d0f945a..ce34a55fd 100644 --- a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx @@ -49,7 +49,7 @@ export const DateField = ({ const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone); - const isDifferentTimeZone = field.inserted && localDateString !== field.customText; + const isDifferentTime = field.inserted && localDateString !== field.customText; const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`; @@ -98,7 +98,7 @@ export const DateField = ({ onSign={onSign} onRemove={onRemove} type="Date" - tooltipText={isDifferentTimeZone ? tooltipText : undefined} + tooltipText={isDifferentTime ? tooltipText : undefined} > {isLoading && (
diff --git a/packages/lib/constants/date-formats.ts b/packages/lib/constants/date-formats.ts index 8c9ebe9e6..5b36cefdf 100644 --- a/packages/lib/constants/date-formats.ts +++ b/packages/lib/constants/date-formats.ts @@ -5,10 +5,15 @@ import { DEFAULT_DOCUMENT_TIME_ZONE } from './time-zones'; export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a'; export const DATE_FORMATS = [ + { + key: 'yyyy-MM-dd_hh:mm_a', + label: 'YYYY-MM-DD HH:mm a', + value: DEFAULT_DOCUMENT_DATE_FORMAT, + }, { key: 'YYYYMMDD', label: 'YYYY-MM-DD', - value: DEFAULT_DOCUMENT_DATE_FORMAT, + value: 'YYYY-MM-DD', }, { key: 'DDMMYYYY', @@ -57,15 +62,18 @@ export const convertToLocalSystemFormat = ( dateFormat: string | null = DEFAULT_DOCUMENT_DATE_FORMAT, timeZone: string | null = DEFAULT_DOCUMENT_TIME_ZONE, ): string => { - const parsedDate = DateTime.fromFormat(customText, dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, { - zone: timeZone ?? DEFAULT_DOCUMENT_TIME_ZONE, + const coalescedDateFormat = dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT; + const coalescedTimeZone = timeZone ?? DEFAULT_DOCUMENT_TIME_ZONE; + + const parsedDate = DateTime.fromFormat(customText, coalescedDateFormat, { + zone: coalescedTimeZone, }); if (!parsedDate.isValid) { return 'Invalid date'; } - const formattedDate = parsedDate.toLocal().toFormat(dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT); + const formattedDate = parsedDate.toLocal().toFormat(coalescedDateFormat); return formattedDate; }; From c4800f74b90687f9f0d10e96bc2510f4cbbb5d44 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 28 Dec 2023 20:07:29 +1100 Subject: [PATCH 047/156] feat: update disabled dropzone text (#787) Update the dropzone so it will display the relevant disabled text based on the reason it is disabled. --- .../app/(dashboard)/documents/upload-document.tsx | 13 ++++++++++++- packages/ui/primitives/document-dropzone.tsx | 6 +++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx index 04ba990d5..65b95f9ec 100644 --- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; @@ -36,6 +36,16 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => { const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation(); + const disabledMessage = useMemo(() => { + if (remaining.documents === 0) { + return 'You have reached your document limit.'; + } + + if (!session?.user.emailVerified) { + return 'Verify your email to upload documents.'; + } + }, [remaining.documents, session?.user.emailVerified]); + const onFileDrop = async (file: File) => { try { setIsLoading(true); @@ -91,6 +101,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => { diff --git a/packages/ui/primitives/document-dropzone.tsx b/packages/ui/primitives/document-dropzone.tsx index 8ba22109a..21337956d 100644 --- a/packages/ui/primitives/document-dropzone.tsx +++ b/packages/ui/primitives/document-dropzone.tsx @@ -87,6 +87,7 @@ const DocumentDescription = { export type DocumentDropzoneProps = { className?: string; disabled?: boolean; + disabledMessage?: string; onDrop?: (_file: File) => void | Promise; type?: 'document' | 'template'; [key: string]: unknown; @@ -96,6 +97,7 @@ export const DocumentDropzone = ({ className, onDrop, disabled, + disabledMessage = 'You cannot upload documents at this time.', type = 'document', ...props }: DocumentDropzoneProps) => { @@ -172,7 +174,9 @@ export const DocumentDropzone = ({ {DocumentDescription[type].headline}

-

Drag & drop your document here.

+

+ {disabled ? disabledMessage : 'Drag & drop your document here.'} +

From 3f89f8725bc407248275e81a363ace551111b486 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 28 Dec 2023 20:08:19 +1100 Subject: [PATCH 048/156] fix: resolve conflicting z-index values (#788) ## Description Currently there are various z-index values that are causing: - Toasts to be placed behind dialog blur background - Menu being cropped off by header ## Changes Made - Revert `z-[1000]` back to `z-50` for the header (not exactly sure why it was bumped) - Refactor z-indexes over 9000 to start from 1000 - Ensure z-index of toast is higher than dialog --- apps/web/src/components/(dashboard)/layout/header.tsx | 2 +- packages/ui/primitives/dialog.tsx | 2 +- packages/ui/primitives/select.tsx | 2 +- packages/ui/primitives/toast.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx index cf8873a1a..bdae6c511 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/layout/header.tsx @@ -33,7 +33,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => { return (
5 && 'border-b-border', className, )} diff --git a/packages/ui/primitives/dialog.tsx b/packages/ui/primitives/dialog.tsx index 8e5ed20e5..47982ab09 100644 --- a/packages/ui/primitives/dialog.tsx +++ b/packages/ui/primitives/dialog.tsx @@ -20,7 +20,7 @@ const DialogPortal = ({ }: DialogPrimitive.DialogPortalProps & { position?: 'start' | 'end' | 'center' }) => (
Date: Thu, 28 Dec 2023 15:06:46 +0530 Subject: [PATCH 049/156] fix: fixed the title box overlapping issue (#785) The issue is fixed. Now the box is no more overlapping Screenshot 2023-12-26 at 10 20 32 AM --- packages/ui/primitives/document-flow/add-title.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/primitives/document-flow/add-title.tsx b/packages/ui/primitives/document-flow/add-title.tsx index 8c2a9dc7a..afce0d9e0 100644 --- a/packages/ui/primitives/document-flow/add-title.tsx +++ b/packages/ui/primitives/document-flow/add-title.tsx @@ -64,7 +64,7 @@ export const AddTitleFormPartial = ({ From fb0d9b8ef98b7ee03e0310cb7ea0728816beb663 Mon Sep 17 00:00:00 2001 From: apoorv taneja Date: Thu, 28 Dec 2023 23:14:46 +0530 Subject: [PATCH 050/156] fixed padding in footer --- .vscode/settings.json | 2 +- apps/web/src/app/layout.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 97d5d1948..82aa3c1a3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "typescript.tsdk": "node_modules/typescript/lib", "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"], "javascript.preferences.importModuleSpecifier": "non-relative", diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index ac88469b0..0afcde320 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -16,6 +16,8 @@ import { PostHogPageview } from '~/providers/posthog'; import './globals.css'; +dasdas; + const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' }); const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' }); From 0bb86963a93bfda8dc003fedeb66e15d84f7a868 Mon Sep 17 00:00:00 2001 From: apoorv taneja Date: Thu, 28 Dec 2023 23:27:45 +0530 Subject: [PATCH 051/156] rolled back to original file --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 82aa3c1a3..97d5d1948 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "typescript.tsdk": "node_modules/typescript/lib", "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" + "source.fixAll.eslint": true }, "eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"], "javascript.preferences.importModuleSpecifier": "non-relative", From 5307fa645337cc18772d7dab9b80ad79a7da2714 Mon Sep 17 00:00:00 2001 From: apoorv taneja Date: Thu, 28 Dec 2023 23:29:44 +0530 Subject: [PATCH 052/156] fixed padding issue in footer --- apps/marketing/src/components/(marketing)/footer.tsx | 2 +- apps/web/src/app/layout.tsx | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index 1399297c7..30a0cb373 100644 --- a/apps/marketing/src/components/(marketing)/footer.tsx +++ b/apps/marketing/src/components/(marketing)/footer.tsx @@ -70,7 +70,7 @@ export const Footer = ({ className, ...props }: FooterProps) => { key={index} href={link.href} target={link.target} - className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 text-sm" + className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 text-sm break-words" > {link.text} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 0afcde320..ac88469b0 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -16,8 +16,6 @@ import { PostHogPageview } from '~/providers/posthog'; import './globals.css'; -dasdas; - const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' }); const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' }); From 5d6f69dc195d62f7cebfade153c59c4207ed0956 Mon Sep 17 00:00:00 2001 From: apoorv taneja Date: Thu, 28 Dec 2023 23:30:20 +0530 Subject: [PATCH 053/156] fixed padding issue in footer --- apps/marketing/src/components/(marketing)/footer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index 30a0cb373..1694a5e48 100644 --- a/apps/marketing/src/components/(marketing)/footer.tsx +++ b/apps/marketing/src/components/(marketing)/footer.tsx @@ -70,7 +70,7 @@ export const Footer = ({ className, ...props }: FooterProps) => { key={index} href={link.href} target={link.target} - className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 text-sm break-words" + className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 break-words text-sm" > {link.text} From 341481d6dba8f91782e8e6787b7ddf3521064e19 Mon Sep 17 00:00:00 2001 From: Mohith Gadireddy <88539464+Mohith234@users.noreply.github.com> Date: Fri, 29 Dec 2023 15:48:19 +0530 Subject: [PATCH 054/156] fix: trimmed long file names for better UX (#760) Fixes #755 ### Notes for Reviewers - The max length of the title is set to be `16` - If the length of the title is <16 it returns the original one. - Or else the title will be the first 8 characters (start) and last 8 characters (end) - The truncated file name will look like `start...end` ### Screenshot for reference ![image](https://github.com/documenso/documenso/assets/88539464/565e4868-7bb1-4b46-9cb0-886d542b8a01) --------- Co-authored-by: Catalin Pit <25515812+catalinpit@users.noreply.github.com> --- .../src/app/(signing)/sign/[token]/complete/page.tsx | 6 +++++- apps/web/src/app/(signing)/sign/[token]/page.tsx | 6 +++++- .../web/src/app/(signing)/sign/[token]/sign-dialog.tsx | 8 +++++--- apps/web/src/helpers/truncate-title.ts | 10 ++++++++++ 4 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/helpers/truncate-title.ts diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx index 54757667a..4b1aed265 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -15,6 +15,8 @@ import { DocumentDownloadButton } from '@documenso/ui/components/document/docume import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { SigningCard3D } from '@documenso/ui/components/signing-card'; +import { truncateTitle } from '~/helpers/truncate-title'; + export type CompletedSigningPageProps = { params: { token?: string; @@ -36,6 +38,8 @@ export default async function CompletedSigningPage({ return notFound(); } + const truncatedTitle = truncateTitle(document.title); + const { documentData } = document; const [fields, recipient] = await Promise.all([ @@ -89,7 +93,7 @@ export default async function CompletedSigningPage({

You have signed - "{document.title}" + "{truncatedTitle}"

{match({ status: document.status, deletedAt: document.deletedAt }) diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 18b81696e..efd0b266c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -17,6 +17,8 @@ import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { truncateTitle } from '~/helpers/truncate-title'; + import { DateField } from './date-field'; import { EmailField } from './email-field'; import { SigningForm } from './form'; @@ -51,6 +53,8 @@ export default async function SigningPage({ params: { token } }: SigningPageProp return notFound(); } + const truncatedTitle = truncateTitle(document.title); + const { documentData } = document; const { user } = await getServerComponentSession(); @@ -82,7 +86,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp >

- {document.title} + {truncatedTitle}

diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx index 0ce750a39..faecf5d7e 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { Document, Field } from '@documenso/prisma/client'; +import type { Document, Field } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -9,6 +9,8 @@ import { DialogTrigger, } from '@documenso/ui/primitives/dialog'; +import { truncateTitle } from '~/helpers/truncate-title'; + export type SignDialogProps = { isSubmitting: boolean; document: Document; @@ -23,7 +25,7 @@ export const SignDialog = ({ onSignatureComplete, }: SignDialogProps) => { const [showDialog, setShowDialog] = useState(false); - + const truncatedTitle = truncateTitle(document.title); const isComplete = fields.every((field) => field.inserted); return ( @@ -43,7 +45,7 @@ export const SignDialog = ({
Sign Document
- You are about to finish signing "{document.title}". Are you sure? + You are about to finish signing "{truncatedTitle}". Are you sure?
diff --git a/apps/web/src/helpers/truncate-title.ts b/apps/web/src/helpers/truncate-title.ts new file mode 100644 index 000000000..2ad25c39a --- /dev/null +++ b/apps/web/src/helpers/truncate-title.ts @@ -0,0 +1,10 @@ +export const truncateTitle = (title: string, maxLength: number = 16) => { + if (title.length <= maxLength) { + return title; + } + + const start = title.slice(0, maxLength / 2); + const end = title.slice(-maxLength / 2); + + return `${start}.....${end}`; +}; From 72a7dc6c051f06323484accff15707eab13abe0d Mon Sep 17 00:00:00 2001 From: harkiratsm Date: Fri, 29 Dec 2023 17:26:33 +0530 Subject: [PATCH 055/156] fix the console error Signed-off-by: harkiratsm --- packages/ui/primitives/document-password-dialog.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/primitives/document-password-dialog.tsx b/packages/ui/primitives/document-password-dialog.tsx index da482bae3..61436aa71 100644 --- a/packages/ui/primitives/document-password-dialog.tsx +++ b/packages/ui/primitives/document-password-dialog.tsx @@ -28,19 +28,19 @@ export const PasswordDialog = ({ open, onOpenChange, handleSubmit, isError, setP Password Required {isError ? ( -

Incorrect password. Please try again.

+ Incorrect password. Please try again. ) : ( -

+ This document is password protected. Please enter the password to view the document. -

+ )}
setPassword(e.target.value)} /> From bed788f78f8d35c8c97a2359cbc7db850cf8b2d6 Mon Sep 17 00:00:00 2001 From: sadam Date: Fri, 29 Dec 2023 08:48:26 -0500 Subject: [PATCH 056/156] docs(url): change URL for cloning --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d4ce339c1..711a1f6b3 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ To run Documenso locally, you will need Want to get up and running quickly? Follow these steps: -1. [Fork this repository ](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your github account. +1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account. After forking clone it using the below command: @@ -154,7 +154,7 @@ npm run d Follow these steps to setup Documenso on your local machine: -1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) it to your github account. +1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account. After forking clone it using the below command: @@ -231,7 +231,7 @@ cp .env.example .env The following environment variables must be set: -* `NEXTAUTH_URL` +* `NEXTAUTH_URL` * `NEXTAUTH_SECRET` * `NEXT_PUBLIC_WEBAPP_URL` * `NEXT_PUBLIC_MARKETING_URL` From 53c570151f43918452c7923a92caef114bde288c Mon Sep 17 00:00:00 2001 From: harkiratsm Date: Fri, 29 Dec 2023 22:11:44 +0530 Subject: [PATCH 057/156] fix lint, description of dialog Signed-off-by: harkiratsm --- .../primitives/document-password-dialog.tsx | 43 +++--- packages/ui/primitives/pdf-viewer.tsx | 144 +++++++++--------- 2 files changed, 96 insertions(+), 91 deletions(-) diff --git a/packages/ui/primitives/document-password-dialog.tsx b/packages/ui/primitives/document-password-dialog.tsx index 61436aa71..08a5de8f3 100644 --- a/packages/ui/primitives/document-password-dialog.tsx +++ b/packages/ui/primitives/document-password-dialog.tsx @@ -1,50 +1,55 @@ import React from 'react'; +import { Button } from './button'; import { Dialog, DialogContent, - DialogHeader, - DialogTitle, DialogDescription, DialogFooter, -} from './dialog'; - -import { Input } from './input'; -import { Button } from './button'; + DialogHeader, + DialogTitle, +} from './dialog'; +import { Input } from './input'; type PasswordDialogProps = { open: boolean; onOpenChange: (_open: boolean) => void; setPassword: (_password: string) => void; - handleSubmit: () => void; + onPasswordSubmit: () => void; isError?: boolean; -} +}; -export const PasswordDialog = ({ open, onOpenChange, handleSubmit, isError, setPassword }: PasswordDialogProps) => { +export const PasswordDialog = ({ + open, + onOpenChange, + onPasswordSubmit, + isError, + setPassword, +}: PasswordDialogProps) => { return ( Password Required - - {isError ? ( - Incorrect password. Please try again. - ) : ( - - This document is password protected. Please enter the password to view the document. - - )} + + This document is password protected. Please enter the password to view the document. setPassword(e.target.value)} + autoComplete="off" /> - + + {isError && ( + + The password you entered is incorrect. Please try again. + + )} ); diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx index c4184b17f..be2d0cc4a 100644 --- a/packages/ui/primitives/pdf-viewer.tsx +++ b/packages/ui/primitives/pdf-viewer.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Loader } from 'lucide-react'; -import { PasswordResponses, type PDFDocumentProxy } from 'pdfjs-dist'; +import { type PDFDocumentProxy, PasswordResponses } from 'pdfjs-dist'; import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf'; import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; import 'react-pdf/dist/esm/Page/TextLayer.css'; @@ -13,8 +13,8 @@ import { getFile } from '@documenso/lib/universal/upload/get-file'; import type { DocumentData } from '@documenso/prisma/client'; import { cn } from '../lib/utils'; -import { useToast } from './use-toast'; import { PasswordDialog } from './document-password-dialog'; +import { useToast } from './use-toast'; export type LoadedPDFDocument = PDFDocumentProxy; @@ -82,14 +82,14 @@ export const PDFViewer = ({ setNumPages(doc.numPages); onDocumentLoad?.(doc); }; - - const handlePasswordSubmit = () => { + + const onPasswordSubmit = () => { setIsPasswordModalOpen(false); if (passwordCallbackRef.current) { passwordCallbackRef.current(password); passwordCallbackRef.current = null; } - } + }; const onDocumentPageClick = ( event: React.MouseEvent, @@ -183,80 +183,80 @@ export const PDFViewer = ({
) : ( <> - { - setIsPasswordModalOpen(true); - passwordCallbackRef.current = callback; - switch (reason) { - case PasswordResponses.NEED_PASSWORD: - setIsPasswordError(false); - break; - case PasswordResponses.INCORRECT_PASSWORD: - setIsPasswordError(true); - break; - default: - break; + { + setIsPasswordModalOpen(true); + passwordCallbackRef.current = callback; + switch (reason) { + case PasswordResponses.NEED_PASSWORD: + setIsPasswordError(false); + break; + case PasswordResponses.INCORRECT_PASSWORD: + setIsPasswordError(true); + break; + default: + break; + } + }} + onLoadSuccess={(d) => onDocumentLoaded(d)} + // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. + // Therefore we add some additional custom error handling. + onSourceError={() => { + setPdfError(true); + }} + externalLinkTarget="_blank" + loading={ +
+ {pdfError ? ( +
+

Something went wrong while loading the document.

+

Please try again or contact our support.

+
+ ) : ( + + )} +
} - }} - onLoadSuccess={(d) => onDocumentLoaded(d)} - // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. - // Therefore we add some additional custom error handling. - onSourceError={() => { - setPdfError(true); - }} - externalLinkTarget="_blank" - loading={ -
- {pdfError ? ( + error={ +

Something went wrong while loading the document.

Please try again or contact our support.

- ) : ( - - )} -
- } - error={ -
-
-

Something went wrong while loading the document.

-

Please try again or contact our support.

-
- } - > - {Array(numPages) - .fill(null) - .map((_, i) => ( -
- ''} - onClick={(e) => onDocumentPageClick(e, i + 1)} - /> -
- ))} - - + } + > + {Array(numPages) + .fill(null) + .map((_, i) => ( +
+ ''} + onClick={(e) => onDocumentPageClick(e, i + 1)} + /> +
+ ))} + + - )} + )}
); }; From 77facba8b44b99425d96a267c86a30ae15cd80aa Mon Sep 17 00:00:00 2001 From: sadam Date: Sat, 30 Dec 2023 18:27:24 -0500 Subject: [PATCH 058/156] docs(url): change URL for cloning --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 711a1f6b3..26f91e8d2 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Want to get up and running quickly? Follow these steps: 1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account. -After forking clone it using the below command: +After forking the repository, clone it to your local device by using the following command: ```sh git clone https://github.com//documenso @@ -156,7 +156,7 @@ Follow these steps to setup Documenso on your local machine: 1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account. -After forking clone it using the below command: +After forking the repository, clone it to your local device by using the following command: ```sh git clone https://github.com//documenso From d02f6774b21bf0dcdc67722824962b0ff810ead5 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu Date: Mon, 1 Jan 2024 17:41:29 +0000 Subject: [PATCH 059/156] chore: update the stale to prevent automatic closure of issues --- .github/workflows/stale.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index efd681a71..ab852de4c 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,9 +17,8 @@ jobs: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-pr-stale: 30 days-before-issue-stale: 30 - stale-issue-message: 'This issue has not seen activity for a while. It will be closed in 30 days unless further activity is detected' + days-before-issue-close: -1 stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.' - close-issue-message: 'This issue has been closed because of inactivity.' close-pr-message: 'This PR has been closed because of inactivity.' exempt-pr-labels: 'WIP,on-hold,needs review' exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned' From b76d2cea3be939805b1bf051d601708435a416eb Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Tue, 2 Jan 2024 04:38:35 +0000 Subject: [PATCH 060/156] fix: changes from code review --- .vscode/settings.json | 2 +- .../documents/data-table-action-button.tsx | 44 ++++++++++++------- .../documents/data-table-action-dropdown.tsx | 42 +++++++++++------- packages/lib/client-only/download-pdf.ts | 33 +++++--------- .../document/document-download-button.tsx | 27 +++++++++--- packages/ui/primitives/use-toast.ts | 3 +- 6 files changed, 85 insertions(+), 66 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 97d5d1948..82aa3c1a3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "typescript.tsdk": "node_modules/typescript/lib", "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"], "javascript.preferences.importModuleSpecifier": "non-relative", diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index 51f3f7c58..9910ef111 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -6,12 +6,13 @@ import { Download, Edit, Pencil } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { match } from 'ts-pattern'; -import { downloadFile } from '@documenso/lib/client-only/download-pdf'; +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import type { Document, Recipient, User } from '@documenso/prisma/client'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; export type DataTableActionButtonProps = { row: Document & { @@ -22,6 +23,7 @@ export type DataTableActionButtonProps = { export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const { data: session } = useSession(); + const { toast } = useToast(); if (!session) { return null; @@ -37,25 +39,33 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const onDownloadClick = async () => { - let document: DocumentWithData | null = null; + try { + let document: DocumentWithData | null = null; - if (!recipient) { - document = await trpcClient.document.getDocumentById.query({ - id: row.id, - }); - } else { - document = await trpcClient.document.getDocumentByToken.query({ - token: recipient.token, + if (!recipient) { + document = await trpcClient.document.getDocumentById.query({ + id: row.id, + }); + } else { + document = await trpcClient.document.getDocumentByToken.query({ + token: recipient.token, + }); + } + + const documentData = document?.documentData; + + if (!documentData) { + throw Error('No document available'); + } + + await downloadPDF({ documentData, fileName: row.title }); + } catch (err) { + toast({ + title: 'Something went wrong', + description: 'An error occurred while downloading your document.', + variant: 'destructive', }); } - - const documentData = document?.documentData; - - if (!documentData) { - return; - } - - await downloadFile({ documentData, fileName: row.title }); }; return match({ diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index e9a713d62..c95a49ff6 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -17,7 +17,7 @@ import { } from 'lucide-react'; import { useSession } from 'next-auth/react'; -import { downloadFile } from '@documenso/lib/client-only/download-pdf'; +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import type { Document, Recipient, User } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; @@ -63,25 +63,33 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = const isDocumentDeletable = isOwner; const onDownloadClick = async () => { - let document: DocumentWithData | null = null; + try { + let document: DocumentWithData | null = null; - if (!recipient) { - document = await trpcClient.document.getDocumentById.query({ - id: row.id, - }); - } else { - document = await trpcClient.document.getDocumentByToken.query({ - token: recipient.token, + if (!recipient) { + document = await trpcClient.document.getDocumentById.query({ + id: row.id, + }); + } else { + document = await trpcClient.document.getDocumentByToken.query({ + token: recipient.token, + }); + } + + const documentData = document?.documentData; + + if (!documentData) { + return; + } + + await downloadPDF({ documentData, fileName: row.title }); + } catch (err) { + toast({ + title: 'Something went wrong', + description: 'An error occurred while downloading your document.', + variant: 'destructive', }); } - - const documentData = document?.documentData; - - if (!documentData) { - return; - } - - await downloadFile({ documentData, fileName: row.title }); }; const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); diff --git a/packages/lib/client-only/download-pdf.ts b/packages/lib/client-only/download-pdf.ts index af304f983..e095002ee 100644 --- a/packages/lib/client-only/download-pdf.ts +++ b/packages/lib/client-only/download-pdf.ts @@ -1,5 +1,4 @@ import type { DocumentData } from '@documenso/prisma/client'; -import { toast } from '@documenso/ui/primitives/use-toast'; import { getFile } from '../universal/upload/get-file'; @@ -8,30 +7,20 @@ type DownloadPDFProps = { fileName?: string; }; -export const downloadFile = async ({ documentData, fileName }: DownloadPDFProps) => { - try { - const bytes = await getFile(documentData); +export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps) => { + const bytes = await getFile(documentData); - const blob = new Blob([bytes], { - type: 'application/pdf', - }); + const blob = new Blob([bytes], { + type: 'application/pdf', + }); - const link = window.document.createElement('a'); - const baseTitle = fileName?.includes('.pdf') ? fileName.split('.pdf')[0] : fileName; + const link = window.document.createElement('a'); + const baseTitle = fileName?.includes('.pdf') ? fileName.split('.pdf')[0] : fileName; - link.href = window.URL.createObjectURL(blob); - link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; + link.href = window.URL.createObjectURL(blob); + link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; - link.click(); + link.click(); - window.URL.revokeObjectURL(link.href); - } catch (err) { - console.error(err); - - toast({ - title: 'Something went wrong', - description: 'An error occurred while downloading your document.', - variant: 'destructive', - }); - } + window.URL.revokeObjectURL(link.href); }; diff --git a/packages/ui/components/document/document-download-button.tsx b/packages/ui/components/document/document-download-button.tsx index 9471611ff..ee41cc691 100644 --- a/packages/ui/components/document/document-download-button.tsx +++ b/packages/ui/components/document/document-download-button.tsx @@ -3,9 +3,10 @@ import type { HTMLAttributes } from 'react'; import { useState } from 'react'; +import { useToast } from '@/primitives/use-toast'; import { Download } from 'lucide-react'; -import { downloadFile } from '@documenso/lib/client-only/download-pdf'; +import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import type { DocumentData } from '@documenso/prisma/client'; import { Button } from '../../primitives/button'; @@ -24,17 +25,29 @@ export const DocumentDownloadButton = ({ ...props }: DownloadButtonProps) => { const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); const onDownloadClick = async () => { - setIsLoading(true); + try { + setIsLoading(true); - if (!documentData) { - return; - } + if (!documentData) { + setIsLoading(false); + return; + } - await downloadFile({ documentData, fileName }).then(() => { + await downloadPDF({ documentData, fileName }).then(() => { + setIsLoading(false); + }); + } catch (err) { setIsLoading(false); - }); + + toast({ + title: 'Something went wrong', + description: 'An error occurred while downloading your document.', + variant: 'destructive', + }); + } }; return ( diff --git a/packages/ui/primitives/use-toast.ts b/packages/ui/primitives/use-toast.ts index 27f96aa29..093ec8bb5 100644 --- a/packages/ui/primitives/use-toast.ts +++ b/packages/ui/primitives/use-toast.ts @@ -1,8 +1,7 @@ // Inspired by react-hot-toast library import * as React from 'react'; -import type { ToastActionElement } from './toast'; -import { type ToastProps } from './toast'; +import type { ToastActionElement, ToastProps } from './toast'; const TOAST_LIMIT = 1; const TOAST_REMOVE_DELAY = 1000000; From 6a26ab4b2b8bcb23348e421d7dc6de7e6c08ff10 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Tue, 2 Jan 2024 04:52:15 +0000 Subject: [PATCH 061/156] fix: toast import errors --- .../app/(dashboard)/documents/data-table-action-dropdown.tsx | 2 ++ packages/ui/components/document/document-download-button.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index c95a49ff6..a0695509d 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -30,6 +30,7 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from '@documenso/ui/primitives/dropdown-menu'; +import { useToast } from '@documenso/ui/primitives/use-toast'; import { ResendDocumentActionItem } from './_action-items/resend-document'; import { DeleteDocumentDialog } from './delete-document-dialog'; @@ -44,6 +45,7 @@ export type DataTableActionDropdownProps = { export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { const { data: session } = useSession(); + const { toast } = useToast(); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); diff --git a/packages/ui/components/document/document-download-button.tsx b/packages/ui/components/document/document-download-button.tsx index ee41cc691..1c4490f4a 100644 --- a/packages/ui/components/document/document-download-button.tsx +++ b/packages/ui/components/document/document-download-button.tsx @@ -3,11 +3,11 @@ import type { HTMLAttributes } from 'react'; import { useState } from 'react'; -import { useToast } from '@/primitives/use-toast'; import { Download } from 'lucide-react'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import type { DocumentData } from '@documenso/prisma/client'; +import { useToast } from '@documenso/ui/primitives/use-toast'; import { Button } from '../../primitives/button'; From d731532fbf57d5556919b3218e3d9d8ecd053887 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Tue, 2 Jan 2024 04:58:35 +0000 Subject: [PATCH 062/156] chore: hide empty accordion for documents without date field --- .../primitives/document-flow/add-subject.tsx | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index d73019732..8fef8af7b 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -160,14 +160,14 @@ export const AddSubjectFormPartial = ({
- - - - Advanced Options - + {hasDateField && ( + + + + Advanced Options + - - {hasDateField && ( +
- )} - {hasDateField && (
- )} -
-
-
+ +
+
+ )}
From c1a6a327af62e1e23cc369d353116a993d37a145 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 3 Jan 2024 12:54:32 +1100 Subject: [PATCH 063/156] chore: update stale workflows --- .github/workflows/stale.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ab852de4c..3e829d24b 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -15,10 +15,10 @@ jobs: - uses: actions/stale@v4 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - days-before-pr-stale: 30 - days-before-issue-stale: 30 - days-before-issue-close: -1 + days-before-pr-stale: 90 + days-before-issue-stale: 90 + days-before-issue-close: 180 stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.' close-pr-message: 'This PR has been closed because of inactivity.' exempt-pr-labels: 'WIP,on-hold,needs review' - exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned' + exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned,needs triage' From 5c16b10dc272e37fef6f7a4a16b54ea482014c35 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 3 Jan 2024 13:16:27 +1100 Subject: [PATCH 064/156] fix: update footer to be responsive --- apps/marketing/src/components/(marketing)/footer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index 1399297c7..bbb2aba7b 100644 --- a/apps/marketing/src/components/(marketing)/footer.tsx +++ b/apps/marketing/src/components/(marketing)/footer.tsx @@ -39,7 +39,7 @@ export const Footer = ({ className, ...props }: FooterProps) => { return (
-
+
{
-
+
{FOOTER_LINKS.map((link, index) => ( Date: Wed, 3 Jan 2024 13:23:13 +0530 Subject: [PATCH 065/156] chore: fix package vulnerabilities Signed-off-by: Adithya Krishna --- package-lock.json | 149 ++++++++++++++++++++++---- packages/eslint-config/package.json | 2 +- packages/tailwind-config/package.json | 2 +- 3 files changed, 133 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 708b54363..e3c1139f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6619,6 +6619,15 @@ "@types/node": "*" } }, + "node_modules/@types/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, "node_modules/@types/hast": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.8.tgz", @@ -6656,6 +6665,11 @@ "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.10.tgz", "integrity": "sha512-Rllzc5KHk0Al5/WANwgSPl1/CwjqCy+AZrGd78zuK+jO9aDM6ffblZ+zIjgPNAaEBmlO0RYDvLNh7wD0zKVgEg==" }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==" + }, "node_modules/@types/minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", @@ -8963,6 +8977,14 @@ "node": ">=6" } }, + "node_modules/detect-indent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", + "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-libc": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", @@ -8971,6 +8993,14 @@ "node": ">=8" } }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -10092,22 +10122,6 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" } }, - "node_modules/eslint-plugin-package-json": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-package-json/-/eslint-plugin-package-json-0.1.5.tgz", - "integrity": "sha512-WEWQHMrKi3XHw5HKsykNO0ui1VQ+Au1H0WcgWU3Kgt/S7yTu9SW5dPUu/pliZ+tbHO0PNWV+tURNkDYL+fxEpA==", - "dependencies": { - "disparity": "^3.0.0", - "package-json-validator": "^0.6.3", - "requireindex": "^1.2.0" - }, - "engines": { - "node": ">=8.0.0" - }, - "peerDependencies": { - "eslint": ">=4.7.0" - } - }, "node_modules/eslint-plugin-prettier": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", @@ -11120,6 +11134,14 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/git-hooks-list": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/git-hooks-list/-/git-hooks-list-1.0.3.tgz", + "integrity": "sha512-Y7wLWcrLUXwk2noSka166byGCvhMtDRpgHdzCno1UQv/n/Hegp++a2xBWJL1lJarnKD3SWaljD+0z1ztqxuKyQ==", + "funding": { + "url": "https://github.com/fisker/git-hooks-list?sponsor=1" + } + }, "node_modules/git-raw-commits": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.11.tgz", @@ -17225,6 +17247,53 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/sort-object-keys": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-1.1.3.tgz", + "integrity": "sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==" + }, + "node_modules/sort-package-json": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/sort-package-json/-/sort-package-json-1.57.0.tgz", + "integrity": "sha512-FYsjYn2dHTRb41wqnv+uEqCUvBpK3jZcTp9rbz2qDTmel7Pmdtf+i2rLaaPMRZeSVM60V3Se31GyWFpmKs4Q5Q==", + "dependencies": { + "detect-indent": "^6.0.0", + "detect-newline": "3.1.0", + "git-hooks-list": "1.0.3", + "globby": "10.0.0", + "is-plain-obj": "2.1.0", + "sort-object-keys": "^1.1.3" + }, + "bin": { + "sort-package-json": "cli.js" + } + }, + "node_modules/sort-package-json/node_modules/globby": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.0.tgz", + "integrity": "sha512-3LifW9M4joGZasyYPz2A1U74zbC/45fvpXUvO/9KbSa+VV0aGZarWkfdgKyR9sExNP0t0x0ss/UMJpNpcaTspw==", + "dependencies": { + "@types/glob": "^7.1.1", + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.0.3", + "glob": "^7.1.3", + "ignore": "^5.1.1", + "merge2": "^1.2.3", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sort-package-json/node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "engines": { + "node": ">=8" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -19539,13 +19608,30 @@ "eslint-config-next": "13.4.19", "eslint-config-prettier": "^8.8.0", "eslint-config-turbo": "^1.9.3", - "eslint-plugin-package-json": "^0.1.4", + "eslint-plugin-package-json": "^0.2.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.32.2", "eslint-plugin-unused-imports": "^3.0.0", "typescript": "5.2.2" } }, + "packages/eslint-config/node_modules/eslint-plugin-package-json": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-package-json/-/eslint-plugin-package-json-0.2.0.tgz", + "integrity": "sha512-JQulhbH8M3gnyEKekqt9+4MKQtK8GRLBQlTvTiqyNSkbF+cDpq6GojCdGN6ov11wE+8iHjZlFDeg8u+gXfjhGA==", + "dependencies": { + "disparity": "^3.2.0", + "package-json-validator": "^0.6.3", + "requireindex": "^1.2.0", + "sort-package-json": "^1.57.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "peerDependencies": { + "eslint": ">=4.7.0" + } + }, "packages/eslint-config/node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", @@ -19675,7 +19761,7 @@ "license": "MIT", "dependencies": { "autoprefixer": "^10.4.13", - "postcss": "^8.4.21", + "postcss": "^8.4.32", "tailwindcss": "3.3.2", "tailwindcss-animate": "^1.0.5" }, @@ -19683,6 +19769,33 @@ "@tailwindcss/typography": "^0.5.9" } }, + "packages/tailwind-config/node_modules/postcss": { + "version": "8.4.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", + "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "packages/trpc": { "name": "@documenso/trpc", "version": "1.0.0", diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index f80719aa1..d519a3362 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -13,7 +13,7 @@ "eslint-config-next": "13.4.19", "eslint-config-prettier": "^8.8.0", "eslint-config-turbo": "^1.9.3", - "eslint-plugin-package-json": "^0.1.4", + "eslint-plugin-package-json": "^0.2.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.32.2", "eslint-plugin-unused-imports": "^3.0.0", diff --git a/packages/tailwind-config/package.json b/packages/tailwind-config/package.json index af96dc595..d6827955f 100644 --- a/packages/tailwind-config/package.json +++ b/packages/tailwind-config/package.json @@ -8,7 +8,7 @@ }, "dependencies": { "autoprefixer": "^10.4.13", - "postcss": "^8.4.21", + "postcss": "^8.4.32", "tailwindcss": "3.3.2", "tailwindcss-animate": "^1.0.5" }, From 6be119ac95468ebfb88896ff730b670fda80a6c1 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 3 Jan 2024 20:10:50 +1100 Subject: [PATCH 066/156] fix: improve document meta logic --- .../server-only/document-meta/upsert-document-meta.ts | 9 +++++++++ packages/trpc/server/document-router/router.ts | 1 + 2 files changed, 10 insertions(+) diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index c7221cce9..34c33e7cd 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -8,6 +8,7 @@ export type CreateDocumentMetaOptions = { message: string; timezone: string; dateFormat: string; + userId: number; }; export const upsertDocumentMeta = async ({ @@ -16,7 +17,15 @@ export const upsertDocumentMeta = async ({ timezone, dateFormat, documentId, + userId, }: CreateDocumentMetaOptions) => { + await prisma.document.findFirstOrThrow({ + where: { + id: documentId, + userId, + }, + }); + return await prisma.documentMeta.upsert({ where: { documentId, diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 425f34857..b4a1b60e3 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -188,6 +188,7 @@ export const documentRouter = router({ message: meta.message, dateFormat: meta.dateFormat, timezone: meta.timezone, + userId: ctx.user.id, }); } From fface15a22002ebb676a36cb6670ae15fd152939 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 5 Jan 2024 12:56:07 +0200 Subject: [PATCH 067/156] feat: jump to next field --- apps/web/src/app/(signing)/sign/[token]/form.tsx | 6 ++++++ apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 4f20a8199..f5c94e6ec 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -49,6 +49,11 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = return sortFieldsByPosition(fields.filter((field) => !field.inserted)); }, [fields]); + const fieldsValidated = () => { + setValidateUninsertedFields(true); + validateFieldsInserted(fields); + }; + const onFormSubmit = async () => { setValidateUninsertedFields(true); @@ -154,6 +159,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = onSignatureComplete={handleSubmit(onFormSubmit)} document={document} fields={fields} + fieldsValidated={fieldsValidated} />
diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx index faecf5d7e..6e01aa3cf 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -15,6 +15,7 @@ export type SignDialogProps = { isSubmitting: boolean; document: Document; fields: Field[]; + fieldsValidated: () => void | Promise; onSignatureComplete: () => void | Promise; }; @@ -22,6 +23,7 @@ export const SignDialog = ({ isSubmitting, document, fields, + fieldsValidated, onSignatureComplete, }: SignDialogProps) => { const [showDialog, setShowDialog] = useState(false); @@ -29,16 +31,17 @@ export const SignDialog = ({ const isComplete = fields.every((field) => field.inserted); return ( - + From 4fd6a0d5b6346ccd273d79175877434015112ef6 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 5 Jan 2024 13:06:16 +0200 Subject: [PATCH 068/156] chore: update onOpenChange --- apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx index 6e01aa3cf..a69a79b5d 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -31,7 +31,7 @@ export const SignDialog = ({ const isComplete = fields.every((field) => field.inserted); return ( - + From 3054d84ba70eb737e27b85e33b82a8541a822b43 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Mon, 8 Jan 2024 09:58:34 +0200 Subject: [PATCH 070/156] chore: implemented feedback --- apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx index a69a79b5d..e4d4571fc 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -37,11 +37,10 @@ export const SignDialog = ({ className="w-full" type="button" size="lg" - variant={isComplete ? 'default' : 'outline'} onClick={fieldsValidated} loading={isSubmitting} > - {isComplete ? 'Complete' : 'Fill fields'} + {isComplete ? 'Complete' : 'Next field'} From f9d26e6b3f19ef83c746019c643a2371569b18e8 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Mon, 8 Jan 2024 13:39:34 +0530 Subject: [PATCH 071/156] fix: stepsRemaining value of the early adopters plan's input section (#803) --- apps/marketing/src/components/(marketing)/widget.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/marketing/src/components/(marketing)/widget.tsx b/apps/marketing/src/components/(marketing)/widget.tsx index c1ceadafe..80c13b275 100644 --- a/apps/marketing/src/components/(marketing)/widget.tsx +++ b/apps/marketing/src/components/(marketing)/widget.tsx @@ -1,6 +1,7 @@ 'use client'; -import { HTMLAttributes, KeyboardEvent, useMemo, useState } from 'react'; +import type { HTMLAttributes, KeyboardEvent } from 'react'; +import { useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { AnimatePresence, motion } from 'framer-motion'; @@ -90,10 +91,10 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { } if (step === STEP.EMAIL) { - return 1; + return 3; } - return 3; + return 1; }, [step]); const onNextStepClick = () => { From 66bb56047a3b8ae2c29d93c991ee1ae1cdcc63ea Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Tue, 9 Jan 2024 14:32:49 +0100 Subject: [PATCH 072/156] chore: update roadmap links --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 62cfeee72..39cbb4332 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ · Issues · - Roadmap + Upcoming Releases · - Upcoming Launches + Roadmap

From ed1998278ad6b5d8aee1c2efb5ec0cc6c14ddc66 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 10 Jan 2024 16:14:02 +0100 Subject: [PATCH 073/156] feat: draft github blogpost --- apps/marketing/content/blog/linear-gh.mdx | 86 +++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 apps/marketing/content/blog/linear-gh.mdx diff --git a/apps/marketing/content/blog/linear-gh.mdx b/apps/marketing/content/blog/linear-gh.mdx new file mode 100644 index 000000000..cd9f379f6 --- /dev/null +++ b/apps/marketing/content/blog/linear-gh.mdx @@ -0,0 +1,86 @@ +--- +title: Moving from Linear to GitHub & LIVE Roadmap 2.0 +description: We are leaving linear and are going all in on GitHub. Here is how we do it. +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2024-01-10 +tags: + - GitHub + - Backlog + - Roadmap +--- + +# From Linear to GitHub + +> TLDR; We are leaving Linear and using only GitHub now. We don't communicate feature timelines anymore, only what we are working on and what's next. + +If you follow us, you know that we have been in full on build-mode. We are building, the community is building, it's great. As building is our daily business now, we think a lot about how to do it better. +Our most recent approach is to reduce the number of tools and plattform we use. Every tool you use + +- Reduces the average time you spend on the tool +- Reduces your focus +- Increases mental load to keep all points of interest in mind + +So, we thought about what's the plattform/ tool is, that we spend the most time on. Hardly suprising, it's Github. No only do we spend a lot of time there, we also WANT to spend a lot of time there because: + +- It's where the community contributes and we are all about community (as you may know) +- It where we show the world what we are working on + +# The old structure + +So far, we have been using Linear for our Backlog/ Task Management and synced issue we want to showcase or work on with the community via synclinear.com. Not onyl did we have our development issue there, but since +we have our own resident founding designer we created a proper design backlog, to structure our design worklows. + +# The new structure + +Once we realized our focus was way more on GitHub anyway, we simply decided to move everything there. This has a few key benefits: + +- Reducing dilution of attention and time: You can just hang out on GitHub without risk of missing much +- Putting different aspects of Documenso close to each other: Development, Design, Community +- Keep longterm, niche and very abstract issues out of the main repo, so we don't get desensitized by large issue numbers + +To achieve this, we created a few GitHub repositories to host issues, with the old main repository remaining the central point of interest, especially for the community. + +## 1. Main Repository - Day to day Issues and the shorter-term roadmap (LIVE Roadmap 2.0) + +> [github.com/documenso/documenso](https://github.com/documenso/documenso) + +Apart from the source code of the Documenso app and plattform the main repo houses issues raised by the community as well as issues where we invite the community to participate. +While overhauling our issue management, we are also updating our progress communication. While the process of software and product development is extremely complex, +we try to give as much insight into what we do as possible. To that end we went throught 3 phases, with 3 being what we do now. + +1. **One big roadmap**: Initially we had one roadmap and where (very) slowly checking of boxsed there (via a "Roadmap" milestone) while this is easy, it's also pretty imprecise and not practical as the project grows +2. **Estimated releases per quarter**: To give better guidance we tried communicating our goals for the quarter, a quite big window, we thought we could roughly "hit". While the idea to not be too detailed was good, it is extremely hard to estimate when some major thigns are done, if you do a lot of minor/ other things in parallel e.g. working with the community and tuning things you go. The reason why hitting time targets is so hard, is that it may not be the smartest thing to stick to that time target if other things come up. This is much easier to grasp for the people closely involved. The fallacy is to assume the thing you plan for exists in vaccuum. +3. Since we do not want to limit ourselves in choosing the most effective course but still give some insight into what's going on and what's coming up we updated the LIVE Roadmap [https://documen.so/live](https://documen.so/live) to show what we are **currently working on and what's up next**, once we finish. We do not provide specific timeline, since we couldn't if we wanted to. Of course try to set our short-term goals based on what's best for the community. We try to give updates on the issues that are being worked on as good as possible. + +## 2. Public Backlog - The longer-term roadmap + +> [github.com/documenso/backlog](https://github.com/documenso/backlog) + +The public backlog houses everything we want to eventually build. We do not provide guidance on when that may be. The only thing we like to say is, that if we decide against something it will be removed from the public backlog, as we consider this our longterm vision for Documenso. If you are interested in something on the roadmap let us know by commenting on the issue or posting on Discord. +Issues in the public backlog are availible to be worked on. For issues to work on please check the main repository issues. The issues found here are scoped broader, since they are not meant for immediate execution but rather give a sense on where Documenso is going and what we consider part of our Domain. + +## 3. Internal Backlog + +> github.com/documenso/backlog-internal + +This is the actual replacement of our Linear backlog. He we host issues, too small or shortterm for the longterm roadmap and too niche or core to go into the main repo. We used a GitHub project as our development kanban board. + +## 4. Internal Design Backlog + +> github.com/documenso/design-internal + +This is the design equivalient of the internal backlog. The internal design backlog houses our design projects that include exploration of new features, detailed UI designs and improving the plattform overall. + +## 5. Public Design Repository + +> [github.com/documenso/backlog-design](https://github.com/documenso/design) + +While the internal design backlog also existed in Linear, the public design repository is new. Since designing is the open is tricky, we opted to instead publish the detailed design artefacts together with the corresponding feature. +We already have design.documenso.com housing our general design system. Here, we will publish the specifics of how we applied this to each feature. We will publish the first artifacts here soon, what may be in the cards can be found on the [LIVE Roadmap](https://documen.so/live). + +If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord). + +Best from Hamburg +Timur From 31050d6b7bf196c88db3b57ba1cf15f3dee656c5 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 10 Jan 2024 16:14:28 +0100 Subject: [PATCH 074/156] chore: spelling --- apps/marketing/content/blog/linear-gh.mdx | 48 +++++++++++------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/marketing/content/blog/linear-gh.mdx b/apps/marketing/content/blog/linear-gh.mdx index cd9f379f6..80526959a 100644 --- a/apps/marketing/content/blog/linear-gh.mdx +++ b/apps/marketing/content/blog/linear-gh.mdx @@ -5,7 +5,7 @@ authorName: 'Timur Ercan' authorImage: '/blog/blog-author-timur.jpeg' authorRole: 'Co-Founder' date: 2024-01-10 -tags: +Tags: - GitHub - Backlog - Roadmap @@ -13,71 +13,71 @@ tags: # From Linear to GitHub -> TLDR; We are leaving Linear and using only GitHub now. We don't communicate feature timelines anymore, only what we are working on and what's next. +> TLDR; We are leaving Linear and using only GitHub now. We no longer communicate feature timelines, only what we are working on and what's next. -If you follow us, you know that we have been in full on build-mode. We are building, the community is building, it's great. As building is our daily business now, we think a lot about how to do it better. -Our most recent approach is to reduce the number of tools and plattform we use. Every tool you use +If you follow us, you know we have been in full-on build mode. We are building, the community is building, it's great. Building is our daily business, so we think a lot about improving it. +Our most recent approach is to reduce the number of tools and platforms we use. Every tool you use - Reduces the average time you spend on the tool - Reduces your focus - Increases mental load to keep all points of interest in mind -So, we thought about what's the plattform/ tool is, that we spend the most time on. Hardly suprising, it's Github. No only do we spend a lot of time there, we also WANT to spend a lot of time there because: +We thought about the platform/ tool we spend the most time on. Hardly surprising: it's GitHub. Not only do we spend a lot of time there, but we also WANT to spend a lot of time there because: -- It's where the community contributes and we are all about community (as you may know) -- It where we show the world what we are working on +- It's where the community contributes, and we are all about community (as you may know) +- It is where we show the world what we are working on # The old structure -So far, we have been using Linear for our Backlog/ Task Management and synced issue we want to showcase or work on with the community via synclinear.com. Not onyl did we have our development issue there, but since -we have our own resident founding designer we created a proper design backlog, to structure our design worklows. +So far, we have been using Linear for our Backlog/ Task Management and synced issues we want to showcase or work on with the community via synclinear.com. Not only did we have our development issue there, but since +we have our own resident founding designer. We created a proper design backlog to structure our design workflows. # The new structure -Once we realized our focus was way more on GitHub anyway, we simply decided to move everything there. This has a few key benefits: +We moved everything to GitHub once we realized our focus was already there. This has a few key benefits: -- Reducing dilution of attention and time: You can just hang out on GitHub without risk of missing much +- Reducing dilution of attention and time: You can hang out on GitHub without risk of missing much - Putting different aspects of Documenso close to each other: Development, Design, Community -- Keep longterm, niche and very abstract issues out of the main repo, so we don't get desensitized by large issue numbers +- Keep long-term, niche, and very abstract issues out of the main repo so we don't get desensitized by large issue numbers -To achieve this, we created a few GitHub repositories to host issues, with the old main repository remaining the central point of interest, especially for the community. +To achieve this, we created a few GitHub repositories to host issues, with the main repository remaining the central point of interest, especially for the community. ## 1. Main Repository - Day to day Issues and the shorter-term roadmap (LIVE Roadmap 2.0) > [github.com/documenso/documenso](https://github.com/documenso/documenso) -Apart from the source code of the Documenso app and plattform the main repo houses issues raised by the community as well as issues where we invite the community to participate. -While overhauling our issue management, we are also updating our progress communication. While the process of software and product development is extremely complex, -we try to give as much insight into what we do as possible. To that end we went throught 3 phases, with 3 being what we do now. +Apart from the source code of the Documenso app and platform, the main repo houses issues raised by the community and issues where we invite the community to participate. +While overhauling our issue management, we are also updating our progress communication. While the software and product development process is highly complex, +we try to give as much insight into what we do as possible. To that end, we went through 3 phases, three being what we do now. -1. **One big roadmap**: Initially we had one roadmap and where (very) slowly checking of boxsed there (via a "Roadmap" milestone) while this is easy, it's also pretty imprecise and not practical as the project grows -2. **Estimated releases per quarter**: To give better guidance we tried communicating our goals for the quarter, a quite big window, we thought we could roughly "hit". While the idea to not be too detailed was good, it is extremely hard to estimate when some major thigns are done, if you do a lot of minor/ other things in parallel e.g. working with the community and tuning things you go. The reason why hitting time targets is so hard, is that it may not be the smartest thing to stick to that time target if other things come up. This is much easier to grasp for the people closely involved. The fallacy is to assume the thing you plan for exists in vaccuum. -3. Since we do not want to limit ourselves in choosing the most effective course but still give some insight into what's going on and what's coming up we updated the LIVE Roadmap [https://documen.so/live](https://documen.so/live) to show what we are **currently working on and what's up next**, once we finish. We do not provide specific timeline, since we couldn't if we wanted to. Of course try to set our short-term goals based on what's best for the community. We try to give updates on the issues that are being worked on as good as possible. +1. **One extensive roadmap**: Initially we had one roadmap and were (very) slowly checking off boxes there (via a "Roadmap" milestone). While this is easy, it's also pretty imprecise and not practical as the project grows +2. **Estimated releases per quarter**: To give better guidance, we tried communicating our goals for the quarter, a pretty big window we thought we could roughly "hit". While the idea of not being too detailed was good, it is tough to estimate when some significant things are done if you do a lot of minor/ other things in parallel, e.g., working with the community and tuning things you go. Hitting time targets is tricky because there may be better ways to stick to that time target if other things arise. This is much easier to grasp for the people closely involved. The fallacy is to assume the thing you plan for exists in a vacuum. +3. Since we do not want to limit ourselves in choosing the most effective course but still give some insight into what's going on and what's coming up, we updated the LIVE Roadmap [https://documen.so/live](https://documen.so/live) to show what we are **currently working on and what's up next**, once we finish. We do not provide a specific timeline since we couldn't if we wanted to. Of course, we should set our short-term goals based on what's best for the community. We give updates on the issues being worked on as well as possible. ## 2. Public Backlog - The longer-term roadmap > [github.com/documenso/backlog](https://github.com/documenso/backlog) -The public backlog houses everything we want to eventually build. We do not provide guidance on when that may be. The only thing we like to say is, that if we decide against something it will be removed from the public backlog, as we consider this our longterm vision for Documenso. If you are interested in something on the roadmap let us know by commenting on the issue or posting on Discord. -Issues in the public backlog are availible to be worked on. For issues to work on please check the main repository issues. The issues found here are scoped broader, since they are not meant for immediate execution but rather give a sense on where Documenso is going and what we consider part of our Domain. +The public backlog houses everything we want to build eventually. We need to guide when that may be. We want to say that if we decide against something, it will be removed from the public backlog, as we consider this our long-term vision for Documenso. If you are interested in something on the roadmap, comment on the issue or post on Discord. +Issues in the public backlog are available to be worked on. For issues to work on, please check the main repository issues. The issues found here are scoped broader since they are not meant for immediate execution but rather give a sense of where Documenso is going and what we consider part of our Domain. ## 3. Internal Backlog > github.com/documenso/backlog-internal -This is the actual replacement of our Linear backlog. He we host issues, too small or shortterm for the longterm roadmap and too niche or core to go into the main repo. We used a GitHub project as our development kanban board. +This is the actual replacement of our Linear backlog. Here, we host issues that are too small or short-term for the long-term roadmap and too niche or core to go into the main repo. We used a GitHub project as our development Kanban board. ## 4. Internal Design Backlog > github.com/documenso/design-internal -This is the design equivalient of the internal backlog. The internal design backlog houses our design projects that include exploration of new features, detailed UI designs and improving the plattform overall. +This is the design equivalent of the internal backlog. The internal design backlog houses our design projects that include the exploration of new features, detailed UI designs, and improving the platform overall. ## 5. Public Design Repository > [github.com/documenso/backlog-design](https://github.com/documenso/design) -While the internal design backlog also existed in Linear, the public design repository is new. Since designing is the open is tricky, we opted to instead publish the detailed design artefacts together with the corresponding feature. +While the internal design backlog also existed in Linear, the public design repository is new. Since designing in the open is tricky, we opted to publish the detailed design artifacts with the corresponding feature instead. We already have design.documenso.com housing our general design system. Here, we will publish the specifics of how we applied this to each feature. We will publish the first artifacts here soon, what may be in the cards can be found on the [LIVE Roadmap](https://documen.so/live). If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord). From b501ffdee9da55840db50ed4d23293ba42556d13 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 10 Jan 2024 16:37:53 +0100 Subject: [PATCH 075/156] chore: images --- apps/marketing/content/blog/linear-gh.mdx | 51 +++++++++++++++++----- apps/marketing/public/blog/gh1.png | Bin 0 -> 157386 bytes apps/marketing/public/blog/gh2.png | Bin 0 -> 179074 bytes 3 files changed, 40 insertions(+), 11 deletions(-) create mode 100644 apps/marketing/public/blog/gh1.png create mode 100644 apps/marketing/public/blog/gh2.png diff --git a/apps/marketing/content/blog/linear-gh.mdx b/apps/marketing/content/blog/linear-gh.mdx index 80526959a..c9ca27508 100644 --- a/apps/marketing/content/blog/linear-gh.mdx +++ b/apps/marketing/content/blog/linear-gh.mdx @@ -16,21 +16,21 @@ Tags: > TLDR; We are leaving Linear and using only GitHub now. We no longer communicate feature timelines, only what we are working on and what's next. If you follow us, you know we have been in full-on build mode. We are building, the community is building, it's great. Building is our daily business, so we think a lot about improving it. -Our most recent approach is to reduce the number of tools and platforms we use. Every tool you use +Our most recent approach is to reduce the number of tools and platforms we use. Every tool we use - Reduces the average time you spend on the tool - Reduces your focus - Increases mental load to keep all points of interest in mind -We thought about the platform/ tool we spend the most time on. Hardly surprising: it's GitHub. Not only do we spend a lot of time there, but we also WANT to spend a lot of time there because: +We thought about where we spend the most time, and hardly surprising: it's GitHub. Not only do we spend a lot of time there, but we also WANT to spend a lot of time there because: - It's where the community contributes, and we are all about community (as you may know) -- It is where we show the world what we are working on +- It is where we show the world what we are working on (which is also what we are all about) # The old structure -So far, we have been using Linear for our Backlog/ Task Management and synced issues we want to showcase or work on with the community via synclinear.com. Not only did we have our development issue there, but since -we have our own resident founding designer. We created a proper design backlog to structure our design workflows. +So far, we have been using Linear for our Backlog/ Task Management and synced issues we want to showcase or work on with the community via synclinear.com. Not only did we have our development issues there, but since +we have our own resident founding designer, we created a proper design backlog to structure our design workflows. # The new structure @@ -46,32 +46,61 @@ To achieve this, we created a few GitHub repositories to host issues, with the m > [github.com/documenso/documenso](https://github.com/documenso/documenso) -Apart from the source code of the Documenso app and platform, the main repo houses issues raised by the community and issues where we invite the community to participate. +Apart from the source code of the Documenso app and website, the main repo houses issues raised by the community and issues where we invite the community to participate. While overhauling our issue management, we are also updating our progress communication. While the software and product development process is highly complex, we try to give as much insight into what we do as possible. To that end, we went through 3 phases, three being what we do now. 1. **One extensive roadmap**: Initially we had one roadmap and were (very) slowly checking off boxes there (via a "Roadmap" milestone). While this is easy, it's also pretty imprecise and not practical as the project grows -2. **Estimated releases per quarter**: To give better guidance, we tried communicating our goals for the quarter, a pretty big window we thought we could roughly "hit". While the idea of not being too detailed was good, it is tough to estimate when some significant things are done if you do a lot of minor/ other things in parallel, e.g., working with the community and tuning things you go. Hitting time targets is tricky because there may be better ways to stick to that time target if other things arise. This is much easier to grasp for the people closely involved. The fallacy is to assume the thing you plan for exists in a vacuum. -3. Since we do not want to limit ourselves in choosing the most effective course but still give some insight into what's going on and what's coming up, we updated the LIVE Roadmap [https://documen.so/live](https://documen.so/live) to show what we are **currently working on and what's up next**, once we finish. We do not provide a specific timeline since we couldn't if we wanted to. Of course, we should set our short-term goals based on what's best for the community. We give updates on the issues being worked on as well as possible. +2. **Estimated releases per quarter**: To give better guidance, we tried communicating our goals for the quarter, a pretty big window we thought we could roughly "hit". While the idea of not being too detailed was good, it is tough to estimate when some significant things are done if you do a lot of minor/ other things in parallel, + like working with the community and tuning things you go. Hitting time targets is tricky because there may be better things to do than sticking to that time target. This is always much easier to grasp for the people closely involved. The fallacy is to assume the thing you plan for exists in a vacuum. +3. Since we do not want to limit ourselves in choosing the most effective course but still give some insight into what's going on and what's coming up, we updated the LIVE Roadmap [https://documen.so/live](https://documen.so/live) to show what we are **currently working on and what's up next**, once we finish. We do not provide + a specific timeline anymore since we couldn't if we wanted to. Of course, we set our short-term goals based on what's best for the community. We give updates on the issues being worked on as well as possible. ## 2. Public Backlog - The longer-term roadmap > [github.com/documenso/backlog](https://github.com/documenso/backlog) -The public backlog houses everything we want to build eventually. We need to guide when that may be. We want to say that if we decide against something, it will be removed from the public backlog, as we consider this our long-term vision for Documenso. If you are interested in something on the roadmap, comment on the issue or post on Discord. -Issues in the public backlog are available to be worked on. For issues to work on, please check the main repository issues. The issues found here are scoped broader since they are not meant for immediate execution but rather give a sense of where Documenso is going and what we consider part of our Domain. +The public backlog houses everything we want to build eventually. We do not provide guidance when that may be. If we decide against something, it will be removed from the public backlog, as we consider this our long-term vision for Documenso. If you are interested in something on the roadmap, comment on the issue or post on Discord. This helps us gauge interest in specific features. +**Issues in the public backlog are not** available to be worked on. For issues to work on, please check the main repository issues. The issues found here are scoped broader since they are not meant for immediate execution but rather give a sense of where Documenso is going and what we consider part of our domain. ## 3. Internal Backlog > github.com/documenso/backlog-internal +
+ + +
+ Our internal Kanban for development +
+
+ This is the actual replacement of our Linear backlog. Here, we host issues that are too small or short-term for the long-term roadmap and too niche or core to go into the main repo. We used a GitHub project as our development Kanban board. ## 4. Internal Design Backlog > github.com/documenso/design-internal +
+ + +
+ Our internal Kanban for design +
+
+ This is the design equivalent of the internal backlog. The internal design backlog houses our design projects that include the exploration of new features, detailed UI designs, and improving the platform overall. +Similar Kanban board to the development backlog () ## 5. Public Design Repository @@ -82,5 +111,5 @@ We already have design.documenso.com housing our general design system. Here, we If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord). -Best from Hamburg +Best from Hamburg\ Timur diff --git a/apps/marketing/public/blog/gh1.png b/apps/marketing/public/blog/gh1.png new file mode 100644 index 0000000000000000000000000000000000000000..f28ce70c2b292d7ba0d770dc94f08b6ca71f75aa GIT binary patch literal 157386 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

QA&|rhr`nE^=!T# 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 literal 0 HcmV?d00001 diff --git a/apps/marketing/public/blog/gh2.png b/apps/marketing/public/blog/gh2.png new file mode 100644 index 0000000000000000000000000000000000000000..7ea7d4fa941d19b2dd4521a37d32028a74f6b9b3 GIT binary patch literal 179074 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%0D=wAxTTqxX$GbRw3OX$pmxYZ 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

uN~d)JCN21G0d3=wdjy^p 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%0Dk_{g_`O76 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 literal 0 HcmV?d00001 From ea0120abc85a2393e62754aca08ed30b8eb1dfd1 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 10 Jan 2024 16:51:42 +0100 Subject: [PATCH 076/156] chore: typo --- apps/marketing/content/blog/linear-gh.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/linear-gh.mdx b/apps/marketing/content/blog/linear-gh.mdx index c9ca27508..4eddc6afc 100644 --- a/apps/marketing/content/blog/linear-gh.mdx +++ b/apps/marketing/content/blog/linear-gh.mdx @@ -25,7 +25,7 @@ Our most recent approach is to reduce the number of tools and platforms we use. We thought about where we spend the most time, and hardly surprising: it's GitHub. Not only do we spend a lot of time there, but we also WANT to spend a lot of time there because: - It's where the community contributes, and we are all about community (as you may know) -- It is where we show the world what we are working on (which is also what we are all about) +- It's where we show the world what we are working on (which is also what we are all about) # The old structure From 1a73f3e007ff4647d3f10f1e26d5662c2d29aab9 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Thu, 11 Jan 2024 14:27:44 +0100 Subject: [PATCH 077/156] chore: feedback and phrasing --- apps/marketing/content/blog/linear-gh.mdx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/marketing/content/blog/linear-gh.mdx b/apps/marketing/content/blog/linear-gh.mdx index 4eddc6afc..a2fa1bf8b 100644 --- a/apps/marketing/content/blog/linear-gh.mdx +++ b/apps/marketing/content/blog/linear-gh.mdx @@ -13,9 +13,9 @@ Tags: # From Linear to GitHub -> TLDR; We are leaving Linear and using only GitHub now. We no longer communicate feature timelines, only what we are working on and what's next. +> TLDR; We are leaving Linear and are using only GitHub going forward. We no longer communicate feature timelines, only what we are working on and what's next. -If you follow us, you know we have been in full-on build mode. We are building, the community is building, it's great. Building is our daily business, so we think a lot about improving it. +If you follow us, you know we have been in full-on build mode. We are building, the community is building, it's great. Building is our daily business, so we think a lot about improving our approach to doing it. Our most recent approach is to reduce the number of tools and platforms we use. Every tool we use - Reduces the average time you spend on the tool @@ -24,8 +24,8 @@ Our most recent approach is to reduce the number of tools and platforms we use. We thought about where we spend the most time, and hardly surprising: it's GitHub. Not only do we spend a lot of time there, but we also WANT to spend a lot of time there because: -- It's where the community contributes, and we are all about community (as you may know) -- It's where we show the world what we are working on (which is also what we are all about) +- It's where the community contributes, and we are all about community +- It's where we show the world what we are working on # The old structure @@ -47,20 +47,20 @@ To achieve this, we created a few GitHub repositories to host issues, with the m > [github.com/documenso/documenso](https://github.com/documenso/documenso) Apart from the source code of the Documenso app and website, the main repo houses issues raised by the community and issues where we invite the community to participate. -While overhauling our issue management, we are also updating our progress communication. While the software and product development process is highly complex, +With the overhauling of our issue management, we are also updating our progress communication. While the software and product development process is highly complex, we try to give as much insight into what we do as possible. To that end, we went through 3 phases, three being what we do now. 1. **One extensive roadmap**: Initially we had one roadmap and were (very) slowly checking off boxes there (via a "Roadmap" milestone). While this is easy, it's also pretty imprecise and not practical as the project grows -2. **Estimated releases per quarter**: To give better guidance, we tried communicating our goals for the quarter, a pretty big window we thought we could roughly "hit". While the idea of not being too detailed was good, it is tough to estimate when some significant things are done if you do a lot of minor/ other things in parallel, +2. **Estimated releases per quarter**: To give better guidance, we tried communicating our goals for the quarter; a pretty big window we thought we could roughly "hit". While the idea of not being too detailed was good, it is tough to estimate when some significant things are done if you do a lot of minor/ other things in parallel, like working with the community and tuning things you go. Hitting time targets is tricky because there may be better things to do than sticking to that time target. This is always much easier to grasp for the people closely involved. The fallacy is to assume the thing you plan for exists in a vacuum. -3. Since we do not want to limit ourselves in choosing the most effective course but still give some insight into what's going on and what's coming up, we updated the LIVE Roadmap [https://documen.so/live](https://documen.so/live) to show what we are **currently working on and what's up next**, once we finish. We do not provide - a specific timeline anymore since we couldn't if we wanted to. Of course, we set our short-term goals based on what's best for the community. We give updates on the issues being worked on as well as possible. +3. Since we do not want to limit ourselves in choosing the most effective course but still give some insight into what's going on and what's coming up, we updated the live roadmap [https://documen.so/live](https://documen.so/live). It now shows what we are currently working on and what we plan on doing next. We do not provide + a specific timeline anymore since we couldn't even if we wanted to. Of course, we set our short-term goals based on what's best for the community. We give updates on the issues being worked on as well as possible. ## 2. Public Backlog - The longer-term roadmap > [github.com/documenso/backlog](https://github.com/documenso/backlog) -The public backlog houses everything we want to build eventually. We do not provide guidance when that may be. If we decide against something, it will be removed from the public backlog, as we consider this our long-term vision for Documenso. If you are interested in something on the roadmap, comment on the issue or post on Discord. This helps us gauge interest in specific features. +The public backlog houses everything we want to build eventually. We do not provide a specific timeline of when that might happen. If we decide against something, it will be removed from the public backlog, as we consider this our long-term vision for Documenso. If you are interested in something on the roadmap, comment on the issue or post on Discord. This helps us gauge interest in specific features. **Issues in the public backlog are not** available to be worked on. For issues to work on, please check the main repository issues. The issues found here are scoped broader since they are not meant for immediate execution but rather give a sense of where Documenso is going and what we consider part of our domain. ## 3. Internal Backlog @@ -100,7 +100,7 @@ This is the actual replacement of our Linear backlog. Here, we host issues that This is the design equivalent of the internal backlog. The internal design backlog houses our design projects that include the exploration of new features, detailed UI designs, and improving the platform overall. -Similar Kanban board to the development backlog () +It's similar to the Kanban board for the development backlog. ## 5. Public Design Repository From 68953d1253b8c688988bbef0d6e256b3d01dee47 Mon Sep 17 00:00:00 2001 From: harkiratsm Date: Fri, 12 Jan 2024 20:54:59 +0530 Subject: [PATCH 078/156] feat add documentPassword to documenet meta and improve the ux Signed-off-by: harkiratsm --- .../documents/[id]/edit-document.tsx | 6 ++-- .../app/(dashboard)/documents/[id]/page.tsx | 5 ++- .../src/app/(signing)/sign/[token]/page.tsx | 2 +- .../document-meta/upsert-document-meta.ts | 12 ++++--- .../document/duplicate-document-by-id.ts | 1 + packages/prisma/schema.prisma | 1 + .../trpc/server/document-router/router.ts | 24 ++++++++++++++ .../trpc/server/document-router/schema.ts | 5 +++ packages/ui/primitives/pdf-viewer.tsx | 32 ++++++++++++++++--- 9 files changed, 75 insertions(+), 13 deletions(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index a5dc9e23e..613146b99 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client'; +import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc } from '@documenso/trpc/react'; @@ -29,6 +29,7 @@ export type EditDocumentFormProps = { user: User; document: DocumentWithData; recipients: Recipient[]; + documentMeta: DocumentMeta | null; fields: Field[]; documentData: DocumentData; }; @@ -41,6 +42,7 @@ export const EditDocumentForm = ({ document, recipients, fields, + documentMeta, user: _user, documentData, }: EditDocumentFormProps) => { @@ -185,7 +187,7 @@ export const EditDocumentForm = ({ gradient > - + diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index b26b6308c..b19e1cf4b 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -13,6 +13,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { DocumentStatus } from '~/components/formatter/document-status'; +import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id'; export type DocumentPageProps = { params: { @@ -41,6 +42,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) { } const { documentData } = document; + const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null); const [recipients, fields] = await Promise.all([ getRecipientsForDocument({ @@ -83,6 +85,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) { className="mt-8" document={document} user={user} + documentMeta={documentMeta} recipients={recipients} fields={fields} documentData={documentData} @@ -91,7 +94,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) { {document.status === InternalDocumentStatus.COMPLETED && (

)}
diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index efd0b266c..80d88ce40 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -101,7 +101,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp gradient > - + diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index 34c33e7cd..3c12bcb35 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -4,10 +4,11 @@ import { prisma } from '@documenso/prisma'; export type CreateDocumentMetaOptions = { documentId: number; - subject: string; - message: string; - timezone: string; - dateFormat: string; + subject?: string; + message?: string; + timezone?: string; + documentPassword?: string; + dateFormat?: string; userId: number; }; @@ -18,6 +19,7 @@ export const upsertDocumentMeta = async ({ dateFormat, documentId, userId, + documentPassword, }: CreateDocumentMetaOptions) => { await prisma.document.findFirstOrThrow({ where: { @@ -35,12 +37,14 @@ export const upsertDocumentMeta = async ({ message, dateFormat, timezone, + documentPassword, documentId, }, update: { subject, message, dateFormat, + documentPassword, timezone, }, }); diff --git a/packages/lib/server-only/document/duplicate-document-by-id.ts b/packages/lib/server-only/document/duplicate-document-by-id.ts index 5986b4cfe..6cdc5bc49 100644 --- a/packages/lib/server-only/document/duplicate-document-by-id.ts +++ b/packages/lib/server-only/document/duplicate-document-by-id.ts @@ -26,6 +26,7 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI message: true, subject: true, dateFormat: true, + documentPassword: true, timezone: true, }, }, diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f0bfc6fda..59a92f296 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -162,6 +162,7 @@ model DocumentMeta { subject String? message String? timezone String? @db.Text @default("Etc/UTC") + documentPassword String? dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a") documentId Int @unique document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index b4a1b60e3..717f8bed2 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -24,6 +24,7 @@ import { ZSearchDocumentsMutationSchema, ZSendDocumentMutationSchema, ZSetFieldsForDocumentMutationSchema, + ZSetPasswordForDocumentMutationSchema, ZSetRecipientsForDocumentMutationSchema, ZSetTitleForDocumentMutationSchema, } from './schema'; @@ -174,6 +175,29 @@ export const documentRouter = router({ }); } }), + + setDocumentPassword: authenticatedProcedure + .input(ZSetPasswordForDocumentMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, documentPassword } = input; + await upsertDocumentMeta({ + documentId, + documentPassword, + userId: ctx.user.id, + }); + + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to send this document. Please try again later.', + }); + } + }), + + sendDocument: authenticatedProcedure .input(ZSendDocumentMutationSchema) diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 4559f65f3..baccc6b85 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -73,6 +73,11 @@ export const ZSendDocumentMutationSchema = z.object({ }), }); +export const ZSetPasswordForDocumentMutationSchema = z.object({ + documentId: z.number(), + documentPassword: z.string(), +}); + export const ZResendDocumentMutationSchema = z.object({ documentId: z.number(), recipients: z.array(z.number()).min(1), diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx index be2d0cc4a..b109dca24 100644 --- a/packages/ui/primitives/pdf-viewer.tsx +++ b/packages/ui/primitives/pdf-viewer.tsx @@ -10,11 +10,13 @@ import 'react-pdf/dist/esm/Page/TextLayer.css'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { getFile } from '@documenso/lib/universal/upload/get-file'; -import type { DocumentData } from '@documenso/prisma/client'; +import type { DocumentData, DocumentMeta } from '@documenso/prisma/client'; import { cn } from '../lib/utils'; import { PasswordDialog } from './document-password-dialog'; import { useToast } from './use-toast'; +import { trpc } from '@documenso/trpc/react'; +import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; export type LoadedPDFDocument = PDFDocumentProxy; @@ -44,6 +46,8 @@ const PDFLoader = () => ( export type PDFViewerProps = { className?: string; documentData: DocumentData; + document?: DocumentWithData; + documentMeta?: DocumentMeta | null; onDocumentLoad?: (_doc: LoadedPDFDocument) => void; onPageClick?: OnPDFViewerPageClick; [key: string]: unknown; @@ -52,6 +56,8 @@ export type PDFViewerProps = { export const PDFViewer = ({ className, documentData, + document, + documentMeta, onDocumentLoad, onPageClick, ...props @@ -62,7 +68,7 @@ export const PDFViewer = ({ const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false); const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); - const [password, setPassword] = useState(null); + const [password, setPassword] = useState(documentMeta?.documentPassword || null); const passwordCallbackRef = useRef<((password: string | null) => void) | null>(null); const [isPasswordError, setIsPasswordError] = useState(false); const [documentBytes, setDocumentBytes] = useState(null); @@ -76,6 +82,9 @@ export const PDFViewer = ({ [documentData.data, documentData.type], ); + const {mutateAsync: addDocumentPassword } = trpc.document.setDocumentPassword.useMutation(); + + const isLoading = isDocumentBytesLoading || !documentBytes; const onDocumentLoaded = (doc: LoadedPDFDocument) => { @@ -83,12 +92,20 @@ export const PDFViewer = ({ onDocumentLoad?.(doc); }; - const onPasswordSubmit = () => { + const onPasswordSubmit = async() => { setIsPasswordModalOpen(false); - if (passwordCallbackRef.current) { - passwordCallbackRef.current(password); + try{ + await addDocumentPassword({ + documentId: document?.id ?? 0, + documentPassword: password!, + }); + passwordCallbackRef.current?.(password); + } catch (error) { + console.error('Error adding document password:', error); + } finally { passwordCallbackRef.current = null; } + }; const onDocumentPageClick = ( @@ -189,6 +206,11 @@ export const PDFViewer = ({ 'h-[80vh] max-h-[60rem]': numPages === 0, })} onPassword={(callback, reason) => { + // If the documentMeta already has a password, we don't need to ask for it again. + if(password && reason !== PasswordResponses.INCORRECT_PASSWORD){ + callback(password); + return; + } setIsPasswordModalOpen(true); passwordCallbackRef.current = callback; switch (reason) { From 7e71e06e04bc991856beccec3b11648a853ee40a Mon Sep 17 00:00:00 2001 From: hiteshwadhwani Date: Sat, 13 Jan 2024 14:19:37 +0530 Subject: [PATCH 079/156] fix: keyboard shortcut ctrl+k default behaviour fixed --- apps/web/src/components/(dashboard)/common/command-menu.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index 39cd9df0d..ffbd213a4 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -95,8 +95,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const currentPage = pages[pages.length - 1]; - const toggleOpen = (e: KeyboardEvent) => { - e.preventDefault(); + const toggleOpen = () => { setIsOpen((isOpen) => !isOpen); onOpenChange?.(!isOpen); @@ -136,7 +135,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]); const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]); - useHotkeys(['ctrl+k', 'meta+k'], toggleOpen); + useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true }); useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings); useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments); useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates); From 58b3a127eaf7416fb92f135c54f36959a45138c9 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 15 Jan 2024 05:18:55 +0530 Subject: [PATCH 080/156] chore: fix color for light mode icon (#806) --- apps/marketing/content/blog/pre-seed.mdx | 2 +- apps/marketing/content/blog/shop.mdx | 2 +- packages/ui/primitives/theme-switcher.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/marketing/content/blog/pre-seed.mdx b/apps/marketing/content/blog/pre-seed.mdx index fae0a6c4a..215700355 100644 --- a/apps/marketing/content/blog/pre-seed.mdx +++ b/apps/marketing/content/blog/pre-seed.mdx @@ -1,6 +1,6 @@ --- title: Announcing Pre-Seed and Open Metrics -description: We are exicited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso. +description: We are excited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso. authorName: 'Timur Ercan' authorImage: '/blog/blog-author-timur.jpeg' authorRole: 'Co-Founder' diff --git a/apps/marketing/content/blog/shop.mdx b/apps/marketing/content/blog/shop.mdx index fafd98a40..cb5b65554 100644 --- a/apps/marketing/content/blog/shop.mdx +++ b/apps/marketing/content/blog/shop.mdx @@ -30,7 +30,7 @@ We kicked off [Malfunction Mania](https://documenso.com/blog/malfunction-mania) ## Documenso Merch Shop -The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contrinuting to Documenso. +The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contributing to Documenso.
{ > {isMounted && theme === THEMES_TYPE.LIGHT && ( )} From a593e045b50b15851ed1c23943bb5ec9f24c3624 Mon Sep 17 00:00:00 2001 From: Gautam Hegde <85569489+Gautam-Hegde@users.noreply.github.com> Date: Tue, 16 Jan 2024 00:08:04 +0530 Subject: [PATCH 081/156] Update improvement.yml --- .github/ISSUE_TEMPLATE/improvement.yml | 54 ++++++++++++++------------ 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/improvement.yml b/.github/ISSUE_TEMPLATE/improvement.yml index 058a025e7..d73d46561 100644 --- a/.github/ISSUE_TEMPLATE/improvement.yml +++ b/.github/ISSUE_TEMPLATE/improvement.yml @@ -1,35 +1,39 @@ -name: 'General Improvement' +name: 'General Improvement Request' description: Suggest a minor enhancement or improvement for this project +title: '[Title for your improvement suggestion]' body: - - type: markdown - attributes: - value: Please provide a clear and concise title for your improvement suggestion - type: textarea attributes: - label: Improvement Description - description: Describe the improvement you are suggesting in detail. Explain what specific aspect of the project it addresses or enhances. + label: "Describe the improvement you are suggesting in detail" + description: "Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change." + validations: + required: true - type: textarea + id: description attributes: - label: Rationale - description: Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change. - - type: textarea + label: "Additional Information & Alternatives (optional)" + description: "Are there any additional context or information that might be relevant to the improvement suggestion." + validations: + required: false + - type: dropdown + id: assignee attributes: - label: Proposed Solution - description: If you have a suggestion for how this improvement could be implemented, describe it here. Include any technical details, design suggestions, or other relevant information. - - type: textarea - attributes: - label: Alternatives (optional) - description: Are there any alternative approaches to achieve the same improvement? Describe other ways to address the issue or enhance the project. - - type: textarea - attributes: - label: Additional Context - description: Add any additional context or information that might be relevant to the improvement suggestion. + label: "Do you want to work on this improvement?" + multiple: false + options: + - "No" + - "Yes" + default: 0 + validations: + required: true - type: checkboxes attributes: - label: Please check the boxes that apply to this improvement suggestion. + label: "Please check the boxes that apply to this improvement suggestion." options: - - label: I have searched the existing issues and improvement suggestions to avoid duplication. - - label: I have provided a clear description of the improvement being suggested. - - label: I have explained the rationale behind this improvement. - - label: I have included any relevant technical details or design suggestions. - - label: I understand that this is a suggestion and that there is no guarantee of implementation. + - label: "I have searched the existing issues and improvement suggestions to avoid duplication." + - label: "I have provided a clear description of the improvement being suggested." + - label: "I have explained the rationale behind this improvement." + - label: "I have included any relevant technical details or design suggestions." + - label: "I understand that this is a suggestion and that there is no guarantee of implementation." + validations: + required: true From 67aebaac1aa41e70695f805ac34654bf3d9013fb Mon Sep 17 00:00:00 2001 From: Gautam Hegde <85569489+Gautam-Hegde@users.noreply.github.com> Date: Tue, 16 Jan 2024 01:14:48 +0530 Subject: [PATCH 082/156] Update improvement.yml code quality --- .github/ISSUE_TEMPLATE/improvement.yml | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/improvement.yml b/.github/ISSUE_TEMPLATE/improvement.yml index d73d46561..de2983b67 100644 --- a/.github/ISSUE_TEMPLATE/improvement.yml +++ b/.github/ISSUE_TEMPLATE/improvement.yml @@ -1,39 +1,39 @@ name: 'General Improvement Request' -description: Suggest a minor enhancement or improvement for this project +description: 'Suggest a minor enhancement or improvement for this project' title: '[Title for your improvement suggestion]' body: - type: textarea attributes: - label: "Describe the improvement you are suggesting in detail" - description: "Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change." + label: 'Describe the improvement you are suggesting in detail' + description: 'Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change.' validations: required: true - type: textarea id: description attributes: - label: "Additional Information & Alternatives (optional)" - description: "Are there any additional context or information that might be relevant to the improvement suggestion." + label: 'Additional Information & Alternatives (optional)' + description: 'Are there any additional context or information that might be relevant to the improvement suggestion.' validations: required: false - type: dropdown id: assignee attributes: - label: "Do you want to work on this improvement?" + label: 'Do you want to work on this improvement?' multiple: false options: - - "No" - - "Yes" + - 'No' + - 'Yes' default: 0 validations: required: true - type: checkboxes attributes: - label: "Please check the boxes that apply to this improvement suggestion." + label: 'Please check the boxes that apply to this improvement suggestion.' options: - - label: "I have searched the existing issues and improvement suggestions to avoid duplication." - - label: "I have provided a clear description of the improvement being suggested." - - label: "I have explained the rationale behind this improvement." - - label: "I have included any relevant technical details or design suggestions." - - label: "I understand that this is a suggestion and that there is no guarantee of implementation." + - label: 'I have searched the existing issues and improvement suggestions to avoid duplication.' + - label: 'I have provided a clear description of the improvement being suggested.' + - label: 'I have explained the rationale behind this improvement.' + - label: 'I have included any relevant technical details or design suggestions.' + - label: 'I understand that this is a suggestion and that there is no guarantee of implementation.' validations: required: true From 1bc885478d05f39b58c34730bd39aa6df6fe7136 Mon Sep 17 00:00:00 2001 From: Fatuma Abdullahi <67555014+FatumaA@users.noreply.github.com> Date: Wed, 17 Jan 2024 03:10:28 +0300 Subject: [PATCH 083/156] fix: display the number of documents in mobile view (#837) This PR fixes #782. It now displays the document count on mobile view. --- apps/web/src/app/(dashboard)/documents/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index f38668fd9..8bb321377 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -88,7 +88,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage {value !== ExtendedDocumentStatus.ALL && ( - + {Math.min(stats[value], 99)} {stats[value] > 99 && '+'} From a94b829ee06e2759051376e7e4d2230af4968925 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 17 Jan 2024 17:17:08 +1100 Subject: [PATCH 084/156] fix: tidy code --- .../documents/[id]/edit-document.tsx | 17 +++- .../app/(dashboard)/documents/[id]/page.tsx | 11 ++- .../src/app/(signing)/sign/[token]/page.tsx | 7 +- package-lock.json | 3 +- .../document-meta/upsert-document-meta.ts | 8 +- .../document/duplicate-document-by-id.ts | 2 +- .../migration.sql | 2 + packages/prisma/schema.prisma | 2 +- .../trpc/server/document-router/router.ts | 42 ++++---- .../trpc/server/document-router/schema.ts | 6 +- packages/ui/package.json | 3 +- .../primitives/document-password-dialog.tsx | 98 +++++++++++++------ packages/ui/primitives/pdf-viewer.tsx | 68 +++++-------- 13 files changed, 161 insertions(+), 108 deletions(-) create mode 100644 packages/prisma/migrations/20240115031508_add_password_to_document_meta/migration.sql diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 613146b99..2159b87f2 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -58,6 +58,8 @@ export const EditDocumentForm = ({ const { mutateAsync: addFields } = trpc.field.addFields.useMutation(); const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation(); const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation(); + const { mutateAsync: setPasswordForDocument } = + trpc.document.setPasswordForDocument.useMutation(); const documentFlow: Record = { title: { @@ -178,6 +180,13 @@ export const EditDocumentForm = ({ } }; + const onPasswordSubmit = async (password: string) => { + await setPasswordForDocument({ + documentId: document.id, + password, + }); + }; + const currentDocumentFlow = documentFlow[step]; return ( @@ -187,7 +196,13 @@ export const EditDocumentForm = ({ gradient > - + diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index b19e1cf4b..4df8453da 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -13,7 +13,6 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { DocumentStatus } from '~/components/formatter/document-status'; -import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id'; export type DocumentPageProps = { params: { @@ -41,8 +40,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) { redirect('/documents'); } - const { documentData } = document; - const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null); + const { documentData, documentMeta } = document; const [recipients, fields] = await Promise.all([ getRecipientsForDocument({ @@ -94,7 +92,12 @@ export default async function DocumentPage({ params }: DocumentPageProps) { {document.status === InternalDocumentStatus.COMPLETED && (
- +
)}
diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 80d88ce40..f8b68d652 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -101,7 +101,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp gradient > - + diff --git a/package-lock.json b/package-lock.json index e3c1139f6..69825e8d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19869,7 +19869,8 @@ "react-rnd": "^10.4.1", "tailwind-merge": "^1.12.0", "tailwindcss-animate": "^1.0.5", - "ts-pattern": "^5.0.5" + "ts-pattern": "^5.0.5", + "zod": "^3.22.4" }, "devDependencies": { "@documenso/tailwind-config": "*", diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index 3c12bcb35..b67c6848b 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -7,7 +7,7 @@ export type CreateDocumentMetaOptions = { subject?: string; message?: string; timezone?: string; - documentPassword?: string; + password?: string; dateFormat?: string; userId: number; }; @@ -19,7 +19,7 @@ export const upsertDocumentMeta = async ({ dateFormat, documentId, userId, - documentPassword, + password, }: CreateDocumentMetaOptions) => { await prisma.document.findFirstOrThrow({ where: { @@ -37,14 +37,14 @@ export const upsertDocumentMeta = async ({ message, dateFormat, timezone, - documentPassword, + password, documentId, }, update: { subject, message, dateFormat, - documentPassword, + password, timezone, }, }); diff --git a/packages/lib/server-only/document/duplicate-document-by-id.ts b/packages/lib/server-only/document/duplicate-document-by-id.ts index 6cdc5bc49..ddb70b1cb 100644 --- a/packages/lib/server-only/document/duplicate-document-by-id.ts +++ b/packages/lib/server-only/document/duplicate-document-by-id.ts @@ -26,7 +26,7 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI message: true, subject: true, dateFormat: true, - documentPassword: true, + password: true, timezone: true, }, }, diff --git a/packages/prisma/migrations/20240115031508_add_password_to_document_meta/migration.sql b/packages/prisma/migrations/20240115031508_add_password_to_document_meta/migration.sql new file mode 100644 index 000000000..c2f5150bc --- /dev/null +++ b/packages/prisma/migrations/20240115031508_add_password_to_document_meta/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "DocumentMeta" ADD COLUMN "password" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 59a92f296..e1549e072 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -162,7 +162,7 @@ model DocumentMeta { subject String? message String? timezone String? @db.Text @default("Etc/UTC") - documentPassword String? + password String? dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a") documentId Int @unique document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 717f8bed2..bdc10a604 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -175,29 +175,27 @@ export const documentRouter = router({ }); } }), - - setDocumentPassword: authenticatedProcedure - .input(ZSetPasswordForDocumentMutationSchema) - .mutation(async ({ input, ctx }) => { - try { - const { documentId, documentPassword } = input; - await upsertDocumentMeta({ - documentId, - documentPassword, - userId: ctx.user.id, - }); - - } catch (err) { - console.error(err); - - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'We were unable to send this document. Please try again later.', - }); - } - }), - + setPasswordForDocument: authenticatedProcedure + .input(ZSetPasswordForDocumentMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, password } = input; + + await upsertDocumentMeta({ + documentId, + password, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to set the password for this document. Please try again later.', + }); + } + }), sendDocument: authenticatedProcedure .input(ZSendDocumentMutationSchema) diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index baccc6b85..c4389bdfb 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -75,9 +75,13 @@ export const ZSendDocumentMutationSchema = z.object({ export const ZSetPasswordForDocumentMutationSchema = z.object({ documentId: z.number(), - documentPassword: z.string(), + password: z.string(), }); +export type TSetPasswordForDocumentMutationSchema = z.infer< + typeof ZSetPasswordForDocumentMutationSchema +>; + export const ZResendDocumentMutationSchema = z.object({ documentId: z.number(), recipients: z.array(z.number()).min(1), diff --git a/packages/ui/package.json b/packages/ui/package.json index ce452091e..34675ba89 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -70,6 +70,7 @@ "react-rnd": "^10.4.1", "tailwind-merge": "^1.12.0", "tailwindcss-animate": "^1.0.5", - "ts-pattern": "^5.0.5" + "ts-pattern": "^5.0.5", + "zod": "^3.22.4" } } diff --git a/packages/ui/primitives/document-password-dialog.tsx b/packages/ui/primitives/document-password-dialog.tsx index 08a5de8f3..571c81716 100644 --- a/packages/ui/primitives/document-password-dialog.tsx +++ b/packages/ui/primitives/document-password-dialog.tsx @@ -1,55 +1,95 @@ -import React from 'react'; +import { useEffect } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; import { Button } from './button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from './dialog'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './dialog'; +import { Form, FormControl, FormField, FormItem, FormMessage } from './form/form'; import { Input } from './input'; +const ZPasswordDialogFormSchema = z.object({ + password: z.string(), +}); + +type TPasswordDialogFormSchema = z.infer; + type PasswordDialogProps = { open: boolean; onOpenChange: (_open: boolean) => void; - setPassword: (_password: string) => void; - onPasswordSubmit: () => void; + defaultPassword?: string; + onPasswordSubmit?: (password: string) => void; isError?: boolean; }; export const PasswordDialog = ({ open, onOpenChange, + defaultPassword, onPasswordSubmit, isError, - setPassword, }: PasswordDialogProps) => { + const form = useForm({ + defaultValues: { + password: defaultPassword ?? '', + }, + resolver: zodResolver(ZPasswordDialogFormSchema), + }); + + const onFormSubmit = ({ password }: TPasswordDialogFormSchema) => { + onPasswordSubmit?.(password); + }; + + useEffect(() => { + if (isError) { + form.setError('password', { + type: 'manual', + message: 'The password you have entered is incorrect. Please try again.', + }); + } + }, [form, isError]); + return ( - - + + Password Required + This document is password protected. Please enter the password to view the document. - - setPassword(e.target.value)} - autoComplete="off" - /> - - - {isError && ( - - The password you entered is incorrect. Please try again. - - )} + +
+ +
+ ( + + + + + + + + )} + /> + +
+ +
+
+
+
); diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx index b109dca24..b4e5c10ba 100644 --- a/packages/ui/primitives/pdf-viewer.tsx +++ b/packages/ui/primitives/pdf-viewer.tsx @@ -7,16 +7,16 @@ import { type PDFDocumentProxy, PasswordResponses } from 'pdfjs-dist'; import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf'; import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; import 'react-pdf/dist/esm/Page/TextLayer.css'; +import { match } from 'ts-pattern'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { getFile } from '@documenso/lib/universal/upload/get-file'; -import type { DocumentData, DocumentMeta } from '@documenso/prisma/client'; +import type { DocumentData } from '@documenso/prisma/client'; +import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { cn } from '../lib/utils'; import { PasswordDialog } from './document-password-dialog'; import { useToast } from './use-toast'; -import { trpc } from '@documenso/trpc/react'; -import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; export type LoadedPDFDocument = PDFDocumentProxy; @@ -47,7 +47,8 @@ export type PDFViewerProps = { className?: string; documentData: DocumentData; document?: DocumentWithData; - documentMeta?: DocumentMeta | null; + password?: string | null; + onPasswordSubmit?: (password: string) => void | Promise; onDocumentLoad?: (_doc: LoadedPDFDocument) => void; onPageClick?: OnPDFViewerPageClick; [key: string]: unknown; @@ -56,8 +57,8 @@ export type PDFViewerProps = { export const PDFViewer = ({ className, documentData, - document, - documentMeta, + password: defaultPassword, + onPasswordSubmit, onDocumentLoad, onPageClick, ...props @@ -66,10 +67,10 @@ export const PDFViewer = ({ const $el = useRef(null); + const passwordCallbackRef = useRef<((password: string | null) => void) | null>(null); + const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false); const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); - const [password, setPassword] = useState(documentMeta?.documentPassword || null); - const passwordCallbackRef = useRef<((password: string | null) => void) | null>(null); const [isPasswordError, setIsPasswordError] = useState(false); const [documentBytes, setDocumentBytes] = useState(null); @@ -82,9 +83,6 @@ export const PDFViewer = ({ [documentData.data, documentData.type], ); - const {mutateAsync: addDocumentPassword } = trpc.document.setDocumentPassword.useMutation(); - - const isLoading = isDocumentBytesLoading || !documentBytes; const onDocumentLoaded = (doc: LoadedPDFDocument) => { @@ -92,22 +90,6 @@ export const PDFViewer = ({ onDocumentLoad?.(doc); }; - const onPasswordSubmit = async() => { - setIsPasswordModalOpen(false); - try{ - await addDocumentPassword({ - documentId: document?.id ?? 0, - documentPassword: password!, - }); - passwordCallbackRef.current?.(password); - } catch (error) { - console.error('Error adding document password:', error); - } finally { - passwordCallbackRef.current = null; - } - - }; - const onDocumentPageClick = ( event: React.MouseEvent, pageNumber: number, @@ -206,23 +188,19 @@ export const PDFViewer = ({ 'h-[80vh] max-h-[60rem]': numPages === 0, })} onPassword={(callback, reason) => { - // If the documentMeta already has a password, we don't need to ask for it again. - if(password && reason !== PasswordResponses.INCORRECT_PASSWORD){ - callback(password); + // If the document already has a password, we don't need to ask for it again. + if (defaultPassword && reason !== PasswordResponses.INCORRECT_PASSWORD) { + callback(defaultPassword); return; } + setIsPasswordModalOpen(true); + passwordCallbackRef.current = callback; - switch (reason) { - case PasswordResponses.NEED_PASSWORD: - setIsPasswordError(false); - break; - case PasswordResponses.INCORRECT_PASSWORD: - setIsPasswordError(true); - break; - default: - break; - } + + match(reason) + .with(PasswordResponses.NEED_PASSWORD, () => setIsPasswordError(false)) + .with(PasswordResponses.INCORRECT_PASSWORD, () => setIsPasswordError(true)); }} onLoadSuccess={(d) => onDocumentLoaded(d)} // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. @@ -270,12 +248,18 @@ export const PDFViewer = ({
))} + { + passwordCallbackRef.current?.(password); + + setIsPasswordModalOpen(false); + + void onPasswordSubmit?.(password); + }} isError={isPasswordError} - setPassword={setPassword} /> )} From 91dd10ec9b5f47b11c47da1d1a81258a9529c8b3 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 17 Jan 2024 17:28:28 +1100 Subject: [PATCH 085/156] fix: add symmetric encryption to document passwords --- .../app/(dashboard)/documents/[id]/page.tsx | 19 +++++++++++++++++++ .../src/app/(signing)/sign/[token]/page.tsx | 19 +++++++++++++++++++ .../trpc/server/document-router/router.ts | 15 ++++++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index 4df8453da..44f3991d8 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -3,10 +3,12 @@ import { redirect } from 'next/navigation'; import { ChevronLeft, Users2 } from 'lucide-react'; +import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; +import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; @@ -42,6 +44,23 @@ export default async function DocumentPage({ params }: DocumentPageProps) { const { documentData, documentMeta } = document; + if (documentMeta?.password) { + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY'); + } + + const securePassword = Buffer.from( + symmetricDecrypt({ + key, + data: documentMeta.password, + }), + ).toString('utf-8'); + + documentMeta.password = securePassword; + } + const [recipients, fields] = await Promise.all([ getRecipientsForDocument({ documentId, diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index f8b68d652..004c59329 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -2,6 +2,7 @@ import { notFound, redirect } from 'next/navigation'; import { match } from 'ts-pattern'; +import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; @@ -12,6 +13,7 @@ import { viewedDocument } from '@documenso/lib/server-only/document/viewed-docum import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; +import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; @@ -66,6 +68,23 @@ export default async function SigningPage({ params: { token } }: SigningPageProp redirect(`/sign/${token}/complete`); } + if (documentMeta?.password) { + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY'); + } + + const securePassword = Buffer.from( + symmetricDecrypt({ + key, + data: documentMeta.password, + }), + ).toString('utf-8'); + + documentMeta.password = securePassword; + } + const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id }); if (document.deletedAt) { diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index bdc10a604..9dba63797 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; +import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; @@ -13,6 +14,7 @@ import { sendDocument } from '@documenso/lib/server-only/document/send-document' import { updateTitle } from '@documenso/lib/server-only/document/update-title'; import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; +import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { @@ -182,9 +184,20 @@ export const documentRouter = router({ try { const { documentId, password } = input; + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!key) { + throw new Error('Missing encryption key'); + } + + const securePassword = symmetricEncrypt({ + data: password, + key, + }); + await upsertDocumentMeta({ documentId, - password, + password: securePassword, userId: ctx.user.id, }); } catch (err) { From 9ff44f10a6b26e68758b131ecd67a5468c1b346f Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 17 Jan 2024 21:41:00 +1100 Subject: [PATCH 086/156] chore: add incident blog post --- .../email-provider-incident-2024-01-10.mdx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 apps/marketing/content/blog/email-provider-incident-2024-01-10.mdx diff --git a/apps/marketing/content/blog/email-provider-incident-2024-01-10.mdx b/apps/marketing/content/blog/email-provider-incident-2024-01-10.mdx new file mode 100644 index 000000000..0f5279d6e --- /dev/null +++ b/apps/marketing/content/blog/email-provider-incident-2024-01-10.mdx @@ -0,0 +1,28 @@ +--- +title: Jan 10th Email Provider Security Incident +description: On January 10th, 2022, we were notified by our email provider that they had experienced a security incident. +authorName: 'Lucas Smith' +authorImage: '/blog/blog-author-lucas.png' +authorRole: 'Co-Founder' +date: 2024-01-17 +tags: + - Security +--- + +On January 10th, 2024 we were notified by our email provider that a security incident had occurred. This security incident which had started on January 7th led to a bad actor obtaining access to their database which contains ours and other customer’s data. + +We understand that during this security incident the following has been accessed: + +- Email addresses. +- Metadata on emails sent excluding the email body. + +While the incident is unfortunate we are pleased with the remediation and the processes that our email provider has put in place to help avoid this kind of situation in the future. Since the incident, our provider has rectified the issue and has engaged a security company to conduct an exhaustive investigation and to help improve their security posture moving forward. + +We remain steadfast in our commitment to our current email provider, and will not be taking any further action with relation to changing providers. + +We are now working with our legal counsel to ensure that we provide the appropriate notice to all our customers in each jurisdiction. If you have any further questions on this incident please feel free to contact our support team at [support@documenso.com](mailto:support@documenso.com). + +We appreciate your ongoing support in this matter. + +You can read more on the incident on our providers blog post below: +[https://resend.com/blog/incident-report-for-january-10-2024](https://resend.com/blog/incident-report-for-january-10-2024) From 6f726565e8ad5e8800052114d145b1a36aff0860 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 17 Jan 2024 14:36:28 +0100 Subject: [PATCH 087/156] Update README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We are nominated for a Product Hunt Gold Kitty 😺✨ and appreciate any support: https://documen.so/kitty --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 39cbb4332..6d2fab334 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +>We are nominated for a Product Hunt Gold Kitty 😺✨ and appreciate any support: https://documen.so/kitty + Documenso Logo

From 0d15b80c2d6f001b01ec500c5433c5bd03b7ed17 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 18 Jan 2024 04:23:22 +0000 Subject: [PATCH 088/156] fix: simplify code --- packages/lib/client-only/download-pdf.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/lib/client-only/download-pdf.ts b/packages/lib/client-only/download-pdf.ts index e095002ee..ec7d0c252 100644 --- a/packages/lib/client-only/download-pdf.ts +++ b/packages/lib/client-only/download-pdf.ts @@ -15,10 +15,13 @@ export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps) }); const link = window.document.createElement('a'); - const baseTitle = fileName?.includes('.pdf') ? fileName.split('.pdf')[0] : fileName; + + const [baseTitle] = fileName?.includes('.pdf') + ? fileName.split('.pdf') + : [fileName ?? 'document']; link.href = window.URL.createObjectURL(blob); - link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf'; + link.download = `${baseTitle}_signed.pdf`; link.click(); From 204388888d30da894bd00f4089c7eddc1263f2ae Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Thu, 18 Jan 2024 09:38:42 +0200 Subject: [PATCH 089/156] fix: fix bug for completed document shortcut (#839) When you're in the `/documents` page in the dashboard, if you hover over a draft and a completed document, you'll see different URLs. At the moment, the shortcut tries to go to the following URL for a completed document `/documents/{doc-id}`. However, that's the wrong URL, since the URL for a completed doc is `/sign/{token}` when the user is the recipient, not the one that sent the document for signing. If it's the document owner & the document is completed, the URL is fine as `/documents/{doc-id}`. --------- Co-authored-by: Lucas Smith --- .../(dashboard)/common/command-menu.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index ffbd213a4..93f7fa729 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -5,6 +5,7 @@ import { useCallback, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Loader, Monitor, Moon, Sun } from 'lucide-react'; +import { useSession } from 'next-auth/react'; import { useTheme } from 'next-themes'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -13,6 +14,7 @@ import { SETTINGS_PAGE_SHORTCUT, TEMPLATES_PAGE_SHORTCUT, } from '@documenso/lib/constants/keyboard-shortcuts'; +import type { Document, Recipient } from '@documenso/prisma/client'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { CommandDialog, @@ -65,6 +67,8 @@ export type CommandMenuProps = { export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const { setTheme } = useTheme(); + const { data: session } = useSession(); + const router = useRouter(); const [isOpen, setIsOpen] = useState(() => open ?? false); @@ -81,6 +85,17 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { }, ); + const isOwner = useCallback( + (document: Document) => document.userId === session?.user.id, + [session?.user.id], + ); + + const getSigningLink = useCallback( + (recipients: Recipient[]) => + `/sign/${recipients.find((r) => r.email === session?.user.email)?.token}`, + [session?.user.email], + ); + const searchResults = useMemo(() => { if (!searchDocumentsData) { return []; @@ -88,10 +103,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { return searchDocumentsData.map((document) => ({ label: document.title, - path: `/documents/${document.id}`, + path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient), value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '), })); - }, [searchDocumentsData]); + }, [searchDocumentsData, isOwner, getSigningLink]); const currentPage = pages[pages.length - 1]; From 1a10cd2ae1393de1ad43ceefc209f1799bfea8b1 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Thu, 18 Jan 2024 17:16:28 +0100 Subject: [PATCH 090/156] Update apps/marketing/content/blog/linear-gh.mdx Co-authored-by: Adithya Krishna --- apps/marketing/content/blog/linear-gh.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/linear-gh.mdx b/apps/marketing/content/blog/linear-gh.mdx index a2fa1bf8b..50aacd8b4 100644 --- a/apps/marketing/content/blog/linear-gh.mdx +++ b/apps/marketing/content/blog/linear-gh.mdx @@ -109,7 +109,7 @@ It's similar to the Kanban board for the development backlog. While the internal design backlog also existed in Linear, the public design repository is new. Since designing in the open is tricky, we opted to publish the detailed design artifacts with the corresponding feature instead. We already have design.documenso.com housing our general design system. Here, we will publish the specifics of how we applied this to each feature. We will publish the first artifacts here soon, what may be in the cards can be found on the [LIVE Roadmap](https://documen.so/live). -If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord). +Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :) Best from Hamburg\ Timur From a7672545d7b8f85104f97aad57742e38336587d5 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Thu, 18 Jan 2024 17:16:49 +0100 Subject: [PATCH 091/156] Update apps/marketing/content/blog/linear-gh.mdx Co-authored-by: Adithya Krishna --- apps/marketing/content/blog/linear-gh.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/linear-gh.mdx b/apps/marketing/content/blog/linear-gh.mdx index 50aacd8b4..27b1ae208 100644 --- a/apps/marketing/content/blog/linear-gh.mdx +++ b/apps/marketing/content/blog/linear-gh.mdx @@ -80,7 +80,7 @@ The public backlog houses everything we want to build eventually. We do not prov -This is the actual replacement of our Linear backlog. Here, we host issues that are too small or short-term for the long-term roadmap and too niche or core to go into the main repo. We used a GitHub project as our development Kanban board. +This serves as the direct replacement for our Linear backlog. Here, we manage issues that are either too small or short-term for inclusion in the long-term roadmap, yet too specialized or fundamental to be integrated into the main repository. Our development Kanban board is implemented using a GitHub project. ## 4. Internal Design Backlog From 9c1e1f50a8625b37a2daafa0cc93534562e0ce66 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Sat, 20 Jan 2024 01:14:34 +0000 Subject: [PATCH 092/156] fix: mask recipient tokens for non-owners --- .../avatar/avatar-with-recipient.tsx | 22 +++++++---- .../server-only/document/find-documents.ts | 10 ++++- .../server-only/document/update-document.ts | 6 ++- .../mask-recipient-tokens-for-document.ts | 38 +++++++++++++++++++ packages/prisma/seed/initial-seed.ts | 2 + 5 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 packages/lib/utils/mask-recipient-tokens-for-document.ts diff --git a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx index 8429870b0..d04b3a998 100644 --- a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx +++ b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx @@ -6,6 +6,7 @@ import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { Recipient } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { StackAvatar } from './stack-avatar'; @@ -19,6 +20,10 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) { const { toast } = useToast(); const onRecipientClick = () => { + if (!recipient.token) { + return; + } + void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => { toast({ title: 'Copied to clipboard', @@ -28,19 +33,22 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) { }; return ( -

+
- - {recipient.email} - + + {recipient.email}
); } diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 18600ebe6..def85f2d4 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -7,6 +7,7 @@ import { SigningStatus } from '@documenso/prisma/client'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { FindResultSet } from '../../types/find-result-set'; +import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document'; export type FindDocumentsOptions = { userId: number; @@ -173,8 +174,15 @@ export const findDocuments = async ({ }), ]); + const maskedData = data.map((document) => + maskRecipientTokensForDocument({ + document, + user, + }), + ); + return { - data, + data: maskedData, count, currentPage: Math.max(page, 1), perPage, diff --git a/packages/lib/server-only/document/update-document.ts b/packages/lib/server-only/document/update-document.ts index 5ad686860..29ab2c998 100644 --- a/packages/lib/server-only/document/update-document.ts +++ b/packages/lib/server-only/document/update-document.ts @@ -5,14 +5,16 @@ import type { Prisma } from '@prisma/client'; import { prisma } from '@documenso/prisma'; export type UpdateDocumentOptions = { - documentId: number; data: Prisma.DocumentUpdateInput; + userId: number; + documentId: number; }; -export const updateDocument = async ({ documentId, data }: UpdateDocumentOptions) => { +export const updateDocument = async ({ documentId, userId, data }: UpdateDocumentOptions) => { return await prisma.document.update({ where: { id: documentId, + userId, }, data: { ...data, diff --git a/packages/lib/utils/mask-recipient-tokens-for-document.ts b/packages/lib/utils/mask-recipient-tokens-for-document.ts new file mode 100644 index 000000000..ed6e2b13e --- /dev/null +++ b/packages/lib/utils/mask-recipient-tokens-for-document.ts @@ -0,0 +1,38 @@ +import type { User } from '@documenso/prisma/client'; +import type { DocumentWithRecipients } from '@documenso/prisma/types/document-with-recipient'; + +export type MaskRecipientTokensForDocumentOptions = { + document: T; + user?: User; + token?: string; +}; + +export const maskRecipientTokensForDocument = ({ + document, + user, + token, +}: MaskRecipientTokensForDocumentOptions) => { + const maskedRecipients = document.Recipient.map((recipient) => { + if (document.userId === user?.id) { + return recipient; + } + + if (recipient.email === user?.email) { + return recipient; + } + + if (recipient.token === token) { + return recipient; + } + + return { + ...recipient, + token: '', + }; + }); + + return { + ...document, + Recipient: maskedRecipients, + }; +}; diff --git a/packages/prisma/seed/initial-seed.ts b/packages/prisma/seed/initial-seed.ts index b01c2d434..6409c5bd9 100644 --- a/packages/prisma/seed/initial-seed.ts +++ b/packages/prisma/seed/initial-seed.ts @@ -18,6 +18,7 @@ export const seedDatabase = async () => { create: { name: 'Example User', email: 'example@documenso.com', + emailVerified: new Date(), password: hashSync('password'), roles: [Role.USER], }, @@ -31,6 +32,7 @@ export const seedDatabase = async () => { create: { name: 'Admin User', email: 'admin@documenso.com', + emailVerified: new Date(), password: hashSync('password'), roles: [Role.USER, Role.ADMIN], }, From 9cc8bbdfc386e2253e5059c1d3fe2e9f694d5baf Mon Sep 17 00:00:00 2001 From: Tawagot0 <26726263+Tawagot0@users.noreply.github.com> Date: Sat, 20 Jan 2024 17:45:59 +0100 Subject: [PATCH 093/156] fix: docker compose variable --- docker/compose.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/compose.yml b/docker/compose.yml index 9d4f0e951..a48702bf9 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -23,7 +23,8 @@ services: - database - inbucket environment: - - DATABASE_URL=postgres://documenso:password@database:5432/documenso + - NEXT_PRIVATE_DATABASE_URL=postgres://documenso:password@database:5432/documenso + - NEXT_PRIVATE_DIRECT_DATABASE_URL=postgres://documenso:password@database:5432/documenso - NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000 - NEXTAUTH_SECRET=my-super-secure-secret - NEXTAUTH_URL=http://localhost:3000 From e8c2ca8890df41878324f1251d29b95be7c07934 Mon Sep 17 00:00:00 2001 From: Mythie Date: Mon, 22 Jan 2024 12:32:19 +1100 Subject: [PATCH 094/156] fix: mask documents in search --- .../lib/server-only/document/get-document-by-token.ts | 8 ++++++-- .../document/search-documents-with-keyword.ts | 11 ++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/lib/server-only/document/get-document-by-token.ts b/packages/lib/server-only/document/get-document-by-token.ts index 89b3777ea..62c8a5ca1 100644 --- a/packages/lib/server-only/document/get-document-by-token.ts +++ b/packages/lib/server-only/document/get-document-by-token.ts @@ -1,5 +1,5 @@ import { prisma } from '@documenso/prisma'; -import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; +import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; export interface GetDocumentAndSenderByTokenOptions { token: string; @@ -58,7 +58,11 @@ export const getDocumentAndRecipientByToken = async ({ }, }, include: { - Recipient: true, + Recipient: { + where: { + token, + }, + }, documentData: true, }, }); diff --git a/packages/lib/server-only/document/search-documents-with-keyword.ts b/packages/lib/server-only/document/search-documents-with-keyword.ts index c4014d37f..8125ae900 100644 --- a/packages/lib/server-only/document/search-documents-with-keyword.ts +++ b/packages/lib/server-only/document/search-documents-with-keyword.ts @@ -1,6 +1,8 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@documenso/prisma/client'; +import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document'; + export type SearchDocumentsWithKeywordOptions = { query: string; userId: number; @@ -77,5 +79,12 @@ export const searchDocumentsWithKeyword = async ({ take: limit, }); - return documents; + const maskedDocuments = documents.map((document) => + maskRecipientTokensForDocument({ + document, + user, + }), + ); + + return maskedDocuments; }; From 4909eee40153803a1b7afa3cb9ee2c5e544a9af6 Mon Sep 17 00:00:00 2001 From: Mythie Date: Mon, 22 Jan 2024 21:36:46 +1100 Subject: [PATCH 095/156] feat: add viewing on completed page for pending documents --- .../complete/document-preview-button.tsx | 39 +++++++++++++++++++ .../sign/[token]/complete/layout.tsx | 17 ++++++++ .../(signing)/sign/[token]/complete/page.tsx | 22 ++++++++--- .../components/document/document-dialog.tsx | 2 +- 4 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/app/(signing)/sign/[token]/complete/document-preview-button.tsx create mode 100644 apps/web/src/app/(signing)/sign/[token]/complete/layout.tsx diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/document-preview-button.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/document-preview-button.tsx new file mode 100644 index 000000000..1ac50f1c0 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/complete/document-preview-button.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useState } from 'react'; + +import { FileSearch } from 'lucide-react'; + +import type { DocumentData } from '@documenso/prisma/client'; +import DocumentDialog from '@documenso/ui/components/document/document-dialog'; +import type { ButtonProps } from '@documenso/ui/primitives/button'; +import { Button } from '@documenso/ui/primitives/button'; + +export type DocumentPreviewButtonProps = { + className?: string; + documentData: DocumentData; +} & ButtonProps; + +export const DocumentPreviewButton = ({ + className, + documentData, + ...props +}: DocumentPreviewButtonProps) => { + const [showDialog, setShowDialog] = useState(false); + + return ( + <> + + + + + ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/layout.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/layout.tsx new file mode 100644 index 000000000..d3d1c15c3 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/complete/layout.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus'; + +export type SigningLayoutProps = { + children: React.ReactNode; +}; + +export default function SigningLayout({ children }: SigningLayoutProps) { + return ( +
+ {children} + + +
+ ); +} diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx index 4b1aed265..ab73755ab 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -17,6 +17,8 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { truncateTitle } from '~/helpers/truncate-title'; +import { DocumentPreviewButton } from './document-preview-button'; + export type CompletedSigningPageProps = { params: { token?: string; @@ -117,12 +119,20 @@ export default async function CompletedSigningPage({
- + {document.status === DocumentStatus.COMPLETED ? ( + + ) : ( + + )}
{isLoggedIn ? ( diff --git a/packages/ui/components/document/document-dialog.tsx b/packages/ui/components/document/document-dialog.tsx index 6099fecff..2693638fb 100644 --- a/packages/ui/components/document/document-dialog.tsx +++ b/packages/ui/components/document/document-dialog.tsx @@ -5,7 +5,7 @@ import { useState } from 'react'; import * as DialogPrimitive from '@radix-ui/react-dialog'; import { X } from 'lucide-react'; -import { DocumentData } from '@documenso/prisma/client'; +import type { DocumentData } from '@documenso/prisma/client'; import { cn } from '../../lib/utils'; import { Dialog, DialogOverlay, DialogPortal } from '../../primitives/dialog'; From 08011f9545afe29e2ddba18487e0f2a4902710f0 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 23 Jan 2024 02:27:10 +0200 Subject: [PATCH 096/156] chore: added target blank for github link: (#851) --- .../src/components/(dashboard)/layout/profile-dropdown.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx index 2dcbb9864..252432b89 100644 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx @@ -141,7 +141,11 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { - + Star on Github From e63122a718af30605d924b7228a64b382d6c6046 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 23 Jan 2024 11:28:11 +1100 Subject: [PATCH 097/156] chore: update github feature template (#849) --- .github/ISSUE_TEMPLATE/feature-request.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index ab21e8828..ffb788c23 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -33,3 +33,4 @@ body: - label: I have explained the use case or scenario for this feature. - label: I have included any relevant technical details or design suggestions. - label: I understand that this is a suggestion and that there is no guarantee of implementation. + - label: I want to work on creating a PR for this issue if approved From 6aed075c56ee9ef77fa4613d8d395bd23bd7b0de Mon Sep 17 00:00:00 2001 From: Anurag Sharma Date: Tue, 23 Jan 2024 11:38:48 +0530 Subject: [PATCH 098/156] fix: add conditional rendering of OAuth providers (#736) Now google OAuth provider is not rendered if client id is not provided --- .../src/app/(unauthenticated)/signin/page.tsx | 4 +- apps/web/src/components/forms/signin.tsx | 40 +++++++++++-------- packages/lib/constants/auth.ts | 4 ++ 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/apps/web/src/app/(unauthenticated)/signin/page.tsx b/apps/web/src/app/(unauthenticated)/signin/page.tsx index 0b0333b65..5fda07e70 100644 --- a/apps/web/src/app/(unauthenticated)/signin/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signin/page.tsx @@ -1,5 +1,7 @@ import Link from 'next/link'; +import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; + import { SignInForm } from '~/components/forms/signin'; export default function SignInPage() { @@ -11,7 +13,7 @@ export default function SignInPage() { Welcome back, we are lucky to have you.

- + {process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (

diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 4e671a569..038f9fe68 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -48,9 +48,10 @@ export type TSignInFormSchema = z.infer; export type SignInFormProps = { className?: string; + isGoogleSSOEnabled?: boolean; }; -export const SignInForm = ({ className }: SignInFormProps) => { +export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) => { const { toast } = useToast(); const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] = useState(false); @@ -203,24 +204,29 @@ export const SignInForm = ({ className }: SignInFormProps) => { {isSubmitting ? 'Signing in...' : 'Sign In'} -

-
- Or continue with -
-
+ {isGoogleSSOEnabled && ( + <> +
+
+ Or continue with +
+
- + + + )} + Date: Tue, 23 Jan 2024 15:54:57 +0100 Subject: [PATCH 099/156] chore: add addi to open page --- apps/marketing/src/app/(marketing)/open/data.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/marketing/src/app/(marketing)/open/data.ts b/apps/marketing/src/app/(marketing)/open/data.ts index 3b109ea74..a3f314d9f 100644 --- a/apps/marketing/src/app/(marketing)/open/data.ts +++ b/apps/marketing/src/app/(marketing)/open/data.ts @@ -47,6 +47,14 @@ export const TEAM_MEMBERS = [ engagement: 'Full-Time', joinDate: 'October 9th, 2023', }, + { + name: 'Adithya Krishna', + role: 'Software Engineer - II', + salary: '-', + location: 'India', + engagement: 'Full-Time', + joinDate: 'December 1st, 2023', + }, ]; export const FUNDING_RAISED = [ From 576544344fa3274584f843bbdd432220810bf9ed Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Tue, 23 Jan 2024 16:20:25 +0100 Subject: [PATCH 100/156] chore: first small step to tracking growth mechanics --- packages/email/template-components/template-footer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/email/template-components/template-footer.tsx b/packages/email/template-components/template-footer.tsx index 4a9e2c7cf..34cd4047e 100644 --- a/packages/email/template-components/template-footer.tsx +++ b/packages/email/template-components/template-footer.tsx @@ -10,7 +10,7 @@ export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => { {isDocument && ( This document was sent using{' '} - + Documenso. From 61967b22c1bbefd3f63f6c7d897472fb793e09d0 Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Wed, 24 Jan 2024 06:04:30 +0530 Subject: [PATCH 101/156] fix: visibility of security fields using identityprovider (#709) fixes #690 --- .../(dashboard)/settings/security/page.tsx | 46 +++++++++++++------ packages/lib/constants/auth.ts | 7 +++ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/security/page.tsx b/apps/web/src/app/(dashboard)/settings/security/page.tsx index 9e99b73e8..ae97e7fb5 100644 --- a/apps/web/src/app/(dashboard)/settings/security/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/security/page.tsx @@ -1,3 +1,4 @@ +import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app'; @@ -17,28 +18,43 @@ export default async function SecuritySettingsPage() {
- + {user.identityProvider === 'DOCUMENSO' ? ( +
+ -
+
-

Two Factor Authentication

+

Two Factor Authentication

-

- Add and manage your two factor security settings to add an extra layer of security to your - account! -

+

+ Add and manage your two factor security settings to add an extra layer of security to + your account! +

-
-
Two-factor methods
+
+
Two-factor methods
- -
+ +
- {user.twoFactorEnabled && ( -
-
Recovery methods
+ {user.twoFactorEnabled && ( +
+
Recovery methods
- + +
+ )} +
+ ) : ( +
+

+ Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]} +

+

+ To update your password, enable two-factor authentication, and manage other security + settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account + settings. +

)}
diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index 9a9652b95..837ca3e3a 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -1,5 +1,12 @@ +import { IdentityProvider } from '@documenso/prisma/client'; + export const SALT_ROUNDS = 12; +export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = { + [IdentityProvider.DOCUMENSO]: 'Documenso', + [IdentityProvider.GOOGLE]: 'Google', +}; + export const IS_GOOGLE_SSO_ENABLED = Boolean( process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET, ); From 51d140cf9ae0cebf2323468fc6f2b20e0ecfd4f2 Mon Sep 17 00:00:00 2001 From: Gautam Hegde <85569489+Gautam-Hegde@users.noreply.github.com> Date: Wed, 24 Jan 2024 11:33:57 +0530 Subject: [PATCH 102/156] feat: command group distinction (#854) fixes #836 - Explicit `div` is used instead of `` , since it failed to render borders for dynamic search results, but only works for initial menu. (initial menu) ![cgrp](https://github.com/documenso/documenso/assets/85569489/0ee0aabb-c780-4c03-97e7-cf9905bb9b61) (search results) ![dyanmic](https://github.com/documenso/documenso/assets/85569489/74b0a714-a952-4516-9787-53d50a60b78c) --- apps/web/src/components/(dashboard)/common/command-menu.tsx | 6 +++++- .../components/(dashboard)/layout/verify-email-banner.tsx | 4 ++-- packages/ui/primitives/command.tsx | 6 +++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index 93f7fa729..0312a96d2 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -252,7 +252,11 @@ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => ); return THEMES.map((theme) => ( - setTheme(theme.theme)}> + setTheme(theme.theme)} + className="mx-2 first:mt-2 last:mb-2" + > {theme.label} diff --git a/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx b/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx index 24e47c186..43eab21c5 100644 --- a/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx +++ b/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import { AlertTriangle } from 'lucide-react'; -import { ONE_SECOND } from '@documenso/lib/constants/time'; +import { ONE_DAY, ONE_SECOND } from '@documenso/lib/constants/time'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -65,7 +65,7 @@ export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => { if (emailVerificationDialogLastShown) { const lastShownTimestamp = parseInt(emailVerificationDialogLastShown); - if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) { + if (Date.now() - lastShownTimestamp < ONE_DAY) { return; } } diff --git a/packages/ui/primitives/command.tsx b/packages/ui/primitives/command.tsx index cbc306c66..65f88fc4e 100644 --- a/packages/ui/primitives/command.tsx +++ b/packages/ui/primitives/command.tsx @@ -35,7 +35,7 @@ const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps) {children} @@ -92,7 +92,7 @@ const CommandGroup = React.forwardRef< Date: Wed, 24 Jan 2024 11:42:33 +0530 Subject: [PATCH 103/156] fix: disabled signing pad when submitting form (#842) fixes : #810 --- apps/web/src/components/forms/profile.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 0ce5c7f3d..7036f4e43 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -112,7 +112,6 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
- { onChange(v ?? '')} /> From 0fac7d7b70fc7db8711d2835f15216d30272901f Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 24 Jan 2024 16:52:38 +0100 Subject: [PATCH 104/156] chore: add tags to manifest --- apps/marketing/content/blog/manifest.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/marketing/content/blog/manifest.mdx b/apps/marketing/content/blog/manifest.mdx index 4abd7c068..7f2b7e7cd 100644 --- a/apps/marketing/content/blog/manifest.mdx +++ b/apps/marketing/content/blog/manifest.mdx @@ -7,6 +7,8 @@ authorRole: 'Co-Founder' date: 2023-07-13 tags: - Manifesto + - Open Source + - Vision ---
From 2be022b9fc1daa6f6ba9cd9beb64e86b83947067 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 24 Jan 2024 18:01:26 +0100 Subject: [PATCH 105/156] feat: commodofying signing blogpost --- .../content/blog/commodifying-signing.mdx | 88 ++++++++++++++++++ apps/marketing/public/blog/lighthouse.jpeg | Bin 0 -> 338815 bytes 2 files changed, 88 insertions(+) create mode 100644 apps/marketing/content/blog/commodifying-signing.mdx create mode 100644 apps/marketing/public/blog/lighthouse.jpeg diff --git a/apps/marketing/content/blog/commodifying-signing.mdx b/apps/marketing/content/blog/commodifying-signing.mdx new file mode 100644 index 000000000..8aeddb7e3 --- /dev/null +++ b/apps/marketing/content/blog/commodifying-signing.mdx @@ -0,0 +1,88 @@ +--- +title: Commodifying Signing +description: We are creating signing as a public good and are commoditizing it to make it cheaper and better. +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2024-01-25 +Tags: + - Vision + - Mission + - Open Source +--- + +
+ + +
+ Lighthouses are often used as an example of a public good; As they benefit all maritime users, but no one can be excluded from using them as a navigational aid. Use by one person neither prevents access by other people, nor does it reduce availability to others. +
+
+ +# Commodifying Signing + +> TLDR; We are creating signing as a public good and are commoditizing it to make it cheaper and better. + +While we are in full-on building mode with Documenso, I think a lot about the big picture of what we are attempting to do. One phrase that keeps popping up is, "We are commodifying signing." Let's dig deeper into what that means. + +Let's start with why we are doing this. Documenso's mission is to solve the domain of signing once and for all for everyone. In so many calls, I hear stories about how organizations build their own solution because the existing ones are too expensive or need to be more flexible. That means not hundreds but probably thousands of companies worldwide have done the same. This is simply wasting humanity's time. Since digital signing systems are understood well enough that seemingly "everyone" can build them, given enough pain, It's time to do it once correctly. + +## Is signing already a commodity? + +> In economics, a **commodity** is an economic good, usually a resource, that has explicitly full or substantial fungibility: that is, the market treats instances of the good as equivalent or nearly so with no regard to who produced them. + +That sounds like the signing market today. There is no shortage of signing providers, and you can get similar signing services from many places. So why is this different from what we want, and why does this not satisfy the market? + +- Signing is expensive and painful when you are locked into your vendor, and they charge by signing volume. +- Signing is also expensive and painful when you have to build it yourself since no vendor fits your requirements or you are not allowed to + +To understand why, we need to look at landscape, as it is today: + +- **Commodity**: Signing SaaS +- **Private Goods**: Signing Code Base, Regulatory Know-How +- **Public Goods**: Web Tech, Digital Signature Algorithms and Standards + +What the current players have done is to commodify the listed public goods into commercial products: + +> […]the action and process of transforming goods, services, ideas, nature, personal information, people or animals into commodities. + +(Let's ignore the end of that list for now and what it says about humanity, yikes) + +While this paradigm brought digital signing to many businesses worldwide, we aim for a different future. To solve signing once and for all, we need to achieve two core points: + +- Making it cheaper, so it's profitable for everyone to use +- Making it more accessible so everyone can use it (e.g. regulated industries) and flexible enough (extendable, open). + +To achieve this, we must transform the landscape to look like this: + +- **Commodities**: Enterprise Components, Support, Hosting, Self-Host Licenses +- **Public Goods**: (no longer private): OS (Open Source) Signing Code Base, OS Regulatory Know-How +- **Public Goods**: OS Web Tech, Digital Signature Algorithms and Standards + +## Raising the Bar + +Before creating a commodity we are raising the bar of what the underlying public good is. Having an open source singing framework you can extend, self-host, and understand makes the resulting solution much more accessible and extendable for everyone. Now for the final feat of making signing cheaper: + +As we have seen, signing has already been commodified. But since it was done by a closed source and, frankly, a very opaque industry, no downward price spiral has ensued. By building Documenso open source with an open culture, we can pierce the veil and trigger what the space has been missing for a long time: Commoditization. If you had to read that again, so did I: + +> In business literature, **commoditization** is defined as the process by which goods that have economic value and are distinguishable in terms of attributes (uniqueness or brand) become simple commodities in the eyes of the market or consumers. + +By only selling what creates value for the customer (hosting a highly available service, keeping it compliant, supporting with technical issues and challenges, preparing industry-specific components), we are commoditizing signing since everyone can do it now: The resources enabling it are public goods, aka. open source. A leveled playing field as described above is the perfect environment for a community-first, technology-first and value-first company like Documenso to flourish. + +## Changing the Game + +In this new world, a company in need of signing (literally every company) can decide if the ROI (Return on Investment) of building signing themselves is greater than simply paying for the value-added activities that will be needing anyway. Pricing our offering not on volume but fixed is a nice additional wedge into the market we intend to use here. + +The market dynamic now changes to who can offer the greatest value added to the public goods, driving the price down as this can be done much more efficiently than locking customers into closed source SaaS. Documenso, being a lean company, which we intend to stay with for a long time, will help kickstart this effect. Open Source capital effiency is real. Our planned enterprise components, hosting support, and partner ecosystem will all leverage this effect. + +We will grow our a community around the public good, the open-source repo, and create an ecosystem around the commodities built on top of it (components, hosting, compliance, support). We will solve signing once and for all, and the world will be better for it. Onwards. + +As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments. + +Best from Hamburg\ +Timur diff --git a/apps/marketing/public/blog/lighthouse.jpeg b/apps/marketing/public/blog/lighthouse.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..d71e1eb5174d725a2e4979b6902010b7a23dc61c GIT binary patch literal 338815 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
  • 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%0A$EC70W%khoK$ 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 z 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 - +

    Already have an account?{' '} diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index b91b4a9fd..3f2723ec8 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -3,6 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { signIn } from 'next-auth/react'; import { useForm } from 'react-hook-form'; +import { FcGoogle } from 'react-icons/fc'; import { z } from 'zod'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; @@ -23,6 +24,8 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { useToast } from '@documenso/ui/primitives/use-toast'; +const SIGN_UP_REDIRECT_PATH = '/documents'; + export const ZSignUpFormSchema = z.object({ name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), email: z.string().email().min(1), @@ -37,9 +40,10 @@ export type TSignUpFormSchema = z.infer; export type SignUpFormProps = { className?: string; + isGoogleSSOEnabled?: boolean; }; -export const SignUpForm = ({ className }: SignUpFormProps) => { +export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) => { const { toast } = useToast(); const analytics = useAnalytics(); @@ -64,7 +68,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => { await signIn('credentials', { email, password, - callbackUrl: '/', + callbackUrl: SIGN_UP_REDIRECT_PATH, }); analytics.capture('App: User Sign Up', { @@ -89,6 +93,19 @@ export const SignUpForm = ({ className }: SignUpFormProps) => { } }; + const onSignUpWithGoogleClick = async () => { + try { + await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH }); + } catch (err) { + toast({ + title: 'An unknown error occurred', + description: + 'We encountered an unknown error while attempting to sign you Up. Please try again later.', + variant: 'destructive', + }); + } + }; + return (

    { > {isSubmitting ? 'Signing up...' : 'Sign Up'} + + {isGoogleSSOEnabled && ( + <> +
    +
    + Or +
    +
    + + + + )} ); From ee0af566a9e8dc1e6409117c49152a7172970a89 Mon Sep 17 00:00:00 2001 From: Sumit Bisht <75713174+sumitbishti@users.noreply.github.com> Date: Thu, 25 Jan 2024 05:59:04 +0530 Subject: [PATCH 107/156] fix: correct document tab count for pending and completed (#855) completed/pending status gets incremented once if sender is one of the recipients fixes #853 --- packages/lib/server-only/document/get-stats.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index a446b0007..044d9a2dc 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -42,6 +42,11 @@ export const getStats = async ({ user }: GetStatsInput) => { _all: true, }, where: { + User: { + email: { + not: user.email, + }, + }, OR: [ { status: ExtendedDocumentStatus.PENDING, From e90dd518dfc91d2307a41cafa3aa8a9052f27274 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 25 Jan 2024 13:30:50 +1100 Subject: [PATCH 108/156] fix: auto verify google sso emails (#856) --- packages/lib/next-auth/auth-options.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 3b9492807..50240174c 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -9,6 +9,7 @@ import type { GoogleProfile } from 'next-auth/providers/google'; import GoogleProvider from 'next-auth/providers/google'; import { prisma } from '@documenso/prisma'; +import { IdentityProvider } from '@documenso/prisma/client'; import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble'; import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa'; @@ -93,7 +94,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { }), ], callbacks: { - async jwt({ token, user }) { + async jwt({ token, user, trigger, account }) { const merged = { ...token, ...user, @@ -138,6 +139,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { merged.emailVerified = user.emailVerified?.toISOString() ?? null; } + if ((trigger === 'signIn' || trigger === 'signUp') && account?.provider === 'google') { + merged.emailVerified = user?.emailVerified + ? new Date(user.emailVerified).toISOString() + : new Date().toISOString(); + + await prisma.user.update({ + where: { + id: Number(merged.id), + }, + data: { + emailVerified: merged.emailVerified, + identityProvider: IdentityProvider.GOOGLE, + }, + }); + } + return { id: merged.id, name: merged.name, From d766b58f42cfeefab9ea59ab67009d088093355d Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 25 Jan 2024 16:07:57 +1100 Subject: [PATCH 109/156] feat: add server crypto (#863) ## Description Currently we are required to ensure PII data is not passed around in search parameters and in the open for GDPR reasons. Allowing us to encrypt and decrypt values with expiry dates will allow us to ensure this doesn't happen. ## Changes Made - Added TPRC router for encryption method ## Testing Performed - Tested encrypting and decrypting data with and without `expiredAt` - Tested via directly accessing API and also via trpc in react components - Tested parsing en email search param in a page and decrypting it successfully ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have followed the project's coding style guidelines. --- .env.example | 6 ++-- packages/lib/constants/crypto.ts | 22 ++++++++++++ packages/lib/server-only/crypto/decrypt.ts | 33 +++++++++++++++++ packages/lib/server-only/crypto/encrypt.ts | 42 ++++++++++++++++++++++ packages/trpc/server/crypto/router.ts | 17 +++++++++ packages/trpc/server/crypto/schema.ts | 15 ++++++++ packages/trpc/server/router.ts | 2 ++ packages/tsconfig/process-env.d.ts | 1 + turbo.json | 1 + 9 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 packages/lib/server-only/crypto/decrypt.ts create mode 100644 packages/lib/server-only/crypto/encrypt.ts create mode 100644 packages/trpc/server/crypto/router.ts create mode 100644 packages/trpc/server/crypto/schema.ts diff --git a/.env.example b/.env.example index d188894de..06498f2bc 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,10 @@ NEXTAUTH_SECRET="secret" # [[CRYPTO]] # Application Key for symmetric encryption and decryption -# This should be a random string of at least 32 characters -NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE" +# REQUIRED: This should be a random string of at least 32 characters +NEXT_PRIVATE_ENCRYPTION_KEY="" +# REQUIRED: This should be a random string of at least 32 characters +NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="" # [[AUTH OPTIONAL]] NEXT_PRIVATE_GOOGLE_CLIENT_ID="" diff --git a/packages/lib/constants/crypto.ts b/packages/lib/constants/crypto.ts index d911cd6cf..40d3ef113 100644 --- a/packages/lib/constants/crypto.ts +++ b/packages/lib/constants/crypto.ts @@ -1 +1,23 @@ export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY; + +export const DOCUMENSO_ENCRYPTION_SECONDARY_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY; + +if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) { + throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys'); +} + +if (DOCUMENSO_ENCRYPTION_KEY === DOCUMENSO_ENCRYPTION_SECONDARY_KEY) { + throw new Error( + 'DOCUMENSO_ENCRYPTION_KEY and DOCUMENSO_ENCRYPTION_SECONDARY_KEY cannot be equal', + ); +} + +if (DOCUMENSO_ENCRYPTION_KEY === 'CAFEBABE') { + console.warn('*********************************************************************'); + console.warn('*'); + console.warn('*'); + console.warn('Please change the encryption key from the default value of "CAFEBABE"'); + console.warn('*'); + console.warn('*'); + console.warn('*********************************************************************'); +} diff --git a/packages/lib/server-only/crypto/decrypt.ts b/packages/lib/server-only/crypto/decrypt.ts new file mode 100644 index 000000000..7b4db9894 --- /dev/null +++ b/packages/lib/server-only/crypto/decrypt.ts @@ -0,0 +1,33 @@ +import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto'; +import { ZEncryptedDataSchema } from '@documenso/lib/server-only/crypto/encrypt'; +import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; + +/** + * Decrypt the passed in data. This uses the secondary encrypt key for miscellaneous data. + * + * @param encryptedData The data encrypted with the `encryptSecondaryData` function. + * @returns The decrypted value, or `null` if the data is invalid or expired. + */ +export const decryptSecondaryData = (encryptedData: string): string | null => { + if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) { + throw new Error('Missing encryption key'); + } + + const decryptedBufferValue = symmetricDecrypt({ + key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY, + data: encryptedData, + }); + + const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8'); + const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue)); + + if (!result.success) { + return null; + } + + if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) { + return null; + } + + return result.data.data; +}; diff --git a/packages/lib/server-only/crypto/encrypt.ts b/packages/lib/server-only/crypto/encrypt.ts new file mode 100644 index 000000000..83de19cc2 --- /dev/null +++ b/packages/lib/server-only/crypto/encrypt.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto'; +import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; +import type { TEncryptSecondaryDataMutationSchema } from '@documenso/trpc/server/crypto/schema'; + +export const ZEncryptedDataSchema = z.object({ + data: z.string(), + expiresAt: z.number().optional(), +}); + +export type EncryptDataOptions = { + data: string; + + /** + * When the data should no longer be allowed to be decrypted. + * + * Leave this empty to never expire the data. + */ + expiresAt?: number; +}; + +/** + * Encrypt the passed in data. This uses the secondary encrypt key for miscellaneous data. + * + * @returns The encrypted data. + */ +export const encryptSecondaryData = ({ data, expiresAt }: TEncryptSecondaryDataMutationSchema) => { + if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) { + throw new Error('Missing encryption key'); + } + + const dataToEncrypt: z.infer = { + data, + expiresAt, + }; + + return symmetricEncrypt({ + key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY, + data: JSON.stringify(dataToEncrypt), + }); +}; diff --git a/packages/trpc/server/crypto/router.ts b/packages/trpc/server/crypto/router.ts new file mode 100644 index 000000000..db9616436 --- /dev/null +++ b/packages/trpc/server/crypto/router.ts @@ -0,0 +1,17 @@ +import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; + +import { procedure, router } from '../trpc'; +import { ZEncryptSecondaryDataMutationSchema } from './schema'; + +export const cryptoRouter = router({ + encryptSecondaryData: procedure + .input(ZEncryptSecondaryDataMutationSchema) + .mutation(({ input }) => { + try { + return encryptSecondaryData(input); + } catch { + // Never leak errors for crypto. + throw new Error('Failed to encrypt data'); + } + }), +}); diff --git a/packages/trpc/server/crypto/schema.ts b/packages/trpc/server/crypto/schema.ts new file mode 100644 index 000000000..ee4b49d53 --- /dev/null +++ b/packages/trpc/server/crypto/schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const ZEncryptSecondaryDataMutationSchema = z.object({ + data: z.string(), + expiresAt: z.number().optional(), +}); + +export const ZDecryptDataMutationSchema = z.object({ + data: z.string(), +}); + +export type TEncryptSecondaryDataMutationSchema = z.infer< + typeof ZEncryptSecondaryDataMutationSchema +>; +export type TDecryptDataMutationSchema = z.infer; diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index 77d18e06d..3ed2a0d05 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -1,5 +1,6 @@ import { adminRouter } from './admin-router/router'; import { authRouter } from './auth-router/router'; +import { cryptoRouter } from './crypto/router'; import { documentRouter } from './document-router/router'; import { fieldRouter } from './field-router/router'; import { profileRouter } from './profile-router/router'; @@ -12,6 +13,7 @@ import { twoFactorAuthenticationRouter } from './two-factor-authentication-route export const appRouter = router({ auth: authRouter, + crypto: cryptoRouter, profile: profileRouter, document: documentRouter, field: fieldRouter, diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index badc05931..d7fc44ef7 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -8,6 +8,7 @@ declare namespace NodeJS { NEXT_PRIVATE_DATABASE_URL: string; NEXT_PRIVATE_ENCRYPTION_KEY: string; + NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; diff --git a/turbo.json b/turbo.json index 3a96c2a07..b78d7c9d0 100644 --- a/turbo.json +++ b/turbo.json @@ -34,6 +34,7 @@ "globalEnv": [ "APP_VERSION", "NEXT_PRIVATE_ENCRYPTION_KEY", + "NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY", "NEXTAUTH_URL", "NEXTAUTH_SECRET", "NEXT_PUBLIC_PROJECT", From 75ad8a488550b6225bb15411d7b6f6f3e3b89b8d Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Thu, 25 Jan 2024 15:35:57 +0100 Subject: [PATCH 110/156] chore: typos --- .../content/blog/commodifying-signing.mdx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/marketing/content/blog/commodifying-signing.mdx b/apps/marketing/content/blog/commodifying-signing.mdx index 8aeddb7e3..65d665beb 100644 --- a/apps/marketing/content/blog/commodifying-signing.mdx +++ b/apps/marketing/content/blog/commodifying-signing.mdx @@ -27,21 +27,19 @@ Tags: # Commodifying Signing > TLDR; We are creating signing as a public good and are commoditizing it to make it cheaper and better. - -While we are in full-on building mode with Documenso, I think a lot about the big picture of what we are attempting to do. One phrase that keeps popping up is, "We are commodifying signing." Let's dig deeper into what that means. +> While we are in full-on building mode with Documenso, I think a lot about the big picture of what we are attempting to do. One phrase that keeps popping up is, "We are commodifying signing." Let's dig deeper into what that means. Let's start with why we are doing this. Documenso's mission is to solve the domain of signing once and for all for everyone. In so many calls, I hear stories about how organizations build their own solution because the existing ones are too expensive or need to be more flexible. That means not hundreds but probably thousands of companies worldwide have done the same. This is simply wasting humanity's time. Since digital signing systems are understood well enough that seemingly "everyone" can build them, given enough pain, It's time to do it once correctly. ## Is signing already a commodity? > In economics, a **commodity** is an economic good, usually a resource, that has explicitly full or substantial fungibility: that is, the market treats instances of the good as equivalent or nearly so with no regard to who produced them. - -That sounds like the signing market today. There is no shortage of signing providers, and you can get similar signing services from many places. So why is this different from what we want, and why does this not satisfy the market? +> That sounds like the signing market today. There is no shortage of signing providers, and you can get similar signing services from many places. So why is this different from what we want, and why does this not satisfy the market? - Signing is expensive and painful when you are locked into your vendor, and they charge by signing volume. - Signing is also expensive and painful when you have to build it yourself since no vendor fits your requirements or you are not allowed to -To understand why, we need to look at landscape, as it is today: +To understand why, we need to look at landscape as it is today: - **Commodity**: Signing SaaS - **Private Goods**: Signing Code Base, Regulatory Know-How @@ -50,12 +48,11 @@ To understand why, we need to look at landscape, as it is today: What the current players have done is to commodify the listed public goods into commercial products: > […]the action and process of transforming goods, services, ideas, nature, personal information, people or animals into commodities. - -(Let's ignore the end of that list for now and what it says about humanity, yikes) +> (Let's ignore the end of that list for now and what it says about humanity, yikes) While this paradigm brought digital signing to many businesses worldwide, we aim for a different future. To solve signing once and for all, we need to achieve two core points: -- Making it cheaper, so it's profitable for everyone to use +- Making it cheaper so it's profitable for everyone to use - Making it more accessible so everyone can use it (e.g. regulated industries) and flexible enough (extendable, open). To achieve this, we must transform the landscape to look like this: @@ -66,13 +63,12 @@ To achieve this, we must transform the landscape to look like this: ## Raising the Bar -Before creating a commodity we are raising the bar of what the underlying public good is. Having an open source singing framework you can extend, self-host, and understand makes the resulting solution much more accessible and extendable for everyone. Now for the final feat of making signing cheaper: +Before creating a commodity, we are raising the bar of what the underlying public good is. Having an open source singing framework you can extend, self-host, and understand makes the resulting solution much more accessible and extendable for everyone. Now for the final feat of making signing cheaper: As we have seen, signing has already been commodified. But since it was done by a closed source and, frankly, a very opaque industry, no downward price spiral has ensued. By building Documenso open source with an open culture, we can pierce the veil and trigger what the space has been missing for a long time: Commoditization. If you had to read that again, so did I: > In business literature, **commoditization** is defined as the process by which goods that have economic value and are distinguishable in terms of attributes (uniqueness or brand) become simple commodities in the eyes of the market or consumers. - -By only selling what creates value for the customer (hosting a highly available service, keeping it compliant, supporting with technical issues and challenges, preparing industry-specific components), we are commoditizing signing since everyone can do it now: The resources enabling it are public goods, aka. open source. A leveled playing field as described above is the perfect environment for a community-first, technology-first and value-first company like Documenso to flourish. +> By only selling what creates value for the customer (hosting a highly available service, keeping it compliant, supporting with technical issues and challenges, preparing industry-specific components), we are commoditizing signing since everyone can do it now: The resources enabling it are public goods, aka. open source. A leveled playing field as described above is the perfect environment for a community-first, technology-first and value-first company like Documenso to flourish. ## Changing the Game @@ -80,7 +76,7 @@ In this new world, a company in need of signing (literally every company) can de The market dynamic now changes to who can offer the greatest value added to the public goods, driving the price down as this can be done much more efficiently than locking customers into closed source SaaS. Documenso, being a lean company, which we intend to stay with for a long time, will help kickstart this effect. Open Source capital effiency is real. Our planned enterprise components, hosting support, and partner ecosystem will all leverage this effect. -We will grow our a community around the public good, the open-source repo, and create an ecosystem around the commodities built on top of it (components, hosting, compliance, support). We will solve signing once and for all, and the world will be better for it. Onwards. +We will grow our community around the public good, the open-source repo, and create an ecosystem around the commodities built on top of it (components, hosting, compliance, support). We will solve signing once and for all, and the world will be better for it. Onwards. As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments. From 56f65f3bb3c11195dac9633b700e53b6bad25879 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Thu, 25 Jan 2024 15:39:34 +0100 Subject: [PATCH 111/156] chore: typos --- apps/marketing/content/blog/commodifying-signing.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/marketing/content/blog/commodifying-signing.mdx b/apps/marketing/content/blog/commodifying-signing.mdx index 65d665beb..4216c6fff 100644 --- a/apps/marketing/content/blog/commodifying-signing.mdx +++ b/apps/marketing/content/blog/commodifying-signing.mdx @@ -39,7 +39,7 @@ Let's start with why we are doing this. Documenso's mission is to solve the doma - Signing is expensive and painful when you are locked into your vendor, and they charge by signing volume. - Signing is also expensive and painful when you have to build it yourself since no vendor fits your requirements or you are not allowed to -To understand why, we need to look at landscape as it is today: +To understand why, we need to look at the landscape as it is today: - **Commodity**: Signing SaaS - **Private Goods**: Signing Code Base, Regulatory Know-How @@ -47,7 +47,7 @@ To understand why, we need to look at landscape as it is today: What the current players have done is to commodify the listed public goods into commercial products: -> […]the action and process of transforming goods, services, ideas, nature, personal information, people or animals into commodities. +> […]the action and process of transforming goods, services, ideas, nature, personal information, people, or animals into commodities. > (Let's ignore the end of that list for now and what it says about humanity, yikes) While this paradigm brought digital signing to many businesses worldwide, we aim for a different future. To solve signing once and for all, we need to achieve two core points: @@ -68,13 +68,13 @@ Before creating a commodity, we are raising the bar of what the underlying publi As we have seen, signing has already been commodified. But since it was done by a closed source and, frankly, a very opaque industry, no downward price spiral has ensued. By building Documenso open source with an open culture, we can pierce the veil and trigger what the space has been missing for a long time: Commoditization. If you had to read that again, so did I: > In business literature, **commoditization** is defined as the process by which goods that have economic value and are distinguishable in terms of attributes (uniqueness or brand) become simple commodities in the eyes of the market or consumers. -> By only selling what creates value for the customer (hosting a highly available service, keeping it compliant, supporting with technical issues and challenges, preparing industry-specific components), we are commoditizing signing since everyone can do it now: The resources enabling it are public goods, aka. open source. A leveled playing field as described above is the perfect environment for a community-first, technology-first and value-first company like Documenso to flourish. +> By only selling what creates value for the customer (hosting a highly available service, keeping it compliant, supporting with technical issues and challenges, preparing industry-specific components), we are commoditizing signing since everyone can do it now: The resources enabling it are public goods, aka. open source. A leveled playing field, as described above, is the perfect environment for a community-first, technology-first, and value-first company like Documenso to flourish. ## Changing the Game -In this new world, a company in need of signing (literally every company) can decide if the ROI (Return on Investment) of building signing themselves is greater than simply paying for the value-added activities that will be needing anyway. Pricing our offering not on volume but fixed is a nice additional wedge into the market we intend to use here. +In this new world, a company needing signing (literally every company) can decide if the ROI (Return on Investment) of building signing themselves is greater than simply paying for the value-added activities they will need anyway. Pricing our offering not on volume but fixed is a nice additional wedge into the market we intend to use here. -The market dynamic now changes to who can offer the greatest value added to the public goods, driving the price down as this can be done much more efficiently than locking customers into closed source SaaS. Documenso, being a lean company, which we intend to stay with for a long time, will help kickstart this effect. Open Source capital effiency is real. Our planned enterprise components, hosting support, and partner ecosystem will all leverage this effect. +The market dynamic now changes to who can offer the greatest value added to the public goods, driving the price down as this can be done much more efficiently than locking customers into closed source SaaS. Documenso, being a lean company, which we intend to stay with for a long time, will help kickstart this effect. Open Source capital efficiency is real. Our planned enterprise components, hosting support, and partner ecosystem will all leverage this effect. We will grow our community around the public good, the open-source repo, and create an ecosystem around the commodities built on top of it (components, hosting, compliance, support). We will solve signing once and for all, and the world will be better for it. Onwards. From fdeab19a7f4018474698e3e4dfaeb848ff703749 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Fri, 26 Jan 2024 12:00:00 +0100 Subject: [PATCH 112/156] chore: fix paragh quote break --- apps/marketing/content/blog/commodifying-signing.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/commodifying-signing.mdx b/apps/marketing/content/blog/commodifying-signing.mdx index 4216c6fff..7fedb1e5d 100644 --- a/apps/marketing/content/blog/commodifying-signing.mdx +++ b/apps/marketing/content/blog/commodifying-signing.mdx @@ -34,7 +34,8 @@ Let's start with why we are doing this. Documenso's mission is to solve the doma ## Is signing already a commodity? > In economics, a **commodity** is an economic good, usually a resource, that has explicitly full or substantial fungibility: that is, the market treats instances of the good as equivalent or nearly so with no regard to who produced them. -> That sounds like the signing market today. There is no shortage of signing providers, and you can get similar signing services from many places. So why is this different from what we want, and why does this not satisfy the market? + +That sounds like the signing market today. There is no shortage of signing providers, and you can get similar signing services from many places. So why is this different from what we want, and why does this not satisfy the market? - Signing is expensive and painful when you are locked into your vendor, and they charge by signing volume. - Signing is also expensive and painful when you have to build it yourself since no vendor fits your requirements or you are not allowed to From 91c89e8bfb204f5b883765b3975b53674b5ef483 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Fri, 26 Jan 2024 12:01:53 +0100 Subject: [PATCH 113/156] chore: quote fix --- apps/marketing/content/blog/commodifying-signing.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/commodifying-signing.mdx b/apps/marketing/content/blog/commodifying-signing.mdx index 7fedb1e5d..69f47dfcb 100644 --- a/apps/marketing/content/blog/commodifying-signing.mdx +++ b/apps/marketing/content/blog/commodifying-signing.mdx @@ -27,7 +27,8 @@ Tags: # Commodifying Signing > TLDR; We are creating signing as a public good and are commoditizing it to make it cheaper and better. -> While we are in full-on building mode with Documenso, I think a lot about the big picture of what we are attempting to do. One phrase that keeps popping up is, "We are commodifying signing." Let's dig deeper into what that means. + +While we are in full-on building mode with Documenso, I think a lot about the big picture of what we are attempting to do. One phrase that keeps popping up is, "We are commodifying signing." Let's dig deeper into what that means. Let's start with why we are doing this. Documenso's mission is to solve the domain of signing once and for all for everyone. In so many calls, I hear stories about how organizations build their own solution because the existing ones are too expensive or need to be more flexible. That means not hundreds but probably thousands of companies worldwide have done the same. This is simply wasting humanity's time. Since digital signing systems are understood well enough that seemingly "everyone" can build them, given enough pain, It's time to do it once correctly. From 8619e02d043b2355ca94335b47fdaa51bdda2de2 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Fri, 26 Jan 2024 12:02:30 +0100 Subject: [PATCH 114/156] chore: quote fix --- apps/marketing/content/blog/commodifying-signing.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/commodifying-signing.mdx b/apps/marketing/content/blog/commodifying-signing.mdx index 69f47dfcb..0a9cf4050 100644 --- a/apps/marketing/content/blog/commodifying-signing.mdx +++ b/apps/marketing/content/blog/commodifying-signing.mdx @@ -70,7 +70,8 @@ Before creating a commodity, we are raising the bar of what the underlying publi As we have seen, signing has already been commodified. But since it was done by a closed source and, frankly, a very opaque industry, no downward price spiral has ensued. By building Documenso open source with an open culture, we can pierce the veil and trigger what the space has been missing for a long time: Commoditization. If you had to read that again, so did I: > In business literature, **commoditization** is defined as the process by which goods that have economic value and are distinguishable in terms of attributes (uniqueness or brand) become simple commodities in the eyes of the market or consumers. -> By only selling what creates value for the customer (hosting a highly available service, keeping it compliant, supporting with technical issues and challenges, preparing industry-specific components), we are commoditizing signing since everyone can do it now: The resources enabling it are public goods, aka. open source. A leveled playing field, as described above, is the perfect environment for a community-first, technology-first, and value-first company like Documenso to flourish. + +By only selling what creates value for the customer (hosting a highly available service, keeping it compliant, supporting with technical issues and challenges, preparing industry-specific components), we are commoditizing signing since everyone can do it now: The resources enabling it are public goods, aka. open source. A leveled playing field, as described above, is the perfect environment for a community-first, technology-first, and value-first company like Documenso to flourish. ## Changing the Game From 671fd916b5c54a3e7f211dd5f6d6bbb63fa92ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Lima?= <30646629+joselimadev@users.noreply.github.com> Date: Fri, 26 Jan 2024 23:16:59 -0300 Subject: [PATCH 115/156] fix: resolve conflicting z-index values btwn avatar in document list and header (#872) ## Description This pull request solves the problem where the avatar component within the document list has the same z-index value as the header component, causing the avatar to be above the header. When two elements have the same z-index value, the last one takes priority! ## Related Issue Fixes #870 ## Changes Made 1. Increases the value of the header's `z-index` by `10` (the current value is `50` --- apps/web/src/components/(dashboard)/layout/header.tsx | 2 +- .../src/components/(dashboard)/layout/profile-dropdown.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx index bdae6c511..ba35671e6 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/layout/header.tsx @@ -33,7 +33,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => { return (
    5 && 'border-b-border', className, )} diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx index 252432b89..f2432c071 100644 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx @@ -68,7 +68,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { - + Account {isUserAdmin && ( @@ -122,7 +122,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { Themes - + Light From 09aa10dad640a99d53d789330d793d6a63516150 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+dephraiim@users.noreply.github.com> Date: Mon, 29 Jan 2024 00:04:57 +0000 Subject: [PATCH 116/156] chore: rewording to avoid confusion between signed and original document (#880) --- .../(signing)/sign/[token]/complete/document-preview-button.tsx | 2 +- apps/web/src/app/(signing)/sign/[token]/complete/page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/document-preview-button.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/document-preview-button.tsx index 1ac50f1c0..c0881bd44 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/document-preview-button.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/document-preview-button.tsx @@ -30,7 +30,7 @@ export const DocumentPreviewButton = ({ {...props} > - View Document + View Original Document diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx index ab73755ab..3d5814113 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -128,7 +128,7 @@ export default async function CompletedSigningPage({ /> ) : ( From 354e16901c66e76f6d4715d8d115f8ae63ec07cc Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+dephraiim@users.noreply.github.com> Date: Mon, 29 Jan 2024 00:08:31 +0000 Subject: [PATCH 117/156] fix: sign dialog completed title color in dark mode (#879) --- apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx index e4d4571fc..1e86e99bc 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -45,7 +45,7 @@ export const SignDialog = ({
    -
    Sign Document
    +
    Sign Document
    You are about to finish signing "{truncatedTitle}". Are you sure?
    From 6d5fe4eea36415b30e9f0e32e3b22ec2c6922312 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 29 Jan 2024 00:47:11 +0000 Subject: [PATCH 118/156] fix: show the fields on the document at the subject selection page --- .../primitives/document-flow/add-fields.tsx | 3 +- .../primitives/document-flow/add-subject.tsx | 5 + .../document-flow/subject-field-item.tsx | 94 +++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 packages/ui/primitives/document-flow/subject-field-item.tsx diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index a8ae9f0e3..afd09809d 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -30,8 +30,7 @@ import { DocumentFlowFormContainerStep, } from './document-flow-root'; import { FieldItem } from './field-item'; -import type { DocumentFlowStep } from './types'; -import { FRIENDLY_FIELD_TYPE } from './types'; +import { type DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types'; const fontCaveat = Caveat({ weight: ['500'], diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index 8fef8af7b..21df88bd1 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -38,6 +38,7 @@ import { DocumentFlowFormContainerHeader, DocumentFlowFormContainerStep, } from './document-flow-root'; +import { SubjectFieldItem } from './subject-field-item'; import type { DocumentFlowStep } from './types'; export type AddSubjectFormProps = { @@ -98,6 +99,10 @@ export const AddSubjectFormPartial = ({ />
    + {fields.map((field, index) => ( + + ))} +

    f zi=xYPHkca;a$8d?%~5ocj5;X=4D#cGQ$?)Ji0iEK!yOi@2lJ?Hh^>uP@u-qjn^mVn zW}|?2aeP~wo@H=q+^N(dK}QBLz)U8?Jo$zSM}J=8?M`aG;s9KqC(NnQekaAHv0SdP ze-VncqAwG$0j_KLB2>EtozuskQ7+y-gXT6x#y`v^jUDj(Bu@b22=R9df>;zovP1^D zm@zz}1(Ri8P}`Ocw{VwEE+=$=sNZ{rD6;>A)Sk zwp|PeD$MjZm>h!VjpcZXO%|x&CN<=myGo&$Qlhk$B1MD$Ui(x;#rZp_K~H5Y*N?3acQ`6V13jCEWU#NIgSQV zZ7=;|l_@Z;Bd<#aPyppC>KMG(#eVU5$ZwoWhO5#QY|`7d&SmTb4F}?4t>+r zhPoj5T9+ud4R!`c1-QteP6zQA6ZydR4>J6S9@q{iZFKn52WD*k&|NauENH?bVKZlw@gLO0Kn1mH0C{5s!kAPn z*Ipo)>uW#`Z8eIB`b4UT=7d90yt&R-YYq_3qSb`D1xKGFi-he7#!xH2DO!%q8=*ix z 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�FwOB?Tpyp<;AmWrpfW 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%0AJwpWDQzMX%}^U(AM_b+mUFF9onp43~Ufe)IUO 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 Date: Thu, 25 Jan 2024 05:52:19 +0530 Subject: [PATCH 106/156] feat: sign up with Google (#862) This PR links to this issue: #791 Now users can see a new option to sign up with Google on the signup page. --- .../src/app/(unauthenticated)/signup/page.tsx | 4 +- apps/web/src/components/forms/signup.tsx | 43 ++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/(unauthenticated)/signup/page.tsx b/apps/web/src/app/(unauthenticated)/signup/page.tsx index 353716d9b..05b9caf21 100644 --- a/apps/web/src/app/(unauthenticated)/signup/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signup/page.tsx @@ -1,6 +1,8 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; +import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; + import { SignUpForm } from '~/components/forms/signup'; export default function SignUpPage() { @@ -17,7 +19,7 @@ export default function SignUpPage() { signing is within your grasp.

  • -
    - @@ -157,7 +158,7 @@ export const DisableAuthenticatorAppDialog = ({ > Disable 2FA -
    + diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx index 0db1c8b50..7a181c4cc 100644 --- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx +++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -15,6 +15,7 @@ import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, } from '@documenso/ui/primitives/dialog'; @@ -190,15 +191,15 @@ export const EnableAuthenticatorAppDialog = ({ )} /> -
    - -
    + ); @@ -251,15 +252,15 @@ export const EnableAuthenticatorAppDialog = ({ )} /> -
    - -
    + )) diff --git a/apps/web/src/components/forms/2fa/recovery-codes.tsx b/apps/web/src/components/forms/2fa/recovery-codes.tsx index 7e8950227..29834c74a 100644 --- a/apps/web/src/components/forms/2fa/recovery-codes.tsx +++ b/apps/web/src/components/forms/2fa/recovery-codes.tsx @@ -7,7 +7,6 @@ import { Button } from '@documenso/ui/primitives/button'; import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog'; type RecoveryCodesProps = { - // backupCodes: string[] | null; isTwoFactorEnabled: boolean; }; @@ -16,22 +15,13 @@ export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => { return ( <> -
    -
    -

    Recovery Codes

    - -

    - Recovery codes are used to access your account in the event that you lose access to your - authenticator app. -

    -
    - -
    - -
    -
    + -
    - -
    + ); diff --git a/apps/web/src/components/forms/password.tsx b/apps/web/src/components/forms/password.tsx index 0fa5ad462..03f95ff7f 100644 --- a/apps/web/src/components/forms/password.tsx +++ b/apps/web/src/components/forms/password.tsx @@ -137,7 +137,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => { /> -
    +
    diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 17bb2c57c..b3e4ea019 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -12,7 +12,13 @@ import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes'; import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; import { Form, FormControl, @@ -111,7 +117,6 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) = const result = await signIn('credentials', { ...credentials, - callbackUrl: LOGIN_REDIRECT_PATH, redirect: false, }); @@ -270,21 +275,23 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) = )} /> )} + + + + + + - -
    - - - -
    diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts index 4039703b8..ed1809691 100644 --- a/apps/web/src/pages/api/auth/[...nextauth].ts +++ b/apps/web/src/pages/api/auth/[...nextauth].ts @@ -1,17 +1,75 @@ -// import { NextApiRequest, NextApiResponse } from 'next'; +import type { NextApiRequest, NextApiResponse } from 'next'; + import NextAuth from 'next-auth'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; +import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { prisma } from '@documenso/prisma'; +import { UserSecurityAuditLogType } from '@documenso/prisma/client'; -export default NextAuth({ - ...NEXT_AUTH_OPTIONS, - pages: { - signIn: '/signin', - signOut: '/signout', - error: '/signin', - }, -}); +export default async function auth(req: NextApiRequest, res: NextApiResponse) { + const { ipAddress, userAgent } = extractRequestMetadata(req); -// export default async function handler(_req: NextApiRequest, res: NextApiResponse) { -// res.json({ hello: 'world' }); -// } + return await NextAuth(req, res, { + ...NEXT_AUTH_OPTIONS, + pages: { + signIn: '/signin', + signOut: '/signout', + error: '/signin', + }, + events: { + createUser: async ({ user }) => { + await prisma.userSecurityAuditLog.create({ + data: { + userId: user.id, + ipAddress, + userAgent, + type: UserSecurityAuditLogType.ACCOUNT_CREATE, + }, + }); + }, + signIn: async ({ user }) => { + await prisma.userSecurityAuditLog.create({ + data: { + userId: user.id, + ipAddress, + userAgent, + type: UserSecurityAuditLogType.SIGN_IN, + }, + }); + }, + signOut: async ({ token }) => { + const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id; + + if (isNaN(userId)) { + return; + } + + await prisma.userSecurityAuditLog.create({ + data: { + userId, + ipAddress, + userAgent, + type: UserSecurityAuditLogType.SIGN_OUT, + }, + }); + }, + linkAccount: async ({ user }) => { + const userId = typeof user.id === 'string' ? parseInt(user.id) : user.id; + + if (isNaN(userId)) { + return; + } + + await prisma.userSecurityAuditLog.create({ + data: { + userId, + ipAddress, + userAgent, + type: UserSecurityAuditLogType.ACCOUNT_SSO_LINK, + }, + }); + }, + }, + }); +} diff --git a/package-lock.json b/package-lock.json index 69825e8d8..9012d3f29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -158,6 +158,7 @@ "sharp": "0.33.1", "ts-pattern": "^5.0.5", "typescript": "5.2.2", + "ua-parser-js": "^1.0.37", "uqr": "^0.1.2", "zod": "^3.22.4" }, @@ -166,7 +167,8 @@ "@types/luxon": "^3.3.1", "@types/node": "20.1.0", "@types/react": "18.2.18", - "@types/react-dom": "18.2.7" + "@types/react-dom": "18.2.7", + "@types/ua-parser-js": "^0.7.39" } }, "apps/web/node_modules/@types/node": { @@ -6756,6 +6758,12 @@ "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==" }, + "node_modules/@types/ua-parser-js": { + "version": "0.7.39", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz", + "integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==", + "dev": true + }, "node_modules/@types/unist": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", @@ -18643,6 +18651,28 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "1.0.37", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz", + "integrity": "sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "engines": { + "node": "*" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index 837ca3e3a..48f1e9d7b 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -1,4 +1,4 @@ -import { IdentityProvider } from '@documenso/prisma/client'; +import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client'; export const SALT_ROUNDS = 12; @@ -10,3 +10,15 @@ export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = { export const IS_GOOGLE_SSO_ENABLED = Boolean( process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET, ); + +export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: string } = { + [UserSecurityAuditLogType.ACCOUNT_CREATE]: 'Account created', + [UserSecurityAuditLogType.ACCOUNT_SSO_LINK]: 'Linked account to SSO', + [UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated', + [UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled', + [UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled', + [UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset', + [UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated', + [UserSecurityAuditLogType.SIGN_IN]: 'Signed In', + [UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out', +}; diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 50240174c..9babae987 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -192,4 +192,5 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { return true; }, }, + // Note: `events` are handled in `apps/web/src/pages/api/auth/[...nextauth].ts` to allow access to the request. }; diff --git a/packages/lib/server-only/2fa/disable-2fa.ts b/packages/lib/server-only/2fa/disable-2fa.ts index 5b27d5c9d..dd8a180c9 100644 --- a/packages/lib/server-only/2fa/disable-2fa.ts +++ b/packages/lib/server-only/2fa/disable-2fa.ts @@ -1,21 +1,25 @@ import { compare } from 'bcrypt'; import { prisma } from '@documenso/prisma'; -import { User } from '@documenso/prisma/client'; +import type { User } from '@documenso/prisma/client'; +import { UserSecurityAuditLogType } from '@documenso/prisma/client'; import { ErrorCode } from '../../next-auth/error-codes'; +import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { validateTwoFactorAuthentication } from './validate-2fa'; type DisableTwoFactorAuthenticationOptions = { user: User; backupCode: string; password: string; + requestMetadata?: RequestMetadata; }; export const disableTwoFactorAuthentication = async ({ backupCode, user, password, + requestMetadata, }: DisableTwoFactorAuthenticationOptions) => { if (!user.password) { throw new Error(ErrorCode.USER_MISSING_PASSWORD); @@ -33,15 +37,26 @@ export const disableTwoFactorAuthentication = async ({ throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE); } - await prisma.user.update({ - where: { - id: user.id, - }, - data: { - twoFactorEnabled: false, - twoFactorBackupCodes: null, - twoFactorSecret: null, - }, + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { + id: user.id, + }, + data: { + twoFactorEnabled: false, + twoFactorBackupCodes: null, + twoFactorSecret: null, + }, + }); + + await tx.userSecurityAuditLog.create({ + data: { + userId: user.id, + type: UserSecurityAuditLogType.AUTH_2FA_DISABLE, + userAgent: requestMetadata?.userAgent, + ipAddress: requestMetadata?.ipAddress, + }, + }); }); return true; diff --git a/packages/lib/server-only/2fa/enable-2fa.ts b/packages/lib/server-only/2fa/enable-2fa.ts index 9f61e52a4..19a2b67c2 100644 --- a/packages/lib/server-only/2fa/enable-2fa.ts +++ b/packages/lib/server-only/2fa/enable-2fa.ts @@ -1,18 +1,21 @@ import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { prisma } from '@documenso/prisma'; -import { User } from '@documenso/prisma/client'; +import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client'; +import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { getBackupCodes } from './get-backup-code'; import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token'; type EnableTwoFactorAuthenticationOptions = { user: User; code: string; + requestMetadata?: RequestMetadata; }; export const enableTwoFactorAuthentication = async ({ user, code, + requestMetadata, }: EnableTwoFactorAuthenticationOptions) => { if (user.identityProvider !== 'DOCUMENSO') { throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER); @@ -32,13 +35,24 @@ export const enableTwoFactorAuthentication = async ({ throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE); } - const updatedUser = await prisma.user.update({ - where: { - id: user.id, - }, - data: { - twoFactorEnabled: true, - }, + const updatedUser = await prisma.$transaction(async (tx) => { + await tx.userSecurityAuditLog.create({ + data: { + userId: user.id, + type: UserSecurityAuditLogType.AUTH_2FA_ENABLE, + userAgent: requestMetadata?.userAgent, + ipAddress: requestMetadata?.ipAddress, + }, + }); + + return await tx.user.update({ + where: { + id: user.id, + }, + data: { + twoFactorEnabled: true, + }, + }); }); const recoveryCodes = getBackupCodes({ user: updatedUser }); diff --git a/packages/lib/server-only/2fa/setup-2fa.ts b/packages/lib/server-only/2fa/setup-2fa.ts index 30ddf0ec3..23f213574 100644 --- a/packages/lib/server-only/2fa/setup-2fa.ts +++ b/packages/lib/server-only/2fa/setup-2fa.ts @@ -5,7 +5,7 @@ import { createTOTPKeyURI } from 'oslo/otp'; import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { prisma } from '@documenso/prisma'; -import { User } from '@documenso/prisma/client'; +import { type User } from '@documenso/prisma/client'; import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; import { symmetricEncrypt } from '../../universal/crypto'; diff --git a/packages/lib/server-only/user/find-user-security-audit-logs.ts b/packages/lib/server-only/user/find-user-security-audit-logs.ts new file mode 100644 index 000000000..0d6b5c8d5 --- /dev/null +++ b/packages/lib/server-only/user/find-user-security-audit-logs.ts @@ -0,0 +1,52 @@ +import type { FindResultSet } from '@documenso/lib/types/find-result-set'; +import { prisma } from '@documenso/prisma'; +import type { UserSecurityAuditLog, UserSecurityAuditLogType } from '@documenso/prisma/client'; + +export type FindUserSecurityAuditLogsOptions = { + userId: number; + type?: UserSecurityAuditLogType; + page?: number; + perPage?: number; + orderBy?: { + column: keyof Omit; + direction: 'asc' | 'desc'; + }; +}; + +export const findUserSecurityAuditLogs = async ({ + userId, + type, + page = 1, + perPage = 10, + orderBy, +}: FindUserSecurityAuditLogsOptions) => { + const orderByColumn = orderBy?.column ?? 'createdAt'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + const whereClause = { + userId, + type, + }; + + const [data, count] = await Promise.all([ + prisma.userSecurityAuditLog.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + }), + prisma.userSecurityAuditLog.count({ + where: whereClause, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultSet; +}; diff --git a/packages/lib/server-only/user/reset-password.ts b/packages/lib/server-only/user/reset-password.ts index 2233894d8..39aac5d28 100644 --- a/packages/lib/server-only/user/reset-password.ts +++ b/packages/lib/server-only/user/reset-password.ts @@ -1,16 +1,19 @@ import { compare, hash } from 'bcrypt'; import { prisma } from '@documenso/prisma'; +import { UserSecurityAuditLogType } from '@documenso/prisma/client'; import { SALT_ROUNDS } from '../../constants/auth'; +import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { sendResetPassword } from '../auth/send-reset-password'; export type ResetPasswordOptions = { token: string; password: string; + requestMetadata?: RequestMetadata; }; -export const resetPassword = async ({ token, password }: ResetPasswordOptions) => { +export const resetPassword = async ({ token, password, requestMetadata }: ResetPasswordOptions) => { if (!token) { throw new Error('Invalid token provided. Please try again.'); } @@ -56,6 +59,14 @@ export const resetPassword = async ({ token, password }: ResetPasswordOptions) = userId: foundToken.userId, }, }), + prisma.userSecurityAuditLog.create({ + data: { + userId: foundToken.userId, + type: UserSecurityAuditLogType.PASSWORD_RESET, + userAgent: requestMetadata?.userAgent, + ipAddress: requestMetadata?.ipAddress, + }, + }), ]); await sendResetPassword({ userId: foundToken.userId }); diff --git a/packages/lib/server-only/user/update-password.ts b/packages/lib/server-only/user/update-password.ts index b7579cd35..2621fe8e3 100644 --- a/packages/lib/server-only/user/update-password.ts +++ b/packages/lib/server-only/user/update-password.ts @@ -1,19 +1,22 @@ import { compare, hash } from 'bcrypt'; +import { SALT_ROUNDS } from '@documenso/lib/constants/auth'; +import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { prisma } from '@documenso/prisma'; - -import { SALT_ROUNDS } from '../../constants/auth'; +import { UserSecurityAuditLogType } from '@documenso/prisma/client'; export type UpdatePasswordOptions = { userId: number; password: string; currentPassword: string; + requestMetadata?: RequestMetadata; }; export const updatePassword = async ({ userId, password, currentPassword, + requestMetadata, }: UpdatePasswordOptions) => { // Existence check const user = await prisma.user.findFirstOrThrow({ @@ -39,14 +42,23 @@ export const updatePassword = async ({ const hashedNewPassword = await hash(password, SALT_ROUNDS); - const updatedUser = await prisma.user.update({ - where: { - id: userId, - }, - data: { - password: hashedNewPassword, - }, - }); + return await prisma.$transaction(async (tx) => { + await tx.userSecurityAuditLog.create({ + data: { + userId, + type: UserSecurityAuditLogType.PASSWORD_UPDATE, + userAgent: requestMetadata?.userAgent, + ipAddress: requestMetadata?.ipAddress, + }, + }); - return updatedUser; + return await tx.user.update({ + where: { + id: userId, + }, + data: { + password: hashedNewPassword, + }, + }); + }); }; diff --git a/packages/lib/server-only/user/update-profile.ts b/packages/lib/server-only/user/update-profile.ts index a28fd21c5..a99caff99 100644 --- a/packages/lib/server-only/user/update-profile.ts +++ b/packages/lib/server-only/user/update-profile.ts @@ -1,12 +1,21 @@ import { prisma } from '@documenso/prisma'; +import { UserSecurityAuditLogType } from '@documenso/prisma/client'; + +import type { RequestMetadata } from '../../universal/extract-request-metadata'; export type UpdateProfileOptions = { userId: number; name: string; signature: string; + requestMetadata?: RequestMetadata; }; -export const updateProfile = async ({ userId, name, signature }: UpdateProfileOptions) => { +export const updateProfile = async ({ + userId, + name, + signature, + requestMetadata, +}: UpdateProfileOptions) => { // Existence check await prisma.user.findFirstOrThrow({ where: { @@ -14,15 +23,24 @@ export const updateProfile = async ({ userId, name, signature }: UpdateProfileOp }, }); - const updatedUser = await prisma.user.update({ - where: { - id: userId, - }, - data: { - name, - signature, - }, - }); + return await prisma.$transaction(async (tx) => { + await tx.userSecurityAuditLog.create({ + data: { + userId, + type: UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE, + userAgent: requestMetadata?.userAgent, + ipAddress: requestMetadata?.ipAddress, + }, + }); - return updatedUser; + return await tx.user.update({ + where: { + id: userId, + }, + data: { + name, + signature, + }, + }); + }); }; diff --git a/packages/lib/types/search-params.ts b/packages/lib/types/search-params.ts new file mode 100644 index 000000000..ff3fdc4e2 --- /dev/null +++ b/packages/lib/types/search-params.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const ZBaseTableSearchParamsSchema = z.object({ + query: z + .string() + .optional() + .catch(() => undefined), + page: z.coerce + .number() + .min(1) + .optional() + .catch(() => undefined), + perPage: z.coerce + .number() + .min(1) + .optional() + .catch(() => undefined), +}); + +export type TBaseTableSearchParamsSchema = z.infer; diff --git a/packages/lib/universal/extract-request-metadata.ts b/packages/lib/universal/extract-request-metadata.ts new file mode 100644 index 000000000..ceb4ad35f --- /dev/null +++ b/packages/lib/universal/extract-request-metadata.ts @@ -0,0 +1,22 @@ +import type { NextApiRequest } from 'next'; + +import { z } from 'zod'; + +const ZIpSchema = z.string().ip(); + +export type RequestMetadata = { + ipAddress?: string; + userAgent?: string; +}; + +export const extractRequestMetadata = (req: NextApiRequest): RequestMetadata => { + const parsedIp = ZIpSchema.safeParse(req.headers['x-forwarded-for'] || req.socket.remoteAddress); + + const ipAddress = parsedIp.success ? parsedIp.data : undefined; + const userAgent = req.headers['user-agent']; + + return { + ipAddress, + userAgent, + }; +}; diff --git a/packages/prisma/migrations/20240130062658_add_user_security_audit_logs/migration.sql b/packages/prisma/migrations/20240130062658_add_user_security_audit_logs/migration.sql new file mode 100644 index 000000000..73d85ada8 --- /dev/null +++ b/packages/prisma/migrations/20240130062658_add_user_security_audit_logs/migration.sql @@ -0,0 +1,17 @@ +-- CreateEnum +CREATE TYPE "UserSecurityAuditLogType" AS ENUM ('ACCOUNT_CREATE', 'ACCOUNT_PROFILE_UPDATE', 'ACCOUNT_SSO_LINK', 'AUTH_2FA_DISABLE', 'AUTH_2FA_ENABLE', 'PASSWORD_RESET', 'PASSWORD_UPDATE', 'SIGN_OUT', 'SIGN_IN'); + +-- CreateTable +CREATE TABLE "UserSecurityAuditLog" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "type" "UserSecurityAuditLogType" NOT NULL, + "userAgent" TEXT, + "ipAddress" TEXT, + + CONSTRAINT "UserSecurityAuditLog_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "UserSecurityAuditLog" ADD CONSTRAINT "UserSecurityAuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index e1549e072..8f83f0ac3 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -40,12 +40,37 @@ model User { twoFactorSecret String? twoFactorEnabled Boolean @default(false) twoFactorBackupCodes String? - VerificationToken VerificationToken[] - Template Template[] + + VerificationToken VerificationToken[] + Template Template[] + securityAuditLogs UserSecurityAuditLog[] @@index([email]) } +enum UserSecurityAuditLogType { + ACCOUNT_CREATE + ACCOUNT_PROFILE_UPDATE + ACCOUNT_SSO_LINK + AUTH_2FA_DISABLE + AUTH_2FA_ENABLE + PASSWORD_RESET + PASSWORD_UPDATE + SIGN_OUT + SIGN_IN +} + +model UserSecurityAuditLog { + id Int @id @default(autoincrement()) + userId Int + createdAt DateTime @default(now()) + type UserSecurityAuditLogType + userAgent String? + ipAddress String? + + User User @relation(fields: [userId], references: [id]) +} + model PasswordResetToken { id Int @id @default(autoincrement()) token String @unique @@ -161,9 +186,9 @@ model DocumentMeta { id String @id @default(cuid()) subject String? message String? - timezone String? @db.Text @default("Etc/UTC") - password String? - dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a") + 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) } @@ -184,19 +209,19 @@ enum SigningStatus { } model Recipient { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) documentId Int? templateId Int? - email String @db.VarChar(255) - name String @default("") @db.VarChar(255) + email String @db.VarChar(255) + name String @default("") @db.VarChar(255) token String expired DateTime? signedAt DateTime? readStatus ReadStatus @default(NOT_OPENED) signingStatus SigningStatus @default(NOT_SIGNED) sendStatus SendStatus @default(NOT_SENT) - Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) - Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) + Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) + Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) Field Field[] Signature Signature[] @@ -280,10 +305,10 @@ model Template { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) - User User @relation(fields: [userId], references: [id], onDelete: Cascade) - Recipient Recipient[] - Field Field[] + templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) + Recipient Recipient[] + Field Field[] @@unique([templateDocumentDataId]) } diff --git a/packages/trpc/server/context.ts b/packages/trpc/server/context.ts index e1973f08b..7136afd70 100644 --- a/packages/trpc/server/context.ts +++ b/packages/trpc/server/context.ts @@ -1,4 +1,4 @@ -import { CreateNextContextOptions } from '@trpc/server/adapters/next'; +import type { CreateNextContextOptions } from '@trpc/server/adapters/next'; import { getServerSession } from '@documenso/lib/next-auth/get-server-session'; @@ -9,6 +9,7 @@ export const createTrpcContext = async ({ req, res }: CreateNextContextOptions) return { session: null, user: null, + req, }; } @@ -16,12 +17,14 @@ export const createTrpcContext = async ({ req, res }: CreateNextContextOptions) return { session: null, user: null, + req, }; } return { session, user, + req, }; }; diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 4dcf4ca93..c595c628c 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,15 +1,18 @@ import { TRPCError } from '@trpc/server'; +import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs'; import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; import { resetPassword } from '@documenso/lib/server-only/user/reset-password'; import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token'; import { updatePassword } from '@documenso/lib/server-only/user/update-password'; import { updateProfile } from '@documenso/lib/server-only/user/update-profile'; +import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc'; import { ZConfirmEmailMutationSchema, + ZFindUserSecurityAuditLogsSchema, ZForgotPasswordFormSchema, ZResetPasswordFormSchema, ZRetrieveUserByIdQuerySchema, @@ -18,6 +21,22 @@ import { } from './schema'; export const profileRouter = router({ + findUserSecurityAuditLogs: authenticatedProcedure + .input(ZFindUserSecurityAuditLogsSchema) + .query(async ({ input, ctx }) => { + try { + return await findUserSecurityAuditLogs({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to find user security audit logs. Please try again.', + }); + } + }), + getUser: adminProcedure.input(ZRetrieveUserByIdQuerySchema).query(async ({ input }) => { try { const { id } = input; @@ -41,6 +60,7 @@ export const profileRouter = router({ userId: ctx.user.id, name, signature, + requestMetadata: extractRequestMetadata(ctx.req), }); } catch (err) { console.error(err); @@ -63,6 +83,7 @@ export const profileRouter = router({ userId: ctx.user.id, password, currentPassword, + requestMetadata: extractRequestMetadata(ctx.req), }); } catch (err) { let message = @@ -91,13 +112,14 @@ export const profileRouter = router({ } }), - resetPassword: procedure.input(ZResetPasswordFormSchema).mutation(async ({ input }) => { + resetPassword: procedure.input(ZResetPasswordFormSchema).mutation(async ({ input, ctx }) => { try { const { password, token } = input; return await resetPassword({ token, password, + requestMetadata: extractRequestMetadata(ctx.req), }); } catch (err) { let message = 'We were unable to reset your password. Please try again.'; diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index 1d6820007..522b13552 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -2,6 +2,11 @@ import { z } from 'zod'; import { ZCurrentPasswordSchema, ZPasswordSchema } from '../auth-router/schema'; +export const ZFindUserSecurityAuditLogsSchema = z.object({ + page: z.number().optional(), + perPage: z.number().optional(), +}); + export const ZRetrieveUserByIdQuerySchema = z.object({ id: z.number().min(1), }); @@ -29,6 +34,7 @@ export const ZConfirmEmailMutationSchema = z.object({ email: z.string().email().min(1), }); +export type TFindUserSecurityAuditLogsSchema = z.infer; export type TRetrieveUserByIdQuerySchema = z.infer; export type TUpdateProfileMutationSchema = z.infer; export type TUpdatePasswordMutationSchema = z.infer; diff --git a/packages/trpc/server/two-factor-authentication-router/router.ts b/packages/trpc/server/two-factor-authentication-router/router.ts index a10f7a543..b499de703 100644 --- a/packages/trpc/server/two-factor-authentication-router/router.ts +++ b/packages/trpc/server/two-factor-authentication-router/router.ts @@ -6,6 +6,7 @@ import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/en import { getBackupCodes } from '@documenso/lib/server-only/2fa/get-backup-code'; import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa'; import { compareSync } from '@documenso/lib/server-only/auth/hash'; +import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { authenticatedProcedure, router } from '../trpc'; import { @@ -23,7 +24,10 @@ export const twoFactorAuthenticationRouter = router({ const { password } = input; - return await setupTwoFactorAuthentication({ user, password }); + return await setupTwoFactorAuthentication({ + user, + password, + }); }), enable: authenticatedProcedure @@ -34,7 +38,11 @@ export const twoFactorAuthenticationRouter = router({ const { code } = input; - return await enableTwoFactorAuthentication({ user, code }); + return await enableTwoFactorAuthentication({ + user, + code, + requestMetadata: extractRequestMetadata(ctx.req), + }); } catch (err) { console.error(err); @@ -53,7 +61,12 @@ export const twoFactorAuthenticationRouter = router({ const { password, backupCode } = input; - return await disableTwoFactorAuthentication({ user, password, backupCode }); + return await disableTwoFactorAuthentication({ + user, + password, + backupCode, + requestMetadata: extractRequestMetadata(ctx.req), + }); } catch (err) { console.error(err); diff --git a/packages/ui/primitives/alert.tsx b/packages/ui/primitives/alert.tsx index 190f7781d..092fbb2b4 100644 --- a/packages/ui/primitives/alert.tsx +++ b/packages/ui/primitives/alert.tsx @@ -1,21 +1,33 @@ import * as React from 'react'; -import { VariantProps, cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; import { cn } from '../lib/utils'; const alertVariants = cva( - 'relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-11', + 'relative w-full rounded-lg p-4 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&>svg~*]:pl-8', { variants: { variant: { - default: 'bg-background text-foreground', - destructive: - 'text-destructive border-destructive/50 dark:border-destructive [&>svg]:text-destructive text-destructive', + default: + 'bg-green-50 text-green-700 [&_.alert-title]:text-green-800 [&>svg]:text-green-400', + neutral: + 'bg-gray-50 dark:bg-neutral-900/20 text-muted-foreground [&_.alert-title]:text-foreground', + secondary: 'bg-blue-50 text-blue-700 [&_.alert-title]:text-blue-800 [&>svg]:text-blue-400', + destructive: 'bg-red-50 text-red-700 [&_.alert-title]:text-red-800 [&>svg]:text-red-400', + warning: + 'bg-yellow-50 text-yellow-700 [&_.alert-title]:text-yellow-800 [&>svg]:text-yellow-400', + }, + padding: { + tighter: 'p-2', + tight: 'px-4 py-2', + default: 'p-4', }, }, defaultVariants: { variant: 'default', + padding: 'default', }, }, ); @@ -23,19 +35,20 @@ const alertVariants = cva( const Alert = React.forwardRef< HTMLDivElement, React.HTMLAttributes & VariantProps ->(({ className, variant, ...props }, ref) => ( -
    +>(({ className, variant, padding, ...props }, ref) => ( +
    )); Alert.displayName = 'Alert'; const AlertTitle = React.forwardRef>( ({ className, ...props }, ref) => ( -
    +
    ), ); @@ -45,7 +58,7 @@ const AlertDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
    +
    )); AlertDescription.displayName = 'AlertDescription'; diff --git a/packages/ui/primitives/data-table.tsx b/packages/ui/primitives/data-table.tsx index e4a89e141..9cc14a684 100644 --- a/packages/ui/primitives/data-table.tsx +++ b/packages/ui/primitives/data-table.tsx @@ -2,36 +2,53 @@ import React, { useMemo } from 'react'; -import { +import type { ColumnDef, PaginationState, Table as TTable, Updater, - flexRender, - getCoreRowModel, - useReactTable, + VisibilityState, } from '@tanstack/react-table'; +import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { Skeleton } from './skeleton'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './table'; export type DataTableChildren = (_table: TTable) => React.ReactNode; export interface DataTableProps { columns: ColumnDef[]; + columnVisibility?: VisibilityState; data: TData[]; perPage?: number; currentPage?: number; totalPages?: number; onPaginationChange?: (_page: number, _perPage: number) => void; + onClearFilters?: () => void; + hasFilters?: boolean; children?: DataTableChildren; + skeleton?: { + enable: boolean; + rows: number; + component?: React.ReactNode; + }; + error?: { + enable: boolean; + component?: React.ReactNode; + }; } export function DataTable({ columns, + columnVisibility, data, + error, perPage, currentPage, totalPages, + skeleton, + hasFilters, + onClearFilters, onPaginationChange, children, }: DataTableProps) { @@ -67,6 +84,7 @@ export function DataTable({ getCoreRowModel: getCoreRowModel(), state: { pagination: manualPagination ? pagination : undefined, + columnVisibility, }, manualPagination, pageCount: totalPages, @@ -103,10 +121,31 @@ export function DataTable({ ))} )) + ) : error?.enable ? ( + + {error.component ?? ( + + Something went wrong. + + )} + + ) : skeleton?.enable ? ( + Array.from({ length: skeleton.rows }).map((_, i) => ( + {skeleton.component ?? } + )) ) : ( - - No results. + +

    No results found

    + + {hasFilters && onClearFilters !== undefined && ( + + )}
    )} From 9427143951563b2d1c58f7aa34700566a23a3e79 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 30 Jan 2024 18:26:46 +1100 Subject: [PATCH 126/156] fix: remove account create log --- apps/web/src/pages/api/auth/[...nextauth].ts | 10 ---------- packages/lib/constants/auth.ts | 1 - .../migration.sql | 2 +- packages/prisma/schema.prisma | 1 - 4 files changed, 1 insertion(+), 13 deletions(-) rename packages/prisma/migrations/{20240130062658_add_user_security_audit_logs => 20240130072543_add_user_security_audit_logs}/migration.sql (71%) diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts index ed1809691..7666dd104 100644 --- a/apps/web/src/pages/api/auth/[...nextauth].ts +++ b/apps/web/src/pages/api/auth/[...nextauth].ts @@ -18,16 +18,6 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) { error: '/signin', }, events: { - createUser: async ({ user }) => { - await prisma.userSecurityAuditLog.create({ - data: { - userId: user.id, - ipAddress, - userAgent, - type: UserSecurityAuditLogType.ACCOUNT_CREATE, - }, - }); - }, signIn: async ({ user }) => { await prisma.userSecurityAuditLog.create({ data: { diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index 48f1e9d7b..54fc9f6a8 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -12,7 +12,6 @@ export const IS_GOOGLE_SSO_ENABLED = Boolean( ); export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: string } = { - [UserSecurityAuditLogType.ACCOUNT_CREATE]: 'Account created', [UserSecurityAuditLogType.ACCOUNT_SSO_LINK]: 'Linked account to SSO', [UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated', [UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled', diff --git a/packages/prisma/migrations/20240130062658_add_user_security_audit_logs/migration.sql b/packages/prisma/migrations/20240130072543_add_user_security_audit_logs/migration.sql similarity index 71% rename from packages/prisma/migrations/20240130062658_add_user_security_audit_logs/migration.sql rename to packages/prisma/migrations/20240130072543_add_user_security_audit_logs/migration.sql index 73d85ada8..b33a614ac 100644 --- a/packages/prisma/migrations/20240130062658_add_user_security_audit_logs/migration.sql +++ b/packages/prisma/migrations/20240130072543_add_user_security_audit_logs/migration.sql @@ -1,5 +1,5 @@ -- CreateEnum -CREATE TYPE "UserSecurityAuditLogType" AS ENUM ('ACCOUNT_CREATE', 'ACCOUNT_PROFILE_UPDATE', 'ACCOUNT_SSO_LINK', 'AUTH_2FA_DISABLE', 'AUTH_2FA_ENABLE', 'PASSWORD_RESET', 'PASSWORD_UPDATE', 'SIGN_OUT', 'SIGN_IN'); +CREATE TYPE "UserSecurityAuditLogType" AS ENUM ('ACCOUNT_PROFILE_UPDATE', 'ACCOUNT_SSO_LINK', 'AUTH_2FA_DISABLE', 'AUTH_2FA_ENABLE', 'PASSWORD_RESET', 'PASSWORD_UPDATE', 'SIGN_OUT', 'SIGN_IN'); -- CreateTable CREATE TABLE "UserSecurityAuditLog" ( diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 8f83f0ac3..9c41cfb30 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -49,7 +49,6 @@ model User { } enum UserSecurityAuditLogType { - ACCOUNT_CREATE ACCOUNT_PROFILE_UPDATE ACCOUNT_SSO_LINK AUTH_2FA_DISABLE From 1bda74b3aa38d74e5d4faf6e6ff1bfa8fa7a9757 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 30 Jan 2024 18:37:48 +1100 Subject: [PATCH 127/156] fix: add cascade delete for audit logs --- .../migration.sql | 2 +- packages/prisma/schema.prisma | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename packages/prisma/migrations/{20240130072543_add_user_security_audit_logs => 20240130073345_add_user_security_audit_logs}/migration.sql (94%) diff --git a/packages/prisma/migrations/20240130072543_add_user_security_audit_logs/migration.sql b/packages/prisma/migrations/20240130073345_add_user_security_audit_logs/migration.sql similarity index 94% rename from packages/prisma/migrations/20240130072543_add_user_security_audit_logs/migration.sql rename to packages/prisma/migrations/20240130073345_add_user_security_audit_logs/migration.sql index b33a614ac..da643eee3 100644 --- a/packages/prisma/migrations/20240130072543_add_user_security_audit_logs/migration.sql +++ b/packages/prisma/migrations/20240130073345_add_user_security_audit_logs/migration.sql @@ -14,4 +14,4 @@ CREATE TABLE "UserSecurityAuditLog" ( ); -- AddForeignKey -ALTER TABLE "UserSecurityAuditLog" ADD CONSTRAINT "UserSecurityAuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "UserSecurityAuditLog" ADD CONSTRAINT "UserSecurityAuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 9c41cfb30..596013a85 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -67,7 +67,7 @@ model UserSecurityAuditLog { userAgent String? ipAddress String? - User User @relation(fields: [userId], references: [id]) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) } model PasswordResetToken { From ada46a5f47e764ba523e8cb55f04d9dbae36bfcd Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 31 Jan 2024 12:27:40 +1100 Subject: [PATCH 128/156] feat: add auth fail logs --- apps/web/src/pages/api/auth/[...nextauth].ts | 4 ++-- packages/lib/constants/auth.ts | 4 +++- packages/lib/next-auth/auth-options.ts | 24 +++++++++++++++++-- .../lib/universal/extract-request-metadata.ts | 17 ++++++++++++- .../migration.sql | 2 +- packages/prisma/schema.prisma | 2 ++ packages/trpc/server/profile-router/router.ts | 8 +++---- .../router.ts | 6 ++--- 8 files changed, 53 insertions(+), 14 deletions(-) rename packages/prisma/migrations/{20240130073345_add_user_security_audit_logs => 20240131004516_add_user_security_audit_logs}/migration.sql (86%) diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts index 7666dd104..365b6ec40 100644 --- a/apps/web/src/pages/api/auth/[...nextauth].ts +++ b/apps/web/src/pages/api/auth/[...nextauth].ts @@ -3,12 +3,12 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import NextAuth from 'next-auth'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; -import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { prisma } from '@documenso/prisma'; import { UserSecurityAuditLogType } from '@documenso/prisma/client'; export default async function auth(req: NextApiRequest, res: NextApiResponse) { - const { ipAddress, userAgent } = extractRequestMetadata(req); + const { ipAddress, userAgent } = extractNextApiRequestMetadata(req); return await NextAuth(req, res, { ...NEXT_AUTH_OPTIONS, diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index 54fc9f6a8..1918e2db0 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -18,6 +18,8 @@ export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: s [UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled', [UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset', [UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated', - [UserSecurityAuditLogType.SIGN_IN]: 'Signed In', [UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out', + [UserSecurityAuditLogType.SIGN_IN]: 'Signed In', + [UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed', + [UserSecurityAuditLogType.SIGN_IN_2FA_FAIL]: 'Sign in 2FA attempt failed', }; diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 9babae987..f23295a81 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -9,11 +9,12 @@ import type { GoogleProfile } from 'next-auth/providers/google'; import GoogleProvider from 'next-auth/providers/google'; import { prisma } from '@documenso/prisma'; -import { IdentityProvider } from '@documenso/prisma/client'; +import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client'; import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble'; import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa'; import { getUserByEmail } from '../server-only/user/get-user-by-email'; +import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata'; import { ErrorCode } from './error-codes'; export const NEXT_AUTH_OPTIONS: AuthOptions = { @@ -35,7 +36,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { }, backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' }, }, - authorize: async (credentials, _req) => { + authorize: async (credentials, req) => { if (!credentials) { throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND); } @@ -51,8 +52,18 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { } const isPasswordsSame = await compare(password, user.password); + const requestMetadata = extractNextAuthRequestMetadata(req); if (!isPasswordsSame) { + await prisma.userSecurityAuditLog.create({ + data: { + userId: user.id, + ipAddress: requestMetadata.ipAddress, + userAgent: requestMetadata.userAgent, + type: UserSecurityAuditLogType.SIGN_IN_FAIL, + }, + }); + throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD); } @@ -62,6 +73,15 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user }); if (!isValid) { + await prisma.userSecurityAuditLog.create({ + data: { + userId: user.id, + ipAddress: requestMetadata.ipAddress, + userAgent: requestMetadata.userAgent, + type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL, + }, + }); + throw new Error( totpCode ? ErrorCode.INCORRECT_TWO_FACTOR_CODE diff --git a/packages/lib/universal/extract-request-metadata.ts b/packages/lib/universal/extract-request-metadata.ts index ceb4ad35f..5549e5de7 100644 --- a/packages/lib/universal/extract-request-metadata.ts +++ b/packages/lib/universal/extract-request-metadata.ts @@ -1,5 +1,6 @@ import type { NextApiRequest } from 'next'; +import type { RequestInternal } from 'next-auth'; import { z } from 'zod'; const ZIpSchema = z.string().ip(); @@ -9,7 +10,7 @@ export type RequestMetadata = { userAgent?: string; }; -export const extractRequestMetadata = (req: NextApiRequest): RequestMetadata => { +export const extractNextApiRequestMetadata = (req: NextApiRequest): RequestMetadata => { const parsedIp = ZIpSchema.safeParse(req.headers['x-forwarded-for'] || req.socket.remoteAddress); const ipAddress = parsedIp.success ? parsedIp.data : undefined; @@ -20,3 +21,17 @@ export const extractRequestMetadata = (req: NextApiRequest): RequestMetadata => userAgent, }; }; + +export const extractNextAuthRequestMetadata = ( + req: Pick, +): RequestMetadata => { + const parsedIp = ZIpSchema.safeParse(req.headers?.['x-forwarded-for']); + + const ipAddress = parsedIp.success ? parsedIp.data : undefined; + const userAgent = req.headers?.['user-agent']; + + return { + ipAddress, + userAgent, + }; +}; diff --git a/packages/prisma/migrations/20240130073345_add_user_security_audit_logs/migration.sql b/packages/prisma/migrations/20240131004516_add_user_security_audit_logs/migration.sql similarity index 86% rename from packages/prisma/migrations/20240130073345_add_user_security_audit_logs/migration.sql rename to packages/prisma/migrations/20240131004516_add_user_security_audit_logs/migration.sql index da643eee3..491012380 100644 --- a/packages/prisma/migrations/20240130073345_add_user_security_audit_logs/migration.sql +++ b/packages/prisma/migrations/20240131004516_add_user_security_audit_logs/migration.sql @@ -1,5 +1,5 @@ -- CreateEnum -CREATE TYPE "UserSecurityAuditLogType" AS ENUM ('ACCOUNT_PROFILE_UPDATE', 'ACCOUNT_SSO_LINK', 'AUTH_2FA_DISABLE', 'AUTH_2FA_ENABLE', 'PASSWORD_RESET', 'PASSWORD_UPDATE', 'SIGN_OUT', 'SIGN_IN'); +CREATE TYPE "UserSecurityAuditLogType" AS ENUM ('ACCOUNT_PROFILE_UPDATE', 'ACCOUNT_SSO_LINK', 'AUTH_2FA_DISABLE', 'AUTH_2FA_ENABLE', 'PASSWORD_RESET', 'PASSWORD_UPDATE', 'SIGN_OUT', 'SIGN_IN', 'SIGN_IN_FAIL', 'SIGN_IN_2FA_FAIL'); -- CreateTable CREATE TABLE "UserSecurityAuditLog" ( diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 596013a85..353a855ae 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -57,6 +57,8 @@ enum UserSecurityAuditLogType { PASSWORD_UPDATE SIGN_OUT SIGN_IN + SIGN_IN_FAIL + SIGN_IN_2FA_FAIL } model UserSecurityAuditLog { diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index c595c628c..4a0d47345 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -7,7 +7,7 @@ import { resetPassword } from '@documenso/lib/server-only/user/reset-password'; import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token'; import { updatePassword } from '@documenso/lib/server-only/user/update-password'; import { updateProfile } from '@documenso/lib/server-only/user/update-profile'; -import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc'; import { @@ -60,7 +60,7 @@ export const profileRouter = router({ userId: ctx.user.id, name, signature, - requestMetadata: extractRequestMetadata(ctx.req), + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); @@ -83,7 +83,7 @@ export const profileRouter = router({ userId: ctx.user.id, password, currentPassword, - requestMetadata: extractRequestMetadata(ctx.req), + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { let message = @@ -119,7 +119,7 @@ export const profileRouter = router({ return await resetPassword({ token, password, - requestMetadata: extractRequestMetadata(ctx.req), + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { let message = 'We were unable to reset your password. Please try again.'; diff --git a/packages/trpc/server/two-factor-authentication-router/router.ts b/packages/trpc/server/two-factor-authentication-router/router.ts index b499de703..36fe93a60 100644 --- a/packages/trpc/server/two-factor-authentication-router/router.ts +++ b/packages/trpc/server/two-factor-authentication-router/router.ts @@ -6,7 +6,7 @@ import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/en import { getBackupCodes } from '@documenso/lib/server-only/2fa/get-backup-code'; import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa'; import { compareSync } from '@documenso/lib/server-only/auth/hash'; -import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { authenticatedProcedure, router } from '../trpc'; import { @@ -41,7 +41,7 @@ export const twoFactorAuthenticationRouter = router({ return await enableTwoFactorAuthentication({ user, code, - requestMetadata: extractRequestMetadata(ctx.req), + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); @@ -65,7 +65,7 @@ export const twoFactorAuthenticationRouter = router({ user, password, backupCode, - requestMetadata: extractRequestMetadata(ctx.req), + requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { console.error(err); From 27d8098511e4d62f01f1ad403ead38ee88b47616 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 31 Jan 2024 12:40:37 +1100 Subject: [PATCH 129/156] fix: document count period filter (#882) ## Description Currently the count for the documents table tabs do not display the correct values when the period filter is applied. ## Changes Made - Updated `getStats` to support filtering on period ## Testing Performed - Tested to see if the documents tab count were being filtered based on the period ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have followed the project's coding style guidelines. --- .../src/app/(dashboard)/documents/page.tsx | 11 +++++---- .../(dashboard)/period-selector/types.ts | 2 +- .../server-only/document/find-documents.ts | 4 +++- .../lib/server-only/document/get-stats.ts | 24 +++++++++++++++++-- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index a15d65306..e61aad649 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { getStats } from '@documenso/lib/server-only/document/get-stats'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; @@ -9,7 +10,6 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector'; -import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types'; import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types'; import { DocumentStatus } from '~/components/formatter/document-status'; @@ -32,15 +32,16 @@ export const metadata: Metadata = { export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) { const { user } = await getRequiredServerComponentSession(); - const stats = await getStats({ - user, - }); - const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL'; const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : ''; const page = Number(searchParams.page) || 1; const perPage = Number(searchParams.perPage) || 20; + const stats = await getStats({ + user, + period, + }); + const results = await findDocuments({ userId: user.id, status, diff --git a/apps/web/src/components/(dashboard)/period-selector/types.ts b/apps/web/src/components/(dashboard)/period-selector/types.ts index 2b50f5d6c..8ae1c5fbe 100644 --- a/apps/web/src/components/(dashboard)/period-selector/types.ts +++ b/apps/web/src/components/(dashboard)/period-selector/types.ts @@ -1,4 +1,4 @@ -export type PeriodSelectorValue = '' | '7d' | '14d' | '30d'; +import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index def85f2d4..2929c515b 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -9,6 +9,8 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen import type { FindResultSet } from '../../types/find-result-set'; import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document'; +export type PeriodSelectorValue = '' | '7d' | '14d' | '30d'; + export type FindDocumentsOptions = { userId: number; term?: string; @@ -19,7 +21,7 @@ export type FindDocumentsOptions = { column: keyof Omit; direction: 'asc' | 'desc'; }; - period?: '' | '7d' | '14d' | '30d'; + period?: PeriodSelectorValue; }; export const findDocuments = async ({ diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index 044d9a2dc..6aaa9a596 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -1,14 +1,31 @@ +import { DateTime } from 'luxon'; + import { prisma } from '@documenso/prisma'; -import type { User } from '@documenso/prisma/client'; +import type { Prisma, User } from '@documenso/prisma/client'; import { SigningStatus } from '@documenso/prisma/client'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; +import type { PeriodSelectorValue } from './find-documents'; + export type GetStatsInput = { user: User; + period?: PeriodSelectorValue; }; -export const getStats = async ({ user }: GetStatsInput) => { +export const getStats = async ({ user, period }: GetStatsInput) => { + let createdAt: Prisma.DocumentWhereInput['createdAt']; + + if (period) { + const daysAgo = parseInt(period.replace(/d$/, ''), 10); + + const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); + + createdAt = { + gte: startOfPeriod.toJSDate(), + }; + } + const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([ prisma.document.groupBy({ by: ['status'], @@ -17,6 +34,7 @@ export const getStats = async ({ user }: GetStatsInput) => { }, where: { userId: user.id, + createdAt, deletedAt: null, }, }), @@ -33,6 +51,7 @@ export const getStats = async ({ user }: GetStatsInput) => { signingStatus: SigningStatus.NOT_SIGNED, }, }, + createdAt, deletedAt: null, }, }), @@ -42,6 +61,7 @@ export const getStats = async ({ user }: GetStatsInput) => { _all: true, }, where: { + createdAt, User: { email: { not: user.email, From 7fbf124b895de5a120c675e4d66f8277551d6aaf Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 1 Feb 2024 01:10:50 +0000 Subject: [PATCH 130/156] fix: use div instead of rnd for preview fields --- .../documents/[id]/edit-document.tsx | 2 +- .../ui/primitives/document-flow/add-title.tsx | 9 ++- .../document-flow/show-field-item.tsx | 62 +++---------------- 3 files changed, 18 insertions(+), 55 deletions(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 2159b87f2..af1877a64 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -218,9 +218,9 @@ export const EditDocumentForm = ({ diff --git a/packages/ui/primitives/document-flow/add-title.tsx b/packages/ui/primitives/document-flow/add-title.tsx index afce0d9e0..730c4248f 100644 --- a/packages/ui/primitives/document-flow/add-title.tsx +++ b/packages/ui/primitives/document-flow/add-title.tsx @@ -17,6 +17,7 @@ import { DocumentFlowFormContainerHeader, DocumentFlowFormContainerStep, } from './document-flow-root'; +import { ShowFieldItem } from './show-field-item'; import type { DocumentFlowStep } from './types'; export type AddTitleFormProps = { @@ -29,8 +30,8 @@ export type AddTitleFormProps = { export const AddTitleFormPartial = ({ documentFlow, - recipients: _recipients, - fields: _fields, + recipients, + fields, document, onSubmit, }: AddTitleFormProps) => { @@ -55,6 +56,10 @@ export const AddTitleFormPartial = ({ description={documentFlow.description} /> + {fields.map((field, index) => ( + + ))} +
    diff --git a/packages/ui/primitives/document-flow/show-field-item.tsx b/packages/ui/primitives/document-flow/show-field-item.tsx index 7aee9c602..4e4a0dc99 100644 --- a/packages/ui/primitives/document-flow/show-field-item.tsx +++ b/packages/ui/primitives/document-flow/show-field-item.tsx @@ -1,12 +1,9 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; - import type { Prisma } from '@prisma/client'; import { createPortal } from 'react-dom'; -import { Rnd } from 'react-rnd'; -import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords'; import { cn } from '../../lib/utils'; import { Card, CardContent } from '../card'; @@ -18,59 +15,20 @@ export type ShowFieldItemProps = { }; export const ShowFieldItem = ({ field, recipients }: ShowFieldItemProps) => { - const [coords, setCoords] = useState({ - pageX: Number(field.positionX), - pageY: Number(field.positionY), - pageHeight: Number(field.height), - pageWidth: Number(field.width), - }); + const coords = useFieldPageCoords(field); const signerEmail = recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? ''; - const calculateCoords = useCallback(() => { - const $page = document.querySelector( - `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`, - ); - - if (!$page) { - return; - } - - const { height, width } = $page.getBoundingClientRect(); - - const top = $page.getBoundingClientRect().top + window.scrollY; - const left = $page.getBoundingClientRect().left + window.scrollX; - - const pageX = (Number(field.positionX) / 100) * width + left; - const pageY = (Number(field.positionY) / 100) * height + top; - - const pageHeight = (Number(field.height) / 100) * height; - const pageWidth = (Number(field.width) / 100) * width; - - setCoords({ - pageX: pageX, - pageY: pageY, - pageHeight: pageHeight, - pageWidth: pageWidth, - }); - }, [field.page, field.positionX, field.positionY, field.height, field.width]); - - useEffect(() => { - calculateCoords(); - }, [calculateCoords]); - return createPortal( - {

    -
    , +
    , document.body, ); }; From 56683aa99897f098cc7d536d4fe73adccc0363e3 Mon Sep 17 00:00:00 2001 From: Apoorv Taneja Date: Thu, 1 Feb 2024 13:44:37 +0530 Subject: [PATCH 131/156] fix: Added signing pad disable state while submitting form (#892) Fixes : #891 --- apps/marketing/src/components/(marketing)/widget.tsx | 1 + apps/web/src/app/(signing)/sign/[token]/form.tsx | 1 + apps/web/src/components/forms/profile.tsx | 6 ++---- apps/web/src/components/forms/signup.tsx | 1 + packages/ui/primitives/signature-pad/signature-pad.tsx | 8 +++++++- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/marketing/src/components/(marketing)/widget.tsx b/apps/marketing/src/components/(marketing)/widget.tsx index 80c13b275..d4305a04c 100644 --- a/apps/marketing/src/components/(marketing)/widget.tsx +++ b/apps/marketing/src/components/(marketing)/widget.tsx @@ -399,6 +399,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { { setSignature(value); diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 7036f4e43..2c278292f 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -121,10 +121,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { onChange(v ?? '')} /> diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index ebfbf72c9..f38ab15d1 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -172,6 +172,7 @@ export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) = onChange(v ?? '')} /> diff --git a/packages/ui/primitives/signature-pad/signature-pad.tsx b/packages/ui/primitives/signature-pad/signature-pad.tsx index 80bac0e18..eb9403df4 100644 --- a/packages/ui/primitives/signature-pad/signature-pad.tsx +++ b/packages/ui/primitives/signature-pad/signature-pad.tsx @@ -16,6 +16,7 @@ const DPI = 2; export type SignaturePadProps = Omit, 'onChange'> & { onChange?: (_signatureDataUrl: string | null) => void; containerClassName?: string; + disabled?: boolean; }; export const SignaturePad = ({ @@ -23,6 +24,7 @@ export const SignaturePad = ({ containerClassName, defaultValue, onChange, + disabled = false, ...props }: SignaturePadProps) => { const $el = useRef(null); @@ -214,7 +216,11 @@ export const SignaturePad = ({ }, [defaultValue]); return ( -
    +
    Date: Fri, 2 Feb 2024 03:00:02 +0530 Subject: [PATCH 132/156] fix: active-tab changes correctly (#897) fixes: #890 --- apps/web/src/app/(dashboard)/documents/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index e61aad649..5780df1dc 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -74,7 +74,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage

    Documents

    - + {[ ExtendedDocumentStatus.INBOX, From 7ece6ef239a3efc12efe102b5f76b33d7bac458b Mon Sep 17 00:00:00 2001 From: Hani Date: Thu, 1 Feb 2024 18:45:02 -0500 Subject: [PATCH 133/156] feat: add recipient roles (#716) Fixes #705 --------- Co-authored-by: Lucas Smith Co-authored-by: David Nguyen --- apps/marketing/content/blog/linear-gh.mdx | 2 +- .../app/(marketing)/singleplayer/client.tsx | 1 + .../documents/data-table-action-button.tsx | 35 +++- .../documents/data-table-action-dropdown.tsx | 36 +++- .../(signing)/sign/[token]/complete/page.tsx | 7 +- .../src/app/(signing)/sign/[token]/form.tsx | 158 +++++++++++------- .../src/app/(signing)/sign/[token]/page.tsx | 7 +- .../(signing)/sign/[token]/sign-dialog.tsx | 20 ++- .../avatar/avatar-with-recipient.tsx | 14 +- .../avatar/stack-avatars-with-tooltip.tsx | 8 +- .../template-document-invite.tsx | 13 +- packages/email/templates/document-invite.tsx | 11 +- packages/lib/client-only/recipient-type.ts | 6 +- packages/lib/constants/recipient-roles.ts | 26 +++ .../server-only/document/find-documents.ts | 8 +- .../server-only/document/resend-document.tsx | 13 +- .../lib/server-only/document/seal-document.ts | 5 +- .../server-only/document/send-document.tsx | 17 +- .../recipient/set-recipients-for-document.ts | 9 + .../migration.sql | 5 + packages/prisma/schema.prisma | 8 + .../trpc/server/document-router/schema.ts | 3 +- .../trpc/server/recipient-router/router.ts | 1 + .../trpc/server/recipient-router/schema.ts | 3 + .../primitives/document-flow/add-fields.tsx | 145 ++++++++++------ .../primitives/document-flow/add-signers.tsx | 57 ++++++- .../document-flow/add-signers.types.ts | 3 + .../primitives/document-flow/add-subject.tsx | 1 - 28 files changed, 466 insertions(+), 156 deletions(-) create mode 100644 packages/lib/constants/recipient-roles.ts create mode 100644 packages/prisma/migrations/20231202220928_add_recipient_roles/migration.sql diff --git a/apps/marketing/content/blog/linear-gh.mdx b/apps/marketing/content/blog/linear-gh.mdx index 27b1ae208..1267931d6 100644 --- a/apps/marketing/content/blog/linear-gh.mdx +++ b/apps/marketing/content/blog/linear-gh.mdx @@ -109,7 +109,7 @@ It's similar to the Kanban board for the development backlog. While the internal design backlog also existed in Linear, the public design repository is new. Since designing in the open is tricky, we opted to publish the detailed design artifacts with the corresponding feature instead. We already have design.documenso.com housing our general design system. Here, we will publish the specifics of how we applied this to each feature. We will publish the first artifacts here soon, what may be in the cards can be found on the [LIVE Roadmap](https://documen.so/live). -Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :) +Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :) Best from Hamburg\ Timur diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index 389528bf8..a1b56257a 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -158,6 +158,7 @@ export const SinglePlayerClient = () => { readStatus: 'OPENED', signingStatus: 'NOT_SIGNED', sendStatus: 'NOT_SENT', + role: 'SIGNER', }; const onFileDrop = async (file: File) => { diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index 9910ef111..ecddf1190 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -2,13 +2,13 @@ import Link from 'next/link'; -import { Download, Edit, Pencil } from 'lucide-react'; +import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { match } from 'ts-pattern'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import type { Document, Recipient, User } from '@documenso/prisma/client'; -import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { Button } from '@documenso/ui/primitives/button'; @@ -37,6 +37,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const isPending = row.status === DocumentStatus.PENDING; const isComplete = row.status === DocumentStatus.COMPLETED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; + const role = recipient?.role; const onDownloadClick = async () => { try { @@ -68,6 +69,11 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { } }; + // TODO: Consider if want to keep this logic for hiding viewing for CC'ers + if (recipient?.role === RecipientRole.CC && isComplete === false) { + return null; + } + return match({ isOwner, isRecipient, @@ -87,15 +93,32 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { .with({ isRecipient: true, isPending: true, isSigned: false }, () => ( )) .with({ isPending: true, isSigned: true }, () => ( )) .with({ isComplete: true }, () => ( diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index f14321b35..e1d9b64bb 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -5,9 +5,11 @@ import { useState } from 'react'; import Link from 'next/link'; import { + CheckCircle, Copy, Download, Edit, + EyeIcon, Loader, MoreHorizontal, Pencil, @@ -19,7 +21,7 @@ import { useSession } from 'next-auth/react'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import type { Document, Recipient, User } from '@documenso/prisma/client'; -import { DocumentStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; @@ -105,12 +107,32 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = Action - - - - Sign - - + {recipient?.role !== RecipientRole.CC && ( + + + {recipient?.role === RecipientRole.VIEWER && ( + <> + + View + + )} + + {recipient?.role === RecipientRole.SIGNER && ( + <> + + Sign + + )} + + {recipient?.role === RecipientRole.APPROVER && ( + <> + + Approve + + )} + + + )} diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx index 3d5814113..a64831804 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -10,7 +10,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; -import { DocumentStatus, FieldType } from '@documenso/prisma/client'; +import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { SigningCard3D } from '@documenso/ui/components/signing-card'; @@ -94,7 +94,10 @@ export default async function CompletedSigningPage({ ))}

    - You have signed + You have + {recipient.role === RecipientRole.SIGNER && ' signed '} + {recipient.role === RecipientRole.VIEWER && ' viewed '} + {recipient.role === RecipientRole.APPROVER && ' approved '} "{truncatedTitle}"

    diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 65dab5e61..7105baafd 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; -import type { Document, Field, Recipient } from '@documenso/prisma/client'; +import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { cn } from '@documenso/ui/lib/utils'; @@ -96,74 +96,114 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
    -
    -

    Sign Document

    +
    +

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

    -

    - Please review the document before signing. -

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

    + Please mark as viewed to complete +

    -
    +
    -
    -
    -
    - +
    +
    +
    + - setFullName(e.target.value.trimStart())} - /> + +
    + + ) : ( + <> +

    + Please review the document before signing. +

    -
    - +
    - - - { - setSignature(value); - }} +
    +
    +
    + + + setFullName(e.target.value.trimStart())} /> - - +
    + +
    + + + + + { + setSignature(value); + }} + /> + + +
    +
    + +
    + + + +
    -
    - -
    - - - -
    -
    + + )}
    diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 004c59329..7e025593c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -14,7 +14,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; -import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; @@ -110,7 +110,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp

    - {document.User.name} ({document.User.email}) has invited you to sign this document. + {document.User.name} ({document.User.email}) has invited you to{' '} + {recipient.role === RecipientRole.VIEWER && 'view'} + {recipient.role === RecipientRole.SIGNER && 'sign'} + {recipient.role === RecipientRole.APPROVER && 'approve'} this document.

    diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx index 1e86e99bc..a9aedbc3d 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import type { Document, Field } from '@documenso/prisma/client'; +import { RecipientRole } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -17,6 +18,7 @@ export type SignDialogProps = { fields: Field[]; fieldsValidated: () => void | Promise; onSignatureComplete: () => void | Promise; + role: RecipientRole; }; export const SignDialog = ({ @@ -25,6 +27,7 @@ export const SignDialog = ({ fields, fieldsValidated, onSignatureComplete, + role, }: SignDialogProps) => { const [showDialog, setShowDialog] = useState(false); const truncatedTitle = truncateTitle(document.title); @@ -45,9 +48,18 @@ export const SignDialog = ({
    -
    Sign Document
    +
    + {role === RecipientRole.VIEWER && 'Mark Document as Viewed'} + {role === RecipientRole.SIGNER && 'Sign Document'} + {role === RecipientRole.APPROVER && 'Approve Document'} +
    - You are about to finish signing "{truncatedTitle}". Are you sure? + {role === RecipientRole.VIEWER && + `You are about to finish viewing "${truncatedTitle}". Are you sure?`} + {role === RecipientRole.SIGNER && + `You are about to finish signing "${truncatedTitle}". Are you sure?`} + {role === RecipientRole.APPROVER && + `You are about to finish approving "${truncatedTitle}". Are you sure?`}
    @@ -71,7 +83,9 @@ export const SignDialog = ({ loading={isSubmitting} onClick={onSignatureComplete} > - Sign + {role === RecipientRole.VIEWER && 'Mark as Viewed'} + {role === RecipientRole.SIGNER && 'Sign'} + {role === RecipientRole.APPROVER && 'Approve'}
    diff --git a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx index d04b3a998..46182c36e 100644 --- a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx +++ b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { Recipient } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; @@ -47,8 +48,17 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) { type={getRecipientType(recipient)} fallbackText={recipientAbbreviation(recipient)} /> - - {recipient.email} +
    +
    +

    {recipient.email}

    +

    + {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

    +
    +
    ); } diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx index 7429d8ee5..bd7bea2b0 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -1,4 +1,5 @@ import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { Recipient } from '@documenso/prisma/client'; import { @@ -59,7 +60,12 @@ export const StackAvatarsWithTooltip = ({ type={getRecipientType(recipient)} fallbackText={recipientAbbreviation(recipient)} /> - {recipient.email} +
    +

    {recipient.email}

    +

    + {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

    +
    ))}
    diff --git a/packages/email/template-components/template-document-invite.tsx b/packages/email/template-components/template-document-invite.tsx index 216a3183d..b958e9029 100644 --- a/packages/email/template-components/template-document-invite.tsx +++ b/packages/email/template-components/template-document-invite.tsx @@ -1,3 +1,6 @@ +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import type { RecipientRole } from '@documenso/prisma/client'; + import { Button, Section, Text } from '../components'; import { TemplateDocumentImage } from './template-document-image'; @@ -7,6 +10,7 @@ export interface TemplateDocumentInviteProps { documentName: string; signDocumentLink: string; assetBaseUrl: string; + role: RecipientRole; } export const TemplateDocumentInvite = ({ @@ -14,19 +18,22 @@ export const TemplateDocumentInvite = ({ documentName, signDocumentLink, assetBaseUrl, + role, }: TemplateDocumentInviteProps) => { + const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role]; + return ( <>
    - {inviterName} has invited you to sign + {inviterName} has invited you to {actionVerb.toLowerCase()}
    "{documentName}"
    - Continue by signing the document. + Continue by {progressiveVerb.toLowerCase()} the document.
    @@ -34,7 +41,7 @@ export const TemplateDocumentInvite = ({ className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline" href={signDocumentLink} > - Sign Document + {actionVerb} Document
    diff --git a/packages/email/templates/document-invite.tsx b/packages/email/templates/document-invite.tsx index d6a45d5fc..d3bceb872 100644 --- a/packages/email/templates/document-invite.tsx +++ b/packages/email/templates/document-invite.tsx @@ -1,3 +1,5 @@ +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import type { RecipientRole } from '@documenso/prisma/client'; import config from '@documenso/tailwind-config'; import { @@ -19,6 +21,7 @@ import { TemplateFooter } from '../template-components/template-footer'; export type DocumentInviteEmailTemplateProps = Partial & { customBody?: string; + role: RecipientRole; }; export const DocumentInviteEmailTemplate = ({ @@ -28,8 +31,11 @@ export const DocumentInviteEmailTemplate = ({ signDocumentLink = 'https://documenso.com', assetBaseUrl = 'http://localhost:3002', customBody, + role, }: DocumentInviteEmailTemplateProps) => { - const previewText = `${inviterName} has invited you to sign ${documentName}`; + const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase(); + + const previewText = `${inviterName} has invited you to ${action} ${documentName}`; const getAssetUrl = (path: string) => { return new URL(path, assetBaseUrl).toString(); @@ -64,6 +70,7 @@ export const DocumentInviteEmailTemplate = ({ documentName={documentName} signDocumentLink={signDocumentLink} assetBaseUrl={assetBaseUrl} + role={role} /> @@ -81,7 +88,7 @@ export const DocumentInviteEmailTemplate = ({ {customBody ? (
    {customBody}
    ) : ( - `${inviterName} has invited you to sign the document "${documentName}".` + `${inviterName} has invited you to ${action} the document "${documentName}".` )} diff --git a/packages/lib/client-only/recipient-type.ts b/packages/lib/client-only/recipient-type.ts index 8b5a8a528..44993796a 100644 --- a/packages/lib/client-only/recipient-type.ts +++ b/packages/lib/client-only/recipient-type.ts @@ -1,10 +1,10 @@ import type { Recipient } from '@documenso/prisma/client'; -import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client'; +import { ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client'; export const getRecipientType = (recipient: Recipient) => { if ( - recipient.sendStatus === SendStatus.SENT && - recipient.signingStatus === SigningStatus.SIGNED + recipient.role === RecipientRole.CC || + (recipient.sendStatus === SendStatus.SENT && recipient.signingStatus === SigningStatus.SIGNED) ) { return 'completed'; } diff --git a/packages/lib/constants/recipient-roles.ts b/packages/lib/constants/recipient-roles.ts new file mode 100644 index 000000000..920cf1f32 --- /dev/null +++ b/packages/lib/constants/recipient-roles.ts @@ -0,0 +1,26 @@ +import { RecipientRole } from '@documenso/prisma/client'; + +export const RECIPIENT_ROLES_DESCRIPTION: { + [key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string }; +} = { + [RecipientRole.APPROVER]: { + actionVerb: 'Approve', + progressiveVerb: 'Approving', + roleName: 'Approver', + }, + [RecipientRole.CC]: { + actionVerb: 'CC', + progressiveVerb: 'CC', + roleName: 'CC', + }, + [RecipientRole.SIGNER]: { + actionVerb: 'Sign', + progressiveVerb: 'Signing', + roleName: 'Signer', + }, + [RecipientRole.VIEWER]: { + actionVerb: 'View', + progressiveVerb: 'Viewing', + roleName: 'Viewer', + }, +}; diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 2929c515b..8d367dbe4 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -3,7 +3,7 @@ import { P, match } from 'ts-pattern'; import { prisma } from '@documenso/prisma'; import type { Document, Prisma } from '@documenso/prisma/client'; -import { SigningStatus } from '@documenso/prisma/client'; +import { RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { FindResultSet } from '../../types/find-result-set'; @@ -87,6 +87,9 @@ export const findDocuments = async ({ some: { email: user.email, signingStatus: SigningStatus.NOT_SIGNED, + role: { + not: RecipientRole.CC, + }, }, }, deletedAt: null, @@ -109,6 +112,9 @@ export const findDocuments = async ({ some: { email: user.email, signingStatus: SigningStatus.SIGNED, + role: { + not: RecipientRole.CC, + }, }, }, deletedAt: null, diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx index da4ffcb58..4c7b66be8 100644 --- a/packages/lib/server-only/document/resend-document.tsx +++ b/packages/lib/server-only/document/resend-document.tsx @@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { prisma } from '@documenso/prisma'; -import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; + +import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles'; export type ResendDocumentOptions = { documentId: number; @@ -59,6 +61,10 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD await Promise.all( document.Recipient.map(async (recipient) => { + if (recipient.role === RecipientRole.CC) { + return; + } + const { email, name } = recipient; const customEmailTemplate = { @@ -77,8 +83,11 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD assetBaseUrl, signDocumentLink, customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate), + role: recipient.role, }); + const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; + await mailer.sendMail({ to: { address: email, @@ -90,7 +99,7 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD }, subject: customEmail?.subject ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) - : 'Please sign this document', + : `Please ${actionVerb.toLowerCase()} this document`, html: render(template), text: render(template, { plainText: true }), }); diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index 5fa4b1a00..b24288c3e 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -6,7 +6,7 @@ import { PDFDocument } from 'pdf-lib'; import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; import { prisma } from '@documenso/prisma'; -import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { signPdf } from '@documenso/signing'; import { getFile } from '../../universal/upload/get-file'; @@ -44,6 +44,9 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen const recipients = await prisma.recipient.findMany({ where: { documentId: document.id, + role: { + not: RecipientRole.CC, + }, }, }); diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 25dc132ba..82b37852b 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { prisma } from '@documenso/prisma'; -import { DocumentStatus, SendStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client'; + +import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles'; export type SendDocumentOptions = { documentId: number; @@ -47,6 +49,10 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) await Promise.all( document.Recipient.map(async (recipient) => { + if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) { + return; + } + const { email, name } = recipient; const customEmailTemplate = { @@ -55,10 +61,6 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) 'document.name': document.title, }; - if (recipient.sendStatus === SendStatus.SENT) { - return; - } - const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000'; const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`; @@ -69,8 +71,11 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) assetBaseUrl, signDocumentLink, customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate), + role: recipient.role, }); + const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; + await mailer.sendMail({ to: { address: email, @@ -82,7 +87,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) }, subject: customEmail?.subject ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) - : 'Please sign this document', + : `Please ${actionVerb.toLowerCase()} this document`, html: render(template), text: render(template, { plainText: true }), }); diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-recipients-for-document.ts index 198f79be1..4917b213d 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -1,4 +1,5 @@ import { prisma } from '@documenso/prisma'; +import { RecipientRole } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client'; import { nanoid } from '../../universal/id'; @@ -10,6 +11,7 @@ export interface SetRecipientsForDocumentOptions { id?: number | null; email: string; name: string; + role: RecipientRole; }[]; } @@ -79,13 +81,20 @@ export const setRecipientsForDocument = async ({ update: { name: recipient.name, email: recipient.email, + role: recipient.role, documentId, + signingStatus: + recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, }, create: { name: recipient.name, email: recipient.email, + role: recipient.role, token: nanoid(), documentId, + sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, + signingStatus: + recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, }, }), ), diff --git a/packages/prisma/migrations/20231202220928_add_recipient_roles/migration.sql b/packages/prisma/migrations/20231202220928_add_recipient_roles/migration.sql new file mode 100644 index 000000000..441132300 --- /dev/null +++ b/packages/prisma/migrations/20231202220928_add_recipient_roles/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "RecipientRole" AS ENUM ('CC', 'SIGNER', 'VIEWER', 'APPROVER'); + +-- AlterTable +ALTER TABLE "Recipient" ADD COLUMN "role" "RecipientRole" NOT NULL DEFAULT 'SIGNER'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 353a855ae..87d29d6b2 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -209,6 +209,13 @@ enum SigningStatus { SIGNED } +enum RecipientRole { + CC + SIGNER + VIEWER + APPROVER +} + model Recipient { id Int @id @default(autoincrement()) documentId Int? @@ -218,6 +225,7 @@ model Recipient { token String expired DateTime? signedAt DateTime? + role RecipientRole @default(SIGNER) readStatus ReadStatus @default(NOT_OPENED) signingStatus SigningStatus @default(NOT_SIGNED) sendStatus SendStatus @default(NOT_SENT) diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index c4389bdfb..5d8c23c27 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { DocumentStatus, FieldType } from '@documenso/prisma/client'; +import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client'; export const ZGetDocumentByIdQuerySchema = z.object({ id: z.number().min(1), @@ -35,6 +35,7 @@ export const ZSetRecipientsForDocumentMutationSchema = z.object({ id: z.number().nullish(), email: z.string().min(1).email(), name: z.string(), + role: z.nativeEnum(RecipientRole), }), ), }); diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index 09097895c..1ada3d0d3 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -25,6 +25,7 @@ export const recipientRouter = router({ id: signer.nativeId, email: signer.email, name: signer.name, + role: signer.role, })), }); } catch (err) { diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index 8920e7672..a6b4e0d11 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { RecipientRole } from '@documenso/prisma/client'; + export const ZAddSignersMutationSchema = z .object({ documentId: z.number(), @@ -8,6 +10,7 @@ export const ZAddSignersMutationSchema = z nativeId: z.number().optional(), email: z.string().email().min(1), name: z.string(), + role: z.nativeEnum(RecipientRole), }), ), }) diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index afd09809d..74764df80 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Caveat } from 'next/font/google'; @@ -10,8 +10,10 @@ import { useFieldArray, useForm } from 'react-hook-form'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { nanoid } from '@documenso/lib/universal/id'; import type { Field, Recipient } from '@documenso/prisma/client'; +import { RecipientRole } from '@documenso/prisma/client'; import { FieldType, SendStatus } from '@documenso/prisma/client'; import { cn } from '../../lib/utils'; @@ -102,6 +104,12 @@ export const AddFieldsFormPartial = ({ const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT; + const isFieldsDisabled = + !selectedSigner || + hasSelectedSignerBeenSent || + selectedSigner?.role === RecipientRole.VIEWER || + selectedSigner?.role === RecipientRole.CC; + const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false); const [coords, setCoords] = useState({ x: 0, @@ -281,12 +289,28 @@ export const AddFieldsFormPartial = ({ setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]); }, [recipients]); + const recipientsByRole = useMemo(() => { + const recipientsByRole: Record = { + CC: [], + VIEWER: [], + SIGNER: [], + APPROVER: [], + }; + + recipients.forEach((recipient) => { + recipientsByRole[recipient.role].push(recipient); + }); + + return recipientsByRole; + }, [recipients]); + return ( <> +
    {selectedField && ( @@ -351,72 +375,94 @@ export const AddFieldsFormPartial = ({ + No recipient matching this description was found. - - {recipients.map((recipient, index) => ( - { - setSelectedSigner(recipient); - setShowRecipientsSelector(false); - }} - > - {recipient.sendStatus !== SendStatus.SENT ? ( - - ) : ( - - - - - - This document has already been sent to this recipient. You can no - longer edit this recipient. - - - )} + {Object.entries(recipientsByRole).map(([role, recipients], roleIndex) => ( + +
    + { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + RECIPIENT_ROLES_DESCRIPTION[role as RecipientRole].roleName + } +
    - {recipient.name && ( + {recipients.length === 0 && ( +
    + No recipients with this role +
    + )} + + {recipients.map((recipient) => ( + { + setSelectedSigner(recipient); + setShowRecipientsSelector(false); + }} + > - {recipient.name} ({recipient.email}) - - )} + {recipient.name && ( + + {recipient.name} ({recipient.email}) + + )} - {!recipient.name && ( - - {recipient.email} + {!recipient.name && ( + {recipient.email} + )} - )} - - ))} -
    + +
    + {recipient.sendStatus !== SendStatus.SENT ? ( + + ) : ( + + + + + + + This document has already been sent to this recipient. You can no + longer edit this recipient. + + + )} +
    +
    + ))} +
    + ))}
    )}
    -
    +
    -
    +
    diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index bd25cb87d..26aedcae7 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -4,19 +4,20 @@ import React, { useId } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { AnimatePresence, motion } from 'framer-motion'; -import { Plus, Trash } from 'lucide-react'; +import { BadgeCheck, Copy, Eye, PencilLine, Plus, Trash } from 'lucide-react'; import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { nanoid } from '@documenso/lib/universal/id'; import type { Field, Recipient } from '@documenso/prisma/client'; -import { DocumentStatus, SendStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { Button } from '../button'; import { FormErrorMessage } from '../form/form-error-message'; import { Input } from '../input'; import { Label } from '../label'; +import { Select, SelectContent, SelectItem, SelectTrigger } from '../select'; import { useStep } from '../stepper'; import { useToast } from '../use-toast'; import type { TAddSignersFormSchema } from './add-signers.types'; @@ -31,6 +32,13 @@ import { import { ShowFieldItem } from './show-field-item'; import type { DocumentFlowStep } from './types'; +const ROLE_ICONS: Record = { + SIGNER: , + APPROVER: , + CC: , + VIEWER: , +}; + export type AddSignersFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; @@ -67,12 +75,14 @@ export const AddSignersFormPartial = ({ formId: String(recipient.id), name: recipient.name, email: recipient.email, + role: recipient.role, })) : [ { formId: initialId, name: '', email: '', + role: RecipientRole.SIGNER, }, ], }, @@ -104,6 +114,7 @@ export const AddSignersFormPartial = ({ formId: nanoid(12), name: '', email: '', + role: RecipientRole.SIGNER, }); }; @@ -189,6 +200,48 @@ export const AddSignersFormPartial = ({ />
    +
    + ( + + )} + /> +
    +
    - )) + .with( + isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true }, + () => ( + + ), + ) .with({ isRecipient: true, isPending: true, isSigned: false }, () => (
    )} - {remaining.documents === 0 && ( + {team?.id === undefined && remaining.documents === 0 && (

    diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index 433aeb18c..99db66c55 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -7,6 +7,7 @@ import { getServerSession } from 'next-auth'; import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeams } from '@documenso/lib/server-only/team/get-teams'; import { Header } from '~/components/(dashboard)/layout/header'; import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner'; @@ -26,13 +27,17 @@ export default async function AuthenticatedDashboardLayout({ redirect('/signin'); } - const { user } = await getRequiredServerComponentSession(); + const [{ user }, teams] = await Promise.all([ + getRequiredServerComponentSession(), + getTeams({ userId: session.user.id }), + ]); return ( {!user.emailVerified && } -
    + +
    {children}
    diff --git a/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx index 8fd78cae3..9ed6a2515 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx @@ -7,7 +7,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { createBillingPortal } from './create-billing-portal.action'; -export const BillingPortalButton = () => { +export type BillingPortalButtonProps = { + buttonProps?: React.ComponentProps; +}; + +export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => { const { toast } = useToast(); const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false); @@ -48,7 +52,11 @@ export const BillingPortalButton = () => { }; return ( - ); diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index e226a7e39..cee2aa2f1 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -5,8 +5,9 @@ import { match } from 'ts-pattern'; import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; -import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type'; +import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan'; import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id'; +import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; import { type Stripe } from '@documenso/lib/server-only/stripe'; @@ -36,23 +37,23 @@ export default async function BillingSettingsPage() { user = await getStripeCustomerByUser(user).then((result) => result.user); } - const [subscriptions, prices, individualPrices] = await Promise.all([ + const [subscriptions, prices, communityPlanPrices] = await Promise.all([ getSubscriptionsByUserId({ userId: user.id }), - getPricesByInterval({ type: 'individual' }), - getPricesByType('individual'), + getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }), + getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY), ]); - const individualPriceIds = individualPrices.map(({ id }) => id); + const communityPlanPriceIds = communityPlanPrices.map(({ id }) => id); let subscriptionProduct: Stripe.Product | null = null; - const individualUserSubscriptions = subscriptions.filter(({ priceId }) => - individualPriceIds.includes(priceId), + const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) => + communityPlanPriceIds.includes(priceId), ); const subscription = - individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ?? - individualUserSubscriptions[0]; + communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ?? + communityPlanUserSubscriptions[0]; if (subscription?.priceId) { subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch( diff --git a/apps/web/src/app/(dashboard)/settings/profile/page.tsx b/apps/web/src/app/(dashboard)/settings/profile/page.tsx index 60f7da49c..2890eb5d5 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; import { ProfileForm } from '~/components/forms/profile'; export const metadata: Metadata = { @@ -13,11 +14,7 @@ export default async function ProfileSettingsPage() { return (
    -

    Profile

    - -

    Here you can edit your personal details.

    - -
    +
    diff --git a/apps/web/src/app/(dashboard)/settings/security/page.tsx b/apps/web/src/app/(dashboard)/settings/security/page.tsx index 4e0a40838..f46784aed 100644 --- a/apps/web/src/app/(dashboard)/settings/security/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/security/page.tsx @@ -6,6 +6,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get- import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app'; import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes'; import { PasswordForm } from '~/components/forms/password'; @@ -19,13 +20,10 @@ export default async function SecuritySettingsPage() { return (
    -

    Security

    - -

    - Here you can manage your password and security settings. -

    - -
    + {user.identityProvider === 'DOCUMENSO' ? (
    diff --git a/apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx b/apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx new file mode 100644 index 000000000..8aa81653d --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AcceptTeamInvitationButtonProps = { + teamId: number; +}; + +export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButtonProps) => { + const { toast } = useToast(); + + const { + mutateAsync: acceptTeamInvitation, + isLoading, + isSuccess, + } = trpc.team.acceptTeamInvitation.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Accepted team invitation', + duration: 5000, + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + variant: 'destructive', + duration: 10000, + description: 'Unable to join this team at this time.', + }); + }, + }); + + return ( + + ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/teams/page.tsx b/apps/web/src/app/(dashboard)/settings/teams/page.tsx new file mode 100644 index 000000000..1a3d90b66 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/teams/page.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { AnimatePresence } from 'framer-motion'; + +import { trpc } from '@documenso/trpc/react'; +import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; + +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { CreateTeamDialog } from '~/components/(teams)/dialogs/create-team-dialog'; +import { UserSettingsTeamsPageDataTable } from '~/components/(teams)/tables/user-settings-teams-page-data-table'; + +import { TeamEmailUsage } from './team-email-usage'; +import { TeamInvitations } from './team-invitations'; + +export default function TeamsSettingsPage() { + const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery(); + + return ( +
    + + + + + + +
    + + {teamEmail && ( + + + + )} + + + +
    +
    + ); +} diff --git a/apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx b/apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx new file mode 100644 index 000000000..56a7b110a --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useState } from 'react'; + +import type { TeamEmail } from '@documenso/prisma/client'; +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 { useToast } from '@documenso/ui/primitives/use-toast'; + +export type TeamEmailUsageProps = { + teamEmail: TeamEmail & { team: { name: string; url: string } }; +}; + +export const TeamEmailUsage = ({ teamEmail }: TeamEmailUsageProps) => { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } = + trpc.team.deleteTeamEmail.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'You have successfully revoked access.', + duration: 5000, + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + variant: 'destructive', + duration: 10000, + description: + 'We encountered an unknown error while attempting to revoke access. Please try again or contact support.', + }); + }, + }); + + return ( + +
    + Team Email + +

    + Your email is currently being used by team{' '} + {teamEmail.team.name} ({teamEmail.team.url} + ). +

    + +

    They have permission on your behalf to:

    + +
      +
    • Display your name and email in documents
    • +
    • View all documents sent to your account
    • +
    +
    +
    + + !isDeletingTeamEmail && setOpen(value)}> + + + + + + + Are you sure? + + + You are about to revoke access for team{' '} + {teamEmail.team.name} ({teamEmail.team.url}) to + use your email. + + + +
    + + + + + +
    +
    +
    +
    + ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx b/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx new file mode 100644 index 000000000..aa1be3f3f --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { AnimatePresence } from 'framer-motion'; +import { BellIcon } from 'lucide-react'; + +import { formatTeamUrl } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; + +import { AcceptTeamInvitationButton } from './accept-team-invitation-button'; + +export const TeamInvitations = () => { + const { data, isInitialLoading } = trpc.team.getTeamInvitations.useQuery(); + + return ( + + {data && data.length > 0 && !isInitialLoading && ( + + +
    + + + + You have {data.length} pending team invitation + {data.length > 1 ? 's' : ''}. + + + + + + + + + + Pending invitations + + + You have {data.length} pending team invitation{data.length > 1 ? 's' : ''}. + + + +
      + {data.map((invitation) => ( +
    • + + {invitation.team.name} + + } + secondaryText={formatTeamUrl(invitation.team.url)} + rightSideComponent={ +
      + +
      + } + /> +
    • + ))} +
    +
    +
    +
    +
    +
    + )} +
    + ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx index 7930dcd0e..0e8f822c2 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -83,7 +83,7 @@ export const TemplatesDataTable = ({ return (
    {remaining.documents === 0 && ( - + Document Limit Exceeded! diff --git a/apps/web/src/app/(signing)/sign/[token]/layout.tsx b/apps/web/src/app/(signing)/sign/[token]/layout.tsx index cfec41cdf..9db36e8aa 100644 --- a/apps/web/src/app/(signing)/sign/[token]/layout.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/layout.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; +import { getTeams } from '@documenso/lib/server-only/team/get-teams'; import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header'; import { NextAuthProvider } from '~/providers/next-auth'; @@ -12,10 +14,16 @@ export type SigningLayoutProps = { export default async function SigningLayout({ children }: SigningLayoutProps) { const { user, session } = await getServerComponentSession(); + let teams: GetTeamsResponse = []; + + if (user && session) { + teams = await getTeams({ userId: user.id }); + } + return (
    - {user && } + {user && }
    {children}
    diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx new file mode 100644 index 000000000..b7f610cff --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx @@ -0,0 +1,20 @@ +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; + +import DocumentPageComponent from '~/app/(dashboard)/documents/[id]/document-page-view'; + +export type DocumentPageProps = { + params: { + id: string; + teamUrl: string; + }; +}; + +export default async function DocumentPage({ params }: DocumentPageProps) { + const { teamUrl } = params; + + const { user } = await getRequiredServerComponentSession(); + const team = await getTeamByUrl({ userId: user.id, teamUrl }); + + return ; +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx new file mode 100644 index 000000000..952aeeeea --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx @@ -0,0 +1,25 @@ +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; + +import type { DocumentsPageViewProps } from '~/app/(dashboard)/documents/documents-page-view'; +import DocumentsPageView from '~/app/(dashboard)/documents/documents-page-view'; + +export type TeamsDocumentPageProps = { + params: { + teamUrl: string; + }; + searchParams?: DocumentsPageViewProps['searchParams']; +}; + +export default async function TeamsDocumentPage({ + params, + searchParams = {}, +}: TeamsDocumentPageProps) { + const { teamUrl } = params; + + const { user } = await getRequiredServerComponentSession(); + + const team = await getTeamByUrl({ userId: user.id, teamUrl }); + + return ; +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/error.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/error.tsx new file mode 100644 index 000000000..1e1eb9921 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/error.tsx @@ -0,0 +1,54 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +import { ChevronLeft } from 'lucide-react'; + +import { AppErrorCode } from '@documenso/lib/errors/app-error'; +import { Button } from '@documenso/ui/primitives/button'; + +type ErrorProps = { + error: Error & { digest?: string }; +}; + +export default function ErrorPage({ error }: ErrorProps) { + const router = useRouter(); + + let errorMessage = 'Unknown error'; + let errorDetails = ''; + + if (error.message === AppErrorCode.UNAUTHORIZED) { + errorMessage = 'Unauthorized'; + errorDetails = 'You are not authorized to view this page.'; + } + + return ( +
    +
    +

    {errorMessage}

    + +

    Oops! Something went wrong.

    + +

    {errorDetails}

    + +
    + + + +
    +
    +
    + ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx new file mode 100644 index 000000000..3b4f43031 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useState } from 'react'; + +import { AlertTriangle } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; +import type { TeamMemberRole } from '@documenso/prisma/client'; +import { type Subscription, SubscriptionStatus } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type LayoutBillingBannerProps = { + subscription: Subscription; + teamId: number; + userRole: TeamMemberRole; +}; + +export const LayoutBillingBanner = ({ + subscription, + teamId, + userRole, +}: LayoutBillingBannerProps) => { + const { toast } = useToast(); + + const [isOpen, setIsOpen] = useState(false); + + const { mutateAsync: createBillingPortal, isLoading } = + trpc.team.createBillingPortal.useMutation(); + + const handleCreatePortal = async () => { + try { + const sessionUrl = await createBillingPortal({ teamId }); + + window.open(sessionUrl, '_blank'); + + setIsOpen(false); + } catch (err) { + toast({ + title: 'Something went wrong', + description: + 'We are unable to proceed to the billing portal at this time. Please try again, or contact support.', + variant: 'destructive', + duration: 10000, + }); + } + }; + + if (subscription.status === SubscriptionStatus.ACTIVE) { + return null; + } + + return ( + <> +
    +
    +
    + + + {match(subscription.status) + .with(SubscriptionStatus.PAST_DUE, () => 'Payment overdue') + .with(SubscriptionStatus.INACTIVE, () => 'Teams restricted') + .exhaustive()} +
    + + +
    +
    + + !isLoading && setIsOpen(value)}> + + Payment overdue + + {match(subscription.status) + .with(SubscriptionStatus.PAST_DUE, () => ( + + Your payment for teams is overdue. Please settle the payment to avoid any service + disruptions. + + )) + .with(SubscriptionStatus.INACTIVE, () => ( + + Due to an unpaid invoice, your team has been restricted. Please settle the payment + to restore full access to your team. + + )) + .otherwise(() => null)} + + {canExecuteTeamAction('MANAGE_BILLING', userRole) && ( + + + + )} + + + + ); +}; diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx new file mode 100644 index 000000000..2883abc21 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx @@ -0,0 +1,65 @@ +import React from 'react'; + +import { RedirectType, redirect } from 'next/navigation'; + +import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server'; +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; +import { getTeams } from '@documenso/lib/server-only/team/get-teams'; +import { SubscriptionStatus } from '@documenso/prisma/client'; + +import { Header } from '~/components/(dashboard)/layout/header'; +import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus'; +import { NextAuthProvider } from '~/providers/next-auth'; + +import { LayoutBillingBanner } from './layout-billing-banner'; + +export type AuthenticatedTeamsLayoutProps = { + children: React.ReactNode; + params: { + teamUrl: string; + }; +}; + +export default async function AuthenticatedTeamsLayout({ + children, + params, +}: AuthenticatedTeamsLayoutProps) { + const { session, user } = await getServerComponentSession(); + + if (!session || !user) { + redirect('/signin'); + } + + const [getTeamsPromise, getTeamPromise] = await Promise.allSettled([ + getTeams({ userId: user.id }), + getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl }), + ]); + + if (getTeamPromise.status === 'rejected') { + redirect('/documents', RedirectType.replace); + } + + const team = getTeamPromise.value; + const teams = getTeamsPromise.status === 'fulfilled' ? getTeamsPromise.value : []; + + return ( + + + {team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && ( + + )} + +
    + +
    {children}
    + + + + + ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx new file mode 100644 index 000000000..35962e264 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx @@ -0,0 +1,32 @@ +'use client'; + +import Link from 'next/link'; + +import { ChevronLeft } from 'lucide-react'; + +import { Button } from '@documenso/ui/primitives/button'; + +export default function NotFound() { + return ( +
    +
    +

    404 Team not found

    + +

    Oops! Something went wrong.

    + +

    + The team you are looking for may have been removed, renamed or may have never existed. +

    + +
    + +
    +
    +
    + ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx new file mode 100644 index 000000000..1d0e87f79 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx @@ -0,0 +1,84 @@ +import { DateTime } from 'luxon'; +import type Stripe from 'stripe'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { stripe } from '@documenso/lib/server-only/stripe'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; +import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; + +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { TeamBillingInvoicesDataTable } from '~/components/(teams)/tables/team-billing-invoices-data-table'; +import { TeamBillingPortalButton } from '~/components/(teams)/team-billing-portal-button'; + +export type TeamsSettingsBillingPageProps = { + params: { + teamUrl: string; + }; +}; + +export default async function TeamsSettingBillingPage({ params }: TeamsSettingsBillingPageProps) { + const session = await getRequiredServerComponentSession(); + + const team = await getTeamByUrl({ userId: session.user.id, teamUrl: params.teamUrl }); + + const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role); + + let teamSubscription: Stripe.Subscription | null = null; + + if (team.subscription) { + teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId); + } + + const formatTeamSubscriptionDetails = (subscription: Stripe.Subscription | null) => { + if (!subscription) { + return 'No payment required'; + } + + const numberOfSeats = subscription.items.data[0].quantity ?? 0; + + const formattedTeamMemberQuanity = numberOfSeats > 1 ? `${numberOfSeats} members` : '1 member'; + + const formattedDate = DateTime.fromSeconds(subscription.current_period_end).toFormat( + 'LLL dd, yyyy', + ); + + return `${formattedTeamMemberQuanity} • Monthly • Renews: ${formattedDate}`; + }; + + return ( +
    + + + + +
    +

    + Current plan: {teamSubscription ? 'Team' : 'Community Team'} +

    + +

    + {formatTeamSubscriptionDetails(teamSubscription)} +

    +
    + + {teamSubscription && ( +
    + +
    + )} +
    +
    + +
    + +
    +
    + ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx new file mode 100644 index 000000000..fe2ee5aee --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import { notFound } from 'next/navigation'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; +import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; + +import { DesktopNav } from '~/components/(teams)/settings/layout/desktop-nav'; +import { MobileNav } from '~/components/(teams)/settings/layout/mobile-nav'; + +export type TeamSettingsLayoutProps = { + children: React.ReactNode; + params: { + teamUrl: string; + }; +}; + +export default async function TeamsSettingsLayout({ + children, + params: { teamUrl }, +}: TeamSettingsLayoutProps) { + const session = await getRequiredServerComponentSession(); + + try { + const team = await getTeamByUrl({ userId: session.user.id, teamUrl }); + + if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) { + throw new Error(AppErrorCode.UNAUTHORIZED); + } + } catch (e) { + const error = AppError.parseError(e); + + if (error.code === 'P2025') { + notFound(); + } + + throw e; + } + + return ( +
    +

    Team Settings

    + +
    + + + +
    {children}
    +
    +
    + ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx new file mode 100644 index 000000000..4617b3d48 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx @@ -0,0 +1,38 @@ +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; + +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { InviteTeamMembersDialog } from '~/components/(teams)/dialogs/invite-team-member-dialog'; +import { TeamsMemberPageDataTable } from '~/components/(teams)/tables/teams-member-page-data-table'; + +export type TeamsSettingsMembersPageProps = { + params: { + teamUrl: string; + }; +}; + +export default async function TeamsSettingsMembersPage({ params }: TeamsSettingsMembersPageProps) { + const { teamUrl } = params; + + const session = await getRequiredServerComponentSession(); + + const team = await getTeamByUrl({ userId: session.user.id, teamUrl }); + + return ( +
    + + + + + +
    + ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx new file mode 100644 index 000000000..a86797191 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx @@ -0,0 +1,186 @@ +import { CheckCircle2, Clock } from 'lucide-react'; +import { P, match } from 'ts-pattern'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import { isTokenExpired } from '@documenso/lib/utils/token-verification'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; + +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { AddTeamEmailDialog } from '~/components/(teams)/dialogs/add-team-email-dialog'; +import { DeleteTeamDialog } from '~/components/(teams)/dialogs/delete-team-dialog'; +import { TransferTeamDialog } from '~/components/(teams)/dialogs/transfer-team-dialog'; +import { UpdateTeamForm } from '~/components/(teams)/forms/update-team-form'; + +import { TeamEmailDropdown } from './team-email-dropdown'; +import { TeamTransferStatus } from './team-transfer-status'; + +export type TeamsSettingsPageProps = { + params: { + teamUrl: string; + }; +}; + +export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) { + const { teamUrl } = params; + + const session = await getRequiredServerComponentSession(); + + const team = await getTeamByUrl({ userId: session.user.id, teamUrl }); + + const isTransferVerificationExpired = + !team.transferVerification || isTokenExpired(team.transferVerification.expiresAt); + + return ( +
    + + + + + + +
    + {(team.teamEmail || team.emailVerification) && ( + + Team email + + + You can view documents associated with this email and use this identity when sending + documents. + + +
    + +
    + + {team.teamEmail?.name || team.emailVerification?.name} + + } + secondaryText={ + + {team.teamEmail?.email || team.emailVerification?.email} + + } + /> + +
    +
    + {match({ + teamEmail: team.teamEmail, + emailVerification: team.emailVerification, + }) + .with({ teamEmail: P.not(null) }, () => ( + <> + + Active + + )) + .with( + { + emailVerification: P.when( + (emailVerification) => + emailVerification && emailVerification?.expiresAt < new Date(), + ), + }, + () => ( + <> + + Expired + + ), + ) + .with({ emailVerification: P.not(null) }, () => ( + <> + + Awaiting email confirmation + + )) + .otherwise(() => null)} +
    + + +
    +
    +
    + )} + + {!team.teamEmail && !team.emailVerification && ( + +
    + Team email + + +
      + {/* Feature not available yet. */} + {/*
    • Display this name and email when sending documents
    • */} + {/*
    • View documents associated with this email
    • */} + + View documents associated with this email +
    +
    +
    + + +
    + )} + + {team.ownerUserId === session.user.id && ( + <> + {isTransferVerificationExpired && ( + +
    + Transfer team + + + Transfer the ownership of the team to another team member. + +
    + + +
    + )} + + +
    + Delete team + + + This team, and any associated data excluding billing invoices will be permanently + deleted. + +
    + + +
    + + )} +
    +
    + ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx new file mode 100644 index 000000000..e2c0a0d87 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react'; + +import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; +import { trpc } from '@documenso/trpc/react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { UpdateTeamEmailDialog } from '~/components/(teams)/dialogs/update-team-email-dialog'; + +export type TeamsSettingsPageProps = { + team: Awaited>; +}; + +export const TeamEmailDropdown = ({ team }: TeamsSettingsPageProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const { mutateAsync: resendEmailVerification, isLoading: isResendingEmailVerification } = + trpc.team.resendTeamEmailVerification.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Email verification has been resent', + duration: 5000, + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + variant: 'destructive', + duration: 10000, + description: 'Unable to resend verification at this time. Please try again.', + }); + }, + }); + + const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } = + trpc.team.deleteTeamEmail.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Team email has been removed', + duration: 5000, + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + variant: 'destructive', + duration: 10000, + description: 'Unable to remove team email at this time. Please try again.', + }); + }, + }); + + const { mutateAsync: deleteTeamEmailVerification, isLoading: isDeletingTeamEmailVerification } = + trpc.team.deleteTeamEmailVerification.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Email verification has been removed', + duration: 5000, + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + variant: 'destructive', + duration: 10000, + description: 'Unable to remove email verification at this time. Please try again.', + }); + }, + }); + + const onRemove = async () => { + if (team.teamEmail) { + await deleteTeamEmail({ teamId: team.id }); + } + + if (team.emailVerification) { + await deleteTeamEmailVerification({ teamId: team.id }); + } + + router.refresh(); + }; + + return ( + + + + + + + {!team.teamEmail && team.emailVerification && ( + { + e.preventDefault(); + void resendEmailVerification({ teamId: team.id }); + }} + > + {isResendingEmailVerification ? ( + + ) : ( + + )} + Resend verification + + )} + + {team.teamEmail && ( + e.preventDefault()}> + + Edit + + } + /> + )} + + onRemove()} + > + + Remove + + + + ); +}; diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx new file mode 100644 index 000000000..cba50966f --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { AnimatePresence } from 'framer-motion'; + +import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; +import { isTokenExpired } from '@documenso/lib/utils/token-verification'; +import type { TeamMemberRole, TeamTransferVerification } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; +import { cn } from '@documenso/ui/lib/utils'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type TeamTransferStatusProps = { + className?: string; + currentUserTeamRole: TeamMemberRole; + teamId: number; + transferVerification: TeamTransferVerification | null; +}; + +export const TeamTransferStatus = ({ + className, + currentUserTeamRole, + teamId, + transferVerification, +}: TeamTransferStatusProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const isExpired = transferVerification && isTokenExpired(transferVerification.expiresAt); + + const { mutateAsync: deleteTeamTransferRequest, isLoading } = + trpc.team.deleteTeamTransferRequest.useMutation({ + onSuccess: () => { + if (!isExpired) { + toast({ + title: 'Success', + description: 'The team transfer invitation has been successfully deleted.', + duration: 5000, + }); + } + + router.refresh(); + }, + onError: () => { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to remove this transfer. Please try again or contact support.', + }); + }, + }); + + return ( + + {transferVerification && ( + + +
    + + {isExpired ? 'Team transfer request expired' : 'Team transfer in progress'} + + + + {isExpired ? ( +

    + The team transfer request to {transferVerification.name} has + expired. +

    + ) : ( +
    +

    + A request to transfer the ownership of this team has been sent to{' '} + + {transferVerification.name} ({transferVerification.email}) + +

    + +

    + If they accept this request, the team will be transferred to their account. +

    +
    + )} +
    +
    + + {canExecuteTeamAction('DELETE_TEAM_TRANSFER_REQUEST', currentUserTeamRole) && ( + + )} +
    +
    + )} +
    + ); +}; diff --git a/apps/web/src/app/(unauthenticated)/signin/page.tsx b/apps/web/src/app/(unauthenticated)/signin/page.tsx index 1332a3f37..8331e7c03 100644 --- a/apps/web/src/app/(unauthenticated)/signin/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signin/page.tsx @@ -1,7 +1,9 @@ import type { Metadata } from 'next'; import Link from 'next/link'; +import { redirect } from 'next/navigation'; import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; +import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { SignInForm } from '~/components/forms/signin'; @@ -9,7 +11,20 @@ export const metadata: Metadata = { title: 'Sign In', }; -export default function SignInPage() { +type SignInPageProps = { + searchParams: { + email?: string; + }; +}; + +export default function SignInPage({ searchParams }: SignInPageProps) { + const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined; + const email = rawEmail ? decryptSecondaryData(rawEmail) : null; + + if (!email && rawEmail) { + redirect('/signin'); + } + return (

    Sign in to your account

    @@ -18,7 +33,11 @@ export default function SignInPage() { Welcome back, we are lucky to have you.

    - + {process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (

    diff --git a/apps/web/src/app/(unauthenticated)/signup/page.tsx b/apps/web/src/app/(unauthenticated)/signup/page.tsx index c6d49f891..dbbbcdba9 100644 --- a/apps/web/src/app/(unauthenticated)/signup/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signup/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; +import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { SignUpForm } from '~/components/forms/signup'; @@ -10,11 +11,24 @@ export const metadata: Metadata = { title: 'Sign Up', }; -export default function SignUpPage() { +type SignUpPageProps = { + searchParams: { + email?: string; + }; +}; + +export default function SignUpPage({ searchParams }: SignUpPageProps) { if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') { redirect('/signin'); } + const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined; + const email = rawEmail ? decryptSecondaryData(rawEmail) : null; + + if (!email && rawEmail) { + redirect('/signup'); + } + return (

    Create a new account

    @@ -24,7 +38,11 @@ export default function SignUpPage() { signing is within your grasp.

    - +

    Already have an account?{' '} diff --git a/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx new file mode 100644 index 000000000..634416fe3 --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx @@ -0,0 +1,121 @@ +import Link from 'next/link'; + +import { DateTime } from 'luxon'; + +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; +import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation'; +import { getTeamById } from '@documenso/lib/server-only/team/get-team'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberInviteStatus } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; + +type AcceptInvitationPageProps = { + params: { + token: string; + }; +}; + +export default async function AcceptInvitationPage({ + params: { token }, +}: AcceptInvitationPageProps) { + const session = await getServerComponentSession(); + + const teamMemberInvite = await prisma.teamMemberInvite.findUnique({ + where: { + token, + }, + }); + + if (!teamMemberInvite) { + return ( +

    +

    Invalid token

    + +

    + This token is invalid or has expired. Please contact your team for a new invitation. +

    + + +
    + ); + } + + const team = await getTeamById({ teamId: teamMemberInvite.teamId }); + + const user = await prisma.user.findFirst({ + where: { + email: { + equals: teamMemberInvite.email, + mode: 'insensitive', + }, + }, + }); + + // Directly convert the team member invite to a team member if they already have an account. + if (user) { + await acceptTeamInvitation({ userId: user.id, teamId: team.id }); + } + + // For users who do not exist yet, set the team invite status to accepted, which is checked during + // user creation to determine if we should add the user to the team at that time. + if (!user && teamMemberInvite.status !== TeamMemberInviteStatus.ACCEPTED) { + await prisma.teamMemberInvite.update({ + where: { + id: teamMemberInvite.id, + }, + data: { + status: TeamMemberInviteStatus.ACCEPTED, + }, + }); + } + + const email = encryptSecondaryData({ + data: teamMemberInvite.email, + expiresAt: DateTime.now().plus({ days: 1 }).toMillis(), + }); + + if (!user) { + return ( +
    +

    Team invitation

    + +

    + You have been invited by {team.name} to join their team. +

    + +

    + To accept this invitation you must create an account. +

    + + +
    + ); + } + + const isSessionUserTheInvitedUser = user.id === session.user?.id; + + return ( +
    +

    Invitation accepted!

    + +

    + You have accepted an invitation from {team.name} to join their team. +

    + + {isSessionUserTheInvitedUser ? ( + + ) : ( + + )} +
    + ); +} diff --git a/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx new file mode 100644 index 000000000..53ad4461b --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx @@ -0,0 +1,89 @@ +import Link from 'next/link'; + +import { isTokenExpired } from '@documenso/lib/utils/token-verification'; +import { prisma } from '@documenso/prisma'; +import { Button } from '@documenso/ui/primitives/button'; + +type VerifyTeamEmailPageProps = { + params: { + token: string; + }; +}; + +export default async function VerifyTeamEmailPage({ params: { token } }: VerifyTeamEmailPageProps) { + const teamEmailVerification = await prisma.teamEmailVerification.findUnique({ + where: { + token, + }, + include: { + team: true, + }, + }); + + if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) { + return ( +
    +

    Invalid link

    + +

    + This link is invalid or has expired. Please contact your team to resend a verification. +

    + + +
    + ); + } + + const { team } = teamEmailVerification; + + let isTeamEmailVerificationError = false; + + try { + await prisma.$transaction([ + prisma.teamEmailVerification.deleteMany({ + where: { + teamId: team.id, + }, + }), + prisma.teamEmail.create({ + data: { + teamId: team.id, + email: teamEmailVerification.email, + name: teamEmailVerification.name, + }, + }), + ]); + } catch (e) { + console.error(e); + isTeamEmailVerificationError = true; + } + + if (isTeamEmailVerificationError) { + return ( +
    +

    Team email verification

    + +

    + Something went wrong while attempting to verify your email address for{' '} + {team.name}. Please try again later. +

    +
    + ); + } + + return ( +
    +

    Team email verified!

    + +

    + You have verified your email address for {team.name}. +

    + + +
    + ); +} diff --git a/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx new file mode 100644 index 000000000..819b7e970 --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx @@ -0,0 +1,80 @@ +import Link from 'next/link'; + +import { transferTeamOwnership } from '@documenso/lib/server-only/team/transfer-team-ownership'; +import { isTokenExpired } from '@documenso/lib/utils/token-verification'; +import { prisma } from '@documenso/prisma'; +import { Button } from '@documenso/ui/primitives/button'; + +type VerifyTeamTransferPage = { + params: { + token: string; + }; +}; + +export default async function VerifyTeamTransferPage({ + params: { token }, +}: VerifyTeamTransferPage) { + const teamTransferVerification = await prisma.teamTransferVerification.findUnique({ + where: { + token, + }, + include: { + team: true, + }, + }); + + if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) { + return ( +
    +

    Invalid link

    + +

    + This link is invalid or has expired. Please contact your team to resend a transfer + request. +

    + + +
    + ); + } + + const { team } = teamTransferVerification; + + let isTransferError = false; + + try { + await transferTeamOwnership({ token }); + } catch (e) { + console.error(e); + isTransferError = true; + } + + if (isTransferError) { + return ( +
    +

    Team ownership transfer

    + +

    + Something went wrong while attempting to transfer the ownership of team{' '} + {team.name} to your. Please try again later or contact support. +

    +
    + ); + } + + return ( +
    +

    Team ownership transferred!

    + +

    + The ownership of team {team.name} has been successfully transferred to you. +

    + + +
    + ); +} diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index 0312a96d2..3fe42a4c4 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -197,20 +197,22 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { )} {!currentPage && ( <> - + - + - + - - addPage('theme')}>Change theme + + addPage('theme')}> + Change theme + {searchResults.length > 0 && ( - + )} @@ -231,6 +233,7 @@ const Commands = ({ }) => { return pages.map((page, idx) => ( push(page.path)} @@ -255,7 +258,7 @@ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => setTheme(theme.theme)} - className="mx-2 first:mt-2 last:mb-2" + className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2" > {theme.label} diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx index e04bc2818..2b11c4be2 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx @@ -4,10 +4,11 @@ import type { HTMLAttributes } from 'react'; import { useEffect, useState } from 'react'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; +import { useParams, usePathname } from 'next/navigation'; import { Search } from 'lucide-react'; +import { getRootHref } from '@documenso/lib/utils/params'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -28,10 +29,13 @@ export type DesktopNavProps = HTMLAttributes; export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { const pathname = usePathname(); + const params = useParams(); const [open, setOpen] = useState(false); const [modifierKey, setModifierKey] = useState(() => 'Ctrl'); + const rootHref = getRootHref(params, { returnEmptyRootString: true }); + useEffect(() => { const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'; const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent); @@ -48,20 +52,24 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { {...props} >
    - {navigationLinks.map(({ href, label }) => ( - - {label} - - ))} + {navigationLinks + .filter(({ href }) => href !== '/templates' || rootHref === '') // Remove templates for team pages. + .map(({ href, label }) => ( + + {label} + + ))}
    diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx index ba35671e6..753f5fb11 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/layout/header.tsx @@ -1,23 +1,34 @@ 'use client'; -import type { HTMLAttributes } from 'react'; -import { useEffect, useState } from 'react'; +import { type HTMLAttributes, useEffect, useState } from 'react'; import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { MenuIcon, SearchIcon } from 'lucide-react'; + +import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; +import { getRootHref } from '@documenso/lib/utils/params'; import type { User } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; import { Logo } from '~/components/branding/logo'; +import { CommandMenu } from '../common/command-menu'; import { DesktopNav } from './desktop-nav'; -import { ProfileDropdown } from './profile-dropdown'; +import { MenuSwitcher } from './menu-switcher'; +import { MobileNavigation } from './mobile-navigation'; export type HeaderProps = HTMLAttributes & { user: User; + teams: GetTeamsResponse; }; -export const Header = ({ className, user, ...props }: HeaderProps) => { +export const Header = ({ className, user, teams, ...props }: HeaderProps) => { + const params = useParams(); + + const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false); + const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false); const [scrollY, setScrollY] = useState(0); useEffect(() => { @@ -41,8 +52,8 @@ export const Header = ({ className, user, ...props }: HeaderProps) => { >
    @@ -50,11 +61,24 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
    - + +
    - {/* */} +
    + + + + + + +
    diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx new file mode 100644 index 000000000..35a05baf2 --- /dev/null +++ b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx @@ -0,0 +1,214 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react'; +import { signOut } from 'next-auth/react'; + +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; +import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; +import type { User } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; + +export type MenuSwitcherProps = { + user: User; + teams: GetTeamsResponse; +}; + +export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProps) => { + const pathname = usePathname(); + + const isUserAdmin = isAdmin(user); + + const { data: teamsQueryResult } = trpc.team.getTeams.useQuery(undefined, { + initialData: initialTeamsData, + }); + + const teams = teamsQueryResult && teamsQueryResult.length > 0 ? teamsQueryResult : null; + + const isPathTeamUrl = (teamUrl: string) => { + if (!pathname || !pathname.startsWith(`/t/`)) { + return false; + } + + return pathname.split('/')[2] === teamUrl; + }; + + const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url)); + + const formatAvatarFallback = (teamName?: string) => { + if (teamName !== undefined) { + return teamName.slice(0, 1).toUpperCase(); + } + + return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase(); + }; + + const formatSecondaryAvatarText = (team?: typeof selectedTeam) => { + if (!team) { + return 'Personal Account'; + } + + if (team.ownerUserId === user.id) { + return 'Owner'; + } + + return TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role]; + }; + + return ( + + + + + + + {teams ? ( + <> + Personal + + + + + ) + } + /> + + + + + + +
    +

    Teams

    + +
    + + + + + + + +
    +
    +
    + + {teams.map((team) => ( + + + + ) + } + /> + + + ))} + + ) : ( + + + Create team + + + + )} + + + + {isUserAdmin && ( + + Admin panel + + )} + + + User settings + + + {selectedTeam && + canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && ( + + Team settings + + )} + + + signOut({ + callbackUrl: '/', + }) + } + > + Sign Out + +
    +
    + ); +}; diff --git a/apps/web/src/components/(dashboard)/layout/mobile-nav.tsx b/apps/web/src/components/(dashboard)/layout/mobile-nav.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx b/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx new file mode 100644 index 000000000..7142de5dc --- /dev/null +++ b/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx @@ -0,0 +1,96 @@ +'use client'; + +import Image from 'next/image'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; + +import { signOut } from 'next-auth/react'; + +import LogoImage from '@documenso/assets/logo.png'; +import { getRootHref } from '@documenso/lib/utils/params'; +import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet'; +import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher'; + +export type MobileNavigationProps = { + isMenuOpen: boolean; + onMenuOpenChange?: (_value: boolean) => void; +}; + +export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => { + const params = useParams(); + + const handleMenuItemClick = () => { + onMenuOpenChange?.(false); + }; + + const rootHref = getRootHref(params, { returnEmptyRootString: true }); + + const menuNavigationLinks = [ + { + href: `${rootHref}/documents`, + text: 'Documents', + }, + { + href: `${rootHref}/templates`, + text: 'Templates', + }, + { + href: '/settings/teams', + text: 'Teams', + }, + { + href: '/settings/profile', + text: 'Settings', + }, + ].filter(({ text, href }) => text !== 'Templates' || href === '/templates'); // Filter out templates for teams. + + return ( + + + + Documenso Logo + + +
    + {menuNavigationLinks.map(({ href, text }) => ( + handleMenuItemClick()} + > + {text} + + ))} + + +
    + +
    +
    + +
    + +

    + © {new Date().getFullYear()} Documenso, Inc. All rights reserved. +

    +
    +
    +
    + ); +}; diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx deleted file mode 100644 index f2432c071..000000000 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ /dev/null @@ -1,169 +0,0 @@ -'use client'; - -import Link from 'next/link'; - -import { - CreditCard, - FileSpreadsheet, - Lock, - LogOut, - User as LucideUser, - Monitor, - Moon, - Palette, - Sun, - UserCog, -} from 'lucide-react'; -import { signOut } from 'next-auth/react'; -import { useTheme } from 'next-themes'; -import { LuGithub } from 'react-icons/lu'; - -import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; -import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; -import { recipientInitials } from '@documenso/lib/utils/recipient-formatter'; -import type { User } from '@documenso/prisma/client'; -import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; -import { Button } from '@documenso/ui/primitives/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuPortal, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from '@documenso/ui/primitives/dropdown-menu'; - -export type ProfileDropdownProps = { - user: User; -}; - -export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { - const { getFlag } = useFeatureFlags(); - const { theme, setTheme } = useTheme(); - const isUserAdmin = isAdmin(user); - - const isBillingEnabled = getFlag('app_billing'); - - const avatarFallback = user.name - ? recipientInitials(user.name) - : user.email.slice(0, 1).toUpperCase(); - - return ( - - - - - - - Account - - {isUserAdmin && ( - <> - - - - Admin - - - - - - )} - - - - - Profile - - - - - - - Security - - - - {isBillingEnabled && ( - - - - Billing - - - )} - - - - - - Templates - - - - - - - - Themes - - - - - - Light - - - - Dark - - - - System - - - - - - - - - - Star on Github - - - - - - - void signOut({ - callbackUrl: '/', - }) - } - > - - Sign Out - - - - ); -}; diff --git a/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx b/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx index caeb780d0..a49e2f284 100644 --- a/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx +++ b/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx @@ -21,9 +21,9 @@ export const PeriodSelector = () => { const router = useRouter(); const period = useMemo(() => { - const p = searchParams?.get('period') ?? ''; + const p = searchParams?.get('period') ?? 'all'; - return isPeriodSelectorValue(p) ? p : ''; + return isPeriodSelectorValue(p) ? p : 'all'; }, [searchParams]); const onPeriodChange = (newPeriod: string) => { @@ -35,7 +35,7 @@ export const PeriodSelector = () => { params.set('period', newPeriod); - if (newPeriod === '') { + if (newPeriod === '' || newPeriod === 'all') { params.delete('period'); } @@ -49,7 +49,7 @@ export const PeriodSelector = () => { - All Time + All Time Last 7 days Last 14 days Last 30 days diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx index f4b2aae5e..c7ab61d8a 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -1,11 +1,11 @@ 'use client'; -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { CreditCard, Lock, User } from 'lucide-react'; +import { CreditCard, Lock, User, Users } from 'lucide-react'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { cn } from '@documenso/ui/lib/utils'; @@ -35,6 +35,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + + + + + + + + + )} + + + + + Add team email + + + A verification email will be sent to the provided email. + + + +
    + +
    + ( + + Name + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + + + + + +
    +
    + +
    + + ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx b/apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx new file mode 100644 index 000000000..f7ee8ca51 --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx @@ -0,0 +1,177 @@ +import { useMemo, useState } from 'react'; + +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Loader, TagIcon } from 'lucide-react'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type CreateTeamCheckoutDialogProps = { + pendingTeamId: number | null; + onClose: () => void; +} & Omit; + +const MotionCard = motion(Card); + +export const CreateTeamCheckoutDialog = ({ + pendingTeamId, + onClose, + ...props +}: CreateTeamCheckoutDialogProps) => { + const { toast } = useToast(); + + const [interval, setInterval] = useState<'monthly' | 'yearly'>('monthly'); + + const { data, isLoading } = trpc.team.getTeamPrices.useQuery(); + + const { mutateAsync: createCheckout, isLoading: isCreatingCheckout } = + trpc.team.createTeamPendingCheckout.useMutation({ + onSuccess: (checkoutUrl) => { + window.open(checkoutUrl, '_blank'); + onClose(); + }, + onError: () => + toast({ + title: 'Something went wrong', + description: + 'We were unable to create a checkout session. Please try again, or contact support', + variant: 'destructive', + }), + }); + + const selectedPrice = useMemo(() => { + if (!data) { + return null; + } + + return data[interval]; + }, [data, interval]); + + const handleOnOpenChange = (open: boolean) => { + if (pendingTeamId === null) { + return; + } + + if (!open) { + onClose(); + } + }; + + if (pendingTeamId === null) { + return null; + } + + return ( + + + + Team checkout + + + Payment is required to finalise the creation of your team. + + + + {(isLoading || !data) && ( +
    + {isLoading ? ( + + ) : ( +

    Something went wrong

    + )} +
    + )} + + {data && selectedPrice && !isLoading && ( +
    + setInterval(value as 'monthly' | 'yearly')} + value={interval} + className="mb-4" + > + + {[data.monthly, data.yearly].map((price) => ( + + {price.friendlyInterval} + + ))} + + + + + + + {selectedPrice.interval === 'monthly' ? ( +
    + $50 USD per month +
    + ) : ( +
    + + $480 USD per year + +
    + + 20% off +
    +
    + )} + +
    +

    This price includes minimum 5 seats.

    + +

    + Adding and removing seats will adjust your invoice accordingly. +

    +
    +
    +
    +
    + + + + + + +
    + )} +
    +
    + ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx new file mode 100644 index 000000000..283fd8dad --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { useRouter, useSearchParams } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type CreateTeamDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({ + teamName: true, + teamUrl: true, +}); + +type TCreateTeamFormSchema = z.infer; + +export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) => { + const { toast } = useToast(); + + const router = useRouter(); + const searchParams = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const [open, setOpen] = useState(false); + + const actionSearchParam = searchParams?.get('action'); + + const form = useForm({ + resolver: zodResolver(ZCreateTeamFormSchema), + defaultValues: { + teamName: '', + teamUrl: '', + }, + }); + + const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation(); + + const onFormSubmit = async ({ teamName, teamUrl }: TCreateTeamFormSchema) => { + try { + const response = await createTeam({ + teamName, + teamUrl, + }); + + setOpen(false); + + if (response.paymentRequired) { + router.push(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`); + return; + } + + toast({ + title: 'Success', + description: 'Your team has been created.', + duration: 5000, + }); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.ALREADY_EXISTS) { + form.setError('teamUrl', { + type: 'manual', + message: 'This URL is already in use.', + }); + + return; + } + + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to create a team. Please try again later.', + }); + } + }; + + const mapTextToUrl = (text: string) => { + return text.toLowerCase().replace(/\s+/g, '-'); + }; + + useEffect(() => { + if (actionSearchParam === 'add-team') { + setOpen(true); + updateSearchParams({ action: null }); + } + }, [actionSearchParam, open, setOpen, updateSearchParams]); + + useEffect(() => { + form.reset(); + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + Create team + + + Create a team to collaborate with your team members. + + + +
    + +
    + ( + + Team Name + + { + const oldGeneratedUrl = mapTextToUrl(field.value); + const newGeneratedUrl = mapTextToUrl(event.target.value); + + const urlField = form.getValues('teamUrl'); + if (urlField === oldGeneratedUrl) { + form.setValue('teamUrl', newGeneratedUrl); + } + + field.onChange(event); + }} + /> + + + + )} + /> + + ( + + Team URL + + + + {!form.formState.errors.teamUrl && ( + + {field.value + ? `${WEBAPP_BASE_URL}/t/${field.value}` + : 'A unique URL to identify your team'} + + )} + + + + )} + /> + + + + + + +
    +
    + +
    +
    + ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx new file mode 100644 index 000000000..99630e57c --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import type { Toast } from '@documenso/ui/primitives/use-toast'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DeleteTeamDialogProps = { + teamId: number; + teamName: string; + trigger?: React.ReactNode; +}; + +export const DeleteTeamDialog = ({ trigger, teamId, teamName }: DeleteTeamDialogProps) => { + const router = useRouter(); + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const deleteMessage = `delete ${teamName}`; + + const ZDeleteTeamFormSchema = z.object({ + teamName: z.literal(deleteMessage, { + errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }), + }), + }); + + const form = useForm({ + resolver: zodResolver(ZDeleteTeamFormSchema), + defaultValues: { + teamName: '', + }, + }); + + const { mutateAsync: deleteTeam } = trpc.team.deleteTeam.useMutation(); + + const onFormSubmit = async () => { + try { + await deleteTeam({ teamId }); + + toast({ + title: 'Success', + description: 'Your team has been successfully deleted.', + duration: 5000, + }); + + setOpen(false); + + router.push('/settings/teams'); + } catch (err) { + const error = AppError.parseError(err); + + let toastError: Toast = { + title: 'An unknown error occurred', + variant: 'destructive', + duration: 10000, + description: + 'We encountered an unknown error while attempting to delete this team. Please try again later.', + }; + + if (error.code === 'resource_missing') { + toastError = { + title: 'Unable to delete team', + variant: 'destructive', + duration: 15000, + description: + 'Something went wrong while updating the team billing subscription, please contact support.', + }; + } + + toast(toastError); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + + {trigger ?? } + + + + + Delete team + + + Are you sure? This is irreversable. + + + +
    + +
    + ( + + + Confirm by typing {deleteMessage} + + + + + + + )} + /> + + + + + + +
    +
    + +
    +
    + ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx new file mode 100644 index 000000000..7ae8ccf1c --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { useState } from 'react'; + +import { trpc } from '@documenso/trpc/react'; +import { Alert } from '@documenso/ui/primitives/alert'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DeleteTeamMemberDialogProps = { + teamId: number; + teamName: string; + teamMemberId: number; + teamMemberName: string; + teamMemberEmail: string; + trigger?: React.ReactNode; +}; + +export const DeleteTeamMemberDialog = ({ + trigger, + teamId, + teamName, + teamMemberId, + teamMemberName, + teamMemberEmail, +}: DeleteTeamMemberDialogProps) => { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const { mutateAsync: deleteTeamMembers, isLoading: isDeletingTeamMember } = + trpc.team.deleteTeamMembers.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'You have successfully removed this user from the team.', + duration: 5000, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + duration: 10000, + description: + 'We encountered an unknown error while attempting to remove this user. Please try again later.', + }); + }, + }); + + return ( + !isDeletingTeamMember && setOpen(value)}> + + {trigger ?? } + + + + + Are you sure? + + + You are about to remove the following user from{' '} + {teamName}. + + + + + {teamMemberName}} + secondaryText={teamMemberEmail} + /> + + +
    + + + + + +
    +
    +
    + ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx new file mode 100644 index 000000000..482142c99 --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx @@ -0,0 +1,244 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { Mail, PlusCircle, Trash } from 'lucide-react'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { TeamMemberRole } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type InviteTeamMembersDialogProps = { + currentUserTeamRole: TeamMemberRole; + teamId: number; + trigger?: React.ReactNode; +} & Omit; + +const ZInviteTeamMembersFormSchema = z + .object({ + invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations, + }) + .refine( + (schema) => { + const emails = schema.invitations.map((invitation) => invitation.email.toLowerCase()); + + return new Set(emails).size === emails.length; + }, + // Dirty hack to handle errors when .root is populated for an array type + { message: 'Members must have unique emails', path: ['members__root'] }, + ); + +type TInviteTeamMembersFormSchema = z.infer; + +export const InviteTeamMembersDialog = ({ + currentUserTeamRole, + teamId, + trigger, + ...props +}: InviteTeamMembersDialogProps) => { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZInviteTeamMembersFormSchema), + defaultValues: { + invitations: [ + { + email: '', + role: TeamMemberRole.MEMBER, + }, + ], + }, + }); + + const { + append: appendTeamMemberInvite, + fields: teamMemberInvites, + remove: removeTeamMemberInvite, + } = useFieldArray({ + control: form.control, + name: 'invitations', + }); + + const { mutateAsync: createTeamMemberInvites } = trpc.team.createTeamMemberInvites.useMutation(); + + const onAddTeamMemberInvite = () => { + appendTeamMemberInvite({ + email: '', + role: TeamMemberRole.MEMBER, + }); + }; + + const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => { + try { + await createTeamMemberInvites({ + teamId, + invitations, + }); + + toast({ + title: 'Success', + description: 'Team invitations have been sent.', + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to invite team members. Please try again later.', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? } + + + + + Invite team members + + + An email containing an invitation will be sent to each member. + + + +
    + +
    + {teamMemberInvites.map((teamMemberInvite, index) => ( +
    + ( + + {index === 0 && Email address} + + + + + + )} + /> + + ( + + {index === 0 && Role} + + + + + + )} + /> + + +
    + ))} + + + + + + + + +
    +
    + +
    +
    + ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx new file mode 100644 index 000000000..27384d680 --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useState } from 'react'; + +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import type { TeamMemberRole } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Alert } from '@documenso/ui/primitives/alert'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type LeaveTeamDialogProps = { + teamId: number; + teamName: string; + role: TeamMemberRole; + trigger?: React.ReactNode; +}; + +export const LeaveTeamDialog = ({ trigger, teamId, teamName, role }: LeaveTeamDialogProps) => { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const { mutateAsync: leaveTeam, isLoading: isLeavingTeam } = trpc.team.leaveTeam.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'You have successfully left this team.', + duration: 5000, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + duration: 10000, + description: + 'We encountered an unknown error while attempting to leave this team. Please try again later.', + }); + }, + }); + + return ( + !isLeavingTeam && setOpen(value)}> + + {trigger ?? } + + + + + Are you sure? + + + You are about to leave the following team. + + + + + + + +
    + + + + + +
    +
    +
    + ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx new file mode 100644 index 000000000..e5dd8ca17 --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx @@ -0,0 +1,293 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Loader } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type TransferTeamDialogProps = { + teamId: number; + teamName: string; + ownerUserId: number; + trigger?: React.ReactNode; +}; + +export const TransferTeamDialog = ({ + trigger, + teamId, + teamName, + ownerUserId, +}: TransferTeamDialogProps) => { + const router = useRouter(); + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const { mutateAsync: requestTeamOwnershipTransfer } = + trpc.team.requestTeamOwnershipTransfer.useMutation(); + + const { + data, + refetch: refetchTeamMembers, + isLoading: loadingTeamMembers, + isLoadingError: loadingTeamMembersError, + } = trpc.team.getTeamMembers.useQuery({ + teamId, + }); + + const confirmTransferMessage = `transfer ${teamName}`; + + const ZTransferTeamFormSchema = z.object({ + teamName: z.literal(confirmTransferMessage, { + errorMap: () => ({ message: `You must enter '${confirmTransferMessage}' to proceed` }), + }), + newOwnerUserId: z.string(), + clearPaymentMethods: z.boolean(), + }); + + const form = useForm>({ + resolver: zodResolver(ZTransferTeamFormSchema), + defaultValues: { + teamName: '', + clearPaymentMethods: false, + }, + }); + + const onFormSubmit = async ({ + newOwnerUserId, + clearPaymentMethods, + }: z.infer) => { + try { + await requestTeamOwnershipTransfer({ + teamId, + newOwnerUserId: Number.parseInt(newOwnerUserId), + clearPaymentMethods, + }); + + router.refresh(); + + toast({ + title: 'Success', + description: 'An email requesting the transfer of this team has been sent.', + duration: 5000, + }); + + setOpen(false); + } catch (err) { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + duration: 10000, + description: + 'We encountered an unknown error while attempting to request a transfer of this team. Please try again later.', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + useEffect(() => { + if (open && loadingTeamMembersError) { + void refetchTeamMembers(); + } + }, [open, loadingTeamMembersError, refetchTeamMembers]); + + const teamMembers = data + ? data.filter((teamMember) => teamMember.userId !== ownerUserId) + : undefined; + + return ( + !form.formState.isSubmitting && setOpen(value)}> + + {trigger ?? ( + + )} + + + {teamMembers && teamMembers.length > 0 ? ( + + + Transfer team + + + Transfer ownership of this team to a selected team member. + + + +
    + +
    + ( + + New team owner + + + + + + )} + /> + + ( + + + Confirm by typing{' '} + {confirmTransferMessage} + + + + + + + )} + /> + + {/* Temporary removed. */} + {/* {IS_BILLING_ENABLED && ( + ( + +
    + + + +
    +
    + )} + /> + )} */} + + + +
      + {IS_BILLING_ENABLED && ( + // Temporary removed. + //
    • + // {form.getValues('clearPaymentMethods') + // ? 'You will not be billed for any upcoming invoices' + // : 'We will continue to bill current payment methods if required'} + //
    • + +
    • + Any payment methods attached to this team will remain attached to this + team. Please contact us if you need to update this information. +
    • + )} +
    • + The selected team member will receive an email which they must accept before + the team is transferred +
    • +
    +
    +
    + + + + + + +
    +
    + +
    + ) : ( + + {loadingTeamMembers ? ( + + ) : ( +

    + {loadingTeamMembersError + ? 'An error occurred while loading team members. Please try again later.' + : 'You must have at least one other team member to transfer ownership.'} +

    + )} +
    + )} +
    + ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx b/apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx new file mode 100644 index 000000000..c6ab8890a --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import type { TeamEmail } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type UpdateTeamEmailDialogProps = { + teamEmail: TeamEmail; + trigger?: React.ReactNode; +} & Omit; + +const ZUpdateTeamEmailFormSchema = z.object({ + name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), +}); + +type TUpdateTeamEmailFormSchema = z.infer; + +export const UpdateTeamEmailDialog = ({ + teamEmail, + trigger, + ...props +}: UpdateTeamEmailDialogProps) => { + const router = useRouter(); + + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZUpdateTeamEmailFormSchema), + defaultValues: { + name: teamEmail.name, + }, + }); + + const { mutateAsync: updateTeamEmail } = trpc.team.updateTeamEmail.useMutation(); + + const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => { + try { + await updateTeamEmail({ + teamId: teamEmail.teamId, + data: { + name, + }, + }); + + toast({ + title: 'Success', + description: 'Team email was updated.', + duration: 5000, + }); + + router.refresh(); + + setOpen(false); + } catch (err) { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting update the team email. Please try again later.', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + Update team email + + + To change the email you must remove and add a new email address. + + + +
    + +
    + ( + + Name + + + + + + )} + /> + + + Email + + + + + + + + + + +
    +
    + +
    +
    + ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx new file mode 100644 index 000000000..cc8ea675f --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import { TeamMemberRole } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type UpdateTeamMemberDialogProps = { + currentUserTeamRole: TeamMemberRole; + trigger?: React.ReactNode; + teamId: number; + teamMemberId: number; + teamMemberName: string; + teamMemberRole: TeamMemberRole; +} & Omit; + +const ZUpdateTeamMemberFormSchema = z.object({ + role: z.nativeEnum(TeamMemberRole), +}); + +type ZUpdateTeamMemberSchema = z.infer; + +export const UpdateTeamMemberDialog = ({ + currentUserTeamRole, + trigger, + teamId, + teamMemberId, + teamMemberName, + teamMemberRole, + ...props +}: UpdateTeamMemberDialogProps) => { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZUpdateTeamMemberFormSchema), + defaultValues: { + role: teamMemberRole, + }, + }); + + const { mutateAsync: updateTeamMember } = trpc.team.updateTeamMember.useMutation(); + + const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => { + try { + await updateTeamMember({ + teamId, + teamMemberId, + data: { + role, + }, + }); + + toast({ + title: 'Success', + description: `You have updated ${teamMemberName}.`, + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to update this team member. Please try again later.', + }); + } + }; + + useEffect(() => { + if (!open) { + return; + } + + form.reset(); + + if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, teamMemberRole)) { + setOpen(false); + + toast({ + title: 'You cannot modify a team member who has a higher role than you.', + variant: 'destructive', + }); + } + }, [open, currentUserTeamRole, teamMemberRole, form, toast]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? } + + + + + Update team member + + + You are currently updating {teamMemberName}. + + + +
    + +
    + ( + + Role + + + + + + )} + /> + + + + + + +
    +
    + +
    +
    + ); +}; diff --git a/apps/web/src/components/(teams)/forms/update-team-form.tsx b/apps/web/src/components/(teams)/forms/update-team-form.tsx new file mode 100644 index 000000000..142914b8c --- /dev/null +++ b/apps/web/src/components/(teams)/forms/update-team-form.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema'; +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 { useToast } from '@documenso/ui/primitives/use-toast'; + +export type UpdateTeamDialogProps = { + teamId: number; + teamName: string; + teamUrl: string; +}; + +const ZUpdateTeamFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({ + name: true, + url: true, +}); + +type TUpdateTeamFormSchema = z.infer; + +export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZUpdateTeamFormSchema), + defaultValues: { + name: teamName, + url: teamUrl, + }, + }); + + const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation(); + + const onFormSubmit = async ({ name, url }: TUpdateTeamFormSchema) => { + try { + await updateTeam({ + data: { + name, + url, + }, + teamId, + }); + + toast({ + title: 'Success', + description: 'Your team has been successfully updated.', + duration: 5000, + }); + + form.reset({ + name, + url, + }); + + if (url !== teamUrl) { + router.push(`${WEBAPP_BASE_URL}/t/${url}/settings`); + } + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.ALREADY_EXISTS) { + form.setError('url', { + type: 'manual', + message: 'This URL is already in use.', + }); + + return; + } + + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to update your team. Please try again later.', + }); + } + }; + + return ( +
    + +
    + ( + + Team Name + + + + + + )} + /> + + ( + + Team URL + + + + {!form.formState.errors.url && ( + + {field.value + ? `${WEBAPP_BASE_URL}/t/${field.value}` + : 'A unique URL to identify your team'} + + )} + + + + )} + /> + +
    + + {form.formState.isDirty && ( + + + + )} + + + +
    +
    +
    + + ); +}; diff --git a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx new file mode 100644 index 000000000..be68f6c03 --- /dev/null +++ b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx @@ -0,0 +1,67 @@ +'use client'; + +import type { HTMLAttributes } from 'react'; + +import Link from 'next/link'; +import { useParams, usePathname } from 'next/navigation'; + +import { CreditCard, Settings, Users } from 'lucide-react'; + +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +export type DesktopNavProps = HTMLAttributes; + +export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + const pathname = usePathname(); + const params = useParams(); + + const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : ''; + + const settingsPath = `/t/${teamUrl}/settings`; + const membersPath = `/t/${teamUrl}/settings/members`; + const billingPath = `/t/${teamUrl}/settings/billing`; + + return ( +
    + + + + + + + + + {IS_BILLING_ENABLED && ( + + + + )} +
    + ); +}; diff --git a/apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx b/apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx new file mode 100644 index 000000000..de01ca9bf --- /dev/null +++ b/apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx @@ -0,0 +1,75 @@ +'use client'; + +import type { HTMLAttributes } from 'react'; + +import Link from 'next/link'; +import { useParams, usePathname } from 'next/navigation'; + +import { CreditCard, Key, User } from 'lucide-react'; + +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +export type MobileNavProps = HTMLAttributes; + +export const MobileNav = ({ className, ...props }: MobileNavProps) => { + const pathname = usePathname(); + const params = useParams(); + + const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : ''; + + const settingsPath = `/t/${teamUrl}/settings`; + const membersPath = `/t/${teamUrl}/settings/members`; + const billingPath = `/t/${teamUrl}/settings/billing`; + + return ( +
    + + + + + + + + + {IS_BILLING_ENABLED && ( + + + + )} +
    + ); +}; diff --git a/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx b/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx new file mode 100644 index 000000000..0dd4bcf4c --- /dev/null +++ b/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx @@ -0,0 +1,158 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +import { LocaleDate } from '~/components/formatter/locale-date'; + +import { LeaveTeamDialog } from '../dialogs/leave-team-dialog'; + +export const CurrentUserTeamsDataTable = () => { + const searchParams = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZBaseTableSearchParamsSchema.parse( + Object.fromEntries(searchParams ?? []), + ); + + const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeams.useQuery( + { + term: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + keepPreviousData: true, + }, + ); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + return ( + ( + + {row.original.name} + } + secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`} + /> + + ), + }, + { + header: 'Role', + accessorKey: 'role', + cell: ({ row }) => + row.original.ownerUserId === row.original.currentTeamMember.userId + ? 'Owner' + : TEAM_MEMBER_ROLE_MAP[row.original.currentTeamMember.role], + }, + { + header: 'Member Since', + accessorKey: 'createdAt', + cell: ({ row }) => , + }, + { + id: 'actions', + cell: ({ row }) => ( +
    + {canExecuteTeamAction('MANAGE_TEAM', row.original.currentTeamMember.role) && ( + + )} + + e.preventDefault()} + > + Leave + + } + /> +
    + ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + +
    + + +
    + + +
    +
    +
    + + + + + + + +
    + + +
    +
    + + ), + }} + > + {(table) => } +
    + ); +}; diff --git a/apps/web/src/components/(teams)/tables/pending-user-teams-data-table-actions.tsx b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table-actions.tsx new file mode 100644 index 000000000..64a58375c --- /dev/null +++ b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table-actions.tsx @@ -0,0 +1,53 @@ +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type PendingUserTeamsDataTableActionsProps = { + className?: string; + pendingTeamId: number; + onPayClick: (pendingTeamId: number) => void; +}; + +export const PendingUserTeamsDataTableActions = ({ + className, + pendingTeamId, + onPayClick, +}: PendingUserTeamsDataTableActionsProps) => { + const { toast } = useToast(); + + const { mutateAsync: deleteTeamPending, isLoading: deletingTeam } = + trpc.team.deleteTeamPending.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Pending team deleted.', + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + description: + 'We encountered an unknown error while attempting to delete the pending team. Please try again later.', + duration: 10000, + variant: 'destructive', + }); + }, + }); + + return ( +
    + + + +
    + ); +}; diff --git a/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx new file mode 100644 index 000000000..84d4e38df --- /dev/null +++ b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { useSearchParams } from 'next/navigation'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { trpc } from '@documenso/trpc/react'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +import { LocaleDate } from '~/components/formatter/locale-date'; + +import { CreateTeamCheckoutDialog } from '../dialogs/create-team-checkout-dialog'; +import { PendingUserTeamsDataTableActions } from './pending-user-teams-data-table-actions'; + +export const PendingUserTeamsDataTable = () => { + const searchParams = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZBaseTableSearchParamsSchema.parse( + Object.fromEntries(searchParams ?? []), + ); + + const [checkoutPendingTeamId, setCheckoutPendingTeamId] = useState(null); + + const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamsPending.useQuery( + { + term: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + keepPreviousData: true, + }, + ); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + useEffect(() => { + const searchParamCheckout = searchParams?.get('checkout'); + + if (searchParamCheckout && !isNaN(parseInt(searchParamCheckout))) { + setCheckoutPendingTeamId(parseInt(searchParamCheckout)); + updateSearchParams({ checkout: null }); + } + }, [searchParams, updateSearchParams]); + + return ( + <> + ( + {row.original.name} + } + secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`} + /> + ), + }, + { + header: 'Created on', + accessorKey: 'createdAt', + cell: ({ row }) => , + }, + { + id: 'actions', + cell: ({ row }) => ( + + ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + +
    + + +
    + + +
    +
    +
    + + + + +
    + + +
    +
    + + ), + }} + > + {(table) => } +
    + + setCheckoutPendingTeamId(null)} + /> + + ); +}; diff --git a/apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx b/apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx new file mode 100644 index 000000000..a860ac6d9 --- /dev/null +++ b/apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx @@ -0,0 +1,152 @@ +'use client'; + +import Link from 'next/link'; + +import { File } from 'lucide-react'; +import { DateTime } from 'luxon'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +export type TeamBillingInvoicesDataTableProps = { + teamId: number; +}; + +export const TeamBillingInvoicesDataTable = ({ teamId }: TeamBillingInvoicesDataTableProps) => { + const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamInvoices.useQuery( + { + teamId, + }, + { + keepPreviousData: true, + }, + ); + + const formatCurrency = (currency: string, amount: number) => { + const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + }); + + return formatter.format(amount); + }; + + const results = { + data: data?.data ?? [], + perPage: 100, + currentPage: 1, + totalPages: 1, + }; + + return ( + ( +
    + + +
    + + {DateTime.fromSeconds(row.original.created).toFormat('MMMM yyyy')} + + + {row.original.quantity} {row.original.quantity > 1 ? 'Seats' : 'Seat'} + +
    +
    + ), + }, + { + header: 'Status', + accessorKey: 'status', + cell: ({ row }) => { + const { status, paid } = row.original; + + if (!status) { + return paid ? 'Paid' : 'Unpaid'; + } + + return status.charAt(0).toUpperCase() + status.slice(1); + }, + }, + { + header: 'Amount', + accessorKey: 'total', + cell: ({ row }) => formatCurrency(row.original.currency, row.original.total / 100), + }, + { + id: 'actions', + cell: ({ row }) => ( +
    + + + +
    + ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + +
    + + +
    + + +
    +
    +
    + + + + + + + +
    + + +
    +
    + + ), + }} + > + {(table) => } +
    + ); +}; diff --git a/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx b/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx new file mode 100644 index 000000000..f0e3580e3 --- /dev/null +++ b/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx @@ -0,0 +1,203 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; + +import { History, MoreHorizontal, Trash2 } from 'lucide-react'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { trpc } from '@documenso/trpc/react'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { LocaleDate } from '~/components/formatter/locale-date'; + +export type TeamMemberInvitesDataTableProps = { + teamId: number; +}; + +export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTableProps) => { + const searchParams = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const { toast } = useToast(); + + const parsedSearchParams = ZBaseTableSearchParamsSchema.parse( + Object.fromEntries(searchParams ?? []), + ); + + const { data, isLoading, isInitialLoading, isLoadingError } = + trpc.team.findTeamMemberInvites.useQuery( + { + teamId, + term: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + keepPreviousData: true, + }, + ); + + const { mutateAsync: resendTeamMemberInvitation } = + trpc.team.resendTeamMemberInvitation.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Invitation has been resent', + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + description: 'Unable to resend invitation. Please try again.', + variant: 'destructive', + }); + }, + }); + + const { mutateAsync: deleteTeamMemberInvitations } = + trpc.team.deleteTeamMemberInvitations.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Invitation has been deleted', + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + description: 'Unable to delete invitation. Please try again.', + variant: 'destructive', + }); + }, + }); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + return ( + { + return ( + {row.original.email} + } + /> + ); + }, + }, + { + header: 'Role', + accessorKey: 'role', + cell: ({ row }) => TEAM_MEMBER_ROLE_MAP[row.original.role] ?? row.original.role, + }, + { + header: 'Invited At', + accessorKey: 'createdAt', + cell: ({ row }) => , + }, + { + header: 'Actions', + cell: ({ row }) => ( + + + + + + + Actions + + + resendTeamMemberInvitation({ + teamId, + invitationId: row.original.id, + }) + } + > + + Resend + + + + deleteTeamMemberInvitations({ + teamId, + invitationIds: [row.original.id], + }) + } + > + + Remove + + + + ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + +
    + + +
    +
    + + + + + + + + + + + ), + }} + > + {(table) => } +
    + ); +}; diff --git a/apps/web/src/components/(teams)/tables/team-members-data-table.tsx b/apps/web/src/components/(teams)/tables/team-members-data-table.tsx new file mode 100644 index 000000000..3002ecbb0 --- /dev/null +++ b/apps/web/src/components/(teams)/tables/team-members-data-table.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; + +import { Edit, MoreHorizontal, Trash2 } from 'lucide-react'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import type { TeamMemberRole } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +import { LocaleDate } from '~/components/formatter/locale-date'; + +import { DeleteTeamMemberDialog } from '../dialogs/delete-team-member-dialog'; +import { UpdateTeamMemberDialog } from '../dialogs/update-team-member-dialog'; + +export type TeamMembersDataTableProps = { + currentUserTeamRole: TeamMemberRole; + teamOwnerUserId: number; + teamId: number; + teamName: string; +}; + +export const TeamMembersDataTable = ({ + currentUserTeamRole, + teamOwnerUserId, + teamId, + teamName, +}: TeamMembersDataTableProps) => { + const searchParams = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZBaseTableSearchParamsSchema.parse( + Object.fromEntries(searchParams ?? []), + ); + + const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamMembers.useQuery( + { + teamId, + term: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + keepPreviousData: true, + }, + ); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + return ( + { + const avatarFallbackText = row.original.user.name + ? extractInitials(row.original.user.name) + : row.original.user.email.slice(0, 1).toUpperCase(); + + return ( + {row.original.user.name} + } + secondaryText={row.original.user.email} + /> + ); + }, + }, + { + header: 'Role', + accessorKey: 'role', + cell: ({ row }) => + teamOwnerUserId === row.original.userId + ? 'Owner' + : TEAM_MEMBER_ROLE_MAP[row.original.role], + }, + { + header: 'Member Since', + accessorKey: 'createdAt', + cell: ({ row }) => , + }, + { + header: 'Actions', + cell: ({ row }) => ( + + + + + + + Actions + + e.preventDefault()} + title="Update team member role" + > + + Update role + + } + /> + + e.preventDefault()} + disabled={ + teamOwnerUserId === row.original.userId || + !isTeamRoleWithinUserHierarchy(currentUserTeamRole, row.original.role) + } + title="Remove team member" + > + + Remove + + } + /> + + + ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + +
    + + +
    + + +
    +
    +
    + + + + + + + + + + + ), + }} + > + {(table) => } +
    + ); +}; diff --git a/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx b/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx new file mode 100644 index 000000000..316c4373f --- /dev/null +++ b/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import Link from 'next/link'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; + +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import type { TeamMemberRole } from '@documenso/prisma/client'; +import { Input } from '@documenso/ui/primitives/input'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; + +import { TeamMemberInvitesDataTable } from '~/components/(teams)/tables/team-member-invites-data-table'; +import { TeamMembersDataTable } from '~/components/(teams)/tables/team-members-data-table'; + +export type TeamsMemberPageDataTableProps = { + currentUserTeamRole: TeamMemberRole; + teamId: number; + teamName: string; + teamOwnerUserId: number; +}; + +export const TeamsMemberPageDataTable = ({ + currentUserTeamRole, + teamId, + teamName, + teamOwnerUserId, +}: TeamsMemberPageDataTableProps) => { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? ''); + + const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); + + const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members'; + + /** + * Handle debouncing the search query. + */ + useEffect(() => { + if (!pathname) { + return; + } + + const params = new URLSearchParams(searchParams?.toString()); + + params.set('query', debouncedSearchQuery); + + if (debouncedSearchQuery === '') { + params.delete('query'); + } + + router.push(`${pathname}?${params.toString()}`); + }, [debouncedSearchQuery, pathname, router, searchParams]); + + return ( +
    +
    + setSearchQuery(e.target.value)} + placeholder="Search" + /> + + + + + All + + + + Pending + + + +
    + + {currentTab === 'invites' ? ( + + ) : ( + + )} +
    + ); +}; diff --git a/apps/web/src/components/(teams)/tables/user-settings-teams-page-data-table.tsx b/apps/web/src/components/(teams)/tables/user-settings-teams-page-data-table.tsx new file mode 100644 index 000000000..277421263 --- /dev/null +++ b/apps/web/src/components/(teams)/tables/user-settings-teams-page-data-table.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import Link from 'next/link'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; + +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { trpc } from '@documenso/trpc/react'; +import { Input } from '@documenso/ui/primitives/input'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; + +import { CurrentUserTeamsDataTable } from './current-user-teams-data-table'; +import { PendingUserTeamsDataTable } from './pending-user-teams-data-table'; + +export const UserSettingsTeamsPageDataTable = () => { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? ''); + + const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); + + const currentTab = searchParams?.get('tab') === 'pending' ? 'pending' : 'active'; + + const { data } = trpc.team.findTeamsPending.useQuery( + {}, + { + keepPreviousData: true, + }, + ); + + /** + * Handle debouncing the search query. + */ + useEffect(() => { + if (!pathname) { + return; + } + + const params = new URLSearchParams(searchParams?.toString()); + + params.set('query', debouncedSearchQuery); + + if (debouncedSearchQuery === '') { + params.delete('query'); + } + + router.push(`${pathname}?${params.toString()}`); + }, [debouncedSearchQuery, pathname, router, searchParams]); + + return ( +
    +
    + setSearchQuery(e.target.value)} + placeholder="Search" + /> + + + + + Active + + + + + Pending + {data && data.count > 0 && ( + {data.count} + )} + + + + +
    + + {currentTab === 'pending' ? : } +
    + ); +}; diff --git a/apps/web/src/components/(teams)/team-billing-portal-button.tsx b/apps/web/src/components/(teams)/team-billing-portal-button.tsx new file mode 100644 index 000000000..808b9b9ba --- /dev/null +++ b/apps/web/src/components/(teams)/team-billing-portal-button.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type TeamBillingPortalButtonProps = { + buttonProps?: React.ComponentProps; + teamId: number; +}; + +export const TeamBillingPortalButton = ({ buttonProps, teamId }: TeamBillingPortalButtonProps) => { + const { toast } = useToast(); + + const { mutateAsync: createBillingPortal, isLoading } = + trpc.team.createBillingPortal.useMutation(); + + const handleCreatePortal = async () => { + try { + const sessionUrl = await createBillingPortal({ teamId }); + + window.open(sessionUrl, '_blank'); + } catch (err) { + toast({ + title: 'Something went wrong', + description: + 'We are unable to proceed to the billing portal at this time. Please try again, or contact support.', + variant: 'destructive', + duration: 10000, + }); + } + }; + + return ( + + ); +}; diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index b3e4ea019..b21e9621b 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -55,10 +55,11 @@ export type TSignInFormSchema = z.infer; export type SignInFormProps = { className?: string; + initialEmail?: string; isGoogleSSOEnabled?: boolean; }; -export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) => { +export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => { const { toast } = useToast(); const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] = useState(false); @@ -69,7 +70,7 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) = const form = useForm({ values: { - email: '', + email: initialEmail ?? '', password: '', totpCode: '', backupCode: '', diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index f38ab15d1..430c7ebdf 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -48,17 +48,18 @@ export type TSignUpFormSchema = z.infer; export type SignUpFormProps = { className?: string; + initialEmail?: string; isGoogleSSOEnabled?: boolean; }; -export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) => { +export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => { const { toast } = useToast(); const analytics = useAnalytics(); const form = useForm({ values: { name: '', - email: '', + email: initialEmail ?? '', password: '', signature: '', }, diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 25bfbbb40..46ee93fdf 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,14 +1,62 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; import { getToken } from 'next-auth/jwt'; +import { TEAM_URL_ROOT_REGEX } from '@documenso/lib/constants/teams'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; + export default async function middleware(req: NextRequest) { + const preferredTeamUrl = cookies().get('preferred-team-url'); + + const referrer = req.headers.get('referer'); + const referrerUrl = referrer ? new URL(referrer) : null; + const referrerPathname = referrerUrl ? referrerUrl.pathname : null; + + // Whether to reset the preferred team url cookie if the user accesses a non team page from a team page. + const resetPreferredTeamUrl = + referrerPathname && + referrerPathname.startsWith('/t/') && + (!req.nextUrl.pathname.startsWith('/t/') || req.nextUrl.pathname === '/'); + + // Redirect root page to `/documents` or `/t/{preferredTeamUrl}/documents`. if (req.nextUrl.pathname === '/') { - const redirectUrl = new URL('/documents', req.url); + const redirectUrlPath = formatDocumentsPath( + resetPreferredTeamUrl ? undefined : preferredTeamUrl?.value, + ); + + const redirectUrl = new URL(redirectUrlPath, req.url); + const response = NextResponse.redirect(redirectUrl); + + return response; + } + + // Redirect `/t` to `/settings/teams`. + if (req.nextUrl.pathname === '/t') { + const redirectUrl = new URL('/settings/teams', req.url); return NextResponse.redirect(redirectUrl); } + // Redirect `/t/` to `/t//documents`. + if (TEAM_URL_ROOT_REGEX.test(req.nextUrl.pathname)) { + const redirectUrl = new URL(`${req.nextUrl.pathname}/documents`, req.url); + + const response = NextResponse.redirect(redirectUrl); + response.cookies.set('preferred-team-url', req.nextUrl.pathname.replace('/t/', '')); + + return response; + } + + // Set the preferred team url cookie if user accesses a team page. + if (req.nextUrl.pathname.startsWith('/t/')) { + const response = NextResponse.next(); + response.cookies.set('preferred-team-url', req.nextUrl.pathname.split('/')[2]); + + return response; + } + if (req.nextUrl.pathname.startsWith('/signin')) { const token = await getToken({ req }); @@ -19,5 +67,34 @@ export default async function middleware(req: NextRequest) { } } + // Clear preferred team url cookie if user accesses a non team page from a team page. + if (resetPreferredTeamUrl || req.nextUrl.pathname === '/documents') { + const response = NextResponse.next(); + response.cookies.set('preferred-team-url', ''); + + return response; + } + return NextResponse.next(); } + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - ingest (analytics) + * - site.webmanifest + */ + { + source: '/((?!api|_next/static|_next/image|ingest|favicon|site.webmanifest).*)', + missing: [ + { type: 'header', key: 'next-router-prefetch' }, + { type: 'header', key: 'purpose', value: 'prefetch' }, + ], + }, + ], +}; diff --git a/package-lock.json b/package-lock.json index 9012d3f29..aae034c57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4886,9 +4886,9 @@ } }, "node_modules/@radix-ui/react-select": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-1.2.2.tgz", - "integrity": "sha512-zI7McXr8fNaSrUY9mZe4x/HC0jTLY9fWNhO1oLWYMQGDXuV4UCivIGTxwioSzO0ZCYX9iSLyWmAh/1TOmX3Cnw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz", + "integrity": "sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==", "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/number": "1.0.1", @@ -4897,12 +4897,12 @@ "@radix-ui/react-compose-refs": "1.0.1", "@radix-ui/react-context": "1.0.1", "@radix-ui/react-direction": "1.0.1", - "@radix-ui/react-dismissable-layer": "1.0.4", + "@radix-ui/react-dismissable-layer": "1.0.5", "@radix-ui/react-focus-guards": "1.0.1", - "@radix-ui/react-focus-scope": "1.0.3", + "@radix-ui/react-focus-scope": "1.0.4", "@radix-ui/react-id": "1.0.1", - "@radix-ui/react-popper": "1.1.2", - "@radix-ui/react-portal": "1.0.3", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", "@radix-ui/react-primitive": "1.0.3", "@radix-ui/react-slot": "1.0.2", "@radix-ui/react-use-callback-ref": "1.0.1", @@ -4928,113 +4928,6 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.4.tgz", - "integrity": "sha512-7UpBa/RKMoHJYjie1gkF1DlK8l1fdU/VKDpoS3rCCo8YBJR294GwcEHyxHw72yvphJ7ld0AXEcSLAzY2F/WyCg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1", - "@radix-ui/react-use-escape-keydown": "1.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-focus-scope": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.3.tgz", - "integrity": "sha512-upXdPfqI4islj2CslyfUBNlaJCPybbqRHAi1KER7Isel9Q2AtSJ0zRBZv8mWQiFXD2nyAJ4BhC3yXgZ6kMBSrQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-popper": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.2.tgz", - "integrity": "sha512-1CnGGfFi/bbqtJZZ0P/NQY20xdG3E0LALJaLUEoKwPLwl6PPPfbeiCqMVQnhoFRAxjJj4RpBRJzDmUgsex2tSg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.0.3", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1", - "@radix-ui/react-use-layout-effect": "1.0.1", - "@radix-ui/react-use-rect": "1.0.1", - "@radix-ui/react-use-size": "1.0.1", - "@radix-ui/rect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-portal": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.3.tgz", - "integrity": "sha512-xLYZeHrWoPmA5mEKEfZZevoVRK/Q43GfzRXkWV6qawIWWK8t6ifIiLQdd7rmQ4Vk1bmI21XhqF9BN3jWf+phpA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "1.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-separator": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz", @@ -19750,13 +19643,19 @@ "@prisma/client": "5.4.2", "dotenv": "^16.3.1", "dotenv-cli": "^7.3.0", - "prisma": "5.4.2" + "prisma": "5.4.2", + "ts-pattern": "^5.0.6" }, "devDependencies": { "ts-node": "^10.9.1", "typescript": "5.2.2" } }, + "packages/prisma/node_modules/ts-pattern": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.6.tgz", + "integrity": "sha512-Y+jOjihlFriWzcBjncPCf2/am+Hgz7LtsWs77pWg5vQQKLQj07oNrJryo/wK2G0ndNaoVn2ownFMeoeAuReu3Q==" + }, "packages/prisma/node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", @@ -19864,7 +19763,7 @@ "@radix-ui/react-checkbox": "^1.0.3", "@radix-ui/react-collapsible": "^1.0.2", "@radix-ui/react-context-menu": "^2.1.3", - "@radix-ui/react-dialog": "^1.0.3", + "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.4", "@radix-ui/react-hover-card": "^1.0.5", "@radix-ui/react-label": "^2.0.1", @@ -19874,7 +19773,7 @@ "@radix-ui/react-progress": "^1.0.2", "@radix-ui/react-radio-group": "^1.1.2", "@radix-ui/react-scroll-area": "^1.0.3", - "@radix-ui/react-select": "^1.2.1", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.2", "@radix-ui/react-slider": "^1.1.1", "@radix-ui/react-slot": "^1.0.2", diff --git a/packages/app-tests/e2e/fixtures/authentication.ts b/packages/app-tests/e2e/fixtures/authentication.ts new file mode 100644 index 000000000..f1926fb2a --- /dev/null +++ b/packages/app-tests/e2e/fixtures/authentication.ts @@ -0,0 +1,40 @@ +import type { Page } from '@playwright/test'; + +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; + +type ManualLoginOptions = { + page: Page; + email?: string; + password?: string; + + /** + * Where to navigate after login. + */ + redirectPath?: string; +}; + +export const manualLogin = async ({ + page, + email = 'example@documenso.com', + password = 'password', + redirectPath, +}: ManualLoginOptions) => { + await page.goto(`${WEBAPP_BASE_URL}/signin`); + + await page.getByLabel('Email').click(); + await page.getByLabel('Email').fill(email); + + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByLabel('Password', { exact: true }).press('Enter'); + + if (redirectPath) { + await page.waitForURL(`${WEBAPP_BASE_URL}/documents`); + await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`); + } +}; + +export const manualSignout = async ({ page }: ManualLoginOptions) => { + await page.getByTestId('menu-switcher').click(); + await page.getByRole('menuitem', { name: 'Sign Out' }).click(); + await page.waitForURL(`${WEBAPP_BASE_URL}/signin`); +}; diff --git a/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts b/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts index 12a099bbf..da95c66f0 100644 --- a/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts +++ b/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts @@ -2,6 +2,8 @@ import { expect, test } from '@playwright/test'; import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents'; +import { manualLogin, manualSignout } from './fixtures/authentication'; + test.describe.configure({ mode: 'serial' }); test('[PR-711]: seeded documents should be visible', async ({ page }) => { @@ -19,17 +21,11 @@ test('[PR-711]: seeded documents should be visible', async ({ page }) => { await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible(); await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible(); - await page.getByTitle('Profile Dropdown').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); - - await page.waitForURL('/signin'); + await manualSignout({ page }); for (const recipient of recipients) { - await page.goto('/signin'); - - await page.getByLabel('Email').fill(recipient.email); - await page.getByLabel('Password', { exact: true }).fill(recipient.password); - await page.getByRole('button', { name: 'Sign In' }).click(); + await page.waitForURL('/signin'); + await manualLogin({ page, email: recipient.email, password: recipient.password }); await page.waitForURL('/documents'); @@ -38,10 +34,7 @@ test('[PR-711]: seeded documents should be visible', async ({ page }) => { await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible(); - await page.getByTitle('Profile Dropdown').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); - - await page.waitForURL('/signin'); + await manualSignout({ page }); } }); @@ -74,13 +67,10 @@ test('[PR-711]: deleting a completed document should not remove it from recipien await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible(); - // signout - await page.getByTitle('Profile Dropdown').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); - - await page.waitForURL('/signin'); + await manualSignout({ page }); for (const recipient of recipients) { + await page.waitForURL('/signin'); await page.goto('/signin'); // sign in @@ -96,11 +86,7 @@ test('[PR-711]: deleting a completed document should not remove it from recipien await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible(); await page.goto('/documents'); - - await page.getByTitle('Profile Dropdown').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); - - await page.waitForURL('/signin'); + await manualSignout({ page }); } }); @@ -115,11 +101,7 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a await page.goto('/signin'); - // sign in - await page.getByLabel('Email').fill(sender.email); - await page.getByLabel('Password', { exact: true }).fill(sender.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - + await manualLogin({ page, email: sender.email, password: sender.password }); await page.waitForURL('/documents'); // open actions menu @@ -133,19 +115,12 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible(); // signout - await page.getByTitle('Profile Dropdown').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); - - await page.waitForURL('/signin'); + await manualSignout({ page }); for (const recipient of recipients) { - await page.goto('/signin'); - - // sign in - await page.getByLabel('Email').fill(recipient.email); - await page.getByLabel('Password', { exact: true }).fill(recipient.password); - await page.getByRole('button', { name: 'Sign In' }).click(); + await page.waitForURL('/signin'); + await manualLogin({ page, email: recipient.email, password: recipient.password }); await page.waitForURL('/documents'); await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible(); @@ -154,11 +129,9 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible(); await page.goto('/documents'); + await page.waitForURL('/documents'); - await page.getByTitle('Profile Dropdown').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); - - await page.waitForURL('/signin'); + await manualSignout({ page }); } }); @@ -167,13 +140,7 @@ test('[PR-711]: deleting a draft document should remove it without additional pr }) => { const [sender] = TEST_USERS; - await page.goto('/signin'); - - // sign in - await page.getByLabel('Email').fill(sender.email); - await page.getByLabel('Password', { exact: true }).fill(sender.password); - await page.getByRole('button', { name: 'Sign In' }).click(); - + await manualLogin({ page, email: sender.email, password: sender.password }); await page.waitForURL('/documents'); // open actions menu diff --git a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts index e9ae60d0e..160113f95 100644 --- a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts +++ b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts @@ -17,12 +17,6 @@ test('[PR-713]: should see sent documents', async ({ page }) => { await page.getByPlaceholder('Type a command or search...').fill('sent'); await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible(); - - await page.keyboard.press('Escape'); - - // signout - await page.getByTitle('Profile Dropdown').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); }); test('[PR-713]: should see received documents', async ({ page }) => { @@ -40,12 +34,6 @@ test('[PR-713]: should see received documents', async ({ page }) => { await page.getByPlaceholder('Type a command or search...').fill('received'); await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible(); - - await page.keyboard.press('Escape'); - - // signout - await page.getByTitle('Profile Dropdown').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); }); test('[PR-713]: should be able to search by recipient', async ({ page }) => { @@ -63,10 +51,4 @@ test('[PR-713]: should be able to search by recipient', async ({ page }) => { await page.getByPlaceholder('Type a command or search...').fill(recipient.email); await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible(); - - await page.keyboard.press('Escape'); - - // signout - await page.getByTitle('Profile Dropdown').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); }); diff --git a/packages/app-tests/e2e/teams/manage-team.spec.ts b/packages/app-tests/e2e/teams/manage-team.spec.ts new file mode 100644 index 000000000..aed56b2bc --- /dev/null +++ b/packages/app-tests/e2e/teams/manage-team.spec.ts @@ -0,0 +1,87 @@ +import { test } from '@playwright/test'; + +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { manualLogin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +test('[TEAMS]: create team', async ({ page }) => { + const user = await seedUser(); + + await manualLogin({ + page, + email: user.email, + redirectPath: '/settings/teams', + }); + + const teamId = `team-${Date.now()}`; + + // Create team. + await page.getByRole('button', { name: 'Create team' }).click(); + await page.getByLabel('Team Name*').fill(teamId); + await page.getByTestId('dialog-create-team-button').click(); + + await page.getByTestId('dialog-create-team-button').waitFor({ state: 'hidden' }); + + const isCheckoutRequired = page.url().includes('pending'); + test.skip(isCheckoutRequired, 'Test skipped because billing is enabled.'); + + // Goto new team settings page. + await page.getByRole('row').filter({ hasText: teamId }).getByRole('link').nth(1).click(); + + await unseedTeam(teamId); +}); + +test('[TEAMS]: delete team', async ({ page }) => { + const team = await seedTeam(); + + await manualLogin({ + page, + email: team.owner.email, + redirectPath: `/t/${team.url}/settings`, + }); + + // Delete team. + await page.getByRole('button', { name: 'Delete team' }).click(); + await page.getByLabel(`Confirm by typing delete ${team.url}`).fill(`delete ${team.url}`); + await page.getByRole('button', { name: 'Delete' }).click(); + + // Check that we have been redirected to the teams page. + await page.waitForURL(`${WEBAPP_BASE_URL}/settings/teams`); +}); + +test('[TEAMS]: update team', async ({ page }) => { + const team = await seedTeam(); + + await manualLogin({ + page, + email: team.owner.email, + }); + + // Navigate to create team page. + await page.getByTestId('menu-switcher').click(); + await page.getByRole('menuitem', { name: 'Manage teams' }).click(); + + // Goto team settings page. + await page.getByRole('row').filter({ hasText: team.url }).getByRole('link').nth(1).click(); + + const updatedTeamId = `team-${Date.now()}`; + + // Update team. + await page.getByLabel('Team Name*').click(); + await page.getByLabel('Team Name*').clear(); + await page.getByLabel('Team Name*').fill(updatedTeamId); + await page.getByLabel('Team URL*').click(); + await page.getByLabel('Team URL*').clear(); + await page.getByLabel('Team URL*').fill(updatedTeamId); + + await page.getByRole('button', { name: 'Update team' }).click(); + + // Check we have been redirected to the new team URL and the name is updated. + await page.waitForURL(`${WEBAPP_BASE_URL}/t/${updatedTeamId}/settings`); + + await unseedTeam(updatedTeamId); +}); diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts new file mode 100644 index 000000000..210189ca7 --- /dev/null +++ b/packages/app-tests/e2e/teams/team-documents.spec.ts @@ -0,0 +1,282 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import { DocumentStatus } from '@documenso/prisma/client'; +import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents'; +import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { manualLogin, manualSignout } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => { + await page.getByRole('tab', { name: tabName }).click(); + + if (tabName !== 'All') { + await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString()); + } + + if (count === 0) { + await expect(page.getByRole('main')).toContainText(`Nothing to do`); + return; + } + + await expect(page.getByRole('main')).toContainText(`Showing ${count}`); +}; + +test('[TEAMS]: check team documents count', async ({ page }) => { + const { team, teamMember2 } = await seedTeamDocuments(); + + // Run the test twice, once with the team owner and once with a team member to ensure the counts are the same. + for (const user of [team.owner, teamMember2]) { + await manualLogin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Check document counts. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 2); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'Draft', 2); + await checkDocumentTabCount(page, 'All', 5); + + // Apply filter. + await page.locator('button').filter({ hasText: 'Sender: All' }).click(); + await page.getByRole('option', { name: teamMember2.name ?? '' }).click(); + await page.waitForURL(/senderIds/); + + // Check counts after filtering. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 2); + await checkDocumentTabCount(page, 'Completed', 0); + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'All', 3); + + await manualSignout({ page }); + } + + await unseedTeam(team.url); +}); + +test('[TEAMS]: check team documents count with internal team email', async ({ page }) => { + const { team, teamMember2, teamMember4 } = await seedTeamDocuments(); + const { team: team2, teamMember2: team2Member2 } = await seedTeamDocuments(); + + const teamEmailMember = teamMember4; + + await seedTeamEmail({ + email: teamEmailMember.email, + teamId: team.id, + }); + + const testUser1 = await seedUser(); + + await seedDocuments([ + // Documents sent from the team email account. + { + sender: teamEmailMember, + recipients: [testUser1], + type: DocumentStatus.COMPLETED, + documentOptions: { + teamId: team.id, + }, + }, + { + sender: teamEmailMember, + recipients: [testUser1], + type: DocumentStatus.PENDING, + documentOptions: { + teamId: team.id, + }, + }, + { + sender: teamMember4, + recipients: [testUser1], + type: DocumentStatus.DRAFT, + }, + // Documents sent to the team email account. + { + sender: testUser1, + recipients: [teamEmailMember], + type: DocumentStatus.COMPLETED, + }, + { + sender: testUser1, + recipients: [teamEmailMember], + type: DocumentStatus.PENDING, + }, + { + sender: testUser1, + recipients: [teamEmailMember], + type: DocumentStatus.DRAFT, + }, + // Document sent to the team email account from another team. + { + sender: team2Member2, + recipients: [teamEmailMember], + type: DocumentStatus.PENDING, + documentOptions: { + teamId: team2.id, + }, + }, + ]); + + // Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same. + for (const user of [team.owner, teamEmailMember]) { + await manualLogin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Check document counts. + await checkDocumentTabCount(page, 'Inbox', 2); + await checkDocumentTabCount(page, 'Pending', 3); + await checkDocumentTabCount(page, 'Completed', 3); + await checkDocumentTabCount(page, 'Draft', 3); + await checkDocumentTabCount(page, 'All', 11); + + // Apply filter. + await page.locator('button').filter({ hasText: 'Sender: All' }).click(); + await page.getByRole('option', { name: teamMember2.name ?? '' }).click(); + await page.waitForURL(/senderIds/); + + // Check counts after filtering. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 2); + await checkDocumentTabCount(page, 'Completed', 0); + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'All', 3); + + await manualSignout({ page }); + } + + await unseedTeamEmail({ teamId: team.id }); + await unseedTeam(team.url); +}); + +test('[TEAMS]: check team documents count with external team email', async ({ page }) => { + const { team, teamMember2 } = await seedTeamDocuments(); + const { team: team2, teamMember2: team2Member2 } = await seedTeamDocuments(); + + const teamEmail = `external-team-email-${team.id}@test.documenso.com`; + + await seedTeamEmail({ + email: teamEmail, + teamId: team.id, + }); + + const testUser1 = await seedUser(); + + await seedDocuments([ + // Documents sent to the team email account. + { + sender: testUser1, + recipients: [teamEmail], + type: DocumentStatus.COMPLETED, + }, + { + sender: testUser1, + recipients: [teamEmail], + type: DocumentStatus.PENDING, + }, + { + sender: testUser1, + recipients: [teamEmail], + type: DocumentStatus.DRAFT, + }, + // Document sent to the team email account from another team. + { + sender: team2Member2, + recipients: [teamEmail], + type: DocumentStatus.PENDING, + documentOptions: { + teamId: team2.id, + }, + }, + // Document sent to the team email account from an individual user. + { + sender: testUser1, + recipients: [teamEmail], + type: DocumentStatus.PENDING, + documentOptions: { + teamId: team2.id, + }, + }, + { + sender: testUser1, + recipients: [teamEmail], + type: DocumentStatus.DRAFT, + documentOptions: { + teamId: team2.id, + }, + }, + ]); + + await manualLogin({ + page, + email: teamMember2.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Check document counts. + await checkDocumentTabCount(page, 'Inbox', 3); + await checkDocumentTabCount(page, 'Pending', 2); + await checkDocumentTabCount(page, 'Completed', 2); + await checkDocumentTabCount(page, 'Draft', 2); + await checkDocumentTabCount(page, 'All', 9); + + // Apply filter. + await page.locator('button').filter({ hasText: 'Sender: All' }).click(); + await page.getByRole('option', { name: teamMember2.name ?? '' }).click(); + await page.waitForURL(/senderIds/); + + // Check counts after filtering. + await checkDocumentTabCount(page, 'Inbox', 0); + await checkDocumentTabCount(page, 'Pending', 2); + await checkDocumentTabCount(page, 'Completed', 0); + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'All', 3); + + await unseedTeamEmail({ teamId: team.id }); + await unseedTeam(team.url); +}); + +test('[TEAMS]: delete pending team document', async ({ page }) => { + const { team, teamMember2: currentUser } = await seedTeamDocuments(); + + await manualLogin({ + page, + email: currentUser.email, + redirectPath: `/t/${team.url}/documents?status=PENDING`, + }); + + await page.getByRole('row').getByRole('button').nth(1).click(); + + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByPlaceholder("Type 'delete' to confirm").fill('delete'); + await page.getByRole('button', { name: 'Delete' }).click(); + + await checkDocumentTabCount(page, 'Pending', 1); +}); + +test('[TEAMS]: resend pending team document', async ({ page }) => { + const { team, teamMember2: currentUser } = await seedTeamDocuments(); + + await manualLogin({ + page, + email: currentUser.email, + redirectPath: `/t/${team.url}/documents?status=PENDING`, + }); + + await page.getByRole('row').getByRole('button').nth(1).click(); + await page.getByRole('menuitem', { name: 'Resend' }).click(); + + await page.getByLabel('test.documenso.com').first().click(); + await page.getByRole('button', { name: 'Send reminder' }).click(); + + await expect(page.getByRole('status')).toContainText('Document re-sent'); +}); diff --git a/packages/app-tests/e2e/teams/team-email.spec.ts b/packages/app-tests/e2e/teams/team-email.spec.ts new file mode 100644 index 000000000..953be5aaf --- /dev/null +++ b/packages/app-tests/e2e/teams/team-email.spec.ts @@ -0,0 +1,102 @@ +import { expect, test } from '@playwright/test'; + +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams'; +import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; + +import { manualLogin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +test('[TEAMS]: send team email request', async ({ page }) => { + const team = await seedTeam(); + + await manualLogin({ + page, + email: team.owner.email, + password: 'password', + redirectPath: `/t/${team.url}/settings`, + }); + + await page.getByRole('button', { name: 'Add email' }).click(); + await page.getByPlaceholder('eg. Legal').click(); + await page.getByPlaceholder('eg. Legal').fill('test@test.documenso.com'); + await page.getByPlaceholder('example@example.com').click(); + await page.getByPlaceholder('example@example.com').fill('test@test.documenso.com'); + await page.getByRole('button', { name: 'Add' }).click(); + + await expect( + page + .getByRole('status') + .filter({ hasText: 'We have sent a confirmation email for verification.' }) + .first(), + ).toBeVisible(); + + await unseedTeam(team.url); +}); + +test('[TEAMS]: accept team email request', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const teamEmailVerification = await seedTeamEmailVerification({ + email: 'team-email-verification@test.documenso.com', + teamId: team.id, + }); + + await page.goto(`${WEBAPP_BASE_URL}/team/verify/email/${teamEmailVerification.token}`); + await expect(page.getByRole('heading')).toContainText('Team email verified!'); + + await unseedTeam(team.url); +}); + +test('[TEAMS]: delete team email', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + createTeamEmail: true, + }); + + await manualLogin({ + page, + email: team.owner.email, + redirectPath: `/t/${team.url}/settings`, + }); + + await page.locator('section div').filter({ hasText: 'Team email' }).getByRole('button').click(); + + await page.getByRole('menuitem', { name: 'Remove' }).click(); + + await expect(page.getByText('Team email has been removed').first()).toBeVisible(); + + await unseedTeam(team.url); +}); + +test('[TEAMS]: team email owner removes access', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + createTeamEmail: true, + }); + + if (!team.teamEmail) { + throw new Error('Not possible'); + } + + const teamEmailOwner = await seedUser({ + email: team.teamEmail.email, + }); + + await manualLogin({ + page, + email: teamEmailOwner.email, + redirectPath: `/settings/teams`, + }); + + await page.getByRole('button', { name: 'Revoke access' }).click(); + await page.getByRole('button', { name: 'Revoke' }).click(); + + await expect(page.getByText('You have successfully revoked').first()).toBeVisible(); + + await unseedTeam(team.url); + await unseedUser(teamEmailOwner.id); +}); diff --git a/packages/app-tests/e2e/teams/team-members.spec.ts b/packages/app-tests/e2e/teams/team-members.spec.ts new file mode 100644 index 000000000..05f096c09 --- /dev/null +++ b/packages/app-tests/e2e/teams/team-members.spec.ts @@ -0,0 +1,110 @@ +import { expect, test } from '@playwright/test'; + +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { manualLogin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +test('[TEAMS]: update team member role', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + await manualLogin({ + page, + email: team.owner.email, + password: 'password', + redirectPath: `/t/${team.url}/settings/members`, + }); + + const teamMemberToUpdate = team.members[1]; + + await page + .getByRole('row') + .filter({ hasText: teamMemberToUpdate.user.email }) + .getByRole('button') + .click(); + + await page.getByRole('menuitem', { name: 'Update role' }).click(); + await page.getByRole('combobox').click(); + await page.getByLabel('Manager').click(); + await page.getByRole('button', { name: 'Update' }).click(); + await expect( + page.getByRole('row').filter({ hasText: teamMemberToUpdate.user.email }), + ).toContainText('Manager'); + + await unseedTeam(team.url); +}); + +test('[TEAMS]: accept team invitation without account', async ({ page }) => { + const team = await seedTeam(); + + const teamInvite = await seedTeamInvite({ + email: `team-invite-test-${Date.now()}@test.documenso.com`, + teamId: team.id, + }); + + await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`); + await expect(page.getByRole('heading')).toContainText('Team invitation'); + + await unseedTeam(team.url); +}); + +test('[TEAMS]: accept team invitation with account', async ({ page }) => { + const team = await seedTeam(); + const user = await seedUser(); + + const teamInvite = await seedTeamInvite({ + email: user.email, + teamId: team.id, + }); + + await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`); + await expect(page.getByRole('heading')).toContainText('Invitation accepted!'); + + await unseedTeam(team.url); +}); + +test('[TEAMS]: member can leave team', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const teamMember = team.members[1]; + + await manualLogin({ + page, + email: teamMember.user.email, + password: 'password', + redirectPath: `/settings/teams`, + }); + + await page.getByRole('button', { name: 'Leave' }).click(); + await page.getByRole('button', { name: 'Leave' }).click(); + + await expect(page.getByRole('status').first()).toContainText( + 'You have successfully left this team.', + ); + + await unseedTeam(team.url); +}); + +test('[TEAMS]: owner cannot leave team', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + await manualLogin({ + page, + email: team.owner.email, + password: 'password', + redirectPath: `/settings/teams`, + }); + + await expect(page.getByRole('button').getByText('Leave')).toBeDisabled(); + + await unseedTeam(team.url); +}); diff --git a/packages/app-tests/e2e/teams/transfer-team.spec.ts b/packages/app-tests/e2e/teams/transfer-team.spec.ts new file mode 100644 index 000000000..a5d95b720 --- /dev/null +++ b/packages/app-tests/e2e/teams/transfer-team.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/test'; + +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams'; + +import { manualLogin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const teamMember = team.members[1]; + + await manualLogin({ + page, + email: team.owner.email, + password: 'password', + redirectPath: `/t/${team.url}/settings`, + }); + + await page.getByRole('button', { name: 'Transfer team' }).click(); + + await page.getByRole('combobox').click(); + await page.getByLabel(teamMember.user.name ?? '').click(); + await page.getByLabel('Confirm by typing transfer').click(); + await page.getByLabel('Confirm by typing transfer').fill('transfer'); + await page.getByRole('button', { name: 'Transfer' }).click(); + + await expect(page.locator('[id="\\:r2\\:-form-item-message"]')).toContainText( + `You must enter 'transfer ${team.name}' to proceed`, + ); + + await page.getByLabel('Confirm by typing transfer').click(); + await page.getByLabel('Confirm by typing transfer').fill(`transfer ${team.name}`); + await page.getByRole('button', { name: 'Transfer' }).click(); + + await expect(page.getByRole('heading', { name: 'Team transfer in progress' })).toBeVisible(); + await page.getByRole('button', { name: 'Cancel' }).click(); + + await expect(page.getByRole('status').first()).toContainText( + 'The team transfer invitation has been successfully deleted.', + ); + + await unseedTeam(team.url); +}); + +/** + * Current skipped until we disable billing during tests. + */ +test.skip('[TEAMS]: accept team transfer', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const newOwnerMember = team.members[1]; + + const teamTransferRequest = await seedTeamTransfer({ + teamId: team.id, + newOwnerUserId: newOwnerMember.userId, + }); + + await page.goto(`${WEBAPP_BASE_URL}/team/verify/transfer/${teamTransferRequest.token}`); + await expect(page.getByRole('heading')).toContainText('Team ownership transferred!'); + + await unseedTeam(team.url); +}); diff --git a/packages/app-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/test-auth-flow.spec.ts index 45b6dea03..40ee5e768 100644 --- a/packages/app-tests/e2e/test-auth-flow.spec.ts +++ b/packages/app-tests/e2e/test-auth-flow.spec.ts @@ -30,7 +30,7 @@ test('user can sign up with email and password', async ({ page }: { page: Page } await page.mouse.up(); } - await page.getByRole('button', { name: 'Sign Up' }).click(); + await page.getByRole('button', { name: 'Sign Up', exact: true }).click(); await page.waitForURL('/documents'); await expect(page).toHaveURL('/documents'); diff --git a/packages/ee/server-only/limits/client.ts b/packages/ee/server-only/limits/client.ts index 7f48e6856..9a36928b1 100644 --- a/packages/ee/server-only/limits/client.ts +++ b/packages/ee/server-only/limits/client.ts @@ -1,17 +1,23 @@ import { APP_BASE_URL } from '@documenso/lib/constants/app'; import { FREE_PLAN_LIMITS } from './constants'; -import { TLimitsResponseSchema, ZLimitsResponseSchema } from './schema'; +import type { TLimitsResponseSchema } from './schema'; +import { ZLimitsResponseSchema } from './schema'; export type GetLimitsOptions = { headers?: Record; + teamId?: number | null; }; -export const getLimits = async ({ headers }: GetLimitsOptions = {}) => { +export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => { const requestHeaders = headers ?? {}; const url = new URL(`${APP_BASE_URL}/api/limits`); + if (teamId) { + requestHeaders['team-id'] = teamId.toString(); + } + return fetch(url, { headers: { ...requestHeaders, diff --git a/packages/ee/server-only/limits/constants.ts b/packages/ee/server-only/limits/constants.ts index 71ff29d9d..4c428f34f 100644 --- a/packages/ee/server-only/limits/constants.ts +++ b/packages/ee/server-only/limits/constants.ts @@ -1,10 +1,15 @@ -import { TLimitsSchema } from './schema'; +import type { TLimitsSchema } from './schema'; export const FREE_PLAN_LIMITS: TLimitsSchema = { documents: 5, recipients: 10, }; +export const TEAM_PLAN_LIMITS: TLimitsSchema = { + documents: Infinity, + recipients: Infinity, +}; + export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = { documents: Infinity, recipients: Infinity, diff --git a/packages/ee/server-only/limits/handler.ts b/packages/ee/server-only/limits/handler.ts index 69f77db75..a497b2314 100644 --- a/packages/ee/server-only/limits/handler.ts +++ b/packages/ee/server-only/limits/handler.ts @@ -1,10 +1,10 @@ -import { NextApiRequest, NextApiResponse } from 'next'; +import type { NextApiRequest, NextApiResponse } from 'next'; import { getToken } from 'next-auth/jwt'; import { match } from 'ts-pattern'; import { ERROR_CODES } from './errors'; -import { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema'; +import type { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema'; import { getServerLimits } from './server'; export const limitsHandler = async ( @@ -14,7 +14,19 @@ export const limitsHandler = async ( try { const token = await getToken({ req }); - const limits = await getServerLimits({ email: token?.email }); + const rawTeamId = req.headers['team-id']; + + let teamId: number | null = null; + + if (typeof rawTeamId === 'string' && !isNaN(parseInt(rawTeamId, 10))) { + teamId = parseInt(rawTeamId, 10); + } + + if (!teamId && rawTeamId) { + throw new Error(ERROR_CODES.INVALID_TEAM_ID); + } + + const limits = await getServerLimits({ email: token?.email, teamId }); return res.status(200).json(limits); } catch (err) { diff --git a/packages/ee/server-only/limits/provider/client.tsx b/packages/ee/server-only/limits/provider/client.tsx index 07a085750..fdc00b439 100644 --- a/packages/ee/server-only/limits/provider/client.tsx +++ b/packages/ee/server-only/limits/provider/client.tsx @@ -6,7 +6,7 @@ import { equals } from 'remeda'; import { getLimits } from '../client'; import { FREE_PLAN_LIMITS } from '../constants'; -import { TLimitsResponseSchema } from '../schema'; +import type { TLimitsResponseSchema } from '../schema'; export type LimitsContextValue = TLimitsResponseSchema; @@ -24,19 +24,22 @@ export const useLimits = () => { export type LimitsProviderProps = { initialValue?: LimitsContextValue; + teamId?: number; children?: React.ReactNode; }; -export const LimitsProvider = ({ initialValue, children }: LimitsProviderProps) => { - const defaultValue: TLimitsResponseSchema = { +export const LimitsProvider = ({ + initialValue = { quota: FREE_PLAN_LIMITS, remaining: FREE_PLAN_LIMITS, - }; - - const [limits, setLimits] = useState(() => initialValue ?? defaultValue); + }, + teamId, + children, +}: LimitsProviderProps) => { + const [limits, setLimits] = useState(() => initialValue); const refreshLimits = async () => { - const newLimits = await getLimits(); + const newLimits = await getLimits({ teamId }); setLimits((oldLimits) => { if (equals(oldLimits, newLimits)) { diff --git a/packages/ee/server-only/limits/provider/server.tsx b/packages/ee/server-only/limits/provider/server.tsx index c9295483a..b7cde3573 100644 --- a/packages/ee/server-only/limits/provider/server.tsx +++ b/packages/ee/server-only/limits/provider/server.tsx @@ -3,16 +3,22 @@ import { headers } from 'next/headers'; import { getLimits } from '../client'; +import type { LimitsContextValue } from './client'; import { LimitsProvider as ClientLimitsProvider } from './client'; export type LimitsProviderProps = { children?: React.ReactNode; + teamId?: number; }; -export const LimitsProvider = async ({ children }: LimitsProviderProps) => { +export const LimitsProvider = async ({ children, teamId }: LimitsProviderProps) => { const requestHeaders = Object.fromEntries(headers().entries()); - const limits = await getLimits({ headers: requestHeaders }); + const limits: LimitsContextValue = await getLimits({ headers: requestHeaders, teamId }); - return {children}; + return ( + + {children} + + ); }; diff --git a/packages/ee/server-only/limits/server.ts b/packages/ee/server-only/limits/server.ts index f256c6356..e48eb7187 100644 --- a/packages/ee/server-only/limits/server.ts +++ b/packages/ee/server-only/limits/server.ts @@ -1,22 +1,22 @@ import { DateTime } from 'luxon'; -import { getFlag } from '@documenso/lib/universal/get-feature-flag'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; import { prisma } from '@documenso/prisma'; import { SubscriptionStatus } from '@documenso/prisma/client'; -import { getPricesByType } from '../stripe/get-prices-by-type'; -import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants'; +import { getPricesByPlan } from '../stripe/get-prices-by-plan'; +import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants'; import { ERROR_CODES } from './errors'; import { ZLimitsSchema } from './schema'; export type GetServerLimitsOptions = { email?: string | null; + teamId?: number | null; }; -export const getServerLimits = async ({ email }: GetServerLimitsOptions) => { - const isBillingEnabled = await getFlag('app_billing'); - - if (!isBillingEnabled) { +export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => { + if (!IS_BILLING_ENABLED) { return { quota: SELFHOSTED_PLAN_LIMITS, remaining: SELFHOSTED_PLAN_LIMITS, @@ -27,6 +27,14 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => { throw new Error(ERROR_CODES.UNAUTHORIZED); } + return teamId ? handleTeamLimits({ email, teamId }) : handleUserLimits({ email }); +}; + +type HandleUserLimitsOptions = { + email: string; +}; + +const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => { const user = await prisma.user.findFirst({ where: { email, @@ -48,10 +56,10 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => { ); if (activeSubscriptions.length > 0) { - const individualPrices = await getPricesByType('individual'); + const communityPlanPrices = await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY); for (const subscription of activeSubscriptions) { - const price = individualPrices.find((price) => price.id === subscription.priceId); + const price = communityPlanPrices.find((price) => price.id === subscription.priceId); if (!price || typeof price.product === 'string' || price.product.deleted) { continue; } @@ -71,6 +79,7 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => { const documents = await prisma.document.count({ where: { userId: user.id, + teamId: null, createdAt: { gte: DateTime.utc().startOf('month').toJSDate(), }, @@ -84,3 +93,50 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => { remaining, }; }; + +type HandleTeamLimitsOptions = { + email: string; + teamId: number; +}; + +const handleTeamLimits = async ({ email, teamId }: HandleTeamLimitsOptions) => { + const team = await prisma.team.findFirst({ + where: { + id: teamId, + members: { + some: { + user: { + email, + }, + }, + }, + }, + include: { + subscription: true, + }, + }); + + if (!team) { + throw new Error('Team not found'); + } + + const { subscription } = team; + + if (subscription && subscription.status === SubscriptionStatus.INACTIVE) { + return { + quota: { + documents: 0, + recipients: 0, + }, + remaining: { + documents: 0, + recipients: 0, + }, + }; + } + + return { + quota: structuredClone(TEAM_PLAN_LIMITS), + remaining: structuredClone(TEAM_PLAN_LIMITS), + }; +}; diff --git a/packages/ee/server-only/stripe/create-team-customer.ts b/packages/ee/server-only/stripe/create-team-customer.ts new file mode 100644 index 000000000..591c445af --- /dev/null +++ b/packages/ee/server-only/stripe/create-team-customer.ts @@ -0,0 +1,20 @@ +import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing'; +import { stripe } from '@documenso/lib/server-only/stripe'; + +type CreateTeamCustomerOptions = { + name: string; + email: string; +}; + +/** + * Create a Stripe customer for a given team. + */ +export const createTeamCustomer = async ({ name, email }: CreateTeamCustomerOptions) => { + return await stripe.customers.create({ + name, + email, + metadata: { + type: STRIPE_CUSTOMER_TYPE.TEAM, + }, + }); +}; diff --git a/packages/ee/server-only/stripe/delete-customer-payment-methods.ts b/packages/ee/server-only/stripe/delete-customer-payment-methods.ts new file mode 100644 index 000000000..749c15763 --- /dev/null +++ b/packages/ee/server-only/stripe/delete-customer-payment-methods.ts @@ -0,0 +1,22 @@ +import { stripe } from '@documenso/lib/server-only/stripe'; + +type DeleteCustomerPaymentMethodsOptions = { + customerId: string; +}; + +/** + * Delete all attached payment methods for a given customer. + */ +export const deleteCustomerPaymentMethods = async ({ + customerId, +}: DeleteCustomerPaymentMethodsOptions) => { + const paymentMethods = await stripe.paymentMethods.list({ + customer: customerId, + }); + + await Promise.all( + paymentMethods.data.map(async (paymentMethod) => + stripe.paymentMethods.detach(paymentMethod.id), + ), + ); +}; diff --git a/packages/ee/server-only/stripe/get-checkout-session.ts b/packages/ee/server-only/stripe/get-checkout-session.ts index fd15d538a..7c89c1f8c 100644 --- a/packages/ee/server-only/stripe/get-checkout-session.ts +++ b/packages/ee/server-only/stripe/get-checkout-session.ts @@ -1,17 +1,21 @@ 'use server'; +import type Stripe from 'stripe'; + import { stripe } from '@documenso/lib/server-only/stripe'; export type GetCheckoutSessionOptions = { customerId: string; priceId: string; returnUrl: string; + subscriptionMetadata?: Stripe.Metadata; }; export const getCheckoutSession = async ({ customerId, priceId, returnUrl, + subscriptionMetadata, }: GetCheckoutSessionOptions) => { 'use server'; @@ -26,6 +30,9 @@ export const getCheckoutSession = async ({ ], success_url: `${returnUrl}?success=true`, cancel_url: `${returnUrl}?canceled=true`, + subscription_data: { + metadata: subscriptionMetadata, + }, }); return session.url; diff --git a/packages/ee/server-only/stripe/get-community-plan-prices.ts b/packages/ee/server-only/stripe/get-community-plan-prices.ts new file mode 100644 index 000000000..86c7f61bd --- /dev/null +++ b/packages/ee/server-only/stripe/get-community-plan-prices.ts @@ -0,0 +1,13 @@ +import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; + +import { getPricesByPlan } from './get-prices-by-plan'; + +export const getCommunityPlanPrices = async () => { + return await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY); +}; + +export const getCommunityPlanPriceIds = async () => { + const prices = await getCommunityPlanPrices(); + + return prices.map((price) => price.id); +}; diff --git a/packages/ee/server-only/stripe/get-customer.ts b/packages/ee/server-only/stripe/get-customer.ts index c85488e6f..6e2d4f088 100644 --- a/packages/ee/server-only/stripe/get-customer.ts +++ b/packages/ee/server-only/stripe/get-customer.ts @@ -1,15 +1,19 @@ +import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing'; import { stripe } from '@documenso/lib/server-only/stripe'; import { prisma } from '@documenso/prisma'; import type { User } from '@documenso/prisma/client'; import { onSubscriptionUpdated } from './webhook/on-subscription-updated'; +/** + * Get a non team Stripe customer by email. + */ export const getStripeCustomerByEmail = async (email: string) => { const foundStripeCustomers = await stripe.customers.list({ email, }); - return foundStripeCustomers.data[0] ?? null; + return foundStripeCustomers.data.find((customer) => customer.metadata.type !== 'team') ?? null; }; export const getStripeCustomerById = async (stripeCustomerId: string) => { @@ -51,6 +55,7 @@ export const getStripeCustomerByUser = async (user: User) => { email: user.email, metadata: { userId: user.id, + type: STRIPE_CUSTOMER_TYPE.INDIVIDUAL, }, }); } @@ -78,6 +83,14 @@ export const getStripeCustomerByUser = async (user: User) => { }; }; +export const getStripeCustomerIdByUser = async (user: User) => { + if (user.customerId !== null) { + return user.customerId; + } + + return await getStripeCustomerByUser(user).then((session) => session.stripeCustomer.id); +}; + const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => { const stripeSubscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId, diff --git a/packages/ee/server-only/stripe/get-invoices.ts b/packages/ee/server-only/stripe/get-invoices.ts new file mode 100644 index 000000000..f8f383921 --- /dev/null +++ b/packages/ee/server-only/stripe/get-invoices.ts @@ -0,0 +1,11 @@ +import { stripe } from '@documenso/lib/server-only/stripe'; + +export type GetInvoicesOptions = { + customerId: string; +}; + +export const getInvoices = async ({ customerId }: GetInvoicesOptions) => { + return await stripe.invoices.list({ + customer: customerId, + }); +}; diff --git a/packages/ee/server-only/stripe/get-portal-session.ts b/packages/ee/server-only/stripe/get-portal-session.ts index 310cc1e47..275d166d8 100644 --- a/packages/ee/server-only/stripe/get-portal-session.ts +++ b/packages/ee/server-only/stripe/get-portal-session.ts @@ -4,7 +4,7 @@ import { stripe } from '@documenso/lib/server-only/stripe'; export type GetPortalSessionOptions = { customerId: string; - returnUrl: string; + returnUrl?: string; }; export const getPortalSession = async ({ customerId, returnUrl }: GetPortalSessionOptions) => { diff --git a/packages/ee/server-only/stripe/get-prices-by-interval.ts b/packages/ee/server-only/stripe/get-prices-by-interval.ts index a5578a813..1b528706a 100644 --- a/packages/ee/server-only/stripe/get-prices-by-interval.ts +++ b/packages/ee/server-only/stripe/get-prices-by-interval.ts @@ -9,12 +9,12 @@ export type PriceIntervals = Record { +export const getPricesByInterval = async ({ plan }: GetPricesByIntervalOptions = {}) => { let { data: prices } = await stripe.prices.search({ query: `active:'true' type:'recurring'`, expand: ['data.product'], @@ -26,7 +26,7 @@ export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions = // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const product = price.product as Stripe.Product; - const filter = !type || product.metadata?.type === type; + const filter = !plan || product.metadata?.plan === plan; // Filter out prices for products that are not active. return product.active && filter; diff --git a/packages/ee/server-only/stripe/get-prices-by-plan.ts b/packages/ee/server-only/stripe/get-prices-by-plan.ts new file mode 100644 index 000000000..5c390b35a --- /dev/null +++ b/packages/ee/server-only/stripe/get-prices-by-plan.ts @@ -0,0 +1,14 @@ +import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; +import { stripe } from '@documenso/lib/server-only/stripe'; + +export const getPricesByPlan = async ( + plan: (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE], +) => { + const { data: prices } = await stripe.prices.search({ + query: `metadata['plan']:'${plan}' type:'recurring'`, + expand: ['data.product'], + limit: 100, + }); + + return prices; +}; diff --git a/packages/ee/server-only/stripe/get-prices-by-type.ts b/packages/ee/server-only/stripe/get-prices-by-type.ts deleted file mode 100644 index 22124562c..000000000 --- a/packages/ee/server-only/stripe/get-prices-by-type.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { stripe } from '@documenso/lib/server-only/stripe'; - -export const getPricesByType = async (type: 'individual') => { - const { data: prices } = await stripe.prices.search({ - query: `metadata['type']:'${type}' type:'recurring'`, - expand: ['data.product'], - limit: 100, - }); - - return prices; -}; diff --git a/packages/ee/server-only/stripe/get-team-prices.ts b/packages/ee/server-only/stripe/get-team-prices.ts new file mode 100644 index 000000000..5c3021b78 --- /dev/null +++ b/packages/ee/server-only/stripe/get-team-prices.ts @@ -0,0 +1,43 @@ +import type Stripe from 'stripe'; + +import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; +import { AppError } from '@documenso/lib/errors/app-error'; + +import { getPricesByPlan } from './get-prices-by-plan'; + +export const getTeamPrices = async () => { + const prices = (await getPricesByPlan(STRIPE_PLAN_TYPE.TEAM)).filter((price) => price.active); + + const monthlyPrice = prices.find((price) => price.recurring?.interval === 'month'); + const yearlyPrice = prices.find((price) => price.recurring?.interval === 'year'); + const priceIds = prices.map((price) => price.id); + + if (!monthlyPrice || !yearlyPrice) { + throw new AppError('INVALID_CONFIG', 'Missing monthly or yearly price'); + } + + return { + monthly: { + friendlyInterval: 'Monthly', + interval: 'monthly', + ...extractPriceData(monthlyPrice), + }, + yearly: { + friendlyInterval: 'Yearly', + interval: 'yearly', + ...extractPriceData(yearlyPrice), + }, + priceIds, + } as const; +}; + +const extractPriceData = (price: Stripe.Price) => { + const product = + typeof price.product !== 'string' && !price.product.deleted ? price.product : null; + + return { + priceId: price.id, + description: product?.description ?? '', + features: product?.features ?? [], + }; +}; diff --git a/packages/ee/server-only/stripe/transfer-team-subscription.ts b/packages/ee/server-only/stripe/transfer-team-subscription.ts new file mode 100644 index 000000000..b4e0bd59a --- /dev/null +++ b/packages/ee/server-only/stripe/transfer-team-subscription.ts @@ -0,0 +1,126 @@ +import type Stripe from 'stripe'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { stripe } from '@documenso/lib/server-only/stripe'; +import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing'; +import { prisma } from '@documenso/prisma'; +import { type Subscription, type Team, type User } from '@documenso/prisma/client'; + +import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods'; +import { getCommunityPlanPriceIds } from './get-community-plan-prices'; +import { getTeamPrices } from './get-team-prices'; + +type TransferStripeSubscriptionOptions = { + /** + * The user to transfer the subscription to. + */ + user: User & { Subscription: Subscription[] }; + + /** + * The team the subscription is associated with. + */ + team: Team & { subscription?: Subscription | null }; + + /** + * Whether to clear any current payment methods attached to the team. + */ + clearPaymentMethods: boolean; +}; + +/** + * Transfer the Stripe Team seats subscription from one user to another. + * + * Will create a new subscription for the new owner and cancel the old one. + * + * Returns the subscription that should be associated with the team, null if + * no subscription is needed (for community plan). + */ +export const transferTeamSubscription = async ({ + user, + team, + clearPaymentMethods, +}: TransferStripeSubscriptionOptions) => { + const teamCustomerId = team.customerId; + + if (!teamCustomerId) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.'); + } + + const [communityPlanIds, teamSeatPrices] = await Promise.all([ + getCommunityPlanPriceIds(), + getTeamPrices(), + ]); + + const teamSubscriptionRequired = !subscriptionsContainsActiveCommunityPlan( + user.Subscription, + communityPlanIds, + ); + + let teamSubscription: Stripe.Subscription | null = null; + + if (team.subscription) { + teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId); + + if (!teamSubscription) { + throw new Error('Could not find the current subscription.'); + } + + if (clearPaymentMethods) { + await deleteCustomerPaymentMethods({ customerId: teamCustomerId }); + } + } + + await stripe.customers.update(teamCustomerId, { + name: user.name ?? team.name, + email: user.email, + }); + + // If team subscription is required and the team does not have a subscription, create one. + if (teamSubscriptionRequired && !teamSubscription) { + const numberOfSeats = await prisma.teamMember.count({ + where: { + teamId: team.id, + }, + }); + + const teamSeatPriceId = teamSeatPrices.monthly.priceId; + + teamSubscription = await stripe.subscriptions.create({ + customer: teamCustomerId, + items: [ + { + price: teamSeatPriceId, + quantity: numberOfSeats, + }, + ], + metadata: { + teamId: team.id.toString(), + }, + }); + } + + // If no team subscription is required, cancel the current team subscription if it exists. + if (!teamSubscriptionRequired && teamSubscription) { + try { + // Set the quantity to 0 so we can refund/charge the old Stripe customer the prorated amount. + await stripe.subscriptions.update(teamSubscription.id, { + items: teamSubscription.items.data.map((item) => ({ + id: item.id, + quantity: 0, + })), + }); + + await stripe.subscriptions.cancel(teamSubscription.id, { + invoice_now: true, + prorate: false, + }); + } catch (e) { + // Do not error out since we can't easily undo the transfer. + // Todo: Teams - Alert us. + } + + return null; + } + + return teamSubscription; +}; diff --git a/packages/ee/server-only/stripe/update-customer.ts b/packages/ee/server-only/stripe/update-customer.ts new file mode 100644 index 000000000..78e223b48 --- /dev/null +++ b/packages/ee/server-only/stripe/update-customer.ts @@ -0,0 +1,18 @@ +import { stripe } from '@documenso/lib/server-only/stripe'; + +type UpdateCustomerOptions = { + customerId: string; + name?: string; + email?: string; +}; + +export const updateCustomer = async ({ customerId, name, email }: UpdateCustomerOptions) => { + if (!name && !email) { + return; + } + + return await stripe.customers.update(customerId, { + name, + email, + }); +}; diff --git a/packages/ee/server-only/stripe/update-subscription-item-quantity.ts b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts new file mode 100644 index 000000000..e0fa95f3d --- /dev/null +++ b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts @@ -0,0 +1,44 @@ +import type Stripe from 'stripe'; + +import { stripe } from '@documenso/lib/server-only/stripe'; + +export type UpdateSubscriptionItemQuantityOptions = { + subscriptionId: string; + quantity: number; + priceId: string; +}; + +export const updateSubscriptionItemQuantity = async ({ + subscriptionId, + quantity, + priceId, +}: UpdateSubscriptionItemQuantityOptions) => { + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + + const items = subscription.items.data.filter((item) => item.price.id === priceId); + + if (items.length !== 1) { + throw new Error('Subscription does not contain required item'); + } + + const hasYearlyItem = items.find((item) => item.price.recurring?.interval === 'year'); + const oldQuantity = items[0].quantity; + + if (oldQuantity === quantity) { + return; + } + + const subscriptionUpdatePayload: Stripe.SubscriptionUpdateParams = { + items: items.map((item) => ({ + id: item.id, + quantity, + })), + }; + + // Only invoice immediately when changing the quantity of yearly item. + if (hasYearlyItem) { + subscriptionUpdatePayload.proration_behavior = 'always_invoice'; + } + + await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload); +}; diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts index 047de7962..23705438a 100644 --- a/packages/ee/server-only/stripe/webhook/handler.ts +++ b/packages/ee/server-only/stripe/webhook/handler.ts @@ -3,8 +3,10 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { buffer } from 'micro'; import { match } from 'ts-pattern'; +import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; import type { Stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe'; +import { createTeamFromPendingTeam } from '@documenso/lib/server-only/team/create-team'; import { getFlag } from '@documenso/lib/universal/get-feature-flag'; import { prisma } from '@documenso/prisma'; @@ -84,14 +86,9 @@ export const stripeWebhookHandler = async ( }, }); - if (!result?.id) { - return res.status(500).json({ - success: false, - message: 'User not found', - }); + if (result?.id) { + userId = result.id; } - - userId = result.id; } const subscriptionId = @@ -99,7 +96,7 @@ export const stripeWebhookHandler = async ( ? session.subscription : session.subscription?.id; - if (!subscriptionId || Number.isNaN(userId)) { + if (!subscriptionId) { return res.status(500).json({ success: false, message: 'Invalid session', @@ -108,6 +105,24 @@ export const stripeWebhookHandler = async ( const subscription = await stripe.subscriptions.retrieve(subscriptionId); + // Handle team creation after seat checkout. + if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) { + await handleTeamSeatCheckout({ subscription }); + + return res.status(200).json({ + success: true, + message: 'Webhook received', + }); + } + + // Validate user ID. + if (!userId || Number.isNaN(userId)) { + return res.status(500).json({ + success: false, + message: 'Invalid session or missing user ID', + }); + } + await onSubscriptionUpdated({ userId, subscription }); return res.status(200).json({ @@ -124,6 +139,28 @@ export const stripeWebhookHandler = async ( ? subscription.customer : subscription.customer.id; + if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) { + const team = await prisma.team.findFirst({ + where: { + customerId, + }, + }); + + if (!team) { + return res.status(500).json({ + success: false, + message: 'No team associated with subscription found', + }); + } + + await onSubscriptionUpdated({ teamId: team.id, subscription }); + + return res.status(200).json({ + success: true, + message: 'Webhook received', + }); + } + const result = await prisma.user.findFirst({ select: { id: true, @@ -182,6 +219,28 @@ export const stripeWebhookHandler = async ( }); } + if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) { + const team = await prisma.team.findFirst({ + where: { + customerId, + }, + }); + + if (!team) { + return res.status(500).json({ + success: false, + message: 'No team associated with subscription found', + }); + } + + await onSubscriptionUpdated({ teamId: team.id, subscription }); + + return res.status(200).json({ + success: true, + message: 'Webhook received', + }); + } + const result = await prisma.user.findFirst({ select: { id: true, @@ -233,6 +292,28 @@ export const stripeWebhookHandler = async ( }); } + if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) { + const team = await prisma.team.findFirst({ + where: { + customerId, + }, + }); + + if (!team) { + return res.status(500).json({ + success: false, + message: 'No team associated with subscription found', + }); + } + + await onSubscriptionUpdated({ teamId: team.id, subscription }); + + return res.status(200).json({ + success: true, + message: 'Webhook received', + }); + } + const result = await prisma.user.findFirst({ select: { id: true, @@ -282,3 +363,21 @@ export const stripeWebhookHandler = async ( }); } }; + +export type HandleTeamSeatCheckoutOptions = { + subscription: Stripe.Subscription; +}; + +const handleTeamSeatCheckout = async ({ subscription }: HandleTeamSeatCheckoutOptions) => { + if (subscription.metadata?.pendingTeamId === undefined) { + throw new Error('Missing pending team ID'); + } + + const pendingTeamId = Number(subscription.metadata.pendingTeamId); + + if (Number.isNaN(pendingTeamId)) { + throw new Error('Invalid pending team ID'); + } + + return await createTeamFromPendingTeam({ pendingTeamId, subscription }).then((team) => team.id); +}; diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts index d7ce7b062..8e2f00df8 100644 --- a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts +++ b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts @@ -2,23 +2,40 @@ import { match } from 'ts-pattern'; import type { Stripe } from '@documenso/lib/server-only/stripe'; import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@documenso/prisma/client'; import { SubscriptionStatus } from '@documenso/prisma/client'; export type OnSubscriptionUpdatedOptions = { - userId: number; + userId?: number; + teamId?: number; subscription: Stripe.Subscription; }; export const onSubscriptionUpdated = async ({ userId, + teamId, subscription, }: OnSubscriptionUpdatedOptions) => { + await prisma.subscription.upsert( + mapStripeSubscriptionToPrismaUpsertAction(subscription, userId, teamId), + ); +}; + +export const mapStripeSubscriptionToPrismaUpsertAction = ( + subscription: Stripe.Subscription, + userId?: number, + teamId?: number, +): Prisma.SubscriptionUpsertArgs => { + if ((!userId && !teamId) || (userId && teamId)) { + throw new Error('Either userId or teamId must be provided.'); + } + const status = match(subscription.status) .with('active', () => SubscriptionStatus.ACTIVE) .with('past_due', () => SubscriptionStatus.PAST_DUE) .otherwise(() => SubscriptionStatus.INACTIVE); - await prisma.subscription.upsert({ + return { where: { planId: subscription.id, }, @@ -27,7 +44,8 @@ export const onSubscriptionUpdated = async ({ planId: subscription.id, priceId: subscription.items.data[0].price.id, periodEnd: new Date(subscription.current_period_end * 1000), - userId, + userId: userId ?? null, + teamId: teamId ?? null, cancelAtPeriodEnd: subscription.cancel_at_period_end, }, update: { @@ -37,5 +55,5 @@ export const onSubscriptionUpdated = async ({ periodEnd: new Date(subscription.current_period_end * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, }, - }); + }; }; diff --git a/packages/email/static/add-user.png b/packages/email/static/add-user.png new file mode 100644 index 0000000000000000000000000000000000000000..abd337ceb70d306c70f31d07f1c74e2ca34399be GIT binary patch literal 3361 zcmV++4c_vJP)Gv`oIefP1`wEzQaL0KGNQ&`y?Uh=78W#-p`*s);-W_U zGcz;iRRRbdVk;eN}Y;suHZD8npcaxE1d`GfO#C`k`2XJ3-i082610B20TR31@FMaS~g zQ2n%>OiOwhAenH=G)1|T;zU(6q<3Os!Yh?ZBXrnj3OF?w?a@cZ9`b|AoI8>eBbe3w z`1rV{f*rztIUFe(LL2ogqL9@TWB63kXP#&vne&=xGTl}U0F_VW5Q$sS#)4i{83OWy%E!lf zlHx?(gKc$;0>$`j7Jz&pKd|m`Z1%aaw!Av7xBRfIqRoHwL66e$fA;R(8_A%4v%vHo zOa`DleSh%a!70vPT<`yBpq2zj^`PAL`Uf=l{y*$#*iNMhOx6QPitqEYq9R7AqA}Jz zGc)sxN(o5~Hk8#LwxdUnE>a;*fBN)Er3q9pdd8`tp`o{`Qkz94!K9%u)%TQ235mRj zaz&Ab484Qif{LWRV3h_Sy&#zOzd?FY@q{Ja8S8!`?Abx#p)z@9u)(``@7``|ix=L# zd#BR?=6)f4>NXul-Z)=6VBuLSpfrlKJym&zRdD`(Y;0`Pix)2@W@l&hmoHy*l0bTx zfQQ28if5CoB0|~(C(;>g43`aUIvF4TBC&aY(X~lZ)v=L`co9F+IdtgIgv^($aCsY1N^CTVyRzhx8)VlMN$i+8pD>_kq#BI8i6eHPM4%eMKcdpRW)AMg_W?f=yWO5#iHhRDI_Y?=Z zMgL0H`ZT3czQ;DMgnbaH2!mkSojGvez_3aIH9f*a{lF&93RTG3n+f1ObwZpaV*d`J zF(`p4HXxeG#yP};UMXxO@rtC@?;WG_)3l$wa^=b-r^E5GF%XcCn$L>9`x>*2$PU3iTu6bk<$wK{p>!iD4X z&?jm86Ll|+hD5zcao;DB|4&ifrm#~Wl;ZLbmoex(*c1PV_R*@^RR?(+_<6bYb>Qah|le5^QA-PRfj)?xkPc}2PtlT}$gmRYu?kBJ1iT5s%yV3JLx&_PC&B$Y{4 zIhuFXHqh(dS#ellgxSM-s3CBt*I^@Wq@GJ0>Q*Q zLpG%PhrBo{jeW`vmQGSU=OL-^Ntu@8vwFburj7IPTzm>3HVH;M+uPd<*ZiX(7Owf* z1?*RvaDQcGklM z4f7d*QF`72j@v=o^=ev0%WE*MI)Pxoc!@v8bGNgurd#&WVTSoe@@ib!Fa#Iy6SM{@YiQQntX!Fg zSEN;ZO)T$iFu=}SXgqoM(9n?IKb_@;Y)yHa<+;e%N~Kakbs(^EU#~lF$!uHPl($(o zJ#`Jj$46!hq^W7Ma;SIoXgM5}@hRbuRD`wNBW;LRrfa!c3UaL^osxEmbLB9_fbS~ll-)B$ zf_gg9g9<^3LM#S2F4LA@D_zAemaGJV_ze!ybbuqYpmddRre^jkEKQEfzOLGt)J9Q_ zps@d8(hx>+(Q?0Ejl7`i*7?8>b=joam8)Y325hNRRY(XTL|H`0LbVAF(-1+Vif~G) zP!D-w6+~HiQ|+jhW6P*6d(Gd_8Sjbf8-nrmAVV(beAsZEWdH?T*Rlz+9j)m~r)#+# zIg7Lc$O5gBhF8b`y6)E*9;bD1h#yV@@{i847CQ}2f*o=R`C4S33%ft;dQcqI?zh7P r9fzHBaBy&NaBy&NaBy&NsEOYJQ|7+=Yxnzq00000NkvXXu0mjfB)pO| literal 0 HcmV?d00001 diff --git a/packages/email/static/mail-open-alert.png b/packages/email/static/mail-open-alert.png new file mode 100644 index 0000000000000000000000000000000000000000..1511f0bc539302bf3fc9de7e18283d8882861ffb GIT binary patch literal 3818 zcmVg_Sq)L?5j9Dxei^XEGSS%Kc#bU8oEbV~9RxpEugWX+ST`zZZbnF+4&vMSI?xu^5 z!u5LoW{x4^NDZjq%ypS-!Baz=`?!d4BKSJw84@&c!~1*RNlH zlUdpWXzh&~H;&xAd6PeQ@PLN_!DC+>gJanWV?~+hk&%(AklDZ1uU}u?uwerera97a z!c?=nckeQx)irU<3y(kkc#T=gh0wxxg%(cC%*^nl2q8n0C&b@-%u)_ildSsLq^vZP z-PR^LsYwolOtuKlY9iWhYm=Q$jT8XPs1WWK8#ivOioeu^3sBakO`F(bk3EJ5=nHGI zb0lq(ywXi}TbbyrOmZ0Q9A&qSiAI#1l}QeROg7HlZYI0*8Vs|zcJ10eQOWOZ+qR8W zDiu~Fi;Iiw_U+ry>Z76|WuUjWcaoKuQVX~!lN?4nne5V<=ps#W80}=TOQ*q?FJIm) zO!50=G09<+-DHnL|16tH4x`K_yEHWjGbe=mzcQNSFv@JQ3)f(`Q@+2m zv-90{Fv(%4Cc9p*AKJBR*90q&LIqsbD7jV2WU>pZ(K1KLtx_hFox28?t>GeTiZdf*}2tlmfd@0r%02XxdyMaD7jVgZWzf_!0p;_ku`am?96Jsof|H)CQp-{ zx(2VdD7jU#Hrc5Pc(q2!t&*k5PE7)Lr^gC6XCUOUu`x_vdE$vD{u1@L$Wp>nBZS%qvuuT% zKdAHWZu#cSk%@_k$gj{umJ~j7Akv|#^|{3%>YoYsSe==fc>xwR$}v#|EEL$;zi!<+ zW+|3OAAJ5oom%(KNHA{6xw$zuH8mw0Jv5d~tmAK~gM2V|eXqA-$BrFRb8(`{ z$w~RQu&}@^*?_u*Ilgn}j;wRUK#&0tK3FQ?z`%eLE8d&019;p zP`{fuZ)Sd}sUi~|_4ulI0rU;LO@8)cl5TLE1`Oyoa;PQg{ z*U3aHg7r1gkp%1(+VB%FL0wyPp*E!oMo)dhw3dK?pM3I3DSg*O^VlXj3AjK+dAMlu z&o-qpgVYDUmVzh*P;p%wxlmUTjanQSWMN9aI1VJx@$vCxL4(!i<{%2%3ayRXiK7Tt zF8b{kmLq|}DeExC>tcRTuuwk<@7e;GIUOqSo z>ZJnE<~(|-5DQQfW71xqv{8rbfL4ahw$wzKHY89=sj}>)$~*N*0`(OH?b8`jfP6~O z)+v%}Rt6}jp@DyxqHi@#%?cXuRuKMI-(BctEF(7?6nd35~K2d>*K6dO_1?Jh!avFYu4)eispRi%G zWrVcY@IKEn${IukK|WZ~;KZE`PLxo*j6i4@76Ts*Gpz<*@Ud8j&p%<@Sew3f<0ymf z5_L8#+tw~7dNn`|9TXo41#I~3yW)KCYQWH&1dLWT&_sug*s~N%l!nI8hK{`ZgDv40 z+M+Ruufb|V2eXt8MJ|b&D%Bw&R)b4~>Vv~lrO;PbEafDMa6#y+E7m-ho3KDDQji7I zZ**9SMQzCfNr&t>^!bK%Y{_b88dH<(-lr$o{Fj&5{O9Mr^&R+D$IdF_-}*MI?O0b8 zt7)O4CbqcKo*mnQ#pRu_M=h@;=jSj6j9hJA9zCCxA#eo{Jx%+-nR_`WR zIG_2`$9!l@m;4)k_G@xE$}z*we%(9n`qr+4COL*`Ff~`UTX$i*0L>lS|6Mu1_POWT zwv(rYuZ{M-?}eeOZ1U$n6;Cm`SyJ#|RF?d7cgOUaXg|Tj zmG@q_^}(rAzgqMBr^)Bu|Mxku&tTK~#mNai``0r~>lHsfF<51ujSqYI%t`u{9ZN8- zNtN2W{h4;|Y}EEctGs#cSWB^?@eZG;c`<2obCk zftAkM=e{FNuIi)Jc{~)IPw#3vZvCsT%c*Fk-b3qxG?<2iSm|cK!nLb%`p8TBnzkW| z-hSpE%Y>{77uzT|R$226k}KcqmBsv7`h?oU_Rd<^2t`YD_|%k~IzA~;rz=Pja+q-C zF;6$I4>HlqJ{K>RFXFqCmV9x1=A#m1qCE|UkcIYsM(+Sw(nD!-T!!v*b)RgaF};-j z3c-cv!9b0kr>CdS%+JqHS}jfo1X7^>|4U8Vbm77*>pt1k89{*?(vg6k#5;n91d5$bjRJV zs)>0Bc>rIf6B^Ae$(cHQNDAbJ=s_|GGb#}JjfXu;AE`2o^o}IgUQ;}N{P=+I$8V2~ zjn(i`6a-)P_mkqY;YUA~X%bBC?bi>mq@YJ>De!V?U5(0sx-B&F=1Px}>6oFRp@Wr5 zWxv?3D)!sMEV1DK{SRxr{i>Wtj}kf^{PJ8K1X_47sM%BaUeR5+a8}(X(zgyY4W@5M z%Wrq=+`04P0|yR#a{BaXZ`|m)Av!2rR~B} z$aUD()6>IgU)qLsbS%xQ$LrAf_3Kc|^6rt2H(hJcvcBG}AMYCJTv&IQv4dU0Frr_# z{^dos@n^qiYM<7*_vt61H|kdpQznPKr3-gybqL(>+YP3nv=FxXVQQ*CTJKgQ1ShBJ z{`v>4O#}Vt_W@0?@4|#L0R@KN4;(;W()v$}9y z^Mkgl51ei>!YWyRkWANw$W?hax9Pn4ai(iDT`y^SZO6J@yuP@%({c7SxYTH=!b=tG zoE3t-)M%_f5hU0|kcsAQ`lSD{rKnt8rqs&YPTTq){(`laeyQ8^vijXToXS&|m1COD zt(||_RKE^&S>I2+2DN;j`}FQrzYo*7)O&Z2_5Rdzs4~bLUATHh`Bh}lys1*@Mtv&Z z`w4ozGNP90SFwHqHVHf6fv0&!4}hso)NQ77E8FtJuL5-;u;!;#7b;^~MFy3vYNcAX z=|N%2L%AiL9)^TB|4`8*oZ{L3N&Ye5<)YOz61cb-7D1)+Mk+G;;^uWMCjx_sl<;s=0RjXDB za?PR2k*gLDA3hXh)decEzrDS^APm`ptng*B!jbXual2ATWLWYDy+C^9Dl7nEEO~F}#qV2kN+40m!3c!t$xF4@xy*fv)*u(|c*P1nJ#M4hdjT`7= zsyX?Ah(Mh@FAnG~Ft{WGPC^;#Y90a>;9J}6JcIGV@Zn3$!xsxjSl?@v< zh{nc7ktTC5;d$8(L)REHF(a|v~GEAlgPRk_+QBN*Avo1PKmmEYrx$MkY@U?5# z){-lJH7hPTh^)KpObJ{l6grkKUw$cTE;)!SyX?%=Alw{@dnJo5IfyK~?9?sT8I;dA zH8s6l4=y9P~6@p|sKNSa!@ z?AR@MsYS`vlC;Z?Rp6x>C09$5E;}{_mU5I_ElIlUsx8>KTMhAGTa*m2lUPsGV@jM%>zi zI&W>2-<)}KWMm}tS7<^jg~uFFI+QbQZhBDt3u+#7QPg5QcPt6%)Ji`FukdEB4#~qS9R=x4~yhjfx1a$VCW4 zdQ4AG%ZQo+t&(y2S#y)u3W)2zLlDKmMWEM;qG@5M9ZK(({NK?B!di+kmf@)q#N|Ts z`$^l&lnVl8h^?bim|PTN~ruxZmKX}LJi=;)|? z&CbpWLo%Rl;g0X$zc1??F%UEW)CNlf?CR>W{MzE6M!IDF)X7mKzb_ZUis3GBP^3+8 z*)-rF4Dms!kbJ>4BL{}Zm6|G=*nVxWuZw2i4nKe5#ECg}(QY6vESMua7T?L)3<1Desr#`MM4`#^8((J^ ztqAMsqC*Mn?6l}5FvCMze4-|$^2eR}1X(QufwyegBGq?Xv>n++R{~BDQ65g3+h>zf z=|Sp)ww8k^1W-|%Hf};4L)2QaZ;*v|Zi}P9f({Q4F9;fpHJ1lbuvS=Y)Il6YT=}op zcD{Kq{&2n(6`b(KjT>cKjL)!UxNmkxOA*8=r~`=0({d`E6{7~hm3DN<;EDvcqgt>E zEEyMpceo>@RCn**T~Zw+Gcz+%*s-xOSqDgc(61jy-+?Z}Q3**>k;bgUBK~X_BZGSTXKijvP4eF9B(8`PZhLUC zoKhheTP!J+JN3cvDy}z)v2?#tq_8{Sj%-Z0vy*_@CH(Sk)BVr%A3ZKrXplP!*7e;SMt=eEe zc$5mjnrksig;;>Q7%Lt1i5qpw4yb0@Y*k&9al?XgN|od&RjpH>O5m{qzipZVb|CK( z^mS5lEwTUyH9YYT?-*N+)3W>~yk!M-tiVs?SZ8$9nz&P{G*h1_aFB)O(;4lHKBL2S zW>s=y9>-uq+{86XRq523G~wu(VGgXK0s z(`K^>Te0T*JPXKb5akEA!HNY}JlJ4`3B|JrM8mWgw9#8Rq$R=UL1%Y2(G446r1O-~5 zf-Imuqr;FcYEKrZbjps!k8h~Qo~(LiWO7Jc8R`+e4{nHoM+GrCJ3q?R(zH^vugr-J z);968%SUsxs8ID_gRObWjv*OFrUu1dKmQ{Mdp*?lB+FTAR-An8#qM6uZG!fJKPC@O zo_`En5SRM8OV--4W`}ro-B-oB<~C`yU}S1Y+$M|u^!`WcO)zSUhOS?H{V%6<`wg6Q z$9~=joE#Vb*Db{b5c6QiaT54B-!gFD?|=T57$vKhSXTb^z2~~z+XY%McURWiaACc0 zWw3|b@>}xr-u46Hz%$216fWF6E3F?`jn?s>?>YC5y52<5DS|fl?SFTX6%JJ-E`u8c zHwrPjys>e2e$RUaUgwzytJ6WuphGfT>U&>`yS8~_#o~g49mhl~?Sp8YDb97O>wG&p zcwRInz6(RFzq-WQCC3G2B{qL^+SiJk$uVuRA z<~d5MfMpeT@iIRP=IJ0t-2_0}3t)LV(3`d3etp$XB zlp(#YPEAea%rhsGXDx|`zzIE9Z-3GyEiFh~FZ^8e{6i3(bcw9@Z~Pq~L-nxbj&M(u z0$VPN99G=XC;KY>=5XMY9cFB7?1Sm)=}|mA)=)iPaQc@A#tYi@=(1#y!&)v!=G1+4 zm6!PmTv8XQ($1YbM`vbczRi!oHB`qlPG9qtq05x|=shDu{?QA65Z{0PSE1bU%_jp8 zm-&NVnpBFOo$tVivuDqqCy{@PC({{`s{<(wtP^?dzi8~&iE4x-xxzea9#_K1AE)XuDm)5Q%%!{o?$<@5NN{aVdIkZP^&AEG!q|Vpjk|xTj3j|j0?Q3GBBNc#>*&#=R(H2_5wde+-@biTPfw3c%S#2Y9&P#c>({Lv zJ9fx-EOT$8mgV*2DdaM&Yinz>d0SqGWn7j&tL5GB@!jX4?2Dh1T`I3TSHJ(=%bofu zog*I$%W~gs*tNX5c#-~Tg~)rh9276Ky(rzd4p=S5fd6^>g8U3|?FO;`^}XF!-P=YI znA>FCR9Y6B;R@vCPDR?z&0wqByB}z+o9M^o^O}cWD>s?#Uha2pUf1B>UOk3$P4bZm z%Kx7GQO}$Aaqir?qS6&C%S`V5Pp;vq*^9K>9R7#6=*Dbcv7I_T<}Dh%U%g{D6Um>UU7pud}EZp^B+qr0$}quRvJ}_Qokl|1|7~b!JpbCB_j*L!u#2{x2Zg&|Vm>NY|CbM}=4DvV>+(;2 z!J4;Ou+F_NEaMlK;g?dT?AWg3FL-yKhx)&JJM|pY{d-(z=~~^_p*xm(?M~U!OFf3F zLE^Z@byt*EMf!c#RVrVoN9C7(g5F&jVfX1(v0eh}#P+%2`Lo~~KvyT~I^D6Ab+yB* z0^K5r^1Ib7st~S<^xL;;mFm8AH;Pbh0->>D*9{?rhlLBhg_iA0vrnyvy7Tedzq2eI zysSc$SFRA{ccpNbb!}6!P`BVhV}-hkbCngk{@VH6bpLP2WTCcNcr9MRJ!un7W zQ&f-6HCaB}dTkT5j?YAnOC_e z!HF8=d7ZDiPxral0Y8QjbdF)R_1Q)Z)_l(s{2yZxW!--FndATf002ovPDHLkV1ftJ BjST<* literal 0 HcmV?d00001 diff --git a/packages/email/template-components/template-image.tsx b/packages/email/template-components/template-image.tsx new file mode 100644 index 000000000..8f821c10f --- /dev/null +++ b/packages/email/template-components/template-image.tsx @@ -0,0 +1,17 @@ +import { Img } from '../components'; + +export interface TemplateImageProps { + assetBaseUrl: string; + className?: string; + staticAsset: string; +} + +export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: TemplateImageProps) => { + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ; +}; + +export default TemplateImage; diff --git a/packages/email/templates/confirm-email.tsx b/packages/email/templates/confirm-email.tsx index b3acd1ecd..59c7add10 100644 --- a/packages/email/templates/confirm-email.tsx +++ b/packages/email/templates/confirm-email.tsx @@ -7,7 +7,7 @@ import { TemplateFooter } from '../template-components/template-footer'; export const ConfirmEmailTemplate = ({ confirmationLink, - assetBaseUrl, + assetBaseUrl = 'http://localhost:3002', }: TemplateConfirmationEmailProps) => { const previewText = `Please confirm your email address`; @@ -55,3 +55,5 @@ export const ConfirmEmailTemplate = ({ ); }; + +export default ConfirmEmailTemplate; diff --git a/packages/email/templates/confirm-team-email.tsx b/packages/email/templates/confirm-team-email.tsx new file mode 100644 index 000000000..5752f806d --- /dev/null +++ b/packages/email/templates/confirm-team-email.tsx @@ -0,0 +1,127 @@ +import { formatTeamUrl } from '@documenso/lib/utils/teams'; +import config from '@documenso/tailwind-config'; + +import { + Body, + Button, + Container, + Head, + Hr, + Html, + Link, + Preview, + Section, + Tailwind, + Text, +} from '../components'; +import { TemplateFooter } from '../template-components/template-footer'; +import TemplateImage from '../template-components/template-image'; + +export type ConfirmTeamEmailProps = { + assetBaseUrl: string; + baseUrl: string; + teamName: string; + teamUrl: string; + token: string; +}; + +export const ConfirmTeamEmailTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + baseUrl = 'https://documenso.com', + teamName = 'Team Name', + teamUrl = 'demo', + token = '', +}: ConfirmTeamEmailProps) => { + const previewText = `Accept team email request for ${teamName} on Documenso`; + + return ( + + + {previewText} + + +
    + + + +
    + +
    + +
    + + Verify your team email address + + + + {teamName} has requested to use your email + address for their team on Documenso. + + +
    + {formatTeamUrl(teamUrl, baseUrl)} +
    + +
    + + By accepting this request, you will be granting {teamName}{' '} + access to: + + +
      +
    • + View all documents sent to and from this email address +
    • +
    • + Allow document recipients to reply directly to this email address +
    • +
    + + + You can revoke access at any time in your team settings on Documenso{' '} + here. + +
    + +
    + +
    +
    + + Link expires in 1 hour. +
    + +
    + + + + +
    + +
    + + ); +}; + +export default ConfirmTeamEmailTemplate; diff --git a/packages/email/templates/team-email-removed.tsx b/packages/email/templates/team-email-removed.tsx new file mode 100644 index 000000000..0a143d1b9 --- /dev/null +++ b/packages/email/templates/team-email-removed.tsx @@ -0,0 +1,83 @@ +import { formatTeamUrl } from '@documenso/lib/utils/teams'; +import config from '@documenso/tailwind-config'; + +import { Body, Container, Head, Hr, Html, Preview, Section, Tailwind, Text } from '../components'; +import { TemplateFooter } from '../template-components/template-footer'; +import TemplateImage from '../template-components/template-image'; + +export type TeamEmailRemovedTemplateProps = { + assetBaseUrl: string; + baseUrl: string; + teamEmail: string; + teamName: string; + teamUrl: string; +}; + +export const TeamEmailRemovedTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + baseUrl = 'https://documenso.com', + teamEmail = 'example@documenso.com', + teamName = 'Team Name', + teamUrl = 'demo', +}: TeamEmailRemovedTemplateProps) => { + const previewText = `Team email removed for ${teamName} on Documenso`; + + return ( + + + {previewText} + + +
    + + + +
    + +
    + +
    + + Team email removed + + + + The team email {teamEmail} has been removed + from the following team + + +
    + {formatTeamUrl(teamUrl, baseUrl)} +
    +
    +
    + +
    + + + + +
    + +
    + + ); +}; + +export default TeamEmailRemovedTemplate; diff --git a/packages/email/templates/team-invite.tsx b/packages/email/templates/team-invite.tsx new file mode 100644 index 000000000..4602b7382 --- /dev/null +++ b/packages/email/templates/team-invite.tsx @@ -0,0 +1,108 @@ +import { formatTeamUrl } from '@documenso/lib/utils/teams'; +import config from '@documenso/tailwind-config'; + +import { + Body, + Button, + Container, + Head, + Hr, + Html, + Preview, + Section, + Tailwind, + Text, +} from '../components'; +import { TemplateFooter } from '../template-components/template-footer'; +import TemplateImage from '../template-components/template-image'; + +export type TeamInviteEmailProps = { + assetBaseUrl: string; + baseUrl: string; + senderName: string; + teamName: string; + teamUrl: string; + token: string; +}; + +export const TeamInviteEmailTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + baseUrl = 'https://documenso.com', + senderName = 'John Doe', + teamName = 'Team Name', + teamUrl = 'demo', + token = '', +}: TeamInviteEmailProps) => { + const previewText = `Accept invitation to join a team on Documenso`; + + return ( + + + {previewText} + + +
    + + + +
    + +
    + +
    + + Join {teamName} on Documenso + + + + You have been invited to join the following team + + +
    + {formatTeamUrl(teamUrl, baseUrl)} +
    + + + by {senderName} + + +
    + +
    +
    +
    + +
    + + + + +
    + +
    + + ); +}; + +export default TeamInviteEmailTemplate; diff --git a/packages/email/templates/team-transfer-request.tsx b/packages/email/templates/team-transfer-request.tsx new file mode 100644 index 000000000..82723226c --- /dev/null +++ b/packages/email/templates/team-transfer-request.tsx @@ -0,0 +1,112 @@ +import { formatTeamUrl } from '@documenso/lib/utils/teams'; +import config from '@documenso/tailwind-config'; + +import { + Body, + Button, + Container, + Head, + Hr, + Html, + Preview, + Section, + Tailwind, + Text, +} from '../components'; +import { TemplateFooter } from '../template-components/template-footer'; +import TemplateImage from '../template-components/template-image'; + +export type TeamTransferRequestTemplateProps = { + assetBaseUrl: string; + baseUrl: string; + senderName: string; + teamName: string; + teamUrl: string; + token: string; +}; + +export const TeamTransferRequestTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + baseUrl = 'https://documenso.com', + senderName = 'John Doe', + teamName = 'Team Name', + teamUrl = 'demo', + token = '', +}: TeamTransferRequestTemplateProps) => { + const previewText = 'Accept team transfer request on Documenso'; + + return ( + + + {previewText} + + +
    + + + +
    + +
    + +
    + + {teamName} ownership transfer request + + + + {senderName} has requested that you take + ownership of the following team + + +
    + {formatTeamUrl(teamUrl, baseUrl)} +
    + + + By accepting this request, you will take responsibility for any billing items + associated with this team. + + +
    + +
    +
    + + Link expires in 1 hour. +
    + +
    + + + + +
    + +
    + + ); +}; + +export default TeamTransferRequestTemplate; diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts index a19d2bb0d..6c4d056d0 100644 --- a/packages/lib/constants/app.ts +++ b/packages/lib/constants/app.ts @@ -1,5 +1,9 @@ export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing'; export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web'; +export const IS_BILLING_ENABLED = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true'; + +export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = + Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50; export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web'; @@ -7,5 +11,6 @@ export const APP_BASE_URL = IS_APP_WEB ? process.env.NEXT_PUBLIC_WEBAPP_URL : process.env.NEXT_PUBLIC_MARKETING_URL; -export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = - Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50; +export const WEBAPP_BASE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'; + +export const MARKETING_BASE_URL = process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001'; diff --git a/packages/lib/constants/billing.ts b/packages/lib/constants/billing.ts new file mode 100644 index 000000000..e6d897af8 --- /dev/null +++ b/packages/lib/constants/billing.ts @@ -0,0 +1,11 @@ +export enum STRIPE_CUSTOMER_TYPE { + INDIVIDUAL = 'individual', + TEAM = 'team', +} + +export enum STRIPE_PLAN_TYPE { + TEAM = 'team', + COMMUNITY = 'community', +} + +export const TEAM_BILLING_DOMAIN = 'billing.team.documenso.com'; diff --git a/packages/lib/constants/teams.ts b/packages/lib/constants/teams.ts new file mode 100644 index 000000000..47705bb14 --- /dev/null +++ b/packages/lib/constants/teams.ts @@ -0,0 +1,102 @@ +import { TeamMemberRole } from '@documenso/prisma/client'; + +export const TEAM_URL_ROOT_REGEX = new RegExp('^/t/[^/]+$'); + +export const TEAM_MEMBER_ROLE_MAP: Record = { + ADMIN: 'Admin', + MANAGER: 'Manager', + MEMBER: 'Member', +}; + +export const TEAM_MEMBER_ROLE_PERMISSIONS_MAP = { + /** + * Includes permissions to: + * - Manage team members + * - Manage team settings, changing name, url, etc. + */ + MANAGE_TEAM: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER], + MANAGE_BILLING: [TeamMemberRole.ADMIN], + DELETE_TEAM_TRANSFER_REQUEST: [TeamMemberRole.ADMIN], +} satisfies Record; + +/** + * A hierarchy of team member roles to determine which role has higher permission than another. + */ +export const TEAM_MEMBER_ROLE_HIERARCHY = { + [TeamMemberRole.ADMIN]: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER, TeamMemberRole.MEMBER], + [TeamMemberRole.MANAGER]: [TeamMemberRole.MANAGER, TeamMemberRole.MEMBER], + [TeamMemberRole.MEMBER]: [TeamMemberRole.MEMBER], +} satisfies Record; + +export const PROTECTED_TEAM_URLS = [ + '403', + '404', + '500', + '502', + '503', + '504', + 'about', + 'account', + 'admin', + 'administrator', + 'api', + 'app', + 'archive', + 'auth', + 'backup', + 'config', + 'configure', + 'contact', + 'contact-us', + 'copyright', + 'crime', + 'criminal', + 'dashboard', + 'docs', + 'documenso', + 'documentation', + 'document', + 'documents', + 'error', + 'exploit', + 'exploitation', + 'exploiter', + 'feedback', + 'finance', + 'forgot-password', + 'fraud', + 'fraudulent', + 'hack', + 'hacker', + 'harassment', + 'help', + 'helpdesk', + 'illegal', + 'internal', + 'legal', + 'login', + 'logout', + 'maintenance', + 'malware', + 'newsletter', + 'policy', + 'privacy', + 'profile', + 'public', + 'reset-password', + 'scam', + 'scammer', + 'settings', + 'setup', + 'sign', + 'signin', + 'signout', + 'signup', + 'spam', + 'support', + 'system', + 'team', + 'terms', + 'virus', + 'webhook', +]; diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts new file mode 100644 index 000000000..3337bab4c --- /dev/null +++ b/packages/lib/errors/app-error.ts @@ -0,0 +1,144 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { TRPCClientError } from '@documenso/trpc/client'; + +/** + * Generic application error codes. + */ +export enum AppErrorCode { + 'ALREADY_EXISTS' = 'AlreadyExists', + 'EXPIRED_CODE' = 'ExpiredCode', + 'INVALID_BODY' = 'InvalidBody', + 'INVALID_REQUEST' = 'InvalidRequest', + 'NOT_FOUND' = 'NotFound', + 'NOT_SETUP' = 'NotSetup', + 'UNAUTHORIZED' = 'Unauthorized', + 'UNKNOWN_ERROR' = 'UnknownError', + 'RETRY_EXCEPTION' = 'RetryException', + 'SCHEMA_FAILED' = 'SchemaFailed', + 'TOO_MANY_REQUESTS' = 'TooManyRequests', +} + +const genericErrorCodeToTrpcErrorCodeMap: Record = { + [AppErrorCode.ALREADY_EXISTS]: 'BAD_REQUEST', + [AppErrorCode.EXPIRED_CODE]: 'BAD_REQUEST', + [AppErrorCode.INVALID_BODY]: 'BAD_REQUEST', + [AppErrorCode.INVALID_REQUEST]: 'BAD_REQUEST', + [AppErrorCode.NOT_FOUND]: 'NOT_FOUND', + [AppErrorCode.NOT_SETUP]: 'BAD_REQUEST', + [AppErrorCode.UNAUTHORIZED]: 'UNAUTHORIZED', + [AppErrorCode.UNKNOWN_ERROR]: 'INTERNAL_SERVER_ERROR', + [AppErrorCode.RETRY_EXCEPTION]: 'INTERNAL_SERVER_ERROR', + [AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR', + [AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS', +}; + +export const ZAppErrorJsonSchema = z.object({ + code: z.string(), + message: z.string().optional(), + userMessage: z.string().optional(), +}); + +export type TAppErrorJsonSchema = z.infer; + +export class AppError extends Error { + /** + * The error code. + */ + code: string; + + /** + * An error message which can be displayed to the user. + */ + userMessage?: string; + + /** + * Create a new AppError. + * + * @param errorCode A string representing the error code. + * @param message An internal error message. + * @param userMessage A error message which can be displayed to the user. + */ + public constructor(errorCode: string, message?: string, userMessage?: string) { + super(message || errorCode); + this.code = errorCode; + this.userMessage = userMessage; + } + + /** + * Parse an unknown value into an AppError. + * + * @param error An unknown type. + */ + static parseError(error: unknown): AppError { + if (error instanceof AppError) { + return error; + } + + // Handle TRPC errors. + if (error instanceof TRPCClientError) { + const parsedJsonError = AppError.parseFromJSONString(error.message); + return parsedJsonError || new AppError('UnknownError', error.message); + } + + // Handle completely unknown errors. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const { code, message, userMessage } = error as { + code: unknown; + message: unknown; + status: unknown; + userMessage: unknown; + }; + + const validCode: string | null = typeof code === 'string' ? code : AppErrorCode.UNKNOWN_ERROR; + const validMessage: string | undefined = typeof message === 'string' ? message : undefined; + const validUserMessage: string | undefined = + typeof userMessage === 'string' ? userMessage : undefined; + + return new AppError(validCode, validMessage, validUserMessage); + } + + static parseErrorToTRPCError(error: unknown): TRPCError { + const appError = AppError.parseError(error); + + return new TRPCError({ + code: genericErrorCodeToTrpcErrorCodeMap[appError.code] || 'BAD_REQUEST', + message: AppError.toJSONString(appError), + }); + } + + /** + * Convert an AppError into a JSON object which represents the error. + * + * @param appError The AppError to convert to JSON. + * @returns A JSON object representing the AppError. + */ + static toJSON({ code, message, userMessage }: AppError): TAppErrorJsonSchema { + return { + code, + message, + userMessage, + }; + } + + /** + * Convert an AppError into a JSON string containing the relevant information. + * + * @param appError The AppError to stringify. + * @returns A JSON string representing the AppError. + */ + static toJSONString(appError: AppError): string { + return JSON.stringify(AppError.toJSON(appError)); + } + + static parseFromJSONString(jsonString: string): AppError | null { + const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString)); + + if (!parsed.success) { + return null; + } + + return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage); + } +} diff --git a/packages/lib/server-only/crypto/decrypt.ts b/packages/lib/server-only/crypto/decrypt.ts index 7b4db9894..de7b82c4b 100644 --- a/packages/lib/server-only/crypto/decrypt.ts +++ b/packages/lib/server-only/crypto/decrypt.ts @@ -13,21 +13,25 @@ export const decryptSecondaryData = (encryptedData: string): string | null => { throw new Error('Missing encryption key'); } - const decryptedBufferValue = symmetricDecrypt({ - key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY, - data: encryptedData, - }); + try { + const decryptedBufferValue = symmetricDecrypt({ + key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY, + data: encryptedData, + }); - const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8'); - const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue)); + const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8'); + const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue)); - if (!result.success) { + if (!result.success) { + return null; + } + + if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) { + return null; + } + + return result.data.data; + } catch { return null; } - - if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) { - return null; - } - - return result.data.data; }; diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index b67c6848b..3e6cd75be 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -24,7 +24,20 @@ export const upsertDocumentMeta = async ({ await prisma.document.findFirstOrThrow({ where: { id: documentId, - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, }); diff --git a/packages/lib/server-only/document/create-document.ts b/packages/lib/server-only/document/create-document.ts index b84f8e46e..93307a7b4 100644 --- a/packages/lib/server-only/document/create-document.ts +++ b/packages/lib/server-only/document/create-document.ts @@ -5,15 +5,37 @@ import { prisma } from '@documenso/prisma'; export type CreateDocumentOptions = { title: string; userId: number; + teamId?: number; documentDataId: string; }; -export const createDocument = async ({ userId, title, documentDataId }: CreateDocumentOptions) => { - return await prisma.document.create({ - data: { - title, - documentDataId, - userId, - }, +export const createDocument = async ({ + userId, + title, + documentDataId, + teamId, +}: CreateDocumentOptions) => { + return await prisma.$transaction(async (tx) => { + if (teamId !== undefined) { + await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + }); + } + + return await tx.document.create({ + data: { + title, + documentDataId, + userId, + teamId, + }, + }); }); }; diff --git a/packages/lib/server-only/document/duplicate-document-by-id.ts b/packages/lib/server-only/document/duplicate-document-by-id.ts index ddb70b1cb..5ca848bb3 100644 --- a/packages/lib/server-only/document/duplicate-document-by-id.ts +++ b/packages/lib/server-only/document/duplicate-document-by-id.ts @@ -1,16 +1,27 @@ import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@documenso/prisma/client'; + +import { getDocumentWhereInput } from './get-document-by-id'; export interface DuplicateDocumentByIdOptions { id: number; userId: number; + teamId?: number; } -export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByIdOptions) => { +export const duplicateDocumentById = async ({ + id, + userId, + teamId, +}: DuplicateDocumentByIdOptions) => { + const documentWhereInput = await getDocumentWhereInput({ + documentId: id, + userId, + teamId, + }); + const document = await prisma.document.findUniqueOrThrow({ - where: { - id, - userId: userId, - }, + where: documentWhereInput, select: { title: true, userId: true, @@ -33,7 +44,7 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI }, }); - const createdDocument = await prisma.document.create({ + const createDocumentArguments: Prisma.DocumentCreateArgs = { data: { title: document.title, User: { @@ -53,7 +64,17 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI }, }, }, - }); + }; + + if (teamId !== undefined) { + createDocumentArguments.data.team = { + connect: { + id: teamId, + }, + }; + } + + const createdDocument = await prisma.document.create(createDocumentArguments); return createdDocument.id; }; diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 8d367dbe4..f34cc4c2c 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -2,8 +2,8 @@ import { DateTime } from 'luxon'; import { P, match } from 'ts-pattern'; import { prisma } from '@documenso/prisma'; -import type { Document, Prisma } from '@documenso/prisma/client'; import { RecipientRole, SigningStatus } from '@documenso/prisma/client'; +import type { Document, Prisma, Team, TeamEmail, User } from '@documenso/prisma/client'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { FindResultSet } from '../../types/find-result-set'; @@ -13,6 +13,7 @@ export type PeriodSelectorValue = '' | '7d' | '14d' | '30d'; export type FindDocumentsOptions = { userId: number; + teamId?: number; term?: string; status?: ExtendedDocumentStatus; page?: number; @@ -22,21 +23,49 @@ export type FindDocumentsOptions = { direction: 'asc' | 'desc'; }; period?: PeriodSelectorValue; + senderIds?: number[]; }; export const findDocuments = async ({ userId, + teamId, term, status = ExtendedDocumentStatus.ALL, page = 1, perPage = 10, orderBy, period, + senderIds, }: FindDocumentsOptions) => { - const user = await prisma.user.findFirstOrThrow({ - where: { - id: userId, - }, + const { user, team } = await prisma.$transaction(async (tx) => { + const user = await tx.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + + let team = null; + + if (teamId !== undefined) { + team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + include: { + teamEmail: true, + }, + }); + } + + return { + user, + team, + }; }); const orderByColumn = orderBy?.column ?? 'createdAt'; @@ -53,96 +82,34 @@ export const findDocuments = async ({ }) .otherwise(() => undefined); - const filters = match(status) - .with(ExtendedDocumentStatus.ALL, () => ({ - OR: [ - { - userId, - deletedAt: null, - }, - { - status: ExtendedDocumentStatus.COMPLETED, - Recipient: { - some: { - email: user.email, - }, - }, - }, - { - status: ExtendedDocumentStatus.PENDING, - Recipient: { - some: { - email: user.email, - }, - }, - deletedAt: null, - }, - ], - })) - .with(ExtendedDocumentStatus.INBOX, () => ({ - status: { - not: ExtendedDocumentStatus.DRAFT, - }, - Recipient: { - some: { - email: user.email, - signingStatus: SigningStatus.NOT_SIGNED, - role: { - not: RecipientRole.CC, - }, - }, - }, - deletedAt: null, - })) - .with(ExtendedDocumentStatus.DRAFT, () => ({ - userId, - status: ExtendedDocumentStatus.DRAFT, - deletedAt: null, - })) - .with(ExtendedDocumentStatus.PENDING, () => ({ - OR: [ - { - userId, - status: ExtendedDocumentStatus.PENDING, - deletedAt: null, - }, - { - status: ExtendedDocumentStatus.PENDING, - Recipient: { - some: { - email: user.email, - signingStatus: SigningStatus.SIGNED, - role: { - not: RecipientRole.CC, - }, - }, - }, - deletedAt: null, - }, - ], - })) - .with(ExtendedDocumentStatus.COMPLETED, () => ({ - OR: [ - { - userId, - status: ExtendedDocumentStatus.COMPLETED, - deletedAt: null, - }, - { - status: ExtendedDocumentStatus.COMPLETED, - Recipient: { - some: { - email: user.email, - }, - }, - }, - ], - })) - .exhaustive(); + const filters = team ? findTeamDocumentsFilter(status, team) : findDocumentsFilter(status, user); - const whereClause = { + if (filters === null) { + return { + data: [], + count: 0, + currentPage: 1, + perPage, + totalPages: 0, + }; + } + + const whereClause: Prisma.DocumentWhereInput = { ...termFilters, ...filters, + AND: { + OR: [ + { + status: ExtendedDocumentStatus.COMPLETED, + }, + { + status: { + not: ExtendedDocumentStatus.COMPLETED, + }, + deletedAt: null, + }, + ], + }, }; if (period) { @@ -155,6 +122,12 @@ export const findDocuments = async ({ }; } + if (senderIds && senderIds.length > 0) { + whereClause.userId = { + in: senderIds, + }; + } + const [data, count] = await Promise.all([ prisma.document.findMany({ where: whereClause, @@ -172,13 +145,16 @@ export const findDocuments = async ({ }, }, Recipient: true, + team: { + select: { + id: true, + url: true, + }, + }, }, }), prisma.document.count({ - where: { - ...termFilters, - ...filters, - }, + where: whereClause, }), ]); @@ -197,3 +173,268 @@ export const findDocuments = async ({ totalPages: Math.ceil(count / perPage), } satisfies FindResultSet; }; + +const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => { + return match(status) + .with(ExtendedDocumentStatus.ALL, () => ({ + OR: [ + { + userId: user.id, + teamId: null, + }, + { + status: ExtendedDocumentStatus.COMPLETED, + Recipient: { + some: { + email: user.email, + }, + }, + }, + { + status: ExtendedDocumentStatus.PENDING, + Recipient: { + some: { + email: user.email, + }, + }, + }, + ], + })) + .with(ExtendedDocumentStatus.INBOX, () => ({ + status: { + not: ExtendedDocumentStatus.DRAFT, + }, + Recipient: { + some: { + email: user.email, + signingStatus: SigningStatus.NOT_SIGNED, + role: { + not: RecipientRole.CC, + }, + }, + }, + })) + .with(ExtendedDocumentStatus.DRAFT, () => ({ + userId: user.id, + teamId: null, + status: ExtendedDocumentStatus.DRAFT, + })) + .with(ExtendedDocumentStatus.PENDING, () => ({ + OR: [ + { + userId: user.id, + teamId: null, + status: ExtendedDocumentStatus.PENDING, + }, + { + status: ExtendedDocumentStatus.PENDING, + Recipient: { + some: { + email: user.email, + signingStatus: SigningStatus.SIGNED, + role: { + not: RecipientRole.CC, + }, + }, + }, + }, + ], + })) + .with(ExtendedDocumentStatus.COMPLETED, () => ({ + OR: [ + { + userId: user.id, + teamId: null, + status: ExtendedDocumentStatus.COMPLETED, + }, + { + status: ExtendedDocumentStatus.COMPLETED, + Recipient: { + some: { + email: user.email, + }, + }, + }, + ], + })) + .exhaustive(); +}; + +/** + * Create a Prisma filter for the Document schema to find documents for a team. + * + * Status All: + * - Documents that belong to the team + * - Documents that have been sent by the team email + * - Non draft documents that have been sent to the team email + * + * Status Inbox: + * - Non draft documents that have been sent to the team email that have not been signed + * + * Status Draft: + * - Documents that belong to the team that are draft + * - Documents that belong to the team email that are draft + * + * Status Pending: + * - Documents that belong to the team that are pending + * - Documents that have been sent by the team email that is pending to be signed by someone else + * - Documents that have been sent to the team email that is pending to be signed by someone else + * + * Status Completed: + * - Documents that belong to the team that are completed + * - Documents that have been sent to the team email that are completed + * - Documents that have been sent by the team email that are completed + * + * @param status The status of the documents to find. + * @param team The team to find the documents for. + * @returns A filter which can be applied to the Prisma Document schema. + */ +const findTeamDocumentsFilter = ( + status: ExtendedDocumentStatus, + team: Team & { teamEmail: TeamEmail | null }, +) => { + const teamEmail = team.teamEmail?.email ?? null; + + return match(status) + .with(ExtendedDocumentStatus.ALL, () => { + const filter: Prisma.DocumentWhereInput = { + // Filter to display all documents that belong to the team. + OR: [ + { + teamId: team.id, + }, + ], + }; + + if (teamEmail && filter.OR) { + // Filter to display all documents received by the team email that are not draft. + filter.OR.push({ + status: { + not: ExtendedDocumentStatus.DRAFT, + }, + Recipient: { + some: { + email: teamEmail, + }, + }, + }); + + // Filter to display all documents that have been sent by the team email. + filter.OR.push({ + User: { + email: teamEmail, + }, + }); + } + + return filter; + }) + .with(ExtendedDocumentStatus.INBOX, () => { + // Return a filter that will return nothing. + if (!teamEmail) { + return null; + } + + return { + status: { + not: ExtendedDocumentStatus.DRAFT, + }, + Recipient: { + some: { + email: teamEmail, + signingStatus: SigningStatus.NOT_SIGNED, + role: { + not: RecipientRole.CC, + }, + }, + }, + }; + }) + .with(ExtendedDocumentStatus.DRAFT, () => { + const filter: Prisma.DocumentWhereInput = { + OR: [ + { + teamId: team.id, + status: ExtendedDocumentStatus.DRAFT, + }, + ], + }; + + if (teamEmail && filter.OR) { + filter.OR.push({ + status: ExtendedDocumentStatus.DRAFT, + User: { + email: teamEmail, + }, + }); + } + + return filter; + }) + .with(ExtendedDocumentStatus.PENDING, () => { + const filter: Prisma.DocumentWhereInput = { + OR: [ + { + teamId: team.id, + status: ExtendedDocumentStatus.PENDING, + }, + ], + }; + + if (teamEmail && filter.OR) { + filter.OR.push({ + status: ExtendedDocumentStatus.PENDING, + OR: [ + { + Recipient: { + some: { + email: teamEmail, + signingStatus: SigningStatus.SIGNED, + role: { + not: RecipientRole.CC, + }, + }, + }, + }, + { + User: { + email: teamEmail, + }, + }, + ], + }); + } + + return filter; + }) + .with(ExtendedDocumentStatus.COMPLETED, () => { + const filter: Prisma.DocumentWhereInput = { + status: ExtendedDocumentStatus.COMPLETED, + OR: [ + { + teamId: team.id, + }, + ], + }; + + if (teamEmail && filter.OR) { + filter.OR.push( + { + Recipient: { + some: { + email: teamEmail, + }, + }, + }, + { + User: { + email: teamEmail, + }, + }, + ); + } + + return filter; + }) + .exhaustive(); +}; diff --git a/packages/lib/server-only/document/get-document-by-id.ts b/packages/lib/server-only/document/get-document-by-id.ts index 0b599a71c..71b614976 100644 --- a/packages/lib/server-only/document/get-document-by-id.ts +++ b/packages/lib/server-only/document/get-document-by-id.ts @@ -1,19 +1,106 @@ import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@documenso/prisma/client'; -export interface GetDocumentByIdOptions { +import { getTeamById } from '../team/get-team'; + +export type GetDocumentByIdOptions = { id: number; userId: number; -} + teamId?: number; +}; + +export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOptions) => { + const documentWhereInput = await getDocumentWhereInput({ + documentId: id, + userId, + teamId, + }); -export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) => { return await prisma.document.findFirstOrThrow({ - where: { - id, - userId, - }, + where: documentWhereInput, include: { documentData: true, documentMeta: true, }, }); }; + +export type GetDocumentWhereInputOptions = { + documentId: number; + userId: number; + teamId?: number; + + /** + * Whether to return a filter that allows access to both the user and team documents. + * This only applies if `teamId` is passed in. + * + * If true, and `teamId` is passed in, the filter will allow both team and user documents. + * If false, and `teamId` is passed in, the filter will only allow team documents. + * + * Defaults to false. + */ + overlapUserTeamScope?: boolean; +}; + +/** + * 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, + overlapUserTeamScope = false, +}: GetDocumentWhereInputOptions) => { + const documentWhereInput: Prisma.DocumentWhereUniqueInput = { + id: documentId, + OR: [ + { + userId, + }, + ], + }; + + if (teamId === undefined || !documentWhereInput.OR) { + return documentWhereInput; + } + + const team = await getTeamById({ teamId, userId }); + + // Allow access to team and user documents. + if (overlapUserTeamScope) { + documentWhereInput.OR.push({ + teamId: team.id, + }); + } + + // Allow access to only team documents. + if (!overlapUserTeamScope) { + documentWhereInput.OR = [ + { + teamId: team.id, + }, + ]; + } + + // Allow access to documents sent to or from the team email. + if (team.teamEmail) { + documentWhereInput.OR.push( + { + Recipient: { + some: { + email: team.teamEmail.email, + }, + }, + }, + { + User: { + email: team.teamEmail.email, + }, + }, + ); + } + + return documentWhereInput; +}; diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index 6aaa9a596..db38fa79d 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -1,19 +1,19 @@ import { DateTime } from 'luxon'; +import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; import { prisma } from '@documenso/prisma'; import type { Prisma, User } from '@documenso/prisma/client'; import { SigningStatus } from '@documenso/prisma/client'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; -import type { PeriodSelectorValue } from './find-documents'; - export type GetStatsInput = { user: User; + team?: Omit; period?: PeriodSelectorValue; }; -export const getStats = async ({ user, period }: GetStatsInput) => { +export const getStats = async ({ user, period, ...options }: GetStatsInput) => { let createdAt: Prisma.DocumentWhereInput['createdAt']; if (period) { @@ -26,7 +26,52 @@ export const getStats = async ({ user, period }: GetStatsInput) => { }; } - const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([ + const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team + ? getTeamCounts({ ...options.team, createdAt }) + : getCounts({ user, createdAt })); + + const stats: Record = { + [ExtendedDocumentStatus.DRAFT]: 0, + [ExtendedDocumentStatus.PENDING]: 0, + [ExtendedDocumentStatus.COMPLETED]: 0, + [ExtendedDocumentStatus.INBOX]: 0, + [ExtendedDocumentStatus.ALL]: 0, + }; + + ownerCounts.forEach((stat) => { + stats[stat.status] = stat._count._all; + }); + + notSignedCounts.forEach((stat) => { + stats[ExtendedDocumentStatus.INBOX] += stat._count._all; + }); + + hasSignedCounts.forEach((stat) => { + if (stat.status === ExtendedDocumentStatus.COMPLETED) { + stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all; + } + + if (stat.status === ExtendedDocumentStatus.PENDING) { + stats[ExtendedDocumentStatus.PENDING] += stat._count._all; + } + }); + + Object.keys(stats).forEach((key) => { + if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) { + stats[ExtendedDocumentStatus.ALL] += stats[key]; + } + }); + + return stats; +}; + +type GetCountsOption = { + user: User; + createdAt: Prisma.DocumentWhereInput['createdAt']; +}; + +const getCounts = async ({ user, createdAt }: GetCountsOption) => { + return Promise.all([ prisma.document.groupBy({ by: ['status'], _count: { @@ -35,6 +80,7 @@ export const getStats = async ({ user, period }: GetStatsInput) => { where: { userId: user.id, createdAt, + teamId: null, deletedAt: null, }, }), @@ -91,38 +137,116 @@ export const getStats = async ({ user, period }: GetStatsInput) => { }, }), ]); +}; - const stats: Record = { - [ExtendedDocumentStatus.DRAFT]: 0, - [ExtendedDocumentStatus.PENDING]: 0, - [ExtendedDocumentStatus.COMPLETED]: 0, - [ExtendedDocumentStatus.INBOX]: 0, - [ExtendedDocumentStatus.ALL]: 0, +type GetTeamCountsOption = { + teamId: number; + teamEmail?: string; + senderIds?: number[]; + createdAt: Prisma.DocumentWhereInput['createdAt']; +}; + +const getTeamCounts = async (options: GetTeamCountsOption) => { + const { createdAt, teamId, teamEmail } = options; + + const senderIds = options.senderIds ?? []; + + const userIdWhereClause: Prisma.DocumentWhereInput['userId'] = + senderIds.length > 0 + ? { + in: senderIds, + } + : undefined; + + let ownerCountsWhereInput: Prisma.DocumentWhereInput = { + userId: userIdWhereClause, + createdAt, + teamId, + deletedAt: null, }; - ownerCounts.forEach((stat) => { - stats[stat.status] = stat._count._all; - }); + let notSignedCountsGroupByArgs = null; + let hasSignedCountsGroupByArgs = null; - notSignedCounts.forEach((stat) => { - stats[ExtendedDocumentStatus.INBOX] += stat._count._all; - }); + if (teamEmail) { + ownerCountsWhereInput = { + userId: userIdWhereClause, + createdAt, + OR: [ + { + teamId, + }, + { + User: { + email: teamEmail, + }, + }, + ], + deletedAt: null, + }; - hasSignedCounts.forEach((stat) => { - if (stat.status === ExtendedDocumentStatus.COMPLETED) { - stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all; - } + notSignedCountsGroupByArgs = { + by: ['status'], + _count: { + _all: true, + }, + where: { + userId: userIdWhereClause, + createdAt, + status: ExtendedDocumentStatus.PENDING, + Recipient: { + some: { + email: teamEmail, + signingStatus: SigningStatus.NOT_SIGNED, + }, + }, + deletedAt: null, + }, + } satisfies Prisma.DocumentGroupByArgs; - if (stat.status === ExtendedDocumentStatus.PENDING) { - stats[ExtendedDocumentStatus.PENDING] += stat._count._all; - } - }); + hasSignedCountsGroupByArgs = { + by: ['status'], + _count: { + _all: true, + }, + where: { + userId: userIdWhereClause, + createdAt, + OR: [ + { + status: ExtendedDocumentStatus.PENDING, + Recipient: { + some: { + email: teamEmail, + signingStatus: SigningStatus.SIGNED, + }, + }, + deletedAt: null, + }, + { + status: ExtendedDocumentStatus.COMPLETED, + Recipient: { + some: { + email: teamEmail, + signingStatus: SigningStatus.SIGNED, + }, + }, + deletedAt: null, + }, + ], + }, + } satisfies Prisma.DocumentGroupByArgs; + } - Object.keys(stats).forEach((key) => { - if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) { - stats[ExtendedDocumentStatus.ALL] += stats[key]; - } - }); - - return stats; + return Promise.all([ + prisma.document.groupBy({ + by: ['status'], + _count: { + _all: true, + }, + where: ownerCountsWhereInput, + }), + notSignedCountsGroupByArgs ? prisma.document.groupBy(notSignedCountsGroupByArgs) : [], + hasSignedCountsGroupByArgs ? prisma.document.groupBy(hasSignedCountsGroupByArgs) : [], + ]); }; diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx index 4c7b66be8..d72da3a8d 100644 --- a/packages/lib/server-only/document/resend-document.tsx +++ b/packages/lib/server-only/document/resend-document.tsx @@ -7,27 +7,38 @@ import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { prisma } from '@documenso/prisma'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; +import type { Prisma } from '@documenso/prisma/client'; import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles'; +import { getDocumentWhereInput } from './get-document-by-id'; export type ResendDocumentOptions = { documentId: number; userId: number; recipients: number[]; + teamId?: number; }; -export const resendDocument = async ({ documentId, userId, recipients }: ResendDocumentOptions) => { +export const resendDocument = async ({ + documentId, + userId, + recipients, + teamId, +}: ResendDocumentOptions) => { const user = await prisma.user.findFirstOrThrow({ where: { id: userId, }, }); + const documentWhereInput: Prisma.DocumentWhereUniqueInput = await getDocumentWhereInput({ + documentId, + userId, + teamId, + }); + const document = await prisma.document.findUnique({ - where: { - id: documentId, - userId, - }, + where: documentWhereInput, include: { Recipient: { where: { diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 82b37852b..312b30462 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -25,7 +25,20 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) const document = await prisma.document.findUnique({ where: { id: documentId, - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, include: { Recipient: true, diff --git a/packages/lib/server-only/document/update-title.ts b/packages/lib/server-only/document/update-title.ts index ba086b9cb..19a902930 100644 --- a/packages/lib/server-only/document/update-title.ts +++ b/packages/lib/server-only/document/update-title.ts @@ -12,7 +12,20 @@ export const updateTitle = async ({ userId, documentId, title }: UpdateTitleOpti return await prisma.document.update({ where: { id: documentId, - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, data: { title, diff --git a/packages/lib/server-only/field/get-fields-for-document.ts b/packages/lib/server-only/field/get-fields-for-document.ts index ddc35b503..72a16c3f7 100644 --- a/packages/lib/server-only/field/get-fields-for-document.ts +++ b/packages/lib/server-only/field/get-fields-for-document.ts @@ -10,7 +10,20 @@ export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForD where: { documentId, Document: { - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, }, orderBy: { diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts index bd14d49b2..2ba592f31 100644 --- a/packages/lib/server-only/field/set-fields-for-document.ts +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -25,7 +25,20 @@ export const setFieldsForDocument = async ({ const document = await prisma.document.findFirst({ where: { id: documentId, - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, }); diff --git a/packages/lib/server-only/recipient/get-recipients-for-document.ts b/packages/lib/server-only/recipient/get-recipients-for-document.ts index 21d198d3e..80e408acc 100644 --- a/packages/lib/server-only/recipient/get-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/get-recipients-for-document.ts @@ -13,7 +13,20 @@ export const getRecipientsForDocument = async ({ where: { documentId, Document: { - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, }, orderBy: { diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-recipients-for-document.ts index 4917b213d..d42d1d707 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -23,7 +23,20 @@ export const setRecipientsForDocument = async ({ const document = await prisma.document.findFirst({ where: { id: documentId, - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, }); diff --git a/packages/lib/server-only/team/accept-team-invitation.ts b/packages/lib/server-only/team/accept-team-invitation.ts new file mode 100644 index 000000000..a69a79ecd --- /dev/null +++ b/packages/lib/server-only/team/accept-team-invitation.ts @@ -0,0 +1,63 @@ +import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; +import { prisma } from '@documenso/prisma'; + +import { IS_BILLING_ENABLED } from '../../constants/app'; + +export type AcceptTeamInvitationOptions = { + userId: number; + teamId: number; +}; + +export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitationOptions) => { + await prisma.$transaction(async (tx) => { + const user = await tx.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + + const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({ + where: { + teamId, + email: user.email, + }, + include: { + team: { + include: { + subscription: true, + }, + }, + }, + }); + + const { team } = teamMemberInvite; + + await tx.teamMember.create({ + data: { + teamId: teamMemberInvite.teamId, + userId: user.id, + role: teamMemberInvite.role, + }, + }); + + await tx.teamMemberInvite.delete({ + where: { + id: teamMemberInvite.id, + }, + }); + + if (IS_BILLING_ENABLED && team.subscription) { + const numberOfSeats = await tx.teamMember.count({ + where: { + teamId: teamMemberInvite.teamId, + }, + }); + + await updateSubscriptionItemQuantity({ + priceId: team.subscription.priceId, + subscriptionId: team.subscription.planId, + quantity: numberOfSeats, + }); + } + }); +}; diff --git a/packages/lib/server-only/team/create-team-billing-portal.ts b/packages/lib/server-only/team/create-team-billing-portal.ts new file mode 100644 index 000000000..d394f2720 --- /dev/null +++ b/packages/lib/server-only/team/create-team-billing-portal.ts @@ -0,0 +1,47 @@ +import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { prisma } from '@documenso/prisma'; + +export type CreateTeamBillingPortalOptions = { + userId: number; + teamId: number; +}; + +export const createTeamBillingPortal = async ({ + userId, + teamId, +}: CreateTeamBillingPortalOptions) => { + if (!IS_BILLING_ENABLED) { + throw new Error('Billing is not enabled'); + } + + const team = await prisma.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_BILLING'], + }, + }, + }, + }, + include: { + subscription: true, + }, + }); + + if (!team.subscription) { + throw new Error('Team has no subscription'); + } + + if (!team.customerId) { + throw new Error('Team has no customerId'); + } + + return getPortalSession({ + customerId: team.customerId, + }); +}; diff --git a/packages/lib/server-only/team/create-team-checkout-session.ts b/packages/lib/server-only/team/create-team-checkout-session.ts new file mode 100644 index 000000000..b80fc260b --- /dev/null +++ b/packages/lib/server-only/team/create-team-checkout-session.ts @@ -0,0 +1,52 @@ +import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session'; +import { getTeamPrices } from '@documenso/ee/server-only/stripe/get-team-prices'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +export type CreateTeamPendingCheckoutSession = { + userId: number; + pendingTeamId: number; + interval: 'monthly' | 'yearly'; +}; + +export const createTeamPendingCheckoutSession = async ({ + userId, + pendingTeamId, + interval, +}: CreateTeamPendingCheckoutSession) => { + const teamPendingCreation = await prisma.teamPending.findFirstOrThrow({ + where: { + id: pendingTeamId, + ownerUserId: userId, + }, + include: { + owner: true, + }, + }); + + const prices = await getTeamPrices(); + const priceId = prices[interval].priceId; + + try { + const stripeCheckoutSession = await getCheckoutSession({ + customerId: teamPendingCreation.customerId, + priceId, + returnUrl: `${WEBAPP_BASE_URL}/settings/teams`, + subscriptionMetadata: { + pendingTeamId: pendingTeamId.toString(), + }, + }); + + if (!stripeCheckoutSession) { + throw new AppError(AppErrorCode.UNKNOWN_ERROR); + } + + return stripeCheckoutSession; + } catch (e) { + console.error(e); + + // Absorb all the errors incase Stripe throws something sensitive. + throw new AppError(AppErrorCode.UNKNOWN_ERROR, 'Something went wrong.'); + } +}; diff --git a/packages/lib/server-only/team/create-team-email-verification.ts b/packages/lib/server-only/team/create-team-email-verification.ts new file mode 100644 index 000000000..28e1538d0 --- /dev/null +++ b/packages/lib/server-only/team/create-team-email-verification.ts @@ -0,0 +1,132 @@ +import { createElement } from 'react'; + +import { z } from 'zod'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { createTokenVerification } from '@documenso/lib/utils/token-verification'; +import { prisma } from '@documenso/prisma'; +import { Prisma } from '@documenso/prisma/client'; + +export type CreateTeamEmailVerificationOptions = { + userId: number; + teamId: number; + data: { + email: string; + name: string; + }; +}; + +export const createTeamEmailVerification = async ({ + userId, + teamId, + data, +}: CreateTeamEmailVerificationOptions) => { + try { + await prisma.$transaction(async (tx) => { + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + include: { + teamEmail: true, + emailVerification: true, + }, + }); + + if (team.teamEmail || team.emailVerification) { + throw new AppError( + AppErrorCode.INVALID_REQUEST, + 'Team already has an email or existing email verification.', + ); + } + + const existingTeamEmail = await tx.teamEmail.findFirst({ + where: { + email: data.email, + }, + }); + + if (existingTeamEmail) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.'); + } + + const { token, expiresAt } = createTokenVerification({ hours: 1 }); + + await tx.teamEmailVerification.create({ + data: { + token, + expiresAt, + email: data.email, + name: data.name, + teamId, + }, + }); + + await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url); + }); + } catch (err) { + console.error(err); + + if (!(err instanceof Prisma.PrismaClientKnownRequestError)) { + throw err; + } + + const target = z.array(z.string()).safeParse(err.meta?.target); + + if (err.code === 'P2002' && target.success && target.data.includes('email')) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.'); + } + + throw err; + } +}; + +/** + * Send an email to a user asking them to accept a team email request. + * + * @param email The email address to use for the team. + * @param token The token used to authenticate that the user has granted access. + * @param teamName The name of the team the user is being invited to. + * @param teamUrl The url of the team the user is being invited to. + */ +export const sendTeamEmailVerificationEmail = async ( + email: string, + token: string, + teamName: string, + teamUrl: string, +) => { + const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000'; + + const template = createElement(ConfirmTeamEmailTemplate, { + assetBaseUrl, + baseUrl: WEBAPP_BASE_URL, + teamName, + teamUrl, + token, + }); + + await mailer.sendMail({ + to: email, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: `A request to use your email has been initiated by ${teamName} on Documenso`, + html: render(template), + text: render(template, { plainText: true }), + }); +}; diff --git a/packages/lib/server-only/team/create-team-member-invites.ts b/packages/lib/server-only/team/create-team-member-invites.ts new file mode 100644 index 000000000..f167d2112 --- /dev/null +++ b/packages/lib/server-only/team/create-team-member-invites.ts @@ -0,0 +1,161 @@ +import { createElement } from 'react'; + +import { nanoid } from 'nanoid'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import type { TeamInviteEmailProps } from '@documenso/email/templates/team-invite'; +import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberInviteStatus } from '@documenso/prisma/client'; +import type { TCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema'; + +export type CreateTeamMemberInvitesOptions = { + userId: number; + userName: string; + teamId: number; + invitations: TCreateTeamMemberInvitesMutationSchema['invitations']; +}; + +/** + * Invite team members via email to join a team. + */ +export const createTeamMemberInvites = async ({ + userId, + userName, + teamId, + invitations, +}: CreateTeamMemberInvitesOptions) => { + const team = await prisma.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + include: { + members: { + select: { + role: true, + user: { + select: { + id: true, + email: true, + }, + }, + }, + }, + invites: true, + }, + }); + + const teamMemberEmails = team.members.map((member) => member.user.email); + const teamMemberInviteEmails = team.invites.map((invite) => invite.email); + const currentTeamMember = team.members.find((member) => member.user.id === userId); + + if (!currentTeamMember) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'User not part of team.'); + } + + const usersToInvite = invitations.filter((invitation) => { + // Filter out users that are already members of the team. + if (teamMemberEmails.includes(invitation.email)) { + return false; + } + + // Filter out users that have already been invited to the team. + if (teamMemberInviteEmails.includes(invitation.email)) { + return false; + } + + return true; + }); + + const unauthorizedRoleAccess = usersToInvite.some( + ({ role }) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, role), + ); + + if (unauthorizedRoleAccess) { + throw new AppError( + AppErrorCode.UNAUTHORIZED, + 'User does not have permission to set high level roles', + ); + } + + const teamMemberInvites = usersToInvite.map(({ email, role }) => ({ + email, + teamId, + role, + status: TeamMemberInviteStatus.PENDING, + token: nanoid(32), + })); + + await prisma.teamMemberInvite.createMany({ + data: teamMemberInvites, + }); + + const sendEmailResult = await Promise.allSettled( + teamMemberInvites.map(async ({ email, token }) => + sendTeamMemberInviteEmail({ + email, + token, + teamName: team.name, + teamUrl: team.url, + senderName: userName, + }), + ), + ); + + const sendEmailResultErrorList = sendEmailResult.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + + if (sendEmailResultErrorList.length > 0) { + console.error(JSON.stringify(sendEmailResultErrorList)); + + throw new AppError( + 'EmailDeliveryFailed', + 'Failed to send invite emails to one or more users.', + `Failed to send invites to ${sendEmailResultErrorList.length}/${teamMemberInvites.length} users.`, + ); + } +}; + +type SendTeamMemberInviteEmailOptions = Omit & { + email: string; +}; + +/** + * Send an email to a user inviting them to join a team. + */ +export const sendTeamMemberInviteEmail = async ({ + email, + ...emailTemplateOptions +}: SendTeamMemberInviteEmailOptions) => { + const template = createElement(TeamInviteEmailTemplate, { + assetBaseUrl: WEBAPP_BASE_URL, + baseUrl: WEBAPP_BASE_URL, + ...emailTemplateOptions, + }); + + await mailer.sendMail({ + to: email, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: `You have been invited to join ${emailTemplateOptions.teamName} on Documenso`, + html: render(template), + text: render(template, { plainText: true }), + }); +}; diff --git a/packages/lib/server-only/team/create-team.ts b/packages/lib/server-only/team/create-team.ts new file mode 100644 index 000000000..f1d245523 --- /dev/null +++ b/packages/lib/server-only/team/create-team.ts @@ -0,0 +1,207 @@ +import type Stripe from 'stripe'; +import { z } from 'zod'; + +import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer'; +import { getCommunityPlanPriceIds } from '@documenso/ee/server-only/stripe/get-community-plan-prices'; +import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing'; +import { prisma } from '@documenso/prisma'; +import { Prisma, TeamMemberRole } from '@documenso/prisma/client'; + +import { stripe } from '../stripe'; + +export type CreateTeamOptions = { + /** + * ID of the user creating the Team. + */ + userId: number; + + /** + * Name of the team to display. + */ + teamName: string; + + /** + * Unique URL of the team. + * + * Used as the URL path, example: https://documenso.com/t/{teamUrl}/settings + */ + teamUrl: string; +}; + +export type CreateTeamResponse = + | { + paymentRequired: false; + } + | { + paymentRequired: true; + pendingTeamId: number; + }; + +/** + * Create a team or pending team depending on the user's subscription or application's billing settings. + */ +export const createTeam = async ({ + userId, + teamName, + teamUrl, +}: CreateTeamOptions): Promise => { + const user = await prisma.user.findUniqueOrThrow({ + where: { + id: userId, + }, + include: { + Subscription: true, + }, + }); + + let isPaymentRequired = IS_BILLING_ENABLED; + let customerId: string | null = null; + + if (IS_BILLING_ENABLED) { + const communityPlanPriceIds = await getCommunityPlanPriceIds(); + + isPaymentRequired = !subscriptionsContainsActiveCommunityPlan( + user.Subscription, + communityPlanPriceIds, + ); + + customerId = await createTeamCustomer({ + name: user.name ?? teamName, + email: user.email, + }).then((customer) => customer.id); + } + + try { + // Create the team directly if no payment is required. + if (!isPaymentRequired) { + await prisma.team.create({ + data: { + name: teamName, + url: teamUrl, + ownerUserId: user.id, + customerId, + members: { + create: [ + { + userId, + role: TeamMemberRole.ADMIN, + }, + ], + }, + }, + }); + + return { + paymentRequired: false, + }; + } + + // Create a pending team if payment is required. + const pendingTeam = await prisma.$transaction(async (tx) => { + const existingTeamWithUrl = await tx.team.findUnique({ + where: { + url: teamUrl, + }, + }); + + if (existingTeamWithUrl) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.'); + } + + if (!customerId) { + throw new AppError(AppErrorCode.UNKNOWN_ERROR, 'Missing customer ID for pending teams.'); + } + + return await tx.teamPending.create({ + data: { + name: teamName, + url: teamUrl, + ownerUserId: user.id, + customerId, + }, + }); + }); + + return { + paymentRequired: true, + pendingTeamId: pendingTeam.id, + }; + } catch (err) { + console.error(err); + + if (!(err instanceof Prisma.PrismaClientKnownRequestError)) { + throw err; + } + + const target = z.array(z.string()).safeParse(err.meta?.target); + + if (err.code === 'P2002' && target.success && target.data.includes('url')) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.'); + } + + throw err; + } +}; + +export type CreateTeamFromPendingTeamOptions = { + pendingTeamId: number; + subscription: Stripe.Subscription; +}; + +export const createTeamFromPendingTeam = async ({ + pendingTeamId, + subscription, +}: CreateTeamFromPendingTeamOptions) => { + return await prisma.$transaction(async (tx) => { + const pendingTeam = await tx.teamPending.findUniqueOrThrow({ + where: { + id: pendingTeamId, + }, + }); + + await tx.teamPending.delete({ + where: { + id: pendingTeamId, + }, + }); + + const team = await tx.team.create({ + data: { + name: pendingTeam.name, + url: pendingTeam.url, + ownerUserId: pendingTeam.ownerUserId, + customerId: pendingTeam.customerId, + members: { + create: [ + { + userId: pendingTeam.ownerUserId, + role: TeamMemberRole.ADMIN, + }, + ], + }, + }, + }); + + await tx.subscription.upsert( + mapStripeSubscriptionToPrismaUpsertAction(subscription, undefined, team.id), + ); + + // Attach the team ID to the subscription metadata for sanity reasons. + await stripe.subscriptions + .update(subscription.id, { + metadata: { + teamId: team.id.toString(), + }, + }) + .catch((e) => { + console.error(e); + // Non-critical error, but we want to log it so we can rectify it. + // Todo: Teams - Alert us. + }); + + return team; + }); +}; diff --git a/packages/lib/server-only/team/delete-team-email-verification.ts b/packages/lib/server-only/team/delete-team-email-verification.ts new file mode 100644 index 000000000..fee39553f --- /dev/null +++ b/packages/lib/server-only/team/delete-team-email-verification.ts @@ -0,0 +1,34 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { prisma } from '@documenso/prisma'; + +export type DeleteTeamEmailVerificationOptions = { + userId: number; + teamId: number; +}; + +export const deleteTeamEmailVerification = async ({ + userId, + teamId, +}: DeleteTeamEmailVerificationOptions) => { + await prisma.$transaction(async (tx) => { + await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + }); + + await tx.teamEmailVerification.delete({ + where: { + teamId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/delete-team-email.ts b/packages/lib/server-only/team/delete-team-email.ts new file mode 100644 index 000000000..c5139a971 --- /dev/null +++ b/packages/lib/server-only/team/delete-team-email.ts @@ -0,0 +1,93 @@ +import { createElement } from 'react'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { prisma } from '@documenso/prisma'; + +export type DeleteTeamEmailOptions = { + userId: number; + userEmail: string; + teamId: number; +}; + +/** + * Delete a team email. + * + * The user must either be part of the team with the required permissions, or the owner of the email. + */ +export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamEmailOptions) => { + const team = await prisma.$transaction(async (tx) => { + const foundTeam = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + OR: [ + { + teamEmail: { + email: userEmail, + }, + }, + { + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + ], + }, + include: { + teamEmail: true, + owner: { + select: { + name: true, + email: true, + }, + }, + }, + }); + + await tx.teamEmail.delete({ + where: { + teamId, + }, + }); + + return foundTeam; + }); + + try { + const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000'; + + const template = createElement(TeamEmailRemovedTemplate, { + assetBaseUrl, + baseUrl: WEBAPP_BASE_URL, + teamEmail: team.teamEmail?.email ?? '', + teamName: team.name, + teamUrl: team.url, + }); + + await mailer.sendMail({ + to: { + address: team.owner.email, + name: team.owner.name ?? '', + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: `Team email has been revoked for ${team.name}`, + html: render(template), + text: render(template, { plainText: true }), + }); + } catch (e) { + // Todo: Teams - Alert us. + // We don't want to prevent a user from revoking access because an email could not be sent. + } +}; diff --git a/packages/lib/server-only/team/delete-team-invitations.ts b/packages/lib/server-only/team/delete-team-invitations.ts new file mode 100644 index 000000000..a2baf8352 --- /dev/null +++ b/packages/lib/server-only/team/delete-team-invitations.ts @@ -0,0 +1,47 @@ +import { prisma } from '@documenso/prisma'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; + +export type DeleteTeamMemberInvitationsOptions = { + /** + * The ID of the user who is initiating this action. + */ + userId: number; + + /** + * The ID of the team to remove members from. + */ + teamId: number; + + /** + * The IDs of the invitations to remove. + */ + invitationIds: number[]; +}; + +export const deleteTeamMemberInvitations = async ({ + userId, + teamId, + invitationIds, +}: DeleteTeamMemberInvitationsOptions) => { + await prisma.$transaction(async (tx) => { + await tx.teamMember.findFirstOrThrow({ + where: { + userId, + teamId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }); + + await tx.teamMemberInvite.deleteMany({ + where: { + id: { + in: invitationIds, + }, + teamId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/delete-team-members.ts b/packages/lib/server-only/team/delete-team-members.ts new file mode 100644 index 000000000..7e282af5a --- /dev/null +++ b/packages/lib/server-only/team/delete-team-members.ts @@ -0,0 +1,102 @@ +import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import { prisma } from '@documenso/prisma'; + +export type DeleteTeamMembersOptions = { + /** + * The ID of the user who is initiating this action. + */ + userId: number; + + /** + * The ID of the team to remove members from. + */ + teamId: number; + + /** + * The IDs of the team members to remove. + */ + teamMemberIds: number[]; +}; + +export const deleteTeamMembers = async ({ + userId, + teamId, + teamMemberIds, +}: DeleteTeamMembersOptions) => { + await prisma.$transaction(async (tx) => { + // Find the team and validate that the user is allowed to remove members. + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + include: { + members: { + select: { + id: true, + userId: true, + role: true, + }, + }, + subscription: true, + }, + }); + + const currentTeamMember = team.members.find((member) => member.userId === userId); + const teamMembersToRemove = team.members.filter((member) => teamMemberIds.includes(member.id)); + + if (!currentTeamMember) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist'); + } + + if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner'); + } + + const isMemberToRemoveHigherRole = teamMembersToRemove.some( + (member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role), + ); + + if (isMemberToRemoveHigherRole) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role'); + } + + // Remove the team members. + await tx.teamMember.deleteMany({ + where: { + id: { + in: teamMemberIds, + }, + teamId, + userId: { + not: team.ownerUserId, + }, + }, + }); + + if (IS_BILLING_ENABLED && team.subscription) { + const numberOfSeats = await tx.teamMember.count({ + where: { + teamId, + }, + }); + + await updateSubscriptionItemQuantity({ + priceId: team.subscription.priceId, + subscriptionId: team.subscription.planId, + quantity: numberOfSeats, + }); + } + }); +}; diff --git a/packages/lib/server-only/team/delete-team-pending.ts b/packages/lib/server-only/team/delete-team-pending.ts new file mode 100644 index 000000000..b339fd862 --- /dev/null +++ b/packages/lib/server-only/team/delete-team-pending.ts @@ -0,0 +1,15 @@ +import { prisma } from '@documenso/prisma'; + +export type DeleteTeamPendingOptions = { + userId: number; + pendingTeamId: number; +}; + +export const deleteTeamPending = async ({ userId, pendingTeamId }: DeleteTeamPendingOptions) => { + await prisma.teamPending.delete({ + where: { + id: pendingTeamId, + ownerUserId: userId, + }, + }); +}; diff --git a/packages/lib/server-only/team/delete-team-transfer-request.ts b/packages/lib/server-only/team/delete-team-transfer-request.ts new file mode 100644 index 000000000..245a72b5a --- /dev/null +++ b/packages/lib/server-only/team/delete-team-transfer-request.ts @@ -0,0 +1,42 @@ +import { prisma } from '@documenso/prisma'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; + +export type DeleteTeamTransferRequestOptions = { + /** + * The ID of the user deleting the transfer. + */ + userId: number; + + /** + * The ID of the team whose team transfer request should be deleted. + */ + teamId: number; +}; + +export const deleteTeamTransferRequest = async ({ + userId, + teamId, +}: DeleteTeamTransferRequestOptions) => { + await prisma.$transaction(async (tx) => { + await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_TEAM_TRANSFER_REQUEST'], + }, + }, + }, + }, + }); + + await tx.teamTransferVerification.delete({ + where: { + teamId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/delete-team.ts b/packages/lib/server-only/team/delete-team.ts new file mode 100644 index 000000000..dffc044d8 --- /dev/null +++ b/packages/lib/server-only/team/delete-team.ts @@ -0,0 +1,42 @@ +import { prisma } from '@documenso/prisma'; + +import { AppError } from '../../errors/app-error'; +import { stripe } from '../stripe'; + +export type DeleteTeamOptions = { + userId: number; + teamId: number; +}; + +export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => { + await prisma.$transaction(async (tx) => { + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + ownerUserId: userId, + }, + include: { + subscription: true, + }, + }); + + if (team.subscription) { + await stripe.subscriptions + .cancel(team.subscription.planId, { + prorate: false, + invoice_now: true, + }) + .catch((err) => { + console.error(err); + throw AppError.parseError(err); + }); + } + + await tx.team.delete({ + where: { + id: teamId, + ownerUserId: userId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/find-team-invoices.ts b/packages/lib/server-only/team/find-team-invoices.ts new file mode 100644 index 000000000..bbc84f3fd --- /dev/null +++ b/packages/lib/server-only/team/find-team-invoices.ts @@ -0,0 +1,52 @@ +import { getInvoices } from '@documenso/ee/server-only/stripe/get-invoices'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +export interface FindTeamInvoicesOptions { + userId: number; + teamId: number; +} + +export const findTeamInvoices = async ({ userId, teamId }: FindTeamInvoicesOptions) => { + const team = await prisma.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + }); + + if (!team.customerId) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Team has no customer ID.'); + } + + const results = await getInvoices({ customerId: team.customerId }); + + if (!results) { + return null; + } + + return { + ...results, + data: results.data.map((invoice) => ({ + invoicePdf: invoice.invoice_pdf, + hostedInvoicePdf: invoice.hosted_invoice_url, + status: invoice.status, + subtotal: invoice.subtotal, + total: invoice.total, + amountPaid: invoice.amount_paid, + amountDue: invoice.amount_due, + created: invoice.created, + paid: invoice.paid, + quantity: invoice.lines.data[0].quantity ?? 0, + currency: invoice.currency, + })), + }; +}; diff --git a/packages/lib/server-only/team/find-team-member-invites.ts b/packages/lib/server-only/team/find-team-member-invites.ts new file mode 100644 index 000000000..8100008b8 --- /dev/null +++ b/packages/lib/server-only/team/find-team-member-invites.ts @@ -0,0 +1,91 @@ +import { P, match } from 'ts-pattern'; + +import { prisma } from '@documenso/prisma'; +import type { TeamMemberInvite } from '@documenso/prisma/client'; +import { Prisma } from '@documenso/prisma/client'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; +import type { FindResultSet } from '../../types/find-result-set'; + +export interface FindTeamMemberInvitesOptions { + userId: number; + teamId: number; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof TeamMemberInvite; + direction: 'asc' | 'desc'; + }; +} + +export const findTeamMemberInvites = async ({ + userId, + teamId, + term, + page = 1, + perPage = 10, + orderBy, +}: FindTeamMemberInvitesOptions) => { + const orderByColumn = orderBy?.column ?? 'email'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + // Check that the user belongs to the team they are trying to find invites in. + const userTeam = await prisma.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + }); + + const termFilters: Prisma.TeamMemberInviteWhereInput | undefined = match(term) + .with(P.string.minLength(1), () => ({ + email: { + contains: term, + mode: Prisma.QueryMode.insensitive, + }, + })) + .otherwise(() => undefined); + + const whereClause: Prisma.TeamMemberInviteWhereInput = { + ...termFilters, + teamId: userTeam.id, + }; + + const [data, count] = await Promise.all([ + prisma.teamMemberInvite.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + // Exclude token attribute. + select: { + id: true, + teamId: true, + email: true, + role: true, + createdAt: true, + }, + }), + prisma.teamMemberInvite.count({ + where: whereClause, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultSet; +}; diff --git a/packages/lib/server-only/team/find-team-members.ts b/packages/lib/server-only/team/find-team-members.ts new file mode 100644 index 000000000..4a1ab8511 --- /dev/null +++ b/packages/lib/server-only/team/find-team-members.ts @@ -0,0 +1,100 @@ +import { P, match } from 'ts-pattern'; + +import { prisma } from '@documenso/prisma'; +import type { TeamMember } from '@documenso/prisma/client'; +import { Prisma } from '@documenso/prisma/client'; + +import type { FindResultSet } from '../../types/find-result-set'; + +export interface FindTeamMembersOptions { + userId: number; + teamId: number; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof TeamMember | 'name'; + direction: 'asc' | 'desc'; + }; +} + +export const findTeamMembers = async ({ + userId, + teamId, + term, + page = 1, + perPage = 10, + orderBy, +}: FindTeamMembersOptions) => { + const orderByColumn = orderBy?.column ?? 'name'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + // Check that the user belongs to the team they are trying to find members in. + const userTeam = await prisma.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + }); + + const termFilters: Prisma.TeamMemberWhereInput | undefined = match(term) + .with(P.string.minLength(1), () => ({ + user: { + name: { + contains: term, + mode: Prisma.QueryMode.insensitive, + }, + }, + })) + .otherwise(() => undefined); + + const whereClause: Prisma.TeamMemberWhereInput = { + ...termFilters, + teamId: userTeam.id, + }; + + let orderByClause: Prisma.TeamMemberOrderByWithRelationInput = { + [orderByColumn]: orderByDirection, + }; + + // Name field is nested in the user so we have to handle it differently. + if (orderByColumn === 'name') { + orderByClause = { + user: { + name: orderByDirection, + }, + }; + } + + const [data, count] = await Promise.all([ + prisma.teamMember.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: orderByClause, + include: { + user: { + select: { + name: true, + email: true, + }, + }, + }, + }), + prisma.teamMember.count({ + where: whereClause, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultSet; +}; diff --git a/packages/lib/server-only/team/find-teams-pending.ts b/packages/lib/server-only/team/find-teams-pending.ts new file mode 100644 index 000000000..d079c6f5f --- /dev/null +++ b/packages/lib/server-only/team/find-teams-pending.ts @@ -0,0 +1,58 @@ +import { prisma } from '@documenso/prisma'; +import type { Team } from '@documenso/prisma/client'; +import { Prisma } from '@documenso/prisma/client'; + +export interface FindTeamsPendingOptions { + userId: number; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof Team; + direction: 'asc' | 'desc'; + }; +} + +export const findTeamsPending = async ({ + userId, + term, + page = 1, + perPage = 10, + orderBy, +}: FindTeamsPendingOptions) => { + const orderByColumn = orderBy?.column ?? 'name'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + const whereClause: Prisma.TeamPendingWhereInput = { + ownerUserId: userId, + }; + + if (term && term.length > 0) { + whereClause.name = { + contains: term, + mode: Prisma.QueryMode.insensitive, + }; + } + + const [data, count] = await Promise.all([ + prisma.teamPending.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + }), + prisma.teamPending.count({ + where: whereClause, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + }; +}; diff --git a/packages/lib/server-only/team/find-teams.ts b/packages/lib/server-only/team/find-teams.ts new file mode 100644 index 000000000..f5376a65d --- /dev/null +++ b/packages/lib/server-only/team/find-teams.ts @@ -0,0 +1,76 @@ +import type { FindResultSet } from '@documenso/lib/types/find-result-set'; +import { prisma } from '@documenso/prisma'; +import type { Team } from '@documenso/prisma/client'; +import { Prisma } from '@documenso/prisma/client'; + +export interface FindTeamsOptions { + userId: number; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof Team; + direction: 'asc' | 'desc'; + }; +} + +export const findTeams = async ({ + userId, + term, + page = 1, + perPage = 10, + orderBy, +}: FindTeamsOptions) => { + const orderByColumn = orderBy?.column ?? 'name'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + const whereClause: Prisma.TeamWhereInput = { + members: { + some: { + userId, + }, + }, + }; + + if (term && term.length > 0) { + whereClause.name = { + contains: term, + mode: Prisma.QueryMode.insensitive, + }; + } + + const [data, count] = await Promise.all([ + prisma.team.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + include: { + members: { + where: { + userId, + }, + }, + }, + }), + prisma.team.count({ + where: whereClause, + }), + ]); + + const maskedData = data.map((team) => ({ + ...team, + currentTeamMember: team.members[0], + members: undefined, + })); + + return { + data: maskedData, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultSet; +}; diff --git a/packages/lib/server-only/team/get-team-email-by-email.ts b/packages/lib/server-only/team/get-team-email-by-email.ts new file mode 100644 index 000000000..665694db4 --- /dev/null +++ b/packages/lib/server-only/team/get-team-email-by-email.ts @@ -0,0 +1,22 @@ +import { prisma } from '@documenso/prisma'; + +export type GetTeamEmailByEmailOptions = { + email: string; +}; + +export const getTeamEmailByEmail = async ({ email }: GetTeamEmailByEmailOptions) => { + return await prisma.teamEmail.findFirst({ + where: { + email, + }, + include: { + team: { + select: { + id: true, + name: true, + url: true, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/team/get-team-invitations.ts b/packages/lib/server-only/team/get-team-invitations.ts new file mode 100644 index 000000000..737f1b3f7 --- /dev/null +++ b/packages/lib/server-only/team/get-team-invitations.ts @@ -0,0 +1,22 @@ +import { prisma } from '@documenso/prisma'; + +export type GetTeamInvitationsOptions = { + email: string; +}; + +export const getTeamInvitations = async ({ email }: GetTeamInvitationsOptions) => { + return await prisma.teamMemberInvite.findMany({ + where: { + email, + }, + include: { + team: { + select: { + id: true, + name: true, + url: true, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/team/get-team-members.ts b/packages/lib/server-only/team/get-team-members.ts new file mode 100644 index 000000000..a29ed6e1d --- /dev/null +++ b/packages/lib/server-only/team/get-team-members.ts @@ -0,0 +1,33 @@ +import { prisma } from '@documenso/prisma'; + +export type GetTeamMembersOptions = { + userId: number; + teamId: number; +}; + +/** + * Get all team members for a given team. + */ +export const getTeamMembers = async ({ userId, teamId }: GetTeamMembersOptions) => { + return await prisma.teamMember.findMany({ + where: { + team: { + id: teamId, + members: { + some: { + userId: userId, + }, + }, + }, + }, + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/team/get-team.ts b/packages/lib/server-only/team/get-team.ts new file mode 100644 index 000000000..59331202e --- /dev/null +++ b/packages/lib/server-only/team/get-team.ts @@ -0,0 +1,95 @@ +import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@documenso/prisma/client'; + +export type GetTeamByIdOptions = { + userId?: number; + teamId: number; +}; + +/** + * Get a team given a teamId. + * + * Provide an optional userId to check that the user is a member of the team. + */ +export const getTeamById = async ({ userId, teamId }: GetTeamByIdOptions) => { + const whereFilter: Prisma.TeamWhereUniqueInput = { + id: teamId, + }; + + if (userId !== undefined) { + whereFilter['members'] = { + some: { + userId, + }, + }; + } + + const result = await prisma.team.findUniqueOrThrow({ + where: whereFilter, + include: { + teamEmail: true, + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + }, + }); + + const { members, ...team } = result; + + return { + ...team, + currentTeamMember: userId !== undefined ? members[0] : null, + }; +}; + +export type GetTeamByUrlOptions = { + userId: number; + teamUrl: string; +}; + +/** + * Get a team given a team URL. + */ +export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) => { + const whereFilter: Prisma.TeamWhereUniqueInput = { + url: teamUrl, + }; + + if (userId !== undefined) { + whereFilter['members'] = { + some: { + userId, + }, + }; + } + + const result = await prisma.team.findUniqueOrThrow({ + where: whereFilter, + include: { + teamEmail: true, + emailVerification: true, + transferVerification: true, + subscription: true, + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + }, + }); + + const { members, ...team } = result; + + return { + ...team, + currentTeamMember: members[0], + }; +}; diff --git a/packages/lib/server-only/team/get-teams.ts b/packages/lib/server-only/team/get-teams.ts new file mode 100644 index 000000000..57a9fb83e --- /dev/null +++ b/packages/lib/server-only/team/get-teams.ts @@ -0,0 +1,33 @@ +import { prisma } from '@documenso/prisma'; + +export type GetTeamsOptions = { + userId: number; +}; +export type GetTeamsResponse = Awaited>; + +export const getTeams = async ({ userId }: GetTeamsOptions) => { + const teams = await prisma.team.findMany({ + where: { + members: { + some: { + userId, + }, + }, + }, + include: { + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + }, + }); + + return teams.map(({ members, ...team }) => ({ + ...team, + currentTeamMember: members[0], + })); +}; diff --git a/packages/lib/server-only/team/leave-team.ts b/packages/lib/server-only/team/leave-team.ts new file mode 100644 index 000000000..d0c6fe145 --- /dev/null +++ b/packages/lib/server-only/team/leave-team.ts @@ -0,0 +1,59 @@ +import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; + +export type LeaveTeamOptions = { + /** + * The ID of the user who is leaving the team. + */ + userId: number; + + /** + * The ID of the team the user is leaving. + */ + teamId: number; +}; + +export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => { + await prisma.$transaction(async (tx) => { + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + ownerUserId: { + not: userId, + }, + }, + include: { + subscription: true, + }, + }); + + await tx.teamMember.delete({ + where: { + userId_teamId: { + userId, + teamId, + }, + team: { + ownerUserId: { + not: userId, + }, + }, + }, + }); + + if (IS_BILLING_ENABLED && team.subscription) { + const numberOfSeats = await tx.teamMember.count({ + where: { + teamId, + }, + }); + + await updateSubscriptionItemQuantity({ + priceId: team.subscription.priceId, + subscriptionId: team.subscription.planId, + quantity: numberOfSeats, + }); + } + }); +}; diff --git a/packages/lib/server-only/team/request-team-ownership-transfer.ts b/packages/lib/server-only/team/request-team-ownership-transfer.ts new file mode 100644 index 000000000..7da976ee1 --- /dev/null +++ b/packages/lib/server-only/team/request-team-ownership-transfer.ts @@ -0,0 +1,106 @@ +import { createElement } from 'react'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import { TeamTransferRequestTemplate } from '@documenso/email/templates/team-transfer-request'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; +import { createTokenVerification } from '@documenso/lib/utils/token-verification'; +import { prisma } from '@documenso/prisma'; + +export type RequestTeamOwnershipTransferOptions = { + /** + * The ID of the user initiating the transfer. + */ + userId: number; + + /** + * The name of the user initiating the transfer. + */ + userName: string; + + /** + * The ID of the team whose ownership is being transferred. + */ + teamId: number; + + /** + * The user ID of the new owner. + */ + newOwnerUserId: number; + + /** + * Whether to clear any current payment methods attached to the team. + */ + clearPaymentMethods: boolean; +}; + +export const requestTeamOwnershipTransfer = async ({ + userId, + userName, + teamId, + newOwnerUserId, +}: RequestTeamOwnershipTransferOptions) => { + // Todo: Clear payment methods disabled for now. + const clearPaymentMethods = false; + + await prisma.$transaction(async (tx) => { + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + ownerUserId: userId, + members: { + some: { + userId: newOwnerUserId, + }, + }, + }, + }); + + const newOwnerUser = await tx.user.findFirstOrThrow({ + where: { + id: newOwnerUserId, + }, + }); + + const { token, expiresAt } = createTokenVerification({ minute: 10 }); + + const teamVerificationPayload = { + teamId, + token, + expiresAt, + userId: newOwnerUserId, + name: newOwnerUser.name ?? '', + email: newOwnerUser.email, + clearPaymentMethods, + }; + + await tx.teamTransferVerification.upsert({ + where: { + teamId, + }, + create: teamVerificationPayload, + update: teamVerificationPayload, + }); + + const template = createElement(TeamTransferRequestTemplate, { + assetBaseUrl: WEBAPP_BASE_URL, + baseUrl: WEBAPP_BASE_URL, + senderName: userName, + teamName: team.name, + teamUrl: team.url, + token, + }); + + await mailer.sendMail({ + to: newOwnerUser.email, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: `You have been requested to take ownership of team ${team.name} on Documenso`, + html: render(template), + text: render(template, { plainText: true }), + }); + }); +}; diff --git a/packages/lib/server-only/team/resend-team-email-verification.ts b/packages/lib/server-only/team/resend-team-email-verification.ts new file mode 100644 index 000000000..55afe61ce --- /dev/null +++ b/packages/lib/server-only/team/resend-team-email-verification.ts @@ -0,0 +1,65 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { createTokenVerification } from '@documenso/lib/utils/token-verification'; +import { prisma } from '@documenso/prisma'; + +import { sendTeamEmailVerificationEmail } from './create-team-email-verification'; + +export type ResendTeamMemberInvitationOptions = { + userId: number; + teamId: number; +}; + +/** + * Resend a team email verification with a new token. + */ +export const resendTeamEmailVerification = async ({ + userId, + teamId, +}: ResendTeamMemberInvitationOptions) => { + await prisma.$transaction(async (tx) => { + const team = await tx.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + include: { + emailVerification: true, + }, + }); + + if (!team) { + throw new AppError('TeamNotFound', 'User is not a member of the team.'); + } + + const { emailVerification } = team; + + if (!emailVerification) { + throw new AppError( + 'VerificationNotFound', + 'No team email verification exists for this team.', + ); + } + + const { token, expiresAt } = createTokenVerification({ hours: 1 }); + + await tx.teamEmailVerification.update({ + where: { + teamId, + }, + data: { + token, + expiresAt, + }, + }); + + await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url); + }); +}; diff --git a/packages/lib/server-only/team/resend-team-member-invitation.ts b/packages/lib/server-only/team/resend-team-member-invitation.ts new file mode 100644 index 000000000..fb860ccc0 --- /dev/null +++ b/packages/lib/server-only/team/resend-team-member-invitation.ts @@ -0,0 +1,76 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +import { sendTeamMemberInviteEmail } from './create-team-member-invites'; + +export type ResendTeamMemberInvitationOptions = { + /** + * The ID of the user who is initiating this action. + */ + userId: number; + + /** + * The name of the user who is initiating this action. + */ + userName: string; + + /** + * The ID of the team. + */ + teamId: number; + + /** + * The IDs of the invitations to resend. + */ + invitationId: number; +}; + +/** + * Resend an email for a given team member invite. + */ +export const resendTeamMemberInvitation = async ({ + userId, + userName, + teamId, + invitationId, +}: ResendTeamMemberInvitationOptions) => { + await prisma.$transaction(async (tx) => { + const team = await tx.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + }); + + if (!team) { + throw new AppError('TeamNotFound', 'User is not a valid member of the team.'); + } + + const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({ + where: { + id: invitationId, + teamId, + }, + }); + + if (!teamMemberInvite) { + throw new AppError('InviteNotFound', 'No invite exists for this user.'); + } + + await sendTeamMemberInviteEmail({ + email: teamMemberInvite.email, + token: teamMemberInvite.token, + teamName: team.name, + teamUrl: team.url, + senderName: userName, + }); + }); +}; diff --git a/packages/lib/server-only/team/transfer-team-ownership.ts b/packages/lib/server-only/team/transfer-team-ownership.ts new file mode 100644 index 000000000..bb14eec55 --- /dev/null +++ b/packages/lib/server-only/team/transfer-team-ownership.ts @@ -0,0 +1,88 @@ +import type Stripe from 'stripe'; + +import { transferTeamSubscription } from '@documenso/ee/server-only/stripe/transfer-team-subscription'; +import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberRole } from '@documenso/prisma/client'; + +export type TransferTeamOwnershipOptions = { + token: string; +}; + +export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOptions) => { + await prisma.$transaction(async (tx) => { + const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({ + where: { + token, + }, + include: { + team: { + include: { + subscription: true, + }, + }, + }, + }); + + const { team, userId: newOwnerUserId } = teamTransferVerification; + + await tx.teamTransferVerification.delete({ + where: { + teamId: team.id, + }, + }); + + const newOwnerUser = await tx.user.findFirstOrThrow({ + where: { + id: newOwnerUserId, + teamMembers: { + some: { + teamId: team.id, + }, + }, + }, + include: { + Subscription: true, + }, + }); + + let teamSubscription: Stripe.Subscription | null = null; + + if (IS_BILLING_ENABLED) { + teamSubscription = await transferTeamSubscription({ + user: newOwnerUser, + team, + clearPaymentMethods: teamTransferVerification.clearPaymentMethods, + }); + } + + if (teamSubscription) { + await tx.subscription.upsert( + mapStripeSubscriptionToPrismaUpsertAction(teamSubscription, undefined, team.id), + ); + } + + await tx.team.update({ + where: { + id: team.id, + }, + data: { + ownerUserId: newOwnerUserId, + members: { + update: { + where: { + userId_teamId: { + teamId: team.id, + userId: newOwnerUserId, + }, + }, + data: { + role: TeamMemberRole.ADMIN, + }, + }, + }, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/update-team-email.ts b/packages/lib/server-only/team/update-team-email.ts new file mode 100644 index 000000000..05023efc7 --- /dev/null +++ b/packages/lib/server-only/team/update-team-email.ts @@ -0,0 +1,42 @@ +import { prisma } from '@documenso/prisma'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; + +export type UpdateTeamEmailOptions = { + userId: number; + teamId: number; + data: { + name: string; + }; +}; + +export const updateTeamEmail = async ({ userId, teamId, data }: UpdateTeamEmailOptions) => { + await prisma.$transaction(async (tx) => { + await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + teamEmail: { + isNot: null, + }, + }, + }); + + await tx.teamEmail.update({ + where: { + teamId, + }, + data: { + // Note: Never allow the email to be updated without re-verifying via email. + name: data.name, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/update-team-member.ts b/packages/lib/server-only/team/update-team-member.ts new file mode 100644 index 000000000..9a4a85f85 --- /dev/null +++ b/packages/lib/server-only/team/update-team-member.ts @@ -0,0 +1,92 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import { prisma } from '@documenso/prisma'; +import type { TeamMemberRole } from '@documenso/prisma/client'; + +export type UpdateTeamMemberOptions = { + userId: number; + teamId: number; + teamMemberId: number; + data: { + role: TeamMemberRole; + }; +}; + +export const updateTeamMember = async ({ + userId, + teamId, + teamMemberId, + data, +}: UpdateTeamMemberOptions) => { + await prisma.$transaction(async (tx) => { + // Find the team and validate that the user is allowed to update members. + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + include: { + members: { + select: { + id: true, + userId: true, + role: true, + }, + }, + }, + }); + + const currentTeamMember = team.members.find((member) => member.userId === userId); + const teamMemberToUpdate = team.members.find((member) => member.id === teamMemberId); + + if (!teamMemberToUpdate || !currentTeamMember) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Team member does not exist'); + } + + if (teamMemberToUpdate.userId === team.ownerUserId) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot update the owner'); + } + + const isMemberToUpdateHigherRole = !isTeamRoleWithinUserHierarchy( + currentTeamMember.role, + teamMemberToUpdate.role, + ); + + if (isMemberToUpdateHigherRole) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot update a member with a higher role'); + } + + const isNewMemberRoleHigherThanCurrentRole = !isTeamRoleWithinUserHierarchy( + currentTeamMember.role, + data.role, + ); + + if (isNewMemberRoleHigherThanCurrentRole) { + throw new AppError( + AppErrorCode.UNAUTHORIZED, + 'Cannot give a member a role higher than the user initating the update', + ); + } + + return await tx.teamMember.update({ + where: { + id: teamMemberId, + teamId, + userId: { + not: team.ownerUserId, + }, + }, + data: { + role: data.role, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/update-team.ts b/packages/lib/server-only/team/update-team.ts new file mode 100644 index 000000000..b172d3359 --- /dev/null +++ b/packages/lib/server-only/team/update-team.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; +import { Prisma } from '@documenso/prisma/client'; + +export type UpdateTeamOptions = { + userId: number; + teamId: number; + data: { + name?: string; + url?: string; + }; +}; + +export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions) => { + try { + await prisma.$transaction(async (tx) => { + const foundPendingTeamWithUrl = await tx.teamPending.findFirst({ + where: { + url: data.url, + }, + }); + + if (foundPendingTeamWithUrl) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.'); + } + + const team = await tx.team.update({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + data: { + url: data.url, + name: data.name, + }, + }); + + return team; + }); + } catch (err) { + console.error(err); + + if (!(err instanceof Prisma.PrismaClientKnownRequestError)) { + throw err; + } + + const target = z.array(z.string()).safeParse(err.meta?.target); + + if (err.code === 'P2002' && target.success && target.data.includes('url')) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.'); + } + + throw err; + } +}; diff --git a/packages/lib/server-only/user/create-user.ts b/packages/lib/server-only/user/create-user.ts index f7db60c85..42a9f128c 100644 --- a/packages/lib/server-only/user/create-user.ts +++ b/packages/lib/server-only/user/create-user.ts @@ -1,11 +1,12 @@ import { hash } from 'bcrypt'; import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; +import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; import { prisma } from '@documenso/prisma'; -import { IdentityProvider } from '@documenso/prisma/client'; +import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/prisma/client'; +import { IS_BILLING_ENABLED } from '../../constants/app'; import { SALT_ROUNDS } from '../../constants/auth'; -import { getFlag } from '../../universal/get-feature-flag'; export interface CreateUserOptions { name: string; @@ -15,8 +16,6 @@ export interface CreateUserOptions { } export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => { - const isBillingEnabled = await getFlag('app_billing'); - const hashedPassword = await hash(password, SALT_ROUNDS); const userExists = await prisma.user.findFirst({ @@ -29,7 +28,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse throw new Error('User already exists'); } - let user = await prisma.user.create({ + const user = await prisma.user.create({ data: { name, email: email.toLowerCase(), @@ -39,12 +38,81 @@ export const createUser = async ({ name, email, password, signature }: CreateUse }, }); - if (isBillingEnabled) { + const acceptedTeamInvites = await prisma.teamMemberInvite.findMany({ + where: { + email: { + equals: email, + mode: Prisma.QueryMode.insensitive, + }, + status: TeamMemberInviteStatus.ACCEPTED, + }, + }); + + // For each team invite, add the user to the team and delete the team invite. + // If an error occurs, reset the invitation to not accepted. + await Promise.allSettled( + acceptedTeamInvites.map(async (invite) => + prisma + .$transaction(async (tx) => { + await tx.teamMember.create({ + data: { + teamId: invite.teamId, + userId: user.id, + role: invite.role, + }, + }); + + await tx.teamMemberInvite.delete({ + where: { + id: invite.id, + }, + }); + + if (!IS_BILLING_ENABLED) { + return; + } + + const team = await tx.team.findFirstOrThrow({ + where: { + id: invite.teamId, + }, + include: { + members: { + select: { + id: true, + }, + }, + subscription: true, + }, + }); + + if (team.subscription) { + await updateSubscriptionItemQuantity({ + priceId: team.subscription.priceId, + subscriptionId: team.subscription.planId, + quantity: team.members.length, + }); + } + }) + .catch(async () => { + await prisma.teamMemberInvite.update({ + where: { + id: invite.id, + }, + data: { + status: TeamMemberInviteStatus.PENDING, + }, + }); + }), + ), + ); + + // Update the user record with a new or existing Stripe customer record. + if (IS_BILLING_ENABLED) { try { - const stripeSession = await getStripeCustomerByUser(user); - user = stripeSession.user; - } catch (e) { - console.error(e); + return await getStripeCustomerByUser(user).then((session) => session.user); + } catch (err) { + console.error(err); } } diff --git a/packages/lib/utils/billing.ts b/packages/lib/utils/billing.ts new file mode 100644 index 000000000..ca85addbb --- /dev/null +++ b/packages/lib/utils/billing.ts @@ -0,0 +1,16 @@ +import type { Subscription } from '.prisma/client'; +import { SubscriptionStatus } from '.prisma/client'; + +/** + * Returns true if there is a subscription that is active and is a community plan. + */ +export const subscriptionsContainsActiveCommunityPlan = ( + subscriptions: Subscription[], + communityPlanPriceIds: string[], +) => { + return subscriptions.some( + (subscription) => + subscription.status === SubscriptionStatus.ACTIVE && + communityPlanPriceIds.includes(subscription.priceId), + ); +}; diff --git a/packages/lib/utils/params.ts b/packages/lib/utils/params.ts new file mode 100644 index 000000000..a8d799400 --- /dev/null +++ b/packages/lib/utils/params.ts @@ -0,0 +1,30 @@ +/** + * From an unknown string, parse it into an integer array. + * + * Filter out unknown values. + */ +export const parseToIntegerArray = (value: unknown): number[] => { + if (typeof value !== 'string') { + return []; + } + + return value + .split(',') + .map((value) => parseInt(value, 10)) + .filter((value) => !isNaN(value)); +}; + +type GetRootHrefOptions = { + returnEmptyRootString?: boolean; +}; + +export const getRootHref = ( + params: Record | null, + options: GetRootHrefOptions = {}, +) => { + if (typeof params?.teamUrl === 'string') { + return `/t/${params.teamUrl}`; + } + + return options.returnEmptyRootString ? '' : '/'; +}; diff --git a/packages/lib/utils/recipient-formatter.ts b/packages/lib/utils/recipient-formatter.ts index 2e2bace3b..5fad45399 100644 --- a/packages/lib/utils/recipient-formatter.ts +++ b/packages/lib/utils/recipient-formatter.ts @@ -1,6 +1,6 @@ import type { Recipient } from '@documenso/prisma/client'; -export const recipientInitials = (text: string) => +export const extractInitials = (text: string) => text .split(' ') .map((name: string) => name.slice(0, 1).toUpperCase()) @@ -8,5 +8,5 @@ export const recipientInitials = (text: string) => .join(''); export const recipientAbbreviation = (recipient: Recipient) => { - return recipientInitials(recipient.name) || recipient.email.slice(0, 1).toUpperCase(); + return extractInitials(recipient.name) || recipient.email.slice(0, 1).toUpperCase(); }; diff --git a/packages/lib/utils/teams.ts b/packages/lib/utils/teams.ts new file mode 100644 index 000000000..eb9be2c2b --- /dev/null +++ b/packages/lib/utils/teams.ts @@ -0,0 +1,42 @@ +import { WEBAPP_BASE_URL } from '../constants/app'; +import type { TEAM_MEMBER_ROLE_MAP } from '../constants/teams'; +import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../constants/teams'; + +export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => { + const formattedBaseUrl = (baseUrl ?? WEBAPP_BASE_URL).replace(/https?:\/\//, ''); + + return `${formattedBaseUrl}/t/${teamUrl}`; +}; + +export const formatDocumentsPath = (teamUrl?: string) => { + return teamUrl ? `/t/${teamUrl}/documents` : '/documents'; +}; + +/** + * Determines whether a team member can execute a given action. + * + * @param action The action the user is trying to execute. + * @param role The current role of the user. + * @returns Whether the user can execute the action. + */ +export const canExecuteTeamAction = ( + action: keyof typeof TEAM_MEMBER_ROLE_PERMISSIONS_MAP, + role: keyof typeof TEAM_MEMBER_ROLE_MAP, +) => { + return TEAM_MEMBER_ROLE_PERMISSIONS_MAP[action].some((i) => i === role); +}; + +/** + * Compares the provided `currentUserRole` with the provided `roleToCheck` to determine + * whether the `currentUserRole` has permission to modify the `roleToCheck`. + * + * @param currentUserRole Role of the current user + * @param roleToCheck Role of another user to see if the current user can modify + * @returns True if the current user can modify the other user, false otherwise + */ +export const isTeamRoleWithinUserHierarchy = ( + currentUserRole: keyof typeof TEAM_MEMBER_ROLE_MAP, + roleToCheck: keyof typeof TEAM_MEMBER_ROLE_MAP, +) => { + return TEAM_MEMBER_ROLE_HIERARCHY[currentUserRole].some((i) => i === roleToCheck); +}; diff --git a/packages/lib/utils/token-verification.ts b/packages/lib/utils/token-verification.ts new file mode 100644 index 000000000..c57ddd1e5 --- /dev/null +++ b/packages/lib/utils/token-verification.ts @@ -0,0 +1,21 @@ +import type { DurationLike } from 'luxon'; +import { DateTime } from 'luxon'; +import { nanoid } from 'nanoid'; + +/** + * Create a token verification object. + * + * @param expiry The date the token expires, or the duration until the token expires. + */ +export const createTokenVerification = (expiry: Date | DurationLike) => { + const expiresAt = expiry instanceof Date ? expiry : DateTime.now().plus(expiry).toJSDate(); + + return { + expiresAt, + token: nanoid(32), + }; +}; + +export const isTokenExpired = (expiresAt: Date) => { + return expiresAt < new Date(); +}; diff --git a/packages/prisma/migrations/20240205040421_add_teams/migration.sql b/packages/prisma/migrations/20240205040421_add_teams/migration.sql new file mode 100644 index 000000000..f80799aab --- /dev/null +++ b/packages/prisma/migrations/20240205040421_add_teams/migration.sql @@ -0,0 +1,187 @@ +/* + Warnings: + + - A unique constraint covering the columns `[teamId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "TeamMemberRole" AS ENUM ('ADMIN', 'MANAGER', 'MEMBER'); + +-- CreateEnum +CREATE TYPE "TeamMemberInviteStatus" AS ENUM ('ACCEPTED', 'PENDING'); + +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "teamId" INTEGER; + +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "teamId" INTEGER, +ALTER COLUMN "userId" DROP NOT NULL; + +-- CreateTable +CREATE TABLE "Team" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "url" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "customerId" TEXT, + "ownerUserId" INTEGER NOT NULL, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamPending" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "url" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "customerId" TEXT NOT NULL, + "ownerUserId" INTEGER NOT NULL, + + CONSTRAINT "TeamPending_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamMember" ( + "id" SERIAL NOT NULL, + "teamId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "role" "TeamMemberRole" NOT NULL, + "userId" INTEGER NOT NULL, + + CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamEmail" ( + "teamId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + + CONSTRAINT "TeamEmail_pkey" PRIMARY KEY ("teamId") +); + +-- CreateTable +CREATE TABLE "TeamEmailVerification" ( + "teamId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TeamEmailVerification_pkey" PRIMARY KEY ("teamId") +); + +-- CreateTable +CREATE TABLE "TeamTransferVerification" ( + "teamId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "clearPaymentMethods" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "TeamTransferVerification_pkey" PRIMARY KEY ("teamId") +); + +-- CreateTable +CREATE TABLE "TeamMemberInvite" ( + "id" SERIAL NOT NULL, + "teamId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "email" TEXT NOT NULL, + "status" "TeamMemberInviteStatus" NOT NULL DEFAULT 'PENDING', + "role" "TeamMemberRole" NOT NULL, + "token" TEXT NOT NULL, + + CONSTRAINT "TeamMemberInvite_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_url_key" ON "Team"("url"); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_customerId_key" ON "Team"("customerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamPending_url_key" ON "TeamPending"("url"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamPending_customerId_key" ON "TeamPending"("customerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMember_userId_teamId_key" ON "TeamMember"("userId", "teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamEmail_teamId_key" ON "TeamEmail"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamEmail_email_key" ON "TeamEmail"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamEmailVerification_teamId_key" ON "TeamEmailVerification"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamEmailVerification_token_key" ON "TeamEmailVerification"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamTransferVerification_teamId_key" ON "TeamTransferVerification"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamTransferVerification_token_key" ON "TeamTransferVerification"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMemberInvite_token_key" ON "TeamMemberInvite"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMemberInvite_teamId_email_key" ON "TeamMemberInvite"("teamId", "email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_teamId_key" ON "Subscription"("teamId"); + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Team" ADD CONSTRAINT "Team_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamPending" ADD CONSTRAINT "TeamPending_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamEmail" ADD CONSTRAINT "TeamEmail_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamEmailVerification" ADD CONSTRAINT "TeamEmailVerification_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamTransferVerification" ADD CONSTRAINT "TeamTransferVerification_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMemberInvite" ADD CONSTRAINT "TeamMemberInvite_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "Subscription" +ADD CONSTRAINT teamId_or_userId_check +CHECK ( + ( + "teamId" IS NOT NULL + AND "userId" IS NULL + ) + OR ( + "teamId" IS NULL + AND "userId" IS NOT NULL + ) +); diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 2fb01a6ac..301b51dba 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -21,7 +21,8 @@ "@prisma/client": "5.4.2", "dotenv": "^16.3.1", "dotenv-cli": "^7.3.0", - "prisma": "5.4.2" + "prisma": "5.4.2", + "ts-pattern": "^5.0.6" }, "devDependencies": { "ts-node": "^10.9.1", diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 87d29d6b2..79dcdf6aa 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -37,6 +37,9 @@ model User { Document Document[] Subscription Subscription[] PasswordResetToken PasswordResetToken[] + ownedTeams Team[] + ownedPendingTeams TeamPending[] + teamMembers TeamMember[] twoFactorSecret String? twoFactorEnabled Boolean @default(false) twoFactorBackupCodes String? @@ -103,12 +106,14 @@ model Subscription { planId String @unique priceId String periodEnd DateTime? - userId Int + userId Int? + teamId Int? @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt cancelAtPeriodEnd Boolean @default(false) - User User @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + User User? @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) } @@ -162,6 +167,8 @@ model Document { updatedAt DateTime @default(now()) @updatedAt completedAt DateTime? deletedAt DateTime? + teamId Int? + team Team? @relation(fields: [teamId], references: [id]) @@unique([documentDataId]) @@index([userId]) @@ -300,6 +307,104 @@ model DocumentShareLink { @@unique([documentId, email]) } +enum TeamMemberRole { + ADMIN + MANAGER + MEMBER +} + +enum TeamMemberInviteStatus { + ACCEPTED + PENDING +} + +model Team { + id Int @id @default(autoincrement()) + name String + url String @unique + createdAt DateTime @default(now()) + customerId String? @unique + ownerUserId Int + members TeamMember[] + invites TeamMemberInvite[] + teamEmail TeamEmail? + emailVerification TeamEmailVerification? + transferVerification TeamTransferVerification? + + owner User @relation(fields: [ownerUserId], references: [id]) + subscription Subscription? + + document Document[] +} + +model TeamPending { + id Int @id @default(autoincrement()) + name String + url String @unique + createdAt DateTime @default(now()) + customerId String @unique + ownerUserId Int + + owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade) +} + +model TeamMember { + id Int @id @default(autoincrement()) + teamId Int + createdAt DateTime @default(now()) + role TeamMemberRole + userId Int + user User @relation(fields: [userId], references: [id]) + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + @@unique([userId, teamId]) +} + +model TeamEmail { + teamId Int @id @unique + createdAt DateTime @default(now()) + name String + email String @unique + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) +} + +model TeamEmailVerification { + teamId Int @id @unique + name String + email String + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) +} + +model TeamTransferVerification { + teamId Int @id @unique + userId Int + name String + email String + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + clearPaymentMethods Boolean @default(false) + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) +} + +model TeamMemberInvite { + id Int @id @default(autoincrement()) + teamId Int + createdAt DateTime @default(now()) + email String + status TeamMemberInviteStatus @default(PENDING) + role TeamMemberRole + token String @unique + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + @@unique([teamId, email]) +} + enum TemplateType { PUBLIC PRIVATE diff --git a/packages/prisma/seed/documents.ts b/packages/prisma/seed/documents.ts new file mode 100644 index 000000000..1f1f5cab8 --- /dev/null +++ b/packages/prisma/seed/documents.ts @@ -0,0 +1,375 @@ +import type { User } from '@prisma/client'; +import { nanoid } from 'nanoid'; +import fs from 'node:fs'; +import path from 'node:path'; +import { match } from 'ts-pattern'; + +import { prisma } from '..'; +import { + DocumentDataType, + DocumentStatus, + FieldType, + Prisma, + ReadStatus, + SendStatus, + SigningStatus, +} from '../client'; +import { seedTeam } from './teams'; +import { seedUser } from './users'; + +const examplePdf = fs + .readFileSync(path.join(__dirname, '../../../assets/example.pdf')) + .toString('base64'); + +type DocumentToSeed = { + sender: User; + recipients: (User | string)[]; + type: DocumentStatus; + documentOptions?: Partial; +}; + +export const seedDocuments = async (documents: DocumentToSeed[]) => { + await Promise.all( + documents.map(async (document, i) => + match(document.type) + .with(DocumentStatus.DRAFT, async () => + createDraftDocument(document.sender, document.recipients, { + key: i, + createDocumentOptions: document.documentOptions, + }), + ) + .with(DocumentStatus.PENDING, async () => + createPendingDocument(document.sender, document.recipients, { + key: i, + createDocumentOptions: document.documentOptions, + }), + ) + .with(DocumentStatus.COMPLETED, async () => + createCompletedDocument(document.sender, document.recipients, { + key: i, + createDocumentOptions: document.documentOptions, + }), + ) + .exhaustive(), + ), + ); +}; + +const createDraftDocument = async ( + sender: User, + recipients: (User | string)[], + options: CreateDocumentOptions = {}, +) => { + const { key, createDocumentOptions = {} } = options; + + const documentData = await prisma.documentData.create({ + data: { + type: DocumentDataType.BYTES_64, + data: examplePdf, + initialData: examplePdf, + }, + }); + + const document = await prisma.document.create({ + data: { + title: `[TEST] Document ${key} - Draft`, + status: DocumentStatus.DRAFT, + documentDataId: documentData.id, + userId: sender.id, + ...createDocumentOptions, + }, + }); + + for (const recipient of recipients) { + const email = typeof recipient === 'string' ? recipient : recipient.email; + const name = typeof recipient === 'string' ? recipient : recipient.name ?? ''; + + await prisma.recipient.create({ + data: { + email, + name, + token: nanoid(), + readStatus: ReadStatus.NOT_OPENED, + sendStatus: SendStatus.NOT_SENT, + signingStatus: SigningStatus.NOT_SIGNED, + signedAt: new Date(), + Document: { + connect: { + id: document.id, + }, + }, + Field: { + create: { + page: 1, + type: FieldType.NAME, + inserted: true, + customText: name, + positionX: new Prisma.Decimal(1), + positionY: new Prisma.Decimal(1), + width: new Prisma.Decimal(1), + height: new Prisma.Decimal(1), + documentId: document.id, + }, + }, + }, + }); + } +}; + +type CreateDocumentOptions = { + key?: string | number; + createDocumentOptions?: Partial; +}; + +const createPendingDocument = async ( + sender: User, + recipients: (User | string)[], + options: CreateDocumentOptions = {}, +) => { + const { key, createDocumentOptions = {} } = options; + + const documentData = await prisma.documentData.create({ + data: { + type: DocumentDataType.BYTES_64, + data: examplePdf, + initialData: examplePdf, + }, + }); + + const document = await prisma.document.create({ + data: { + title: `[TEST] Document ${key} - Pending`, + status: DocumentStatus.PENDING, + documentDataId: documentData.id, + userId: sender.id, + ...createDocumentOptions, + }, + }); + + for (const recipient of recipients) { + const email = typeof recipient === 'string' ? recipient : recipient.email; + const name = typeof recipient === 'string' ? recipient : recipient.name ?? ''; + + await prisma.recipient.create({ + data: { + email, + name, + token: nanoid(), + readStatus: ReadStatus.OPENED, + sendStatus: SendStatus.SENT, + signingStatus: SigningStatus.NOT_SIGNED, + signedAt: new Date(), + Document: { + connect: { + id: document.id, + }, + }, + Field: { + create: { + page: 1, + type: FieldType.NAME, + inserted: true, + customText: name, + positionX: new Prisma.Decimal(1), + positionY: new Prisma.Decimal(1), + width: new Prisma.Decimal(1), + height: new Prisma.Decimal(1), + documentId: document.id, + }, + }, + }, + }); + } +}; + +const createCompletedDocument = async ( + sender: User, + recipients: (User | string)[], + options: CreateDocumentOptions = {}, +) => { + const { key, createDocumentOptions = {} } = options; + + const documentData = await prisma.documentData.create({ + data: { + type: DocumentDataType.BYTES_64, + data: examplePdf, + initialData: examplePdf, + }, + }); + + const document = await prisma.document.create({ + data: { + title: `[TEST] Document ${key} - Completed`, + status: DocumentStatus.COMPLETED, + documentDataId: documentData.id, + userId: sender.id, + ...createDocumentOptions, + }, + }); + + for (const recipient of recipients) { + const email = typeof recipient === 'string' ? recipient : recipient.email; + const name = typeof recipient === 'string' ? recipient : recipient.name ?? ''; + + await prisma.recipient.create({ + data: { + email, + name, + token: nanoid(), + readStatus: ReadStatus.OPENED, + sendStatus: SendStatus.SENT, + signingStatus: SigningStatus.SIGNED, + signedAt: new Date(), + Document: { + connect: { + id: document.id, + }, + }, + Field: { + create: { + page: 1, + type: FieldType.NAME, + inserted: true, + customText: name, + positionX: new Prisma.Decimal(1), + positionY: new Prisma.Decimal(1), + width: new Prisma.Decimal(1), + height: new Prisma.Decimal(1), + documentId: document.id, + }, + }, + }, + }); + } +}; + +/** + * Create 5 team documents: + * - Completed document with 2 recipients. + * - Pending document with 1 recipient. + * - Pending document with 4 recipients. + * - Draft document with 3 recipients. + * - Draft document with 2 recipients. + * + * Create 3 non team documents where the user is a team member: + * - Completed document with 2 recipients. + * - Pending document with 1 recipient. + * - Draft document with 2 recipients. + * + * Create 3 non team documents where the user is not a team member, but the recipient is: + * - Completed document with 2 recipients. + * - Pending document with 1 recipient. + * - Draft document with 2 recipients. + * + * This should result in the following team document dashboard counts: + * - 0 Inbox + * - 2 Pending + * - 1 Completed + * - 2 Draft + * - 5 All + */ +export const seedTeamDocuments = async () => { + const team = await seedTeam({ + createTeamMembers: 4, + }); + + const documentOptions = { + teamId: team.id, + }; + + const teamMember1 = team.members[1].user; + const teamMember2 = team.members[2].user; + const teamMember3 = team.members[3].user; + const teamMember4 = team.members[4].user; + + const [testUser1, testUser2, testUser3, testUser4] = await Promise.all([ + seedUser(), + seedUser(), + seedUser(), + seedUser(), + ]); + + await seedDocuments([ + /** + * Team documents. + */ + { + sender: teamMember1, + recipients: [testUser1, testUser2], + type: DocumentStatus.COMPLETED, + documentOptions, + }, + { + sender: teamMember2, + recipients: [testUser1], + type: DocumentStatus.PENDING, + documentOptions, + }, + { + sender: teamMember2, + recipients: [testUser1, testUser2, testUser3, testUser4], + type: DocumentStatus.PENDING, + documentOptions, + }, + { + sender: teamMember2, + recipients: [testUser1, testUser2, teamMember1], + type: DocumentStatus.DRAFT, + documentOptions, + }, + { + sender: team.owner, + recipients: [testUser1, testUser2], + type: DocumentStatus.DRAFT, + documentOptions, + }, + /** + * Non team documents where the sender is a team member and recipient is not. + */ + { + sender: teamMember1, + recipients: [testUser1, testUser2], + type: DocumentStatus.COMPLETED, + }, + { + sender: teamMember2, + recipients: [testUser1], + type: DocumentStatus.PENDING, + }, + { + sender: teamMember3, + recipients: [testUser1, testUser2], + type: DocumentStatus.DRAFT, + }, + /** + * Non team documents where the sender is not a team member and recipient is. + */ + { + sender: testUser1, + recipients: [teamMember1, teamMember2], + type: DocumentStatus.COMPLETED, + }, + { + sender: testUser2, + recipients: [teamMember1], + type: DocumentStatus.PENDING, + }, + { + sender: testUser3, + recipients: [teamMember1, teamMember2], + type: DocumentStatus.DRAFT, + }, + ]); + + return { + team, + teamMember1, + teamMember2, + teamMember3, + teamMember4, + testUser1, + testUser2, + testUser3, + testUser4, + }; +}; diff --git a/packages/prisma/seed/teams.ts b/packages/prisma/seed/teams.ts new file mode 100644 index 000000000..99b0df8d5 --- /dev/null +++ b/packages/prisma/seed/teams.ts @@ -0,0 +1,177 @@ +import { prisma } from '..'; +import { TeamMemberInviteStatus, TeamMemberRole } from '../client'; +import { seedUser } from './users'; + +const EMAIL_DOMAIN = `test.documenso.com`; + +type SeedTeamOptions = { + createTeamMembers?: number; + createTeamEmail?: true | string; +}; + +export const seedTeam = async ({ + createTeamMembers = 0, + createTeamEmail, +}: SeedTeamOptions = {}) => { + const teamUrl = `team-${Date.now()}`; + const teamEmail = createTeamEmail === true ? `${teamUrl}@${EMAIL_DOMAIN}` : createTeamEmail; + + const teamOwner = await seedUser({ + name: `${teamUrl}-original-owner`, + email: `${teamUrl}-original-owner@${EMAIL_DOMAIN}`, + }); + + const teamMembers = await Promise.all( + Array.from({ length: createTeamMembers }).map(async (_, i) => { + return seedUser({ + name: `${teamUrl}-member-${i + 1}`, + email: `${teamUrl}-member-${i + 1}@${EMAIL_DOMAIN}`, + }); + }), + ); + + const team = await prisma.team.create({ + data: { + name: teamUrl, + url: teamUrl, + ownerUserId: teamOwner.id, + members: { + createMany: { + data: [teamOwner, ...teamMembers].map((user) => ({ + userId: user.id, + role: TeamMemberRole.ADMIN, + })), + }, + }, + teamEmail: teamEmail + ? { + create: { + email: teamEmail, + name: teamEmail, + }, + } + : undefined, + }, + }); + + return await prisma.team.findFirstOrThrow({ + where: { + id: team.id, + }, + include: { + owner: true, + members: { + include: { + user: true, + }, + }, + teamEmail: true, + }, + }); +}; + +export const unseedTeam = async (teamUrl: string) => { + const team = await prisma.team.findUnique({ + where: { + url: teamUrl, + }, + include: { + members: true, + }, + }); + + if (!team) { + return; + } + + await prisma.team.delete({ + where: { + url: teamUrl, + }, + }); + + await prisma.user.deleteMany({ + where: { + id: { + in: team.members.map((member) => member.userId), + }, + }, + }); +}; + +export const seedTeamTransfer = async (options: { newOwnerUserId: number; teamId: number }) => { + return await prisma.teamTransferVerification.create({ + data: { + teamId: options.teamId, + token: Date.now().toString(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + userId: options.newOwnerUserId, + name: '', + email: '', + }, + }); +}; + +export const seedTeamEmail = async ({ email, teamId }: { email: string; teamId: number }) => { + return await prisma.teamEmail.create({ + data: { + name: email, + email, + teamId, + }, + }); +}; + +export const unseedTeamEmail = async ({ teamId }: { teamId: number }) => { + return await prisma.teamEmail.delete({ + where: { + teamId, + }, + }); +}; + +export const seedTeamInvite = async ({ + email, + teamId, + role = TeamMemberRole.ADMIN, +}: { + email: string; + teamId: number; + role?: TeamMemberRole; +}) => { + return await prisma.teamMemberInvite.create({ + data: { + email, + teamId, + role, + status: TeamMemberInviteStatus.PENDING, + token: Date.now().toString(), + }, + }); +}; + +export const seedTeamEmailVerification = async ({ + email, + teamId, +}: { + email: string; + teamId: number; +}) => { + return await prisma.teamEmailVerification.create({ + data: { + teamId, + email, + name: email, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + token: Date.now().toString(), + }, + }); +}; + +export const unseedTeamEmailVerification = async ({ teamId }: { teamId: number }) => { + return await prisma.teamEmailVerification.delete({ + where: { + teamId, + }, + }); +}; diff --git a/packages/prisma/seed/users.ts b/packages/prisma/seed/users.ts new file mode 100644 index 000000000..ce3858bc6 --- /dev/null +++ b/packages/prisma/seed/users.ts @@ -0,0 +1,34 @@ +import { hashSync } from '@documenso/lib/server-only/auth/hash'; + +import { prisma } from '..'; + +type SeedUserOptions = { + name?: string; + email?: string; + password?: string; + verified?: boolean; +}; + +export const seedUser = async ({ + name = `user-${Date.now()}`, + email = `user-${Date.now()}@test.documenso.com`, + password = 'password', + verified = true, +}: SeedUserOptions = {}) => { + return await prisma.user.create({ + data: { + name, + email, + password: hashSync(password), + emailVerified: verified ? new Date() : undefined, + }, + }); +}; + +export const unseedUser = async (userId: number) => { + await prisma.user.delete({ + where: { + id: userId, + }, + }); +}; diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 9dba63797..5940d971d 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -36,10 +36,8 @@ export const documentRouter = router({ .input(ZGetDocumentByIdQuerySchema) .query(async ({ input, ctx }) => { try { - const { id } = input; - return await getDocumentById({ - id, + ...input, userId: ctx.user.id, }); } catch (err) { @@ -73,9 +71,9 @@ export const documentRouter = router({ .input(ZCreateDocumentMutationSchema) .mutation(async ({ input, ctx }) => { try { - const { title, documentDataId } = input; + const { title, documentDataId, teamId } = input; - const { remaining } = await getServerLimits({ email: ctx.user.email }); + const { remaining } = await getServerLimits({ email: ctx.user.email, teamId }); if (remaining.documents <= 0) { throw new TRPCError({ @@ -87,6 +85,7 @@ export const documentRouter = router({ return await createDocument({ userId: ctx.user.id, + teamId, title, documentDataId, }); @@ -245,12 +244,9 @@ export const documentRouter = router({ .input(ZResendDocumentMutationSchema) .mutation(async ({ input, ctx }) => { try { - const { documentId, recipients } = input; - return await resendDocument({ userId: ctx.user.id, - documentId, - recipients, + ...input, }); } catch (err) { console.error(err); @@ -266,14 +262,13 @@ export const documentRouter = router({ .input(ZGetDocumentByIdQuerySchema) .mutation(async ({ input, ctx }) => { try { - const { id } = input; - return await duplicateDocumentById({ - id, userId: ctx.user.id, + ...input, }); } catch (err) { console.log(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We are unable to duplicate this document. Please try again later.', diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 5d8c23c27..f8d008f50 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -4,6 +4,7 @@ import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/clie export const ZGetDocumentByIdQuerySchema = z.object({ id: z.number().min(1), + teamId: z.number().min(1).optional(), }); export type TGetDocumentByIdQuerySchema = z.infer; @@ -17,6 +18,7 @@ export type TGetDocumentByTokenQuerySchema = z.infer; @@ -86,6 +88,7 @@ export type TSetPasswordForDocumentMutationSchema = z.infer< export const ZResendDocumentMutationSchema = z.object({ documentId: z.number(), recipients: z.array(z.number()).min(1), + teamId: z.number().min(1).optional(), }); export type TSendDocumentMutationSchema = z.infer; diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index 3ed2a0d05..aec70fd63 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -7,6 +7,7 @@ import { profileRouter } from './profile-router/router'; import { recipientRouter } from './recipient-router/router'; import { shareLinkRouter } from './share-link-router/router'; import { singleplayerRouter } from './singleplayer-router/router'; +import { teamRouter } from './team-router/router'; import { templateRouter } from './template-router/router'; import { router } from './trpc'; import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router'; @@ -21,8 +22,9 @@ export const appRouter = router({ admin: adminRouter, shareLink: shareLinkRouter, singleplayer: singleplayerRouter, - twoFactorAuthentication: twoFactorAuthenticationRouter, + team: teamRouter, template: templateRouter, + twoFactorAuthentication: twoFactorAuthenticationRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/trpc/server/team-router/router.ts b/packages/trpc/server/team-router/router.ts new file mode 100644 index 000000000..dd2032daf --- /dev/null +++ b/packages/trpc/server/team-router/router.ts @@ -0,0 +1,508 @@ +import { getTeamPrices } from '@documenso/ee/server-only/stripe/get-team-prices'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation'; +import { createTeam } from '@documenso/lib/server-only/team/create-team'; +import { createTeamBillingPortal } from '@documenso/lib/server-only/team/create-team-billing-portal'; +import { createTeamPendingCheckoutSession } from '@documenso/lib/server-only/team/create-team-checkout-session'; +import { createTeamEmailVerification } from '@documenso/lib/server-only/team/create-team-email-verification'; +import { createTeamMemberInvites } from '@documenso/lib/server-only/team/create-team-member-invites'; +import { deleteTeam } from '@documenso/lib/server-only/team/delete-team'; +import { deleteTeamEmail } from '@documenso/lib/server-only/team/delete-team-email'; +import { deleteTeamEmailVerification } from '@documenso/lib/server-only/team/delete-team-email-verification'; +import { deleteTeamMemberInvitations } from '@documenso/lib/server-only/team/delete-team-invitations'; +import { deleteTeamMembers } from '@documenso/lib/server-only/team/delete-team-members'; +import { deleteTeamPending } from '@documenso/lib/server-only/team/delete-team-pending'; +import { deleteTeamTransferRequest } from '@documenso/lib/server-only/team/delete-team-transfer-request'; +import { findTeamInvoices } from '@documenso/lib/server-only/team/find-team-invoices'; +import { findTeamMemberInvites } from '@documenso/lib/server-only/team/find-team-member-invites'; +import { findTeamMembers } from '@documenso/lib/server-only/team/find-team-members'; +import { findTeams } from '@documenso/lib/server-only/team/find-teams'; +import { findTeamsPending } from '@documenso/lib/server-only/team/find-teams-pending'; +import { getTeamById } from '@documenso/lib/server-only/team/get-team'; +import { getTeamEmailByEmail } from '@documenso/lib/server-only/team/get-team-email-by-email'; +import { getTeamInvitations } from '@documenso/lib/server-only/team/get-team-invitations'; +import { getTeamMembers } from '@documenso/lib/server-only/team/get-team-members'; +import { getTeams } from '@documenso/lib/server-only/team/get-teams'; +import { leaveTeam } from '@documenso/lib/server-only/team/leave-team'; +import { requestTeamOwnershipTransfer } from '@documenso/lib/server-only/team/request-team-ownership-transfer'; +import { resendTeamEmailVerification } from '@documenso/lib/server-only/team/resend-team-email-verification'; +import { resendTeamMemberInvitation } from '@documenso/lib/server-only/team/resend-team-member-invitation'; +import { updateTeam } from '@documenso/lib/server-only/team/update-team'; +import { updateTeamEmail } from '@documenso/lib/server-only/team/update-team-email'; +import { updateTeamMember } from '@documenso/lib/server-only/team/update-team-member'; + +import { authenticatedProcedure, router } from '../trpc'; +import { + ZAcceptTeamInvitationMutationSchema, + ZCreateTeamBillingPortalMutationSchema, + ZCreateTeamEmailVerificationMutationSchema, + ZCreateTeamMemberInvitesMutationSchema, + ZCreateTeamMutationSchema, + ZCreateTeamPendingCheckoutMutationSchema, + ZDeleteTeamEmailMutationSchema, + ZDeleteTeamEmailVerificationMutationSchema, + ZDeleteTeamMemberInvitationsMutationSchema, + ZDeleteTeamMembersMutationSchema, + ZDeleteTeamMutationSchema, + ZDeleteTeamPendingMutationSchema, + ZDeleteTeamTransferRequestMutationSchema, + ZFindTeamInvoicesQuerySchema, + ZFindTeamMemberInvitesQuerySchema, + ZFindTeamMembersQuerySchema, + ZFindTeamsPendingQuerySchema, + ZFindTeamsQuerySchema, + ZGetTeamMembersQuerySchema, + ZGetTeamQuerySchema, + ZLeaveTeamMutationSchema, + ZRequestTeamOwnerhsipTransferMutationSchema, + ZResendTeamEmailVerificationMutationSchema, + ZResendTeamMemberInvitationMutationSchema, + ZUpdateTeamEmailMutationSchema, + ZUpdateTeamMemberMutationSchema, + ZUpdateTeamMutationSchema, +} from './schema'; + +export const teamRouter = router({ + acceptTeamInvitation: authenticatedProcedure + .input(ZAcceptTeamInvitationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await acceptTeamInvitation({ + teamId: input.teamId, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + createBillingPortal: authenticatedProcedure + .input(ZCreateTeamBillingPortalMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createTeamBillingPortal({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + createTeam: authenticatedProcedure + .input(ZCreateTeamMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createTeam({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + createTeamEmailVerification: authenticatedProcedure + .input(ZCreateTeamEmailVerificationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createTeamEmailVerification({ + teamId: input.teamId, + userId: ctx.user.id, + data: { + email: input.email, + name: input.name, + }, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + createTeamMemberInvites: authenticatedProcedure + .input(ZCreateTeamMemberInvitesMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createTeamMemberInvites({ + userId: ctx.user.id, + userName: ctx.user.name ?? '', + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + createTeamPendingCheckout: authenticatedProcedure + .input(ZCreateTeamPendingCheckoutMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createTeamPendingCheckoutSession({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeam: authenticatedProcedure + .input(ZDeleteTeamMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeam({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamEmail: authenticatedProcedure + .input(ZDeleteTeamEmailMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamEmail({ + userId: ctx.user.id, + userEmail: ctx.user.email, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamEmailVerification: authenticatedProcedure + .input(ZDeleteTeamEmailVerificationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamEmailVerification({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamMemberInvitations: authenticatedProcedure + .input(ZDeleteTeamMemberInvitationsMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamMemberInvitations({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamMembers: authenticatedProcedure + .input(ZDeleteTeamMembersMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamMembers({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamPending: authenticatedProcedure + .input(ZDeleteTeamPendingMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamPending({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamTransferRequest: authenticatedProcedure + .input(ZDeleteTeamTransferRequestMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamTransferRequest({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findTeamInvoices: authenticatedProcedure + .input(ZFindTeamInvoicesQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await findTeamInvoices({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findTeamMemberInvites: authenticatedProcedure + .input(ZFindTeamMemberInvitesQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await findTeamMemberInvites({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findTeamMembers: authenticatedProcedure + .input(ZFindTeamMembersQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await findTeamMembers({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findTeams: authenticatedProcedure.input(ZFindTeamsQuerySchema).query(async ({ input, ctx }) => { + try { + return await findTeams({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findTeamsPending: authenticatedProcedure + .input(ZFindTeamsPendingQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await findTeamsPending({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeam: authenticatedProcedure.input(ZGetTeamQuerySchema).query(async ({ input, ctx }) => { + try { + return await getTeamById({ teamId: input.teamId, userId: ctx.user.id }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeamEmailByEmail: authenticatedProcedure.query(async ({ ctx }) => { + try { + return await getTeamEmailByEmail({ email: ctx.user.email }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeamInvitations: authenticatedProcedure.query(async ({ ctx }) => { + try { + return await getTeamInvitations({ email: ctx.user.email }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeamMembers: authenticatedProcedure + .input(ZGetTeamMembersQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await getTeamMembers({ teamId: input.teamId, userId: ctx.user.id }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeamPrices: authenticatedProcedure.query(async () => { + try { + return await getTeamPrices(); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeams: authenticatedProcedure.query(async ({ ctx }) => { + try { + return await getTeams({ userId: ctx.user.id }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + leaveTeam: authenticatedProcedure + .input(ZLeaveTeamMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await leaveTeam({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + updateTeam: authenticatedProcedure + .input(ZUpdateTeamMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await updateTeam({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + updateTeamEmail: authenticatedProcedure + .input(ZUpdateTeamEmailMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await updateTeamEmail({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + updateTeamMember: authenticatedProcedure + .input(ZUpdateTeamMemberMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await updateTeamMember({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + requestTeamOwnershipTransfer: authenticatedProcedure + .input(ZRequestTeamOwnerhsipTransferMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await requestTeamOwnershipTransfer({ + userId: ctx.user.id, + userName: ctx.user.name ?? '', + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + resendTeamEmailVerification: authenticatedProcedure + .input(ZResendTeamEmailVerificationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + await resendTeamEmailVerification({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + resendTeamMemberInvitation: authenticatedProcedure + .input(ZResendTeamMemberInvitationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + await resendTeamMemberInvitation({ + userId: ctx.user.id, + userName: ctx.user.name ?? '', + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), +}); diff --git a/packages/trpc/server/team-router/schema.ts b/packages/trpc/server/team-router/schema.ts new file mode 100644 index 000000000..953b12490 --- /dev/null +++ b/packages/trpc/server/team-router/schema.ts @@ -0,0 +1,213 @@ +import { z } from 'zod'; + +import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams'; +import { TeamMemberRole } from '@documenso/prisma/client'; + +const GenericFindQuerySchema = z.object({ + term: z.string().optional(), + page: z.number().optional(), + perPage: z.number().optional(), +}); + +/** + * Restrict team URLs schema. + * + * Allowed characters: + * - Alphanumeric + * - Lowercase + * - Dashes + * - Underscores + * + * Conditions: + * - 3-30 characters + * - Cannot start and end with underscores or dashes. + * - Cannot contain consecutive underscores or dashes. + * - Cannot be a reserved URL in the PROTECTED_TEAM_URLS list + */ +export const ZTeamUrlSchema = z + .string() + .trim() + .min(3, { message: 'Team URL must be at least 3 characters long.' }) + .max(30, { message: 'Team URL must not exceed 30 characters.' }) + .toLowerCase() + .regex(/^[a-z0-9].*[^_-]$/, 'Team URL cannot start or end with dashes or underscores.') + .regex(/^(?!.*[-_]{2})/, 'Team URL cannot contain consecutive dashes or underscores.') + .regex( + /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/, + 'Team URL can only contain letters, numbers, dashes and underscores.', + ) + .refine((value) => !PROTECTED_TEAM_URLS.includes(value), { + message: 'This URL is already in use.', + }); + +export const ZTeamNameSchema = z + .string() + .trim() + .min(3, { message: 'Team name must be at least 3 characters long.' }) + .max(30, { message: 'Team name must not exceed 30 characters.' }); + +export const ZAcceptTeamInvitationMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZCreateTeamBillingPortalMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZCreateTeamMutationSchema = z.object({ + teamName: ZTeamNameSchema, + teamUrl: ZTeamUrlSchema, +}); + +export const ZCreateTeamEmailVerificationMutationSchema = z.object({ + teamId: z.number(), + name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), + email: z.string().trim().email().toLowerCase().min(1, 'Please enter a valid email.'), +}); + +export const ZCreateTeamMemberInvitesMutationSchema = z.object({ + teamId: z.number(), + invitations: z.array( + z.object({ + email: z.string().email().toLowerCase(), + role: z.nativeEnum(TeamMemberRole), + }), + ), +}); + +export const ZCreateTeamPendingCheckoutMutationSchema = z.object({ + interval: z.union([z.literal('monthly'), z.literal('yearly')]), + pendingTeamId: z.number(), +}); + +export const ZDeleteTeamEmailMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZDeleteTeamEmailVerificationMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZDeleteTeamMembersMutationSchema = z.object({ + teamId: z.number(), + teamMemberIds: z.array(z.number()), +}); + +export const ZDeleteTeamMemberInvitationsMutationSchema = z.object({ + teamId: z.number(), + invitationIds: z.array(z.number()), +}); + +export const ZDeleteTeamMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZDeleteTeamPendingMutationSchema = z.object({ + pendingTeamId: z.number(), +}); + +export const ZDeleteTeamTransferRequestMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZFindTeamInvoicesQuerySchema = z.object({ + teamId: z.number(), +}); + +export const ZFindTeamMemberInvitesQuerySchema = GenericFindQuerySchema.extend({ + teamId: z.number(), +}); + +export const ZFindTeamMembersQuerySchema = GenericFindQuerySchema.extend({ + teamId: z.number(), +}); + +export const ZFindTeamsQuerySchema = GenericFindQuerySchema; + +export const ZFindTeamsPendingQuerySchema = GenericFindQuerySchema; + +export const ZGetTeamQuerySchema = z.object({ + teamId: z.number(), +}); + +export const ZGetTeamMembersQuerySchema = z.object({ + teamId: z.number(), +}); + +export const ZLeaveTeamMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZUpdateTeamMutationSchema = z.object({ + teamId: z.number(), + data: z.object({ + name: ZTeamNameSchema, + url: ZTeamUrlSchema, + }), +}); + +export const ZUpdateTeamEmailMutationSchema = z.object({ + teamId: z.number(), + data: z.object({ + name: z.string().trim().min(1), + }), +}); + +export const ZUpdateTeamMemberMutationSchema = z.object({ + teamId: z.number(), + teamMemberId: z.number(), + data: z.object({ + role: z.nativeEnum(TeamMemberRole), + }), +}); + +export const ZRequestTeamOwnerhsipTransferMutationSchema = z.object({ + teamId: z.number(), + newOwnerUserId: z.number(), + clearPaymentMethods: z.boolean(), +}); + +export const ZResendTeamEmailVerificationMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZResendTeamMemberInvitationMutationSchema = z.object({ + teamId: z.number(), + invitationId: z.number(), +}); + +export type TCreateTeamMutationSchema = z.infer; +export type TCreateTeamEmailVerificationMutationSchema = z.infer< + typeof ZCreateTeamEmailVerificationMutationSchema +>; +export type TCreateTeamMemberInvitesMutationSchema = z.infer< + typeof ZCreateTeamMemberInvitesMutationSchema +>; +export type TCreateTeamPendingCheckoutMutationSchema = z.infer< + typeof ZCreateTeamPendingCheckoutMutationSchema +>; +export type TDeleteTeamEmailMutationSchema = z.infer; +export type TDeleteTeamMembersMutationSchema = z.infer; +export type TDeleteTeamMutationSchema = z.infer; +export type TDeleteTeamPendingMutationSchema = z.infer; +export type TDeleteTeamTransferRequestMutationSchema = z.infer< + typeof ZDeleteTeamTransferRequestMutationSchema +>; +export type TFindTeamMemberInvitesQuerySchema = z.infer; +export type TFindTeamMembersQuerySchema = z.infer; +export type TFindTeamsQuerySchema = z.infer; +export type TFindTeamsPendingQuerySchema = z.infer; +export type TGetTeamQuerySchema = z.infer; +export type TGetTeamMembersQuerySchema = z.infer; +export type TLeaveTeamMutationSchema = z.infer; +export type TUpdateTeamMutationSchema = z.infer; +export type TUpdateTeamEmailMutationSchema = z.infer; +export type TRequestTeamOwnerhsipTransferMutationSchema = z.infer< + typeof ZRequestTeamOwnerhsipTransferMutationSchema +>; +export type TResendTeamEmailVerificationMutationSchema = z.infer< + typeof ZResendTeamEmailVerificationMutationSchema +>; +export type TResendTeamMemberInvitationMutationSchema = z.infer< + typeof ZResendTeamMemberInvitationMutationSchema +>; diff --git a/packages/ui/components/animate/animate-generic-fade-in-out.tsx b/packages/ui/components/animate/animate-generic-fade-in-out.tsx new file mode 100644 index 000000000..5f57c96df --- /dev/null +++ b/packages/ui/components/animate/animate-generic-fade-in-out.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { motion } from 'framer-motion'; + +type AnimateGenericFadeInOutProps = { + children: React.ReactNode; + className?: string; +}; + +export const AnimateGenericFadeInOut = ({ children, className }: AnimateGenericFadeInOutProps) => { + return ( + + {children} + + ); +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 34675ba89..44d14cb82 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -35,7 +35,7 @@ "@radix-ui/react-checkbox": "^1.0.3", "@radix-ui/react-collapsible": "^1.0.2", "@radix-ui/react-context-menu": "^2.1.3", - "@radix-ui/react-dialog": "^1.0.3", + "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.4", "@radix-ui/react-hover-card": "^1.0.5", "@radix-ui/react-label": "^2.0.1", @@ -45,7 +45,7 @@ "@radix-ui/react-progress": "^1.0.2", "@radix-ui/react-radio-group": "^1.1.2", "@radix-ui/react-scroll-area": "^1.0.3", - "@radix-ui/react-select": "^1.2.1", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.2", "@radix-ui/react-slider": "^1.1.1", "@radix-ui/react-slot": "^1.0.2", diff --git a/packages/ui/primitives/avatar.tsx b/packages/ui/primitives/avatar.tsx index 0039ad4eb..c80e3a658 100644 --- a/packages/ui/primitives/avatar.tsx +++ b/packages/ui/primitives/avatar.tsx @@ -48,4 +48,37 @@ const AvatarFallback = React.forwardRef< AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; -export { Avatar, AvatarImage, AvatarFallback }; +type AvatarWithTextProps = { + avatarClass?: string; + avatarFallback: string; + className?: string; + primaryText: React.ReactNode; + secondaryText?: React.ReactNode; + rightSideComponent?: React.ReactNode; +}; + +const AvatarWithText = ({ + avatarClass, + avatarFallback, + className, + primaryText, + secondaryText, + rightSideComponent, +}: AvatarWithTextProps) => ( +
    + + {avatarFallback} + + +
    + {primaryText} + {secondaryText} +
    + + {rightSideComponent} +
    +); + +export { Avatar, AvatarImage, AvatarFallback, AvatarWithText }; diff --git a/packages/ui/primitives/badge.tsx b/packages/ui/primitives/badge.tsx index 1ff153f79..fd56bc1ce 100644 --- a/packages/ui/primitives/badge.tsx +++ b/packages/ui/primitives/badge.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; -import { VariantProps, cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; import { cn } from '../lib/utils'; diff --git a/packages/ui/primitives/button.tsx b/packages/ui/primitives/button.tsx index 5754b35a5..5fc3fc1bb 100644 --- a/packages/ui/primitives/button.tsx +++ b/packages/ui/primitives/button.tsx @@ -18,6 +18,7 @@ const buttonVariants = cva( secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', link: 'underline-offset-4 hover:underline text-primary', + none: '', }, size: { default: 'h-10 py-2 px-4', diff --git a/packages/ui/primitives/command.tsx b/packages/ui/primitives/command.tsx index 65f88fc4e..fee5321cd 100644 --- a/packages/ui/primitives/command.tsx +++ b/packages/ui/primitives/command.tsx @@ -92,7 +92,7 @@ const CommandGroup = React.forwardRef< ) => (
    ); diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 74764df80..9c8db7918 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -403,7 +403,7 @@ export const AddFieldsFormPartial = ({ {recipients.map((recipient) => ( { @@ -439,7 +439,7 @@ export const AddFieldsFormPartial = ({ ) : ( - + diff --git a/packages/ui/primitives/multi-select-combobox.tsx b/packages/ui/primitives/multi-select-combobox.tsx new file mode 100644 index 000000000..62e5fa2cf --- /dev/null +++ b/packages/ui/primitives/multi-select-combobox.tsx @@ -0,0 +1,165 @@ +'use client'; + +import * as React from 'react'; + +import { AnimatePresence } from 'framer-motion'; +import { Check, ChevronsUpDown, Loader, XIcon } from 'lucide-react'; + +import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; + +import { cn } from '../lib/utils'; +import { Button } from './button'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from './command'; +import { Popover, PopoverContent, PopoverTrigger } from './popover'; + +type OptionValue = string | number | boolean | null; + +type ComboBoxOption = { + label: string; + value: T; + disabled?: boolean; +}; + +type MultiSelectComboboxProps = { + emptySelectionPlaceholder?: React.ReactNode | string; + enableClearAllButton?: boolean; + loading?: boolean; + inputPlaceholder?: string; + onChange: (_values: T[]) => void; + options: ComboBoxOption[]; + selectedValues: T[]; +}; + +/** + * Multi select combo box component which supports: + * + * - Label/value pairs + * - Loading state + * - Clear all button + */ +export function MultiSelectCombobox({ + emptySelectionPlaceholder = 'Select values...', + enableClearAllButton, + inputPlaceholder, + loading, + onChange, + options, + selectedValues, +}: MultiSelectComboboxProps) { + const [open, setOpen] = React.useState(false); + + const handleSelect = (selectedOption: T) => { + let newSelectedOptions = [...selectedValues, selectedOption]; + + if (selectedValues.includes(selectedOption)) { + newSelectedOptions = selectedValues.filter((v) => v !== selectedOption); + } + + onChange(newSelectedOptions); + + setOpen(false); + }; + + const selectedOptions = React.useMemo(() => { + return selectedValues.map((value): ComboBoxOption => { + const foundOption = options.find((option) => option.value === value); + + if (foundOption) { + return foundOption; + } + + let label = ''; + + if (typeof value === 'string' || typeof value === 'number') { + label = value.toString(); + } + + return { + label, + value, + }; + }); + }, [selectedValues, options]); + + const buttonLabel = React.useMemo(() => { + if (loading) { + return ''; + } + + if (selectedOptions.length === 0) { + return emptySelectionPlaceholder; + } + + return selectedOptions.map((option) => option.label).join(', '); + }, [selectedOptions, emptySelectionPlaceholder, loading]); + + const showClearButton = enableClearAllButton && selectedValues.length > 0; + + return ( + +
    + + + + + {/* This is placed outside the trigger since we can't have nested buttons. */} + {showClearButton && !loading && ( +
    + +
    + )} +
    + + + + + No value found. + + {options.map((option, i) => ( + handleSelect(option.value)}> + + {option.label} + + ))} + + + +
    + ); +} diff --git a/turbo.json b/turbo.json index b0a7a0fc6..4ea966a4d 100644 --- a/turbo.json +++ b/turbo.json @@ -43,7 +43,6 @@ "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_FEATURE_BILLING_ENABLED", "NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID", - "NEXT_PUBLIC_STRIPE_FREE_PLAN_ID", "NEXT_PUBLIC_DISABLE_SIGNUP", "NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT", "NEXT_PRIVATE_DATABASE_URL", From fe4345eeb9f3cfc891469d901ebde22554b9f634 Mon Sep 17 00:00:00 2001 From: rajesh <71485855+rajesh-1252@users.noreply.github.com> Date: Tue, 6 Feb 2024 11:15:46 +0530 Subject: [PATCH 141/156] fix: add responsive for blog preview mobile view (#906) This Issue https://github.com/documenso/documenso/issues/904 --- apps/marketing/src/app/(marketing)/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/src/app/(marketing)/layout.tsx b/apps/marketing/src/app/(marketing)/layout.tsx index 248414b33..dd1a46418 100644 --- a/apps/marketing/src/app/(marketing)/layout.tsx +++ b/apps/marketing/src/app/(marketing)/layout.tsx @@ -41,7 +41,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
    -
    {children}
    +
    {children}
    From 2f696ddd13c245c9943e3b01ce69000609a95a86 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Tue, 6 Feb 2024 18:55:16 +0100 Subject: [PATCH 142/156] feat: blog article why i started documenso --- .../content/blog/why-i-started-documenso.mdx | 67 ++++++++++++++++++ apps/marketing/public/blog/burgers.jpeg | Bin 0 -> 166613 bytes 2 files changed, 67 insertions(+) create mode 100644 apps/marketing/content/blog/why-i-started-documenso.mdx create mode 100644 apps/marketing/public/blog/burgers.jpeg diff --git a/apps/marketing/content/blog/why-i-started-documenso.mdx b/apps/marketing/content/blog/why-i-started-documenso.mdx new file mode 100644 index 000000000..58da0956e --- /dev/null +++ b/apps/marketing/content/blog/why-i-started-documenso.mdx @@ -0,0 +1,67 @@ +--- +title: Why I started Documenso +description: TLDR; I started Documenso because I wanted to build a modern tech company in a growing space with a mission bigger than money, I overpaid for a SSL cert 13 years ago, like encryption and want to help make the internet/ world more open. +authorName: 'Timur Ercan' +authorImage: '/blog/blog-author-timur.jpeg' +authorRole: 'Co-Founder' +date: 2024-02-06 +Tags: + - Founders + - Mission + - Open Source +--- + +
    + + +
    + No the burger from the story. But it could be as well, the place is pretty generic. +
    +
    + +> TLDR; I started Documenso because I wanted to build a modern tech company in a growing space with a mission bigger than money, I overpaid for a SSL cert 13 years ago, like encryption and want to help make the world/ Internet more open + +It’s hard to pinpoint when I decided to start Documenso. I first uttered the word “Documenso” while sitting in a restaurant with @FelixM while discussing what’s next in late 2022. Shortly after i sat down with a can of caffeine and started building Documenso 0.9. Starting Documenso is the most deliberate business decision I ever made. It was deliberate from the personal side and deliberate from the business side. + +Personally I’ve had some time off and was actively looking for my next move. Looking back, my first company I stumbled into, the second one less so, but I joined my co-founders and did not come up with the concept myself. While coming up with Documenso, I was deliberatly looking for: + +- An entrepreneurial space, that was big enough opportunity +- A huge macro trend, lifting everything in it’s space +- A mode of working that fits my personal flow (which luckily for me, pretty close to the modern startup/ tech scene) +- An bigger impact to be made, that just earning lots of money (though there is nothing wrong with that) + +Quick shoutout to everyone feeling even a pinch of imposter syndrom while calling themselves a founder. It was after 10 years, slightly after starting Documenso, that I started doing it in my head without cringing. So cut yourself some slack. Considering how long I’ve been doing this, I guess I would have earned the internal title sooner and so do you probably. So after grappeling with my identity for second, as is customary for founders, my decision to start this journey came pretty quickly. + +Aside from the personal dimension, I had a pretty clear mindset of what I was looking for. The criteria I go on describing happend to click into place one after another, in no particular order. Having experienced no market demand and a very grindy market, I was looking for something more fundamental. Something basic, infrastructure-like, with a huge demand. A growing market, deeply rooted in the growing digitalization of the world. + +And to be honest, I just always liked digital signature tools. It’s a product, easy enough to comprehend and build but complex and impactful enough to satisfy a hard need. It’s a product you can build very product-driven since the market and domain are well understood at this point. So when asked about what’s next for me, I literally said “digital, um, let’s say… signatures”. As it turns out, my first gut feeling was spot on, but how spot on I only realized when I started researching the space. An open source document signing company happens to be the perfect intersection of all criteria and personal preferences I described above, it’s pretty amazing actually: + +- The global signing market is huge and rapidly growing +- The signing space is huge dominated by one outdated player, to put it bluntly. Outdated in terms of tech, pricing and ecosystem +- The signing space is also ridiculously opaque for a space that is based on open web tech, open encryption tech and open signing standards. Even by closed source standards +- We are currently seeing a renaissance for commercial open source startups, combining venture founder financial with open source mechanics +- Rebuilding a fundamental infrastructure as open source with a meaningful scale, has a profoundly transformative effect for a space +- Working in open source requires you to be open, cooperative and inclusive. It also requires quite a bit of context jumping, “going with the flow” and empathy +- Apart from fixing the signing space, making Documenso successful, would be another domino tile toward open source eating the world, which is great for everyone + +Building a company is so complex, it can’t be planned out. Basing it on great fundamentals and the expected dynmamics it the best founders can do in my humble opinion. After these fundamental decisions you are (almost) just along for the ride and need to focus on solving the “convential” problems of starting a company the best you can. With digital signatures hitting so many point of my personal and professional checklist, this already was a great fit. What got me exited at first though, apart from the perspective of drinking caffeine and coding, was this: + +Roughly 13 years ago, I was launching my first product. We obviously wanted SSL encryption on the product site, so I had to buy an SSL certificate. ~$200ish, 2 years validity, from VeriSign I think. Apart from it being ridiculously complicated to get, even back then it bothered me, that we had basically paid for $200 for what is essentially a long number, someone generated. SSL wasn’t even that widespread back then, because it was mainly considered important for ecommerce, no wonder considering it costed so much. “Why would I encrypt a blog?”. Fast forward to today, and everyone can get a free SSL cert courtesy of Let’s Encrypt and browsers basically block unencrypted sites. Mostly even build into hosting plattforms so you barely even notice as a developer. + +I had forgotten all about that story until I realized, this is where signing is today. A global need, fullfilled only by closed ecosystem, not really state-of-the-art companies, leading to, let’s call it steep prices. I had for so long considered Let’s Encrypt a pillar of the open internet, that I forgot that they weren’t always there. One day someone said, let’s make the internet better. Signing is another domain, that should have had an open ecosystem for a long time. Another parallel to that story is the fact that the cryptographic certificates you need for document signing are also stuck in the “pre Let’s Encrypt world”. Free document signing certificates via "Let’s Sign" are now another todo on the [longterm roadmap](https://documen.so/roadmap) list for open signing ecossytem. Actually effecting this change in any way, is a huge driver for me, personally. + +Apart from my personal gripes with the coporate certificate industry, I always found encryption fascinating. It’s such a fundamental force in society when you think about it: Secure Communication, Secure Commerce and even internet native money (Bitcoin) was created using a bit of smart math. All these examples are expressions of very fundamental human behaviours, that should be enabled and protected by open infrastructures. + +I never told anyone before, but since starting Documenso I realized that I underestimated the impact and importance of open source for quite some time. When I was in University, I distantly remember my mindset of “yeah open source is nice, but the great, commercially successful products used in the real world are build by closed companies (aka Microsoft)” _shudder_ It was never really a conscious thought, but enough that I started learning MS Silverlight before plain Javascript. It was slowly over time, that I realized that open web standards are superior to closed ones and even later that I understood the same holds true for all software. Open sources fixes something in the economy, I find hard to articulate. I did my best in [commodifying signing]. + +To wrap this up, Documenso happens to be the perfect storm of market opportunity, my personal interests and passions. Creating a company people actually want to work for longterm while tackleing these issues is critical side quest of Documenso. This is not only about building the next generation signing tech, it’s also about doing our part to normalize open, healthy, efficient working cultures, tackling relevant problems. + +As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments. + +Best from Hamburg\ +Timur diff --git a/apps/marketing/public/blog/burgers.jpeg b/apps/marketing/public/blog/burgers.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..4fd897e759cc088eed32481a498824eeb52b48dd GIT binary patch literal 166613 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⋘wh|Ug#R0FT_^yAf}%J4zTm;y4FN&ZDwp<` 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 LF`i2+hJXLrIGefV literal 0 HcmV?d00001 From 94ddb0252b099ad47579d3a009c140b39d0fd5eb Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Tue, 6 Feb 2024 19:09:27 +0100 Subject: [PATCH 143/156] fix: stuff --- apps/marketing/content/blog/why-i-started-documenso.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/why-i-started-documenso.mdx b/apps/marketing/content/blog/why-i-started-documenso.mdx index 58da0956e..31d29a1a6 100644 --- a/apps/marketing/content/blog/why-i-started-documenso.mdx +++ b/apps/marketing/content/blog/why-i-started-documenso.mdx @@ -26,7 +26,7 @@ Tags: > TLDR; I started Documenso because I wanted to build a modern tech company in a growing space with a mission bigger than money, I overpaid for a SSL cert 13 years ago, like encryption and want to help make the world/ Internet more open -It’s hard to pinpoint when I decided to start Documenso. I first uttered the word “Documenso” while sitting in a restaurant with @FelixM while discussing what’s next in late 2022. Shortly after i sat down with a can of caffeine and started building Documenso 0.9. Starting Documenso is the most deliberate business decision I ever made. It was deliberate from the personal side and deliberate from the business side. +It’s hard to pinpoint when I decided to start Documenso. I first uttered the word “Documenso” while sitting in a restaurant with [Felix](https://twitter.com/flxmgdnz), eating a burger and discussing what’s next in late 2022. Shortly after i sat down with a can of caffeine and started building Documenso 0.9. Starting Documenso is the most deliberate business decision I ever made. It was deliberate from the personal side and deliberate from the business side. Personally I’ve had some time off and was actively looking for my next move. Looking back, my first company I stumbled into, the second one less so, but I joined my co-founders and did not come up with the concept myself. While coming up with Documenso, I was deliberatly looking for: From 58e2eda5e9929c91fd5ab0a904cbbec2ecb8208f Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 7 Feb 2024 14:40:15 +1100 Subject: [PATCH 144/156] fix: update field remove logic --- packages/lib/server-only/field/set-fields-for-document.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts index 2ba592f31..ecb45d461 100644 --- a/packages/lib/server-only/field/set-fields-for-document.ts +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -56,11 +56,7 @@ export const setFieldsForDocument = async ({ }); const removedFields = existingFields.filter( - (existingField) => - !fields.find( - (field) => - field.id === existingField.id || field.signerEmail === existingField.Recipient?.email, - ), + (existingField) => !fields.find((field) => field.id === existingField.id), ); const linkedFields = fields From bf26f2cb9d972d17c2419f9a747d7e26b7e2f74e Mon Sep 17 00:00:00 2001 From: Sumit Bisht <75713174+sumitbishti@users.noreply.github.com> Date: Wed, 7 Feb 2024 10:55:39 +0530 Subject: [PATCH 145/156] fix: empty document titles (#917) fixes: #909 --- packages/ui/primitives/document-flow/add-title.tsx | 5 ++++- packages/ui/primitives/document-flow/add-title.types.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-title.tsx b/packages/ui/primitives/document-flow/add-title.tsx index 730c4248f..a6390fd3a 100644 --- a/packages/ui/primitives/document-flow/add-title.tsx +++ b/packages/ui/primitives/document-flow/add-title.tsx @@ -1,5 +1,6 @@ 'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import type { Field, Recipient } from '@documenso/prisma/client'; @@ -10,6 +11,7 @@ import { Input } from '../input'; import { Label } from '../label'; import { useStep } from '../stepper'; import type { TAddTitleFormSchema } from './add-title.types'; +import { ZAddTitleFormSchema } from './add-title.types'; import { DocumentFlowFormContainerActions, DocumentFlowFormContainerContent, @@ -40,6 +42,7 @@ export const AddTitleFormPartial = ({ handleSubmit, formState: { errors, isSubmitting }, } = useForm({ + resolver: zodResolver(ZAddTitleFormSchema), defaultValues: { title: document.title, }, @@ -71,7 +74,7 @@ export const AddTitleFormPartial = ({ id="title" className="bg-background my-2" disabled={isSubmitting} - {...register('title', { required: "Title can't be empty" })} + {...register('title')} /> diff --git a/packages/ui/primitives/document-flow/add-title.types.ts b/packages/ui/primitives/document-flow/add-title.types.ts index aaa8c17e4..b910c060a 100644 --- a/packages/ui/primitives/document-flow/add-title.types.ts +++ b/packages/ui/primitives/document-flow/add-title.types.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const ZAddTitleFormSchema = z.object({ - title: z.string().min(1), + title: z.string().trim().min(1, { message: "Title can't be empty" }), }); export type TAddTitleFormSchema = z.infer; From 7d39e3d0658206b34b3f1dc85a3457783c9f09ea Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 7 Feb 2024 21:32:44 +1100 Subject: [PATCH 146/156] feat: add team feature flag (#915) ## Description Add the ability to feature flag the teams feature via UI. Also added minor UI changes ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have added/updated tests that prove the effectiveness of these changes. - [X] I have followed the project's coding style guidelines. --- .../components/(dashboard)/layout/header.tsx | 38 +++- .../(dashboard)/layout/mobile-navigation.tsx | 2 +- .../(dashboard)/layout/profile-dropdown.tsx | 169 ++++++++++++++++++ .../settings/layout/desktop-nav.tsx | 27 +-- .../settings/layout/mobile-nav.tsx | 27 +-- .../dialogs/create-team-checkout-dialog.tsx | 3 +- .../forms/2fa/authenticator-app.tsx | 2 - packages/lib/constants/feature-flags.ts | 1 + packages/ui/primitives/sheet.tsx | 9 +- 9 files changed, 244 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx index 753f5fb11..65bb63230 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/layout/header.tsx @@ -7,6 +7,7 @@ import { useParams } from 'next/navigation'; import { MenuIcon, SearchIcon } from 'lucide-react'; +import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; import { getRootHref } from '@documenso/lib/utils/params'; import type { User } from '@documenso/prisma/client'; @@ -18,6 +19,7 @@ import { CommandMenu } from '../common/command-menu'; import { DesktopNav } from './desktop-nav'; import { MenuSwitcher } from './menu-switcher'; import { MobileNavigation } from './mobile-navigation'; +import { ProfileDropdown } from './profile-dropdown'; export type HeaderProps = HTMLAttributes & { user: User; @@ -27,6 +29,10 @@ export type HeaderProps = HTMLAttributes & { export const Header = ({ className, user, teams, ...props }: HeaderProps) => { const params = useParams(); + const { getFlag } = useFeatureFlags(); + + const isTeamsEnabled = getFlag('app_teams'); + const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false); const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false); const [scrollY, setScrollY] = useState(0); @@ -41,6 +47,34 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => { return () => window.removeEventListener('scroll', onScroll); }, []); + if (!isTeamsEnabled) { + return ( +
    5 && 'border-b-border', + className, + )} + {...props} + > +
    + + + + + + +
    + +
    +
    +
    + ); + } + return (
    {
    diff --git a/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx b/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx index 7142de5dc..a77300d9e 100644 --- a/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx +++ b/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx @@ -47,7 +47,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat return ( - + Documenso Logo { + const { getFlag } = useFeatureFlags(); + const { theme, setTheme } = useTheme(); + const isUserAdmin = isAdmin(user); + + const isBillingEnabled = getFlag('app_billing'); + + const avatarFallback = user.name + ? extractInitials(user.name) + : user.email.slice(0, 1).toUpperCase(); + + return ( + + + + + + + Account + + {isUserAdmin && ( + <> + + + + Admin + + + + + + )} + + + + + Profile + + + + + + + Security + + + + {isBillingEnabled && ( + + + + Billing + + + )} + + + + + + Templates + + + + + + + + Themes + + + + + + Light + + + + Dark + + + + System + + + + + + + + + + Star on Github + + + + + + + void signOut({ + callbackUrl: '/', + }) + } + > + + Sign Out + + + + ); +}; diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx index c7ab61d8a..572c91c76 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -19,6 +19,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { const { getFlag } = useFeatureFlags(); const isBillingEnabled = getFlag('app_billing'); + const isTeamsEnabled = getFlag('app_teams'); return (
    @@ -35,18 +36,20 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { - - - + {isTeamsEnabled && ( + + + + )} - - - + {isTeamsEnabled && ( + + + + )}
    diff --git a/apps/web/src/components/forms/2fa/authenticator-app.tsx b/apps/web/src/components/forms/2fa/authenticator-app.tsx index 316272e34..3aa0e123e 100644 --- a/apps/web/src/components/forms/2fa/authenticator-app.tsx +++ b/apps/web/src/components/forms/2fa/authenticator-app.tsx @@ -30,13 +30,11 @@ export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps)
    !open && setModalState(null)} /> !open && setModalState(null)} /> diff --git a/packages/lib/constants/feature-flags.ts b/packages/lib/constants/feature-flags.ts index e972b47c2..947409be1 100644 --- a/packages/lib/constants/feature-flags.ts +++ b/packages/lib/constants/feature-flags.ts @@ -17,6 +17,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000; */ export const LOCAL_FEATURE_FLAGS: Record = { app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true', + app_teams: true, marketing_header_single_player_mode: false, } as const; diff --git a/packages/ui/primitives/sheet.tsx b/packages/ui/primitives/sheet.tsx index e9f1b4401..a6326de0f 100644 --- a/packages/ui/primitives/sheet.tsx +++ b/packages/ui/primitives/sheet.tsx @@ -3,7 +3,8 @@ import * as React from 'react'; import * as SheetPrimitive from '@radix-ui/react-dialog'; -import { VariantProps, cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; import { X } from 'lucide-react'; import { cn } from '../lib/utils'; @@ -12,7 +13,7 @@ const Sheet = SheetPrimitive.Root; const SheetTrigger = SheetPrimitive.Trigger; -const portalVariants = cva('fixed inset-0 z-50 flex', { +const portalVariants = cva('fixed inset-0 z-[61] flex', { variants: { position: { top: 'items-start', @@ -42,7 +43,7 @@ const SheetOverlay = React.forwardRef< >(({ className, children: _children, ...props }, ref) => ( Date: Wed, 7 Feb 2024 11:40:11 +0100 Subject: [PATCH 147/156] Update apps/marketing/content/blog/why-i-started-documenso.mdx Co-authored-by: Adithya Krishna --- apps/marketing/content/blog/why-i-started-documenso.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/why-i-started-documenso.mdx b/apps/marketing/content/blog/why-i-started-documenso.mdx index 31d29a1a6..eb7a492fe 100644 --- a/apps/marketing/content/blog/why-i-started-documenso.mdx +++ b/apps/marketing/content/blog/why-i-started-documenso.mdx @@ -20,7 +20,7 @@ Tags: />
    - No the burger from the story. But it could be as well, the place is pretty generic. + Not the burger from the story. But it could be as well, the place is pretty generic.
    From 2431db06f56ae1150b5e4509459fa73982f515f4 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 7 Feb 2024 11:41:14 +0100 Subject: [PATCH 148/156] Update apps/marketing/content/blog/why-i-started-documenso.mdx Co-authored-by: Adithya Krishna --- apps/marketing/content/blog/why-i-started-documenso.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/why-i-started-documenso.mdx b/apps/marketing/content/blog/why-i-started-documenso.mdx index eb7a492fe..f270208c3 100644 --- a/apps/marketing/content/blog/why-i-started-documenso.mdx +++ b/apps/marketing/content/blog/why-i-started-documenso.mdx @@ -26,7 +26,7 @@ Tags: > TLDR; I started Documenso because I wanted to build a modern tech company in a growing space with a mission bigger than money, I overpaid for a SSL cert 13 years ago, like encryption and want to help make the world/ Internet more open -It’s hard to pinpoint when I decided to start Documenso. I first uttered the word “Documenso” while sitting in a restaurant with [Felix](https://twitter.com/flxmgdnz), eating a burger and discussing what’s next in late 2022. Shortly after i sat down with a can of caffeine and started building Documenso 0.9. Starting Documenso is the most deliberate business decision I ever made. It was deliberate from the personal side and deliberate from the business side. +It’s hard to pinpoint when I decided to start Documenso. I first uttered the word “Documenso” while sitting in a restaurant with [Felix](https://twitter.com/flxmgdnz), eating a burger and discussing what’s next in late 2022. Shortly after I sat down with a can of caffeine and started building Documenso 0.9. Starting Documenso is the most deliberate business decision I ever made. It was deliberate from the personal side and deliberate from the business side. Personally I’ve had some time off and was actively looking for my next move. Looking back, my first company I stumbled into, the second one less so, but I joined my co-founders and did not come up with the concept myself. While coming up with Documenso, I was deliberatly looking for: From 2e719288ffa811a1bfb3948653e487f30bd8b637 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 7 Feb 2024 11:41:32 +0100 Subject: [PATCH 149/156] Update apps/marketing/content/blog/why-i-started-documenso.mdx Co-authored-by: Adithya Krishna --- apps/marketing/content/blog/why-i-started-documenso.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/why-i-started-documenso.mdx b/apps/marketing/content/blog/why-i-started-documenso.mdx index f270208c3..dc165dad5 100644 --- a/apps/marketing/content/blog/why-i-started-documenso.mdx +++ b/apps/marketing/content/blog/why-i-started-documenso.mdx @@ -49,7 +49,7 @@ And to be honest, I just always liked digital signature tools. It’s a product, - Working in open source requires you to be open, cooperative and inclusive. It also requires quite a bit of context jumping, “going with the flow” and empathy - Apart from fixing the signing space, making Documenso successful, would be another domino tile toward open source eating the world, which is great for everyone -Building a company is so complex, it can’t be planned out. Basing it on great fundamentals and the expected dynmamics it the best founders can do in my humble opinion. After these fundamental decisions you are (almost) just along for the ride and need to focus on solving the “convential” problems of starting a company the best you can. With digital signatures hitting so many point of my personal and professional checklist, this already was a great fit. What got me exited at first though, apart from the perspective of drinking caffeine and coding, was this: +Building a company is so complex, it can’t be planned out. Basing it on great fundamentals and the expected dynamics it is the best founders can do in my humble opinion. After these fundamental decisions, you are (almost) just along for the ride and need to focus on solving the “conventional” problems of starting a company the best you can. With digital signatures hitting so many points of my personal and professional checklist, this already was a great fit. What got me excited at first though, apart from the perspective of drinking caffeine and coding, was this: Roughly 13 years ago, I was launching my first product. We obviously wanted SSL encryption on the product site, so I had to buy an SSL certificate. ~$200ish, 2 years validity, from VeriSign I think. Apart from it being ridiculously complicated to get, even back then it bothered me, that we had basically paid for $200 for what is essentially a long number, someone generated. SSL wasn’t even that widespread back then, because it was mainly considered important for ecommerce, no wonder considering it costed so much. “Why would I encrypt a blog?”. Fast forward to today, and everyone can get a free SSL cert courtesy of Let’s Encrypt and browsers basically block unencrypted sites. Mostly even build into hosting plattforms so you barely even notice as a developer. From 58477e060aba7a5f97e1aa981c79000b827cf5aa Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 7 Feb 2024 11:41:40 +0100 Subject: [PATCH 150/156] Update apps/marketing/content/blog/why-i-started-documenso.mdx Co-authored-by: Adithya Krishna --- apps/marketing/content/blog/why-i-started-documenso.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/why-i-started-documenso.mdx b/apps/marketing/content/blog/why-i-started-documenso.mdx index dc165dad5..13db38209 100644 --- a/apps/marketing/content/blog/why-i-started-documenso.mdx +++ b/apps/marketing/content/blog/why-i-started-documenso.mdx @@ -51,7 +51,7 @@ And to be honest, I just always liked digital signature tools. It’s a product, Building a company is so complex, it can’t be planned out. Basing it on great fundamentals and the expected dynamics it is the best founders can do in my humble opinion. After these fundamental decisions, you are (almost) just along for the ride and need to focus on solving the “conventional” problems of starting a company the best you can. With digital signatures hitting so many points of my personal and professional checklist, this already was a great fit. What got me excited at first though, apart from the perspective of drinking caffeine and coding, was this: -Roughly 13 years ago, I was launching my first product. We obviously wanted SSL encryption on the product site, so I had to buy an SSL certificate. ~$200ish, 2 years validity, from VeriSign I think. Apart from it being ridiculously complicated to get, even back then it bothered me, that we had basically paid for $200 for what is essentially a long number, someone generated. SSL wasn’t even that widespread back then, because it was mainly considered important for ecommerce, no wonder considering it costed so much. “Why would I encrypt a blog?”. Fast forward to today, and everyone can get a free SSL cert courtesy of Let’s Encrypt and browsers basically block unencrypted sites. Mostly even build into hosting plattforms so you barely even notice as a developer. +Roughly 13 years ago, I was launching my first product. We obviously wanted SSL encryption on the product site, so I had to buy an SSL certificate. ~$200ish, 2 years validity, from VeriSign I think. Apart from it being ridiculously complicated to get, even back then it bothered me, that we had basically paid for $200 for what is essentially a long number, someone generated. SSL wasn’t even that widespread back then, because it was mainly considered important for e-commerce, no wonder considering it cost so much. “Why would I encrypt a blog?”. Fast forward to today, and everyone can get a free SSL cert courtesy of Let’s Encrypt and browsers block unencrypted sites. Mostly even build into hosting platforms so you barely even notice as a developer. I had forgotten all about that story until I realized, this is where signing is today. A global need, fullfilled only by closed ecosystem, not really state-of-the-art companies, leading to, let’s call it steep prices. I had for so long considered Let’s Encrypt a pillar of the open internet, that I forgot that they weren’t always there. One day someone said, let’s make the internet better. Signing is another domain, that should have had an open ecosystem for a long time. Another parallel to that story is the fact that the cryptographic certificates you need for document signing are also stuck in the “pre Let’s Encrypt world”. Free document signing certificates via "Let’s Sign" are now another todo on the [longterm roadmap](https://documen.so/roadmap) list for open signing ecossytem. Actually effecting this change in any way, is a huge driver for me, personally. From 718f5664ac9ca6b38514f0a1bb1735c2681aa8f1 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 7 Feb 2024 12:00:01 +0100 Subject: [PATCH 151/156] Update apps/marketing/content/blog/why-i-started-documenso.mdx Co-authored-by: Adithya Krishna --- apps/marketing/content/blog/why-i-started-documenso.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/why-i-started-documenso.mdx b/apps/marketing/content/blog/why-i-started-documenso.mdx index 13db38209..95f750e9b 100644 --- a/apps/marketing/content/blog/why-i-started-documenso.mdx +++ b/apps/marketing/content/blog/why-i-started-documenso.mdx @@ -53,7 +53,7 @@ Building a company is so complex, it can’t be planned out. Basing it on great Roughly 13 years ago, I was launching my first product. We obviously wanted SSL encryption on the product site, so I had to buy an SSL certificate. ~$200ish, 2 years validity, from VeriSign I think. Apart from it being ridiculously complicated to get, even back then it bothered me, that we had basically paid for $200 for what is essentially a long number, someone generated. SSL wasn’t even that widespread back then, because it was mainly considered important for e-commerce, no wonder considering it cost so much. “Why would I encrypt a blog?”. Fast forward to today, and everyone can get a free SSL cert courtesy of Let’s Encrypt and browsers block unencrypted sites. Mostly even build into hosting platforms so you barely even notice as a developer. -I had forgotten all about that story until I realized, this is where signing is today. A global need, fullfilled only by closed ecosystem, not really state-of-the-art companies, leading to, let’s call it steep prices. I had for so long considered Let’s Encrypt a pillar of the open internet, that I forgot that they weren’t always there. One day someone said, let’s make the internet better. Signing is another domain, that should have had an open ecosystem for a long time. Another parallel to that story is the fact that the cryptographic certificates you need for document signing are also stuck in the “pre Let’s Encrypt world”. Free document signing certificates via "Let’s Sign" are now another todo on the [longterm roadmap](https://documen.so/roadmap) list for open signing ecossytem. Actually effecting this change in any way, is a huge driver for me, personally. +I had forgotten all about that story until I realized, this is where signing is today. A global need, fulfilled only by closed ecosystem, not really state-of-the-art companies, leading to, let’s call it steep prices. I had for so long considered Let’s Encrypt a pillar of the open internet, that I forgot that they weren’t always there. One day someone said, let’s make the internet better. Signing is another domain, that should have had an open ecosystem for a long time. Another parallel to that story is the fact that the cryptographic certificates you need for document signing are also stuck in the “pre Let’s Encrypt world”. Free document signing certificates via "Let’s Sign" are now another to-do on the [longterm roadmap](https://documen.so/roadmap) list for the open signing ecosystem. Effecting this change in any way is a huge driver for me. Apart from my personal gripes with the coporate certificate industry, I always found encryption fascinating. It’s such a fundamental force in society when you think about it: Secure Communication, Secure Commerce and even internet native money (Bitcoin) was created using a bit of smart math. All these examples are expressions of very fundamental human behaviours, that should be enabled and protected by open infrastructures. From b6bdbf72a71b59c3ebc54119b36edf5ca7e2375f Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 7 Feb 2024 12:00:12 +0100 Subject: [PATCH 152/156] Update apps/marketing/content/blog/why-i-started-documenso.mdx Co-authored-by: Adithya Krishna --- apps/marketing/content/blog/why-i-started-documenso.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/why-i-started-documenso.mdx b/apps/marketing/content/blog/why-i-started-documenso.mdx index 95f750e9b..228efdc0f 100644 --- a/apps/marketing/content/blog/why-i-started-documenso.mdx +++ b/apps/marketing/content/blog/why-i-started-documenso.mdx @@ -59,7 +59,7 @@ Apart from my personal gripes with the coporate certificate industry, I always f I never told anyone before, but since starting Documenso I realized that I underestimated the impact and importance of open source for quite some time. When I was in University, I distantly remember my mindset of “yeah open source is nice, but the great, commercially successful products used in the real world are build by closed companies (aka Microsoft)” _shudder_ It was never really a conscious thought, but enough that I started learning MS Silverlight before plain Javascript. It was slowly over time, that I realized that open web standards are superior to closed ones and even later that I understood the same holds true for all software. Open sources fixes something in the economy, I find hard to articulate. I did my best in [commodifying signing]. -To wrap this up, Documenso happens to be the perfect storm of market opportunity, my personal interests and passions. Creating a company people actually want to work for longterm while tackleing these issues is critical side quest of Documenso. This is not only about building the next generation signing tech, it’s also about doing our part to normalize open, healthy, efficient working cultures, tackling relevant problems. +To wrap this up, Documenso happens to be the perfect storm of market opportunity, my interests, and my passions. Creating a company people want to work for the long term while tackling these issues is a critical side quest of Documenso. This is not only about building the next generation of signing tech, it’s also about doing our part to normalize open, healthy, efficient working cultures, and tackling relevant problems. As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments. From 33ab8797a556a5388bb38b0c55daec6c930cf214 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 7 Feb 2024 12:22:07 +0100 Subject: [PATCH 153/156] chore: text --- apps/marketing/content/blog/why-i-started-documenso.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/content/blog/why-i-started-documenso.mdx b/apps/marketing/content/blog/why-i-started-documenso.mdx index 228efdc0f..458a4823a 100644 --- a/apps/marketing/content/blog/why-i-started-documenso.mdx +++ b/apps/marketing/content/blog/why-i-started-documenso.mdx @@ -1,6 +1,6 @@ --- title: Why I started Documenso -description: TLDR; I started Documenso because I wanted to build a modern tech company in a growing space with a mission bigger than money, I overpaid for a SSL cert 13 years ago, like encryption and want to help make the internet/ world more open. +description: I started Documenso because I wanted to build a modern tech company in a growing space with a mission bigger than money, I overpaid for a SSL cert 13 years ago, like encryption and want to help make the internet/ world more open. authorName: 'Timur Ercan' authorImage: '/blog/blog-author-timur.jpeg' authorRole: 'Co-Founder' From e2a5638f50cdb51eb32c9070faebfbce52f67be1 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Wed, 7 Feb 2024 13:07:22 +0100 Subject: [PATCH 154/156] chore: fixed --- .../content/blog/why-i-started-documenso.mdx | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/apps/marketing/content/blog/why-i-started-documenso.mdx b/apps/marketing/content/blog/why-i-started-documenso.mdx index 458a4823a..2fceddd25 100644 --- a/apps/marketing/content/blog/why-i-started-documenso.mdx +++ b/apps/marketing/content/blog/why-i-started-documenso.mdx @@ -24,44 +24,45 @@ Tags: -> TLDR; I started Documenso because I wanted to build a modern tech company in a growing space with a mission bigger than money, I overpaid for a SSL cert 13 years ago, like encryption and want to help make the world/ Internet more open +> TLDR; I started Documenso because I wanted to build a modern tech company in a growing space with a mission bigger than money, I overpaid for a SSL cert 13 years ago, like encryption, and wanted to help make the world/ Internet more open. -It’s hard to pinpoint when I decided to start Documenso. I first uttered the word “Documenso” while sitting in a restaurant with [Felix](https://twitter.com/flxmgdnz), eating a burger and discussing what’s next in late 2022. Shortly after I sat down with a can of caffeine and started building Documenso 0.9. Starting Documenso is the most deliberate business decision I ever made. It was deliberate from the personal side and deliberate from the business side. +It's hard to pinpoint when I decided to start Documenso. I first uttered the word "Documenso" while sitting in a restaurant with [Felix](https://twitter.com/flxmgdnz), eating a burger and discussing what's next in late 2022. Shortly after, I sat down with a can of caffeine and started building [Documenso 0.9](https://github.com/documenso/documenso/releases/tag/0.9-developer-preview). Starting Documenso is the most deliberate business decision I ever made. It was deliberate from the personal side and deliberate from the business side. -Personally I’ve had some time off and was actively looking for my next move. Looking back, my first company I stumbled into, the second one less so, but I joined my co-founders and did not come up with the concept myself. While coming up with Documenso, I was deliberatly looking for: +Looking at the personal side, I've had some time off and was actively looking for my next move. Looking back, I stumbled into my first company. Less so with the second one, but I joined my co-founders and did not develop the core concept myself. While coming up with Documenso, I was deliberately looking for a few things, based on my previous experiences: -- An entrepreneurial space, that was big enough opportunity -- A huge macro trend, lifting everything in it’s space -- A mode of working that fits my personal flow (which luckily for me, pretty close to the modern startup/ tech scene) -- An bigger impact to be made, that just earning lots of money (though there is nothing wrong with that) +- An entrepreneurial space that was a big enough opportunity +- A huge macro trend, lifting everything in it's space +- A mode of working that fits my flow (which, luckily for me, is pretty close to the modern startup/ tech scene) +- A more significant impact to be made than just earning lots of money (though there is nothing wrong with that) -Quick shoutout to everyone feeling even a pinch of imposter syndrom while calling themselves a founder. It was after 10 years, slightly after starting Documenso, that I started doing it in my head without cringing. So cut yourself some slack. Considering how long I’ve been doing this, I guess I would have earned the internal title sooner and so do you probably. So after grappeling with my identity for second, as is customary for founders, my decision to start this journey came pretty quickly. +Quick shoutout to everyone feeling even a pinch of imposter syndrome while calling themselves a founder. It was after ten years, slightly after starting Documenso, that I started doing it in my head without cringing. So cut yourself some slack. Considering how long I've been doing this, I would have earned the internal title sooner, and so do you. After grappling with my identity for a second, as is customary for founders, my decision to start this journey came quickly. -Aside from the personal dimension, I had a pretty clear mindset of what I was looking for. The criteria I go on describing happend to click into place one after another, in no particular order. Having experienced no market demand and a very grindy market, I was looking for something more fundamental. Something basic, infrastructure-like, with a huge demand. A growing market, deeply rooted in the growing digitalization of the world. +Aside from the personal dimension, I had a clear mindset of what I wanted. The criteria I describe below clicked into place one after another, in no particular order. Having experienced no market demand and a very gritty, grindy market, I was looking for something more fundamental. Something basic, infrastructure-like, with a huge demand. A growing market deeply rooted in the ever-increasing digitalization of the world. -And to be honest, I just always liked digital signature tools. It’s a product, easy enough to comprehend and build but complex and impactful enough to satisfy a hard need. It’s a product you can build very product-driven since the market and domain are well understood at this point. So when asked about what’s next for me, I literally said “digital, um, let’s say… signatures”. As it turns out, my first gut feeling was spot on, but how spot on I only realized when I started researching the space. An open source document signing company happens to be the perfect intersection of all criteria and personal preferences I described above, it’s pretty amazing actually: +And to be honest, I just always liked digital signature tools. It's a product that is easy enough to comprehend and build but complex and impactful enough to satisfy a hard need. It's a product you can build very product-driven since the market and domain are well understood. So when asked about what's next for me, I literally said, "Digital, um, let's say… signatures". As it turns out, my first gut feeling was spot on, but how spot on I only realized when I started researching the space. An open source document signing company happens to be the perfect intersection of all the criteria and personal preferences I described above; it's pretty amazing, actually: -- The global signing market is huge and rapidly growing -- The signing space is huge dominated by one outdated player, to put it bluntly. Outdated in terms of tech, pricing and ecosystem -- The signing space is also ridiculously opaque for a space that is based on open web tech, open encryption tech and open signing standards. Even by closed source standards -- We are currently seeing a renaissance for commercial open source startups, combining venture founder financial with open source mechanics -- Rebuilding a fundamental infrastructure as open source with a meaningful scale, has a profoundly transformative effect for a space -- Working in open source requires you to be open, cooperative and inclusive. It also requires quite a bit of context jumping, “going with the flow” and empathy -- Apart from fixing the signing space, making Documenso successful, would be another domino tile toward open source eating the world, which is great for everyone +- The global signing market is enormous and rapidly growing +- To put it bluntly, the signing space is vast and dominated by one outdated player. Outdated in terms of tech, pricing, and ecosystem +- The signing space is also ridiculously opaque for a space based on open web tech, open encryption tech, and open signing standards. Even by closed-source standards +- We are currently seeing a renaissance for commercial open source startups, combining venture founder financials with open source mechanics +- Rebuilding a fundamental infrastructure as open source with a meaningful scale has a profoundly transformative effect on any space +- Working in open source requires being open, cooperative, and inclusive. It also requires quite a bit of context jumping, "going with the flow," and empathy +- Apart from fixing the signing space, making Documenso successful would be another domino tile toward open source eating the world, which is great for everyone -Building a company is so complex, it can’t be planned out. Basing it on great fundamentals and the expected dynamics it is the best founders can do in my humble opinion. After these fundamental decisions, you are (almost) just along for the ride and need to focus on solving the “conventional” problems of starting a company the best you can. With digital signatures hitting so many points of my personal and professional checklist, this already was a great fit. What got me excited at first though, apart from the perspective of drinking caffeine and coding, was this: +Building a company is so complex it can't be planned out. Basing it on great fundamentals and the expected dynamics is the best founders can do, in my humble opinion. After these fundamental decisions, you are (almost) just along for the ride and need to focus on solving the "conventional" problems of starting a company the best you can. With digital signatures hitting so many points of my personal and professional checklist, this already was a great fit. What got me excited at first, though, apart from the perspective of drinking caffeine and coding, was this: -Roughly 13 years ago, I was launching my first product. We obviously wanted SSL encryption on the product site, so I had to buy an SSL certificate. ~$200ish, 2 years validity, from VeriSign I think. Apart from it being ridiculously complicated to get, even back then it bothered me, that we had basically paid for $200 for what is essentially a long number, someone generated. SSL wasn’t even that widespread back then, because it was mainly considered important for e-commerce, no wonder considering it cost so much. “Why would I encrypt a blog?”. Fast forward to today, and everyone can get a free SSL cert courtesy of Let’s Encrypt and browsers block unencrypted sites. Mostly even build into hosting platforms so you barely even notice as a developer. +Roughly 13 years ago, I was launching my first product. We obviously wanted SSL encryption on the product site, so I had to buy an SSL certificate. ~$200ish, two years validity, from VeriSign, I think. Apart from it being ridiculously complicated to get, it bothered me that we had basically paid $200 for what is essentially a long number someone generated. SSL wasn't even that widespread back then because it was mainly considered important for e-commerce, no wonder considering it cost so much. "Why would I encrypt a blog?". Fast forward to today, and everyone can get a free SSL cert courtesy of [Let's Encrypt](https://letsencrypt.org/) and browsers are basically blocking unencrypted sites. Mostly, it is even built into hosting platforms, so you barely even notice as a developer. -I had forgotten all about that story until I realized, this is where signing is today. A global need, fulfilled only by closed ecosystem, not really state-of-the-art companies, leading to, let’s call it steep prices. I had for so long considered Let’s Encrypt a pillar of the open internet, that I forgot that they weren’t always there. One day someone said, let’s make the internet better. Signing is another domain, that should have had an open ecosystem for a long time. Another parallel to that story is the fact that the cryptographic certificates you need for document signing are also stuck in the “pre Let’s Encrypt world”. Free document signing certificates via "Let’s Sign" are now another to-do on the [longterm roadmap](https://documen.so/roadmap) list for the open signing ecosystem. Effecting this change in any way is a huge driver for me. +I had forgotten all about that story until I realized this is where signing is today. A global need fulfilled only by a closed ecosystem, not really state-of-the-art companies, leading to, let's call it, steep prices. I had considered Let's Encrypt a pillar of the open internet for so long that I forgot that they weren't always there. One day, someone said, let's make the internet better. Signing is another domain that should have had an open ecosystem for a long time. Another parallel to that story is the fact that the cryptographic certificates you need for document signing are also stuck in the "pre-Let's Encrypt world." Free document signing certificates via "Let's Sign" are now another to-do on the [long-term roadmap](https://documen.so/roadmap) list for the open signing ecosystem. Effecting this change in any way is a huge driver for me. -Apart from my personal gripes with the coporate certificate industry, I always found encryption fascinating. It’s such a fundamental force in society when you think about it: Secure Communication, Secure Commerce and even internet native money (Bitcoin) was created using a bit of smart math. All these examples are expressions of very fundamental human behaviours, that should be enabled and protected by open infrastructures. +Apart from my personal gripes with the corporate certificate industry, I have always found encryption fascinating. It's such a fundamental force in society when you think about it: Secure Communication, Secure Commerce, and even [internet native, open source money (Bitcoin)](https://github.com/bitcoin/bitcoin) were created using a bit of smart math. All these examples are expressions of very fundamental human behaviors that should be enabled and protected by open infrastructures. -I never told anyone before, but since starting Documenso I realized that I underestimated the impact and importance of open source for quite some time. When I was in University, I distantly remember my mindset of “yeah open source is nice, but the great, commercially successful products used in the real world are build by closed companies (aka Microsoft)” _shudder_ It was never really a conscious thought, but enough that I started learning MS Silverlight before plain Javascript. It was slowly over time, that I realized that open web standards are superior to closed ones and even later that I understood the same holds true for all software. Open sources fixes something in the economy, I find hard to articulate. I did my best in [commodifying signing]. +I never told rthis to anyone before, but since starting Documenso, I realized that I underestimated the impact and importance of open source for quite some time. When I was in University, I distantly remember my mindset of "yeah, open source is nice, but the great, commercially successful products used in the real world are built by closed companies (aka Microsoft)" _shudder_ It was never really a conscious thought, but enough that I started learning MS Silverlight before plain Javascript. It was slowly, over time, that I realized that open web standards are superior to closed ones, and even later, I understood the same holds true for all software. Open source fixes something in the economy I find hard to articulate. I did my best in [Commodifying Signing](https://documenso.com/blog/commodifying-signing). -To wrap this up, Documenso happens to be the perfect storm of market opportunity, my interests, and my passions. Creating a company people want to work for the long term while tackling these issues is a critical side quest of Documenso. This is not only about building the next generation of signing tech, it’s also about doing our part to normalize open, healthy, efficient working cultures, and tackling relevant problems. +To wrap this up, Documenso happens to be the perfect storm of market opportunity, my interests, and my passions. Creating a company in which people want to work for the long term while tackling these issues is a critical side quest of Documenso. This is not only about building the next generation of signing tech; it's also about doing our part to normalize open, healthy, efficient working cultures and tackling relevant problems. -As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments. +As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions, comments, thoughts or feelings. +\ Best from Hamburg\ Timur From e97b9b4f1cd9a200e169059593737853fc351957 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 8 Feb 2024 12:33:20 +1100 Subject: [PATCH 155/156] feat: add team templates (#912) --- .../documents/[id]/document-page-view.tsx | 4 +- .../app/(dashboard)/documents/[id]/page.tsx | 2 +- .../documents/documents-page-view.tsx | 7 +- .../src/app/(dashboard)/documents/page.tsx | 2 +- .../templates/[id]/edit-template.tsx | 4 +- .../app/(dashboard)/templates/[id]/page.tsx | 81 +------ .../templates/[id]/template-page-view.tsx | 86 ++++++++ .../templates/data-table-action-dropdown.tsx | 25 ++- .../templates/data-table-templates.tsx | 15 +- .../templates/delete-template-dialog.tsx | 35 ++- .../templates/duplicate-template-dialog.tsx | 56 ++--- .../templates/new-template-dialog.tsx | 11 +- .../src/app/(dashboard)/templates/page.tsx | 50 +---- .../templates/templates-page-view.tsx | 73 +++++++ .../t/[teamUrl]/documents/[id]/page.tsx | 4 +- .../(teams)/t/[teamUrl]/documents/page.tsx | 2 +- .../t/[teamUrl]/templates/[id]/page.tsx | 22 ++ .../(teams)/t/[teamUrl]/templates/page.tsx | 26 +++ .../(dashboard)/layout/desktop-nav.tsx | 34 ++- .../(dashboard)/layout/menu-switcher.tsx | 22 +- .../(dashboard)/layout/mobile-navigation.tsx | 2 +- .../e2e/templates/manage-templates.spec.ts | 205 ++++++++++++++++++ packages/lib/constants/teams.ts | 1 + .../field/get-fields-for-template.ts | 15 +- .../field/set-fields-for-template.ts | 15 +- .../recipient/get-recipients-for-template.ts | 15 +- .../recipient/set-recipients-for-template.ts | 15 +- .../template/create-document-from-template.ts | 19 +- .../server-only/template/create-template.ts | 18 +- .../server-only/template/delete-template.ts | 20 +- .../template/duplicate-template.ts | 30 ++- .../server-only/template/find-templates.ts | 56 +++++ .../template/get-template-by-id.ts | 24 +- .../lib/server-only/template/get-templates.ts | 35 --- packages/lib/utils/teams.ts | 4 + .../migration.sql | 5 + packages/prisma/schema.prisma | 5 +- packages/prisma/seed/templates.ts | 36 +++ packages/trpc/server/field-router/router.ts | 2 +- .../trpc/server/recipient-router/router.ts | 4 +- .../trpc/server/template-router/router.ts | 12 +- .../trpc/server/template-router/schema.ts | 4 +- 42 files changed, 831 insertions(+), 272 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx create mode 100644 apps/web/src/app/(dashboard)/templates/templates-page-view.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/templates/[id]/page.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/templates/page.tsx create mode 100644 packages/app-tests/e2e/templates/manage-templates.spec.ts create mode 100644 packages/lib/server-only/template/find-templates.ts delete mode 100644 packages/lib/server-only/template/get-templates.ts create mode 100644 packages/prisma/migrations/20240206051948_add_teams_templates/migration.sql create mode 100644 packages/prisma/seed/templates.ts diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index 3a46ed5e7..6759d91ac 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -25,7 +25,7 @@ export type DocumentPageViewProps = { team?: Team; }; -export default async function DocumentPageView({ params, team }: DocumentPageViewProps) { +export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => { const { id } = params; const documentId = Number(id); @@ -128,4 +128,4 @@ export default async function DocumentPageView({ params, team }: DocumentPageVie )}
    ); -} +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index e7a34889e..5ad224737 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -1,4 +1,4 @@ -import DocumentPageView from './document-page-view'; +import { DocumentPageView } from './document-page-view'; export type DocumentPageProps = { params: { diff --git a/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx index ead3e8f4f..9059b8e88 100644 --- a/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx @@ -33,10 +33,7 @@ export type DocumentsPageViewProps = { team?: Team & { teamEmail?: TeamEmail | null }; }; -export default async function DocumentsPageView({ - searchParams = {}, - team, -}: DocumentsPageViewProps) { +export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => { const { user } = await getRequiredServerComponentSession(); const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL'; @@ -155,4 +152,4 @@ export default async function DocumentsPageView({

    ); -} +}; diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index b67ed6f02..67f432a13 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import type { DocumentsPageViewProps } from './documents-page-view'; -import DocumentsPageView from './documents-page-view'; +import { DocumentsPageView } from './documents-page-view'; export type DocumentsPageProps = { searchParams?: DocumentsPageViewProps['searchParams']; diff --git a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx index bdc769e79..f8c7f9a43 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/edit-template.tsx @@ -28,6 +28,7 @@ export type EditTemplateFormProps = { recipients: Recipient[]; fields: Field[]; documentData: DocumentData; + templateRootPath: string; }; type EditTemplateStep = 'signers' | 'fields'; @@ -40,6 +41,7 @@ export const EditTemplateForm = ({ fields, user: _user, documentData, + templateRootPath, }: EditTemplateFormProps) => { const { toast } = useToast(); const router = useRouter(); @@ -98,7 +100,7 @@ export const EditTemplateForm = ({ duration: 5000, }); - router.push('/templates'); + router.push(templateRootPath); } catch (err) { toast({ title: 'Error', diff --git a/apps/web/src/app/(dashboard)/templates/[id]/page.tsx b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx index 6d234eff2..aa55d1943 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/page.tsx @@ -1,81 +1,10 @@ import React from 'react'; -import Link from 'next/link'; -import { redirect } from 'next/navigation'; +import type { TemplatePageViewProps } from './template-page-view'; +import { TemplatePageView } from './template-page-view'; -import { ChevronLeft } from 'lucide-react'; +type TemplatePageProps = Pick; -import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; -import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template'; -import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template'; -import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; - -import { TemplateType } from '~/components/formatter/template-type'; - -import { EditTemplateForm } from './edit-template'; - -export type TemplatePageProps = { - params: { - id: string; - }; -}; - -export default async function TemplatePage({ params }: TemplatePageProps) { - const { id } = params; - - const templateId = Number(id); - - if (!templateId || Number.isNaN(templateId)) { - redirect('/documents'); - } - - const { user } = await getRequiredServerComponentSession(); - - const template = await getTemplateById({ - id: templateId, - userId: user.id, - }).catch(() => null); - - if (!template || !template.templateDocumentData) { - redirect('/documents'); - } - - const { templateDocumentData } = template; - - const [templateRecipients, templateFields] = await Promise.all([ - getRecipientsForTemplate({ - templateId, - userId: user.id, - }), - getFieldsForTemplate({ - templateId, - userId: user.id, - }), - ]); - - return ( -
    - - - Templates - - -

    - {template.title} -

    - -
    - -
    - - -
    - ); +export default function TemplatePage({ params }: TemplatePageProps) { + return ; } diff --git a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx new file mode 100644 index 000000000..899e600f1 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx @@ -0,0 +1,86 @@ +import React from 'react'; + +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +import { ChevronLeft } from 'lucide-react'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template'; +import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template'; +import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; +import { formatTemplatesPath } from '@documenso/lib/utils/teams'; +import type { Team } from '@documenso/prisma/client'; + +import { TemplateType } from '~/components/formatter/template-type'; + +import { EditTemplateForm } from './edit-template'; + +export type TemplatePageViewProps = { + params: { + id: string; + }; + team?: Team; +}; + +export const TemplatePageView = async ({ params, team }: TemplatePageViewProps) => { + const { id } = params; + + const templateId = Number(id); + const templateRootPath = formatTemplatesPath(team?.url); + + if (!templateId || Number.isNaN(templateId)) { + redirect(templateRootPath); + } + + const { user } = await getRequiredServerComponentSession(); + + const template = await getTemplateById({ + id: templateId, + userId: user.id, + }).catch(() => null); + + if (!template || !template.templateDocumentData) { + redirect(templateRootPath); + } + + const { templateDocumentData } = template; + + const [templateRecipients, templateFields] = await Promise.all([ + getRecipientsForTemplate({ + templateId, + userId: user.id, + }), + getFieldsForTemplate({ + templateId, + userId: user.id, + }), + ]); + + return ( +
    + + + Templates + + +

    + {template.title} +

    + +
    + +
    + + +
    + ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx index 9f26d632c..eee32b920 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx @@ -21,9 +21,15 @@ import { DuplicateTemplateDialog } from './duplicate-template-dialog'; export type DataTableActionDropdownProps = { row: Template; + templateRootPath: string; + teamId?: number; }; -export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { +export const DataTableActionDropdown = ({ + row, + templateRootPath, + teamId, +}: DataTableActionDropdownProps) => { const { data: session } = useSession(); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -34,6 +40,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = } const isOwner = row.userId === session.user.id; + const isTeamTemplate = row.teamId === teamId; return ( @@ -44,20 +51,25 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = Action - - + + Edit - {/* onDuplicateButtonClick(row.id)}> */} - setDuplicateDialogOpen(true)}> + setDuplicateDialogOpen(true)} + > Duplicate - setDeleteDialogOpen(true)}> + setDeleteDialogOpen(true)} + > Delete @@ -65,6 +77,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx index 0e8f822c2..309695c88 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -28,6 +28,9 @@ type TemplatesDataTableProps = { perPage: number; page: number; totalPages: number; + documentRootPath: string; + templateRootPath: string; + teamId?: number; }; export const TemplatesDataTable = ({ @@ -35,6 +38,9 @@ export const TemplatesDataTable = ({ perPage, page, totalPages, + documentRootPath, + templateRootPath, + teamId, }: TemplatesDataTableProps) => { const [isPending, startTransition] = useTransition(); const updateSearchParams = useUpdateSearchParams(); @@ -70,7 +76,7 @@ export const TemplatesDataTable = ({ duration: 5000, }); - router.push(`/documents/${id}`); + router.push(`${documentRootPath}/${id}`); } catch (err) { toast({ title: 'Error', @@ -131,7 +137,12 @@ export const TemplatesDataTable = ({ {!isRowLoading && } Use Template - + +
    ); }, diff --git a/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx index 9075f4677..b31ad2048 100644 --- a/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx @@ -35,20 +35,15 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD onOpenChange(false); }, - }); - - const onDeleteTemplate = async () => { - try { - await deleteTemplate({ id }); - } catch { + onError: () => { toast({ title: 'Something went wrong', description: 'This template could not be deleted at this time. Please try again.', variant: 'destructive', duration: 7500, }); - } - }; + }, + }); return ( !isLoading && onOpenChange(value)}> @@ -63,20 +58,18 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD -
    - + - -
    +
    diff --git a/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx index be743ff48..cdd3000c2 100644 --- a/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx @@ -14,12 +14,14 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; type DuplicateTemplateDialogProps = { id: number; + teamId?: number; open: boolean; onOpenChange: (_open: boolean) => void; }; export const DuplicateTemplateDialog = ({ id, + teamId, open, onOpenChange, }: DuplicateTemplateDialogProps) => { @@ -40,22 +42,15 @@ export const DuplicateTemplateDialog = ({ onOpenChange(false); }, + onError: () => { + toast({ + title: 'Error', + description: 'An error occurred while duplicating template.', + variant: 'destructive', + }); + }, }); - const onDuplicate = async () => { - try { - await duplicateTemplate({ - templateId: id, - }); - } catch (err) { - toast({ - title: 'Error', - description: 'An error occurred while duplicating template.', - variant: 'destructive', - }); - } - }; - return ( !isLoading && onOpenChange(value)}> @@ -66,20 +61,27 @@ export const DuplicateTemplateDialog = ({ -
    - + - -
    +
    diff --git a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx index a4aa9bce2..37d60f946 100644 --- a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx @@ -43,8 +43,14 @@ const ZCreateTemplateFormSchema = z.object({ type TCreateTemplateFormSchema = z.infer; -export const NewTemplateDialog = () => { +type NewTemplateDialogProps = { + teamId?: number; + templateRootPath: string; +}; + +export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialogProps) => { const router = useRouter(); + const { data: session } = useSession(); const { toast } = useToast(); @@ -99,6 +105,7 @@ export const NewTemplateDialog = () => { }); const { id } = await createTemplate({ + teamId, title: values.name ? values.name : file.name, templateDocumentDataId, }); @@ -112,7 +119,7 @@ export const NewTemplateDialog = () => { setShowNewTemplateDialog(false); - void router.push(`/templates/${id}`); + router.push(`${templateRootPath}/${id}`); } catch { toast({ title: 'Something went wrong', diff --git a/apps/web/src/app/(dashboard)/templates/page.tsx b/apps/web/src/app/(dashboard)/templates/page.tsx index d3dacd501..7c7bd4e4f 100644 --- a/apps/web/src/app/(dashboard)/templates/page.tsx +++ b/apps/web/src/app/(dashboard)/templates/page.tsx @@ -2,57 +2,17 @@ import React from 'react'; import type { Metadata } from 'next'; -import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; -import { getTemplates } from '@documenso/lib/server-only/template/get-templates'; - -import { TemplatesDataTable } from './data-table-templates'; -import { EmptyTemplateState } from './empty-state'; -import { NewTemplateDialog } from './new-template-dialog'; +import { TemplatesPageView } from './templates-page-view'; +import type { TemplatesPageViewProps } from './templates-page-view'; type TemplatesPageProps = { - searchParams?: { - page?: number; - perPage?: number; - }; + searchParams?: TemplatesPageViewProps['searchParams']; }; export const metadata: Metadata = { title: 'Templates', }; -export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) { - const { user } = await getRequiredServerComponentSession(); - const page = Number(searchParams.page) || 1; - const perPage = Number(searchParams.perPage) || 10; - - const { templates, totalPages } = await getTemplates({ - userId: user.id, - page: page, - perPage: perPage, - }); - - return ( -
    -
    -

    Templates

    - -
    - -
    -
    - -
    - {templates.length > 0 ? ( - - ) : ( - - )} -
    -
    - ); +export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) { + return ; } diff --git a/apps/web/src/app/(dashboard)/templates/templates-page-view.tsx b/apps/web/src/app/(dashboard)/templates/templates-page-view.tsx new file mode 100644 index 000000000..4736f4268 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/templates-page-view.tsx @@ -0,0 +1,73 @@ +import React from 'react'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { findTemplates } from '@documenso/lib/server-only/template/find-templates'; +import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; +import type { Team } from '@documenso/prisma/client'; +import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; + +import { TemplatesDataTable } from './data-table-templates'; +import { EmptyTemplateState } from './empty-state'; +import { NewTemplateDialog } from './new-template-dialog'; + +export type TemplatesPageViewProps = { + searchParams?: { + page?: number; + perPage?: number; + }; + team?: Team; +}; + +export const TemplatesPageView = async ({ searchParams = {}, team }: TemplatesPageViewProps) => { + const { user } = await getRequiredServerComponentSession(); + const page = Number(searchParams.page) || 1; + const perPage = Number(searchParams.perPage) || 10; + + const documentRootPath = formatDocumentsPath(team?.url); + const templateRootPath = formatTemplatesPath(team?.url); + + const { templates, totalPages } = await findTemplates({ + userId: user.id, + teamId: team?.id, + page: page, + perPage: perPage, + }); + + return ( +
    +
    +
    + {team && ( + + + {team.name.slice(0, 1)} + + + )} + +

    Templates

    +
    + +
    + +
    +
    + +
    + {templates.length > 0 ? ( + + ) : ( + + )} +
    +
    + ); +}; diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx index b7f610cff..26b1d7c91 100644 --- a/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx +++ b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx @@ -1,7 +1,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; -import DocumentPageComponent from '~/app/(dashboard)/documents/[id]/document-page-view'; +import { DocumentPageView } from '~/app/(dashboard)/documents/[id]/document-page-view'; export type DocumentPageProps = { params: { @@ -16,5 +16,5 @@ export default async function DocumentPage({ params }: DocumentPageProps) { const { user } = await getRequiredServerComponentSession(); const team = await getTeamByUrl({ userId: user.id, teamUrl }); - return ; + return ; } diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx index 952aeeeea..d3d5b5bee 100644 --- a/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx +++ b/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx @@ -2,7 +2,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get- import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import type { DocumentsPageViewProps } from '~/app/(dashboard)/documents/documents-page-view'; -import DocumentsPageView from '~/app/(dashboard)/documents/documents-page-view'; +import { DocumentsPageView } from '~/app/(dashboard)/documents/documents-page-view'; export type TeamsDocumentPageProps = { params: { diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/templates/[id]/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/templates/[id]/page.tsx new file mode 100644 index 000000000..3fe7cbf67 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/templates/[id]/page.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; + +import type { TemplatePageViewProps } from '~/app/(dashboard)/templates/[id]/template-page-view'; +import { TemplatePageView } from '~/app/(dashboard)/templates/[id]/template-page-view'; + +type TeamTemplatePageProps = { + params: TemplatePageViewProps['params'] & { + teamUrl: string; + }; +}; + +export default async function TeamTemplatePage({ params }: TeamTemplatePageProps) { + const { teamUrl } = params; + + const { user } = await getRequiredServerComponentSession(); + const team = await getTeamByUrl({ userId: user.id, teamUrl }); + + return ; +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/templates/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/templates/page.tsx new file mode 100644 index 000000000..6954d8e2d --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/templates/page.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; + +import type { TemplatesPageViewProps } from '~/app/(dashboard)/templates/templates-page-view'; +import { TemplatesPageView } from '~/app/(dashboard)/templates/templates-page-view'; + +type TeamTemplatesPageProps = { + searchParams?: TemplatesPageViewProps['searchParams']; + params: { + teamUrl: string; + }; +}; + +export default async function TeamTemplatesPage({ + searchParams = {}, + params, +}: TeamTemplatesPageProps) { + const { teamUrl } = params; + + const { user } = await getRequiredServerComponentSession(); + const team = await getTeamByUrl({ userId: user.id, teamUrl }); + + return ; +} diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx index 2b11c4be2..9eef1f4bd 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx @@ -52,24 +52,22 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { {...props} >
    - {navigationLinks - .filter(({ href }) => href !== '/templates' || rootHref === '') // Remove templates for team pages. - .map(({ href, label }) => ( - - {label} - - ))} + {navigationLinks.map(({ href, label }) => ( + + {label} + + ))}
    diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx index 35a05baf2..195716d64 100644 --- a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx +++ b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx @@ -6,7 +6,7 @@ import { usePathname } from 'next/navigation'; import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react'; import { signOut } from 'next-auth/react'; -import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams'; import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; @@ -71,6 +71,22 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp return TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role]; }; + /** + * Formats the redirect URL so we can switch between documents and templates page + * seemlessly between teams and personal accounts. + */ + const formatRedirectUrlOnSwitch = (teamUrl?: string) => { + const baseUrl = teamUrl ? `/t/${teamUrl}/` : '/'; + + const currentPathname = (pathname ?? '/').replace(TEAM_URL_REGEX, ''); + + if (currentPathname === '/templates') { + return `${baseUrl}templates`; + } + + return baseUrl; + }; + return ( @@ -100,7 +116,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp Personal - + ( - + text !== 'Templates' || href === '/templates'); // Filter out templates for teams. + ]; return ( diff --git a/packages/app-tests/e2e/templates/manage-templates.spec.ts b/packages/app-tests/e2e/templates/manage-templates.spec.ts new file mode 100644 index 000000000..53edc705d --- /dev/null +++ b/packages/app-tests/e2e/templates/manage-templates.spec.ts @@ -0,0 +1,205 @@ +import { expect, test } from '@playwright/test'; + +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; +import { seedTemplate } from '@documenso/prisma/seed/templates'; + +import { manualLogin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel' }); + +test('[TEMPLATES]: view templates', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + const teamMemberUser = team.members[1].user; + + // Should only be visible to the owner in personal templates. + await seedTemplate({ + title: 'Personal template', + userId: owner.id, + }); + + // Should be visible to team members. + await seedTemplate({ + title: 'Team template 1', + userId: owner.id, + teamId: team.id, + }); + + // Should be visible to team members. + await seedTemplate({ + title: 'Team template 2', + userId: teamMemberUser.id, + teamId: team.id, + }); + + await manualLogin({ + page, + email: owner.email, + redirectPath: '/templates', + }); + + // Owner should see both team templates. + await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`); + await expect(page.getByRole('main')).toContainText('Showing 2 results'); + + // Only should only see their personal template. + await page.goto(`${WEBAPP_BASE_URL}/templates`); + await expect(page.getByRole('main')).toContainText('Showing 1 result'); + + await unseedTeam(team.url); +}); + +test('[TEMPLATES]: delete template', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + const teamMemberUser = team.members[1].user; + + // Should only be visible to the owner in personal templates. + await seedTemplate({ + title: 'Personal template', + userId: owner.id, + }); + + // Should be visible to team members. + await seedTemplate({ + title: 'Team template 1', + userId: owner.id, + teamId: team.id, + }); + + // Should be visible to team members. + await seedTemplate({ + title: 'Team template 2', + userId: teamMemberUser.id, + teamId: team.id, + }); + + await manualLogin({ + page, + email: owner.email, + redirectPath: '/templates', + }); + + // Owner should be able to delete their personal template. + await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete' }).click(); + await expect(page.getByText('Template deleted').first()).toBeVisible(); + + // Team member should be able to delete all templates. + await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`); + + for (const template of ['Team template 1', 'Team template 2']) { + await page + .getByRole('row', { name: template }) + .getByRole('cell', { name: 'Use Template' }) + .getByRole('button') + .nth(1) + .click(); + + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete' }).click(); + await expect(page.getByText('Template deleted').first()).toBeVisible(); + } + + await unseedTeam(team.url); +}); + +test('[TEMPLATES]: duplicate template', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + const teamMemberUser = team.members[1].user; + + // Should only be visible to the owner in personal templates. + await seedTemplate({ + title: 'Personal template', + userId: owner.id, + }); + + // Should be visible to team members. + await seedTemplate({ + title: 'Team template 1', + userId: teamMemberUser.id, + teamId: team.id, + }); + + await manualLogin({ + page, + email: owner.email, + redirectPath: '/templates', + }); + + // Duplicate personal template. + await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click(); + await page.getByRole('menuitem', { name: 'Duplicate' }).click(); + await page.getByRole('button', { name: 'Duplicate' }).click(); + await expect(page.getByText('Template duplicated').first()).toBeVisible(); + await expect(page.getByRole('main')).toContainText('Showing 2 results'); + + await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`); + + // Duplicate team template. + await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click(); + await page.getByRole('menuitem', { name: 'Duplicate' }).click(); + await page.getByRole('button', { name: 'Duplicate' }).click(); + await expect(page.getByText('Template duplicated').first()).toBeVisible(); + await expect(page.getByRole('main')).toContainText('Showing 2 results'); + + await unseedTeam(team.url); +}); + +test('[TEMPLATES]: use template', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + const teamMemberUser = team.members[1].user; + + // Should only be visible to the owner in personal templates. + await seedTemplate({ + title: 'Personal template', + userId: owner.id, + }); + + // Should be visible to team members. + await seedTemplate({ + title: 'Team template 1', + userId: teamMemberUser.id, + teamId: team.id, + }); + + await manualLogin({ + page, + email: owner.email, + redirectPath: '/templates', + }); + + // Use personal template. + await page.getByRole('button', { name: 'Use Template' }).click(); + await page.waitForURL(/documents/); + await page.getByRole('main').getByRole('link', { name: 'Documents' }).click(); + await page.waitForURL('/documents'); + await expect(page.getByRole('main')).toContainText('Showing 1 result'); + + await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`); + + // Use team template. + await page.getByRole('button', { name: 'Use Template' }).click(); + await page.waitForURL(/\/t\/.+\/documents/); + await page.getByRole('main').getByRole('link', { name: 'Documents' }).click(); + await page.waitForURL(`/t/${team.url}/documents`); + await expect(page.getByRole('main')).toContainText('Showing 1 result'); + + await unseedTeam(team.url); +}); diff --git a/packages/lib/constants/teams.ts b/packages/lib/constants/teams.ts index 47705bb14..67f3ef16f 100644 --- a/packages/lib/constants/teams.ts +++ b/packages/lib/constants/teams.ts @@ -1,6 +1,7 @@ import { TeamMemberRole } from '@documenso/prisma/client'; export const TEAM_URL_ROOT_REGEX = new RegExp('^/t/[^/]+$'); +export const TEAM_URL_REGEX = new RegExp('^/t/[^/]+'); export const TEAM_MEMBER_ROLE_MAP: Record = { ADMIN: 'Admin', diff --git a/packages/lib/server-only/field/get-fields-for-template.ts b/packages/lib/server-only/field/get-fields-for-template.ts index c174d7eff..724ec75fb 100644 --- a/packages/lib/server-only/field/get-fields-for-template.ts +++ b/packages/lib/server-only/field/get-fields-for-template.ts @@ -10,7 +10,20 @@ export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForT where: { templateId, Template: { - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, }, orderBy: { diff --git a/packages/lib/server-only/field/set-fields-for-template.ts b/packages/lib/server-only/field/set-fields-for-template.ts index 9431666bf..2062e06bc 100644 --- a/packages/lib/server-only/field/set-fields-for-template.ts +++ b/packages/lib/server-only/field/set-fields-for-template.ts @@ -27,7 +27,20 @@ export const setFieldsForTemplate = async ({ const template = await prisma.template.findFirst({ where: { id: templateId, - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, }); diff --git a/packages/lib/server-only/recipient/get-recipients-for-template.ts b/packages/lib/server-only/recipient/get-recipients-for-template.ts index ab6f860eb..4b393353d 100644 --- a/packages/lib/server-only/recipient/get-recipients-for-template.ts +++ b/packages/lib/server-only/recipient/get-recipients-for-template.ts @@ -13,7 +13,20 @@ export const getRecipientsForTemplate = async ({ where: { templateId, Template: { - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, }, orderBy: { diff --git a/packages/lib/server-only/recipient/set-recipients-for-template.ts b/packages/lib/server-only/recipient/set-recipients-for-template.ts index c21c8cbf9..7c96bcf44 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-template.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-template.ts @@ -20,7 +20,20 @@ export const setRecipientsForTemplate = async ({ const template = await prisma.template.findFirst({ where: { id: templateId, - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, }); diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 1c23d8f85..c520d4ce1 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -11,7 +11,23 @@ export const createDocumentFromTemplate = async ({ userId, }: CreateDocumentFromTemplateOptions) => { const template = await prisma.template.findUnique({ - where: { id: templateId, userId }, + where: { + id: templateId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }, include: { Recipient: true, Field: true, @@ -34,6 +50,7 @@ export const createDocumentFromTemplate = async ({ const document = await prisma.document.create({ data: { userId, + teamId: template.teamId, title: template.title, documentDataId: documentData.id, Recipient: { diff --git a/packages/lib/server-only/template/create-template.ts b/packages/lib/server-only/template/create-template.ts index d00526a64..e51d69485 100644 --- a/packages/lib/server-only/template/create-template.ts +++ b/packages/lib/server-only/template/create-template.ts @@ -1,20 +1,36 @@ import { prisma } from '@documenso/prisma'; -import { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema'; +import type { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema'; export type CreateTemplateOptions = TCreateTemplateMutationSchema & { userId: number; + teamId?: number; }; export const createTemplate = async ({ title, userId, + teamId, templateDocumentDataId, }: CreateTemplateOptions) => { + if (teamId) { + await prisma.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + }); + } + return await prisma.template.create({ data: { title, userId, templateDocumentDataId, + teamId, }, }); }; diff --git a/packages/lib/server-only/template/delete-template.ts b/packages/lib/server-only/template/delete-template.ts index f693bcec0..c24cc1333 100644 --- a/packages/lib/server-only/template/delete-template.ts +++ b/packages/lib/server-only/template/delete-template.ts @@ -8,5 +8,23 @@ export type DeleteTemplateOptions = { }; export const deleteTemplate = async ({ id, userId }: DeleteTemplateOptions) => { - return await prisma.template.delete({ where: { id, userId } }); + return await prisma.template.delete({ + where: { + id, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }, + }); }; diff --git a/packages/lib/server-only/template/duplicate-template.ts b/packages/lib/server-only/template/duplicate-template.ts index 6078a1945..97b3f0a0b 100644 --- a/packages/lib/server-only/template/duplicate-template.ts +++ b/packages/lib/server-only/template/duplicate-template.ts @@ -1,14 +1,39 @@ import { nanoid } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@documenso/prisma/client'; import type { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema'; export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & { userId: number; }; -export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplateOptions) => { +export const duplicateTemplate = async ({ + templateId, + userId, + teamId, +}: DuplicateTemplateOptions) => { + let templateWhereFilter: Prisma.TemplateWhereUniqueInput = { + id: templateId, + userId, + teamId: null, + }; + + if (teamId !== undefined) { + templateWhereFilter = { + id: templateId, + teamId, + team: { + members: { + some: { + userId, + }, + }, + }, + }; + } + const template = await prisma.template.findUnique({ - where: { id: templateId, userId }, + where: templateWhereFilter, include: { Recipient: true, Field: true, @@ -31,6 +56,7 @@ export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplat const duplicatedTemplate = await prisma.template.create({ data: { userId, + teamId, title: template.title + ' (copy)', templateDocumentDataId: documentData.id, Recipient: { diff --git a/packages/lib/server-only/template/find-templates.ts b/packages/lib/server-only/template/find-templates.ts new file mode 100644 index 000000000..d453d28a0 --- /dev/null +++ b/packages/lib/server-only/template/find-templates.ts @@ -0,0 +1,56 @@ +import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@documenso/prisma/client'; + +export type FindTemplatesOptions = { + userId: number; + teamId?: number; + page: number; + perPage: number; +}; + +export const findTemplates = async ({ + userId, + teamId, + page = 1, + perPage = 10, +}: FindTemplatesOptions) => { + let whereFilter: Prisma.TemplateWhereInput = { + userId, + teamId: null, + }; + + if (teamId !== undefined) { + whereFilter = { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + }; + } + + const [templates, count] = await Promise.all([ + prisma.template.findMany({ + where: whereFilter, + include: { + templateDocumentData: true, + Field: true, + }, + skip: Math.max(page - 1, 0) * perPage, + orderBy: { + createdAt: 'desc', + }, + }), + prisma.template.count({ + where: whereFilter, + }), + ]); + + return { + templates, + totalPages: Math.ceil(count / perPage), + }; +}; diff --git a/packages/lib/server-only/template/get-template-by-id.ts b/packages/lib/server-only/template/get-template-by-id.ts index 56f959a9b..c4295c3c3 100644 --- a/packages/lib/server-only/template/get-template-by-id.ts +++ b/packages/lib/server-only/template/get-template-by-id.ts @@ -1,4 +1,5 @@ import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@documenso/prisma/client'; export interface GetTemplateByIdOptions { id: number; @@ -6,11 +7,26 @@ export interface GetTemplateByIdOptions { } export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) => { + const whereFilter: Prisma.TemplateWhereInput = { + id, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }; + return await prisma.template.findFirstOrThrow({ - where: { - id, - userId, - }, + where: whereFilter, include: { templateDocumentData: true, }, diff --git a/packages/lib/server-only/template/get-templates.ts b/packages/lib/server-only/template/get-templates.ts deleted file mode 100644 index 5f802d278..000000000 --- a/packages/lib/server-only/template/get-templates.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { prisma } from '@documenso/prisma'; - -export type GetTemplatesOptions = { - userId: number; - page: number; - perPage: number; -}; - -export const getTemplates = async ({ userId, page = 1, perPage = 10 }: GetTemplatesOptions) => { - const [templates, count] = await Promise.all([ - prisma.template.findMany({ - where: { - userId, - }, - include: { - templateDocumentData: true, - Field: true, - }, - skip: Math.max(page - 1, 0) * perPage, - orderBy: { - createdAt: 'desc', - }, - }), - prisma.template.count({ - where: { - userId, - }, - }), - ]); - - return { - templates, - totalPages: Math.ceil(count / perPage), - }; -}; diff --git a/packages/lib/utils/teams.ts b/packages/lib/utils/teams.ts index eb9be2c2b..c6dfd27fd 100644 --- a/packages/lib/utils/teams.ts +++ b/packages/lib/utils/teams.ts @@ -12,6 +12,10 @@ export const formatDocumentsPath = (teamUrl?: string) => { return teamUrl ? `/t/${teamUrl}/documents` : '/documents'; }; +export const formatTemplatesPath = (teamUrl?: string) => { + return teamUrl ? `/t/${teamUrl}/templates` : '/templates'; +}; + /** * Determines whether a team member can execute a given action. * diff --git a/packages/prisma/migrations/20240206051948_add_teams_templates/migration.sql b/packages/prisma/migrations/20240206051948_add_teams_templates/migration.sql new file mode 100644 index 000000000..3a79168bf --- /dev/null +++ b/packages/prisma/migrations/20240206051948_add_teams_templates/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Template" ADD COLUMN "teamId" INTEGER; + +-- AddForeignKey +ALTER TABLE "Template" ADD CONSTRAINT "Template_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 79dcdf6aa..fc128efc1 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -334,7 +334,8 @@ model Team { owner User @relation(fields: [ownerUserId], references: [id]) subscription Subscription? - document Document[] + document Document[] + templates Template[] } model TeamPending { @@ -415,10 +416,12 @@ model Template { type TemplateType @default(PRIVATE) title String userId Int + teamId Int? templateDocumentDataId String createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) User User @relation(fields: [userId], references: [id], onDelete: Cascade) Recipient Recipient[] diff --git a/packages/prisma/seed/templates.ts b/packages/prisma/seed/templates.ts new file mode 100644 index 000000000..7f1b2f8e9 --- /dev/null +++ b/packages/prisma/seed/templates.ts @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { prisma } from '..'; +import { DocumentDataType } from '../client'; + +const examplePdf = fs + .readFileSync(path.join(__dirname, '../../../assets/example.pdf')) + .toString('base64'); + +type SeedTemplateOptions = { + title?: string; + userId: number; + teamId?: number; +}; + +export const seedTemplate = async (options: SeedTemplateOptions) => { + const { title = 'Untitled', userId, teamId } = options; + + const documentData = await prisma.documentData.create({ + data: { + type: DocumentDataType.BYTES_64, + data: examplePdf, + initialData: examplePdf, + }, + }); + + return await prisma.template.create({ + data: { + title, + templateDocumentDataId: documentData.id, + userId: userId, + teamId, + }, + }); +}; diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index 07cdcd347..5ae3cbe4b 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -39,7 +39,7 @@ export const fieldRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', - message: 'We were unable to sign this field. Please try again later.', + message: 'We were unable to set this field. Please try again later.', }); } }), diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index 1ada3d0d3..9553a8aae 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -33,7 +33,7 @@ export const recipientRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', - message: 'We were unable to sign this field. Please try again later.', + message: 'We were unable to set this field. Please try again later.', }); } }), @@ -58,7 +58,7 @@ export const recipientRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', - message: 'We were unable to sign this field. Please try again later.', + message: 'We were unable to set this field. Please try again later.', }); } }), diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 28e919e92..7417e7d00 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -19,11 +19,12 @@ export const templateRouter = router({ .input(ZCreateTemplateMutationSchema) .mutation(async ({ input, ctx }) => { try { - const { title, templateDocumentDataId } = input; + const { teamId, title, templateDocumentDataId } = input; return await createTemplate({ - title, userId: ctx.user.id, + teamId, + title, templateDocumentDataId, }); } catch (err) { @@ -64,11 +65,12 @@ export const templateRouter = router({ .input(ZDuplicateTemplateMutationSchema) .mutation(async ({ input, ctx }) => { try { - const { templateId } = input; + const { teamId, templateId } = input; return await duplicateTemplate({ - templateId, userId: ctx.user.id, + teamId, + templateId, }); } catch (err) { console.error(err); @@ -88,7 +90,7 @@ export const templateRouter = router({ const userId = ctx.user.id; - return await deleteTemplate({ id, userId }); + return await deleteTemplate({ userId, id }); } catch (err) { console.error(err); diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index bc7161f74..3d87d4b4f 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; export const ZCreateTemplateMutationSchema = z.object({ - title: z.string().min(1), + title: z.string().min(1).trim(), + teamId: z.number().optional(), templateDocumentDataId: z.string().min(1), }); @@ -11,6 +12,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({ export const ZDuplicateTemplateMutationSchema = z.object({ templateId: z.number(), + teamId: z.number().optional(), }); export const ZDeleteTemplateMutationSchema = z.object({ From 8641884515a9799e1a57c0c555bf720644ad4cc7 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 9 Feb 2024 12:37:17 +1100 Subject: [PATCH 156/156] fix: recipients with CC role not being editable (#918) ## Description Fixed issue where setting a recipient role as CC will prevent any further changes as it is considered as "sent" and "signed". ## Other changes - Prevent editing document after completed - Removed CC and Viewers from the field recipient list since they will never be filled - Minor UI issues ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have added/updated tests that prove the effectiveness of these changes. - [X] I have followed the project's coding style guidelines. --- .../documents/data-table-action-dropdown.tsx | 2 +- .../app/(signing)/sign/[token]/name-field.tsx | 2 +- .../field/set-fields-for-document.ts | 4 ++++ .../recipient/set-recipients-for-document.ts | 10 ++++++++-- .../ui/primitives/document-flow/add-fields.tsx | 18 +++++++++++------- .../primitives/document-flow/add-signers.tsx | 5 ++++- 6 files changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index b7d2cf452..2bd888bb0 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -114,7 +114,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr Action - {recipient?.role !== RecipientRole.CC && ( + {recipient && recipient?.role !== RecipientRole.CC && ( {recipient?.role === RecipientRole.VIEWER && ( diff --git a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx index 6e661e77a..44de2fc36 100644 --- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx @@ -118,7 +118,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { ({recipient.email}) -
    +
    ({ ...recipient, email: recipient.email.toLowerCase(), @@ -77,8 +81,9 @@ export const setRecipientsForDocument = async ({ }) .filter((recipient) => { return ( - recipient._persisted?.sendStatus !== SendStatus.SENT && - recipient._persisted?.signingStatus !== SigningStatus.SIGNED + recipient._persisted?.role === RecipientRole.CC || + (recipient._persisted?.sendStatus !== SendStatus.SENT && + recipient._persisted?.signingStatus !== SigningStatus.SIGNED) ); }); @@ -96,6 +101,7 @@ export const setRecipientsForDocument = async ({ email: recipient.email, role: recipient.role, documentId, + sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, }, diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 9c8db7918..be7d451f7 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -304,6 +304,13 @@ export const AddFieldsFormPartial = ({ return recipientsByRole; }, [recipients]); + const recipientsByRoleToDisplay = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter( + ([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER, + ); + }, [recipientsByRole]); + return ( <> - {Object.entries(recipientsByRole).map(([role, recipients], roleIndex) => ( + {recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => (
    - { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - RECIPIENT_ROLES_DESCRIPTION[role as RecipientRole].roleName - } + {`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
    {recipients.length === 0 && ( @@ -403,7 +407,7 @@ export const AddFieldsFormPartial = ({ {recipients.map((recipient) => ( { @@ -413,7 +417,7 @@ export const AddFieldsFormPartial = ({ > {recipient.name && ( diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 26aedcae7..b1341c6ca 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -105,7 +105,10 @@ export const AddSignersFormPartial = ({ } return recipients.some( - (recipient) => recipient.id === id && recipient.sendStatus === SendStatus.SENT, + (recipient) => + recipient.id === id && + recipient.sendStatus === SendStatus.SENT && + recipient.role !== RecipientRole.CC, ); };