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-signing/document-signing-fields.tsx b/apps/remix/app/components/general/document-signing/document-signing-fields.tsx index 8899836ba..805cbf7ea 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-fields.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-fields.tsx @@ -12,7 +12,7 @@ export const DocumentSigningFieldsLoader = () => { export const DocumentSigningFieldsUninserted = ({ children }: { children: React.ReactNode }) => { return ( -

+

{children}

); @@ -37,7 +37,7 @@ export const DocumentSigningFieldsInserted = ({

{ - 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/api/v1/contract.ts b/packages/api/v1/contract.ts index dcd0f13b1..50f096498 100644 --- a/packages/api/v1/contract.ts +++ b/packages/api/v1/contract.ts @@ -1,5 +1,10 @@ import { initContract } from '@ts-rest/core'; +import { + ZCreateTemplateV2RequestSchema, + ZCreateTemplateV2ResponseSchema, +} from '@documenso/trpc/server/template-router/schema'; + import { ZAuthorizationHeadersSchema, ZCreateDocumentFromTemplateMutationResponseSchema, @@ -87,6 +92,18 @@ export const ApiContractV1 = c.router( summary: 'Upload a new document and get a presigned URL', }, + createTemplate: { + method: 'POST', + path: '/api/v1/templates', + body: ZCreateTemplateV2RequestSchema, + responses: { + 200: ZCreateTemplateV2ResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + }, + summary: 'Create a new template and get a presigned URL', + }, + deleteTemplate: { method: 'DELETE', path: '/api/v1/templates/:id', diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 57a3ace40..e8afd9af4 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -30,6 +30,7 @@ import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set- import { updateDocumentRecipients } from '@documenso/lib/server-only/recipient/update-document-recipients'; import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy'; +import { createTemplate } from '@documenso/lib/server-only/template/create-template'; import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; import { findTemplates } from '@documenso/lib/server-only/template/find-templates'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; @@ -400,6 +401,109 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { } }), + createTemplate: authenticatedMiddleware(async (args, user, team) => { + const { body } = args; + const { + title, + folderId, + externalId, + visibility, + globalAccessAuth, + globalActionAuth, + publicTitle, + publicDescription, + type, + meta, + } = body; + + try { + if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') { + return { + status: 500, + body: { + message: 'Create template is not available without S3 transport.', + }, + }; + } + + const dateFormat = meta?.dateFormat + ? DATE_FORMATS.find((format) => format.value === meta?.dateFormat) + : DATE_FORMATS.find((format) => format.value === DEFAULT_DOCUMENT_DATE_FORMAT); + + if (meta?.dateFormat && !dateFormat) { + return { + status: 400, + body: { + message: 'Invalid date format. Please provide a valid date format', + }, + }; + } + + const timezone = meta?.timezone + ? TIME_ZONES.find((tz) => tz === meta?.timezone) + : DEFAULT_DOCUMENT_TIME_ZONE; + + const isTimeZoneValid = meta?.timezone ? TIME_ZONES.includes(String(timezone)) : true; + + if (!isTimeZoneValid) { + return { + status: 400, + body: { + message: 'Invalid timezone. Please provide a valid timezone', + }, + }; + } + + const fileName = title?.endsWith('.pdf') ? title : `${title}.pdf`; + + const { url, key } = await getPresignPostUrl(fileName, 'application/pdf'); + + const templateDocumentData = await createDocumentData({ + data: key, + type: DocumentDataType.S3_PATH, + }); + + const createdTemplate = await createTemplate({ + userId: user.id, + teamId: team.id, + templateDocumentDataId: templateDocumentData.id, + data: { + title, + folderId, + externalId, + visibility, + globalAccessAuth, + globalActionAuth, + publicTitle, + publicDescription, + type, + }, + meta, + }); + + const fullTemplate = await getTemplateById({ + id: createdTemplate.id, + userId: user.id, + teamId: team.id, + }); + + return { + status: 200, + body: { + uploadUrl: url, + template: fullTemplate, + }, + }; + } catch (err) { + return { + status: 404, + body: { + message: 'An error has occured while creating the template', + }, + }; + } + }), + deleteTemplate: authenticatedMiddleware(async (args, user, team, { logger }) => { const { id: templateId } = args.params; 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/date-formats.ts b/packages/lib/constants/date-formats.ts index 2a3855586..95a254705 100644 --- a/packages/lib/constants/date-formats.ts +++ b/packages/lib/constants/date-formats.ts @@ -9,6 +9,7 @@ export const VALID_DATE_FORMAT_VALUES = [ 'yyyy-MM-dd', 'dd/MM/yyyy hh:mm a', 'MM/dd/yyyy hh:mm a', + 'dd.MM.yyyy HH:mm', 'yyyy-MM-dd HH:mm', 'yy-MM-dd hh:mm a', 'yyyy-MM-dd HH:mm:ss', @@ -40,6 +41,11 @@ export const DATE_FORMATS = [ label: 'MM/DD/YYYY', value: 'MM/dd/yyyy hh:mm a', }, + { + key: 'DDMMYYYYHHMM', + label: 'DD.MM.YYYY HH:mm', + value: 'dd.MM.yyyy HH:mm', + }, { key: 'YYYYMMDDHHmm', label: 'YYYY-MM-DD HH:mm', 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 8c583e11e..3daaa1569 100644 --- a/packages/lib/server-only/document/update-document.ts +++ b/packages/lib/server-only/document/update-document.ts @@ -1,4 +1,5 @@ import { DocumentStatus, DocumentVisibility, 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'; @@ -120,9 +121,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 4d26ce5c9..5019407a2 100644 --- a/packages/lib/server-only/template/create-template.ts +++ b/packages/lib/server-only/template/create-template.ts @@ -1,16 +1,32 @@ +import type { DocumentVisibility, Template, TemplateMeta } from '@prisma/client'; import type { z } from 'zod'; import { prisma } from '@documenso/prisma'; import { TemplateSchema } from '@documenso/prisma/generated/zod/modelSchema//TemplateSchema'; -import type { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema'; 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'; -export type CreateTemplateOptions = TCreateTemplateMutationSchema & { +export type CreateTemplateOptions = { userId: number; teamId: number; + templateDocumentDataId: string; + data: { + title: string; + folderId?: string; + externalId?: string | null; + visibility?: DocumentVisibility; + globalAccessAuth?: TDocumentAccessAuthTypes[]; + globalActionAuth?: TDocumentActionAuthTypes[]; + publicTitle?: string; + publicDescription?: string; + type?: Template['type']; + }; + meta?: Partial>; }; export const ZCreateTemplateResponseSchema = TemplateSchema; @@ -18,12 +34,14 @@ export const ZCreateTemplateResponseSchema = TemplateSchema; export type TCreateTemplateResponse = z.infer; export const createTemplate = async ({ - title, userId, teamId, templateDocumentDataId, - folderId, + data, + meta = {}, }: CreateTemplateOptions) => { + const { title, folderId } = data; + const team = await prisma.team.findFirst({ where: buildTeamWhereQuery({ teamId, userId }), }); @@ -52,20 +70,42 @@ 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, + teamId, userId, templateDocumentDataId, - teamId, - folderId: folderId, + folderId, + externalId: data.externalId, + visibility: data.visibility ?? settings.documentVisibility, + authOptions: createDocumentAuthOptions({ + globalAccessAuth: data.globalAccessAuth || [], + globalActionAuth: data.globalActionAuth || [], + }), + publicTitle: data.publicTitle, + publicDescription: data.publicDescription, + type: data.type, templateMeta: { - create: { - language: settings.documentLanguage, - typedSignatureEnabled: settings.typedSignatureEnabled, - uploadSignatureEnabled: settings.uploadSignatureEnabled, - drawSignatureEnabled: settings.drawSignatureEnabled, - }, + 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 a5ea8fa2d..1aa740eb2 100644 --- a/packages/lib/server-only/template/update-template.ts +++ b/packages/lib/server-only/template/update-template.ts @@ -43,6 +43,7 @@ export const updateTemplate = async ({ templateMeta: true, team: { select: { + organisationId: true, organisation: { select: { organisationClaim: true, @@ -99,6 +100,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/translations/en/web.po b/packages/lib/translations/en/web.po index a1994ef0e..e3bdb0536 100644 --- a/packages/lib/translations/en/web.po +++ b/packages/lib/translations/en/web.po @@ -116,6 +116,7 @@ msgstr "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients} #. placeholder {0}: route.label #. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType]) #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx #: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx msgid "{0}" msgstr "{0}" @@ -152,7 +153,7 @@ msgstr "{0} of {1} row(s) selected." #. placeholder {0}: user.name || user.email #. placeholder {1}: document.team.name #. placeholder {2}: document.title -#: packages/lib/server-only/document/resend-document.tsx +#: packages/lib/server-only/document/resend-document.ts msgid "{0} on behalf of \"{1}\" has invited you to {recipientActionVerb} the document \"{2}\"." msgstr "{0} on behalf of \"{1}\" has invited you to {recipientActionVerb} the document \"{2}\"." @@ -512,6 +513,10 @@ msgstr "3 months" msgid "401 Unauthorized" msgstr "401 Unauthorized" +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +msgid "404 Email domain not found" +msgstr "404 Email domain not found" + #: apps/remix/app/components/general/generic-error-layout.tsx msgid "404 not found" msgstr "404 not found" @@ -538,6 +543,10 @@ msgstr "404 Team not found" msgid "404 User not found" msgstr "404 User not found" +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx +msgid "404 Webhook not found" +msgstr "404 Webhook not found" + #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx msgid "5 documents a month" msgstr "5 documents a month" @@ -743,6 +752,8 @@ msgstr "Acknowledgment" msgid "Action" msgstr "Action" +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/components/tables/team-members-table.tsx #: apps/remix/app/components/tables/team-members-table.tsx @@ -753,6 +764,7 @@ msgstr "Action" #: apps/remix/app/components/tables/organisation-member-invites-table.tsx #: apps/remix/app/components/tables/organisation-member-invites-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx +#: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/inbox-table.tsx #: apps/remix/app/components/tables/documents-table.tsx #: apps/remix/app/components/tables/admin-organisations-table.tsx @@ -763,6 +775,7 @@ msgstr "Actions" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.members.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx +#: apps/remix/app/components/tables/organisation-email-domains-table.tsx msgid "Active" msgstr "Active" @@ -779,6 +792,10 @@ msgstr "Active Subscriptions" msgid "Add" msgstr "Add" +#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx +msgid "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." +msgstr "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." + #: packages/ui/primitives/document-dropzone.tsx msgid "Add a document" msgstr "Add a document" @@ -823,10 +840,22 @@ msgstr "Add another option" msgid "Add another value" msgstr "Add another value" +#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx +msgid "Add Custom Email Domain" +msgstr "Add Custom Email Domain" + #: apps/remix/app/components/dialogs/team-email-add-dialog.tsx msgid "Add email" msgstr "Add email" +#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx +msgid "Add Email" +msgstr "Add Email" + +#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx +msgid "Add Email Domain" +msgstr "Add Email Domain" + #: apps/remix/app/components/general/template/template-edit-form.tsx #: apps/remix/app/components/general/document/document-edit-form.tsx msgid "Add Fields" @@ -866,6 +895,10 @@ msgstr "Add myself" msgid "Add Myself" msgstr "Add Myself" +#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx +msgid "Add Organisation Email" +msgstr "Add Organisation Email" + #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "Add passkey" @@ -912,6 +945,10 @@ msgstr "Add the people who will sign the document." msgid "Add the recipients to create the document with" msgstr "Add the recipients to create the document with" +#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx +msgid "Add these DNS records to verify your domain ownership" +msgstr "Add these DNS records to verify your domain ownership" + #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Additional brand information to display at the bottom of emails" msgstr "Additional brand information to display at the bottom of emails" @@ -934,6 +971,10 @@ msgstr "Admin panel" msgid "Admin Panel" msgstr "Admin Panel" +#: apps/remix/app/components/dialogs/folder-update-dialog.tsx +msgid "Admins only" +msgstr "Admins only" + #: packages/ui/primitives/template-flow/add-template-settings.tsx #: packages/ui/primitives/document-flow/add-settings.tsx msgid "Advanced Options" @@ -978,6 +1019,10 @@ msgstr "All documents have been processed. Any new documents that are sent or re msgid "All documents related to the electronic signing process will be provided to you electronically through our platform or via email. It is your responsibility to ensure that your email address is current and that you can receive and open our emails." msgstr "All documents related to the electronic signing process will be provided to you electronically through our platform or via email. It is your responsibility to ensure that your email address is current and that you can receive and open our emails." +#: apps/remix/app/components/tables/organisation-email-domains-table.tsx +msgid "All email domains have been synced successfully" +msgstr "All email domains have been synced successfully" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.folders._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx msgid "All Folders" @@ -1059,6 +1104,10 @@ msgstr "An email account" msgid "An email containing an invitation will be sent to each member." msgstr "An email containing an invitation will be sent to each member." +#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx +msgid "An email with this address already exists." +msgstr "An email with this address already exists." + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -1068,6 +1117,7 @@ msgstr "An email containing an invitation will be sent to each member." #: apps/remix/app/components/forms/reset-password.tsx #: apps/remix/app/components/forms/password.tsx #: apps/remix/app/components/forms/avatar-image.tsx +#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx msgid "An error occurred" msgstr "An error occurred" @@ -1196,6 +1246,7 @@ msgstr "An error occurred while signing as assistant." msgid "An error occurred while signing the document." msgstr "An error occurred while signing the document." +#: apps/remix/app/components/general/billing-plans.tsx #: apps/remix/app/components/general/billing-plans.tsx msgid "An error occurred while trying to create a checkout session." msgstr "An error occurred while trying to create a checkout session." @@ -1254,6 +1305,10 @@ msgstr "An unexpected error occurred." #: apps/remix/app/components/dialogs/organisation-leave-dialog.tsx #: apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-update-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx @@ -1287,10 +1342,9 @@ msgid "Any Status" msgstr "Any Status" #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx #: apps/remix/app/components/general/settings-nav-mobile.tsx #: apps/remix/app/components/general/settings-nav-desktop.tsx -#: apps/remix/app/components/general/teams/team-settings-nav-mobile.tsx -#: apps/remix/app/components/general/teams/team-settings-nav-desktop.tsx msgid "API Tokens" msgstr "API Tokens" @@ -1373,6 +1427,8 @@ msgstr "Are you sure you wish to delete this team?" #: apps/remix/app/components/dialogs/organisation-member-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-leave-dialog.tsx #: apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx #: apps/remix/app/components/dialogs/document-delete-dialog.tsx msgid "Are you sure?" msgstr "Are you sure?" @@ -1512,18 +1568,25 @@ msgstr "Brand Details" msgid "Brand Website" msgstr "Brand Website" +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx +#: apps/remix/app/components/general/settings-nav-desktop.tsx +msgid "Branding" +msgstr "Branding" + #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Branding Logo" msgstr "Branding Logo" -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx -#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.preferences.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +#: apps/remix/app/components/general/settings-nav-mobile.tsx msgid "Branding Preferences" msgstr "Branding Preferences" -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx -#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.preferences.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Branding preferences updated" msgstr "Branding preferences updated" @@ -1613,6 +1676,7 @@ msgstr "Can prepare" #: apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx #: apps/remix/app/components/forms/2fa/view-recovery-codes-dialog.tsx #: apps/remix/app/components/forms/2fa/enable-authenticator-app-dialog.tsx +#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx #: apps/remix/app/components/dialogs/webhook-delete-dialog.tsx #: apps/remix/app/components/dialogs/webhook-create-dialog.tsx #: apps/remix/app/components/dialogs/token-delete-dialog.tsx @@ -1644,9 +1708,15 @@ msgstr "Can prepare" #: apps/remix/app/components/dialogs/organisation-leave-dialog.tsx #: apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-update-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx +#: apps/remix/app/components/dialogs/folder-update-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-create-dialog.tsx @@ -1712,6 +1782,7 @@ msgstr "Checkbox values" #: apps/remix/app/components/general/billing-plans.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx msgid "Checkout" msgstr "Checkout" @@ -1799,6 +1870,7 @@ msgstr "Click to insert field" #: apps/remix/app/components/dialogs/team-group-delete-dialog.tsx #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx msgid "Close" msgstr "Close" @@ -1938,6 +2010,7 @@ msgid "Confirm" msgstr "Confirm" #: apps/remix/app/components/dialogs/team-delete-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx msgid "Confirm by typing <0>{deleteMessage}" msgstr "Confirm by typing <0>{deleteMessage}" @@ -2016,6 +2089,10 @@ msgstr "Continue by viewing the document." msgid "Continue to login" msgstr "Continue to login" +#: apps/remix/app/components/forms/email-preferences-form.tsx +msgid "Controls the default email settings when new documents or templates are created" +msgstr "Controls the default email settings when new documents or templates are created" + #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Controls the default language of an uploaded document. This will be used as the language in email communications with the recipients." msgstr "Controls the default language of an uploaded document. This will be used as the language in email communications with the recipients." @@ -2053,6 +2130,9 @@ msgstr "Copied" #: apps/remix/app/components/general/document/document-page-view-recipients.tsx #: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx #: packages/ui/components/document/document-share-button.tsx msgid "Copied to clipboard" @@ -2100,6 +2180,11 @@ msgstr "Create a <0>free account to access your signed documents at any time msgid "Create a new account" msgstr "Create a new account" +#. placeholder {0}: emailDomain.domain +#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx +msgid "Create a new email address for your organisation using the domain <0>{0}." +msgstr "Create a new email address for your organisation using the domain <0>{0}." + #: apps/remix/app/components/general/billing-plans.tsx msgid "Create a new organisation with {planName} plan. Keep your current organisation on it's current plan" msgstr "Create a new organisation with {planName} plan. Keep your current organisation on it's current plan" @@ -2157,6 +2242,10 @@ msgstr "Create Direct Signing Link" msgid "Create document from template" msgstr "Create document from template" +#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx +msgid "Create Email" +msgstr "Create Email" + #: apps/remix/app/components/general/folder/folder-card.tsx msgid "Create folder" msgstr "Create folder" @@ -2320,10 +2409,14 @@ msgstr "Current recipients:" msgid "Currently all organisation members can access this team" msgstr "Currently all organisation members can access this team" -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx msgid "Currently branding can only be configured for Teams and above plans." msgstr "Currently branding can only be configured for Teams and above plans." +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +msgid "Currently email domains can only be configured for Platform and above plans." +msgstr "Currently email domains can only be configured for Platform and above plans." + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups._index.tsx msgid "Custom Organisation Groups" msgstr "Custom Organisation Groups" @@ -2359,6 +2452,10 @@ msgstr "Date Format" msgid "Decline" msgstr "Decline" +#: apps/remix/app/components/forms/document-preferences-form.tsx +msgid "Default Date Format" +msgstr "Default Date Format" + #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Document Language" msgstr "Default Document Language" @@ -2367,10 +2464,22 @@ msgstr "Default Document Language" msgid "Default Document Visibility" msgstr "Default Document Visibility" +#: apps/remix/app/components/forms/email-preferences-form.tsx +msgid "Default Email" +msgstr "Default Email" + +#: apps/remix/app/components/forms/email-preferences-form.tsx +msgid "Default Email Settings" +msgstr "Default Email Settings" + #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" msgstr "Default Signature Settings" +#: apps/remix/app/components/forms/document-preferences-form.tsx +msgid "Default Time Zone" +msgstr "Default Time Zone" + #: apps/remix/app/components/dialogs/document-delete-dialog.tsx msgid "delete" msgstr "delete" @@ -2384,6 +2493,7 @@ msgstr "delete" #: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx +#: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/general/folder/folder-card.tsx @@ -2396,6 +2506,8 @@ msgstr "delete" #: apps/remix/app/components/dialogs/team-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx @@ -2414,6 +2526,10 @@ msgstr "Delete" msgid "delete {0}" msgstr "delete {0}" +#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx +msgid "delete {emailDomain}" +msgstr "delete {emailDomain}" + #: apps/remix/app/components/dialogs/team-delete-dialog.tsx msgid "delete {teamName}" msgstr "delete {teamName}" @@ -2440,6 +2556,19 @@ msgstr "Delete document" msgid "Delete Document" msgstr "Delete Document" +#: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx +msgid "Delete email" +msgstr "Delete email" + +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx +msgid "Delete email domain" +msgstr "Delete email domain" + +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +msgid "Delete Email Domain" +msgstr "Delete Email Domain" + #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx msgid "Delete Folder" msgstr "Delete Folder" @@ -2560,6 +2689,10 @@ msgstr "Direct template link deleted" msgid "Direct template link usage exceeded ({0}/{1})" msgstr "Direct template link usage exceeded ({0}/{1})" +#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx +msgid "Direction" +msgstr "Direction" + #: apps/remix/app/components/dialogs/team-inherit-member-disable-dialog.tsx #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Disable" @@ -2600,6 +2733,11 @@ msgstr "Disabling direct link signing will prevent anyone from accessing the lin msgid "Disabling the user results in the user not being able to use the account. It also disables all the related contents such as subscription, webhooks, teams, and API keys." msgstr "Disabling the user results in the user not being able to use the account. It also disables all the related contents such as subscription, webhooks, teams, and API keys." +#: apps/remix/app/components/dialogs/organisation-email-update-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx +msgid "Display Name" +msgstr "Display Name" + #: apps/remix/app/components/general/teams/team-email-usage.tsx msgid "Display your name and email in documents" msgstr "Display your name and email in documents" @@ -2612,6 +2750,14 @@ msgstr "Distribute Document" msgid "Distribution Method" msgstr "Distribution Method" +#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx +msgid "DKIM records generated. Please add the DNS records to verify your domain." +msgstr "DKIM records generated. Please add the DNS records to verify your domain." + +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +msgid "DNS Records" +msgstr "DNS Records" + #: apps/remix/app/components/dialogs/template-delete-dialog.tsx msgid "Do you want to delete this template?" msgstr "Do you want to delete this template?" @@ -2624,7 +2770,10 @@ msgstr "Do you want to duplicate this template?" msgid "Documenso will delete <0>all of your documents, along with all of your completed documents, signatures, and all other resources belonging to your Account." msgstr "Documenso will delete <0>all of your documents, along with all of your completed documents, signatures, and all other resources belonging to your Account." +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx +#: apps/remix/app/components/general/settings-nav-desktop.tsx #: apps/remix/app/components/general/template/template-page-view-documents-table.tsx msgid "Document" msgstr "Document" @@ -2796,8 +2945,14 @@ msgstr "Document pending" msgid "Document pending email" msgstr "Document pending email" -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx -#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.preferences.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx +#: apps/remix/app/components/general/settings-nav-mobile.tsx +msgid "Document Preferences" +msgstr "Document Preferences" + +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx msgid "Document preferences updated" msgstr "Document preferences updated" @@ -2911,6 +3066,22 @@ msgstr "Documents that require your attention will appear here" msgid "Documents Viewed" msgstr "Documents Viewed" +#: apps/remix/app/components/tables/organisation-email-domains-table.tsx +msgid "Domain" +msgstr "Domain" + +#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx +msgid "Domain Added" +msgstr "Domain Added" + +#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx +msgid "Domain already in use" +msgstr "Domain already in use" + +#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx +msgid "Domain Name" +msgstr "Domain Name" + #: apps/remix/app/routes/_unauthenticated+/signin.tsx #: apps/remix/app/routes/_unauthenticated+/reset-password.$token.tsx msgid "Don't have an account? <0>Sign up" @@ -2990,10 +3161,6 @@ msgstr "Dropdown" msgid "Dropdown options" msgstr "Dropdown options" -#: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx -msgid "Due to an unpaid invoice, your team has been restricted. Please settle the payment to restore full access to your team." -msgstr "Due to an unpaid invoice, your team has been restricted. Please settle the payment to restore full access to your team." - #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx @@ -3037,10 +3204,14 @@ msgstr "Electronic Signature Disclosure" #: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx #: apps/remix/app/components/tables/admin-dashboard-users-table.tsx +#: apps/remix/app/components/general/settings-nav-desktop.tsx #: apps/remix/app/components/general/document-signing/document-signing-email-field.tsx #: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx #: apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx @@ -3076,6 +3247,7 @@ msgid "Email address" msgstr "Email address" #: apps/remix/app/components/forms/signup.tsx +#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx msgid "Email Address" msgstr "Email Address" @@ -3083,6 +3255,10 @@ msgstr "Email Address" msgid "Email already confirmed" msgstr "Email already confirmed" +#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx +msgid "Email already exists" +msgstr "Email already exists" + #: apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx msgid "Email cannot already exist in the template" msgstr "Email cannot already exist in the template" @@ -3091,14 +3267,52 @@ msgstr "Email cannot already exist in the template" msgid "Email Confirmed!" msgstr "Email Confirmed!" +#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx +msgid "Email Created" +msgstr "Email Created" + +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +msgid "Email domain not found" +msgstr "Email domain not found" + +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +msgid "Email Domain Settings" +msgstr "Email Domain Settings" + +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx +msgid "Email Domains" +msgstr "Email Domains" + +#: apps/remix/app/components/tables/organisation-email-domains-table.tsx +msgid "Email domains synced" +msgstr "Email domains synced" + #: packages/ui/primitives/template-flow/add-template-settings.tsx msgid "Email Options" msgstr "Email Options" +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.email.tsx +#: apps/remix/app/components/general/settings-nav-mobile.tsx +msgid "Email Preferences" +msgstr "Email Preferences" + +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.email.tsx +msgid "Email preferences updated" +msgstr "Email preferences updated" + #: packages/lib/utils/document-audit-logs.ts msgid "Email resent" msgstr "Email resent" +#: packages/ui/primitives/template-flow/add-template-settings.tsx +#: packages/ui/primitives/document-flow/add-subject.tsx +msgid "Email Sender" +msgstr "Email Sender" + #: packages/lib/utils/document-audit-logs.ts msgid "Email sent" msgstr "Email sent" @@ -3115,6 +3329,11 @@ msgstr "Email verification has been removed" msgid "Email verification has been resent" msgstr "Email verification has been resent" +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +#: apps/remix/app/components/tables/organisation-email-domains-table.tsx +msgid "Emails" +msgstr "Emails" + #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx msgid "Embedding, 5 members included and more" msgstr "Embedding, 5 members included and more" @@ -3195,6 +3414,10 @@ msgstr "Enter a name for your new folder. Folders help you organise your items." msgid "Enter claim name" msgstr "Enter claim name" +#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx +msgid "Enter the domain you want to use for sending emails (without http:// or www)" +msgstr "Enter the domain you want to use for sending emails (without http:// or www)" + #: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx msgid "Enter your 2FA code" msgstr "Enter your 2FA code" @@ -3277,6 +3500,7 @@ msgstr "Enterprise" #: apps/remix/app/components/dialogs/session-logout-all-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx @@ -3289,6 +3513,14 @@ msgstr "Error" msgid "Error uploading file" msgstr "Error uploading file" +#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx +msgid "Event Type" +msgstr "Event Type" + +#: apps/remix/app/components/dialogs/folder-update-dialog.tsx +msgid "Everyone" +msgstr "Everyone" + #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Everyone can access and view the document" msgstr "Everyone can access and view the document" @@ -3471,13 +3703,17 @@ msgstr "Folder moved successfully" msgid "Folder Name" msgstr "Folder Name" -#: apps/remix/app/components/dialogs/folder-settings-dialog.tsx +#: apps/remix/app/components/dialogs/folder-update-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx msgid "Folder not found" msgstr "Folder not found" -#: apps/remix/app/components/dialogs/folder-settings-dialog.tsx +#: apps/remix/app/components/dialogs/folder-update-dialog.tsx +msgid "Folder Settings" +msgstr "Folder Settings" + +#: apps/remix/app/components/dialogs/folder-update-dialog.tsx msgid "Folder updated successfully" msgstr "Folder updated successfully" @@ -3533,15 +3769,18 @@ msgstr "Full Name" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx #: apps/remix/app/components/general/template/template-edit-form.tsx -#: apps/remix/app/components/general/teams/team-settings-nav-mobile.tsx -#: apps/remix/app/components/general/teams/team-settings-nav-desktop.tsx #: apps/remix/app/components/general/document/document-edit-form.tsx #: apps/remix/app/components/general/direct-template/direct-template-page.tsx #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx msgid "General" msgstr "General" +#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx +msgid "Generate DKIM Records" +msgstr "Generate DKIM Records" + #: packages/ui/primitives/document-flow/add-subject.tsx msgid "Generate Links" msgstr "Generate Links" @@ -3551,6 +3790,8 @@ msgid "Global recipient action authentication" msgstr "Global recipient action authentication" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Go back" @@ -3622,9 +3863,8 @@ msgid "Group Name" msgstr "Group Name" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx #: apps/remix/app/components/tables/organisation-members-table.tsx -#: apps/remix/app/components/general/teams/team-settings-nav-mobile.tsx -#: apps/remix/app/components/general/teams/team-settings-nav-desktop.tsx #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Groups" msgstr "Groups" @@ -3658,6 +3898,10 @@ msgstr "Having an assistant as the last signer means they will be unable to take msgid "Help complete the document for other signers." msgstr "Help complete the document for other signers." +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +msgid "Here you can add email domains to your organisation." +msgstr "Here you can add email domains to your organisation." + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx msgid "Here you can edit your organisation details." msgstr "Here you can edit your organisation details." @@ -3670,22 +3914,33 @@ msgstr "Here you can edit your personal details." msgid "Here you can manage your password and security settings." msgstr "Here you can manage your password and security settings." -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx -#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.preferences.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +msgid "Here you can set branding preferences for your organisation. Teams will inherit these settings by default." +msgstr "Here you can set branding preferences for your organisation. Teams will inherit these settings by default." + +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +msgid "Here you can set branding preferences for your team" +msgstr "Here you can set branding preferences for your team" + +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx +msgid "Here you can set document preferences for your organisation. Teams will inherit these settings by default." +msgstr "Here you can set document preferences for your organisation. Teams will inherit these settings by default." + +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Here you can set preferences and defaults for branding." msgstr "Here you can set preferences and defaults for branding." -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx -msgid "Here you can set preferences and defaults for your organisation. Teams will inherit these settings by default." -msgstr "Here you can set preferences and defaults for your organisation. Teams will inherit these settings by default." - -#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.preferences.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx msgid "Here you can set preferences and defaults for your team." msgstr "Here you can set preferences and defaults for your team." -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx -msgid "Here you can set your general preferences" -msgstr "Here you can set your general preferences" +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +msgid "Here you can set your general branding preferences" +msgstr "Here you can set your general branding preferences" + +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx +msgid "Here you can set your general document preferences" +msgstr "Here you can set your general document preferences" #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Here's how it works:" @@ -3720,6 +3975,10 @@ msgstr "Home" msgid "Home (No Folder)" msgstr "Home (No Folder)" +#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx +msgid "Horizontal" +msgstr "Horizontal" + #: packages/lib/constants/recipient-roles.ts msgid "I am a signer of this document" msgstr "I am a signer of this document" @@ -3757,6 +4016,10 @@ msgstr "ID" msgid "ID copied to clipboard" msgstr "ID copied to clipboard" +#: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx +msgid "If there is any issue with your subscription, please contact us at <0>{SUPPORT_EMAIL}." +msgstr "If there is any issue with your subscription, please contact us at <0>{SUPPORT_EMAIL}." + #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "If you do not want to use the authenticator prompted, you can close it, which will then display the next available authenticator." msgstr "If you do not want to use the authenticator prompted, you can close it, which will then display the next available authenticator." @@ -3792,6 +4055,10 @@ msgstr "Information" msgid "Inherit authentication method" msgstr "Inherit authentication method" +#: apps/remix/app/components/forms/email-preferences-form.tsx +#: apps/remix/app/components/forms/email-preferences-form.tsx +#: apps/remix/app/components/forms/document-preferences-form.tsx +#: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -3999,6 +4266,7 @@ msgstr "Leaderboard" msgid "Leave" msgstr "Leave" +#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/branding-preferences-form.tsx #: apps/remix/app/components/forms/branding-preferences-form.tsx #: apps/remix/app/components/forms/branding-preferences-form.tsx @@ -4057,6 +4325,10 @@ msgstr "Loading Document..." msgid "Loading..." msgstr "Loading..." +#: apps/remix/app/components/forms/document-preferences-form.tsx +msgid "Local timezone" +msgstr "Local timezone" + #: apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx #: apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx #: apps/remix/app/components/general/direct-template/direct-template-signing-auth-page.tsx @@ -4090,6 +4362,10 @@ msgstr "Manage and view template" msgid "Manage billing" msgstr "Manage billing" +#: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx +msgid "Manage Billing" +msgstr "Manage Billing" + #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Manage details for this public template" msgstr "Manage details for this public template" @@ -4132,10 +4408,10 @@ msgstr "Manage subscription" msgid "Manage the {0} organisation" msgstr "Manage the {0} organisation" -#. placeholder {1}: organisation.name +#. placeholder {0}: organisation.name #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx -msgid "Manage the {1} organisation subscription" -msgstr "Manage the {1} organisation subscription" +msgid "Manage the {0} organisation subscription" +msgstr "Manage the {0} organisation subscription" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups._index.tsx msgid "Manage the custom groups of members for your organisation." @@ -4157,6 +4433,10 @@ msgstr "Manage the members of your team." msgid "Manage the members or invite new members." msgstr "Manage the members or invite new members." +#: apps/remix/app/components/dialogs/folder-update-dialog.tsx +msgid "Manage the settings for this folder." +msgstr "Manage the settings for this folder." + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx msgid "Manage the teams in this organisation." msgstr "Manage the teams in this organisation." @@ -4165,6 +4445,10 @@ msgstr "Manage the teams in this organisation." msgid "Manage users" msgstr "Manage users" +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +msgid "Manage your email domain settings." +msgstr "Manage your email domain settings." + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Manage your organisation group settings." msgstr "Manage your organisation group settings." @@ -4182,6 +4466,10 @@ msgstr "Manage your site settings here" msgid "Manager" msgstr "Manager" +#: apps/remix/app/components/dialogs/folder-update-dialog.tsx +msgid "Managers and above" +msgstr "Managers and above" + #: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx msgid "Mark as viewed" msgstr "Mark as viewed" @@ -4232,18 +4520,20 @@ msgstr "Member Since" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx #: apps/remix/app/components/tables/team-groups-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx -#: apps/remix/app/components/general/teams/team-settings-nav-mobile.tsx -#: apps/remix/app/components/general/teams/team-settings-nav-desktop.tsx #: apps/remix/app/components/dialogs/team-member-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx msgid "Members" msgstr "Members" -#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx #: packages/ui/primitives/template-flow/add-template-settings.tsx #: packages/ui/primitives/document-flow/add-subject.tsx +msgid "Message" +msgstr "Message" + +#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx msgid "Message <0>(Optional)" msgstr "Message <0>(Optional)" @@ -4309,6 +4599,7 @@ msgstr "Multiple access methods can be selected." msgid "My Folder" msgstr "My Folder" +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx #: apps/remix/app/components/tables/settings-security-passkey-table.tsx #: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx @@ -4326,6 +4617,7 @@ msgstr "My Folder" #: apps/remix/app/components/dialogs/template-use-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-add-dialog.tsx +#: apps/remix/app/components/dialogs/folder-update-dialog.tsx #: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx #: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx #: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -4576,6 +4868,10 @@ msgstr "Once enabled, you can select any active recipient to be a direct link si msgid "Once you have scanned the QR code or entered the code manually, enter the code provided by your authenticator app below." msgstr "Once you have scanned the QR code or entered the code manually, enter the code provided by your authenticator app below." +#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx +msgid "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" +msgstr "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" + #: packages/lib/constants/template.ts msgid "Once your template is set up, share the link anywhere you want. The person who opens the link will be able to enter their information in the direct link recipient field and complete any other fields assigned to them." msgstr "Once your template is set up, share the link anywhere you want. The person who opens the link will be able to enter their information in the direct link recipient field and complete any other fields assigned to them." @@ -4673,10 +4969,6 @@ msgstr "Organisation Name" msgid "Organisation not found" msgstr "Organisation not found" -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx -msgid "Organisation Preferences" -msgstr "Organisation Preferences" - #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx msgid "Organisation role" msgstr "Organisation role" @@ -4736,6 +5028,10 @@ msgstr "Organize your documents and templates" msgid "Otherwise, the document will be created as a draft." msgstr "Otherwise, the document will be created as a draft." +#: apps/remix/app/components/forms/email-preferences-form.tsx +msgid "Override organisation settings" +msgstr "Override organisation settings" + #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx @@ -4841,6 +5137,7 @@ msgid "Payment overdue" msgstr "Payment overdue" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.members.tsx +#: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/general/template/template-page-view-documents-table.tsx #: apps/remix/app/components/general/document/document-status.tsx #: apps/remix/app/components/general/document/document-page-view-recipients.tsx @@ -5033,6 +5330,10 @@ msgstr "Please review the document before signing." msgid "Please select a PDF file" msgstr "Please select a PDF file" +#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx +msgid "Please try a different domain." +msgstr "Please try a different domain." + #: apps/remix/app/components/forms/send-confirmation-email.tsx msgid "Please try again and make sure you enter the correct email address." msgstr "Please try again and make sure you enter the correct email address." @@ -5064,13 +5365,10 @@ msgstr "Please upload a logo" msgid "Pre-formatted CSV template with example data." msgstr "Pre-formatted CSV template with example data." -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx -#: apps/remix/app/components/general/settings-nav-mobile.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx #: apps/remix/app/components/general/settings-nav-desktop.tsx #: apps/remix/app/components/general/app-command-menu.tsx -#: apps/remix/app/components/general/teams/team-settings-nav-mobile.tsx -#: apps/remix/app/components/general/teams/team-settings-nav-desktop.tsx msgid "Preferences" msgstr "Preferences" @@ -5125,10 +5423,9 @@ msgid "Public" msgstr "Public" #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx #: apps/remix/app/components/general/settings-nav-mobile.tsx #: apps/remix/app/components/general/settings-nav-desktop.tsx -#: apps/remix/app/components/general/teams/team-settings-nav-mobile.tsx -#: apps/remix/app/components/general/teams/team-settings-nav-desktop.tsx msgid "Public Profile" msgstr "Public Profile" @@ -5156,10 +5453,6 @@ msgstr "Radio values" msgid "Read only" msgstr "Read only" -#: apps/remix/app/components/general/document-signing/document-signing-field-container.tsx -msgid "Read only field" -msgstr "Read only field" - #: apps/remix/app/components/general/document-signing/document-signing-disclosure.tsx msgid "Read the full <0>signature disclosure." msgstr "Read the full <0>signature disclosure." @@ -5252,6 +5545,18 @@ msgstr "Recipients metrics" msgid "Recipients will still retain their copy of the document" msgstr "Recipients will still retain their copy of the document" +#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx +msgid "Record Name" +msgstr "Record Name" + +#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx +msgid "Record Type" +msgstr "Record Type" + +#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx +msgid "Record Value" +msgstr "Record Value" + #: apps/remix/app/components/forms/2fa/recovery-code-list.tsx msgid "Recovery code copied" msgstr "Recovery code copied" @@ -5304,23 +5609,24 @@ msgid "Remembered your password? <0>Sign In" msgstr "Remembered your password? <0>Sign In" #. placeholder {0}: customEmail.subject -#: packages/lib/server-only/document/resend-document.tsx +#: packages/lib/server-only/document/resend-document.ts msgid "Reminder: {0}" msgstr "Reminder: {0}" #. placeholder {0}: document.team.name -#: packages/lib/server-only/document/resend-document.tsx +#: packages/lib/server-only/document/resend-document.ts msgid "Reminder: {0} invited you to {recipientActionVerb} a document" msgstr "Reminder: {0} invited you to {recipientActionVerb} a document" -#: packages/lib/server-only/document/resend-document.tsx +#: packages/lib/server-only/document/resend-document.ts msgid "Reminder: Please {recipientActionVerb} this document" msgstr "Reminder: Please {recipientActionVerb} this document" -#: packages/lib/server-only/document/resend-document.tsx +#: packages/lib/server-only/document/resend-document.ts msgid "Reminder: Please {recipientActionVerb} your document" msgstr "Reminder: Please {recipientActionVerb} your document" +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/components/tables/team-members-table.tsx #: apps/remix/app/components/tables/team-groups-table.tsx #: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx @@ -5339,6 +5645,11 @@ msgstr "Reminder: Please {recipientActionVerb} your document" msgid "Remove" msgstr "Remove" +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +#: apps/remix/app/components/tables/organisation-email-domains-table.tsx +msgid "Remove email domain" +msgstr "Remove email domain" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx msgid "Remove organisation group" @@ -5362,6 +5673,15 @@ msgstr "Remove team member" msgid "Repeat Password" msgstr "Repeat Password" +#: apps/remix/app/components/forms/email-preferences-form.tsx +msgid "Reply to email" +msgstr "Reply to email" + +#: packages/ui/primitives/template-flow/add-template-settings.tsx +#: packages/ui/primitives/document-flow/add-subject.tsx +msgid "Reply To Email" +msgstr "Reply To Email" + #: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/radio-field.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx @@ -5593,6 +5913,10 @@ msgstr "Select a template you'd like to display on your public profile" msgid "Select a template you'd like to display on your team's public profile" msgstr "Select a template you'd like to display on your team's public profile" +#: apps/remix/app/components/forms/document-preferences-form.tsx +msgid "Select a time zone" +msgstr "Select a time zone" + #: packages/ui/components/document/document-global-auth-access-select.tsx msgid "Select access methods" msgstr "Select access methods" @@ -5618,6 +5942,10 @@ msgstr "Select authentication methods" msgid "Select default option" msgstr "Select default option" +#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx +msgid "Select direction" +msgstr "Select direction" + #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Select groups" msgstr "Select groups" @@ -5665,6 +5993,10 @@ msgstr "Select the members to include in this group" msgid "Select triggers" msgstr "Select triggers" +#: apps/remix/app/components/dialogs/folder-update-dialog.tsx +msgid "Select visibility" +msgstr "Select visibility" + #: packages/ui/primitives/document-flow/send-document-action-dialog.tsx #: packages/ui/primitives/document-flow/send-document-action-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx @@ -5672,6 +6004,11 @@ msgstr "Select triggers" msgid "Send" msgstr "Send" +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx +#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx +msgid "Send a test webhook with sample data to verify your integration is working correctly." +msgstr "Send a test webhook with sample data to verify your integration is working correctly." + #: apps/remix/app/components/forms/send-confirmation-email.tsx msgid "Send confirmation email" msgstr "Send confirmation email" @@ -5728,6 +6065,10 @@ msgstr "Send recipient signing request email" msgid "Send reminder" msgstr "Send reminder" +#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx +msgid "Send Test Webhook" +msgstr "Send Test Webhook" + #: apps/remix/app/components/tables/inbox-table.tsx #: apps/remix/app/components/tables/documents-table.tsx msgid "Sender" @@ -6035,9 +6376,9 @@ msgstr "Some signers have not been assigned a signature field. Please assign at #: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx #: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx -#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.preferences.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx #: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx #: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx #: apps/remix/app/components/tables/organisation-member-invites-table.tsx @@ -6048,6 +6389,7 @@ msgstr "Some signers have not been assigned a signature field. Please assign at #: apps/remix/app/components/tables/documents-table-action-button.tsx #: apps/remix/app/components/general/share-document-download-button.tsx #: apps/remix/app/components/general/billing-plans.tsx +#: apps/remix/app/components/general/billing-plans.tsx #: apps/remix/app/components/general/teams/team-email-usage.tsx #: apps/remix/app/components/general/teams/team-email-dropdown.tsx #: apps/remix/app/components/general/organisations/organisation-invitations.tsx @@ -6104,8 +6446,10 @@ msgstr "Something went wrong while sending the confirmation email." msgid "Something went wrong while updating the team billing subscription, please contact support." msgstr "Something went wrong while updating the team billing subscription, please contact support." -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx -#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.preferences.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.email.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx msgid "Something went wrong!" msgstr "Something went wrong!" @@ -6141,6 +6485,7 @@ msgstr "Stats" #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx #: apps/remix/app/components/tables/inbox-table.tsx #: apps/remix/app/components/tables/documents-table.tsx @@ -6165,12 +6510,16 @@ msgstr "Stripe customer created successfully" msgid "Stripe Customer ID" msgstr "Stripe Customer ID" +#: packages/ui/primitives/document-flow/add-subject.tsx +msgid "Subject" +msgstr "Subject" + #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx #: packages/ui/primitives/template-flow/add-template-settings.tsx -#: packages/ui/primitives/document-flow/add-subject.tsx msgid "Subject <0>(Optional)" msgstr "Subject <0>(Optional)" +#: apps/remix/app/components/general/billing-plans.tsx #: apps/remix/app/components/general/billing-plans.tsx #: apps/remix/app/components/general/billing-plans.tsx msgid "Subscribe" @@ -6197,6 +6546,10 @@ msgstr "Subscription claim updated successfully." msgid "Subscription Claims" msgstr "Subscription Claims" +#: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx +msgid "Subscription invalid" +msgstr "Subscription invalid" + #: apps/remix/app/routes/embed+/v1+/authoring+/template.edit.$id.tsx #: apps/remix/app/routes/embed+/v1+/authoring+/document.edit.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx @@ -6239,6 +6592,9 @@ msgstr "Subscription Claims" #: apps/remix/app/components/dialogs/organisation-leave-dialog.tsx #: apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-update-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx @@ -6257,6 +6613,14 @@ msgstr "Successfully created: {successCount}" msgid "Summary:" msgstr "Summary:" +#: apps/remix/app/components/tables/organisation-email-domains-table.tsx +msgid "Sync" +msgstr "Sync" + +#: apps/remix/app/components/tables/organisation-email-domains-table.tsx +msgid "Sync Email Domains" +msgstr "Sync Email Domains" + #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "System Requirements" msgstr "System Requirements" @@ -6371,10 +6735,6 @@ msgstr "Team Only" msgid "Team only templates are not linked anywhere and are visible only to your team." msgstr "Team only templates are not linked anywhere and are visible only to your team." -#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.preferences.tsx -msgid "Team Preferences" -msgstr "Team Preferences" - #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx #: apps/remix/app/components/dialogs/team-member-create-dialog.tsx #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx @@ -6480,6 +6840,20 @@ msgstr "Templates" msgid "Templates allow you to quickly generate documents with pre-filled recipients and fields." msgstr "Templates allow you to quickly generate documents with pre-filled recipients and fields." +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx +#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx +msgid "Test Webhook" +msgstr "Test Webhook" + +#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx +msgid "Test webhook failed" +msgstr "Test webhook failed" + +#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx +msgid "Test webhook sent" +msgstr "Test webhook sent" + #: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx #: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx #: packages/ui/primitives/template-flow/add-template-fields.tsx @@ -6541,12 +6915,21 @@ msgstr "The authentication methods required for recipients to view the document. msgid "The content to show in the banner, HTML is allowed" msgstr "The content to show in the banner, HTML is allowed" +#: apps/remix/app/components/forms/email-preferences-form.tsx +msgid "The default email to use when sending emails to recipients" +msgstr "The default email to use when sending emails to recipients" + #: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx #: apps/remix/app/components/general/template/template-direct-link-badge.tsx #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "The direct link has been copied to your clipboard" msgstr "The direct link has been copied to your clipboard" +#: apps/remix/app/components/dialogs/organisation-email-update-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx +msgid "The display name for this email address" +msgstr "The display name for this email address" + #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx msgid "The document has been moved successfully." msgstr "The document has been moved successfully." @@ -6584,6 +6967,18 @@ msgstr "The document will be immediately sent to recipients if this is checked." msgid "The document's name" msgstr "The document's name" +#: apps/remix/app/components/forms/email-preferences-form.tsx +msgid "The email address which will show up in the \"Reply To\" field in emails" +msgstr "The email address which will show up in the \"Reply To\" field in emails" + +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +msgid "" +"The email domain you are looking for may have been removed, renamed or may have never\n" +" existed." +msgstr "" +"The email domain you are looking for may have been removed, renamed or may have never\n" +" existed." + #: apps/remix/app/components/forms/signin.tsx msgid "The email or password provided is incorrect" msgstr "The email or password provided is incorrect" @@ -6621,6 +7016,10 @@ msgstr "The following errors occurred:" msgid "The following team has been deleted. You will no longer be able to access this team and its documents" msgstr "The following team has been deleted. You will no longer be able to access this team and its documents" +#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx +msgid "The organisation email has been created successfully." +msgstr "The organisation email has been created successfully." + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "" "The organisation group you are looking for may have been removed, renamed or may have never\n" @@ -6653,6 +7052,11 @@ msgstr "" msgid "The page you are looking for was moved, removed, renamed or might never have existed." msgstr "The page you are looking for was moved, removed, renamed or might never have existed." +#. placeholder {0}: emailDomain.domain +#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx +msgid "The part before the @ symbol (e.g., \"support\" for support@{0})" +msgstr "The part before the @ symbol (e.g., \"support\" for support@{0})" + #: apps/remix/app/components/forms/public-profile-form.tsx msgid "The profile link has been copied to your clipboard" msgstr "The profile link has been copied to your clipboard" @@ -6752,6 +7156,10 @@ msgstr "The template has been moved successfully." msgid "The template will be removed from your profile" msgstr "The template will be removed from your profile" +#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx +msgid "The test webhook has been successfully sent to your endpoint." +msgstr "The test webhook has been successfully sent to your endpoint." + #: apps/remix/app/components/forms/token.tsx msgid "The token was copied to your clipboard." msgstr "The token was copied to your clipboard." @@ -6797,6 +7205,14 @@ msgstr "The webhook has been updated successfully." msgid "The webhook was successfully created." msgstr "The webhook was successfully created." +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx +msgid "" +"The webhook you are looking for may have been removed, renamed or may have never\n" +" existed." +msgstr "" +"The webhook you are looking for may have been removed, renamed or may have never\n" +" existed." + #: apps/remix/app/components/tables/documents-table-empty-state.tsx msgid "There are no active drafts at the current moment. You can upload a document to start drafting." msgstr "There are no active drafts at the current moment. You can upload a document to start drafting." @@ -6936,8 +7352,8 @@ msgid "This field cannot be modified or deleted. When you share this template's msgstr "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them." #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx -msgid "This folder contains multiple items. Deleting it will also delete all items in the folder, including nested folders and their contents." -msgstr "This folder contains multiple items. Deleting it will also delete all items in the folder, including nested folders and their contents." +msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder." +msgstr "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder." #: packages/ui/primitives/template-flow/add-template-settings.tsx msgid "This is how the document will reach the recipients once the document is ready for signing." @@ -7013,10 +7429,18 @@ msgstr "This will be sent to all recipients once the document has been fully com msgid "This will be sent to the document owner once the document has been fully completed." msgstr "This will be sent to the document owner once the document has been fully completed." +#: apps/remix/app/components/tables/organisation-email-domains-table.tsx +msgid "This will check and sync the status of all email domains for this organisation" +msgstr "This will check and sync the status of all email domains for this organisation" + #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" msgstr "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +msgid "This will remove all emails associated with this email domain" +msgstr "This will remove all emails associated with this email domain" + #: apps/remix/app/components/dialogs/session-logout-all-dialog.tsx msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account." msgstr "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account." @@ -7329,12 +7753,14 @@ msgid "Untitled Group" msgstr "Untitled Group" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx #: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/forms/public-profile-form.tsx +#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/branding-preferences-form.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx @@ -7342,6 +7768,8 @@ msgstr "Untitled Group" #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/organisation-email-update-dialog.tsx +#: apps/remix/app/components/dialogs/folder-update-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx #: packages/ui/primitives/document-flow/add-subject.tsx msgid "Update" @@ -7351,7 +7779,8 @@ msgstr "Update" msgid "Update Banner" msgstr "Update Banner" -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx msgid "Update Billing" msgstr "Update Billing" @@ -7363,6 +7792,10 @@ msgstr "Update Claim" msgid "Update current organisation" msgstr "Update current organisation" +#: apps/remix/app/components/dialogs/organisation-email-update-dialog.tsx +msgid "Update email" +msgstr "Update email" + #: apps/remix/app/components/general/legacy-field-warning-popover.tsx msgid "Update Fields" msgstr "Update Fields" @@ -7605,6 +8038,10 @@ msgstr "Verification Email Sent" msgid "Verification email sent successfully." msgstr "Verification email sent successfully." +#: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx +msgid "Verify Domain" +msgstr "Verify Domain" + #: apps/remix/app/components/general/verify-email-banner.tsx msgid "Verify Now" msgstr "Verify Now" @@ -7629,6 +8066,10 @@ msgstr "Verify your team email address" msgid "Version History" msgstr "Version History" +#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx +msgid "Vertical" +msgstr "Vertical" + #: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx #: apps/remix/app/components/tables/inbox-table.tsx #: apps/remix/app/components/tables/inbox-table.tsx @@ -7675,6 +8116,10 @@ msgstr "View and manage all active sessions for your account." msgid "View Codes" msgstr "View Codes" +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +msgid "View DNS Records" +msgstr "View DNS Records" + #: packages/email/templates/document-created-from-direct-template.tsx msgid "View document" msgstr "View document" @@ -7725,6 +8170,10 @@ msgstr "View Recovery Codes" msgid "View teams" msgstr "View teams" +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx +msgid "View the DNS records for this email domain" +msgstr "View the DNS records for this email domain" + #: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx #: apps/remix/app/components/general/document/document-page-view-recipients.tsx #: apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx @@ -7744,6 +8193,10 @@ msgstr "Viewers" msgid "Viewing" msgstr "Viewing" +#: apps/remix/app/components/dialogs/folder-update-dialog.tsx +msgid "Visibility" +msgstr "Visibility" + #: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx msgid "Waiting" msgstr "Waiting" @@ -7808,10 +8261,18 @@ msgstr "We couldn't update the group. Please try again." msgid "We couldn't update the organisation. Please try again." msgstr "We couldn't update the organisation. Please try again." +#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx +msgid "We encountered an error while creating the email. Please try again later." +msgstr "We encountered an error while creating the email. Please try again later." + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "We encountered an error while removing the direct template link. Please try again later." msgstr "We encountered an error while removing the direct template link. Please try again later." +#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx +msgid "We encountered an error while sending the test webhook. Please check your endpoint and try again." +msgstr "We encountered an error while sending the test webhook. Please check your endpoint and try again." + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx msgid "We encountered an error while updating the webhook. Please try again later." msgstr "We encountered an error while updating the webhook. Please try again later." @@ -7825,6 +8286,10 @@ msgstr "We encountered an unknown error while attempting to add team members. Pl msgid "We encountered an unknown error while attempting to add this email. Please try again later." msgstr "We encountered an unknown error while attempting to add this email. Please try again later." +#: apps/remix/app/components/dialogs/organisation-email-domain-create-dialog.tsx +msgid "We encountered an unknown error while attempting to add your domain. Please try again later." +msgstr "We encountered an unknown error while attempting to add your domain. Please try again later." + #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx msgid "We encountered an unknown error while attempting to create a group. Please try again later." msgstr "We encountered an unknown error while attempting to create a group. Please try again later." @@ -7874,6 +8339,14 @@ msgstr "We encountered an unknown error while attempting to invite organisation msgid "We encountered an unknown error while attempting to leave this organisation. Please try again later." msgstr "We encountered an unknown error while attempting to leave this organisation. Please try again later." +#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx +msgid "We encountered an unknown error while attempting to remove this email domain. Please try again later." +msgstr "We encountered an unknown error while attempting to remove this email domain. Please try again later." + +#: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx +msgid "We encountered an unknown error while attempting to remove this email. Please try again later." +msgstr "We encountered an unknown error while attempting to remove this email. Please try again later." + #: apps/remix/app/components/dialogs/team-group-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx msgid "We encountered an unknown error while attempting to remove this group. Please try again later." @@ -7993,16 +8466,21 @@ msgstr "We were unable to setup two-factor authentication for your account. Plea msgid "We were unable to submit this document at this time. Please try again later." msgstr "We were unable to submit this document at this time. Please try again later." -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx -#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.preferences.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "We were unable to update your branding preferences at this time, please try again later" msgstr "We were unable to update your branding preferences at this time, please try again later" -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx -#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.preferences.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx msgid "We were unable to update your document preferences at this time, please try again later" msgstr "We were unable to update your document preferences at this time, please try again later" +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.email.tsx +msgid "We were unable to update your email preferences at this time, please try again later" +msgstr "We were unable to update your email preferences at this time, please try again later" + #: apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx #: apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx msgid "We were unable to verify your details. Please try again or contact support" @@ -8051,19 +8529,23 @@ msgstr "Webhook created" msgid "Webhook deleted" msgstr "Webhook deleted" +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx +msgid "Webhook not found" +msgstr "Webhook not found" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id.tsx msgid "Webhook updated" msgstr "Webhook updated" +#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx #: apps/remix/app/components/dialogs/webhook-create-dialog.tsx msgid "Webhook URL" msgstr "Webhook URL" #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx #: apps/remix/app/components/general/settings-nav-mobile.tsx #: apps/remix/app/components/general/settings-nav-desktop.tsx -#: apps/remix/app/components/general/teams/team-settings-nav-mobile.tsx -#: apps/remix/app/components/general/teams/team-settings-nav-desktop.tsx msgid "Webhooks" msgstr "Webhooks" @@ -8175,6 +8657,16 @@ msgstr "You are about to leave the following organisation." msgid "You are about to remove default access to this team for all organisation members. Any members not explicitly added to this team will no longer have access." msgstr "You are about to remove default access to this team for all organisation members. Any members not explicitly added to this team will no longer have access." +#. placeholder {0}: organisation.name +#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx +msgid "You are about to remove the email domain <0>{emailDomain} from <1>{0}. All emails associated with this domain will be deleted." +msgstr "You are about to remove the email domain <0>{emailDomain} from <1>{0}. All emails associated with this domain will be deleted." + +#. placeholder {0}: organisation.name +#: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx +msgid "You are about to remove the following email from <0>{0}." +msgstr "You are about to remove the following email from <0>{0}." + #. placeholder {0}: team.name #. placeholder {0}: organisation.name #: apps/remix/app/components/dialogs/team-group-delete-dialog.tsx @@ -8209,6 +8701,11 @@ msgstr "You are about to subscribe to the {planName}" msgid "You are currently on the <0>Free Plan." msgstr "You are currently on the <0>Free Plan." +#. placeholder {0}: organisationEmail.email +#: apps/remix/app/components/dialogs/organisation-email-update-dialog.tsx +msgid "You are currently updating <0>{0}" +msgstr "You are currently updating <0>{0}" + #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx msgid "You are currently updating <0>{memberName}." msgstr "You are currently updating <0>{memberName}." @@ -8225,6 +8722,10 @@ msgstr "You are currently updating the <0>{passkeyName} passkey." msgid "You are currently updating the <0>{teamGroupName} team group." msgstr "You are currently updating the <0>{teamGroupName} team group." +#: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +msgid "You are not allowed to move this document." +msgstr "You are not allowed to move this document." + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._layout.tsx msgid "You are not authorized to access this page." @@ -8258,6 +8759,11 @@ msgstr "You can copy and share these links to recipients so they can action the msgid "You can enable access to allow all organisation members to access this team by default." msgstr "You can enable access to allow all organisation members to access this team by default." +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.email.tsx +msgid "You can manage your email preferences here" +msgstr "You can manage your email preferences here" + #: packages/email/templates/confirm-team-email.tsx msgid "You can revoke access at any time in your team settings on Documenso <0>here." msgstr "You can revoke access at any time in your team settings on Documenso <0>here." @@ -8376,7 +8882,7 @@ msgid "You have declined the invitation from <0>{0} to join their organisati msgstr "You have declined the invitation from <0>{0} to join their organisation." #. placeholder {0}: `"${document.title}"` -#: packages/lib/server-only/document/resend-document.tsx +#: packages/lib/server-only/document/resend-document.ts #: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts msgid "You have initiated the document {0} that requires you to {recipientActionVerb} it." msgstr "You have initiated the document {0} that requires you to {recipientActionVerb} it." @@ -8399,8 +8905,8 @@ msgid "You have reached the maximum limit of {0} direct templates. <0>Upgrade yo msgstr "You have reached the maximum limit of {0} direct templates. <0>Upgrade your account to continue!" #: apps/remix/app/components/dialogs/team-create-dialog.tsx -msgid "You have reached the maximum number of teams for your plan. Please contact sales at <0>support@documenso.com if you would like to adjust your plan." -msgstr "You have reached the maximum number of teams for your plan. Please contact sales at <0>support@documenso.com if you would like to adjust your plan." +msgid "You have reached the maximum number of teams for your plan. Please contact sales at <0>{SUPPORT_EMAIL} if you would like to adjust your plan." +msgstr "You have reached the maximum number of teams for your plan. Please contact sales at <0>{SUPPORT_EMAIL} if you would like to adjust your plan." #: apps/remix/app/components/general/document/document-upload.tsx #: apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx @@ -8438,6 +8944,14 @@ msgstr "You have successfully left this organisation." msgid "You have successfully registered. Please verify your account by clicking on the link you received in the email." msgstr "You have successfully registered. Please verify your account by clicking on the link you received in the email." +#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx +msgid "You have successfully removed this email domain from the organisation." +msgstr "You have successfully removed this email domain from the organisation." + +#: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx +msgid "You have successfully removed this email from the organisation." +msgstr "You have successfully removed this email from the organisation." + #: apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx msgid "You have successfully removed this group from the organisation." msgstr "You have successfully removed this group from the organisation." @@ -8486,6 +9000,7 @@ msgstr "You have verified your email address for <0>{0}." msgid "You must enter '{deleteMessage}' to proceed" msgstr "You must enter '{deleteMessage}' to proceed" +#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx msgid "You must type '{deleteMessage}' to confirm" msgstr "You must type '{deleteMessage}' to confirm" @@ -8534,8 +9049,8 @@ msgstr "Your banner has been updated successfully." msgid "Your brand website URL" msgstr "Your brand website URL" -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx -#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.preferences.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Your branding preferences have been updated" msgstr "Your branding preferences have been updated" @@ -8602,8 +9117,8 @@ msgstr "Your document has been uploaded successfully." msgid "Your document has been uploaded successfully. You will be redirected to the template page." msgstr "Your document has been uploaded successfully. You will be redirected to the template page." -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.preferences.tsx -#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.preferences.tsx +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx msgid "Your document preferences have been updated" msgstr "Your document preferences have been updated" @@ -8625,6 +9140,11 @@ msgstr "Your email has been successfully confirmed! You can now use all features msgid "Your email is currently being used by team <0>{0} ({1})." msgstr "Your email is currently being used by team <0>{0} ({1})." +#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.email.tsx +msgid "Your email preferences have been updated" +msgstr "Your email preferences have been updated" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx msgid "Your existing tokens" msgstr "Your existing tokens" @@ -8656,16 +9176,20 @@ msgid "Your password has been updated." msgstr "Your password has been updated." #: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx -msgid "Your payment for teams is overdue. Please settle the payment to avoid any service disruptions." -msgstr "Your payment for teams is overdue. Please settle the payment to avoid any service disruptions." +msgid "Your payment is overdue. Please settle the payment to avoid any service disruptions." +msgstr "Your payment is overdue. Please settle the payment to avoid any service disruptions." #: apps/remix/app/components/tables/user-organisations-table.tsx msgid "Your personal organisation" msgstr "Your personal organisation" #: apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx -msgid "Your plan does not support inviting members. Please upgrade or your plan or contact sales at <0>support@documenso.com if you would like to discuss your options." -msgstr "Your plan does not support inviting members. Please upgrade or your plan or contact sales at <0>support@documenso.com if you would like to discuss your options." +msgid "Your plan does not support inviting members. Please upgrade or your plan or contact sales at <0>{SUPPORT_EMAIL} if you would like to discuss your options." +msgstr "Your plan does not support inviting members. Please upgrade or your plan or contact sales at <0>{SUPPORT_EMAIL} if you would like to discuss your options." + +#: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx +msgid "Your plan is no longer valid. Please subscribe to a new plan to continue using Documenso." +msgstr "Your plan is no longer valid. Please subscribe to a new plan to continue using Documenso." #: apps/remix/app/components/forms/profile.tsx msgid "Your profile has been updated successfully." diff --git a/packages/lib/translations/fr/web.po b/packages/lib/translations/fr/web.po index 93f70462e..b2940060b 100644 --- a/packages/lib/translations/fr/web.po +++ b/packages/lib/translations/fr/web.po @@ -20,7 +20,7 @@ msgstr "" #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid " Enable direct link signing" -msgstr " Activer la signature de lien direct" +msgstr " Activer la signature par lien direct" #: apps/remix/app/components/embed/authoring/configure-document-upload.tsx msgid ".PDF documents accepted (max {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB)" @@ -42,7 +42,7 @@ msgstr "« {documentName} » a été signé" #: packages/email/template-components/template-document-completed.tsx msgid "“{documentName}” was signed by all signers" -msgstr "“{documentName}” a été signé par tous les signataires" +msgstr "« {documentName} » a été signé par tous les signataires" #: apps/remix/app/components/dialogs/document-delete-dialog.tsx msgid "\"{documentTitle}\" has been successfully deleted" @@ -50,7 +50,7 @@ msgstr "\"{documentTitle}\" a été supprimé avec succès" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "\"{placeholderEmail}\" on behalf of \"Team Name\" has invited you to sign \"example document\"." -msgstr "\"{placeholderEmail}\" au nom de \"Team Name\" vous a invité à signer \"example document\"." +msgstr "\"{placeholderEmail}\" représentant \"Team Name\" vous a invité à signer \"example document\"." #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "\"Team Name\" has invited you to sign \"example document\"." @@ -411,7 +411,7 @@ msgstr "<0>Cliquez pour importer ou faites glisser et déposez" #: packages/ui/components/document/document-signature-settings-tooltip.tsx msgid "<0>Drawn - A signature that is drawn using a mouse or stylus." -msgstr "<0>Signée - Une signature dessinée en utilisant une souris ou un stylet." +msgstr "<0>Dessinée - Une signature dessinée en utilisant une souris ou un stylet." #: packages/ui/primitives/template-flow/add-template-settings.tsx msgid "<0>Email - The recipient will be emailed the document to sign, approve, etc." @@ -465,11 +465,11 @@ msgstr "<0>Expéditeur : Tous" #: packages/ui/components/document/document-signature-settings-tooltip.tsx msgid "<0>Typed - A signature that is typed using a keyboard." -msgstr "<0>Tappée - Une signature tapée à l'aide d'un clavier." +msgstr "<0>Écrite - Une signature écrite à l'aide d'un clavier." #: packages/ui/components/document/document-signature-settings-tooltip.tsx msgid "<0>Uploaded - A signature that is uploaded from a file." -msgstr "<0>Téléchargée - Une signature téléchargée à partir d'un fichier." +msgstr "<0>Importée - Une signature importée à partir d'un fichier." #: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx msgid "<0>You are about to complete approving <1>\"{documentTitle}\".<2/> Are you sure?" @@ -1020,7 +1020,7 @@ msgstr "Autoriser les destinataires du document à répondre directement à cett #: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx #: packages/ui/primitives/document-flow/add-signers.tsx msgid "Allow signers to dictate next signer" -msgstr "Permettre aux signataires de dicter le prochain signataire" +msgstr "Permettre aux signataires de désigner le prochain signataire" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx #: packages/ui/primitives/template-flow/add-template-settings.tsx @@ -1333,7 +1333,7 @@ msgstr "Approval en cours" #: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx msgid "Are you sure you want to complete the document? This action cannot be undone. Please ensure that you have completed prefilling all relevant fields before proceeding." -msgstr "Êtes-vous sûr de vouloir terminer le document ? Cette action ne peut être annulée. Veuillez vous assurer d'avoir pré-rempli tous les champs pertinents avant de procéder." +msgstr "Êtes-vous sûr de vouloir finaliser le document ? Cette action ne peut être annulée. Veuillez vous assurer d'avoir pré-rempli tous les champs pertinents avant de continuer." #: apps/remix/app/components/dialogs/claim-delete-dialog.tsx msgid "Are you sure you want to delete the following claim?" @@ -1840,7 +1840,7 @@ msgstr "Compléter la signature" #: apps/remix/app/components/general/document-signing/document-signing-form.tsx msgid "Complete the fields for the following signers. Once reviewed, they will inform you if any modifications are needed." -msgstr "Complétez les champs pour les signataires suivants. Une fois révisés, ils vous informeront si des modifications sont nécessaires." +msgstr "Complétez les champs pour les signataires suivants. Une fois vérifiés, ils vous informeront si des modifications sont nécessaires." #: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx #: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -3655,7 +3655,7 @@ msgstr "t'a invité à voir ce document" #: packages/ui/primitives/document-flow/add-signers.tsx #: packages/ui/primitives/document-flow/add-signers.tsx msgid "Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist." -msgstr "Avoir un assistant comme dernier signataire signifie qu'il ne pourra prendre aucune mesure car il n'y a pas de signataires ultérieurs à assister." +msgstr "Avoir un assistant comme dernier signataire signifie qu'il ne pourra rien faire car il n'y a pas d'autres signataires à assister." #: apps/remix/app/components/embed/embed-document-signing-page.tsx msgid "Help complete the document for other signers." @@ -5185,7 +5185,7 @@ msgstr "Raison de l'annulation: {cancellationReason}" #: apps/remix/app/components/general/document/document-page-view-recipients.tsx msgid "Reason for rejection: " -msgstr "Raison du rejet: " +msgstr "Raison du rejet : " #: packages/email/template-components/template-document-rejected.tsx msgid "Reason for rejection: {rejectionReason}" @@ -5940,7 +5940,7 @@ msgstr "La signature est trop petite" #: apps/remix/app/components/forms/profile.tsx msgid "Signature Pad cannot be empty." -msgstr "Le Pad de Signature ne peut pas être vide." +msgstr "Le champ de signature ne peut pas être vide." #: packages/ui/components/document/document-signature-settings-tooltip.tsx msgid "Signature types" @@ -7453,7 +7453,7 @@ msgstr "Mettez à niveau votre plan pour importer plus de documents" #: packages/lib/constants/document.ts msgid "Upload" -msgstr "Télécharger" +msgstr "Importer" #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx msgid "Upload a CSV file to create multiple documents from this template. Each row represents one document with its recipient details." @@ -8092,7 +8092,7 @@ msgstr "Ce que vous pouvez faire avec les équipes :" #: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx #: packages/ui/primitives/document-flow/add-signers.tsx msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order." -msgstr "Lorsqu'il est activé, les signataires peuvent choisir qui doit signer ensuite dans la séquence au lieu de suivre l'ordre prédéfini." +msgstr "Lorsqu'il est activé, les signataires peuvent choisir qui doit signer ensuite au lieu de suivre l'ordre prédéfini." #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "When you click continue, you will be prompted to add the first available authenticator on your system." diff --git a/packages/lib/translations/pl/web.po b/packages/lib/translations/pl/web.po index 93306c89c..6784fbae1 100644 --- a/packages/lib/translations/pl/web.po +++ b/packages/lib/translations/pl/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: pl\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2025-07-14 18:04\n" +"PO-Revision-Date: 2025-07-18 02:41\n" "Last-Translator: \n" "Language-Team: Polish\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" @@ -3832,7 +3832,7 @@ msgstr "Statystyki instancji" #: apps/remix/app/components/forms/2fa/view-recovery-codes-dialog.tsx msgid "Invalid code. Please try again." -msgstr "Nieprawidłowy kod. Proszę spróbuj ponownie." +msgstr "Kod jest nieprawidłowy. Spróbuj ponownie." #: packages/ui/primitives/document-flow/add-signers.types.ts msgid "Invalid email" @@ -4947,7 +4947,7 @@ msgstr "Proszę sprawdzić plik CSV i upewnić się, że jest zgodny z naszym fo #: apps/remix/app/components/embed/embed-document-waiting-for-turn.tsx msgid "Please check with the parent application for more information." -msgstr "Sprawdź, proszę, aplikację nadrzędną po więcej informacji." +msgstr "Sprawdź aplikację nadrzędną, aby uzyskać więcej informacji." #: apps/remix/app/routes/_recipient+/sign.$token+/waiting.tsx msgid "Please check your email for updates." @@ -4955,7 +4955,7 @@ msgstr "Proszę sprawdzić swój email w celu aktualizacji." #: apps/remix/app/routes/_unauthenticated+/reset-password.$token.tsx msgid "Please choose your new password" -msgstr "Proszę wybrać nowe hasło" +msgstr "Wybierz nowe hasło" #: apps/remix/app/routes/embed+/v1+/authoring+/template.edit.$id.tsx #: apps/remix/app/routes/embed+/v1+/authoring+/document.edit.$id.tsx @@ -5065,7 +5065,7 @@ msgstr "Wpisz <0>{0}, aby potwierdzić." #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Please upload a logo" -msgstr "Proszę przesłać logo" +msgstr "Prześlij logo" #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx msgid "Pre-formatted CSV template with example data." 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 ownerUser = organisation.owner; const template = await createTemplate({ - title: `[TEST] Template ${nanoid(8)} - Draft`, + data: { + title: `[TEST] Template ${nanoid(8)} - Draft`, + }, userId: ownerUser.id, teamId: team.id, templateDocumentDataId: documentData.id, diff --git a/packages/prisma/types/types.d.ts b/packages/prisma/types/types.d.ts index 3b12e3efc..2621bd54a 100644 --- a/packages/prisma/types/types.d.ts +++ b/packages/prisma/types/types.d.ts @@ -18,6 +18,7 @@ declare global { type DocumentFormValues = TDocumentFormValues; type DocumentAuthOptions = TDocumentAuthOptions; type DocumentEmailSettings = TDocumentEmailSettings; + type DocumentEmailSettingsNullable = TDocumentEmailSettings | null; type RecipientAuthOptions = TRecipientAuthOptions; diff --git a/packages/trpc/server/billing/router.ts b/packages/trpc/server/billing/router.ts deleted file mode 100644 index e11a759de..000000000 --- a/packages/trpc/server/billing/router.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { router } from '../trpc'; -import { createSubscriptionRoute } from './create-subscription'; -import { getInvoicesRoute } from './get-invoices'; -import { getPlansRoute } from './get-plans'; -import { getSubscriptionRoute } from './get-subscription'; -import { manageSubscriptionRoute } from './manage-subscription'; - -export const billingRouter = router({ - plans: { - get: getPlansRoute, - }, - subscription: { - get: getSubscriptionRoute, - create: createSubscriptionRoute, - manage: manageSubscriptionRoute, - }, - invoices: { - get: getInvoicesRoute, - }, -}); diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 08cf6cd09..6ca9ac77b 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -322,7 +322,7 @@ export const documentRouter = router({ return { document: createdDocument, - folder: createdDocument.folder, + folder: createdDocument.folder, // Todo: Remove this prior to api-v2 release. uploadUrl: url, }; }), @@ -367,7 +367,7 @@ export const documentRouter = router({ title, documentDataId, normalizePdf: true, - timezone, + userTimezone: timezone, requestMetadata: ctx.metadata, folderId, }); @@ -477,6 +477,8 @@ export const documentRouter = router({ distributionMethod: meta.distributionMethod, emailSettings: meta.emailSettings, language: meta.language, + emailId: meta.emailId, + emailReplyTo: meta.emailReplyTo, requestMetadata: ctx.metadata, }); } diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 315b593d4..67ceca84e 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -294,6 +294,8 @@ export const ZDistributeDocumentRequestSchema = z.object({ distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(), redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(), language: ZDocumentMetaLanguageSchema.optional(), + emailId: z.string().nullish(), + emailReplyTo: z.string().nullish(), emailSettings: ZDocumentEmailSettingsSchema.optional(), }) .optional(), diff --git a/packages/trpc/server/document-router/update-document.ts b/packages/trpc/server/document-router/update-document.ts index b60a592bb..44a7fb990 100644 --- a/packages/trpc/server/document-router/update-document.ts +++ b/packages/trpc/server/document-router/update-document.ts @@ -44,6 +44,8 @@ export const updateDocumentRoute = authenticatedProcedure distributionMethod: meta.distributionMethod, signingOrder: meta.signingOrder, allowDictateNextSigner: meta.allowDictateNextSigner, + emailId: meta.emailId, + emailReplyTo: meta.emailReplyTo, emailSettings: meta.emailSettings, requestMetadata: ctx.metadata, }); diff --git a/packages/trpc/server/document-router/update-document.types.ts b/packages/trpc/server/document-router/update-document.types.ts index 291eb82d8..66ea89f19 100644 --- a/packages/trpc/server/document-router/update-document.types.ts +++ b/packages/trpc/server/document-router/update-document.types.ts @@ -61,6 +61,8 @@ export const ZUpdateDocumentRequestSchema = z.object({ typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(), uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(), drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(), + emailId: z.string().nullish(), + emailReplyTo: z.string().nullish(), emailSettings: ZDocumentEmailSettingsSchema.optional(), }) .optional(), diff --git a/packages/trpc/server/embedding-router/create-embedding-template.ts b/packages/trpc/server/embedding-router/create-embedding-template.ts index b238cd167..5dbc40cd9 100644 --- a/packages/trpc/server/embedding-router/create-embedding-template.ts +++ b/packages/trpc/server/embedding-router/create-embedding-template.ts @@ -33,7 +33,9 @@ export const createEmbeddingTemplateRoute = procedure // First create the template const template = await createTemplate({ userId: apiToken.userId, - title, + data: { + title, + }, templateDocumentDataId: documentDataId, teamId: apiToken.teamId ?? undefined, }); @@ -77,16 +79,31 @@ export const createEmbeddingTemplateRoute = procedure // Update the template meta if needed if (meta) { + const upsertMetaData = { + subject: meta.subject, + message: meta.message, + timezone: meta.timezone, + dateFormat: meta.dateFormat, + distributionMethod: meta.distributionMethod, + signingOrder: meta.signingOrder, + redirectUrl: meta.redirectUrl, + language: meta.language, + typedSignatureEnabled: meta.typedSignatureEnabled, + drawSignatureEnabled: meta.drawSignatureEnabled, + uploadSignatureEnabled: meta.uploadSignatureEnabled, + emailSettings: meta.emailSettings, + }; + await prisma.templateMeta.upsert({ where: { templateId: template.id, }, create: { templateId: template.id, - ...meta, + ...upsertMetaData, }, update: { - ...meta, + ...upsertMetaData, }, }); } diff --git a/packages/trpc/server/enterprise-router/create-organisation-email-domain.ts b/packages/trpc/server/enterprise-router/create-organisation-email-domain.ts new file mode 100644 index 000000000..4823c1dd3 --- /dev/null +++ b/packages/trpc/server/enterprise-router/create-organisation-email-domain.ts @@ -0,0 +1,66 @@ +import { createEmailDomain } from '@documenso/ee/server-only/lib/create-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 { + ZCreateOrganisationEmailDomainRequestSchema, + ZCreateOrganisationEmailDomainResponseSchema, +} from './create-organisation-email-domain.types'; + +export const createOrganisationEmailDomainRoute = authenticatedProcedure + .input(ZCreateOrganisationEmailDomainRequestSchema) + .output(ZCreateOrganisationEmailDomainResponseSchema) + .mutation(async ({ input, ctx }) => { + 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/router.ts b/packages/trpc/server/template-router/router.ts index 2fc8f6f86..e820d91e7 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -1,9 +1,11 @@ import type { Document } from '@prisma/client'; +import { DocumentDataType } from '@prisma/client'; import { TRPCError } from '@trpc/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { jobs } from '@documenso/lib/jobs/client'; +import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { @@ -23,6 +25,7 @@ import { findTemplates } from '@documenso/lib/server-only/template/find-template import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link'; import { updateTemplate } from '@documenso/lib/server-only/template/update-template'; +import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema'; import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc'; @@ -34,6 +37,8 @@ import { ZCreateTemplateDirectLinkRequestSchema, ZCreateTemplateDirectLinkResponseSchema, ZCreateTemplateMutationSchema, + ZCreateTemplateV2RequestSchema, + ZCreateTemplateV2ResponseSchema, ZDeleteTemplateDirectLinkRequestSchema, ZDeleteTemplateMutationSchema, ZDuplicateTemplateMutationSchema, @@ -141,12 +146,88 @@ export const templateRouter = router({ return await createTemplate({ userId: ctx.user.id, teamId, - title, templateDocumentDataId, - folderId, + data: { + title, + folderId, + }, }); }), + /** + * Temporariy endpoint for V2 Beta until we allow passthrough documents on create. + * + * @public + * @deprecated + */ + createTemplateTemporary: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/template/create/beta', + summary: 'Create template', + description: + 'You will need to upload the PDF to the provided URL returned. Note: Once V2 API is released, this will be removed since we will allow direct uploads, instead of using an upload URL.', + tags: ['Template'], + }, + }) + .input(ZCreateTemplateV2RequestSchema) + .output(ZCreateTemplateV2ResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + + const { + title, + folderId, + externalId, + visibility, + globalAccessAuth, + globalActionAuth, + publicTitle, + publicDescription, + type, + meta, + } = input; + + const fileName = title.endsWith('.pdf') ? title : `${title}.pdf`; + + const { url, key } = await getPresignPostUrl(fileName, 'application/pdf'); + + const templateDocumentData = await createDocumentData({ + data: key, + type: DocumentDataType.S3_PATH, + }); + + const createdTemplate = await createTemplate({ + userId: user.id, + teamId, + templateDocumentDataId: templateDocumentData.id, + data: { + title, + folderId, + externalId, + visibility, + globalAccessAuth, + globalActionAuth, + publicTitle, + publicDescription, + type, + }, + meta, + }); + + const fullTemplate = await getTemplateById({ + id: createdTemplate.id, + userId: user.id, + teamId, + }); + + return { + template: fullTemplate, + uploadUrl: url, + }; + }), + /** * @public */ diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index e0804ed9b..ff8bb0eca 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -30,6 +30,52 @@ import { } from '../document-router/schema'; import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema'; +export const MAX_TEMPLATE_PUBLIC_TITLE_LENGTH = 50; +export const MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH = 256; + +export const ZTemplateTitleSchema = z + .string() + .trim() + .min(1) + .max(255) + .describe('The title of the document.'); + +export const ZTemplatePublicTitleSchema = z + .string() + .trim() + .min(1) + .max(MAX_TEMPLATE_PUBLIC_TITLE_LENGTH) + .describe( + 'The title of the template that will be displayed to the public. Only applicable for public templates.', + ); + +export const ZTemplatePublicDescriptionSchema = z + .string() + .trim() + .min(1) + .max(MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH) + .describe( + 'The description of the template that will be displayed to the public. Only applicable for public templates.', + ); + +export const ZTemplateMetaUpsertSchema = z.object({ + subject: ZDocumentMetaSubjectSchema.optional(), + message: ZDocumentMetaMessageSchema.optional(), + 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(), + typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(), + uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(), + drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(), + signingOrder: z.nativeEnum(DocumentSigningOrder).optional(), + allowDictateNextSigner: z.boolean().optional(), +}); + export const ZCreateTemplateMutationSchema = z.object({ title: z.string().min(1).trim(), templateDocumentDataId: z.string().min(1), @@ -123,57 +169,46 @@ export const ZDeleteTemplateMutationSchema = z.object({ templateId: z.number(), }); -export const MAX_TEMPLATE_PUBLIC_TITLE_LENGTH = 50; -export const MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH = 256; +/** + * Note: This is the same between V1 and V2. Be careful when updating this schema and think of the consequences. + */ +export const ZCreateTemplateV2RequestSchema = z.object({ + title: ZTemplateTitleSchema, + folderId: z.string().optional(), + externalId: z.string().nullish(), + visibility: z.nativeEnum(DocumentVisibility).optional(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]), + publicTitle: ZTemplatePublicTitleSchema.optional(), + publicDescription: ZTemplatePublicDescriptionSchema.optional(), + type: z.nativeEnum(TemplateType).optional(), + meta: ZTemplateMetaUpsertSchema.optional(), +}); + +/** + * Note: This is the same between V1 and V2. Be careful when updating this schema and think of the consequences. + */ +export const ZCreateTemplateV2ResponseSchema = z.object({ + template: ZTemplateSchema, + uploadUrl: z.string().min(1), +}); export const ZUpdateTemplateRequestSchema = z.object({ templateId: z.number(), data: z .object({ - title: z.string().min(1).optional(), + title: ZTemplateTitleSchema.optional(), externalId: z.string().nullish(), visibility: z.nativeEnum(DocumentVisibility).optional(), globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]), globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]), - publicTitle: z - .string() - .trim() - .min(1) - .max(MAX_TEMPLATE_PUBLIC_TITLE_LENGTH) - .describe( - 'The title of the template that will be displayed to the public. Only applicable for public templates.', - ) - .optional(), - publicDescription: z - .string() - .trim() - .min(1) - .max(MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH) - .describe( - 'The description of the template that will be displayed to the public. Only applicable for public templates.', - ) - .optional(), + publicTitle: ZTemplatePublicTitleSchema.optional(), + publicDescription: ZTemplatePublicDescriptionSchema.optional(), type: z.nativeEnum(TemplateType).optional(), useLegacyFieldInsertion: z.boolean().optional(), }) .optional(), - meta: z - .object({ - subject: ZDocumentMetaSubjectSchema.optional(), - message: ZDocumentMetaMessageSchema.optional(), - timezone: ZDocumentMetaTimezoneSchema.optional(), - dateFormat: ZDocumentMetaDateFormatSchema.optional(), - distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(), - emailSettings: ZDocumentEmailSettingsSchema.optional(), - redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(), - language: ZDocumentMetaLanguageSchema.optional(), - typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(), - uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(), - drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(), - signingOrder: z.nativeEnum(DocumentSigningOrder).optional(), - allowDictateNextSigner: z.boolean().optional(), - }) - .optional(), + meta: ZTemplateMetaUpsertSchema.optional(), }); export const ZUpdateTemplateResponseSchema = ZTemplateLiteSchema; 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/components/document/document-signature-settings-tooltip.tsx b/packages/ui/components/document/document-signature-settings-tooltip.tsx index 6f09927ee..51d9a7f54 100644 --- a/packages/ui/components/document/document-signature-settings-tooltip.tsx +++ b/packages/ui/components/document/document-signature-settings-tooltip.tsx @@ -6,7 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive export const DocumentSignatureSettingsTooltip = () => { return ( - + 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 dedb9a2a9..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 + - + + + -