diff --git a/.env.example b/.env.example index 693f1d8a5..87ad09a63 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,12 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY= # OPTIONAL: Displays the maximum document upload limit to the user in MBs NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5 +# [[EE ONLY]] +# OPTIONAL: The AWS SES API KEY to verify email domains with. +NEXT_PRIVATE_SES_ACCESS_KEY_ID= +NEXT_PRIVATE_SES_SECRET_ACCESS_KEY= +NEXT_PRIVATE_SES_REGION= + # [[STRIPE]] NEXT_PRIVATE_STRIPE_API_KEY= NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= diff --git a/apps/documentation/pages/users/_meta.json b/apps/documentation/pages/users/_meta.json index 06334a4d8..3726fbd08 100644 --- a/apps/documentation/pages/users/_meta.json +++ b/apps/documentation/pages/users/_meta.json @@ -11,6 +11,7 @@ "documents": "Documents", "templates": "Templates", "branding": "Branding", + "email-domains": "Email Domains", "direct-links": "Direct Signing Links", "-- Legal Overview": { "type": "separator", diff --git a/apps/documentation/pages/users/branding.mdx b/apps/documentation/pages/users/branding.mdx index 8fb9412cb..68fb16cf5 100644 --- a/apps/documentation/pages/users/branding.mdx +++ b/apps/documentation/pages/users/branding.mdx @@ -17,7 +17,7 @@ Branding preferences can be set on either the organisation or team level. By default, teams inherit the preferences from the organisation. You can override these preferences on the team level at any time. -To access the preferences, navigate to either the organisation or teams settings page and click the **Preferences** tab. This page contains both the preferences for documents and branding, the branding section is located at the bottom of the page. +To access the preferences, navigate to either the organisation or teams settings page and click the **Branding** tab under the **Preferences** section. ![A screenshot of the organisation's document preferences page](/organisations/organisation-branding.webp) diff --git a/apps/documentation/pages/users/documents/_meta.json b/apps/documentation/pages/users/documents/_meta.json index 0705deb93..9312fe12c 100644 --- a/apps/documentation/pages/users/documents/_meta.json +++ b/apps/documentation/pages/users/documents/_meta.json @@ -1,5 +1,7 @@ { "sending-documents": "Sending Documents", "document-preferences": "Document Preferences", - "document-visibility": "Document Visibility" + "document-visibility": "Document Visibility", + "fields": "Document Fields", + "email-preferences": "Email Preferences" } \ No newline at end of file diff --git a/apps/documentation/pages/users/documents/document-preferences.mdx b/apps/documentation/pages/users/documents/document-preferences.mdx index 18b26f44b..0b1e3093b 100644 --- a/apps/documentation/pages/users/documents/document-preferences.mdx +++ b/apps/documentation/pages/users/documents/document-preferences.mdx @@ -19,12 +19,14 @@ Document preferences can be set on either the organisation or team level. By default, teams inherit the preferences from the organisation. You can override these preferences on the team level at any time. -To access the preferences, navigate to either the organisation or teams settings page and click the **Preferences** tab. +To access the preferences, navigate to either the organisation or teams settings page and click the **Document** tab under the **Preferences** section. ![A screenshot of the organisation's document preferences page](/organisations/organisation-document-preferences.webp) - **Document Visibility** - Set the default visibility of the documents created by team members. Learn more about [document visibility](/users/documents/document-visibility). - **Default Document Language** - This setting allows you to set the default language for the documents uploaded in the organisation. The default language is used as the default language in the email communications with the document recipients. +- **Default Time Zone** - The timezone to use for date fields and signing the document. +- **Default Date Format** - The date format to use for date fields and signing the document. - **Signature Settings** - Controls what signatures are allowed to be used when signing the documents. - **Sender Details** - Set whether the sender's name should be included in the emails sent by the team. See more below [sender details](/users/documents/document-preferences#sender-details). - **Include the Signing Certificate** - This setting controls whether the signing certificate should be included in the signed documents. If enabled, the signing certificate is included in the signed documents. If disabled, the signing certificate is not included in the signed documents. Regardless of this setting, the signing certificate is always available in the document's audit log page. diff --git a/apps/documentation/pages/users/documents/email-preferences.mdx b/apps/documentation/pages/users/documents/email-preferences.mdx new file mode 100644 index 000000000..95cc42456 --- /dev/null +++ b/apps/documentation/pages/users/documents/email-preferences.mdx @@ -0,0 +1,26 @@ +--- +title: Email Preferences +description: Learn how to set the email preferences for your team account. +--- + +import Image from 'next/image'; + +import { Callout, Steps } from 'nextra/components'; + +# Email Preferences + +Email preferences allow you to set the default settings when emailing documents to your recipients. + +## Preferences + +Email preferences can be set on either the organisation or team level. + +By default, teams inherit the preferences from the organisation. You can override these preferences on the team level at any time. + +To access the preferences, navigate to either the organisation or teams settings page and click the **Email** tab under the **Preferences** section. + +![A screenshot of the organisation's email preferences page](/organisations/organisation-email-preferences.webp) + +- **Default Email** - Use a custom email address when sending documents to your recipients. See [email domains](/users/email-domains) for more information. +- **Reply To** - The email address that will be used in the "Reply To" field in emails +- **Email Settings** - Which emails to send to recipients during document signing diff --git a/apps/documentation/pages/users/email-domains.mdx b/apps/documentation/pages/users/email-domains.mdx new file mode 100644 index 000000000..c30005ae5 --- /dev/null +++ b/apps/documentation/pages/users/email-domains.mdx @@ -0,0 +1,112 @@ +import { Callout, Steps } from 'nextra/components'; + +# Email Domains + +Email Domains allow you to send emails to recipients from your own domain instead of the default Documenso email address. + + + **Platform and Enterprise Only**: Email Domains is only available to Platform and Enterprise + customers. + + +## Creating Email Domains + +Before setting up email domains, ensure you have: + +- A Platform or Enterprise subscription +- Access to your domain's DNS settings +- Access to your Documenso organisation as an admin or manager + + + +### Access Email Domains Settings + +Navigate to your Organisation email domains settings page and click the "Add Email Domain" button. + +![Email Domains settings page](/email-domains/email-domains-settings-page.webp) + +### Configure DNS Records + +After adding your domain, Documenso will provide you with the following required DNS records that need to be configured on your domain: + +- **SPF Record**: Specifies which servers are authorized to send emails from your domain +- **DKIM Record**: Provides email authentication and prevents tampering + +![DNS configuration instructions](/email-domains/email-domains-record.webp) + + + If you already have an SPF record configured, you will need to update it to include Amazon SES as + an authorized server instead of creating a new record. + + +Configure these records in your domain's DNS settings according to their specific instructions. + +### Verify Domain Configuration + +Once you've added the DNS records, return to the Documenso email domains settings page and click the "Verify" button. +This will trigger a verification process which will check if the DNS records are properly configured. If successful, the domain will be marked as "Active". + +![Domain verification process](/email-domains/email-domain-sync.webp) + + + Please note that it may take up to 48 hours for the DNS records to propagate. + + + + +## Creating Emails + +Once your email domain has been configured, you can create multiple email addresses which your members can use when sending documents to recipients. + + + +### Select the Email Domain You Want to Use + +Navigate to the email domains settings page and click "Manage" on the domain you want to use. + +![Email Domains settings page](/email-domains/email-domains-manage.webp) + +### Add a New Email + +Click on the "Add Email" button to begin the setup process. + +![Create email](/email-domains/email-domains-manage-create-email.webp) + +### Use Email + +Once you have added an email, you can configure it to be the default email on either the: + +- Organisation email preferences page +- Team email preferences page + +When a draft document is created, it will inherit the email configured on the team if set, otherwise it will inherit the email configured in the organisation. + +You can also configure the email address directly on the document to override the default email if required. + + + +## Notes + +- If you change the default email, it will not retroactively update any existing documents with the old default email. +- If the email domain becomes invalid, all emails using that domain will fail to send. + +## Troubleshooting + +### Common Issues + +**DNS Verification Fails** + +- Double-check all DNS record values +- Ensure records are added to the correct domain +- Wait for DNS propagation (up to 48 hours) + +**Emails Not Delivering** + +- Check domain reputation and blacklist status +- Verify SPF, DKIM, and DMARC records +- Review bounce and spam reports + + + For additional support with Email Domains configuration, contact our support team at + support@documenso.com. + diff --git a/apps/documentation/public/email-domains/create-domain-dialog.webp b/apps/documentation/public/email-domains/create-domain-dialog.webp new file mode 100644 index 000000000..0e9c3bc70 Binary files /dev/null and b/apps/documentation/public/email-domains/create-domain-dialog.webp differ diff --git a/apps/documentation/public/email-domains/email-domain-sync.webp b/apps/documentation/public/email-domains/email-domain-sync.webp new file mode 100644 index 000000000..cd62cce21 Binary files /dev/null and b/apps/documentation/public/email-domains/email-domain-sync.webp differ diff --git a/apps/documentation/public/email-domains/email-domains-manage-create-email.webp b/apps/documentation/public/email-domains/email-domains-manage-create-email.webp new file mode 100644 index 000000000..d3aafb982 Binary files /dev/null and b/apps/documentation/public/email-domains/email-domains-manage-create-email.webp differ diff --git a/apps/documentation/public/email-domains/email-domains-manage.webp b/apps/documentation/public/email-domains/email-domains-manage.webp new file mode 100644 index 000000000..ec0dc9269 Binary files /dev/null and b/apps/documentation/public/email-domains/email-domains-manage.webp differ diff --git a/apps/documentation/public/email-domains/email-domains-record.webp b/apps/documentation/public/email-domains/email-domains-record.webp new file mode 100644 index 000000000..f69696799 Binary files /dev/null and b/apps/documentation/public/email-domains/email-domains-record.webp differ diff --git a/apps/documentation/public/email-domains/email-domains-settings-page.webp b/apps/documentation/public/email-domains/email-domains-settings-page.webp new file mode 100644 index 000000000..512c436a0 Binary files /dev/null and b/apps/documentation/public/email-domains/email-domains-settings-page.webp differ diff --git a/apps/documentation/public/organisations/organisation-branding.webp b/apps/documentation/public/organisations/organisation-branding.webp index dee0d7c32..e28df3530 100644 Binary files a/apps/documentation/public/organisations/organisation-branding.webp and b/apps/documentation/public/organisations/organisation-branding.webp differ diff --git a/apps/documentation/public/organisations/organisation-document-preferences.webp b/apps/documentation/public/organisations/organisation-document-preferences.webp index 4aa01b4a5..6f2548590 100644 Binary files a/apps/documentation/public/organisations/organisation-document-preferences.webp and b/apps/documentation/public/organisations/organisation-document-preferences.webp differ diff --git a/apps/documentation/public/organisations/organisation-email-preferences.webp b/apps/documentation/public/organisations/organisation-email-preferences.webp new file mode 100644 index 000000000..a66e64316 Binary files /dev/null and b/apps/documentation/public/organisations/organisation-email-preferences.webp differ diff --git a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx index eec04ff2e..ba211538b 100644 --- a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx @@ -87,7 +87,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea const { mutateAsync: createOrganisation } = trpc.organisation.create.useMutation(); - const { data: plansData } = trpc.billing.plans.get.useQuery(undefined, { + const { data: plansData } = trpc.enterprise.billing.plans.get.useQuery(undefined, { enabled: IS_BILLING_ENABLED(), }); diff --git a/apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx new file mode 100644 index 000000000..c05fadd8c --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx @@ -0,0 +1,243 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateOrganisationEmailRequestSchema } from '@documenso/trpc/server/enterprise-router/create-organisation-email.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + 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'; + +type EmailDomain = { + id: string; + domain: string; + status: string; +}; + +export type OrganisationEmailCreateDialogProps = { + trigger?: React.ReactNode; + emailDomain: EmailDomain; +} & Omit; + +const ZCreateOrganisationEmailFormSchema = ZCreateOrganisationEmailRequestSchema.pick({ + emailName: true, + email: true, + // replyTo: true, +}); + +type TCreateOrganisationEmailFormSchema = z.infer; + +export const OrganisationEmailCreateDialog = ({ + trigger, + emailDomain, + ...props +}: OrganisationEmailCreateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(ZCreateOrganisationEmailFormSchema), + defaultValues: { + emailName: '', + email: '', + // replyTo: '', + }, + }); + + const { mutateAsync: createOrganisationEmail, isPending } = + trpc.enterprise.organisation.email.create.useMutation(); + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + const onFormSubmit = async (data: TCreateOrganisationEmailFormSchema) => { + try { + await createOrganisationEmail({ + emailDomainId: emailDomain.id, + ...data, + }); + + toast({ + title: t`Email Created`, + description: t`The organisation email has been created successfully.`, + }); + + setOpen(false); + } catch (err) { + const error = AppError.parseError(err); + + console.error(error); + + if (error.code === AppErrorCode.ALREADY_EXISTS) { + toast({ + title: t`Email already exists`, + description: t`An email with this address already exists.`, + variant: 'destructive', + }); + } else { + toast({ + title: t`An error occurred`, + description: t`We encountered an error while creating the email. Please try again later.`, + variant: 'destructive', + }); + } + } + }; + + return ( + !isPending && setOpen(value)}> + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + + Add Organisation Email + + + + Create a new email address for your organisation using the domain{' '} + {emailDomain.domain}. + + + + +
+ +
+ ( + + + Display Name + + + + + + + The display name for this email address + + + )} + /> + + ( + + + Email Address + + +
+ { + field.onChange(e.target.value + '@' + emailDomain.domain); + }} + placeholder="support" + /> +
+ @{emailDomain.domain} +
+
+
+ + {!form.formState.errors.email && ( + + {field.value ? ( + field.value + ) : ( + + The part before the @ symbol (e.g., "support" for support@ + {emailDomain.domain}) + + )} + + )} +
+ )} + /> + + {/* ( + + + Reply-To Email + + + + + + + + Optional no-reply email address attached to emails. Leave blank to default + to the organisation settings reply-to email. + + + + )} + /> */} + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx new file mode 100644 index 000000000..5f48ce9f1 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; + +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +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 { useToast } from '@documenso/ui/primitives/use-toast'; + +export type OrganisationEmailDeleteDialogProps = { + emailId: string; + email: string; + trigger?: React.ReactNode; +}; + +export const OrganisationEmailDeleteDialog = ({ + trigger, + emailId, + email, +}: OrganisationEmailDeleteDialogProps) => { + const [open, setOpen] = useState(false); + + const { t } = useLingui(); + const { toast } = useToast(); + + const organisation = useCurrentOrganisation(); + + const { mutateAsync: deleteEmail, isPending: isDeleting } = + trpc.enterprise.organisation.email.delete.useMutation({ + onSuccess: () => { + toast({ + title: t`Success`, + description: t`You have successfully removed this email from the organisation.`, + duration: 5000, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: t`An unknown error occurred`, + description: t`We encountered an unknown error while attempting to remove this email. Please try again later.`, + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + !isDeleting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to remove the following email from{' '} + {organisation.name}. + + + + + + {email} + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx new file mode 100644 index 000000000..d51967b1d --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx @@ -0,0 +1,199 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateOrganisationEmailDomainRequestSchema } from '@documenso/trpc/server/enterprise-router/create-organisation-email-domain.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + 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'; + +import { OrganisationEmailDomainRecordContent } from './organisation-email-domain-records-dialog'; + +export type OrganisationEmailCreateDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +const ZCreateOrganisationEmailDomainFormSchema = ZCreateOrganisationEmailDomainRequestSchema.pick({ + domain: true, +}); + +type TCreateOrganisationEmailDomainFormSchema = z.infer< + typeof ZCreateOrganisationEmailDomainFormSchema +>; + +type DomainRecord = { + name: string; + value: string; + type: string; +}; + +export const OrganisationEmailDomainCreateDialog = ({ + trigger, + ...props +}: OrganisationEmailCreateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + const organisation = useCurrentOrganisation(); + + const [open, setOpen] = useState(false); + const [step, setStep] = useState<'domain' | 'verification'>('domain'); + const [recordsToAdd, setRecordsToAdd] = useState([]); + + const form = useForm({ + resolver: zodResolver(ZCreateOrganisationEmailDomainFormSchema), + defaultValues: { + domain: '', + }, + }); + + const { mutateAsync: createOrganisationEmail } = + trpc.enterprise.organisation.emailDomain.create.useMutation(); + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + form.reset(); + setStep('domain'); + } + }, [open, form]); + + const onFormSubmit = async ({ domain }: TCreateOrganisationEmailDomainFormSchema) => { + try { + const { records } = await createOrganisationEmail({ + domain, + organisationId: organisation.id, + }); + + setRecordsToAdd(records); + setStep('verification'); + + toast({ + title: t`Domain Added`, + description: t`DKIM records generated. Please add the DNS records to verify your domain.`, + }); + } catch (err) { + const error = AppError.parseError(err); + console.error(error); + + if (error.code === AppErrorCode.ALREADY_EXISTS) { + toast({ + title: t`Domain already in use`, + description: t`Please try a different domain.`, + variant: 'destructive', + duration: 10000, + }); + } else { + toast({ + title: t`An unknown error occurred`, + description: t`We encountered an unknown error while attempting to add your domain. Please try again later.`, + variant: 'destructive', + }); + } + } + }; + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + {step === 'domain' ? ( + + + + Add Custom Email Domain + + + + Add a custom domain to send emails on behalf of your organisation. We'll generate + DKIM records that you need to add to your DNS provider. + + + + +
+ +
+ ( + + + Domain Name + + + + + + + + Enter the domain you want to use for sending emails (without http:// or + www) + + + + )} + /> + + + + + + +
+
+ +
+ ) : ( + + )} +
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx new file mode 100644 index 000000000..0fb7eedd2 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +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 OrganisationEmailDomainDeleteDialogProps = { + emailDomainId: string; + emailDomain: string; + trigger?: React.ReactNode; +}; + +export const OrganisationEmailDomainDeleteDialog = ({ + trigger, + emailDomainId, + emailDomain, +}: OrganisationEmailDomainDeleteDialogProps) => { + const [open, setOpen] = useState(false); + + const { t } = useLingui(); + const { toast } = useToast(); + + const organisation = useCurrentOrganisation(); + + const deleteMessage = t`delete ${emailDomain}`; + + const ZDeleteEmailDomainFormSchema = z.object({ + confirmText: z.literal(deleteMessage, { + errorMap: () => ({ message: t`You must type '${deleteMessage}' to confirm` }), + }), + }); + + const form = useForm>({ + resolver: zodResolver(ZDeleteEmailDomainFormSchema), + defaultValues: { + confirmText: '', + }, + }); + + const { mutateAsync: deleteEmailDomain, isPending: isDeleting } = + trpc.enterprise.organisation.emailDomain.delete.useMutation({ + onSuccess: () => { + toast({ + title: t`Success`, + description: t`You have successfully removed this email domain from the organisation.`, + duration: 5000, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: t`An unknown error occurred`, + description: t`We encountered an unknown error while attempting to remove this email domain. Please try again later.`, + variant: 'destructive', + duration: 10000, + }); + }, + }); + + const onFormSubmit = async () => { + await deleteEmailDomain({ + emailDomainId, + }); + }; + + return ( + !isDeleting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to remove the email domain{' '} + {emailDomain} from{' '} + {organisation.name}. All emails associated with + this domain will be deleted. + + + + +
+ +
+ ( + + + + Confirm by typing{' '} + + {deleteMessage} + + + + + + + + + )} + /> + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx b/apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx new file mode 100644 index 000000000..ce91f3d52 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx @@ -0,0 +1,139 @@ +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; + +import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type OrganisationEmailDomainRecordsDialogProps = { + trigger: React.ReactNode; + records: DomainRecord[]; +} & Omit; + +type DomainRecord = { + name: string; + value: string; + type: string; +}; + +export const OrganisationEmailDomainRecordsDialog = ({ + trigger, + records, + ...props +}: OrganisationEmailDomainRecordsDialogProps) => { + return ( + + e.stopPropagation()} asChild={true}> + {trigger} + + + + + ); +}; + +export const OrganisationEmailDomainRecordContent = ({ records }: { records: DomainRecord[] }) => { + const { t } = useLingui(); + const { toast } = useToast(); + + return ( + + + + Verify Domain + + + Add these DNS records to verify your domain ownership + + + +
+
+ {records.map((record) => ( +
+
+ + +
+ +
+ toast({ title: t`Copied to clipboard` })} + /> +
+
+
+ +
+ + +
+ +
+ toast({ title: t`Copied to clipboard` })} + /> +
+
+
+ +
+ + +
+ +
+ toast({ title: t`Copied to clipboard` })} + /> +
+
+
+
+ ))} +
+ + + + + Once you update your DNS records, it may take up to 48 hours for it to be propogated. + Once the DNS propagation is complete you will need to come back and press the "Sync" + domains button + + + + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-email-update-dialog.tsx b/apps/remix/app/components/dialogs/organisation-email-update-dialog.tsx new file mode 100644 index 000000000..4bca18d8d --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-email-update-dialog.tsx @@ -0,0 +1,184 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types'; +import { ZUpdateOrganisationEmailRequestSchema } from '@documenso/trpc/server/enterprise-router/update-organisation-email.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + 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 OrganisationEmailUpdateDialogProps = { + trigger: React.ReactNode; + organisationEmail: TGetOrganisationEmailDomainResponse['emails'][number]; +} & Omit; + +const ZUpdateOrganisationEmailFormSchema = ZUpdateOrganisationEmailRequestSchema.pick({ + emailName: true, + // replyTo: true, +}); + +type ZUpdateOrganisationEmailSchema = z.infer; + +export const OrganisationEmailUpdateDialog = ({ + trigger, + organisationEmail, + ...props +}: OrganisationEmailUpdateDialogProps) => { + const [open, setOpen] = useState(false); + + const { t } = useLingui(); + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZUpdateOrganisationEmailFormSchema), + defaultValues: { + emailName: organisationEmail.emailName, + // replyTo: organisationEmail.replyTo ?? undefined, + }, + }); + + const { mutateAsync: updateOrganisationEmail, isPending } = + trpc.enterprise.organisation.email.update.useMutation(); + + const onFormSubmit = async ({ emailName }: ZUpdateOrganisationEmailSchema) => { + try { + await updateOrganisationEmail({ + emailId: organisationEmail.id, + emailName, + // replyTo, + }); + + toast({ + title: t`Success`, + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: t`An unknown error occurred`, + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + return; + } + + form.reset({ + emailName: organisationEmail.emailName, + // replyTo: organisationEmail.replyTo ?? undefined, + }); + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger} + + + + + + Update email + + + + + You are currently updating{' '} + {organisationEmail.email} + + + + +
+ +
+ ( + + + Display Name + + + + + + + The display name for this email address + + + )} + /> + + {/* ( + + + Reply-To Email + + + + + + + + Optional no-reply email address attached to emails. Leave blank to default + to the organisation settings reply-to email. + + + + )} + /> */} + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/embed/embed-document-fields.tsx b/apps/remix/app/components/embed/embed-document-fields.tsx index 0cb53f17c..561fdf4cb 100644 --- a/apps/remix/app/components/embed/embed-document-fields.tsx +++ b/apps/remix/app/components/embed/embed-document-fields.tsx @@ -32,7 +32,14 @@ import { DocumentSigningTextField } from '~/components/general/document-signing/ export type EmbedDocumentFieldsProps = { fields: Field[]; - metadata?: DocumentMeta | TemplateMeta | null; + metadata?: Pick< + DocumentMeta | TemplateMeta, + | 'timezone' + | 'dateFormat' + | 'typedSignatureEnabled' + | 'uploadSignatureEnabled' + | 'drawSignatureEnabled' + > | null; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; diff --git a/apps/remix/app/components/forms/document-preferences-form.tsx b/apps/remix/app/components/forms/document-preferences-form.tsx index ac91ee13e..eb91a4b84 100644 --- a/apps/remix/app/components/forms/document-preferences-form.tsx +++ b/apps/remix/app/components/forms/document-preferences-form.tsx @@ -8,17 +8,24 @@ import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { DATE_FORMATS } from '@documenso/lib/constants/date-formats'; import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document'; import { SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGE_CODES, isValidLanguageCode, } from '@documenso/lib/constants/i18n'; +import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { isPersonalLayout } from '@documenso/lib/utils/organisations'; import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams'; +import { + type TDocumentMetaDateFormat, + ZDocumentMetaTimezoneSchema, +} from '@documenso/trpc/server/document-router/schema'; import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip'; import { Alert } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; +import { Combobox } from '@documenso/ui/primitives/combobox'; import { Form, FormControl, @@ -44,6 +51,8 @@ import { export type TDocumentPreferencesFormSchema = { documentVisibility: DocumentVisibility | null; documentLanguage: (typeof SUPPORTED_LANGUAGE_CODES)[number] | null; + documentTimezone: string | null; + documentDateFormat: TDocumentMetaDateFormat | null; includeSenderDetails: boolean | null; includeSigningCertificate: boolean | null; signatureTypes: DocumentSignatureType[]; @@ -53,6 +62,8 @@ type SettingsSubset = Pick< TeamGlobalSettings, | 'documentVisibility' | 'documentLanguage' + | 'documentTimezone' + | 'documentDateFormat' | 'includeSenderDetails' | 'includeSigningCertificate' | 'typedSignatureEnabled' @@ -81,6 +92,8 @@ export const DocumentPreferencesForm = ({ const ZDocumentPreferencesFormSchema = z.object({ documentVisibility: z.nativeEnum(DocumentVisibility).nullable(), documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(), + documentTimezone: z.string().nullable(), + documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(), includeSenderDetails: z.boolean().nullable(), includeSigningCertificate: z.boolean().nullable(), signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, { @@ -94,6 +107,9 @@ export const DocumentPreferencesForm = ({ documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null, + documentTimezone: settings.documentTimezone, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null, includeSenderDetails: settings.includeSenderDetails, includeSigningCertificate: settings.includeSigningCertificate, signatureTypes: extractTeamSignatureSettings({ ...settings }), @@ -124,7 +140,10 @@ export const DocumentPreferencesForm = ({ value={field.value === null ? '-1' : field.value} onValueChange={(value) => field.onChange(value === '-1' ? null : value)} > - + @@ -171,7 +190,10 @@ export const DocumentPreferencesForm = ({ value={field.value === null ? '-1' : field.value} onValueChange={(value) => field.onChange(value === '-1' ? null : value)} > - + @@ -199,6 +221,72 @@ export const DocumentPreferencesForm = ({ )} /> + ( + + + Default Date Format + + + + + + + + + )} + /> + + ( + + + Default Time Zone + + + + field.onChange(value)} + testId="document-timezone-trigger" + /> + + + + + )} + /> + @@ -257,7 +345,10 @@ export const DocumentPreferencesForm = ({ field.onChange(value === 'true' ? true : value === 'false' ? false : null) } > - + @@ -325,7 +416,10 @@ export const DocumentPreferencesForm = ({ field.onChange(value === 'true' ? true : value === 'false' ? false : null) } > - + diff --git a/apps/remix/app/components/forms/email-preferences-form.tsx b/apps/remix/app/components/forms/email-preferences-form.tsx new file mode 100644 index 000000000..2dd992676 --- /dev/null +++ b/apps/remix/app/components/forms/email-preferences-form.tsx @@ -0,0 +1,238 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans } from '@lingui/react/macro'; +import type { TeamGlobalSettings } from '@prisma/client'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { FROM_ADDRESS } from '@documenso/lib/constants/email'; +import { + DEFAULT_DOCUMENT_EMAIL_SETTINGS, + ZDocumentEmailSettingsSchema, +} from '@documenso/lib/types/document-email'; +import { trpc } from '@documenso/trpc/react'; +import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormDescription, + 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'; + +const ZEmailPreferencesFormSchema = z.object({ + emailId: z.string().nullable(), + emailReplyTo: z.string().email().nullable(), + // emailReplyToName: z.string(), + emailDocumentSettings: ZDocumentEmailSettingsSchema.nullable(), +}); + +export type TEmailPreferencesFormSchema = z.infer; + +type SettingsSubset = Pick< + TeamGlobalSettings, + 'emailId' | 'emailReplyTo' | 'emailDocumentSettings' +>; + +export type EmailPreferencesFormProps = { + settings: SettingsSubset; + canInherit: boolean; + onFormSubmit: (data: TEmailPreferencesFormSchema) => Promise; +}; + +export const EmailPreferencesForm = ({ + settings, + onFormSubmit, + canInherit, +}: EmailPreferencesFormProps) => { + const organisation = useCurrentOrganisation(); + + const form = useForm({ + defaultValues: { + emailId: settings.emailId, + emailReplyTo: settings.emailReplyTo, + // emailReplyToName: settings.emailReplyToName, + emailDocumentSettings: settings.emailDocumentSettings, + }, + resolver: zodResolver(ZEmailPreferencesFormSchema), + }); + + const { data: emailData, isLoading: isLoadingEmails } = + trpc.enterprise.organisation.email.find.useQuery({ + organisationId: organisation.id, + perPage: 100, + }); + + const emails = emailData?.data || []; + + return ( +
+ +
+ {organisation.organisationClaim.flags.emailDomains && ( + ( + + + Default Email + + + + + + + + The default email to use when sending emails to recipients + + + )} + /> + )} + + ( + + + Reply to email + + + field.onChange(value.target.value || null)} + placeholder="noreply@example.com" + type="email" + /> + + + + + The email address which will show up in the "Reply To" field in emails + + + {canInherit && ( + + {'. '} + Leave blank to inherit from the organisation. + + )} + + + )} + /> + + {/* ( + + + Reply to name + + + + + + + )} + /> */} + + ( + + + Default Email Settings + + {canInherit && ( + + )} + + {field.value && ( +
+ field.onChange(value)} + /> +
+ )} + + + + Controls the default email settings when new documents or templates are created + + +
+ )} + /> + +
+ +
+
+
+ + ); +}; diff --git a/apps/remix/app/components/general/billing-plans.tsx b/apps/remix/app/components/general/billing-plans.tsx index 6c85ac89d..e85a8480d 100644 --- a/apps/remix/app/components/general/billing-plans.tsx +++ b/apps/remix/app/components/general/billing-plans.tsx @@ -186,7 +186,7 @@ const BillingDialog = ({ }); const { mutateAsync: createSubscription, isPending: isCreatingSubscription } = - trpc.billing.subscription.create.useMutation(); + trpc.enterprise.billing.subscription.create.useMutation(); const { mutateAsync: createOrganisation, isPending: isCreatingOrganisation } = trpc.organisation.create.useMutation(); @@ -346,7 +346,7 @@ export const IndividualPersonalLayoutCheckoutButton = ({ const { organisations } = useSession(); const { mutateAsync: createSubscription, isPending } = - trpc.billing.subscription.create.useMutation(); + trpc.enterprise.billing.subscription.create.useMutation(); const onSubscribeClick = async () => { try { diff --git a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx b/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx index f31abb5db..16a52194e 100644 --- a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx +++ b/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx @@ -67,7 +67,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon const { id } = await createDocument({ title: file.name, documentDataId: response.id, - timezone: userTimezone, + timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field. folderId: folderId ?? undefined, }); diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index 014310226..60d120b3e 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -278,7 +278,8 @@ export const DocumentEditForm = ({ }; const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { - const { subject, message, distributionMethod, emailSettings } = data.meta; + const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } = + data.meta; try { await sendDocument({ @@ -287,7 +288,9 @@ export const DocumentEditForm = ({ subject, message, distributionMethod, - emailSettings, + emailId, + emailReplyTo, + emailSettings: emailSettings, }, }); diff --git a/apps/remix/app/components/general/document/document-upload.tsx b/apps/remix/app/components/general/document/document-upload.tsx index c19929b66..c21fcc5f0 100644 --- a/apps/remix/app/components/general/document/document-upload.tsx +++ b/apps/remix/app/components/general/document/document-upload.tsx @@ -78,7 +78,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp const { id } = await createDocument({ title: file.name, documentDataId: response.id, - timezone: userTimezone, + timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field. folderId: folderId ?? undefined, }); diff --git a/apps/remix/app/components/general/organisations/organisation-billing-banner.tsx b/apps/remix/app/components/general/organisations/organisation-billing-banner.tsx index 0a78faaf0..a533ad42e 100644 --- a/apps/remix/app/components/general/organisations/organisation-billing-banner.tsx +++ b/apps/remix/app/components/general/organisations/organisation-billing-banner.tsx @@ -35,7 +35,7 @@ export const OrganisationBillingBanner = () => { const organisation = useOptionalCurrentOrganisation(); const { mutateAsync: manageSubscription, isPending } = - trpc.billing.subscription.manage.useMutation(); + trpc.enterprise.billing.subscription.manage.useMutation(); const handleCreatePortal = async (organisationId: string) => { try { diff --git a/apps/remix/app/components/general/organisations/organisation-billing-portal-button.tsx b/apps/remix/app/components/general/organisations/organisation-billing-portal-button.tsx index cda0c4ab4..7e4e1cb53 100644 --- a/apps/remix/app/components/general/organisations/organisation-billing-portal-button.tsx +++ b/apps/remix/app/components/general/organisations/organisation-billing-portal-button.tsx @@ -21,7 +21,7 @@ export const OrganisationBillingPortalButton = ({ const { toast } = useToast(); const { mutateAsync: manageSubscription, isPending } = - trpc.billing.subscription.manage.useMutation(); + trpc.enterprise.billing.subscription.manage.useMutation(); const canManageBilling = canExecuteOrganisationAction( 'MANAGE_BILLING', diff --git a/apps/remix/app/components/general/settings-nav-desktop.tsx b/apps/remix/app/components/general/settings-nav-desktop.tsx index 7f4100f87..7be14fe23 100644 --- a/apps/remix/app/components/general/settings-nav-desktop.tsx +++ b/apps/remix/app/components/general/settings-nav-desktop.tsx @@ -46,16 +46,46 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr {isPersonalLayoutMode && ( <> - + + + + + + + + + + + + + diff --git a/apps/remix/app/components/general/settings-nav-mobile.tsx b/apps/remix/app/components/general/settings-nav-mobile.tsx index 196b464ed..5278a6af9 100644 --- a/apps/remix/app/components/general/settings-nav-mobile.tsx +++ b/apps/remix/app/components/general/settings-nav-mobile.tsx @@ -6,6 +6,8 @@ import { CreditCardIcon, Globe2Icon, Lock, + MailIcon, + PaletteIcon, Settings2Icon, User, Users, @@ -48,16 +50,42 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp {isPersonalLayoutMode && ( <> - + + + + + + + + + diff --git a/apps/remix/app/components/general/share-document-download-button.tsx b/apps/remix/app/components/general/share-document-download-button.tsx index c79ac4f51..5ee7d0453 100644 --- a/apps/remix/app/components/general/share-document-download-button.tsx +++ b/apps/remix/app/components/general/share-document-download-button.tsx @@ -28,10 +28,6 @@ export const ShareDocumentDownloadButton = ({ try { setIsDownloading(true); - await new Promise((resolve) => { - setTimeout(resolve, 4000); - }); - await downloadPDF({ documentData, fileName: title }); } catch (err) { toast({ diff --git a/apps/remix/app/components/general/teams/team-settings-nav-desktop.tsx b/apps/remix/app/components/general/teams/team-settings-nav-desktop.tsx deleted file mode 100644 index 0429b5db2..000000000 --- a/apps/remix/app/components/general/teams/team-settings-nav-desktop.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import type { HTMLAttributes } from 'react'; - -import { Trans } from '@lingui/react/macro'; -import { Braces, Globe2Icon, GroupIcon, Settings, Settings2, Users, Webhook } from 'lucide-react'; -import { Link, useLocation, useParams } from 'react-router'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; - -export type TeamSettingsNavDesktopProps = HTMLAttributes; - -export const TeamSettingsNavDesktop = ({ className, ...props }: TeamSettingsNavDesktopProps) => { - const { pathname } = useLocation(); - const params = useParams(); - - const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : ''; - - const settingsPath = `/t/${teamUrl}/settings`; - const preferencesPath = `/t/${teamUrl}/settings/preferences`; - const publicProfilePath = `/t/${teamUrl}/settings/public-profile`; - const membersPath = `/t/${teamUrl}/settings/members`; - const groupsPath = `/t/${teamUrl}/settings/groups`; - const tokensPath = `/t/${teamUrl}/settings/tokens`; - const webhooksPath = `/t/${teamUrl}/settings/webhooks`; - - return ( -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
- ); -}; diff --git a/apps/remix/app/components/general/teams/team-settings-nav-mobile.tsx b/apps/remix/app/components/general/teams/team-settings-nav-mobile.tsx deleted file mode 100644 index c769cb27d..000000000 --- a/apps/remix/app/components/general/teams/team-settings-nav-mobile.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import type { HTMLAttributes } from 'react'; - -import { Trans } from '@lingui/react/macro'; -import { Braces, Globe2Icon, GroupIcon, Key, Settings2, User, Webhook } from 'lucide-react'; -import { Link, useLocation, useParams } from 'react-router'; - -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; - -export type TeamSettingsNavMobileProps = HTMLAttributes; - -export const TeamSettingsNavMobile = ({ className, ...props }: TeamSettingsNavMobileProps) => { - const { pathname } = useLocation(); - const params = useParams(); - - const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : ''; - - const settingsPath = `/t/${teamUrl}/settings`; - const preferencesPath = `/t/${teamUrl}/preferences`; - const publicProfilePath = `/t/${teamUrl}/settings/public-profile`; - const membersPath = `/t/${teamUrl}/settings/members`; - const groupsPath = `/t/${teamUrl}/settings/groups`; - const tokensPath = `/t/${teamUrl}/settings/tokens`; - const webhooksPath = `/t/${teamUrl}/settings/webhooks`; - - return ( -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
- ); -}; diff --git a/apps/remix/app/components/tables/organisation-billing-invoices-table.tsx b/apps/remix/app/components/tables/organisation-billing-invoices-table.tsx index 43af226df..9cc84d903 100644 --- a/apps/remix/app/components/tables/organisation-billing-invoices-table.tsx +++ b/apps/remix/app/components/tables/organisation-billing-invoices-table.tsx @@ -25,7 +25,7 @@ export const OrganisationBillingInvoicesTable = ({ }: OrganisationBillingInvoicesTableProps) => { const { _ } = useLingui(); - const { data, isLoading, isLoadingError } = trpc.billing.invoices.get.useQuery( + const { data, isLoading, isLoadingError } = trpc.enterprise.billing.invoices.get.useQuery( { organisationId, }, diff --git a/apps/remix/app/components/tables/organisation-email-domains-table.tsx b/apps/remix/app/components/tables/organisation-email-domains-table.tsx new file mode 100644 index 000000000..e864b02a3 --- /dev/null +++ b/apps/remix/app/components/tables/organisation-email-domains-table.tsx @@ -0,0 +1,205 @@ +import { useMemo } from 'react'; + +import { Trans, useLingui } from '@lingui/react/macro'; +import { EmailDomainStatus } from '@prisma/client'; +import { CheckCircle2Icon, ClockIcon } from 'lucide-react'; +import { Link, useSearchParams } from 'react-router'; +import { match } from 'ts-pattern'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { trpc } from '@documenso/trpc/react'; +import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +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 { useToast } from '@documenso/ui/primitives/use-toast'; + +import { OrganisationEmailDomainDeleteDialog } from '../dialogs/organisation-email-domain-delete-dialog'; + +export const OrganisationEmailDomainsDataTable = () => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [searchParams] = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + const organisation = useCurrentOrganisation(); + + const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); + + const { mutate: verifyEmails, isPending: isVerifyingEmails } = + trpc.enterprise.organisation.emailDomain.verify.useMutation({ + onSuccess: () => { + toast({ + title: t`Email domains synced`, + description: t`All email domains have been synced successfully`, + }); + }, + }); + + const { data, isLoading, isLoadingError } = + trpc.enterprise.organisation.emailDomain.find.useQuery( + { + organisationId: organisation.id, + query: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + placeholderData: (previousData) => previousData, + }, + ); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + const columns = useMemo(() => { + return [ + { + header: t`Domain`, + accessorKey: 'domain', + }, + { + header: t`Status`, + accessorKey: 'status', + cell: ({ row }) => + match(row.original.status) + .with(EmailDomainStatus.ACTIVE, () => ( + + + Active + + )) + .with(EmailDomainStatus.PENDING, () => ( + + + Pending + + )) + .exhaustive(), + }, + { + header: t`Emails`, + accessorKey: 'emailCount', + cell: ({ row }) => row.original.emailCount, + }, + { + header: t`Actions`, + cell: ({ row }) => ( +
+ + + + Delete + + } + /> +
+ ), + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + }, []); + + return ( + <> + + + + + + + + + + + +
+ + +
+
+ + ), + }} + > + {(table) => + results.totalPages > 1 && ( + + ) + } +
+ + + {results.data.length > 0 && ( + +
+ + Sync Email Domains + + + + + This will check and sync the status of all email domains for this organisation + + +
+ + +
+ )} +
+ + ); +}; diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx index 4ac4f285e..87326a5fa 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -1,6 +1,13 @@ import { msg } from '@lingui/core/macro'; import { Trans, useLingui } from '@lingui/react/macro'; -import { Building2Icon, CreditCardIcon, GroupIcon, Settings2Icon, Users2Icon } from 'lucide-react'; +import { + Building2Icon, + CreditCardIcon, + GroupIcon, + MailboxIcon, + Settings2Icon, + Users2Icon, +} from 'lucide-react'; import { FaUsers } from 'react-icons/fa6'; import { Link, NavLink, Outlet } from 'react-router'; @@ -30,9 +37,30 @@ export default function SettingsLayout() { icon: Building2Icon, }, { - path: `/o/${organisation.url}/settings/preferences`, + path: `/o/${organisation.url}/settings/document`, label: t`Preferences`, icon: Settings2Icon, + hideHighlight: true, + }, + { + path: `/o/${organisation.url}/settings/document`, + label: t`Document`, + isSubNav: true, + }, + { + path: `/o/${organisation.url}/settings/branding`, + label: t`Branding`, + isSubNav: true, + }, + { + path: `/o/${organisation.url}/settings/email`, + label: t`Email`, + isSubNav: true, + }, + { + path: `/o/${organisation.url}/settings/email-domains`, + label: t`Email Domains`, + icon: MailboxIcon, }, { path: `/o/${organisation.url}/settings/teams`, @@ -54,7 +82,20 @@ export default function SettingsLayout() { label: t`Billing`, icon: CreditCardIcon, }, - ].filter((route) => (isBillingEnabled ? route : !route.path.includes('/billing'))); + ].filter((route) => { + if (!isBillingEnabled && route.path.includes('/billing')) { + return false; + } + + if ( + (!isBillingEnabled || !organisation.organisationClaim.flags.emailDomains) && + route.path.includes('/email-domains') + ) { + return false; + } + + return true; + }); if (!canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole)) { return ( @@ -93,12 +134,18 @@ export default function SettingsLayout() { )} > {organisationSettingRoutes.map((route) => ( - + diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx index 992f365fa..d309cf166 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx @@ -24,7 +24,7 @@ export default function TeamsSettingBillingPage() { const organisation = useCurrentOrganisation(); const { data: subscriptionQuery, isLoading: isLoadingSubscription } = - trpc.billing.subscription.get.useQuery({ + trpc.enterprise.billing.subscription.get.useQuery({ organisationId: organisation.id, }); diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx similarity index 64% rename from apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx rename to apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx index 2b2920711..4109835f3 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx @@ -5,7 +5,6 @@ import { Link } from 'react-router'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; -import { DocumentSignatureType } from '@documenso/lib/constants/document'; import { putFile } from '@documenso/lib/universal/upload/put-file'; import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations'; import { trpc } from '@documenso/trpc/react'; @@ -17,21 +16,19 @@ import { BrandingPreferencesForm, type TBrandingPreferencesFormSchema, } from '~/components/forms/branding-preferences-form'; -import { - DocumentPreferencesForm, - type TDocumentPreferencesFormSchema, -} from '~/components/forms/document-preferences-form'; import { SettingsHeader } from '~/components/general/settings-header'; +import { useOptionalCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; export function meta() { - return appMetaTags('Preferences'); + return appMetaTags('Branding Preferences'); } -export default function OrganisationSettingsPreferencesPage() { +export default function OrganisationSettingsBrandingPage() { const { organisations } = useSession(); const organisation = useCurrentOrganisation(); + const team = useOptionalCurrentTeam(); const { t } = useLingui(); const { toast } = useToast(); @@ -46,51 +43,6 @@ export default function OrganisationSettingsPreferencesPage() { const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation(); - const onDocumentPreferencesFormSubmit = async (data: TDocumentPreferencesFormSchema) => { - try { - const { - documentVisibility, - documentLanguage, - includeSenderDetails, - includeSigningCertificate, - signatureTypes, - } = data; - - if ( - documentVisibility === null || - documentLanguage === null || - includeSenderDetails === null || - includeSigningCertificate === null - ) { - throw new Error('Should not be possible.'); - } - - await updateOrganisationSettings({ - organisationId: organisation.id, - data: { - documentVisibility, - documentLanguage, - includeSenderDetails, - includeSigningCertificate, - typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), - uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), - drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), - }, - }); - - toast({ - title: t`Document preferences updated`, - description: t`Your document preferences have been updated`, - }); - } catch (err) { - toast({ - title: t`Something went wrong!`, - description: t`We were unable to update your document preferences at this time, please try again later`, - variant: 'destructive', - }); - } - }; - const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => { try { const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data; @@ -132,32 +84,21 @@ export default function OrganisationSettingsPreferencesPage() { ); } - const settingsHeaderText = isPersonalLayoutMode ? t`Preferences` : t`Organisation Preferences`; + const settingsHeaderText = t`Branding Preferences`; + const settingsHeaderSubtitle = isPersonalLayoutMode - ? t`Here you can set your general preferences` - : t`Here you can set preferences and defaults for your organisation. Teams will inherit these settings by default.`; + ? t`Here you can set your general branding preferences` + : team + ? t`Here you can set branding preferences for your team` + : t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`; return (
-
- -
- {organisationWithSettings.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED() ? (
- - { + try { + const { + documentVisibility, + documentLanguage, + documentTimezone, + documentDateFormat, + includeSenderDetails, + includeSigningCertificate, + signatureTypes, + } = data; + + if ( + documentVisibility === null || + documentLanguage === null || + documentDateFormat === null || + includeSenderDetails === null || + includeSigningCertificate === null + ) { + throw new Error('Should not be possible.'); + } + + await updateOrganisationSettings({ + organisationId: organisation.id, + data: { + documentVisibility, + documentLanguage, + documentTimezone, + documentDateFormat, + includeSenderDetails, + includeSigningCertificate, + typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), + uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), + drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), + }, + }); + + toast({ + title: t`Document preferences updated`, + description: t`Your document preferences have been updated`, + }); + } catch (err) { + toast({ + title: t`Something went wrong!`, + description: t`We were unable to update your document preferences at this time, please try again later`, + variant: 'destructive', + }); + } + }; + + if (isLoadingOrganisation || !organisationWithSettings) { + return ( +
+ +
+ ); + } + + const settingsHeaderText = t`Document Preferences`; + const settingsHeaderSubtitle = isPersonalLayoutMode + ? t`Here you can set your general document preferences` + : t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`; + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx new file mode 100644 index 000000000..e495d82ea --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx @@ -0,0 +1,207 @@ +import { useMemo } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react'; +import { Link } from 'react-router'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains'; +import { trpc } from '@documenso/trpc/react'; +import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { SpinnerBox } from '@documenso/ui/primitives/spinner'; + +import { OrganisationEmailCreateDialog } from '~/components/dialogs/organisation-email-create-dialog'; +import { OrganisationEmailDeleteDialog } from '~/components/dialogs/organisation-email-delete-dialog'; +import { OrganisationEmailDomainDeleteDialog } from '~/components/dialogs/organisation-email-domain-delete-dialog'; +import { OrganisationEmailDomainRecordsDialog } from '~/components/dialogs/organisation-email-domain-records-dialog'; +import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog'; +import { GenericErrorLayout } from '~/components/general/generic-error-layout'; +import { SettingsHeader } from '~/components/general/settings-header'; + +import type { Route } from './+types/o.$orgUrl.settings.groups.$id'; + +export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) { + const { t } = useLingui(); + + const organisation = useCurrentOrganisation(); + + const emailDomainId = params.id; + + const { data: emailDomain, isLoading: isLoadingEmailDomain } = + trpc.enterprise.organisation.emailDomain.get.useQuery( + { + emailDomainId, + }, + { + enabled: !!emailDomainId, + }, + ); + + const emailColumns = useMemo(() => { + return [ + { + header: t`Name`, + accessorKey: 'emailName', + }, + { + header: t`Email`, + accessorKey: 'email', + }, + { + header: t`Actions`, + cell: ({ row }) => ( + + + + + + + + Actions + + + e.preventDefault()}> + + Update + + } + /> + + e.preventDefault()}> + + Remove + + } + /> + + + ), + }, + ] satisfies DataTableColumnDef[]; + }, [organisation]); + + if (!IS_BILLING_ENABLED()) { + return null; + } + + if (isLoadingEmailDomain) { + return ; + } + + // Todo: Update UI, currently out of place. + if (!emailDomain) { + return ( + + + Go back + + + } + secondaryButton={null} + /> + ); + } + + const records = generateEmailDomainRecords(emailDomain.selector, emailDomain.publicKey); + + return ( +
+ + + + +
+ + +
+ +
+
+ + +
+ + DNS Records + + + + View the DNS records for this email domain + +
+ + + View DNS Records + + } + /> +
+ + +
+ + Delete email domain + + + + This will remove all emails associated with this email domain + +
+ + + Delete Email Domain + + } + /> +
+
+ ); +} diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx new file mode 100644 index 000000000..e2b988a79 --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx @@ -0,0 +1,81 @@ +import { Trans, useLingui } from '@lingui/react/macro'; +import { Link } from 'react-router'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; + +import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog'; +import { SettingsHeader } from '~/components/general/settings-header'; +import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisation-email-domains-table'; +import { appMetaTags } from '~/utils/meta'; + +export function meta() { + return appMetaTags('Email Domains'); +} + +export default function OrganisationSettingsEmailDomains() { + const { t } = useLingui(); + const { organisations } = useSession(); + + const organisation = useCurrentOrganisation(); + + const isPersonalLayoutMode = isPersonalLayout(organisations); + + const isEmailDomainsEnabled = organisation.organisationClaim.flags.emailDomains; + + if (!IS_BILLING_ENABLED()) { + return null; + } + + return ( +
+ + {isEmailDomainsEnabled && } + + + {isEmailDomainsEnabled ? ( +
+ +
+ ) : ( + +
+ + Email Domains + + + + + Currently email domains can only be configured for Platform and above plans. + + +
+ + {canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && ( + + )} +
+ )} +
+ ); +} diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email.tsx new file mode 100644 index 000000000..7d74e5776 --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email.tsx @@ -0,0 +1,80 @@ +import { useLingui } from '@lingui/react/macro'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { trpc } from '@documenso/trpc/react'; +import { SpinnerBox } from '@documenso/ui/primitives/spinner'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { + EmailPreferencesForm, + type TEmailPreferencesFormSchema, +} from '~/components/forms/email-preferences-form'; +import { SettingsHeader } from '~/components/general/settings-header'; +import { appMetaTags } from '~/utils/meta'; + +export function meta() { + return appMetaTags('Email Preferences'); +} + +export default function OrganisationSettingsGeneral() { + const { t } = useLingui(); + const { toast } = useToast(); + + const organisation = useCurrentOrganisation(); + + const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = + trpc.organisation.get.useQuery({ + organisationReference: organisation.url, + }); + + const { mutateAsync: updateOrganisationSettings } = + trpc.organisation.settings.update.useMutation(); + + const onEmailPreferencesSubmit = async (data: TEmailPreferencesFormSchema) => { + try { + const { emailId, emailReplyTo, emailDocumentSettings } = data; + + await updateOrganisationSettings({ + organisationId: organisation.id, + data: { + emailId, + emailReplyTo: emailReplyTo || null, + // emailReplyToName, + emailDocumentSettings, + }, + }); + + toast({ + title: t`Email preferences updated`, + description: t`Your email preferences have been updated`, + }); + } catch (err) { + toast({ + title: t`Something went wrong!`, + description: t`We were unable to update your email preferences at this time, please try again later`, + variant: 'destructive', + }); + } + }; + + if (isLoadingOrganisation || !organisationWithSettings) { + return ; + } + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/branding.tsx b/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/branding.tsx new file mode 100644 index 000000000..80a662596 --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/branding.tsx @@ -0,0 +1,5 @@ +import BrandingPage, { meta } from '../../o.$orgUrl.settings.branding'; + +export { meta }; + +export default BrandingPage; diff --git a/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/document.tsx b/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/document.tsx new file mode 100644 index 000000000..697d2fc37 --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/document.tsx @@ -0,0 +1,5 @@ +import DocumentPage, { meta } from '../../o.$orgUrl.settings.document'; + +export { meta }; + +export default DocumentPage; diff --git a/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/email.tsx b/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/email.tsx new file mode 100644 index 000000000..c076fe1c3 --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/email.tsx @@ -0,0 +1,5 @@ +import EmailPage, { meta } from '../../o.$orgUrl.settings.email'; + +export { meta }; + +export default EmailPage; diff --git a/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/preferences.tsx b/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/preferences.tsx deleted file mode 100644 index db082d779..000000000 --- a/apps/remix/app/routes/_authenticated+/settings+/_dynamic_personal_routes+/preferences.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import PreferencesPage, { meta } from '../../o.$orgUrl.settings.preferences'; - -export { meta }; - -export default PreferencesPage; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx index 004f8aa7a..ab98512f9 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx @@ -1,15 +1,23 @@ import { msg } from '@lingui/core/macro'; -import { Trans } from '@lingui/react/macro'; -import { Link, Outlet, redirect } from 'react-router'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { + BracesIcon, + Globe2Icon, + GroupIcon, + Settings2Icon, + SettingsIcon, + Users2Icon, + WebhookIcon, +} from 'lucide-react'; +import { Link, NavLink, Outlet, redirect } from 'react-router'; import { getSession } from '@documenso/auth/server/lib/utils/get-session'; import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { canExecuteTeamAction } from '@documenso/lib/utils/teams'; +import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; -import { TeamSettingsNavDesktop } from '~/components/general/teams/team-settings-nav-desktop'; -import { TeamSettingsNavMobile } from '~/components/general/teams/team-settings-nav-mobile'; import { useCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; @@ -37,8 +45,64 @@ export async function clientLoader() { } export default function TeamsSettingsLayout() { + const { t } = useLingui(); + const team = useCurrentTeam(); + const teamSettingRoutes = [ + { + path: `/t/${team.url}/settings`, + label: t`General`, + icon: SettingsIcon, + }, + { + path: `/t/${team.url}/settings/document`, + label: t`Preferences`, + icon: Settings2Icon, + isSubNavParent: true, + }, + { + path: `/t/${team.url}/settings/document`, + label: t`Document`, + isSubNav: true, + }, + { + path: `/t/${team.url}/settings/branding`, + label: t`Branding`, + isSubNav: true, + }, + { + path: `/t/${team.url}/settings/email`, + label: t`Email`, + isSubNav: true, + }, + { + path: `/t/${team.url}/settings/public-profile`, + label: t`Public Profile`, + icon: Globe2Icon, + }, + { + path: `/t/${team.url}/settings/members`, + label: t`Members`, + icon: Users2Icon, + }, + { + path: `/t/${team.url}/settings/groups`, + label: t`Groups`, + icon: GroupIcon, + }, + { + path: `/t/${team.url}/settings/tokens`, + label: t`API Tokens`, + icon: BracesIcon, + }, + { + path: `/t/${team.url}/settings/webhooks`, + label: t`Webhooks`, + icon: WebhookIcon, + }, + ]; + if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) { return (
- - +
+ {teamSettingRoutes.map((route) => ( + + + + ))} +
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx new file mode 100644 index 000000000..e13bcd030 --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx @@ -0,0 +1,94 @@ +import { useLingui } from '@lingui/react/macro'; +import { Loader } from 'lucide-react'; + +import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { trpc } from '@documenso/trpc/react'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { + BrandingPreferencesForm, + type TBrandingPreferencesFormSchema, +} from '~/components/forms/branding-preferences-form'; +import { SettingsHeader } from '~/components/general/settings-header'; +import { useCurrentTeam } from '~/providers/team'; +import { appMetaTags } from '~/utils/meta'; + +export function meta() { + return appMetaTags('Branding Preferences'); +} + +export default function TeamsSettingsPage() { + const team = useCurrentTeam(); + + const { t } = useLingui(); + const { toast } = useToast(); + + const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({ + teamReference: team.id, + }); + + const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation(); + + const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => { + try { + const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data; + + let uploadedBrandingLogo = teamWithSettings?.teamSettings?.brandingLogo; + + if (brandingLogo) { + uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo)); + } + + if (brandingLogo === null) { + uploadedBrandingLogo = ''; + } + + await updateTeamSettings({ + teamId: team.id, + data: { + brandingEnabled, + brandingLogo: uploadedBrandingLogo || null, + brandingUrl: brandingUrl || null, + brandingCompanyDetails: brandingCompanyDetails || null, + }, + }); + + toast({ + title: t`Branding preferences updated`, + description: t`Your branding preferences have been updated`, + }); + } catch (err) { + toast({ + title: t`Something went wrong`, + description: t`We were unable to update your branding preferences at this time, please try again later`, + variant: 'destructive', + }); + } + }; + + if (isLoadingTeam || !teamWithSettings) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.preferences.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx similarity index 61% rename from apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.preferences.tsx rename to apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx index c7d631de9..45c376077 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.preferences.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx @@ -2,14 +2,9 @@ import { useLingui } from '@lingui/react/macro'; import { Loader } from 'lucide-react'; import { DocumentSignatureType } from '@documenso/lib/constants/document'; -import { putFile } from '@documenso/lib/universal/upload/put-file'; import { trpc } from '@documenso/trpc/react'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { - BrandingPreferencesForm, - type TBrandingPreferencesFormSchema, -} from '~/components/forms/branding-preferences-form'; import { DocumentPreferencesForm, type TDocumentPreferencesFormSchema, @@ -19,7 +14,7 @@ import { useCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; export function meta() { - return appMetaTags('Preferences'); + return appMetaTags('Document Preferences'); } export default function TeamsSettingsPage() { @@ -39,6 +34,8 @@ export default function TeamsSettingsPage() { const { documentVisibility, documentLanguage, + documentTimezone, + documentDateFormat, includeSenderDetails, includeSigningCertificate, signatureTypes, @@ -49,6 +46,8 @@ export default function TeamsSettingsPage() { data: { documentVisibility, documentLanguage, + documentTimezone, + documentDateFormat, includeSenderDetails, includeSigningCertificate, ...(signatureTypes.length === 0 @@ -78,43 +77,6 @@ export default function TeamsSettingsPage() { } }; - const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => { - try { - const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data; - - let uploadedBrandingLogo = teamWithSettings?.teamSettings?.brandingLogo; - - if (brandingLogo) { - uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo)); - } - - if (brandingLogo === null) { - uploadedBrandingLogo = ''; - } - - await updateTeamSettings({ - teamId: team.id, - data: { - brandingEnabled, - brandingLogo: uploadedBrandingLogo || null, - brandingUrl: brandingUrl || null, - brandingCompanyDetails: brandingCompanyDetails || null, - }, - }); - - toast({ - title: t`Branding preferences updated`, - description: t`Your branding preferences have been updated`, - }); - } catch (err) { - toast({ - title: t`Something went wrong`, - description: t`We were unable to update your branding preferences at this time, please try again later`, - variant: 'destructive', - }); - } - }; - if (isLoadingTeam || !teamWithSettings) { return (
@@ -126,7 +88,7 @@ export default function TeamsSettingsPage() { return (
@@ -137,21 +99,6 @@ export default function TeamsSettingsPage() { onFormSubmit={onDocumentPreferencesSubmit} />
- - - -
- -
); } diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.email.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.email.tsx new file mode 100644 index 000000000..f1fed0374 --- /dev/null +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.email.tsx @@ -0,0 +1,78 @@ +import { useLingui } from '@lingui/react/macro'; + +import { trpc } from '@documenso/trpc/react'; +import { SpinnerBox } from '@documenso/ui/primitives/spinner'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { + EmailPreferencesForm, + type TEmailPreferencesFormSchema, +} from '~/components/forms/email-preferences-form'; +import { SettingsHeader } from '~/components/general/settings-header'; +import { useCurrentTeam } from '~/providers/team'; +import { appMetaTags } from '~/utils/meta'; + +export function meta() { + return appMetaTags('Settings'); +} + +export default function TeamEmailSettingsGeneral() { + const { t } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({ + teamReference: team.url, + }); + + const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation(); + + const onEmailPreferencesSubmit = async (data: TEmailPreferencesFormSchema) => { + try { + const { emailId, emailReplyTo, emailDocumentSettings } = data; + + await updateTeamSettings({ + teamId: team.id, + data: { + emailId, + emailReplyTo, + // emailReplyToName, + emailDocumentSettings, + }, + }); + + toast({ + title: t`Email preferences updated`, + description: t`Your email preferences have been updated`, + }); + } catch (err) { + toast({ + title: t`Something went wrong!`, + description: t`We were unable to update your email preferences at this time, please try again later`, + variant: 'destructive', + }); + } + }; + + if (isLoadingTeam || !teamWithSettings) { + return ; + } + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/package-lock.json b/package-lock.json index da1f7c26f..e07bdf900 100644 --- a/package-lock.json +++ b/package-lock.json @@ -514,6 +514,534 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/client-sesv2": { + "version": "3.828.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.828.0.tgz", + "integrity": "sha512-pJQE+D1Su2sbYcSwqgq/fgkPMY1c/h90ntvwknGiTlHJRWoF8MRHJ65+PaLBiS6nGvjEtTE1+Y1YQDA7etrRNg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.826.0", + "@aws-sdk/credential-provider-node": "3.828.0", + "@aws-sdk/middleware-host-header": "3.821.0", + "@aws-sdk/middleware-logger": "3.821.0", + "@aws-sdk/middleware-recursion-detection": "3.821.0", + "@aws-sdk/middleware-user-agent": "3.828.0", + "@aws-sdk/region-config-resolver": "3.821.0", + "@aws-sdk/signature-v4-multi-region": "3.826.0", + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-endpoints": "3.828.0", + "@aws-sdk/util-user-agent-browser": "3.821.0", + "@aws-sdk/util-user-agent-node": "3.828.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.5.3", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.11", + "@smithy/middleware-retry": "^4.1.12", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.3", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.19", + "@smithy/util-defaults-mode-node": "^4.0.19", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.5", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/client-sso": { + "version": "3.828.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.828.0.tgz", + "integrity": "sha512-qxw8JcPTaFaBwTBUr4YmLajaMh3En65SuBWAKEtjctbITRRekzR7tvr/TkwoyVOh+XoAtkwOn+BQeQbX+/wgHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.826.0", + "@aws-sdk/middleware-host-header": "3.821.0", + "@aws-sdk/middleware-logger": "3.821.0", + "@aws-sdk/middleware-recursion-detection": "3.821.0", + "@aws-sdk/middleware-user-agent": "3.828.0", + "@aws-sdk/region-config-resolver": "3.821.0", + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-endpoints": "3.828.0", + "@aws-sdk/util-user-agent-browser": "3.821.0", + "@aws-sdk/util-user-agent-node": "3.828.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.5.3", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.11", + "@smithy/middleware-retry": "^4.1.12", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.3", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.19", + "@smithy/util-defaults-mode-node": "^4.0.19", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.5", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/core": { + "version": "3.826.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.826.0.tgz", + "integrity": "sha512-BGbQYzWj3ps+dblq33FY5tz/SsgJCcXX0zjQlSC07tYvU1jHTUvsefphyig+fY38xZ4wdKjbTop+KUmXUYrOXw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/core": "^3.5.3", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/smithy-client": "^4.4.3", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-utf8": "^4.0.0", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.826.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.826.0.tgz", + "integrity": "sha512-DK3pQY8+iKK3MGDdC3uOZQ2psU01obaKlTYhEwNu4VWzgwQL4Vi3sWj4xSWGEK41vqZxiRLq6fOq7ysRI+qEZA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.826.0", + "@aws-sdk/types": "3.821.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.826.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.826.0.tgz", + "integrity": "sha512-N+IVZBh+yx/9GbMZTKO/gErBi/FYZQtcFRItoLbY+6WU+0cSWyZYfkoeOxHmQV3iX9k65oljERIWUmL9x6OSQg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.826.0", + "@aws-sdk/types": "3.821.0", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.3", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.828.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.828.0.tgz", + "integrity": "sha512-T3DJMo2/j7gCPpFg2+xEHWgua05t8WP89ye7PaZxA2Fc6CgScHkZsJZTri1QQIU2h+eOZ75EZWkeFLIPgN0kRQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.826.0", + "@aws-sdk/credential-provider-env": "3.826.0", + "@aws-sdk/credential-provider-http": "3.826.0", + "@aws-sdk/credential-provider-process": "3.826.0", + "@aws-sdk/credential-provider-sso": "3.828.0", + "@aws-sdk/credential-provider-web-identity": "3.828.0", + "@aws-sdk/nested-clients": "3.828.0", + "@aws-sdk/types": "3.821.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.828.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.828.0.tgz", + "integrity": "sha512-9z3iPwVYOQYNzVZj8qycZaS/BOSKRXWA+QVNQlfEnQ4sA4sOcKR4kmV2h+rJcuBsSFfmOF62ZDxyIBGvvM4t/w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.826.0", + "@aws-sdk/credential-provider-http": "3.826.0", + "@aws-sdk/credential-provider-ini": "3.828.0", + "@aws-sdk/credential-provider-process": "3.826.0", + "@aws-sdk/credential-provider-sso": "3.828.0", + "@aws-sdk/credential-provider-web-identity": "3.828.0", + "@aws-sdk/types": "3.821.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.826.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.826.0.tgz", + "integrity": "sha512-kURrc4amu3NLtw1yZw7EoLNEVhmOMRUTs+chaNcmS+ERm3yK0nKjaJzmKahmwlTQTSl3wJ8jjK7x962VPo+zWw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.826.0", + "@aws-sdk/types": "3.821.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.828.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.828.0.tgz", + "integrity": "sha512-9CEAXzUDSzOjOCb3XfM15TZhTaM+l07kumZyx2z8NC6T2U4qbCJqn4h8mFlRvYrs6cBj2SN40sD3r5Wp0Cq2Kw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.828.0", + "@aws-sdk/core": "3.826.0", + "@aws-sdk/token-providers": "3.828.0", + "@aws-sdk/types": "3.821.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.828.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.828.0.tgz", + "integrity": "sha512-MguDhGHlQBeK9CQ/P4NOY0whAJ4HJU4x+f1dphg3I1sGlccFqfB8Moor2vXNKu0Th2kvAwkn9pr7gGb/+NGR9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.826.0", + "@aws-sdk/nested-clients": "3.828.0", + "@aws-sdk/types": "3.821.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.821.0.tgz", + "integrity": "sha512-xSMR+sopSeWGx5/4pAGhhfMvGBHioVBbqGvDs6pG64xfNwM5vq5s5v6D04e2i+uSTj4qGa71dLUs5I0UzAK3sw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-logger": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.821.0.tgz", + "integrity": "sha512-0cvI0ipf2tGx7fXYEEN5fBeZDz2RnHyb9xftSgUsEq7NBxjV0yTZfLJw6Za5rjE6snC80dRN8+bTNR1tuG89zA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.821.0.tgz", + "integrity": "sha512-efmaifbhBoqKG3bAoEfDdcM8hn1psF+4qa7ykWuYmfmah59JBeqHLfz5W9m9JoTwoKPkFcVLWZxnyZzAnVBOIg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.826.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.826.0.tgz", + "integrity": "sha512-8F0qWaYKfvD/de1AKccXuigM+gb/IZSncCqxdnFWqd+TFzo9qI9Hh+TpUhWOMYSgxsMsYQ8ipmLzlD/lDhjrmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.826.0", + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-arn-parser": "3.804.0", + "@smithy/core": "^3.5.3", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/smithy-client": "^4.4.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-stream": "^4.2.2", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.828.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.828.0.tgz", + "integrity": "sha512-nixvI/SETXRdmrVab4D9LvXT3lrXkwAWGWk2GVvQvzlqN1/M/RfClj+o37Sn4FqRkGH9o9g7Fqb1YqZ4mqDAtA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.826.0", + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-endpoints": "3.828.0", + "@smithy/core": "^3.5.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/nested-clients": { + "version": "3.828.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.828.0.tgz", + "integrity": "sha512-xmeOILiR9LvfC8MctgeRXXN8nQTwbOvO4wHvgE8tDRsjnBpyyO0j50R4+viHXdMUGtgGkHEXRv8fFNBq54RgnA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.826.0", + "@aws-sdk/middleware-host-header": "3.821.0", + "@aws-sdk/middleware-logger": "3.821.0", + "@aws-sdk/middleware-recursion-detection": "3.821.0", + "@aws-sdk/middleware-user-agent": "3.828.0", + "@aws-sdk/region-config-resolver": "3.821.0", + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-endpoints": "3.828.0", + "@aws-sdk/util-user-agent-browser": "3.821.0", + "@aws-sdk/util-user-agent-node": "3.828.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.5.3", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.11", + "@smithy/middleware-retry": "^4.1.12", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.3", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.19", + "@smithy/util-defaults-mode-node": "^4.0.19", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.5", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.821.0.tgz", + "integrity": "sha512-t8og+lRCIIy5nlId0bScNpCkif8sc0LhmtaKsbm0ZPm3sCa/WhCbSZibjbZ28FNjVCV+p0D9RYZx0VDDbtWyjw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.826.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.826.0.tgz", + "integrity": "sha512-3fEi/zy6tpMzomYosksGtu7jZqGFcdBXoL7YRsG7OEeQzBbOW9B+fVaQZ4jnsViSjzA/yKydLahMrfPnt+iaxg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.826.0", + "@aws-sdk/types": "3.821.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/token-providers": { + "version": "3.828.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.828.0.tgz", + "integrity": "sha512-JdOjI/TxkfQpY/bWbdGMdCiePESXTbtl6MfnJxz35zZ3tfHvBnxAWCoYJirdmjzY/j/dFo5oEyS6mQuXAG9w2w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.826.0", + "@aws-sdk/nested-clients": "3.828.0", + "@aws-sdk/types": "3.821.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/types": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.821.0.tgz", + "integrity": "sha512-Znroqdai1a90TlxGaJ+FK1lwC0fHpo97Xjsp5UKGR5JODYm7f9+/fF17ebO1KdoBr/Rm0UIFiF5VmI8ts9F1eA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/util-endpoints": { + "version": "3.828.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.828.0.tgz", + "integrity": "sha512-RvKch111SblqdkPzg3oCIdlGxlQs+k+P7Etory9FmxPHyPDvsP1j1c74PmgYqtzzMWmoXTjd+c9naUHh9xG8xg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/types": "^4.3.1", + "@smithy/util-endpoints": "^3.0.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.821.0.tgz", + "integrity": "sha512-irWZHyM0Jr1xhC+38OuZ7JB6OXMLPZlj48thElpsO1ZSLRkLZx5+I7VV6k3sp2yZ7BYbKz/G2ojSv4wdm7XTLw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.828.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.828.0.tgz", + "integrity": "sha512-LdN6fTBzTlQmc8O8f1wiZN0qF3yBWVGis7NwpWK7FUEzP9bEZRxYfIkV9oV9zpt6iNRze1SedK3JQVB/udxBoA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.828.0", + "@aws-sdk/types": "3.821.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-sesv2/node_modules/@aws-sdk/xml-builder": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.821.0.tgz", + "integrity": "sha512-DIIotRnefVL6DiaHtO6/21DhJ4JZnnIwdNbpwiAhdt/AVbttcE4yw925gsjur0OGv5BTYXQXU3YnANBYnZjuQA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/client-sso": { "version": "3.812.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.812.0.tgz", @@ -9906,12 +10434,12 @@ } }, "node_modules/@smithy/abort-controller": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.3.tgz", - "integrity": "sha512-AqXFf6DXnuRBXy4SoK/n1mfgHaKaq36bmkphmD1KO0nHq6xK/g9KHSW4HEsPQUBCGdIEfuJifGHwxFXPIFay9Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.4.tgz", + "integrity": "sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -9944,15 +10472,15 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.3.tgz", - "integrity": "sha512-N5e7ofiyYDmHxnPnqF8L4KtsbSDwyxFRfDK9bp1d9OyPO4ytRLd0/XxCqi5xVaaqB65v4woW8uey6jND6zxzxQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.4.tgz", + "integrity": "sha512-prmU+rDddxHOH0oNcwemL+SwnzcG65sBF2yXRO7aeXIn/xTlq2pX7JLVbkBnVLowHLg4/OL4+jBmv9hVrVGS+w==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.1.2", - "@smithy/types": "^4.3.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.3", + "@smithy/util-middleware": "^4.0.4", "tslib": "^2.6.2" }, "engines": { @@ -9960,17 +10488,18 @@ } }, "node_modules/@smithy/core": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.4.0.tgz", - "integrity": "sha512-dDYISQo7k0Ml/rXlFIjkTmTcQze/LxhtIRAEmZ6HJ/EI0inVxVEVnrUXJ7jPx6ZP0GHUhFm40iQcCgS5apXIXA==", + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.5.3.tgz", + "integrity": "sha512-xa5byV9fEguZNofCclv6v9ra0FYh5FATQW/da7FQUVTic94DfrN/NvmKZjrMyzbpqfot9ZjBaO8U1UeTbmSLuA==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.0.6", - "@smithy/protocol-http": "^5.1.1", - "@smithy/types": "^4.3.0", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.3", - "@smithy/util-stream": "^4.2.1", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-stream": "^4.2.2", "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, @@ -9979,15 +10508,15 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.5.tgz", - "integrity": "sha512-saEAGwrIlkb9XxX/m5S5hOtzjoJPEK6Qw2f9pYTbIsMPOFyGSXBBTw95WbOyru8A1vIS2jVCCU1Qhz50QWG3IA==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.6.tgz", + "integrity": "sha512-hKMWcANhUiNbCJouYkZ9V3+/Qf9pteR1dnwgdyzR09R4ODEYx8BbUysHwRSyex4rZ9zapddZhLFTnT4ZijR4pw==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.1.2", - "@smithy/property-provider": "^4.0.3", - "@smithy/types": "^4.3.0", - "@smithy/url-parser": "^4.0.3", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", "tslib": "^2.6.2" }, "engines": { @@ -10065,14 +10594,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.0.3.tgz", - "integrity": "sha512-yBZwavI31roqTndNI7ONHqesfH01JmjJK6L3uUpZAhyAmr86LN5QiPzfyZGIxQmed8VEK2NRSQT3/JX5V1njfQ==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.0.4.tgz", + "integrity": "sha512-AMtBR5pHppYMVD7z7G+OlHHAcgAN7v0kVKEpHuTO4Gb199Gowh0taYi9oDStFeUhetkeP55JLSVlTW1n9rFtUw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.1.1", - "@smithy/querystring-builder": "^4.0.3", - "@smithy/types": "^4.3.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", "@smithy/util-base64": "^4.0.0", "tslib": "^2.6.2" }, @@ -10096,12 +10625,12 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.3.tgz", - "integrity": "sha512-W5Uhy6v/aYrgtjh9y0YP332gIQcwccQ+EcfWhllL0B9rPae42JngTTUpb8W6wuxaNFzqps4xq5klHckSSOy5fw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.4.tgz", + "integrity": "sha512-qnbTPUhCVnCgBp4z4BUJUhOEkVwxiEi1cyFM+Zj6o+aY8OFGxUQleKWq8ltgp3dujuhXojIvJWdoqpm6dVO3lQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.0", + "@smithy/types": "^4.3.1", "@smithy/util-buffer-from": "^4.0.0", "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" @@ -10125,12 +10654,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.3.tgz", - "integrity": "sha512-1Bo8Ur1ZGqxvwTqBmv6DZEn0rXtwJGeqiiO2/JFcCtz3nBakOqeXbJBElXJMMzd0ghe8+eB6Dkw98nMYctgizg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.4.tgz", + "integrity": "sha512-bNYMi7WKTJHu0gn26wg8OscncTt1t2b8KcsZxvOv56XA6cyXtOAAAaNP7+m45xfppXfOatXF3Sb1MNsLUgVLTw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -10164,13 +10693,13 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.3.tgz", - "integrity": "sha512-NE/Zph4BP5u16bzYq2csq9qD0T6UBLeg4AuNrwNJ7Gv9uLYaGEgelZUOdRndGdMGcUfSGvNlXGb2aA2hPCwJ6g==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.4.tgz", + "integrity": "sha512-F7gDyfI2BB1Kc+4M6rpuOLne5LOcEknH1n6UQB69qv+HucXBR1rkzXBnQTB2q46sFy1PM/zuSJOB532yc8bg3w==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.1.1", - "@smithy/types": "^4.3.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -10178,18 +10707,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.7.tgz", - "integrity": "sha512-KDzM7Iajo6K7eIWNNtukykRT4eWwlHjCEsULZUaSfi/SRSBK8BPRqG5FsVfp58lUxcvre8GT8AIPIqndA0ERKw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.11.tgz", + "integrity": "sha512-zDogwtRLzKl58lVS8wPcARevFZNBOOqnmzWWxVe9XiaXU2CADFjvJ9XfNibgkOWs08sxLuSr81NrpY4mgp9OwQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.4.0", - "@smithy/middleware-serde": "^4.0.6", - "@smithy/node-config-provider": "^4.1.2", - "@smithy/shared-ini-file-loader": "^4.0.3", - "@smithy/types": "^4.3.0", - "@smithy/url-parser": "^4.0.3", - "@smithy/util-middleware": "^4.0.3", + "@smithy/core": "^3.5.3", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-middleware": "^4.0.4", "tslib": "^2.6.2" }, "engines": { @@ -10197,18 +10726,18 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.8.tgz", - "integrity": "sha512-e2OtQgFzzlSG0uCjcJmi02QuFSRTrpT11Eh2EcqqDFy7DYriteHZJkkf+4AsxsrGDugAtPFcWBz1aq06sSX5fQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.12.tgz", + "integrity": "sha512-wvIH70c4e91NtRxdaLZF+mbLZ/HcC6yg7ySKUiufL6ESp6zJUSnJucZ309AvG9nqCFHSRB5I6T3Ez1Q9wCh0Ww==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.1.2", - "@smithy/protocol-http": "^5.1.1", - "@smithy/service-error-classification": "^4.0.4", - "@smithy/smithy-client": "^4.3.0", - "@smithy/types": "^4.3.0", - "@smithy/util-middleware": "^4.0.3", - "@smithy/util-retry": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/service-error-classification": "^4.0.5", + "@smithy/smithy-client": "^4.4.3", + "@smithy/types": "^4.3.1", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.5", "tslib": "^2.6.2", "uuid": "^9.0.1" }, @@ -10217,13 +10746,13 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.6.tgz", - "integrity": "sha512-YECyl7uNII+jCr/9qEmCu8xYL79cU0fqjo0qxpcVIU18dAPHam/iYwcknAu4Jiyw1uN+sAx7/SMf/Kmef/Jjsg==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.8.tgz", + "integrity": "sha512-iSSl7HJoJaGyMIoNn2B7czghOVwJ9nD7TMvLhMWeSB5vt0TnEYyRRqPJu/TqW76WScaNvYYB8nRoiBHR9S1Ddw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.1.1", - "@smithy/types": "^4.3.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -10231,12 +10760,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.3.tgz", - "integrity": "sha512-baeV7t4jQfQtFxBADFmnhmqBmqR38dNU5cvEgHcMK/Kp3D3bEI0CouoX2Sr/rGuntR+Eg0IjXdxnGGTc6SbIkw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.4.tgz", + "integrity": "sha512-kagK5ggDrBUCCzI93ft6DjteNSfY8Ulr83UtySog/h09lTIOAJ/xUSObutanlPT0nhoHAkpmW9V5K8oPyLh+QA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -10244,14 +10773,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.2.tgz", - "integrity": "sha512-SUvNup8iU1v7fmM8XPk+27m36udmGCfSz+VZP5Gb0aJ3Ne0X28K/25gnsrg3X1rWlhcnhzNUUysKW/Ied46ivQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.3.tgz", + "integrity": "sha512-HGHQr2s59qaU1lrVH6MbLlmOBxadtzTsoO4c+bF5asdgVik3I8o7JIOzoeqWc5MjVa+vD36/LWE0iXKpNqooRw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.0.3", - "@smithy/shared-ini-file-loader": "^4.0.3", - "@smithy/types": "^4.3.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -10259,15 +10788,15 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.0.5.tgz", - "integrity": "sha512-T7QglZC1vS7SPT44/1qSIAQEx5bFKb3LfO6zw/o4Xzt1eC5HNoH1TkS4lMYA9cWFbacUhx4hRl/blLun4EOCkg==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.0.6.tgz", + "integrity": "sha512-NqbmSz7AW2rvw4kXhKGrYTiJVDHnMsFnX4i+/FzcZAfbOBauPYs2ekuECkSbtqaxETLLTu9Rl/ex6+I2BKErPA==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.0.3", - "@smithy/protocol-http": "^5.1.1", - "@smithy/querystring-builder": "^4.0.3", - "@smithy/types": "^4.3.0", + "@smithy/abort-controller": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -10275,12 +10804,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.3.tgz", - "integrity": "sha512-Wcn17QNdawJZcZZPBuMuzyBENVi1AXl4TdE0jvzo4vWX2x5df/oMlmr/9M5XAAC6+yae4kWZlOYIsNsgDrMU9A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.4.tgz", + "integrity": "sha512-qHJ2sSgu4FqF4U/5UUp4DhXNmdTrgmoAai6oQiM+c5RZ/sbDwJ12qxB1M6FnP+Tn/ggkPZf9ccn4jqKSINaquw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -10288,12 +10817,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.1.tgz", - "integrity": "sha512-Vsay2mzq05DwNi9jK01yCFtfvu9HimmgC7a4HTs7lhX12Sx8aWsH0mfz6q/02yspSp+lOB+Q2HJwi4IV2GKz7A==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.2.tgz", + "integrity": "sha512-rOG5cNLBXovxIrICSBm95dLqzfvxjEmuZx4KK3hWwPFHGdW3lxY0fZNXfv2zebfRO7sJZ5pKJYHScsqopeIWtQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -10301,12 +10830,12 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.3.tgz", - "integrity": "sha512-UUzIWMVfPmDZcOutk2/r1vURZqavvQW0OHvgsyNV0cKupChvqg+/NKPRMaMEe+i8tP96IthMFeZOZWpV+E4RAw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.4.tgz", + "integrity": "sha512-SwREZcDnEYoh9tLNgMbpop+UTGq44Hl9tdj3rf+yeLcfH7+J8OXEBaMc2kDxtyRHu8BhSg9ADEx0gFHvpJgU8w==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.0", + "@smithy/types": "^4.3.1", "@smithy/util-uri-escape": "^4.0.0", "tslib": "^2.6.2" }, @@ -10315,12 +10844,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.3.tgz", - "integrity": "sha512-K5M4ZJQpFCblOJ5Oyw7diICpFg1qhhR47m2/5Ef1PhGE19RaIZf50tjYFrxa6usqcuXyTiFPGo4d1geZdH4YcQ==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.4.tgz", + "integrity": "sha512-6yZf53i/qB8gRHH/l2ZwUG5xgkPgQF15/KxH0DdXMDHjesA9MeZje/853ifkSY0x4m5S+dfDZ+c4x439PF0M2w==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -10328,24 +10857,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.4.tgz", - "integrity": "sha512-W5ScbQ1bTzgH91kNEE2CvOzM4gXlDOqdow4m8vMFSIXCel2scbHwjflpVNnC60Y3F1m5i7w2gQg9lSnR+JsJAA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.5.tgz", + "integrity": "sha512-LvcfhrnCBvCmTee81pRlh1F39yTS/+kYleVeLCwNtkY8wtGg8V/ca9rbZZvYIl8OjlMtL6KIjaiL/lgVqHD2nA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.0" + "@smithy/types": "^4.3.1" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.3.tgz", - "integrity": "sha512-vHwlrqhZGIoLwaH8vvIjpHnloShqdJ7SUPNM2EQtEox+yEDFTVQ7E+DLZ+6OhnYEgFUwPByJyz6UZaOu2tny6A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.4.tgz", + "integrity": "sha512-63X0260LoFBjrHifPDs+nM9tV0VMkOTl4JRMYNuKh/f5PauSjowTfvF3LogfkWdcPoxsA9UjqEOgjeYIbhb7Nw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -10353,16 +10882,16 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.1.tgz", - "integrity": "sha512-zy8Repr5zvT0ja+Tf5wjV/Ba6vRrhdiDcp/ww6cvqYbSEudIkziDe3uppNRlFoCViyJXdPnLcwyZdDLA4CHzSg==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.2.tgz", + "integrity": "sha512-d3+U/VpX7a60seHziWnVZOHuEgJlclufjkS6zhXvxcJgkJq4UWdH5eOBLzHRMx6gXjsdT9h6lfpmLzbrdupHgQ==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.0.0", - "@smithy/protocol-http": "^5.1.1", - "@smithy/types": "^4.3.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", "@smithy/util-hex-encoding": "^4.0.0", - "@smithy/util-middleware": "^4.0.3", + "@smithy/util-middleware": "^4.0.4", "@smithy/util-uri-escape": "^4.0.0", "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" @@ -10372,17 +10901,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.3.0.tgz", - "integrity": "sha512-DNsRA38pN6tYHUjebmwD9e4KcgqTLldYQb2gC6K+oxXYdCTxPn6wV9+FvOa6wrU2FQEnGJoi+3GULzOTKck/tg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.4.3.tgz", + "integrity": "sha512-xxzNYgA0HD6ETCe5QJubsxP0hQH3QK3kbpJz3QrosBCuIWyEXLR/CO5hFb2OeawEKUxMNhz3a1nuJNN2np2RMA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.4.0", - "@smithy/middleware-endpoint": "^4.1.7", - "@smithy/middleware-stack": "^4.0.3", - "@smithy/protocol-http": "^5.1.1", - "@smithy/types": "^4.3.0", - "@smithy/util-stream": "^4.2.1", + "@smithy/core": "^3.5.3", + "@smithy/middleware-endpoint": "^4.1.11", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -10390,9 +10919,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.0.tgz", - "integrity": "sha512-+1iaIQHthDh9yaLhRzaoQxRk+l9xlk+JjMFxGRhNLz+m9vKOkjNeU8QuB4w3xvzHyVR/BVlp/4AXDHjoRIkfgQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", + "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -10402,13 +10931,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.3.tgz", - "integrity": "sha512-n5/DnosDu/tweOqUUNtUbu7eRIR4J/Wz9nL7V5kFYQQVb8VYdj7a4G5NJHCw6o21ul7CvZoJkOpdTnsQDLT0tQ==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.4.tgz", + "integrity": "sha512-eMkc144MuN7B0TDA4U2fKs+BqczVbk3W+qIvcoCY6D1JY3hnAdCuhCZODC+GAeaxj0p6Jroz4+XMUn3PCxQQeQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.0.3", - "@smithy/types": "^4.3.0", + "@smithy/querystring-parser": "^4.0.4", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -10479,14 +11008,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.15.tgz", - "integrity": "sha512-bJJ/B8owQbHAflatSq92f9OcV8858DJBQF1Y3GRjB8psLyUjbISywszYPFw16beREHO/C3I3taW4VGH+tOuwrQ==", + "version": "4.0.19", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.19.tgz", + "integrity": "sha512-mvLMh87xSmQrV5XqnUYEPoiFFeEGYeAKIDDKdhE2ahqitm8OHM3aSvhqL6rrK6wm1brIk90JhxDf5lf2hbrLbQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.0.3", - "@smithy/smithy-client": "^4.3.0", - "@smithy/types": "^4.3.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/smithy-client": "^4.4.3", + "@smithy/types": "^4.3.1", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -10495,17 +11024,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.15.tgz", - "integrity": "sha512-8CUrEW2Ni5q+NmYkj8wsgkfqoP7l4ZquptFbq92yQE66xevc4SxqP2zH6tMtN158kgBqBDsZ+qlrRwXWOjCR8A==", + "version": "4.0.19", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.19.tgz", + "integrity": "sha512-8tYnx+LUfj6m+zkUUIrIQJxPM1xVxfRBvoGHua7R/i6qAxOMjqR6CpEpDwKoIs1o0+hOjGvkKE23CafKL0vJ9w==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.1.3", - "@smithy/credential-provider-imds": "^4.0.5", - "@smithy/node-config-provider": "^4.1.2", - "@smithy/property-provider": "^4.0.3", - "@smithy/smithy-client": "^4.3.0", - "@smithy/types": "^4.3.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/smithy-client": "^4.4.3", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -10513,13 +11042,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.5.tgz", - "integrity": "sha512-PjDpqLk24/vAl340tmtCA++Q01GRRNH9cwL9qh46NspAX9S+IQVcK+GOzPt0GLJ6KYGyn8uOgo2kvJhiThclJw==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.6.tgz", + "integrity": "sha512-YARl3tFL3WgPuLzljRUnrS2ngLiUtkwhQtj8PAL13XZSyUiNLQxwG3fBBq3QXFqGFUXepIN73pINp3y8c2nBmA==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.1.2", - "@smithy/types": "^4.3.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -10539,12 +11068,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.3.tgz", - "integrity": "sha512-iIsC6qZXxkD7V3BzTw3b1uK8RVC1M8WvwNxK1PKrH9FnxntCd30CSunXjL/8iJBE8Z0J14r2P69njwIpRG4FBQ==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.4.tgz", + "integrity": "sha512-9MLKmkBmf4PRb0ONJikCbCwORACcil6gUWojwARCClT7RmLzF04hUR4WdRprIXal7XVyrddadYNfp2eF3nrvtQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.0", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -10552,13 +11081,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.4.tgz", - "integrity": "sha512-Aoqr9W2jDYGrI6OxljN8VmLDQIGO4VdMAUKMf9RGqLG8hn6or+K41NEy1Y5dtum9q8F7e0obYAuKl2mt/GnpZg==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.5.tgz", + "integrity": "sha512-V7MSjVDTlEt/plmOFBn1762Dyu5uqMrV2Pl2X0dYk4XvWfdWJNe9Bs5Bzb56wkCuiWjSfClVMGcsuKrGj7S/yg==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.0.4", - "@smithy/types": "^4.3.0", + "@smithy/service-error-classification": "^4.0.5", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -10566,14 +11095,14 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.1.tgz", - "integrity": "sha512-W3IR0x5DY6iVtjj5p902oNhD+Bz7vs5S+p6tppbPa509rV9BdeXZjGuRSCtVEad9FA0Mba+tNUtUmtnSI1nwUw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.2.tgz", + "integrity": "sha512-aI+GLi7MJoVxg24/3J1ipwLoYzgkB4kUfogZfnslcYlynj3xsQ0e7vk4TnTro9hhsS5PvX1mwmkRqqHQjwcU7w==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.0.3", - "@smithy/node-http-handler": "^4.0.5", - "@smithy/types": "^4.3.0", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/types": "^4.3.1", "@smithy/util-base64": "^4.0.0", "@smithy/util-buffer-from": "^4.0.0", "@smithy/util-hex-encoding": "^4.0.0", @@ -10610,13 +11139,13 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.0.4.tgz", - "integrity": "sha512-73aeIvHjtSB6fd9I08iFaQIGTICKpLrI3EtlWAkStVENGo1ARMq9qdoD4QwkY0RUp6A409xlgbD9NCCfCF5ieg==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.0.5.tgz", + "integrity": "sha512-4QvC49HTteI1gfemu0I1syWovJgPvGn7CVUoN9ZFkdvr/cCFkrEL7qNCdx/2eICqDWEGnnr68oMdSIPCLAriSQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.0.3", - "@smithy/types": "^4.3.0", + "@smithy/abort-controller": "^4.0.4", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { @@ -36037,6 +36566,7 @@ "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.410.0", + "@aws-sdk/client-sesv2": "^3.410.0", "@aws-sdk/cloudfront-signer": "^3.410.0", "@aws-sdk/s3-request-presigner": "^3.410.0", "@aws-sdk/signature-v4-crt": "^3.410.0", diff --git a/packages/app-tests/e2e/features/include-document-certificate.spec.ts b/packages/app-tests/e2e/features/include-document-certificate.spec.ts index 0f8c30be7..b447b09c2 100644 --- a/packages/app-tests/e2e/features/include-document-certificate.spec.ts +++ b/packages/app-tests/e2e/features/include-document-certificate.spec.ts @@ -244,7 +244,7 @@ test.describe('Signing Certificate Tests', () => { await apiSignin({ page, email: owner.email, - redirectPath: `/t/${team.url}/settings/preferences`, + redirectPath: `/t/${team.url}/settings/document`, }); await page diff --git a/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts b/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts index dff2d5ddd..90cb6ae34 100644 --- a/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts +++ b/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts @@ -8,7 +8,7 @@ import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from '../fixtures/authentication'; -test('[ORGANISATIONS]: manage preferences', async ({ page }) => { +test('[ORGANISATIONS]: manage document preferences', async ({ page }) => { const { user, organisation, team } = await seedUser({ isPersonalOrganisation: false, }); @@ -16,7 +16,7 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => { await apiSignin({ page, email: user.email, - redirectPath: `/o/${organisation.url}/settings/preferences`, + redirectPath: `/o/${organisation.url}/settings/document`, }); // Update document preferences. @@ -24,26 +24,25 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => { await page.getByRole('option', { name: 'Only managers and above can' }).click(); await page.getByRole('combobox').filter({ hasText: 'English' }).click(); await page.getByRole('option', { name: 'German' }).click(); - await page.getByTestId('signature-types-combobox').click(); + + // Set default timezone + await page.getByRole('combobox').filter({ hasText: 'Local timezone' }).click(); + await page.getByRole('option', { name: 'Australia/Perth' }).click(); + + // Set default date + await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm a' }).click(); + await page.getByRole('option', { name: 'DD/MM/YYYY' }).click(); + + await page.getByTestId('signature-types-trigger').click(); await page.getByRole('option', { name: 'Draw' }).click(); await page.getByRole('option', { name: 'Upload' }).click(); - await page.getByRole('combobox').nth(3).click(); + await page.getByTestId('include-sender-details-trigger').click(); await page.getByRole('option', { name: 'No' }).click(); - await page.getByRole('combobox').filter({ hasText: 'Yes' }).click(); + await page.getByTestId('include-signing-certificate-trigger').click(); await page.getByRole('option', { name: 'No' }).click(); await page.getByRole('button', { name: 'Update' }).first().click(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); - // Update branding. - await page.getByTestId('enable-branding').click(); - await page.getByRole('option', { name: 'Yes' }).click(); - await page.getByRole('textbox', { name: 'Brand Website' }).click(); - await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com'); - await page.getByRole('textbox', { name: 'Brand Details' }).click(); - await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails'); - await page.getByRole('button', { name: 'Update' }).nth(1).click(); - await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible(); - const teamSettings = await getTeamSettings({ teamId: team.id, }); @@ -51,34 +50,30 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => { // Check that the team settings have inherited these values. expect(teamSettings.documentVisibility).toEqual(DocumentVisibility.MANAGER_AND_ABOVE); expect(teamSettings.documentLanguage).toEqual('de'); + expect(teamSettings.documentTimezone).toEqual('Australia/Perth'); + expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy hh:mm a'); expect(teamSettings.includeSenderDetails).toEqual(false); expect(teamSettings.includeSigningCertificate).toEqual(false); expect(teamSettings.typedSignatureEnabled).toEqual(true); expect(teamSettings.uploadSignatureEnabled).toEqual(false); expect(teamSettings.drawSignatureEnabled).toEqual(false); - expect(teamSettings.brandingEnabled).toEqual(true); - expect(teamSettings.brandingUrl).toEqual('https://documenso.com'); - expect(teamSettings.brandingCompanyDetails).toEqual('BrandDetails'); // Edit the team settings - await page.goto(`/t/${team.url}/settings/preferences`); + await page.goto(`/t/${team.url}/settings/document`); - await page - .getByRole('group') - .locator('div') - .filter({ - hasText: 'Default Document Visibility', - }) - .getByRole('combobox') - .click(); + await page.getByTestId('document-visibility-trigger').click(); await page.getByRole('option', { name: 'Everyone can access and view' }).click(); - await page - .getByRole('group') - .locator('div') - .filter({ hasText: 'Default Document Language' }) - .getByRole('combobox') - .click(); + await page.getByTestId('document-language-trigger').click(); await page.getByRole('option', { name: 'Polish' }).click(); + + // Override team timezone settings + await page.getByTestId('document-timezone-trigger').click(); + await page.getByRole('option', { name: 'Europe/London' }).click(); + + // Override team date format settings + await page.getByTestId('document-date-format-trigger').click(); + await page.getByRole('option', { name: 'MM/DD/YYYY' }).click(); + await page.getByRole('button', { name: 'Update' }).first().click(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); @@ -89,6 +84,8 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => { // Check that the team settings have inherited/overriden the correct values. expect(updatedTeamSettings.documentVisibility).toEqual(DocumentVisibility.EVERYONE); expect(updatedTeamSettings.documentLanguage).toEqual('pl'); + expect(updatedTeamSettings.documentTimezone).toEqual('Europe/London'); + expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy hh:mm a'); expect(updatedTeamSettings.includeSenderDetails).toEqual(false); expect(updatedTeamSettings.includeSigningCertificate).toEqual(false); expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true); @@ -110,4 +107,228 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => { expect(documentMeta.uploadSignatureEnabled).toEqual(false); expect(documentMeta.drawSignatureEnabled).toEqual(false); expect(documentMeta.language).toEqual('pl'); + expect(documentMeta.timezone).toEqual('Europe/London'); + expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy hh:mm a'); +}); + +test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => { + const { user, organisation, team } = await seedUser({ + isPersonalOrganisation: false, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/o/${organisation.url}/settings/branding`, + }); + + // Update branding preferences. + await page.getByTestId('enable-branding').click(); + await page.getByRole('option', { name: 'Yes' }).click(); + await page.getByRole('textbox', { name: 'Brand Website' }).click(); + await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com'); + await page.getByRole('textbox', { name: 'Brand Details' }).click(); + await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails'); + await page.getByRole('button', { name: 'Update' }).first().click(); + await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible(); + + const teamSettings = await getTeamSettings({ + teamId: team.id, + }); + + // Check that the team settings have inherited these values. + expect(teamSettings.brandingEnabled).toEqual(true); + expect(teamSettings.brandingUrl).toEqual('https://documenso.com'); + expect(teamSettings.brandingCompanyDetails).toEqual('BrandDetails'); + + // Edit the team branding settings + await page.goto(`/t/${team.url}/settings/branding`); + + // Override team settings with different values + await page.getByTestId('enable-branding').click(); + await page.getByRole('option', { name: 'Yes' }).click(); + await page.getByRole('textbox', { name: 'Brand Website' }).click(); + await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://example.com'); + await page.getByRole('textbox', { name: 'Brand Details' }).click(); + await page.getByRole('textbox', { name: 'Brand Details' }).fill('UpdatedBrandDetails'); + await page.getByRole('button', { name: 'Update' }).first().click(); + await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible(); + + const updatedTeamSettings = await getTeamSettings({ + teamId: team.id, + }); + + // Check that the team settings have overridden the organisation values. + expect(updatedTeamSettings.brandingEnabled).toEqual(true); + expect(updatedTeamSettings.brandingUrl).toEqual('https://example.com'); + expect(updatedTeamSettings.brandingCompanyDetails).toEqual('UpdatedBrandDetails'); + + // Test inheritance by setting team back to inherit from organisation + await page.getByTestId('enable-branding').click(); + await page.getByRole('option', { name: 'Inherit from organisation' }).click(); + await page.getByRole('button', { name: 'Update' }).first().click(); + await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible(); + + await page.waitForTimeout(2000); + + const inheritedTeamSettings = await getTeamSettings({ + teamId: team.id, + }); + + // Check that the team settings now inherit from organisation again. + expect(inheritedTeamSettings.brandingEnabled).toEqual(true); + expect(inheritedTeamSettings.brandingUrl).toEqual('https://documenso.com'); + expect(inheritedTeamSettings.brandingCompanyDetails).toEqual('BrandDetails'); + + // Verify that a document can be created successfully with the branding settings + const document = await seedTeamDocumentWithMeta(team); + + // Confirm the document was created successfully with the team's branding settings + expect(document).toBeDefined(); + expect(document.teamId).toEqual(team.id); +}); + +test('[ORGANISATIONS]: manage email preferences', async ({ page }) => { + const { user, organisation, team } = await seedUser({ + isPersonalOrganisation: false, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/o/${organisation.url}/settings/email`, + }); + + // Update email preferences at organisation level. + // Set reply to email + await page.getByRole('textbox', { name: 'Reply to email' }).click(); + await page.getByRole('textbox', { name: 'Reply to email' }).fill('organisation@documenso.com'); + + // Update email document settings by enabling/disabling some checkboxes + await page.getByRole('checkbox', { name: 'Send recipient signed email' }).uncheck(); + await page.getByRole('checkbox', { name: 'Send document pending email' }).uncheck(); + await page.getByRole('checkbox', { name: 'Send document deleted email' }).uncheck(); + + await page.getByRole('button', { name: 'Update' }).first().click(); + await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible(); + + const teamSettings = await getTeamSettings({ + teamId: team.id, + }); + + // Check that the team settings have inherited these values. + expect(teamSettings.emailReplyTo).toEqual('organisation@documenso.com'); + expect(teamSettings.emailDocumentSettings).toEqual({ + recipientSigningRequest: true, + recipientRemoved: true, + recipientSigned: false, // unchecked + documentPending: false, // unchecked + documentCompleted: true, + documentDeleted: false, // unchecked + ownerDocumentCompleted: true, + }); + + // Edit the team email settings + await page.goto(`/t/${team.url}/settings/email`); + + // Override team settings with different values + await page.getByRole('textbox', { name: 'Reply to email' }).click(); + await page.getByRole('textbox', { name: 'Reply to email' }).fill('team@example.com'); + + // Change email document settings inheritance to controlled + await page.getByRole('combobox').filter({ hasText: 'Inherit from organisation' }).click(); + await page.getByRole('option', { name: 'Override organisation settings' }).click(); + + // Update some email settings + await page.getByRole('checkbox', { name: 'Send recipient signing request email' }).uncheck(); + await page + .getByRole('checkbox', { name: 'Send document completed email', exact: true }) + .uncheck(); + await page + .getByRole('checkbox', { name: 'Send document completed email to the owner' }) + .uncheck(); + + await page.getByRole('button', { name: 'Update' }).first().click(); + await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible(); + + const updatedTeamSettings = await getTeamSettings({ + teamId: team.id, + }); + + // Check that the team settings have overridden the organisation values. + expect(updatedTeamSettings.emailReplyTo).toEqual('team@example.com'); + expect(updatedTeamSettings.emailDocumentSettings).toEqual({ + recipientSigned: true, + recipientSigningRequest: false, + recipientRemoved: true, + documentPending: true, + documentCompleted: false, + documentDeleted: true, + ownerDocumentCompleted: false, + }); + + // Verify that a document can be created successfully with the team email settings + const teamOverrideDocument = await seedTeamDocumentWithMeta(team); + + const teamOverrideDocumentMeta = await prisma.documentMeta.findFirstOrThrow({ + where: { + documentId: teamOverrideDocument.id, + }, + }); + + expect(teamOverrideDocumentMeta.emailReplyTo).toEqual('team@example.com'); + expect(teamOverrideDocumentMeta.emailSettings).toEqual({ + recipientSigned: true, + recipientSigningRequest: false, + recipientRemoved: true, + documentPending: true, + documentCompleted: false, + documentDeleted: true, + ownerDocumentCompleted: false, + }); + + // Test inheritance by setting team back to inherit from organisation + await page.getByRole('textbox', { name: 'Reply to email' }).fill(''); + await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click(); + await page.getByRole('option', { name: 'Inherit from organisation' }).click(); + await page.getByRole('button', { name: 'Update' }).first().click(); + await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible(); + + await page.waitForTimeout(1000); + + const inheritedTeamSettings = await getTeamSettings({ + teamId: team.id, + }); + + // Check that the team settings now inherit from organisation again. + expect(inheritedTeamSettings.emailReplyTo).toEqual('organisation@documenso.com'); + expect(inheritedTeamSettings.emailDocumentSettings).toEqual({ + recipientSigningRequest: true, + recipientRemoved: true, + recipientSigned: false, + documentPending: false, + documentCompleted: true, + documentDeleted: false, + ownerDocumentCompleted: true, + }); + + // Verify that a document can be created successfully with the email settings + const document = await seedTeamDocumentWithMeta(team); + + const documentMeta = await prisma.documentMeta.findFirstOrThrow({ + where: { + documentId: document.id, + }, + }); + + expect(documentMeta.emailReplyTo).toEqual('organisation@documenso.com'); + expect(documentMeta.emailSettings).toEqual({ + recipientSigningRequest: true, + recipientRemoved: true, + recipientSigned: false, + documentPending: false, + documentCompleted: true, + documentDeleted: false, + ownerDocumentCompleted: true, + }); }); diff --git a/packages/app-tests/e2e/teams/team-signature-settings.spec.ts b/packages/app-tests/e2e/teams/team-signature-settings.spec.ts index 987fedc23..bebf91371 100644 --- a/packages/app-tests/e2e/teams/team-signature-settings.spec.ts +++ b/packages/app-tests/e2e/teams/team-signature-settings.spec.ts @@ -15,7 +15,7 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn await apiSignin({ page, email: user.email, - redirectPath: `/t/${team.url}/settings/preferences`, + redirectPath: `/t/${team.url}/settings/document`, }); const document = await seedTeamDocumentWithMeta(team); @@ -45,17 +45,17 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => { await apiSignin({ page, email: user.email, - redirectPath: `/t/${team.url}/settings/preferences`, + redirectPath: `/t/${team.url}/settings/document`, }); const allTabs = ['Type', 'Upload', 'Draw']; const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']]; for (const tabs of tabTest) { - await page.goto(`/t/${team.url}/settings/preferences`); + await page.goto(`/t/${team.url}/settings/document`); // Update combobox to have the correct tabs - await page.getByTestId('signature-types-combobox').click(); + await page.getByTestId('signature-types-trigger').click(); await expect(page.getByRole('option', { name: 'Type' })).toBeVisible(); await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible(); @@ -112,17 +112,17 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => { await apiSignin({ page, email: user.email, - redirectPath: `/t/${team.url}/settings/preferences`, + redirectPath: `/t/${team.url}/settings/document`, }); const allTabs = ['Type', 'Upload', 'Draw']; const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']]; for (const tabs of tabTest) { - await page.goto(`/t/${team.url}/settings/preferences`); + await page.goto(`/t/${team.url}/settings/document`); // Update combobox to have the correct tabs - await page.getByTestId('signature-types-combobox').click(); + await page.getByTestId('signature-types-trigger').click(); await expect(page.getByRole('option', { name: 'Type' })).toBeVisible(); await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible(); diff --git a/packages/ee/server-only/lib/create-email-domain.ts b/packages/ee/server-only/lib/create-email-domain.ts new file mode 100644 index 000000000..b50a55965 --- /dev/null +++ b/packages/ee/server-only/lib/create-email-domain.ts @@ -0,0 +1,154 @@ +import { CreateEmailIdentityCommand, SESv2Client } from '@aws-sdk/client-sesv2'; +import { EmailDomainStatus } from '@prisma/client'; +import { generateKeyPair } from 'crypto'; +import { promisify } from 'util'; + +import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; +import { generateDatabaseId } from '@documenso/lib/universal/id'; +import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains'; +import { env } from '@documenso/lib/utils/env'; +import { prisma } from '@documenso/prisma'; + +export const getSesClient = () => { + const accessKeyId = env('NEXT_PRIVATE_SES_ACCESS_KEY_ID'); + const secretAccessKey = env('NEXT_PRIVATE_SES_SECRET_ACCESS_KEY'); + const region = env('NEXT_PRIVATE_SES_REGION'); + + if (!accessKeyId || !secretAccessKey || !region) { + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Missing AWS SES credentials', + }); + } + + return new SESv2Client({ + region, + credentials: { + accessKeyId, + secretAccessKey, + }, + }); +}; + +/** + * Removes first and last line, then removes all newlines + */ +const flattenKey = (key: string) => { + return key.trim().split('\n').slice(1, -1).join(''); +}; + +export async function verifyDomainWithDKIM(domain: string, selector: string, privateKey: string) { + const command = new CreateEmailIdentityCommand({ + EmailIdentity: domain, + DkimSigningAttributes: { + DomainSigningSelector: selector, + DomainSigningPrivateKey: privateKey, + }, + }); + + return await getSesClient().send(command); +} + +type CreateEmailDomainOptions = { + domain: string; + organisationId: string; +}; + +type DomainRecord = { + name: string; + value: string; + type: string; +}; + +export const createEmailDomain = async ({ domain, organisationId }: CreateEmailDomainOptions) => { + const encryptionKey = DOCUMENSO_ENCRYPTION_KEY; + + if (!encryptionKey) { + throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY'); + } + + const selector = `documenso-${organisationId}`.replace(/[_.]/g, '-'); + const recordName = `${selector}._domainkey.${domain}`; + + // Check if domain already exists + const existingDomain = await prisma.emailDomain.findUnique({ + where: { + domain, + }, + }); + + if (existingDomain) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, { + message: 'Domain already exists in database', + }); + } + + // Generate DKIM key pair + const generateKeyPairAsync = promisify(generateKeyPair); + + const { publicKey, privateKey } = await generateKeyPairAsync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + + // Format public key for DNS record + const publicKeyFlattened = flattenKey(publicKey); + const privateKeyFlattened = flattenKey(privateKey); + + // Create DNS records + const records: DomainRecord[] = generateEmailDomainRecords(recordName, publicKeyFlattened); + + const encryptedPrivateKey = symmetricEncrypt({ + key: encryptionKey, + data: privateKeyFlattened, + }); + + const emailDomain = await prisma.$transaction(async (tx) => { + await verifyDomainWithDKIM(domain, selector, privateKeyFlattened).catch((err) => { + if (err.name === 'AlreadyExistsException') { + throw new AppError(AppErrorCode.ALREADY_EXISTS, { + message: 'Domain already exists in SES', + }); + } + + throw err; + }); + + // Create email domain record. + return await tx.emailDomain.create({ + data: { + id: generateDatabaseId('email_domain'), + domain, + status: EmailDomainStatus.PENDING, + organisationId, + selector: recordName, + publicKey: publicKeyFlattened, + privateKey: encryptedPrivateKey, + }, + select: { + id: true, + status: true, + organisationId: true, + domain: true, + selector: true, + publicKey: true, + createdAt: true, + updatedAt: true, + emails: true, + }, + }); + }); + + return { + emailDomain, + records, + }; +}; diff --git a/packages/ee/server-only/lib/delete-email-domain.ts b/packages/ee/server-only/lib/delete-email-domain.ts new file mode 100644 index 000000000..0598d9425 --- /dev/null +++ b/packages/ee/server-only/lib/delete-email-domain.ts @@ -0,0 +1,52 @@ +import { DeleteEmailIdentityCommand } from '@aws-sdk/client-sesv2'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +import { getSesClient } from './create-email-domain'; + +type DeleteEmailDomainOptions = { + emailDomainId: string; +}; + +/** + * Delete the email domain and SES email identity. + * + * Permission is assumed to be checked in the caller. + */ +export const deleteEmailDomain = async ({ emailDomainId }: DeleteEmailDomainOptions) => { + const emailDomain = await prisma.emailDomain.findUnique({ + where: { + id: emailDomainId, + }, + }); + + if (!emailDomain) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Email domain not found', + }); + } + + const sesClient = getSesClient(); + + await sesClient + .send( + new DeleteEmailIdentityCommand({ + EmailIdentity: emailDomain.domain, + }), + ) + .catch((err) => { + console.error(err); + + // Do nothing if it no longer exists in SES. + if (err.name === 'NotFoundException') { + return; + } + }); + + await prisma.emailDomain.delete({ + where: { + id: emailDomainId, + }, + }); +}; diff --git a/packages/ee/server-only/lib/verify-email-domain.ts b/packages/ee/server-only/lib/verify-email-domain.ts new file mode 100644 index 000000000..0c898016e --- /dev/null +++ b/packages/ee/server-only/lib/verify-email-domain.ts @@ -0,0 +1,45 @@ +import { GetEmailIdentityCommand } from '@aws-sdk/client-sesv2'; +import { EmailDomainStatus } from '@prisma/client'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +import { getSesClient } from './create-email-domain'; + +export const verifyEmailDomain = async (emailDomainId: string) => { + const emailDomain = await prisma.emailDomain.findUnique({ + where: { + id: emailDomainId, + }, + }); + + if (!emailDomain) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Email domain not found', + }); + } + + const sesClient = getSesClient(); + + const response = await sesClient.send( + new GetEmailIdentityCommand({ + EmailIdentity: emailDomain.domain, + }), + ); + + const isVerified = response.VerificationStatus === 'SUCCESS'; + + const updatedEmailDomain = await prisma.emailDomain.update({ + where: { + id: emailDomainId, + }, + data: { + status: isVerified ? EmailDomainStatus.ACTIVE : EmailDomainStatus.PENDING, + }, + }); + + return { + emailDomain: updatedEmailDomain, + isVerified, + }; +}; diff --git a/packages/lib/constants/email.ts b/packages/lib/constants/email.ts index f385e1748..3eb597ce1 100644 --- a/packages/lib/constants/email.ts +++ b/packages/lib/constants/email.ts @@ -3,6 +3,11 @@ import { env } from '../utils/env'; export const FROM_ADDRESS = env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com'; export const FROM_NAME = env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso'; +export const DOCUMENSO_INTERNAL_EMAIL = { + name: FROM_NAME, + address: FROM_ADDRESS, +}; + export const SERVICE_USER_EMAIL = 'serviceaccount@documenso.com'; export const EMAIL_VERIFICATION_STATE = { diff --git a/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts index 245d34463..43cbc7846 100644 --- a/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts @@ -9,7 +9,6 @@ import { prisma } from '@documenso/prisma'; import { getI18nInstance } from '../../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; -import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email'; import { getEmailContext } from '../../../server-only/email/get-email-context'; import { extractDerivedDocumentEmailSettings } from '../../../types/document-email'; import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; @@ -43,11 +42,13 @@ export const run = async ({ }, }); - const { branding, settings } = await getEmailContext({ + const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({ + emailType: 'RECIPIENT', source: { type: 'team', teamId: document.teamId, }, + meta: document.documentMeta || null, }); const { documentMeta, user: documentOwner } = document; @@ -59,9 +60,7 @@ export const run = async ({ return; } - const lang = documentMeta?.language ?? settings.documentLanguage; - - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); // Send cancellation emails to all recipients who have been sent the document or viewed it const recipientsToNotify = document.recipients.filter( @@ -82,9 +81,9 @@ export const run = async ({ }); const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding }), renderEmailWithI18N(template, { - lang, + lang: emailLanguage, branding, plainText: true, }), @@ -95,10 +94,8 @@ export const run = async ({ name: recipient.name, address: recipient.email, }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, + replyTo: replyToEmail, subject: i18n._(msg`Document "${document.title}" Cancelled`), html, text, diff --git a/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts b/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts index 37b1db387..444ce2985 100644 --- a/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts @@ -8,7 +8,6 @@ import { prisma } from '@documenso/prisma'; import { getI18nInstance } from '../../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; -import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email'; import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations'; import { getEmailContext } from '../../../server-only/email/get-email-context'; import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; @@ -56,7 +55,8 @@ export const run = async ({ }, }); - const { branding, settings } = await getEmailContext({ + const { branding, emailLanguage, senderEmail } = await getEmailContext({ + emailType: 'INTERNAL', source: { type: 'organisation', organisationId: organisation.id, @@ -80,29 +80,24 @@ export const run = async ({ organisationUrl: organisation.url, }); - const lang = settings.documentLanguage; - // !: Replace with the actual language of the recipient later const [html, text] = await Promise.all([ renderEmailWithI18N(emailContent, { - lang, + lang: emailLanguage, branding, }), renderEmailWithI18N(emailContent, { - lang, + lang: emailLanguage, branding, plainText: true, }), ]); - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); await mailer.sendMail({ to: member.user.email, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, subject: i18n._(msg`A new member has joined your organisation`), html, text, diff --git a/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts b/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts index 9ce4162ab..2bfc88aef 100644 --- a/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts @@ -8,7 +8,6 @@ import { prisma } from '@documenso/prisma'; import { getI18nInstance } from '../../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; -import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email'; import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations'; import { getEmailContext } from '../../../server-only/email/get-email-context'; import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; @@ -52,7 +51,8 @@ export const run = async ({ }, }); - const { branding, settings } = await getEmailContext({ + const { branding, emailLanguage, senderEmail } = await getEmailContext({ + emailType: 'INTERNAL', source: { type: 'organisation', organisationId: organisation.id, @@ -76,28 +76,23 @@ export const run = async ({ organisationUrl: organisation.url, }); - const lang = settings.documentLanguage; - const [html, text] = await Promise.all([ renderEmailWithI18N(emailContent, { - lang, + lang: emailLanguage, branding, }), renderEmailWithI18N(emailContent, { - lang, + lang: emailLanguage, branding, plainText: true, }), ]); - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); await mailer.sendMail({ to: member.user.email, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, subject: i18n._(msg`A member has left your organisation`), html, text, diff --git a/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts b/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts index 6c094c606..7845333c1 100644 --- a/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts @@ -8,7 +8,6 @@ import { prisma } from '@documenso/prisma'; import { getI18nInstance } from '../../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; -import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email'; import { getEmailContext } from '../../../server-only/email/get-email-context'; import { extractDerivedDocumentEmailSettings } from '../../../types/document-email'; import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; @@ -71,17 +70,18 @@ export const run = async ({ return; } - const { branding, settings } = await getEmailContext({ + const { branding, emailLanguage, senderEmail } = await getEmailContext({ + emailType: 'INTERNAL', source: { type: 'team', teamId: document.teamId, }, + meta: document.documentMeta || null, }); const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; - const lang = document.documentMeta?.language ?? settings.documentLanguage; - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); const template = createElement(DocumentRecipientSignedEmailTemplate, { documentName: document.title, @@ -92,9 +92,9 @@ export const run = async ({ await io.runTask('send-recipient-signed-email', async () => { const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding }), renderEmailWithI18N(template, { - lang, + lang: emailLanguage, branding, plainText: true, }), @@ -105,10 +105,7 @@ export const run = async ({ name: owner.name ?? '', address: owner.email, }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, subject: i18n._(msg`${recipientReference} has signed "${document.title}"`), html, text, diff --git a/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts index 25ff654a7..8faa098f8 100644 --- a/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts @@ -10,7 +10,7 @@ import { prisma } from '@documenso/prisma'; import { getI18nInstance } from '../../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; -import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email'; +import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email'; import { getEmailContext } from '../../../server-only/email/get-email-context'; import { extractDerivedDocumentEmailSettings } from '../../../types/document-email'; import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; @@ -52,7 +52,7 @@ export const run = async ({ }), ]); - const { documentMeta, user: documentOwner } = document; + const { user: documentOwner } = document; const isEmailEnabled = extractDerivedDocumentEmailSettings( document.documentMeta, @@ -62,16 +62,16 @@ export const run = async ({ return; } - const { branding, settings } = await getEmailContext({ + const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({ + emailType: 'RECIPIENT', source: { type: 'team', teamId: document.teamId, }, + meta: document.documentMeta || null, }); - const lang = documentMeta?.language ?? settings.documentLanguage; - - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); // Send confirmation email to the recipient who rejected await io.runTask('send-rejection-confirmation-email', async () => { @@ -84,9 +84,9 @@ export const run = async ({ }); const [html, text] = await Promise.all([ - renderEmailWithI18N(recipientTemplate, { lang, branding }), + renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }), renderEmailWithI18N(recipientTemplate, { - lang, + lang: emailLanguage, branding, plainText: true, }), @@ -97,10 +97,8 @@ export const run = async ({ name: recipient.name, address: recipient.email, }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, + replyTo: replyToEmail, subject: i18n._(msg`Document "${document.title}" - Rejection Confirmed`), html, text, @@ -120,9 +118,9 @@ export const run = async ({ }); const [html, text] = await Promise.all([ - renderEmailWithI18N(ownerTemplate, { lang, branding }), + renderEmailWithI18N(ownerTemplate, { lang: emailLanguage, branding }), renderEmailWithI18N(ownerTemplate, { - lang, + lang: emailLanguage, branding, plainText: true, }), @@ -133,10 +131,7 @@ export const run = async ({ name: documentOwner.name || '', address: documentOwner.email, }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: DOCUMENSO_INTERNAL_EMAIL, // Purposefully using internal email here. subject: i18n._(msg`Document "${document.title}" - Rejected by ${recipient.name}`), html, text, diff --git a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts index edc585a11..dc5bde691 100644 --- a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts @@ -15,7 +15,6 @@ import { prisma } from '@documenso/prisma'; import { getI18nInstance } from '../../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; -import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email'; import { RECIPIENT_ROLES_DESCRIPTION, RECIPIENT_ROLE_TO_EMAIL_TYPE, @@ -80,12 +79,15 @@ export const run = async ({ return; } - const { branding, settings, organisationType } = await getEmailContext({ - source: { - type: 'team', - teamId: document.teamId, - }, - }); + const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail } = + await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId: document.teamId, + }, + meta: document.documentMeta || null, + }); const customEmail = document?.documentMeta; const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK; @@ -95,9 +97,7 @@ export const run = async ({ const { email, name } = recipient; const selfSigner = email === user.email; - const lang = documentMeta?.language ?? settings.documentLanguage; - - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); const recipientActionVerb = i18n ._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb) @@ -166,9 +166,9 @@ export const run = async ({ await io.runTask('send-signing-email', async () => { const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding }), renderEmailWithI18N(template, { - lang, + lang: emailLanguage, branding, plainText: true, }), @@ -179,10 +179,8 @@ export const run = async ({ name: recipient.name, address: recipient.email, }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, + replyTo: replyToEmail, subject: renderCustomEmailTemplate( documentMeta?.subject || emailSubject, customEmailTemplate, diff --git a/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts b/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts index a7ee7d6b2..98e6daba9 100644 --- a/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts +++ b/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts @@ -13,7 +13,6 @@ import { prisma } from '@documenso/prisma'; import { getI18nInstance } from '../../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; -import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email'; import { AppError } from '../../../errors/app-error'; import { getEmailContext } from '../../../server-only/email/get-email-context'; import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; @@ -162,24 +161,23 @@ export const run = async ({ assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(), }); - const { branding, settings } = await getEmailContext({ + const { branding, emailLanguage, senderEmail } = await getEmailContext({ + emailType: 'INTERNAL', source: { type: 'team', teamId, }, }); - const lang = template.templateMeta?.language ?? settings.documentLanguage; - - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); const [html, text] = await Promise.all([ renderEmailWithI18N(completionTemplate, { - lang, + lang: emailLanguage, branding, }), renderEmailWithI18N(completionTemplate, { - lang, + lang: emailLanguage, branding, plainText: true, }), @@ -190,10 +188,7 @@ export const run = async ({ name: user.name || '', address: user.email, }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, subject: i18n._(msg`Bulk Send Complete: ${template.title}`), html, text, diff --git a/packages/lib/package.json b/packages/lib/package.json index 5d2a4053e..374793db8 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.410.0", + "@aws-sdk/client-sesv2": "^3.410.0", "@aws-sdk/cloudfront-signer": "^3.410.0", "@aws-sdk/s3-request-presigner": "^3.410.0", "@aws-sdk/signature-v4-crt": "^3.410.0", 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 a9edede98..08ceadcba 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -23,6 +23,8 @@ export type CreateDocumentMetaOptions = { password?: string; dateFormat?: string; redirectUrl?: string; + emailId?: string | null; + emailReplyTo?: string | null; emailSettings?: TDocumentEmailSettings; signingOrder?: DocumentSigningOrder; allowDictateNextSigner?: boolean; @@ -46,6 +48,8 @@ export const upsertDocumentMeta = async ({ redirectUrl, signingOrder, allowDictateNextSigner, + emailId, + emailReplyTo, emailSettings, distributionMethod, typedSignatureEnabled, @@ -54,7 +58,7 @@ export const upsertDocumentMeta = async ({ language, requestMetadata, }: CreateDocumentMetaOptions) => { - const { documentWhereInput } = await getDocumentWhereInput({ + const { documentWhereInput, team } = await getDocumentWhereInput({ documentId, userId, teamId, @@ -75,6 +79,22 @@ export const upsertDocumentMeta = async ({ const { documentMeta: originalDocumentMeta } = document; + // Validate the emailId belongs to the organisation. + if (emailId) { + const email = await prisma.organisationEmail.findFirst({ + where: { + id: emailId, + organisationId: team.organisationId, + }, + }); + + if (!email) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Email not found', + }); + } + } + return await prisma.$transaction(async (tx) => { const upsertedDocumentMeta = await tx.documentMeta.upsert({ where: { @@ -90,6 +110,8 @@ export const upsertDocumentMeta = async ({ redirectUrl, signingOrder, allowDictateNextSigner, + emailId, + emailReplyTo, emailSettings, distributionMethod, typedSignatureEnabled, @@ -106,6 +128,8 @@ export const upsertDocumentMeta = async ({ redirectUrl, signingOrder, allowDictateNextSigner, + emailId, + emailReplyTo, emailSettings, distributionMethod, typedSignatureEnabled, diff --git a/packages/lib/server-only/document/create-document-v2.ts b/packages/lib/server-only/document/create-document-v2.ts index 1fdb1f962..419bc8935 100644 --- a/packages/lib/server-only/document/create-document-v2.ts +++ b/packages/lib/server-only/document/create-document-v2.ts @@ -24,6 +24,7 @@ import { } from '../../types/webhook-payload'; import { getFileServerSide } from '../../universal/upload/get-file.server'; import { putPdfFileServerSide } from '../../universal/upload/put-file.server'; +import { extractDerivedDocumentMeta } from '../../utils/document'; import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth'; import { determineDocumentVisibility } from '../../utils/document-visibility'; import { buildTeamWhereQuery } from '../../utils/teams'; @@ -134,6 +135,24 @@ export const createDocumentV2 = async ({ const visibility = determineDocumentVisibility(settings.documentVisibility, teamRole); + const emailId = meta?.emailId; + + // Validate that the email ID belongs to the organisation. + if (emailId) { + const email = await prisma.organisationEmail.findFirst({ + where: { + id: emailId, + organisationId: team.organisationId, + }, + }); + + if (!email) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Email not found', + }); + } + } + return await prisma.$transaction(async (tx) => { const document = await tx.document.create({ data: { @@ -148,15 +167,7 @@ export const createDocumentV2 = async ({ formValues, source: DocumentSource.DOCUMENT, documentMeta: { - create: { - ...meta, - signingOrder: meta?.signingOrder || undefined, - emailSettings: meta?.emailSettings || undefined, - language: meta?.language || settings.documentLanguage, - typedSignatureEnabled: meta?.typedSignatureEnabled ?? settings.typedSignatureEnabled, - uploadSignatureEnabled: meta?.uploadSignatureEnabled ?? settings.uploadSignatureEnabled, - drawSignatureEnabled: meta?.drawSignatureEnabled ?? settings.drawSignatureEnabled, - }, + create: extractDerivedDocumentMeta(settings, meta), }, }, }); diff --git a/packages/lib/server-only/document/create-document.ts b/packages/lib/server-only/document/create-document.ts index e0fb096fc..1f1bab522 100644 --- a/packages/lib/server-only/document/create-document.ts +++ b/packages/lib/server-only/document/create-document.ts @@ -15,6 +15,7 @@ import { import { prefixedId } from '../../universal/id'; import { getFileServerSide } from '../../universal/upload/get-file.server'; import { putPdfFileServerSide } from '../../universal/upload/put-file.server'; +import { extractDerivedDocumentMeta } from '../../utils/document'; import { determineDocumentVisibility } from '../../utils/document-visibility'; import { buildTeamWhereQuery } from '../../utils/teams'; import { getTeamById } from '../team/get-team'; @@ -30,6 +31,7 @@ export type CreateDocumentOptions = { formValues?: Record; normalizePdf?: boolean; timezone?: string; + userTimezone?: string; requestMetadata: ApiRequestMetadata; folderId?: string; }; @@ -44,6 +46,7 @@ export const createDocument = async ({ formValues, requestMetadata, timezone, + userTimezone, folderId, }: CreateDocumentOptions) => { const team = await getTeamById({ userId, teamId }); @@ -101,6 +104,10 @@ export const createDocument = async ({ } } + // userTimezone is last because it's always passed in regardless of the organisation/team settings + // for uploads from the frontend + const timezoneToUse = timezone || settings.documentTimezone || userTimezone; + return await prisma.$transaction(async (tx) => { const document = await tx.document.create({ data: { @@ -117,13 +124,9 @@ export const createDocument = async ({ formValues, source: DocumentSource.DOCUMENT, documentMeta: { - create: { - language: settings.documentLanguage, - timezone: timezone, - typedSignatureEnabled: settings.typedSignatureEnabled, - uploadSignatureEnabled: settings.uploadSignatureEnabled, - drawSignatureEnabled: settings.drawSignatureEnabled, - }, + create: extractDerivedDocumentMeta(settings, { + timezone: timezoneToUse, + }), }, }, }); diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index 4aea1ec1c..67511bdd6 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -10,7 +10,6 @@ import { prisma } from '@documenso/prisma'; import { getI18nInstance } from '../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; -import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; @@ -151,11 +150,13 @@ const handleDocumentOwnerDelete = async ({ return; } - const { branding, settings } = await getEmailContext({ + const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({ + emailType: 'RECIPIENT', source: { type: 'team', teamId: document.teamId, }, + meta: document.documentMeta || null, }); // Soft delete completed documents. @@ -232,28 +233,24 @@ const handleDocumentOwnerDelete = async ({ assetBaseUrl, }); - const lang = document.documentMeta?.language ?? settings.documentLanguage; - const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding }), renderEmailWithI18N(template, { - lang, + lang: emailLanguage, branding, plainText: true, }), ]); - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); await mailer.sendMail({ to: { address: recipient.email, name: recipient.name, }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, + replyTo: replyToEmail, subject: i18n._(msg`Document Cancelled`), html, text, diff --git a/packages/lib/server-only/document/resend-document.ts b/packages/lib/server-only/document/resend-document.ts index 4ec896102..bf64a2e6b 100644 --- a/packages/lib/server-only/document/resend-document.ts +++ b/packages/lib/server-only/document/resend-document.ts @@ -5,7 +5,6 @@ import { DocumentStatus, OrganisationType, RecipientRole, SigningStatus } from ' import { mailer } from '@documenso/email/mailer'; import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite'; -import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { RECIPIENT_ROLES_DESCRIPTION, RECIPIENT_ROLE_TO_EMAIL_TYPE, @@ -96,12 +95,15 @@ export const resendDocument = async ({ return; } - const { branding, settings, organisationType } = await getEmailContext({ - source: { - type: 'team', - teamId: document.teamId, - }, - }); + const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } = + await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId: document.teamId, + }, + meta: document.documentMeta || null, + }); await Promise.all( recipientsToRemind.map(async (recipient) => { @@ -109,8 +111,7 @@ export const resendDocument = async ({ return; } - const lang = document.documentMeta?.language ?? settings.documentLanguage; - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; @@ -169,11 +170,11 @@ export const resendDocument = async ({ const [html, text] = await Promise.all([ renderEmailWithI18N(template, { - lang, + lang: emailLanguage, branding, }), renderEmailWithI18N(template, { - lang, + lang: emailLanguage, branding, plainText: true, }), @@ -186,10 +187,8 @@ export const resendDocument = async ({ address: email, name, }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, + replyTo: replyToEmail, subject: customEmail?.subject ? renderCustomEmailTemplate( i18n._(msg`Reminder: ${customEmail.subject}`), diff --git a/packages/lib/server-only/document/send-completed-email.ts b/packages/lib/server-only/document/send-completed-email.ts index 38322f77c..c69ef772b 100644 --- a/packages/lib/server-only/document/send-completed-email.ts +++ b/packages/lib/server-only/document/send-completed-email.ts @@ -14,7 +14,6 @@ import { extractDerivedDocumentEmailSettings } from '../../types/document-email' import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { getFileServerSide } from '../../universal/upload/get-file.server'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; -import { env } from '../../utils/env'; import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { formatDocumentsPath } from '../../utils/teams'; @@ -54,11 +53,13 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo throw new Error('Document has no recipients'); } - const { branding, settings } = await getEmailContext({ + const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({ + emailType: 'RECIPIENT', source: { type: 'team', teamId: document.teamId, }, + meta: document.documentMeta || null, }); const { user: owner } = document; @@ -97,18 +98,16 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo downloadLink: documentOwnerDownloadLink, }); - const lang = document.documentMeta?.language ?? settings.documentLanguage; - const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding }), renderEmailWithI18N(template, { - lang, + lang: emailLanguage, branding, plainText: true, }), ]); - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); await mailer.sendMail({ to: [ @@ -117,10 +116,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo address: owner.email, }, ], - from: { - name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso', - address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com', - }, + from: senderEmail, + replyTo: replyToEmail, subject: i18n._(msg`Signing Complete!`), html, text, @@ -174,18 +171,16 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo : undefined, }); - const lang = document.documentMeta?.language ?? settings.documentLanguage; - const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding }), renderEmailWithI18N(template, { - lang, + lang: emailLanguage, branding, plainText: true, }), ]); - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); await mailer.sendMail({ to: [ @@ -194,10 +189,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo address: recipient.email, }, ], - from: { - name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso', - address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com', - }, + from: senderEmail, + replyTo: replyToEmail, subject: isDirectTemplate && document.documentMeta?.subject ? renderCustomEmailTemplate(document.documentMeta.subject, customEmailTemplate) diff --git a/packages/lib/server-only/document/send-delete-email.ts b/packages/lib/server-only/document/send-delete-email.ts index 0c969574f..5ac1043ed 100644 --- a/packages/lib/server-only/document/send-delete-email.ts +++ b/packages/lib/server-only/document/send-delete-email.ts @@ -10,7 +10,6 @@ import { getI18nInstance } from '../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; -import { env } from '../../utils/env'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { getEmailContext } from '../email/get-email-context'; @@ -44,11 +43,13 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt return; } - const { branding, settings } = await getEmailContext({ + const { branding, emailLanguage, senderEmail } = await getEmailContext({ + emailType: 'INTERNAL', source: { type: 'team', teamId: document.teamId, }, + meta: document.documentMeta || null, }); const { email, name } = document.user; @@ -61,28 +62,23 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt assetBaseUrl, }); - const lang = document.documentMeta?.language ?? settings.documentLanguage; - const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding }), renderEmailWithI18N(template, { - lang, + lang: emailLanguage, branding, plainText: true, }), ]); - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); await mailer.sendMail({ to: { address: email, name: name || '', }, - from: { - name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso', - address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com', - }, + from: senderEmail, subject: i18n._(msg`Document Deleted!`), html, text, diff --git a/packages/lib/server-only/document/send-pending-email.ts b/packages/lib/server-only/document/send-pending-email.ts index 9726cd5cc..7609eb3a3 100644 --- a/packages/lib/server-only/document/send-pending-email.ts +++ b/packages/lib/server-only/document/send-pending-email.ts @@ -9,7 +9,6 @@ import { prisma } from '@documenso/prisma'; import { getI18nInstance } from '../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; -import { env } from '../../utils/env'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { getEmailContext } from '../email/get-email-context'; @@ -46,11 +45,13 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE throw new Error('Document has no recipients'); } - const { branding, settings } = await getEmailContext({ + const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({ + emailType: 'RECIPIENT', source: { type: 'team', teamId: document.teamId, }, + meta: document.documentMeta || null, }); const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings( @@ -72,28 +73,24 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE assetBaseUrl, }); - const lang = document.documentMeta?.language ?? settings.documentLanguage; - const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding }), renderEmailWithI18N(template, { - lang, + lang: emailLanguage, branding, plainText: true, }), ]); - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); await mailer.sendMail({ to: { address: email, name, }, - from: { - name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso', - address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com', - }, + from: senderEmail, + replyTo: replyToEmail, subject: i18n._(msg`Waiting for others to complete signing.`), html, text, diff --git a/packages/lib/server-only/document/super-delete-document.ts b/packages/lib/server-only/document/super-delete-document.ts index 85dc5aab2..ae2e2bf4d 100644 --- a/packages/lib/server-only/document/super-delete-document.ts +++ b/packages/lib/server-only/document/super-delete-document.ts @@ -9,7 +9,6 @@ import { prisma } from '@documenso/prisma'; import { getI18nInstance } from '../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; -import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; @@ -41,11 +40,13 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo }); } - const { branding, settings } = await getEmailContext({ + const { branding, settings, senderEmail, replyToEmail } = await getEmailContext({ + emailType: 'RECIPIENT', source: { type: 'team', teamId: document.teamId, }, + meta: document.documentMeta || null, }); const { status, user } = document; @@ -92,10 +93,8 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo address: recipient.email, name: recipient.name, }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, + replyTo: replyToEmail, subject: i18n._(msg`Document Cancelled`), html, text, diff --git a/packages/lib/server-only/document/update-document.ts b/packages/lib/server-only/document/update-document.ts index accb9fadf..0c1586498 100644 --- a/packages/lib/server-only/document/update-document.ts +++ b/packages/lib/server-only/document/update-document.ts @@ -1,5 +1,6 @@ import { DocumentVisibility } from '@prisma/client'; import { DocumentStatus, TeamMemberRole } from '@prisma/client'; +import { isDeepEqual } from 'remeda'; import { match } from 'ts-pattern'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; @@ -128,9 +129,11 @@ export const updateDocument = async ({ const isTitleSame = data.title === undefined || data.title === document.title; const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId; const isGlobalAccessSame = - documentGlobalAccessAuth === undefined || documentGlobalAccessAuth === newGlobalAccessAuth; + documentGlobalAccessAuth === undefined || + isDeepEqual(documentGlobalAccessAuth, newGlobalAccessAuth); const isGlobalActionSame = - documentGlobalActionAuth === undefined || documentGlobalActionAuth === newGlobalActionAuth; + documentGlobalActionAuth === undefined || + isDeepEqual(documentGlobalActionAuth, newGlobalActionAuth); const isDocumentVisibilitySame = data.visibility === undefined || data.visibility === document.visibility; diff --git a/packages/lib/server-only/email/get-email-context.ts b/packages/lib/server-only/email/get-email-context.ts index 5b8961008..56964ce28 100644 --- a/packages/lib/server-only/email/get-email-context.ts +++ b/packages/lib/server-only/email/get-email-context.ts @@ -1,16 +1,34 @@ import type { BrandingSettings } from '@documenso/email/providers/branding'; import { prisma } from '@documenso/prisma'; -import type { OrganisationType } from '@documenso/prisma/client'; -import { type OrganisationClaim, type OrganisationGlobalSettings } from '@documenso/prisma/client'; +import type { + DocumentMeta, + EmailDomain, + Organisation, + OrganisationEmail, + OrganisationType, +} from '@documenso/prisma/client'; +import { + EmailDomainStatus, + type OrganisationClaim, + type OrganisationGlobalSettings, +} from '@documenso/prisma/client'; +import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { organisationGlobalSettingsToBranding, teamGlobalSettingsToBranding, } from '../../utils/team-global-settings-to-branding'; -import { getTeamSettings } from '../team/get-team-settings'; +import { extractDerivedTeamSettings } from '../../utils/teams'; -type GetEmailContextOptions = { +type EmailMetaOption = Partial>; + +type BaseGetEmailContextOptions = { + /** + * The source to extract the email context from. + * - "Team" will use the team settings followed by the inherited organisation settings + * - "Organisation" will use the organisation settings + */ source: | { type: 'team'; @@ -20,37 +38,112 @@ type GetEmailContextOptions = { type: 'organisation'; organisationId: string; }; + + /** + * The email type being sent, used to determine what email sender and language to use. + * - INTERNAL: Emails to users, such as team invites, etc. + * - RECIPIENT: Emails to recipients, such as document sent, document signed, etc. + */ + emailType: 'INTERNAL' | 'RECIPIENT'; }; +type InternalGetEmailContextOptions = BaseGetEmailContextOptions & { + emailType: 'INTERNAL'; + meta?: EmailMetaOption | null; +}; + +type RecipientGetEmailContextOptions = BaseGetEmailContextOptions & { + emailType: 'RECIPIENT'; + + /** + * Force meta options as a typesafe way to ensure developers don't forget to + * pass it in if it is available. + */ + meta: EmailMetaOption | null; +}; + +type GetEmailContextOptions = InternalGetEmailContextOptions | RecipientGetEmailContextOptions; + type EmailContextResponse = { + allowedEmails: OrganisationEmail[]; branding: BrandingSettings; settings: Omit; claims: OrganisationClaim; organisationType: OrganisationType; + senderEmail: { + name: string; + address: string; + }; + replyToEmail: string | undefined; + emailLanguage: string; }; export const getEmailContext = async ( options: GetEmailContextOptions, ): Promise => { - const { source } = options; + const { source, meta } = options; + let emailContext: Omit; + + if (source.type === 'organisation') { + emailContext = await handleOrganisationEmailContext(source.organisationId); + } else { + emailContext = await handleTeamEmailContext(source.teamId); + } + + const emailLanguage = meta?.language || emailContext.settings.documentLanguage; + + // Immediate return for internal emails. + if (options.emailType === 'INTERNAL') { + return { + ...emailContext, + senderEmail: DOCUMENSO_INTERNAL_EMAIL, + replyToEmail: undefined, + emailLanguage, // Not sure if we want to use this for internal emails. + }; + } + + const replyToEmail = meta?.emailReplyTo || emailContext.settings.emailReplyTo || undefined; + const senderEmailId = meta?.emailId || emailContext.settings.emailId; + + const foundSenderEmail = emailContext.allowedEmails.find((email) => email.id === senderEmailId); + + // Reset the emailId to null if not found. + if (!foundSenderEmail) { + emailContext.settings.emailId = null; + } + + const senderEmail = foundSenderEmail + ? { + name: foundSenderEmail.emailName, + address: foundSenderEmail.email, + } + : DOCUMENSO_INTERNAL_EMAIL; + + return { + ...emailContext, + senderEmail, + replyToEmail, + emailLanguage, + }; +}; + +const handleOrganisationEmailContext = async (organisationId: string) => { const organisation = await prisma.organisation.findFirst({ - where: - source.type === 'organisation' - ? { - id: source.organisationId, - } - : { - teams: { - some: { - id: source.teamId, - }, - }, - }, + where: { + id: organisationId, + }, include: { - subscription: true, organisationClaim: true, organisationGlobalSettings: true, + emailDomains: { + omit: { + privateKey: true, + }, + include: { + emails: true, + }, + }, }, }); @@ -60,27 +153,64 @@ export const getEmailContext = async ( const claims = organisation.organisationClaim; - if (source.type === 'organisation') { - return { - branding: organisationGlobalSettingsToBranding( - organisation.organisationGlobalSettings, - organisation.id, - claims.flags.hidePoweredBy ?? false, - ), - settings: organisation.organisationGlobalSettings, - claims, - organisationType: organisation.type, - }; - } - - const teamSettings = await getTeamSettings({ - teamId: source.teamId, - }); + const allowedEmails = getAllowedEmails(organisation); return { + allowedEmails, + branding: organisationGlobalSettingsToBranding( + organisation.organisationGlobalSettings, + organisation.id, + claims.flags.hidePoweredBy ?? false, + ), + settings: organisation.organisationGlobalSettings, + claims, + organisationType: organisation.type, + }; +}; + +const handleTeamEmailContext = async (teamId: number) => { + const team = await prisma.team.findFirst({ + where: { + id: teamId, + }, + include: { + teamGlobalSettings: true, + organisation: { + include: { + organisationClaim: true, + organisationGlobalSettings: true, + emailDomains: { + omit: { + privateKey: true, + }, + include: { + emails: true, + }, + }, + }, + }, + }, + }); + + if (!team) { + throw new AppError(AppErrorCode.NOT_FOUND); + } + + const organisation = team.organisation; + const claims = organisation.organisationClaim; + + const allowedEmails = getAllowedEmails(organisation); + + const teamSettings = extractDerivedTeamSettings( + organisation.organisationGlobalSettings, + team.teamGlobalSettings, + ); + + return { + allowedEmails, branding: teamGlobalSettingsToBranding( teamSettings, - source.teamId, + teamId, claims.flags.hidePoweredBy ?? false, ), settings: teamSettings, @@ -88,3 +218,18 @@ export const getEmailContext = async ( organisationType: organisation.type, }; }; + +const getAllowedEmails = ( + organisation: Organisation & { + emailDomains: (Pick & { emails: OrganisationEmail[] })[]; + organisationClaim: OrganisationClaim; + }, +) => { + if (!organisation.organisationClaim.flags.emailDomains) { + return []; + } + + return organisation.emailDomains + .filter((emailDomain) => emailDomain.status === EmailDomainStatus.ACTIVE) + .flatMap((emailDomain) => emailDomain.emails); +}; diff --git a/packages/lib/server-only/organisation/create-organisation-member-invites.ts b/packages/lib/server-only/organisation/create-organisation-member-invites.ts index 032a49d03..209b1aeca 100644 --- a/packages/lib/server-only/organisation/create-organisation-member-invites.ts +++ b/packages/lib/server-only/organisation/create-organisation-member-invites.ts @@ -9,7 +9,6 @@ import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/str import { mailer } from '@documenso/email/mailer'; import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; -import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations'; @@ -190,7 +189,8 @@ export const sendOrganisationMemberInviteEmail = async ({ organisationName: organisation.name, }); - const { branding, settings } = await getEmailContext({ + const { branding, emailLanguage, senderEmail } = await getEmailContext({ + emailType: 'INTERNAL', source: { type: 'organisation', organisationId: organisation.id, @@ -199,24 +199,21 @@ export const sendOrganisationMemberInviteEmail = async ({ const [html, text] = await Promise.all([ renderEmailWithI18N(template, { - lang: settings.documentLanguage, + lang: emailLanguage, branding, }), renderEmailWithI18N(template, { - lang: settings.documentLanguage, + lang: emailLanguage, branding, plainText: true, }), ]); - const i18n = await getI18nInstance(settings.documentLanguage); + const i18n = await getI18nInstance(emailLanguage); await mailer.sendMail({ to: email, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, subject: i18n._(msg`You have been invited to join ${organisation.name} on Documenso`), html, text, diff --git a/packages/lib/server-only/recipient/delete-document-recipient.ts b/packages/lib/server-only/recipient/delete-document-recipient.ts index a364fa410..a9b7790b9 100644 --- a/packages/lib/server-only/recipient/delete-document-recipient.ts +++ b/packages/lib/server-only/recipient/delete-document-recipient.ts @@ -11,7 +11,6 @@ import { prisma } from '@documenso/prisma'; import { getI18nInstance } from '../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; -import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; @@ -125,31 +124,29 @@ export const deleteDocumentRecipient = async ({ assetBaseUrl, }); - const { branding, settings } = await getEmailContext({ + const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({ + emailType: 'RECIPIENT', source: { type: 'team', teamId: document.teamId, }, + meta: document.documentMeta || null, }); - const lang = document.documentMeta?.language ?? settings.documentLanguage; - const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang, branding }), - renderEmailWithI18N(template, { lang, branding, plainText: true }), + renderEmailWithI18N(template, { lang: emailLanguage, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }), ]); - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); await mailer.sendMail({ to: { address: recipientToDelete.email, name: recipientToDelete.name, }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, + replyTo: replyToEmail, subject: i18n._(msg`You have been removed from a document`), html, text, diff --git a/packages/lib/server-only/recipient/set-document-recipients.ts b/packages/lib/server-only/recipient/set-document-recipients.ts index abe55dc58..25ff0b480 100644 --- a/packages/lib/server-only/recipient/set-document-recipients.ts +++ b/packages/lib/server-only/recipient/set-document-recipients.ts @@ -25,7 +25,6 @@ import { prisma } from '@documenso/prisma'; import { getI18nInstance } from '../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; -import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { canRecipientBeModified } from '../../utils/recipients'; @@ -71,13 +70,6 @@ export const setDocumentRecipients = async ({ }, }); - const { branding, settings } = await getEmailContext({ - source: { - type: 'team', - teamId, - }, - }); - const user = await prisma.user.findFirstOrThrow({ where: { id: userId, @@ -97,6 +89,15 @@ export const setDocumentRecipients = async ({ throw new Error('Document already complete'); } + const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId, + }, + meta: document.documentMeta || null, + }); + const recipientsHaveActionAuth = recipients.some( (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, ); @@ -302,24 +303,20 @@ export const setDocumentRecipients = async ({ assetBaseUrl, }); - const lang = document.documentMeta?.language ?? settings.documentLanguage; - const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang, branding }), - renderEmailWithI18N(template, { lang, branding, plainText: true }), + renderEmailWithI18N(template, { lang: emailLanguage, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }), ]); - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); await mailer.sendMail({ to: { address: recipient.email, name: recipient.name, }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, + replyTo: replyToEmail, subject: i18n._(msg`You have been removed from a document`), html, text, diff --git a/packages/lib/server-only/team/create-team-email-verification.ts b/packages/lib/server-only/team/create-team-email-verification.ts index de4dcddd2..f2565d6a8 100644 --- a/packages/lib/server-only/team/create-team-email-verification.ts +++ b/packages/lib/server-only/team/create-team-email-verification.ts @@ -8,14 +8,12 @@ import { z } from 'zod'; import { mailer } from '@documenso/email/mailer'; import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email'; import { NEXT_PUBLIC_WEBAPP_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 { getI18nInstance } from '../../client-only/providers/i18n-server'; -import type { SupportedLanguageCodes } from '../../constants/i18n'; import { env } from '../../utils/env'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { buildTeamWhereQuery } from '../../utils/teams'; @@ -122,33 +120,28 @@ export const sendTeamEmailVerificationEmail = async (email: string, token: strin token, }); - const { branding, settings } = await getEmailContext({ + const { branding, emailLanguage, senderEmail } = await getEmailContext({ + emailType: 'INTERNAL', source: { type: 'team', teamId: team.id, }, }); - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const lang = settings.documentLanguage as SupportedLanguageCodes; - const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding }), renderEmailWithI18N(template, { - lang, + lang: emailLanguage, branding, plainText: true, }), ]); - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); await mailer.sendMail({ to: email, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, subject: i18n._( msg`A request to use your email has been initiated by ${team.name} on Documenso`, ), diff --git a/packages/lib/server-only/team/delete-team-email.ts b/packages/lib/server-only/team/delete-team-email.ts index da6e44f2b..4e4b538e5 100644 --- a/packages/lib/server-only/team/delete-team-email.ts +++ b/packages/lib/server-only/team/delete-team-email.ts @@ -5,7 +5,6 @@ import { msg } from '@lingui/core/macro'; import { mailer } from '@documenso/email/mailer'; import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed'; import { NEXT_PUBLIC_WEBAPP_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'; @@ -27,7 +26,8 @@ export type DeleteTeamEmailOptions = { * 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 { branding, settings } = await getEmailContext({ + const { branding, emailLanguage, senderEmail } = await getEmailContext({ + emailType: 'INTERNAL', source: { type: 'team', teamId, @@ -82,24 +82,19 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE teamUrl: team.url, }); - const lang = settings.documentLanguage; - const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang, branding }), - renderEmailWithI18N(template, { lang, branding, plainText: true }), + renderEmailWithI18N(template, { lang: emailLanguage, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }), ]); - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); await mailer.sendMail({ to: { address: team.organisation.owner.email, name: team.organisation.owner.name ?? '', }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, subject: i18n._(msg`Team email has been revoked for ${team.name}`), html, text, diff --git a/packages/lib/server-only/team/delete-team.ts b/packages/lib/server-only/team/delete-team.ts index ccaefa7d4..9ae1cfc68 100644 --- a/packages/lib/server-only/team/delete-team.ts +++ b/packages/lib/server-only/team/delete-team.ts @@ -7,7 +7,6 @@ import { uniqueBy } from 'remeda'; import { mailer } from '@documenso/email/mailer'; import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; -import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; @@ -130,28 +129,24 @@ export const sendTeamDeleteEmail = async ({ teamUrl: team.url, }); - const { branding, settings } = await getEmailContext({ + const { branding, emailLanguage, senderEmail } = await getEmailContext({ + emailType: 'INTERNAL', source: { type: 'organisation', organisationId, }, }); - const lang = settings.documentLanguage; - const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang, branding }), - renderEmailWithI18N(template, { lang, branding, plainText: true }), + renderEmailWithI18N(template, { lang: emailLanguage, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }), ]); - const i18n = await getI18nInstance(lang); + const i18n = await getI18nInstance(emailLanguage); await mailer.sendMail({ to: email, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, + from: senderEmail, subject: i18n._(msg`Team "${team.name}" has been deleted on Documenso`), html, text, diff --git a/packages/lib/server-only/team/get-team-settings.ts b/packages/lib/server-only/team/get-team-settings.ts index 6eb0b0817..0bdac9e69 100644 --- a/packages/lib/server-only/team/get-team-settings.ts +++ b/packages/lib/server-only/team/get-team-settings.ts @@ -33,5 +33,13 @@ export const getTeamSettings = async ({ userId, teamId }: GetTeamSettingsOptions const organisationSettings = team.organisation.organisationGlobalSettings; const teamSettings = team.teamGlobalSettings; + // Override branding settings if inherit is enabled. + if (teamSettings.brandingEnabled === null) { + teamSettings.brandingEnabled = organisationSettings.brandingEnabled; + teamSettings.brandingLogo = organisationSettings.brandingLogo; + teamSettings.brandingUrl = organisationSettings.brandingUrl; + teamSettings.brandingCompanyDetails = organisationSettings.brandingCompanyDetails; + } + return extractDerivedTeamSettings(organisationSettings, teamSettings); }; diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index 9292a8116..bed997aab 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -3,7 +3,6 @@ import { createElement } from 'react'; import { msg } from '@lingui/core/macro'; import type { Field, Signature } from '@prisma/client'; import { - DocumentSigningOrder, DocumentSource, DocumentStatus, FieldType, @@ -25,8 +24,6 @@ import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/f import { getI18nInstance } from '../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; -import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; -import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import type { TRecipientActionAuthTypes } from '../../types/document-auth'; @@ -38,6 +35,7 @@ import { } from '../../types/webhook-payload'; import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; import { isRequiredField } from '../../utils/advanced-fields-helpers'; +import { extractDerivedDocumentMeta } from '../../utils/document'; import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { @@ -45,7 +43,6 @@ import { createRecipientAuthOptions, extractDocumentAuthMethods, } from '../../utils/document-auth'; -import { env } from '../../utils/env'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { formatDocumentsPath } from '../../utils/teams'; import { sendDocument } from '../document/send-document'; @@ -116,7 +113,8 @@ export const createDocumentFromDirectTemplate = async ({ throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' }); } - const { branding, settings } = await getEmailContext({ + const { branding, settings, senderEmail, emailLanguage } = await getEmailContext({ + emailType: 'INTERNAL', source: { type: 'team', teamId: template.teamId, @@ -169,13 +167,7 @@ export const createDocumentFromDirectTemplate = async ({ const nonDirectTemplateRecipients = template.recipients.filter( (recipient) => recipient.id !== directTemplateRecipient.id, ); - - const metaTimezone = template.templateMeta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE; - const metaDateFormat = template.templateMeta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT; - const metaEmailMessage = template.templateMeta?.message || ''; - const metaEmailSubject = template.templateMeta?.subject || ''; - const metaLanguage = template.templateMeta?.language ?? settings.documentLanguage; - const metaSigningOrder = template.templateMeta?.signingOrder || DocumentSigningOrder.PARALLEL; + const derivedDocumentMeta = extractDerivedDocumentMeta(settings, template.templateMeta); // Associate, validate and map to a query every direct template recipient field with the provided fields. // Only process fields that are either required or have been signed by the user @@ -234,7 +226,9 @@ export const createDocumentFromDirectTemplate = async ({ const typedSignature = isSignatureField && !isBase64 ? value : undefined; if (templateField.type === FieldType.DATE) { - customText = DateTime.now().setZone(metaTimezone).toFormat(metaDateFormat); + customText = DateTime.now() + .setZone(derivedDocumentMeta.timezone) + .toFormat(derivedDocumentMeta.dateFormat); } if (isSignatureField && !signatureImageAsBase64 && !typedSignature) { @@ -318,18 +312,7 @@ export const createDocumentFromDirectTemplate = async ({ }, }, documentMeta: { - create: { - timezone: metaTimezone, - dateFormat: metaDateFormat, - message: metaEmailMessage, - subject: metaEmailSubject, - language: metaLanguage, - signingOrder: metaSigningOrder, - distributionMethod: template.templateMeta?.distributionMethod, - typedSignatureEnabled: template.templateMeta?.typedSignatureEnabled, - uploadSignatureEnabled: template.templateMeta?.uploadSignatureEnabled, - drawSignatureEnabled: template.templateMeta?.drawSignatureEnabled, - }, + create: derivedDocumentMeta, }, }, include: { @@ -589,11 +572,11 @@ export const createDocumentFromDirectTemplate = async ({ }); const [html, text] = await Promise.all([ - renderEmailWithI18N(emailTemplate, { lang: metaLanguage, branding }), - renderEmailWithI18N(emailTemplate, { lang: metaLanguage, branding, plainText: true }), + renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }), + renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }), ]); - const i18n = await getI18nInstance(metaLanguage); + const i18n = await getI18nInstance(emailLanguage); await mailer.sendMail({ to: [ @@ -602,10 +585,7 @@ export const createDocumentFromDirectTemplate = async ({ address: templateOwner.email, }, ], - from: { - name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso', - address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com', - }, + from: senderEmail, subject: i18n._(msg`Document created from direct template`), html, text, diff --git a/packages/lib/server-only/template/create-document-from-template-legacy.ts b/packages/lib/server-only/template/create-document-from-template-legacy.ts index 5dd98b032..e0a20f55e 100644 --- a/packages/lib/server-only/template/create-document-from-template-legacy.ts +++ b/packages/lib/server-only/template/create-document-from-template-legacy.ts @@ -3,6 +3,7 @@ import { DocumentSource, type RecipientRole } from '@prisma/client'; import { nanoid, prefixedId } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; +import { extractDerivedDocumentMeta } from '../../utils/document'; import { buildTeamWhereQuery } from '../../utils/teams'; import { getTeamSettings } from '../team/get-team-settings'; @@ -78,18 +79,7 @@ export const createDocumentFromTemplateLegacy = async ({ })), }, documentMeta: { - create: { - subject: template.templateMeta?.subject, - message: template.templateMeta?.message, - timezone: template.templateMeta?.timezone, - dateFormat: template.templateMeta?.dateFormat, - redirectUrl: template.templateMeta?.redirectUrl, - signingOrder: template.templateMeta?.signingOrder ?? undefined, - language: template.templateMeta?.language || settings.documentLanguage, - typedSignatureEnabled: template.templateMeta?.typedSignatureEnabled, - uploadSignatureEnabled: template.templateMeta?.uploadSignatureEnabled, - drawSignatureEnabled: template.templateMeta?.drawSignatureEnabled, - }, + create: extractDerivedDocumentMeta(settings, template.templateMeta), }, }, 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 afe2d5e95..35946a155 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -1,6 +1,5 @@ -import type { DocumentDistributionMethod } from '@prisma/client'; +import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client'; import { - DocumentSigningOrder, DocumentSource, type Field, type Recipient, @@ -40,6 +39,7 @@ import { mapDocumentToWebhookDocumentPayload, } from '../../types/webhook-payload'; import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; +import { extractDerivedDocumentMeta } from '../../utils/document'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuthOptions, @@ -378,7 +378,7 @@ export const createDocumentFromTemplate = async ({ visibility: template.visibility || settings.documentVisibility, useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false, documentMeta: { - create: { + create: extractDerivedDocumentMeta(settings, { subject: override?.subject || template.templateMeta?.subject, message: override?.message || template.templateMeta?.message, timezone: override?.timezone || template.templateMeta?.timezone, @@ -387,13 +387,8 @@ export const createDocumentFromTemplate = async ({ redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl, distributionMethod: override?.distributionMethod || template.templateMeta?.distributionMethod, - // last `undefined` is due to JsonValue's - emailSettings: - override?.emailSettings || template.templateMeta?.emailSettings || undefined, - signingOrder: - override?.signingOrder || - template.templateMeta?.signingOrder || - DocumentSigningOrder.PARALLEL, + emailSettings: override?.emailSettings || template.templateMeta?.emailSettings, + signingOrder: override?.signingOrder || template.templateMeta?.signingOrder, language: override?.language || template.templateMeta?.language || settings.documentLanguage, typedSignatureEnabled: @@ -403,10 +398,8 @@ export const createDocumentFromTemplate = async ({ drawSignatureEnabled: override?.drawSignatureEnabled ?? template.templateMeta?.drawSignatureEnabled, allowDictateNextSigner: - override?.allowDictateNextSigner ?? - template.templateMeta?.allowDictateNextSigner ?? - false, - }, + override?.allowDictateNextSigner ?? template.templateMeta?.allowDictateNextSigner, + }), }, recipients: { createMany: { diff --git a/packages/lib/server-only/template/create-template.ts b/packages/lib/server-only/template/create-template.ts index bd67eebe6..5019407a2 100644 --- a/packages/lib/server-only/template/create-template.ts +++ b/packages/lib/server-only/template/create-template.ts @@ -6,6 +6,7 @@ import { TemplateSchema } from '@documenso/prisma/generated/zod/modelSchema//Tem import { AppError, AppErrorCode } from '../../errors/app-error'; import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth'; +import { extractDerivedDocumentMeta } from '../../utils/document'; import { createDocumentAuthOptions } from '../../utils/document-auth'; import { buildTeamWhereQuery } from '../../utils/teams'; import { getTeamSettings } from '../team/get-team-settings'; @@ -69,6 +70,24 @@ export const createTemplate = async ({ teamId, }); + const emailId = meta.emailId; + + // Validate that the email ID belongs to the organisation. + if (emailId) { + const email = await prisma.organisationEmail.findFirst({ + where: { + id: emailId, + organisationId: team.organisationId, + }, + }); + + if (!email) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Email not found', + }); + } + } + return await prisma.template.create({ data: { title, @@ -86,14 +105,7 @@ export const createTemplate = async ({ publicDescription: data.publicDescription, type: data.type, templateMeta: { - create: { - ...meta, - language: meta?.language ?? settings.documentLanguage, - typedSignatureEnabled: meta?.typedSignatureEnabled ?? settings.typedSignatureEnabled, - uploadSignatureEnabled: meta?.uploadSignatureEnabled ?? settings.uploadSignatureEnabled, - drawSignatureEnabled: meta?.drawSignatureEnabled ?? settings.drawSignatureEnabled, - emailSettings: meta?.emailSettings || undefined, - }, + create: extractDerivedDocumentMeta(settings, meta), }, }, }); diff --git a/packages/lib/server-only/template/update-template.ts b/packages/lib/server-only/template/update-template.ts index 4485fdc66..65314dd48 100644 --- a/packages/lib/server-only/template/update-template.ts +++ b/packages/lib/server-only/template/update-template.ts @@ -41,6 +41,7 @@ export const updateTemplate = async ({ templateMeta: true, team: { select: { + organisationId: true, organisation: { select: { organisationClaim: true, @@ -86,6 +87,24 @@ export const updateTemplate = async ({ globalActionAuth: newGlobalActionAuth, }); + const emailId = meta.emailId; + + // Validate the emailId belongs to the organisation. + if (emailId) { + const email = await prisma.organisationEmail.findFirst({ + where: { + id: emailId, + organisationId: template.team.organisationId, + }, + }); + + if (!email) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Email not found', + }); + } + } + return await prisma.template.update({ where: { id: templateId, diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index 5c3f5bd86..e11f9b31b 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -58,6 +58,9 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([ 'REDIRECT_URL', 'SUBJECT', 'TIMEZONE', + 'EMAIL_ID', + 'EMAIL_REPLY_TO', + 'EMAIL_SETTINGS', ]); export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']); @@ -109,6 +112,9 @@ export const ZDocumentAuditLogDocumentMetaSchema = z.union([ z.literal(DOCUMENT_META_DIFF_TYPE.REDIRECT_URL), z.literal(DOCUMENT_META_DIFF_TYPE.SUBJECT), z.literal(DOCUMENT_META_DIFF_TYPE.TIMEZONE), + z.literal(DOCUMENT_META_DIFF_TYPE.EMAIL_ID), + z.literal(DOCUMENT_META_DIFF_TYPE.EMAIL_REPLY_TO), + z.literal(DOCUMENT_META_DIFF_TYPE.EMAIL_SETTINGS), ]), from: z.string().nullable(), to: z.string().nullable(), diff --git a/packages/lib/types/document-email.ts b/packages/lib/types/document-email.ts index 06b44929d..0d2d2b52e 100644 --- a/packages/lib/types/document-email.ts +++ b/packages/lib/types/document-email.ts @@ -54,15 +54,7 @@ export const ZDocumentEmailSettingsSchema = z .default(true), }) .strip() - .catch(() => ({ - recipientSigningRequest: true, - recipientRemoved: true, - recipientSigned: true, - documentPending: true, - documentCompleted: true, - documentDeleted: true, - ownerDocumentCompleted: true, - })); + .catch(() => ({ ...DEFAULT_DOCUMENT_EMAIL_SETTINGS })); export type TDocumentEmailSettings = z.infer; @@ -88,3 +80,13 @@ export const extractDerivedDocumentEmailSettings = ( ownerDocumentCompleted: emailSettings.ownerDocumentCompleted, }; }; + +export const DEFAULT_DOCUMENT_EMAIL_SETTINGS: TDocumentEmailSettings = { + recipientSigningRequest: true, + recipientRemoved: true, + recipientSigned: true, + documentPending: true, + documentCompleted: true, + documentDeleted: true, + ownerDocumentCompleted: true, +}; diff --git a/packages/lib/types/document.ts b/packages/lib/types/document.ts index 2b2fe8284..b42801b72 100644 --- a/packages/lib/types/document.ts +++ b/packages/lib/types/document.ts @@ -58,6 +58,8 @@ export const ZDocumentSchema = DocumentSchema.pick({ allowDictateNextSigner: true, language: true, emailSettings: true, + emailId: true, + emailReplyTo: true, }).nullable(), folder: FolderSchema.pick({ id: true, diff --git a/packages/lib/types/email-domain.ts b/packages/lib/types/email-domain.ts new file mode 100644 index 000000000..2dbf79b2a --- /dev/null +++ b/packages/lib/types/email-domain.ts @@ -0,0 +1,40 @@ +import type { z } from 'zod'; + +import { EmailDomainSchema } from '@documenso/prisma/generated/zod/modelSchema/EmailDomainSchema'; + +import { ZOrganisationEmailLiteSchema } from './organisation-email'; + +/** + * The full email domain response schema. + * + * Mainly used for returning a single email domain from the API. + */ +export const ZEmailDomainSchema = EmailDomainSchema.pick({ + id: true, + status: true, + organisationId: true, + domain: true, + selector: true, + publicKey: true, + createdAt: true, + updatedAt: true, +}).extend({ + emails: ZOrganisationEmailLiteSchema.array(), +}); + +export type TEmailDomain = z.infer; + +/** + * A version of the email domain response schema when returning multiple email domains at once from a single API endpoint. + */ +export const ZEmailDomainManySchema = EmailDomainSchema.pick({ + id: true, + status: true, + organisationId: true, + domain: true, + selector: true, + createdAt: true, + updatedAt: true, +}); + +export type TEmailDomainMany = z.infer; diff --git a/packages/lib/types/organisation-email.ts b/packages/lib/types/organisation-email.ts new file mode 100644 index 000000000..c72d6a38d --- /dev/null +++ b/packages/lib/types/organisation-email.ts @@ -0,0 +1,42 @@ +import { EmailDomainStatus } from '@prisma/client'; +import { z } from 'zod'; + +import { OrganisationEmailSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationEmailSchema'; + +export const ZOrganisationEmailSchema = OrganisationEmailSchema.pick({ + id: true, + createdAt: true, + updatedAt: true, + email: true, + emailName: true, + // replyTo: true, + emailDomainId: true, + organisationId: true, +}).extend({ + emailDomain: z.object({ + id: z.string(), + status: z.nativeEnum(EmailDomainStatus), + }), +}); + +export type TOrganisationEmail = z.infer; + +/** + * A lite version of the organisation email response schema without relations. + */ +export const ZOrganisationEmailLiteSchema = OrganisationEmailSchema.pick({ + id: true, + createdAt: true, + updatedAt: true, + email: true, + emailName: true, + // replyTo: true, + emailDomainId: true, + organisationId: true, +}); + +export const ZOrganisationEmailManySchema = ZOrganisationEmailLiteSchema.extend({ + // Put anything extra here. +}); + +export type TOrganisationEmailMany = z.infer; diff --git a/packages/lib/types/subscription.ts b/packages/lib/types/subscription.ts index 34fc588ba..c01f8f4e8 100644 --- a/packages/lib/types/subscription.ts +++ b/packages/lib/types/subscription.ts @@ -19,6 +19,8 @@ export const ZClaimFlagsSchema = z.object({ unlimitedDocuments: z.boolean().optional(), + emailDomains: z.boolean().optional(), + embedAuthoring: z.boolean().optional(), embedAuthoringWhiteLabel: z.boolean().optional(), @@ -50,6 +52,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record< key: 'hidePoweredBy', label: 'Hide Documenso branding by', }, + emailDomains: { + key: 'emailDomains', + label: 'Email domains', + }, embedAuthoring: { key: 'embedAuthoring', label: 'Embed authoring', @@ -128,6 +134,7 @@ export const internalClaims: InternalClaims = { unlimitedDocuments: true, allowCustomBranding: true, hidePoweredBy: true, + emailDomains: true, embedAuthoring: false, embedAuthoringWhiteLabel: true, embedSigning: false, @@ -144,6 +151,7 @@ export const internalClaims: InternalClaims = { unlimitedDocuments: true, allowCustomBranding: true, hidePoweredBy: true, + emailDomains: true, embedAuthoring: true, embedAuthoringWhiteLabel: true, embedSigning: true, diff --git a/packages/lib/types/template.ts b/packages/lib/types/template.ts index f5f5dd6f0..1171ad18d 100644 --- a/packages/lib/types/template.ts +++ b/packages/lib/types/template.ts @@ -55,6 +55,8 @@ export const ZTemplateSchema = TemplateSchema.pick({ redirectUrl: true, language: true, emailSettings: true, + emailId: true, + emailReplyTo: true, }).nullable(), directLink: TemplateDirectLinkSchema.nullable(), user: UserSchema.pick({ diff --git a/packages/lib/universal/id.ts b/packages/lib/universal/id.ts index dbe159fbe..a6fb55ad5 100644 --- a/packages/lib/universal/id.ts +++ b/packages/lib/universal/id.ts @@ -11,7 +11,9 @@ export const prefixedId = (prefix: string, length = 16) => { }; type DatabaseIdPrefix = + | 'email_domain' | 'org' + | 'org_email' | 'org_claim' | 'org_group' | 'org_setting' diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index 58797510f..7617439e6 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -205,12 +205,18 @@ export const diffDocumentMetaChanges = ( const oldTimezone = oldData?.timezone ?? ''; const oldPassword = oldData?.password ?? null; const oldRedirectUrl = oldData?.redirectUrl ?? ''; + const oldEmailId = oldData?.emailId || null; + const oldEmailReplyTo = oldData?.emailReplyTo || null; + const oldEmailSettings = oldData?.emailSettings || null; const newDateFormat = newData?.dateFormat ?? ''; const newMessage = newData?.message ?? ''; const newSubject = newData?.subject ?? ''; const newTimezone = newData?.timezone ?? ''; const newRedirectUrl = newData?.redirectUrl ?? ''; + const newEmailId = newData?.emailId || null; + const newEmailReplyTo = newData?.emailReplyTo || null; + const newEmailSettings = newData?.emailSettings || null; if (oldDateFormat !== newDateFormat) { diffs.push({ @@ -258,6 +264,30 @@ export const diffDocumentMetaChanges = ( }); } + if (oldEmailId !== newEmailId) { + diffs.push({ + type: DOCUMENT_META_DIFF_TYPE.EMAIL_ID, + from: oldEmailId, + to: newEmailId, + }); + } + + if (oldEmailReplyTo !== newEmailReplyTo) { + diffs.push({ + type: DOCUMENT_META_DIFF_TYPE.EMAIL_REPLY_TO, + from: oldEmailReplyTo, + to: newEmailReplyTo, + }); + } + + if (!isDeepEqual(oldEmailSettings, newEmailSettings)) { + diffs.push({ + type: DOCUMENT_META_DIFF_TYPE.EMAIL_SETTINGS, + from: JSON.stringify(oldEmailSettings), + to: JSON.stringify(newEmailSettings), + }); + } + return diffs; }; diff --git a/packages/lib/utils/document.ts b/packages/lib/utils/document.ts index 52ceca627..8bae70ca2 100644 --- a/packages/lib/utils/document.ts +++ b/packages/lib/utils/document.ts @@ -1,8 +1,62 @@ -import type { Document } from '@prisma/client'; -import { DocumentStatus } from '@prisma/client'; +import type { + Document, + DocumentMeta, + OrganisationGlobalSettings, + TemplateMeta, +} from '@prisma/client'; +import { DocumentDistributionMethod, DocumentSigningOrder, DocumentStatus } from '@prisma/client'; + +import { DEFAULT_DOCUMENT_TIME_ZONE } from '../constants/time-zones'; +import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email'; export const isDocumentCompleted = (document: Pick | DocumentStatus) => { const status = typeof document === 'string' ? document : document.status; return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED; }; + +/** + * Extracts the derived document meta which should be used when creating a document + * from scratch, or from a template. + * + * Uses the following, the lower number overrides the higher number: + * 1. Merged organisation/team settings + * 2. Meta overrides + * + * @param settings - The merged organisation/team settings. + * @param overrideMeta - The meta to override the settings with. + * @returns The derived document meta. + */ +export const extractDerivedDocumentMeta = ( + settings: Omit, + overrideMeta: Partial | undefined | null, +) => { + const meta = overrideMeta ?? {}; + + // Note: If you update this you will also need to update `create-document-from-template.ts` + // since there is custom work there which allows 3 overrides. + return { + language: meta.language || settings.documentLanguage, + timezone: meta.timezone || settings.documentTimezone || DEFAULT_DOCUMENT_TIME_ZONE, + dateFormat: meta.dateFormat || settings.documentDateFormat, + message: meta.message || null, + subject: meta.subject || null, + password: meta.password || null, + redirectUrl: meta.redirectUrl || null, + + signingOrder: meta.signingOrder || DocumentSigningOrder.PARALLEL, + allowDictateNextSigner: meta.allowDictateNextSigner ?? false, + distributionMethod: meta.distributionMethod || DocumentDistributionMethod.EMAIL, // Todo: Make this a setting. + + // Signature settings. + typedSignatureEnabled: meta.typedSignatureEnabled ?? settings.typedSignatureEnabled, + uploadSignatureEnabled: meta.uploadSignatureEnabled ?? settings.uploadSignatureEnabled, + drawSignatureEnabled: meta.drawSignatureEnabled ?? settings.drawSignatureEnabled, + + // Email settings. + emailId: meta.emailId ?? settings.emailId, + emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo, + emailSettings: + meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS, + } satisfies Omit; +}; diff --git a/packages/lib/utils/email-domains.ts b/packages/lib/utils/email-domains.ts new file mode 100644 index 000000000..cbb517ef0 --- /dev/null +++ b/packages/lib/utils/email-domains.ts @@ -0,0 +1,17 @@ +export const generateDkimRecord = (recordName: string, publicKeyFlattened: string) => { + return { + name: recordName, + value: `v=DKIM1; k=rsa; p=${publicKeyFlattened}`, + type: 'TXT', + }; +}; + +export const AWS_SES_SPF_RECORD = { + name: `@`, + value: 'v=spf1 include:amazonses.com -all', + type: 'TXT', +}; + +export const generateEmailDomainRecords = (recordName: string, publicKeyFlattened: string) => { + return [generateDkimRecord(recordName, publicKeyFlattened), AWS_SES_SPF_RECORD]; +}; diff --git a/packages/lib/utils/organisations.ts b/packages/lib/utils/organisations.ts index 539db071b..f8a41c5f8 100644 --- a/packages/lib/utils/organisations.ts +++ b/packages/lib/utils/organisations.ts @@ -7,11 +7,13 @@ import { import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations'; +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats'; import { LOWEST_ORGANISATION_ROLE, ORGANISATION_MEMBER_ROLE_HIERARCHY, ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP, } from '../constants/organisations'; +import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email'; export const isPersonalLayout = (organisations: Pick[]) => { return organisations.length === 1 && organisations[0].type === 'PERSONAL'; @@ -113,6 +115,9 @@ export const generateDefaultOrganisationSettings = (): Omit< return { documentVisibility: DocumentVisibility.EVERYONE, documentLanguage: 'en', + documentTimezone: null, // Null means local timezone. + documentDateFormat: DEFAULT_DOCUMENT_DATE_FORMAT, + includeSenderDetails: true, includeSigningCertificate: true, @@ -124,5 +129,10 @@ export const generateDefaultOrganisationSettings = (): Omit< brandingLogo: '', brandingUrl: '', brandingCompanyDetails: '', + + emailId: null, + emailReplyTo: null, + // emailReplyToName: null, + emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS, }; }; diff --git a/packages/lib/utils/teams.ts b/packages/lib/utils/teams.ts index 9e72c462c..ecc8006fe 100644 --- a/packages/lib/utils/teams.ts +++ b/packages/lib/utils/teams.ts @@ -165,6 +165,9 @@ export const generateDefaultTeamSettings = (): Omit { + const { organisationId, domain } = input; + const { user } = ctx; + + ctx.logger.info({ + input: { + organisationId, + domain, + }, + }); + + if (!IS_BILLING_ENABLED()) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Billing is not enabled', + }); + } + + const organisation = await prisma.organisation.findFirst({ + where: buildOrganisationWhereQuery({ + organisationId, + userId: user.id, + roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }), + include: { + emailDomains: true, + organisationClaim: true, + }, + }); + + if (!organisation) { + throw new AppError(AppErrorCode.UNAUTHORIZED); + } + + if (!organisation.organisationClaim.flags.emailDomains) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'Email domains are not enabled for this organisation', + }); + } + + if (organisation.emailDomains.length >= 100) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'You have reached the maximum number of email domains', + }); + } + + return await createEmailDomain({ + domain, + organisationId, + }); + }); diff --git a/packages/trpc/server/enterprise-router/create-organisation-email-domain.types.ts b/packages/trpc/server/enterprise-router/create-organisation-email-domain.types.ts new file mode 100644 index 000000000..3dcb3fc3b --- /dev/null +++ b/packages/trpc/server/enterprise-router/create-organisation-email-domain.types.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import { ZEmailDomainSchema } from '@documenso/lib/types/email-domain'; + +const domainRegex = + /^(?!https?:\/\/)(?!www\.)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; + +export const ZDomainSchema = z + .string() + .regex(domainRegex, { message: 'Invalid domain name' }) + .toLowerCase(); + +export const ZCreateOrganisationEmailDomainRequestSchema = z.object({ + organisationId: z.string(), + domain: ZDomainSchema, +}); + +export const ZCreateOrganisationEmailDomainResponseSchema = z.object({ + emailDomain: ZEmailDomainSchema, + records: z.array( + z.object({ + name: z.string(), + value: z.string(), + type: z.string(), + }), + ), +}); diff --git a/packages/trpc/server/enterprise-router/create-organisation-email.ts b/packages/trpc/server/enterprise-router/create-organisation-email.ts new file mode 100644 index 000000000..6a047da37 --- /dev/null +++ b/packages/trpc/server/enterprise-router/create-organisation-email.ts @@ -0,0 +1,61 @@ +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { generateDatabaseId } from '@documenso/lib/universal/id'; +import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZCreateOrganisationEmailRequestSchema, + ZCreateOrganisationEmailResponseSchema, +} from './create-organisation-email.types'; + +export const createOrganisationEmailRoute = authenticatedProcedure + .input(ZCreateOrganisationEmailRequestSchema) + .output(ZCreateOrganisationEmailResponseSchema) + .mutation(async ({ input, ctx }) => { + const { email, emailName, emailDomainId } = input; + const { user } = ctx; + + ctx.logger.info({ + input: { + emailDomainId, + }, + }); + + const emailDomain = await prisma.emailDomain.findFirst({ + where: { + id: emailDomainId, + organisation: buildOrganisationWhereQuery({ + organisationId: undefined, + userId: user.id, + roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }), + }, + }); + + if (!emailDomain) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Email domain not found', + }); + } + + const allowedEmailSuffix = '@' + emailDomain.domain; + + if (!email.endsWith(allowedEmailSuffix)) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'Cannot create an email with a different domain', + }); + } + + await prisma.organisationEmail.create({ + data: { + id: generateDatabaseId('org_email'), + organisationId: emailDomain.organisationId, + emailName, + // replyTo, + email, + emailDomainId, + }, + }); + }); diff --git a/packages/trpc/server/enterprise-router/create-organisation-email.types.ts b/packages/trpc/server/enterprise-router/create-organisation-email.types.ts new file mode 100644 index 000000000..3de340790 --- /dev/null +++ b/packages/trpc/server/enterprise-router/create-organisation-email.types.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const ZCreateOrganisationEmailRequestSchema = z.object({ + emailDomainId: z.string(), + emailName: z.string().min(1).max(100), + email: z.string().email().toLowerCase(), + + // This does not need to be validated to be part of the domain. + // replyTo: z.string().email().optional(), +}); + +export const ZCreateOrganisationEmailResponseSchema = z.void(); diff --git a/packages/trpc/server/billing/create-subscription.ts b/packages/trpc/server/enterprise-router/create-subscription.ts similarity index 100% rename from packages/trpc/server/billing/create-subscription.ts rename to packages/trpc/server/enterprise-router/create-subscription.ts diff --git a/packages/trpc/server/billing/create-subscription.types.ts b/packages/trpc/server/enterprise-router/create-subscription.types.ts similarity index 100% rename from packages/trpc/server/billing/create-subscription.types.ts rename to packages/trpc/server/enterprise-router/create-subscription.types.ts diff --git a/packages/trpc/server/enterprise-router/delete-organisation-email-domain.ts b/packages/trpc/server/enterprise-router/delete-organisation-email-domain.ts new file mode 100644 index 000000000..1edadfbd7 --- /dev/null +++ b/packages/trpc/server/enterprise-router/delete-organisation-email-domain.ts @@ -0,0 +1,53 @@ +import { deleteEmailDomain } from '@documenso/ee/server-only/lib/delete-email-domain'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZDeleteOrganisationEmailDomainRequestSchema, + ZDeleteOrganisationEmailDomainResponseSchema, +} from './delete-organisation-email-domain.types'; + +export const deleteOrganisationEmailDomainRoute = authenticatedProcedure + .input(ZDeleteOrganisationEmailDomainRequestSchema) + .output(ZDeleteOrganisationEmailDomainResponseSchema) + .mutation(async ({ input, ctx }) => { + const { emailDomainId } = input; + const { user } = ctx; + + ctx.logger.info({ + input: { + emailDomainId, + }, + }); + + if (!IS_BILLING_ENABLED()) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Billing is not enabled', + }); + } + + const emailDomain = await prisma.emailDomain.findFirst({ + where: { + id: emailDomainId, + organisation: buildOrganisationWhereQuery({ + organisationId: undefined, + userId: user.id, + roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }), + }, + }); + + if (!emailDomain) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Email domain not found', + }); + } + + await deleteEmailDomain({ + emailDomainId: emailDomain.id, + }); + }); diff --git a/packages/trpc/server/enterprise-router/delete-organisation-email-domain.types.ts b/packages/trpc/server/enterprise-router/delete-organisation-email-domain.types.ts new file mode 100644 index 000000000..2ba8802da --- /dev/null +++ b/packages/trpc/server/enterprise-router/delete-organisation-email-domain.types.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const ZDeleteOrganisationEmailDomainRequestSchema = z.object({ + emailDomainId: z.string(), +}); + +export const ZDeleteOrganisationEmailDomainResponseSchema = z.void(); diff --git a/packages/trpc/server/enterprise-router/delete-organisation-email.ts b/packages/trpc/server/enterprise-router/delete-organisation-email.ts new file mode 100644 index 000000000..770649d71 --- /dev/null +++ b/packages/trpc/server/enterprise-router/delete-organisation-email.ts @@ -0,0 +1,45 @@ +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZDeleteOrganisationEmailRequestSchema, + ZDeleteOrganisationEmailResponseSchema, +} from './delete-organisation-email.types'; + +export const deleteOrganisationEmailRoute = authenticatedProcedure + .input(ZDeleteOrganisationEmailRequestSchema) + .output(ZDeleteOrganisationEmailResponseSchema) + .mutation(async ({ input, ctx }) => { + const { emailId } = input; + const { user } = ctx; + + ctx.logger.info({ + input: { + emailId, + }, + }); + + const email = await prisma.organisationEmail.findFirst({ + where: { + id: emailId, + organisation: buildOrganisationWhereQuery({ + organisationId: undefined, + userId: user.id, + roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }), + }, + }); + + if (!email) { + throw new AppError(AppErrorCode.UNAUTHORIZED); + } + + await prisma.organisationEmail.delete({ + where: { + id: email.id, + }, + }); + }); diff --git a/packages/trpc/server/enterprise-router/delete-organisation-email.types.ts b/packages/trpc/server/enterprise-router/delete-organisation-email.types.ts new file mode 100644 index 000000000..116d60d4b --- /dev/null +++ b/packages/trpc/server/enterprise-router/delete-organisation-email.types.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const ZDeleteOrganisationEmailRequestSchema = z.object({ + emailId: z.string(), +}); + +export const ZDeleteOrganisationEmailResponseSchema = z.void(); diff --git a/packages/trpc/server/enterprise-router/find-organisation-email-domain.ts b/packages/trpc/server/enterprise-router/find-organisation-email-domain.ts new file mode 100644 index 000000000..6901efb76 --- /dev/null +++ b/packages/trpc/server/enterprise-router/find-organisation-email-domain.ts @@ -0,0 +1,122 @@ +import type { EmailDomainStatus } from '@prisma/client'; +import { Prisma } from '@prisma/client'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { FindResultResponse } from '@documenso/lib/types/search-params'; +import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZFindOrganisationEmailDomainsRequestSchema, + ZFindOrganisationEmailDomainsResponseSchema, +} from './find-organisation-email-domain.types'; + +export const findOrganisationEmailDomainsRoute = authenticatedProcedure + .input(ZFindOrganisationEmailDomainsRequestSchema) + .output(ZFindOrganisationEmailDomainsResponseSchema) + .query(async ({ input, ctx }) => { + const { organisationId, emailDomainId, statuses, query, page, perPage } = input; + const { user } = ctx; + + ctx.logger.info({ + input: { + organisationId, + }, + }); + + return await findOrganisationEmailDomains({ + userId: user.id, + organisationId, + emailDomainId, + statuses, + query, + page, + perPage, + }); + }); + +type FindOrganisationEmailDomainsOptions = { + userId: number; + organisationId: string; + emailDomainId?: string; + statuses?: EmailDomainStatus[]; + query?: string; + page?: number; + perPage?: number; +}; + +export const findOrganisationEmailDomains = async ({ + userId, + organisationId, + emailDomainId, + statuses = [], + query, + page = 1, + perPage = 100, +}: FindOrganisationEmailDomainsOptions) => { + const organisation = await prisma.organisation.findFirst({ + where: buildOrganisationWhereQuery({ organisationId, userId }), + }); + + if (!organisation) { + throw new AppError(AppErrorCode.NOT_FOUND); + } + + const whereClause: Prisma.EmailDomainWhereInput = { + organisationId: organisation.id, + status: statuses.length > 0 ? { in: statuses } : undefined, + }; + + if (emailDomainId) { + whereClause.id = emailDomainId; + } + + if (query) { + whereClause.domain = { + contains: query, + mode: Prisma.QueryMode.insensitive, + }; + } + + const [data, count] = await Promise.all([ + prisma.emailDomain.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + createdAt: 'desc', + }, + select: { + id: true, + status: true, + organisationId: true, + domain: true, + selector: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + emails: true, + }, + }, + }, + }), + prisma.emailDomain.count({ + where: whereClause, + }), + ]); + + const mappedData = data.map((item) => ({ + ...item, + emailCount: item._count.emails, + })); + + return { + data: mappedData, + count, + currentPage: page, + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultResponse; +}; diff --git a/packages/trpc/server/enterprise-router/find-organisation-email-domain.types.ts b/packages/trpc/server/enterprise-router/find-organisation-email-domain.types.ts new file mode 100644 index 000000000..1aa4bde0c --- /dev/null +++ b/packages/trpc/server/enterprise-router/find-organisation-email-domain.types.ts @@ -0,0 +1,23 @@ +import { EmailDomainStatus } from '@prisma/client'; +import { z } from 'zod'; + +import { ZEmailDomainManySchema } from '@documenso/lib/types/email-domain'; +import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; + +export const ZFindOrganisationEmailDomainsRequestSchema = ZFindSearchParamsSchema.extend({ + organisationId: z.string(), + emailDomainId: z.string().optional(), + statuses: z.nativeEnum(EmailDomainStatus).array().optional(), +}); + +export const ZFindOrganisationEmailDomainsResponseSchema = ZFindResultResponse.extend({ + data: z.array( + ZEmailDomainManySchema.extend({ + emailCount: z.number(), + }), + ), +}); + +export type TFindOrganisationEmailDomainsResponse = z.infer< + typeof ZFindOrganisationEmailDomainsResponseSchema +>; diff --git a/packages/trpc/server/enterprise-router/find-organisation-emails.ts b/packages/trpc/server/enterprise-router/find-organisation-emails.ts new file mode 100644 index 000000000..9f0f759ac --- /dev/null +++ b/packages/trpc/server/enterprise-router/find-organisation-emails.ts @@ -0,0 +1,105 @@ +import { Prisma } from '@prisma/client'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { FindResultResponse } from '@documenso/lib/types/search-params'; +import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZFindOrganisationEmailsRequestSchema, + ZFindOrganisationEmailsResponseSchema, +} from './find-organisation-emails.types'; + +export const findOrganisationEmailsRoute = authenticatedProcedure + .input(ZFindOrganisationEmailsRequestSchema) + .output(ZFindOrganisationEmailsResponseSchema) + .query(async ({ input, ctx }) => { + const { organisationId, emailDomainId, query, page, perPage } = input; + const { user } = ctx; + + ctx.logger.info({ + input: { + organisationId, + }, + }); + + return await findOrganisationEmails({ + userId: user.id, + organisationId, + emailDomainId, + query, + page, + perPage, + }); + }); + +type FindOrganisationEmailsOptions = { + userId: number; + organisationId: string; + emailDomainId?: string; + query?: string; + page?: number; + perPage?: number; +}; + +export const findOrganisationEmails = async ({ + userId, + organisationId, + emailDomainId, + query, + page = 1, + perPage = 100, +}: FindOrganisationEmailsOptions) => { + const organisation = await prisma.organisation.findFirst({ + where: buildOrganisationWhereQuery({ organisationId, userId }), + }); + + if (!organisation) { + throw new AppError(AppErrorCode.NOT_FOUND); + } + + const whereClause: Prisma.OrganisationEmailWhereInput = { + organisationId: organisation.id, + emailDomainId, + }; + + if (query) { + whereClause.email = { + contains: query, + mode: Prisma.QueryMode.insensitive, + }; + } + + const [data, count] = await Promise.all([ + prisma.organisationEmail.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + createdAt: 'desc', + }, + select: { + id: true, + createdAt: true, + updatedAt: true, + email: true, + emailName: true, + // replyTo: true, + emailDomainId: true, + organisationId: true, + }, + }), + prisma.organisationEmail.count({ + where: whereClause, + }), + ]); + + return { + data, + count, + currentPage: page, + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultResponse; +}; diff --git a/packages/trpc/server/enterprise-router/find-organisation-emails.types.ts b/packages/trpc/server/enterprise-router/find-organisation-emails.types.ts new file mode 100644 index 000000000..2e0d25e4e --- /dev/null +++ b/packages/trpc/server/enterprise-router/find-organisation-emails.types.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +import { ZOrganisationEmailManySchema } from '@documenso/lib/types/organisation-email'; +import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; + +export const ZFindOrganisationEmailsRequestSchema = ZFindSearchParamsSchema.extend({ + organisationId: z.string(), + emailDomainId: z.string().optional(), +}); + +export const ZFindOrganisationEmailsResponseSchema = ZFindResultResponse.extend({ + data: ZOrganisationEmailManySchema.array(), +}); + +export type TFindOrganisationEmailsResponse = z.infer; diff --git a/packages/trpc/server/billing/get-invoices.ts b/packages/trpc/server/enterprise-router/get-invoices.ts similarity index 100% rename from packages/trpc/server/billing/get-invoices.ts rename to packages/trpc/server/enterprise-router/get-invoices.ts diff --git a/packages/trpc/server/billing/get-invoices.types.ts b/packages/trpc/server/enterprise-router/get-invoices.types.ts similarity index 100% rename from packages/trpc/server/billing/get-invoices.types.ts rename to packages/trpc/server/enterprise-router/get-invoices.types.ts diff --git a/packages/trpc/server/enterprise-router/get-organisation-email-domain.ts b/packages/trpc/server/enterprise-router/get-organisation-email-domain.ts new file mode 100644 index 000000000..c2834d2a3 --- /dev/null +++ b/packages/trpc/server/enterprise-router/get-organisation-email-domain.ts @@ -0,0 +1,63 @@ +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetOrganisationEmailDomainRequestSchema, + ZGetOrganisationEmailDomainResponseSchema, +} from './get-organisation-email-domain.types'; + +export const getOrganisationEmailDomainRoute = authenticatedProcedure + .input(ZGetOrganisationEmailDomainRequestSchema) + .output(ZGetOrganisationEmailDomainResponseSchema) + .query(async ({ input, ctx }) => { + const { emailDomainId } = input; + + ctx.logger.info({ + input: { + emailDomainId, + }, + }); + + return await getOrganisationEmailDomain({ + userId: ctx.user.id, + emailDomainId, + }); + }); + +type GetOrganisationEmailDomainOptions = { + userId: number; + emailDomainId: string; +}; + +export const getOrganisationEmailDomain = async ({ + userId, + emailDomainId, +}: GetOrganisationEmailDomainOptions) => { + const emailDomain = await prisma.emailDomain.findFirst({ + where: { + id: emailDomainId, + organisation: buildOrganisationWhereQuery({ + organisationId: undefined, + userId, + roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }), + }, + omit: { + privateKey: true, + }, + include: { + emails: true, + }, + }); + + if (!emailDomain) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Email domain not found', + }); + } + + return emailDomain; +}; diff --git a/packages/trpc/server/enterprise-router/get-organisation-email-domain.types.ts b/packages/trpc/server/enterprise-router/get-organisation-email-domain.types.ts new file mode 100644 index 000000000..4fc9afaf7 --- /dev/null +++ b/packages/trpc/server/enterprise-router/get-organisation-email-domain.types.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +import { ZEmailDomainSchema } from '@documenso/lib/types/email-domain'; + +export const ZGetOrganisationEmailDomainRequestSchema = z.object({ + emailDomainId: z.string(), +}); + +export const ZGetOrganisationEmailDomainResponseSchema = ZEmailDomainSchema; + +export type TGetOrganisationEmailDomainResponse = z.infer< + typeof ZGetOrganisationEmailDomainResponseSchema +>; diff --git a/packages/trpc/server/billing/get-plans.ts b/packages/trpc/server/enterprise-router/get-plans.ts similarity index 100% rename from packages/trpc/server/billing/get-plans.ts rename to packages/trpc/server/enterprise-router/get-plans.ts diff --git a/packages/trpc/server/billing/get-subscription.ts b/packages/trpc/server/enterprise-router/get-subscription.ts similarity index 100% rename from packages/trpc/server/billing/get-subscription.ts rename to packages/trpc/server/enterprise-router/get-subscription.ts diff --git a/packages/trpc/server/billing/get-subscription.types.ts b/packages/trpc/server/enterprise-router/get-subscription.types.ts similarity index 100% rename from packages/trpc/server/billing/get-subscription.types.ts rename to packages/trpc/server/enterprise-router/get-subscription.types.ts diff --git a/packages/trpc/server/billing/manage-subscription.ts b/packages/trpc/server/enterprise-router/manage-subscription.ts similarity index 100% rename from packages/trpc/server/billing/manage-subscription.ts rename to packages/trpc/server/enterprise-router/manage-subscription.ts diff --git a/packages/trpc/server/billing/manage-subscription.types.ts b/packages/trpc/server/enterprise-router/manage-subscription.types.ts similarity index 100% rename from packages/trpc/server/billing/manage-subscription.types.ts rename to packages/trpc/server/enterprise-router/manage-subscription.types.ts diff --git a/packages/trpc/server/enterprise-router/router.ts b/packages/trpc/server/enterprise-router/router.ts new file mode 100644 index 000000000..3fcac5708 --- /dev/null +++ b/packages/trpc/server/enterprise-router/router.ts @@ -0,0 +1,46 @@ +import { router } from '../trpc'; +import { createOrganisationEmailRoute } from './create-organisation-email'; +import { createOrganisationEmailDomainRoute } from './create-organisation-email-domain'; +import { createSubscriptionRoute } from './create-subscription'; +import { deleteOrganisationEmailRoute } from './delete-organisation-email'; +import { deleteOrganisationEmailDomainRoute } from './delete-organisation-email-domain'; +import { findOrganisationEmailDomainsRoute } from './find-organisation-email-domain'; +import { findOrganisationEmailsRoute } from './find-organisation-emails'; +import { getInvoicesRoute } from './get-invoices'; +import { getOrganisationEmailDomainRoute } from './get-organisation-email-domain'; +import { getPlansRoute } from './get-plans'; +import { getSubscriptionRoute } from './get-subscription'; +import { manageSubscriptionRoute } from './manage-subscription'; +import { updateOrganisationEmailRoute } from './update-organisation-email'; +import { verifyOrganisationEmailDomainRoute } from './verify-organisation-email-domain'; + +export const enterpriseRouter = router({ + organisation: { + email: { + find: findOrganisationEmailsRoute, + create: createOrganisationEmailRoute, + update: updateOrganisationEmailRoute, + delete: deleteOrganisationEmailRoute, + }, + emailDomain: { + get: getOrganisationEmailDomainRoute, + find: findOrganisationEmailDomainsRoute, + create: createOrganisationEmailDomainRoute, + delete: deleteOrganisationEmailDomainRoute, + verify: verifyOrganisationEmailDomainRoute, + }, + }, + billing: { + plans: { + get: getPlansRoute, + }, + subscription: { + get: getSubscriptionRoute, + create: createSubscriptionRoute, + manage: manageSubscriptionRoute, + }, + invoices: { + get: getInvoicesRoute, + }, + }, +}); diff --git a/packages/trpc/server/enterprise-router/update-organisation-email.ts b/packages/trpc/server/enterprise-router/update-organisation-email.ts new file mode 100644 index 000000000..59ca52435 --- /dev/null +++ b/packages/trpc/server/enterprise-router/update-organisation-email.ts @@ -0,0 +1,49 @@ +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZUpdateOrganisationEmailRequestSchema, + ZUpdateOrganisationEmailResponseSchema, +} from './update-organisation-email.types'; + +export const updateOrganisationEmailRoute = authenticatedProcedure + .input(ZUpdateOrganisationEmailRequestSchema) + .output(ZUpdateOrganisationEmailResponseSchema) + .mutation(async ({ input, ctx }) => { + const { emailId, emailName } = input; + const { user } = ctx; + + ctx.logger.info({ + input: { + emailId, + }, + }); + + const organisationEmail = await prisma.organisationEmail.findFirst({ + where: { + id: emailId, + organisation: buildOrganisationWhereQuery({ + organisationId: undefined, + userId: user.id, + roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }), + }, + }); + + if (!organisationEmail) { + throw new AppError(AppErrorCode.UNAUTHORIZED); + } + + await prisma.organisationEmail.update({ + where: { + id: emailId, + }, + data: { + emailName, + // replyTo, + }, + }); + }); diff --git a/packages/trpc/server/enterprise-router/update-organisation-email.types.ts b/packages/trpc/server/enterprise-router/update-organisation-email.types.ts new file mode 100644 index 000000000..61222c1a2 --- /dev/null +++ b/packages/trpc/server/enterprise-router/update-organisation-email.types.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +import { ZCreateOrganisationEmailRequestSchema } from './create-organisation-email.types'; + +export const ZUpdateOrganisationEmailRequestSchema = z + .object({ + emailId: z.string(), + }) + .extend( + ZCreateOrganisationEmailRequestSchema.pick({ + emailName: true, + // replyTo: true + }).shape, + ); + +export const ZUpdateOrganisationEmailResponseSchema = z.void(); + +export type TUpdateOrganisationEmailRequest = z.infer; diff --git a/packages/trpc/server/enterprise-router/verify-organisation-email-domain.ts b/packages/trpc/server/enterprise-router/verify-organisation-email-domain.ts new file mode 100644 index 000000000..436e1a33d --- /dev/null +++ b/packages/trpc/server/enterprise-router/verify-organisation-email-domain.ts @@ -0,0 +1,59 @@ +import { verifyEmailDomain } from '@documenso/ee/server-only/lib/verify-email-domain'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZVerifyOrganisationEmailDomainRequestSchema, + ZVerifyOrganisationEmailDomainResponseSchema, +} from './verify-organisation-email-domain.types'; + +export const verifyOrganisationEmailDomainRoute = authenticatedProcedure + .input(ZVerifyOrganisationEmailDomainRequestSchema) + .output(ZVerifyOrganisationEmailDomainResponseSchema) + .mutation(async ({ input, ctx }) => { + const { organisationId, emailDomainId } = input; + const { user } = ctx; + + ctx.logger.info({ + input: { + organisationId, + emailDomainId, + }, + }); + + if (!IS_BILLING_ENABLED()) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Billing is not enabled', + }); + } + + const organisation = await prisma.organisation.findFirst({ + where: buildOrganisationWhereQuery({ + organisationId, + userId: user.id, + roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }), + include: { + emailDomains: true, + }, + }); + + if (!organisation) { + throw new AppError(AppErrorCode.UNAUTHORIZED); + } + + // Filter down emails to verify a specific email, otherwise verify all emails regardless of status. + const emailsToVerify = organisation.emailDomains.filter((email) => { + if (emailDomainId && email.id !== emailDomainId) { + return false; + } + + return true; + }); + + await Promise.all(emailsToVerify.map(async (email) => verifyEmailDomain(email.id))); + }); diff --git a/packages/trpc/server/enterprise-router/verify-organisation-email-domain.types.ts b/packages/trpc/server/enterprise-router/verify-organisation-email-domain.types.ts new file mode 100644 index 000000000..49897b4a4 --- /dev/null +++ b/packages/trpc/server/enterprise-router/verify-organisation-email-domain.types.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const ZVerifyOrganisationEmailDomainRequestSchema = z.object({ + organisationId: z.string(), + emailDomainId: z.string().optional().describe('Leave blank to revalidate all emails'), +}); + +export const ZVerifyOrganisationEmailDomainResponseSchema = z.void(); diff --git a/packages/trpc/server/organisation-router/update-organisation-settings.ts b/packages/trpc/server/organisation-router/update-organisation-settings.ts index 09061bf07..ca347bc10 100644 --- a/packages/trpc/server/organisation-router/update-organisation-settings.ts +++ b/packages/trpc/server/organisation-router/update-organisation-settings.ts @@ -26,16 +26,25 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure // Document related settings. documentVisibility, documentLanguage, + documentTimezone, + documentDateFormat, includeSenderDetails, includeSigningCertificate, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, + // Branding related settings. brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, + + // Email related settings. + emailId, + emailReplyTo, + // emailReplyToName, + emailDocumentSettings, } = data; if (Object.values(data).length === 0) { @@ -61,6 +70,22 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure }); } + // Validate that the email ID belongs to the organisation. + if (emailId) { + const email = await prisma.organisationEmail.findFirst({ + where: { + id: emailId, + organisationId, + }, + }); + + if (!email) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Email not found', + }); + } + } + const derivedTypedSignatureEnabled = typedSignatureEnabled ?? organisation.organisationGlobalSettings.typedSignatureEnabled; const derivedUploadSignatureEnabled = @@ -88,6 +113,8 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure // Document related settings. documentVisibility, documentLanguage, + documentTimezone, + documentDateFormat, includeSenderDetails, includeSigningCertificate, typedSignatureEnabled, @@ -99,6 +126,12 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure brandingLogo, brandingUrl, brandingCompanyDetails, + + // Email related settings. + emailId, + emailReplyTo, + // emailReplyToName, + emailDocumentSettings, }, }, }, diff --git a/packages/trpc/server/organisation-router/update-organisation-settings.types.ts b/packages/trpc/server/organisation-router/update-organisation-settings.types.ts index 99987ba21..c7b0ac75b 100644 --- a/packages/trpc/server/organisation-router/update-organisation-settings.types.ts +++ b/packages/trpc/server/organisation-router/update-organisation-settings.types.ts @@ -1,14 +1,22 @@ import { z } from 'zod'; import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n'; +import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; +import { + ZDocumentMetaDateFormatSchema, + ZDocumentMetaTimezoneSchema, +} from '../document-router/schema'; + export const ZUpdateOrganisationSettingsRequestSchema = z.object({ organisationId: z.string(), data: z.object({ // Document related settings. documentVisibility: z.nativeEnum(DocumentVisibility).optional(), documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).optional(), + documentTimezone: ZDocumentMetaTimezoneSchema.nullish(), // Null means local timezone. + documentDateFormat: ZDocumentMetaDateFormatSchema.optional(), includeSenderDetails: z.boolean().optional(), includeSigningCertificate: z.boolean().optional(), typedSignatureEnabled: z.boolean().optional(), @@ -20,6 +28,12 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({ brandingLogo: z.string().optional(), brandingUrl: z.string().optional(), brandingCompanyDetails: z.string().optional(), + + // Email related settings. + emailId: z.string().nullish(), + emailReplyTo: z.string().email().nullish(), + // emailReplyToName: z.string().optional(), + emailDocumentSettings: ZDocumentEmailSettingsSchema.optional(), }), }); diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index 861fd41ad..fbe35147c 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -1,9 +1,9 @@ import { adminRouter } from './admin-router/router'; import { apiTokenRouter } from './api-token-router/router'; import { authRouter } from './auth-router/router'; -import { billingRouter } from './billing/router'; import { documentRouter } from './document-router/router'; import { embeddingPresignRouter } from './embedding-router/_router'; +import { enterpriseRouter } from './enterprise-router/router'; import { fieldRouter } from './field-router/router'; import { folderRouter } from './folder-router/router'; import { organisationRouter } from './organisation-router/router'; @@ -16,8 +16,8 @@ import { router } from './trpc'; import { webhookRouter } from './webhook-router/router'; export const appRouter = router({ + enterprise: enterpriseRouter, auth: authRouter, - billing: billingRouter, profile: profileRouter, document: documentRouter, field: fieldRouter, diff --git a/packages/trpc/server/team-router/update-team-settings.ts b/packages/trpc/server/team-router/update-team-settings.ts index 72082a9a1..9cae5b330 100644 --- a/packages/trpc/server/team-router/update-team-settings.ts +++ b/packages/trpc/server/team-router/update-team-settings.ts @@ -1,3 +1,5 @@ +import { Prisma } from '@prisma/client'; + import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; @@ -26,6 +28,8 @@ export const updateTeamSettingsRoute = authenticatedProcedure // Document related settings. documentVisibility, documentLanguage, + documentTimezone, + documentDateFormat, includeSenderDetails, includeSigningCertificate, typedSignatureEnabled, @@ -37,6 +41,12 @@ export const updateTeamSettingsRoute = authenticatedProcedure brandingLogo, brandingUrl, brandingCompanyDetails, + + // Email related settings. + emailId, + emailReplyTo, + // emailReplyToName, + emailDocumentSettings, } = data; if (Object.values(data).length === 0) { @@ -70,6 +80,22 @@ export const updateTeamSettingsRoute = authenticatedProcedure }); } + // Validate that the email ID belongs to the organisation. + if (emailId) { + const email = await prisma.organisationEmail.findFirst({ + where: { + id: emailId, + organisationId: team.organisationId, + }, + }); + + if (!email) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Email not found', + }); + } + } + await prisma.team.update({ where: { id: teamId, @@ -80,6 +106,8 @@ export const updateTeamSettingsRoute = authenticatedProcedure // Document related settings. documentVisibility, documentLanguage, + documentTimezone, + documentDateFormat, includeSenderDetails, includeSigningCertificate, typedSignatureEnabled, @@ -91,6 +119,13 @@ export const updateTeamSettingsRoute = authenticatedProcedure brandingLogo, brandingUrl, brandingCompanyDetails, + + // Email related settings. + emailId, + emailReplyTo, + // emailReplyToName, + emailDocumentSettings: + emailDocumentSettings === null ? Prisma.DbNull : emailDocumentSettings, }, }, }, diff --git a/packages/trpc/server/team-router/update-team-settings.types.ts b/packages/trpc/server/team-router/update-team-settings.types.ts index 5b7781b92..9f1fee8fd 100644 --- a/packages/trpc/server/team-router/update-team-settings.types.ts +++ b/packages/trpc/server/team-router/update-team-settings.types.ts @@ -1,8 +1,14 @@ import { z } from 'zod'; import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n'; +import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; +import { + ZDocumentMetaDateFormatSchema, + ZDocumentMetaTimezoneSchema, +} from '../document-router/schema'; + /** * Null = Inherit from organisation. * Undefined = Do nothing @@ -13,6 +19,8 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({ // Document related settings. documentVisibility: z.nativeEnum(DocumentVisibility).nullish(), documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullish(), + documentTimezone: ZDocumentMetaTimezoneSchema.nullish(), + documentDateFormat: ZDocumentMetaDateFormatSchema.nullish(), includeSenderDetails: z.boolean().nullish(), includeSigningCertificate: z.boolean().nullish(), typedSignatureEnabled: z.boolean().nullish(), @@ -24,6 +32,12 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({ brandingLogo: z.string().nullish(), brandingUrl: z.string().nullish(), brandingCompanyDetails: z.string().nullish(), + + // Email related settings. + emailId: z.string().nullish(), + emailReplyTo: z.string().email().nullish(), + // emailReplyToName: z.string().nullish(), + emailDocumentSettings: ZDocumentEmailSettingsSchema.nullish(), }), }); diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 41452915a..ff8bb0eca 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -64,6 +64,8 @@ export const ZTemplateMetaUpsertSchema = z.object({ timezone: ZDocumentMetaTimezoneSchema.optional(), dateFormat: ZDocumentMetaDateFormatSchema.optional(), distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(), + emailId: z.string().nullish(), + emailReplyTo: z.string().nullish(), emailSettings: ZDocumentEmailSettingsSchema.optional(), redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(), language: ZDocumentMetaLanguageSchema.optional(), diff --git a/packages/ui/components/document/document-read-only-fields.tsx b/packages/ui/components/document/document-read-only-fields.tsx index 4dcf72104..80e998c26 100644 --- a/packages/ui/components/document/document-read-only-fields.tsx +++ b/packages/ui/components/document/document-read-only-fields.tsx @@ -23,7 +23,7 @@ import { FieldContent } from '../../primitives/document-flow/field-content'; export type DocumentReadOnlyFieldsProps = { fields: DocumentField[]; - documentMeta?: DocumentMeta | TemplateMeta; + documentMeta?: Pick; showFieldStatus?: boolean; diff --git a/packages/ui/primitives/combobox.tsx b/packages/ui/primitives/combobox.tsx index 93f4167f7..2f5bb6712 100644 --- a/packages/ui/primitives/combobox.tsx +++ b/packages/ui/primitives/combobox.tsx @@ -15,8 +15,10 @@ type ComboboxProps = { options: string[]; value: string | null; onChange: (_value: string | null) => void; + triggerPlaceholder?: string; placeholder?: string; disabled?: boolean; + testId?: string; }; const Combobox = ({ @@ -25,7 +27,9 @@ const Combobox = ({ value, onChange, disabled = false, + triggerPlaceholder, placeholder, + testId, }: ComboboxProps) => { const { _ } = useLingui(); @@ -47,8 +51,9 @@ const Combobox = ({ aria-expanded={open} className={cn('my-2 w-full justify-between', className)} disabled={disabled} + data-testid={testId} > - {value ? value : placeholderValue} + {value ? value : triggerPlaceholder || placeholderValue} diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index 5666bae91..6553597f2 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -5,13 +5,31 @@ import { Trans } from '@lingui/react/macro'; import type { Field, Recipient } from '@prisma/client'; import { DocumentDistributionMethod, DocumentStatus, RecipientRole } from '@prisma/client'; import { AnimatePresence, motion } from 'framer-motion'; +import { InfoIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import type { TDocument } from '@documenso/lib/types/document'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { formatSigningLink } from '@documenso/lib/utils/recipients'; +import { trpc } from '@documenso/trpc/react'; import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; +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 { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { CopyTextButton } from '../../components/common/copy-text-button'; @@ -21,11 +39,10 @@ import { mapFieldsWithRecipients, } from '../../components/document/document-read-only-fields'; import { AvatarWithText } from '../avatar'; -import { FormErrorMessage } from '../form/form-error-message'; import { Input } from '../input'; -import { Label } from '../label'; import { useStep } from '../stepper'; import { Textarea } from '../textarea'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; import { toast } from '../use-toast'; import { type TAddSubjectFormSchema, ZAddSubjectFormSchema } from './add-subject.types'; import { @@ -56,15 +73,14 @@ export const AddSubjectFormPartial = ({ }: AddSubjectFormProps) => { const { _ } = useLingui(); - const { - register, - handleSubmit, - setValue, - watch, - formState: { errors, isSubmitting }, - } = useForm({ + const organisation = useCurrentOrganisation(); + + const form = useForm({ defaultValues: { meta: { + emailId: document.documentMeta?.emailId ?? null, + emailReplyTo: document.documentMeta?.emailReplyTo || undefined, + // emailReplyName: document.documentMeta?.emailReplyName || undefined, subject: document.documentMeta?.subject ?? '', message: document.documentMeta?.message ?? '', distributionMethod: @@ -75,6 +91,21 @@ export const AddSubjectFormPartial = ({ resolver: zodResolver(ZAddSubjectFormSchema), }); + const { + handleSubmit, + setValue, + watch, + formState: { isSubmitting }, + } = form; + + const { data: emailData, isLoading: isLoadingEmails } = + trpc.enterprise.organisation.email.find.useQuery({ + organisationId: organisation.id, + perPage: 100, + }); + + const emails = emailData?.data || []; + const GoNextLabel = { [DocumentDistributionMethod.EMAIL]: { [DocumentStatus.DRAFT]: msg`Send`, @@ -139,54 +170,141 @@ export const AddSubjectFormPartial = ({ initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }} exit={{ opacity: 0, transition: { duration: 0.15 } }} - className="flex flex-col gap-y-4 rounded-lg border p-4" > -
- +
+
+ {organisation.organisationClaim.flags.emailDomains && ( + ( + + + Email Sender + - + + + -