diff --git a/README.md b/README.md index f5117abd0..b52220554 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ For full instructions, requirements, and configuration details, see the [Self Ho #### Railway -[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic) #### Render diff --git a/SIGNING.md b/SIGNING.md index cb719ffb8..f794bc9ba 100644 --- a/SIGNING.md +++ b/SIGNING.md @@ -60,7 +60,7 @@ We support a variety of deployment methods, and are actively working on adding m ## Railway -[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/DjrRRX) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic) ## Render diff --git a/apps/docs/content/docs/policies/meta.json b/apps/docs/content/docs/policies/meta.json index 14ba43d70..2251871a5 100644 --- a/apps/docs/content/docs/policies/meta.json +++ b/apps/docs/content/docs/policies/meta.json @@ -8,6 +8,7 @@ "privacy", "terms", "security", + "verify-email", "support" ] } diff --git a/apps/docs/content/docs/policies/verify-email.mdx b/apps/docs/content/docs/policies/verify-email.mdx new file mode 100644 index 000000000..72e3a1098 --- /dev/null +++ b/apps/docs/content/docs/policies/verify-email.mdx @@ -0,0 +1,68 @@ +--- +title: Verifying Emails from Documenso +description: How to confirm that an email is genuinely from Documenso, and what to do if you receive a suspicious message. +--- + +import { Callout } from 'fumadocs-ui/components/callout'; + +## Check the Sender Domain + +All email sent by Documenso originates from one of the following domains. If you receive an email claiming to be from Documenso and the sender address does not end in one of these domains, treat it as suspicious. + +| Domain | Used for | +| ------------------------ | -------------------------------------------------------------- | +| `app.documenso.com` | Transactional email | +| `documensomail.com` | Transactional email | +| `documensoemail.com` | Transactional email | +| Custom domain | [Enterprise organisations](/docs/users/organisations/email-domains) using a custom email domain | + +Typical sender addresses include: + +- `noreply@app.documenso.com` +- `noreply@free.documensomail.com` +- `noreply@individual.documensoemail.com` + + + A misspelling such as `documenso-mail.com`, `documensomaiI.com` (capital i instead of l), or any other variation is not a Documenso domain. + + +## Types of Email Documenso Sends + +Documenso sends email only for the following purposes: + +- **Account verification** — confirming your email address when you sign up or change it +- **Password reset** — a link to reset your password that you requested +- **Document invitations** — notifying you that a document has been shared with you to sign, approve, or view +- **Signing reminders** — follow-up reminders for pending document actions +- **Completed document notifications** — confirmation that all parties have signed a document +- **Team invitations** — inviting you to join an organisation or team + +## What Documenso Will Never Do + +- Ask for your password via email +- Send you an attachment and ask you to open it to verify your identity +- Ask you to confirm payment details or billing information over email +- Send unsolicited marketing emails if you have not opted in + +## How to Tell If an Email Is Legitimate + +1. **Check the sender address** — the domain must be `documenso.com` or `documensomail.com` +2. **Look at the link destination** — hover over any link before clicking; it should point to `app.documenso.com` +3. **Watch for urgency or threats** — legitimate Documenso emails do not threaten account suspension to pressure you into clicking a link immediately +4. **Verify the action yourself** — if in doubt, log in to [app.documenso.com](https://app.documenso.com) directly (not via the email link) and check whether the document or notification exists there + +## Report a Suspicious Email + +If you receive an email that appears to impersonate Documenso: + +1. Do not click any links or download any attachments +2. Forward the email as an attachment to **support@documenso.com** +3. Delete the email from your inbox + +You can also report phishing emails directly to your email provider using their built-in reporting tools. + +## Related + +- [Security Policy](/docs/policies/security) — Documenso's security practices and vulnerability disclosure process +- [Create an Account](/docs/users/getting-started/create-account) — What to expect during sign-up +- [Security Settings](/docs/users/settings/security) — Enable two-factor authentication and manage sessions diff --git a/apps/docs/content/docs/self-hosting/deployment/railway.mdx b/apps/docs/content/docs/self-hosting/deployment/railway.mdx index 93c861678..81392a37d 100644 --- a/apps/docs/content/docs/self-hosting/deployment/railway.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/railway.mdx @@ -24,7 +24,7 @@ Before deploying, you need: The fastest way to deploy Documenso on Railway is using the official template: -[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p) +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic) This template automatically provisions: diff --git a/apps/docs/content/docs/users/getting-started/create-account.mdx b/apps/docs/content/docs/users/getting-started/create-account.mdx index 17030e730..8047f48ea 100644 --- a/apps/docs/content/docs/users/getting-started/create-account.mdx +++ b/apps/docs/content/docs/users/getting-started/create-account.mdx @@ -39,7 +39,11 @@ Navigate to [documen.so/free](https://documen.so/free) to create a free account. Provide your name, email address, and create a password. Alternatively, sign up with Google for faster access. -{/* TODO: Add screenshot of registration form */} +Documenso registration form with name, email, and password fields diff --git a/apps/docs/public/get-started-images/documenso-registration-form.webp b/apps/docs/public/get-started-images/documenso-registration-form.webp new file mode 100644 index 000000000..5b414adc5 Binary files /dev/null and b/apps/docs/public/get-started-images/documenso-registration-form.webp differ diff --git a/apps/remix/app/components/dialogs/claim-update-dialog.tsx b/apps/remix/app/components/dialogs/claim-update-dialog.tsx index bcbd91a56..bdcdfbd55 100644 --- a/apps/remix/app/components/dialogs/claim-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/claim-update-dialog.tsx @@ -2,6 +2,7 @@ import type { TLicenseClaim } from '@documenso/lib/types/license'; import { trpc } from '@documenso/trpc/react'; import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types'; import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Dialog, DialogContent, @@ -28,6 +29,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD const { toast } = useToast(); const [open, setOpen] = useState(false); + const [backportEmailTransport, setBackportEmailTransport] = useState(false); const { mutateAsync: updateClaim, isPending } = trpc.admin.claims.update.useMutation({ onSuccess: () => { @@ -67,19 +69,33 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD await updateClaim({ id: claim.id, data, + backportEmailTransport, }) } licenseFlags={licenseFlags} formSubmitTrigger={ - - + <> +
+ setBackportEmailTransport(checked === true)} + /> + +
- -
+ + + + + + } /> diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx index d4e4a5168..493d07138 100644 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx @@ -1,5 +1,6 @@ import { useSession } from '@documenso/lib/client-only/providers/session'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { AppError } from '@documenso/lib/errors/app-error'; import type { TRecipientLite } from '@documenso/lib/types/recipient'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { Document } from '@documenso/prisma/types/document-legacy-schema'; @@ -30,6 +31,7 @@ import { useForm, useWatch } from 'react-hook-form'; import * as z from 'zod'; import { useCurrentTeam } from '~/providers/team'; +import { getDistributeErrorMessage } from '~/utils/toast-error-messages'; import { StackAvatar } from '../general/stack-avatar'; @@ -99,9 +101,12 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia setIsOpen(false); } catch (err) { + const error = AppError.parseError(err); + const errorMessage = getDistributeErrorMessage(error.code); + toast({ - title: _(msg`Something went wrong`), - description: _(msg`This document could not be re-sent at this time. Please try again.`), + title: _(errorMessage.title), + description: _(errorMessage.description), variant: 'destructive', duration: 7500, }); diff --git a/apps/remix/app/components/dialogs/email-transport-create-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-create-dialog.tsx new file mode 100644 index 000000000..462e41921 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-create-dialog.tsx @@ -0,0 +1,95 @@ +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 { useToast } from '@documenso/ui/primitives/use-toast'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; + +import { + EmailTransportForm, + type EmailTransportFormValues, + emailTransportFormToConfig, +} from '../forms/email-transport-form'; + +export type EmailTransportCreateDialogProps = { + trigger?: React.ReactNode; +}; + +export const EmailTransportCreateDialog = ({ trigger }: EmailTransportCreateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: createTransport, isPending } = trpc.admin.emailTransport.create.useMutation({ + onSuccess: () => { + toast({ + title: t`Transport created.`, + }); + + setOpen(false); + }, + onError: (error) => { + toast({ + title: t`Failed to create transport.`, + description: error.message, + variant: 'destructive', + }); + }, + }); + + const onFormSubmit = async (values: EmailTransportFormValues) => { + await createTransport({ + name: values.name, + fromName: values.fromName, + fromAddress: values.fromAddress, + config: emailTransportFormToConfig(values), + }); + }; + + return ( + !isPending && setOpen(value)}> + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Add Email Transport + + + Fill in the details to create a new email transport. + + + + + + + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx new file mode 100644 index 000000000..055d920b3 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx @@ -0,0 +1,114 @@ +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'; +import { Plural, Trans, useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; + +export type EmailTransportDeleteDialogProps = { + transportId: string; + transportName: string; + subscriptionClaimCount: number; + organisationClaimCount: number; + trigger: React.ReactNode; +}; + +export const EmailTransportDeleteDialog = ({ + transportId, + transportName, + subscriptionClaimCount, + organisationClaimCount, + trigger, +}: EmailTransportDeleteDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const isInUse = subscriptionClaimCount + organisationClaimCount > 0; + + const { mutateAsync: deleteTransport, isPending } = trpc.admin.emailTransport.delete.useMutation({ + onSuccess: () => { + toast({ + title: t`Transport deleted.`, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: t`Failed to delete transport.`, + variant: 'destructive', + }); + }, + }); + + return ( + !isPending && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Delete Email Transport + + + Are you sure you want to delete the following transport? + + + + + {transportName} + + + {isInUse && ( + + + Warning, this email transport is currently being used by: + +
    + {subscriptionClaimCount > 0 && ( +
  • + +
  • + )} + + {organisationClaimCount > 0 && ( +
  • + +
  • + )} +
+
+
+ )} + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx new file mode 100644 index 000000000..1a463ff72 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx @@ -0,0 +1,126 @@ +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'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const ZSendTestEmailFormSchema = z.object({ + to: z.string().email(), +}); + +type TSendTestEmailFormSchema = z.infer; + +export type EmailTransportSendTestDialogProps = { + transportId: string; + trigger: React.ReactNode; +}; + +export const EmailTransportSendTestDialog = ({ transportId, trigger }: EmailTransportSendTestDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: sendTest } = trpc.admin.emailTransport.sendTest.useMutation({ + onSuccess: () => { + toast({ + title: t`Test email sent.`, + }); + setOpen(false); + }, + onError: (error) => { + toast({ + title: t`Test failed.`, + description: error.message, + variant: 'destructive', + }); + }, + }); + + const form = useForm({ + resolver: zodResolver(ZSendTestEmailFormSchema), + defaultValues: { + to: '', + }, + }); + + const onFormSubmit = async ({ to }: TSendTestEmailFormSchema) => { + await sendTest({ id: transportId, to }); + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Send Test Email + + + Send a test email using this transport to verify the configuration. + + + +
+ +
+ ( + + + Email + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/email-transport-update-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-update-dialog.tsx new file mode 100644 index 000000000..5ad3ae023 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-update-dialog.tsx @@ -0,0 +1,104 @@ +import { trpc } from '@documenso/trpc/react'; +import type { TFindEmailTransportsResponse } from '@documenso/trpc/server/admin-router/email-transport/find-email-transports.types'; +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'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; + +import { + EmailTransportForm, + type EmailTransportFormValues, + emailTransportFormToConfig, +} from '../forms/email-transport-form'; + +export type EmailTransportUpdateDialogProps = { + transport: TFindEmailTransportsResponse['data'][number]; + trigger: React.ReactNode; +}; + +export const EmailTransportUpdateDialog = ({ transport, trigger }: EmailTransportUpdateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: updateTransport, isPending } = trpc.admin.emailTransport.update.useMutation(); + + const onFormSubmit = async (values: EmailTransportFormValues) => { + try { + await updateTransport({ + id: transport.id, + data: { + name: values.name, + fromName: values.fromName, + fromAddress: values.fromAddress, + config: emailTransportFormToConfig(values), + }, + }); + + toast({ + title: t`Transport updated.`, + }); + + setOpen(false); + } catch { + toast({ + title: t`Failed to save transport.`, + variant: 'destructive', + }); + } + }; + + return ( + !isPending && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Edit Email Transport + + + Modify the details of the email transport. + + + + + + + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx index daac27df1..750ab38f2 100644 --- a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx @@ -1,6 +1,7 @@ import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError } from '@documenso/lib/errors/app-error'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients'; import { zEmail } from '@documenso/lib/utils/zod'; @@ -37,6 +38,7 @@ import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { match } from 'ts-pattern'; import * as z from 'zod'; +import { getDistributeErrorMessage } from '~/utils/toast-error-messages'; export type EnvelopeDistributeDialogProps = { onDistribute?: () => Promise; @@ -66,7 +68,7 @@ export const EnvelopeDistributeDialog = ({ const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor(); const { toast } = useToast(); - const { t } = useLingui(); + const { t, i18n } = useLingui(); const navigate = useNavigate(); const [isOpen, setIsOpen] = useState(false); @@ -174,9 +176,13 @@ export const EnvelopeDistributeDialog = ({ setIsOpen(false); } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = getDistributeErrorMessage(error.code); + toast({ - title: t`Something went wrong`, - description: t`This envelope could not be distributed at this time. Please try again.`, + title: i18n._(errorMessage.title), + description: i18n._(errorMessage.description), variant: 'destructive', duration: 7500, }); diff --git a/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx index c645df0dd..161fd94d0 100644 --- a/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx @@ -1,4 +1,5 @@ import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { AppError } from '@documenso/lib/errors/app-error'; import type { TEnvelope } from '@documenso/lib/types/envelope'; import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; @@ -25,7 +26,7 @@ import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; - +import { getDistributeErrorMessage } from '~/utils/toast-error-messages'; import { StackAvatar } from '../general/stack-avatar'; export type EnvelopeRedistributeDialogProps = { @@ -47,7 +48,7 @@ export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedist const recipients = envelope.recipients; const { toast } = useToast(); - const { t } = useLingui(); + const { t, i18n } = useLingui(); const [isOpen, setIsOpen] = useState(false); @@ -77,9 +78,12 @@ export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedist setIsOpen(false); } catch (err) { + const error = AppError.parseError(err); + const errorMessage = getDistributeErrorMessage(error.code); + toast({ - title: t`Something went wrong`, - description: t`This envelope could not be resent at this time. Please try again.`, + title: i18n._(errorMessage.title), + description: i18n._(errorMessage.description), variant: 'destructive', duration: 7500, }); diff --git a/apps/remix/app/components/dialogs/template-use-dialog.tsx b/apps/remix/app/components/dialogs/template-use-dialog.tsx index 5baa48584..48507fb7d 100644 --- a/apps/remix/app/components/dialogs/template-use-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-use-dialog.tsx @@ -4,7 +4,7 @@ import { TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX, } from '@documenso/lib/constants/template'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { AppError } from '@documenso/lib/errors/app-error'; import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { trpc } from '@documenso/trpc/react'; @@ -35,8 +35,8 @@ import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; -import { match } from 'ts-pattern'; import * as z from 'zod'; +import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages'; const ZAddRecipientsForNewDocumentSchema = z.object({ distributeDocument: z.boolean(), @@ -180,22 +180,11 @@ export function TemplateUseDialog({ await navigate(documentPath); } catch (err) { const error = AppError.parseError(err); - - const errorMessage = match(error.code) - .with('DOCUMENT_SEND_FAILED', () => msg`The document was created but could not be sent to recipients.`) - .with( - AppErrorCode.INVALID_BODY, - AppErrorCode.INVALID_REQUEST, - () => - msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`, - ) - .with(AppErrorCode.NOT_FOUND, () => msg`The template or one of its recipients could not be found.`) - .with(AppErrorCode.LIMIT_EXCEEDED, () => msg`You have reached your document limit for this plan.`) - .otherwise(() => msg`An error occurred while creating document from template.`); + const errorMessage = getTemplateUseErrorMessage(error.code); toast({ - title: _(msg`Error`), - description: _(errorMessage), + title: _(errorMessage.title), + description: _(errorMessage.description), variant: 'destructive', }); } diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index e0e37d35c..20a0a23fa 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -3,6 +3,7 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import { AppError } from '@documenso/lib/errors/app-error'; import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema'; import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; @@ -42,6 +43,7 @@ import { useSearchParams } from 'react-router'; import { BrandingLogo } from '~/components/general/branding-logo'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; import { injectCss } from '~/utils/css-vars'; +import { getDirectTemplateErrorMessage } from '~/utils/toast-error-messages'; import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form'; import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover'; @@ -259,9 +261,12 @@ export const EmbedDirectTemplateClientPage = ({ ); } + const error = AppError.parseError(err); + const errorMessage = getDirectTemplateErrorMessage(error.code); + toast({ - title: _(msg`Something went wrong`), - description: _(msg`We were unable to submit this document at this time. Please try again later.`), + title: _(errorMessage.title), + description: _(errorMessage.description), variant: 'destructive', }); } diff --git a/apps/remix/app/components/forms/email-transport-form.tsx b/apps/remix/app/components/forms/email-transport-form.tsx new file mode 100644 index 000000000..2e20fdb59 --- /dev/null +++ b/apps/remix/app/components/forms/email-transport-form.tsx @@ -0,0 +1,317 @@ +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'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const ZEmailTransportFormSchema = z.object({ + name: z.string().min(1), + fromName: z.string().min(1), + fromAddress: z.string().email(), + type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']), + host: z.string().optional(), + port: z.coerce.number().int().positive().optional(), + secure: z.boolean().optional(), + ignoreTLS: z.boolean().optional(), + username: z.string().optional(), + password: z.string().optional(), + service: z.string().optional(), + apiKey: z.string().optional(), + apiKeyUser: z.string().optional(), + endpoint: z.string().optional(), +}); + +export type EmailTransportFormValues = z.infer; + +type EmailTransportFormProps = { + defaultValues?: Partial; + isEdit?: boolean; + onFormSubmit: (values: EmailTransportFormValues) => Promise; + formSubmitTrigger?: React.ReactNode; +}; + +export const EmailTransportForm = ({ + defaultValues, + isEdit = false, + onFormSubmit, + formSubmitTrigger, +}: EmailTransportFormProps) => { + const { t } = useLingui(); + + const form = useForm({ + resolver: zodResolver(ZEmailTransportFormSchema), + defaultValues: { + name: '', + fromName: '', + fromAddress: '', + type: 'SMTP_AUTH', + secure: false, + ignoreTLS: false, + ...defaultValues, + }, + }); + + const type = form.watch('type'); + const secretPlaceholder = isEdit ? t`Leave blank to keep current` : undefined; + + return ( +
+ +
+ ( + + + Name + + + + + + + )} + /> + +
+ ( + + + From name + + + + + + + )} + /> + + ( + + + From address + + + + + + + )} + /> +
+ + ( + + + Transport type + + + {isEdit && ( + + Transport type cannot be changed after creation. + + )} + + + )} + /> + + {(type === 'SMTP_AUTH' || type === 'SMTP_API') && ( +
+ ( + + + Host + + + + + + + )} + /> + ( + + + Port + + + + + + + )} + /> +
+ )} + + {type === 'SMTP_AUTH' && ( + <> + ( + + + Username + + + + + + + )} + /> + ( + + + Password + + + + + + + )} + /> + + )} + + {type === 'SMTP_API' && ( + ( + + + API key + + + + + + + )} + /> + )} + + {(type === 'RESEND' || type === 'MAILCHANNELS') && ( + ( + + + API key + + + + + + + )} + /> + )} + + {type === 'MAILCHANNELS' && ( + ( + + + Endpoint (optional) + + + + + + + )} + /> + )} + + {formSubmitTrigger} +
+
+ + ); +}; + +/** + * Maps flat form values to the tRPC `config` discriminated union. + */ +export const emailTransportFormToConfig = (values: EmailTransportFormValues) => { + switch (values.type) { + case 'SMTP_AUTH': + return { + type: 'SMTP_AUTH' as const, + host: values.host ?? '', + port: values.port ?? 587, + secure: values.secure ?? false, + ignoreTLS: values.ignoreTLS ?? false, + username: values.username || undefined, + password: values.password || undefined, + service: values.service || undefined, + }; + case 'SMTP_API': + return { + type: 'SMTP_API' as const, + host: values.host ?? '', + port: values.port ?? 587, + secure: values.secure ?? false, + apiKey: values.apiKey || '', + apiKeyUser: values.apiKeyUser || undefined, + }; + case 'RESEND': + return { type: 'RESEND' as const, apiKey: values.apiKey || '' }; + case 'MAILCHANNELS': + return { + type: 'MAILCHANNELS' as const, + apiKey: values.apiKey || '', + endpoint: values.endpoint || undefined, + }; + } +}; diff --git a/apps/remix/app/components/forms/subscription-claim-form.tsx b/apps/remix/app/components/forms/subscription-claim-form.tsx index e3cef8d92..1dd903a3f 100644 --- a/apps/remix/app/components/forms/subscription-claim-form.tsx +++ b/apps/remix/app/components/forms/subscription-claim-form.tsx @@ -1,5 +1,6 @@ import type { TLicenseClaim } from '@documenso/lib/types/license'; import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription'; +import { trpc } from '@documenso/trpc/react'; import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; @@ -13,6 +14,7 @@ import { FormMessage, } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; import type { SubscriptionClaim } from '@prisma/client'; @@ -59,9 +61,14 @@ export const SubscriptionClaimForm = ({ emailQuota: subscriptionClaim.emailQuota, apiRateLimits: subscriptionClaim.apiRateLimits, apiQuota: subscriptionClaim.apiQuota, + emailTransportId: subscriptionClaim.emailTransportId ?? null, }, }); + const { data: transportsData } = trpc.admin.emailTransport.find.useQuery({ perPage: 100 }); + const transports = transportsData?.data ?? []; + const NONE_VALUE = '__none__'; + return (
@@ -238,6 +245,40 @@ export const SubscriptionClaimForm = ({ + ( + + + Email transport + + + + Plans without a transport use the system default mailer. + + + + )} + /> + {formSubmitTrigger} diff --git a/apps/remix/app/components/general/billing-plans.tsx b/apps/remix/app/components/general/billing-plans.tsx index 538471b49..1de96f1d0 100644 --- a/apps/remix/app/components/general/billing-plans.tsx +++ b/apps/remix/app/components/general/billing-plans.tsx @@ -54,7 +54,6 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => { if (plan[interval] && plan[interval].isVisibleInApp) { prices.push({ ...plan[interval], - memberCount: plan.memberCount, claim: plan.id, }); } @@ -120,12 +119,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => { Subscribe ) : ( - + )} @@ -136,16 +130,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => { ); }; -const BillingDialog = ({ - priceId, - planName, - claim, -}: { - priceId: string; - planName: string; - memberCount: number; - claim: string; -}) => { +const BillingDialog = ({ priceId, planName, claim }: { priceId: string; planName: string; claim: string }) => { const [isOpen, setIsOpen] = useState(false); const { t } = useLingui(); diff --git a/apps/remix/app/components/general/direct-template/direct-template-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-page.tsx index 1cdb03276..24753f7e0 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-page.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-page.tsx @@ -1,4 +1,5 @@ import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { AppError } from '@documenso/lib/errors/app-error'; import type { TTemplate } from '@documenso/lib/types/template'; import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; @@ -17,6 +18,7 @@ import { useNavigate, useSearchParams } from 'react-router'; import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider'; import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; +import { getDirectTemplateErrorMessage } from '~/utils/toast-error-messages'; import { DirectTemplateConfigureForm, type TDirectTemplateConfigureFormSchema } from './direct-template-configure-form'; import { type DirectTemplateLocalField, DirectTemplateSigningForm } from './direct-template-signing-form'; @@ -120,9 +122,12 @@ export const DirectTemplatePageView = ({ await navigate(`/sign/${token}/complete`); } } catch (err) { + const error = AppError.parseError(err); + const errorMessage = getDirectTemplateErrorMessage(error.code); + toast({ - title: _(msg`Something went wrong`), - description: _(msg`We were unable to submit this document at this time. Please try again later.`), + title: _(errorMessage.title), + description: _(errorMessage.description), variant: 'destructive', }); diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index 782832044..051ee562c 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -1,6 +1,7 @@ import { DocumentSignatureType } from '@documenso/lib/constants/document'; import { isValidLanguageCode } from '@documenso/lib/constants/i18n'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc'; +import { AppError } from '@documenso/lib/errors/app-error'; import type { TDocument } from '@documenso/lib/types/document'; import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; @@ -25,9 +26,9 @@ import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client'; import { useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; import { z } from 'zod'; - import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; import { useCurrentTeam } from '~/providers/team'; +import { getDistributeErrorMessage } from '~/utils/toast-error-messages'; export type DocumentEditFormProps = { className?: string; @@ -387,9 +388,12 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath } catch (err) { console.error(err); + const error = AppError.parseError(err); + const errorMessage = getDistributeErrorMessage(error.code); + toast({ - title: _(msg`Error`), - description: _(msg`An error occurred while sending the document.`), + title: _(errorMessage.title), + description: _(errorMessage.description), variant: 'destructive', }); } diff --git a/apps/remix/app/components/general/document/document-upload-button-legacy.tsx b/apps/remix/app/components/general/document/document-upload-button-legacy.tsx index 225e13cd0..c53cd93c0 100644 --- a/apps/remix/app/components/general/document/document-upload-button-legacy.tsx +++ b/apps/remix/app/components/general/document/document-upload-button-legacy.tsx @@ -3,7 +3,7 @@ import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { AppError } from '@documenso/lib/errors/app-error'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types'; @@ -20,9 +20,9 @@ import { EnvelopeType } from '@prisma/client'; import { useMemo, useState } from 'react'; import type { FileRejection } from 'react-dropzone'; import { useNavigate, useParams } from 'react-router'; -import { match } from 'ts-pattern'; import { useCurrentTeam } from '~/providers/team'; +import { getUploadErrorMessage } from '~/utils/toast-error-messages'; export type DocumentUploadButtonLegacyProps = { className?: string; @@ -130,30 +130,11 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu console.error(err); - const errorMessage = match(error.code) - .with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs.`) - .with( - AppErrorCode.LIMIT_EXCEEDED, - () => msg`You have reached your document limit for this month. Please upgrade your plan.`, - ) - .with( - 'ENVELOPE_ITEM_LIMIT_EXCEEDED', - () => msg`You have reached the limit of the number of files per envelope.`, - ) - .with('UNSUPPORTED_FILE_TYPE', () => msg`This file type isn't supported. Please upload a PDF or Word document.`) - .with( - 'CONVERSION_SERVICE_UNAVAILABLE', - () => msg`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`, - ) - .with( - 'CONVERSION_FAILED', - () => msg`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`, - ) - .otherwise(() => msg`An error occurred while uploading your document.`); + const errorMessage = getUploadErrorMessage(error.code); toast({ - title: _(msg`Error`), - description: _(errorMessage), + title: _(errorMessage.title), + description: _(errorMessage.description), variant: 'destructive', duration: 7500, }); diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx index 7573d5fb4..a5676dc60 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx @@ -14,13 +14,22 @@ import { import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; -import { CommandDialog } from '@documenso/ui/primitives/command'; +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@documenso/ui/primitives/command'; +import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types'; import { useLingui } from '@lingui/react/macro'; import type { FieldType } from '@prisma/client'; import Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; import type { Transformer } from 'konva/lib/shapes/Transformer'; -import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react'; +import { CopyPlusIcon, ShapesIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { fieldButtonList } from './envelope-editor-fields-drag-drop'; @@ -470,6 +479,22 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR } }; + const changeSelectedFieldsType = (type: FieldType) => { + const fields = selectedKonvaFieldGroups + .map((field) => editorFields.getFieldByFormId(field.id())) + .filter((field) => field !== undefined); + + for (const field of fields) { + if (field.type !== type) { + editorFields.updateFieldByFormId(field.formId, { + type, + fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[type]), + id: undefined, + }); + } + } + }; + const duplicatedSelectedFields = () => { const fields = selectedKonvaFieldGroups .map((field) => editorFields.getFieldByFormId(field.id())) @@ -554,6 +579,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages} handleDeleteSelectedFields={deletedSelectedFields} handleChangeRecipient={changeSelectedFieldsRecipients} + handleChangeFieldType={changeSelectedFieldsType} selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())} style={{ position: 'absolute', @@ -602,6 +628,7 @@ type FieldActionButtonsProps = React.HTMLAttributes & { handleDuplicateSelectedFieldsOnAllPages: () => void; handleDeleteSelectedFields: () => void; handleChangeRecipient: (recipientId: number) => void; + handleChangeFieldType: (type: FieldType) => void; selectedFieldFormId: string[]; }; @@ -610,15 +637,40 @@ const FieldActionButtons = ({ handleDuplicateSelectedFieldsOnAllPages, handleDeleteSelectedFields, handleChangeRecipient, + handleChangeFieldType, selectedFieldFormId, ...props }: FieldActionButtonsProps) => { const { t } = useLingui(); const [showRecipientSelector, setShowRecipientSelector] = useState(false); + const [showFieldTypeSelector, setShowFieldTypeSelector] = useState(false); const { editorFields, envelope } = useCurrentEnvelopeEditor(); + /** + * Decide the preselected field type in the command input. + * + * If all fields share the same type, use that as the default selection. + * Otherwise show no preselection. + */ + const preselectedFieldType = useMemo(() => { + if (selectedFieldFormId.length === 0) { + return null; + } + + const fields = editorFields.localFields.filter((field) => selectedFieldFormId.includes(field.formId)); + + if (fields.length === 0) { + return null; + } + + const firstType = fields[0].type; + const isTypesSame = fields.every((field) => field.type === firstType); + + return isTypesSame ? firstType : null; + }, [editorFields.localFields, selectedFieldFormId]); + /** * Decide the preselected recipient in the command input. * @@ -656,6 +708,7 @@ const FieldActionButtons = ({
+ +
); }; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx index 1acfa915d..cff9bab64 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx @@ -21,6 +21,7 @@ import { } from '@documenso/ui/components/recipient/recipient-autocomplete-input'; import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; import { cn } from '@documenso/ui/lib/utils'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; @@ -586,6 +587,9 @@ export const EnvelopeEditorRecipientForm = () => { } }, [formValues]); + const recipientCountLimit = organisation.organisationClaim.recipientCount; + const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit; + return ( @@ -650,6 +654,17 @@ export const EnvelopeEditorRecipientForm = () => { + {isOverRecipientLimit && ( + + + + This envelope cannot have more than {recipientCountLimit} recipients. Please contact support if you need + more. + + + + )} +
; type EnvelopeEditorSettingsDialogProps = { @@ -205,6 +218,8 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit ); const emailSettings = form.watch('meta.emailSettings'); + const distributionMethod = form.watch('meta.distributionMethod'); + const isEmailDistribution = distributionMethod === DocumentDistributionMethod.EMAIL; const { data: emailData, isLoading: isLoadingEmails } = trpc.enterprise.organisation.email.find.useQuery( { @@ -334,7 +349,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit