diff --git a/.env.example b/.env.example index 693f1d8a5..7b8872b69 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= @@ -130,3 +136,5 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123" # OPTIONAL: The file to save the logger output to. Will disable stdout if provided. NEXT_PRIVATE_LOGGER_FILE_PATH= +# [[PLAIN SUPPORT]] +NEXT_PRIVATE_PLAIN_API_KEY= diff --git a/.github/workflows/translations-upload.yml b/.github/workflows/translations-upload.yml index cb69d6338..adadc0d61 100644 --- a/.github/workflows/translations-upload.yml +++ b/.github/workflows/translations-upload.yml @@ -20,8 +20,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - token: ${{ secrets.GH_PAT }} - uses: ./.github/actions/node-install diff --git a/README.md b/README.md index f44b88c2a..aa423aa2b 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ The Web UI can be found at http://localhost:9000, while the SMTP port will be on ### Support IPv6 -If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Next.js start command +If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Remix start command For local docker run 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..d28b467e3 --- /dev/null +++ b/apps/documentation/pages/users/email-domains.mdx @@ -0,0 +1,111 @@ +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. + + + **Enterprise Only**: Email Domains is only available to Enterprise customers and custom plans + + +## Creating Email Domains + +Before setting up email domains, ensure you have: + +- An 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/admin-user-reset-two-factor-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx new file mode 100644 index 000000000..f95657d9f --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { User } from '@prisma/client'; +import { useRevalidator } from 'react-router'; +import { match } from 'ts-pattern'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AdminUserResetTwoFactorDialogProps = { + className?: string; + user: User; +}; + +export const AdminUserResetTwoFactorDialog = ({ + className, + user, +}: AdminUserResetTwoFactorDialogProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + const { revalidate } = useRevalidator(); + const [email, setEmail] = useState(''); + const [open, setOpen] = useState(false); + + const { mutateAsync: resetTwoFactor, isPending: isResettingTwoFactor } = + trpc.admin.user.resetTwoFactor.useMutation(); + + const onResetTwoFactor = async () => { + try { + await resetTwoFactor({ + userId: user.id, + }); + + toast({ + title: _(msg`2FA Reset`), + description: _(msg`The user's two factor authentication has been reset successfully.`), + duration: 5000, + }); + + await revalidate(); + setOpen(false); + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = match(error.code) + .with(AppErrorCode.NOT_FOUND, () => msg`User not found.`) + .with( + AppErrorCode.UNAUTHORIZED, + () => msg`You are not authorized to reset two factor authentcation for this user.`, + ) + .otherwise( + () => msg`An error occurred while resetting two factor authentication for the user.`, + ); + + toast({ + title: _(msg`Error`), + description: _(errorMessage), + variant: 'destructive', + duration: 7500, + }); + } + }; + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + + if (!newOpen) { + setEmail(''); + } + }; + + return ( +
+ +
+ Reset Two Factor Authentication + + + Reset the users two factor authentication. This action is irreversible and will + disable two factor authentication for the user. + + +
+ +
+ + + + + + + + + Reset Two Factor Authentication + + + + + + + This action is irreversible. Please ensure you have informed the user before + proceeding. + + + + +
+ + + To confirm, please enter the accounts email address
({user.email}). +
+
+ + setEmail(e.target.value)} + /> +
+ + + + +
+
+
+
+
+ ); +}; 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/dialogs/team-member-create-dialog.tsx b/apps/remix/app/components/dialogs/team-member-create-dialog.tsx index b691d910c..8f822cfad 100644 --- a/apps/remix/app/components/dialogs/team-member-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-member-create-dialog.tsx @@ -4,7 +4,9 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; import { TeamMemberRole } from '@prisma/client'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { InfoIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; +import { Link } from 'react-router'; import { match } from 'ts-pattern'; import { z } from 'zod'; @@ -39,6 +41,7 @@ import { SelectTrigger, SelectValue, } from '@documenso/ui/primitives/select'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { useCurrentTeam } from '~/providers/team'; @@ -140,8 +143,28 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi {match(step) .with('SELECT', () => ( - + Add members + + + + + + + To be able to add members to a team, you must first add them to the + organisation. For more information, please see the{' '} + + documentation + + . + + + diff --git a/apps/remix/app/components/dialogs/template-use-dialog.tsx b/apps/remix/app/components/dialogs/template-use-dialog.tsx index 2f8266ef1..3d4e7ba61 100644 --- a/apps/remix/app/components/dialogs/template-use-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-use-dialog.tsx @@ -15,6 +15,7 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX, TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX, + isTemplateRecipientEmailPlaceholder, } from '@documenso/lib/constants/template'; import { AppError } from '@documenso/lib/errors/app-error'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; @@ -279,7 +280,11 @@ export function TemplateUseDialog({ @@ -484,6 +489,7 @@ export function TemplateUseDialog({ { 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..eb3c64e06 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,8 +51,11 @@ 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; + includeAuditLog: boolean | null; signatureTypes: DocumentSignatureType[]; }; @@ -53,8 +63,11 @@ type SettingsSubset = Pick< TeamGlobalSettings, | 'documentVisibility' | 'documentLanguage' + | 'documentTimezone' + | 'documentDateFormat' | 'includeSenderDetails' | 'includeSigningCertificate' + | 'includeAuditLog' | 'typedSignatureEnabled' | 'uploadSignatureEnabled' | 'drawSignatureEnabled' @@ -81,8 +94,11 @@ 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(), + includeAuditLog: z.boolean().nullable(), signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, { message: msg`At least one signature type must be enabled`.id, }), @@ -94,8 +110,12 @@ 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, + includeAuditLog: settings.includeAuditLog, signatureTypes: extractTeamSignatureSettings({ ...settings }), }, resolver: zodResolver(ZDocumentPreferencesFormSchema), @@ -124,7 +144,10 @@ export const DocumentPreferencesForm = ({ value={field.value === null ? '-1' : field.value} onValueChange={(value) => field.onChange(value === '-1' ? null : value)} > - + @@ -171,7 +194,10 @@ export const DocumentPreferencesForm = ({ value={field.value === null ? '-1' : field.value} onValueChange={(value) => field.onChange(value === '-1' ? null : value)} > - + @@ -199,6 +225,72 @@ export const DocumentPreferencesForm = ({ )} /> + ( + + + Default Date Format + + + + + + + + + )} + /> + + ( + + + Default Time Zone + + + + field.onChange(value)} + testId="document-timezone-trigger" + /> + + + + + )} + /> + @@ -257,7 +349,10 @@ export const DocumentPreferencesForm = ({ field.onChange(value === 'true' ? true : value === 'false' ? false : null) } > - + @@ -325,7 +420,10 @@ export const DocumentPreferencesForm = ({ field.onChange(value === 'true' ? true : value === 'false' ? false : null) } > - + @@ -358,6 +456,56 @@ export const DocumentPreferencesForm = ({ )} /> + ( + + + Include the Audit Logs in the Document + + + + + + + + + Controls whether the audit logs will be included in the document when it is + downloaded. The audit logs can still be downloaded from the logs page + separately. + + + + )} + /> +
+
+ + + + ); +}; diff --git a/apps/remix/app/components/forms/support-ticket-form.tsx b/apps/remix/app/components/forms/support-ticket-form.tsx new file mode 100644 index 000000000..e80f12d21 --- /dev/null +++ b/apps/remix/app/components/forms/support-ticket-form.tsx @@ -0,0 +1,138 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +const ZSupportTicketSchema = z.object({ + subject: z.string().min(3, 'Subject is required'), + message: z.string().min(10, 'Message must be at least 10 characters'), +}); + +type TSupportTicket = z.infer; + +export type SupportTicketFormProps = { + organisationId: string; + teamId?: string | null; + onSuccess?: () => void; + onClose?: () => void; +}; + +export const SupportTicketForm = ({ + organisationId, + teamId, + onSuccess, + onClose, +}: SupportTicketFormProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const { mutateAsync: submitSupportTicket, isPending } = + trpc.profile.submitSupportTicket.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZSupportTicketSchema), + defaultValues: { + subject: '', + message: '', + }, + }); + + const isLoading = form.formState.isLoading || isPending; + + const onSubmit = async (data: TSupportTicket) => { + const { subject, message } = data; + + try { + await submitSupportTicket({ + subject, + message, + organisationId, + teamId, + }); + + toast({ + title: t`Support ticket created`, + description: t`Your support request has been submitted. We'll get back to you soon!`, + }); + + if (onSuccess) { + onSuccess(); + } + + form.reset(); + } catch (err) { + toast({ + title: t`Failed to create support ticket`, + description: t`An error occurred. Please try again later.`, + variant: 'destructive', + }); + } + }; + + return ( + <> +
+ +
+ ( + + + Subject + + + + + + + )} + /> + + ( + + + Message + + +