mirror of
https://github.com/documenso/documenso
synced 2026-04-21 13:27:18 +00:00
Merge branch 'main' into fix/template-use-error-messages
This commit is contained in:
commit
40aabdcb18
102 changed files with 2491 additions and 5360 deletions
|
|
@ -182,6 +182,14 @@ GOOGLE_VERTEX_LOCATION="global"
|
|||
# https://console.cloud.google.com/vertex-ai/studio/settings/api-keys
|
||||
GOOGLE_VERTEX_API_KEY=""
|
||||
|
||||
# [[CLOUDFLARE TURNSTILE]]
|
||||
# OPTIONAL: Cloudflare Turnstile site key (public). When configured, Turnstile challenges
|
||||
# will be shown on sign-up (visible) and sign-in (invisible) pages.
|
||||
# See: https://developers.cloudflare.com/turnstile/
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||
# OPTIONAL: Cloudflare Turnstile secret key (server-side verification).
|
||||
NEXT_PRIVATE_TURNSTILE_SECRET_KEY=
|
||||
|
||||
# [[E2E Tests]]
|
||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||
|
|
|
|||
4
.github/actions/node-install/action.yml
vendored
4
.github/actions/node-install/action.yml
vendored
|
|
@ -12,6 +12,10 @@ runs:
|
|||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
|
||||
- name: Enable corepack
|
||||
shell: bash
|
||||
run: corepack enable npm
|
||||
|
||||
- name: Cache npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -71,3 +71,6 @@ scripts/bench-*
|
|||
|
||||
# tmp
|
||||
tmp/
|
||||
|
||||
# opencode
|
||||
.opencode/package-lock.json
|
||||
|
|
|
|||
1
.npmrc
1
.npmrc
|
|
@ -1,2 +1,3 @@
|
|||
legacy-peer-deps = true
|
||||
prefer-dedupe = true
|
||||
min-release-age = 7
|
||||
|
|
|
|||
|
|
@ -182,6 +182,9 @@ git clone https://github.com/<your-username>/documenso
|
|||
- Optional: Seed the database using `npm run prisma:seed -w @documenso/prisma` to create a test user and document.
|
||||
- Optional: Create your own signing certificate.
|
||||
- To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL), see **[Create your own signing certificate](./SIGNING.md)**.
|
||||
- Optional: Configure job provider for document reminders.
|
||||
- The default local job provider does not support scheduled jobs required for document reminders.
|
||||
- To enable reminders, set `NEXT_PRIVATE_JOBS_PROVIDER=inngest` and provide `NEXT_PRIVATE_INNGEST_EVENT_KEY` in your `.env` file.
|
||||
|
||||
### Run in Gitpod
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
"fumadocs-ui": "16.5.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"next": "16.1.6",
|
||||
"next": "16.2.4",
|
||||
"next-plausible": "^3.12.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"dependencies": {
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.7.2",
|
||||
"next": "15.5.12"
|
||||
"next": "16.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
|
|
@ -22,6 +22,12 @@
|
|||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Recipient, SigningStatus, type Team, type User } from '@prisma/client';
|
||||
import { SigningStatus, type Team, type User } from '@prisma/client';
|
||||
import { History } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
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';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
|
|
@ -45,10 +46,10 @@ const FORM_ID = 'resend-email';
|
|||
export type DocumentResendDialogProps = {
|
||||
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
};
|
||||
|
||||
export const ZResendDocumentFormSchema = z.object({
|
||||
|
|
@ -183,7 +184,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
|||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
className="flex-1 bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, EnvelopeType, type Recipient, SigningStatus } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
|
@ -38,7 +39,7 @@ import { StackAvatar } from '../general/stack-avatar';
|
|||
|
||||
export type EnvelopeRedistributeDialogProps = {
|
||||
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||
recipients: Recipient[];
|
||||
recipients: TEnvelopeRecipientLite[];
|
||||
};
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,15 +26,22 @@ import { useCurrentTeam } from '~/providers/team';
|
|||
type EnvelopeSaveAsTemplateDialogProps = {
|
||||
envelopeId: string;
|
||||
trigger?: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const EnvelopeSaveAsTemplateDialog = ({
|
||||
envelopeId,
|
||||
trigger,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
}: EnvelopeSaveAsTemplateDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = controlledOnOpenChange ?? setInternalOpen;
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Recipient, RecipientRole, type TemplateDirectLink } from '@prisma/client';
|
||||
import { RecipientRole, type TemplateDirectLink } from '@prisma/client';
|
||||
import {
|
||||
CircleDotIcon,
|
||||
CircleIcon,
|
||||
|
|
@ -21,6 +21,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
|
|||
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
|
|
@ -52,7 +53,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||
type TemplateDirectLinkDialogProps = {
|
||||
templateId: number;
|
||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
trigger?: React.ReactNode;
|
||||
onCreateSuccess?: () => Promise<void> | void;
|
||||
onDeleteSuccess?: () => Promise<void> | void;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
|
||||
import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
|
@ -21,7 +20,7 @@ import {
|
|||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
|
@ -79,7 +78,7 @@ export type TemplateUseDialogProps = {
|
|||
envelopeId: string;
|
||||
templateId: number;
|
||||
templateSigningOrder?: DocumentSigningOrder | null;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
documentDistributionMethod?: DocumentDistributionMethod;
|
||||
documentRootPath: string;
|
||||
trigger?: React.ReactNode;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
|
|||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { EnvelopeItem, FieldType } from '@prisma/client';
|
||||
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { ChevronsUpDown } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
|
@ -13,6 +13,7 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
|
|||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
||||
|
|
@ -105,7 +106,7 @@ export const ConfigureFieldsView = ({
|
|||
}, [configData.documentData, envelopeItem, presignToken]);
|
||||
|
||||
const recipients = useMemo(() => {
|
||||
return configData.signers.map<Recipient>((signer, index) => ({
|
||||
return configData.signers.map<TRecipientLite>((signer, index) => ({
|
||||
id: signer.nativeId || index,
|
||||
name: signer.name || '',
|
||||
email: signer.email || '',
|
||||
|
|
@ -128,7 +129,7 @@ export const ConfigureFieldsView = ({
|
|||
}));
|
||||
}, [configData.signers]);
|
||||
|
||||
const [selectedRecipient, setSelectedRecipient] = useState<Recipient | null>(
|
||||
const [selectedRecipient, setSelectedRecipient] = useState<TRecipientLite | null>(
|
||||
() => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null,
|
||||
);
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export function BrandingPreferencesForm({
|
|||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
|
||||
|
||||
setPreviewUrl(logoUrl);
|
||||
setPreviewUrl(logoUrl + '?v=' + Date.now());
|
||||
setHasLoadedPreview(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -173,7 +173,7 @@ export function BrandingPreferencesForm({
|
|||
/>
|
||||
|
||||
<div className="relative flex w-full flex-col gap-y-4">
|
||||
{!isBrandingEnabled && <div className="bg-background/60 absolute inset-0 z-[9998]" />}
|
||||
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
|
@ -185,7 +185,7 @@ export function BrandingPreferencesForm({
|
|||
</FormLabel>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="border-border bg-background relative h-48 w-full overflow-hidden rounded-lg border">
|
||||
<div className="relative h-48 w-full overflow-hidden rounded-lg border border-border bg-background">
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
|
|
@ -193,12 +193,12 @@ export function BrandingPreferencesForm({
|
|||
className="h-full w-full object-contain p-4"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted/20 dark:bg-muted text-muted-foreground relative flex h-full w-full items-center justify-center text-sm">
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-muted/20 text-sm text-muted-foreground dark:bg-muted">
|
||||
<Trans>Please upload a logo</Trans>
|
||||
|
||||
{!hasLoadedPreview && (
|
||||
<div className="bg-muted dark:bg-muted absolute inset-0 z-[999] flex items-center justify-center">
|
||||
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
<div className="absolute inset-0 z-[999] flex items-center justify-center bg-muted dark:bg-muted">
|
||||
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -243,7 +243,7 @@ export function BrandingPreferencesForm({
|
|||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-destructive text-xs"
|
||||
className="text-xs text-destructive"
|
||||
onClick={() => {
|
||||
setPreviewUrl('');
|
||||
onChange(null);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import {
|
|||
type TEnvelopeExpirationPeriod,
|
||||
ZEnvelopeExpirationPeriod,
|
||||
} from '@documenso/lib/constants/envelope-expiration';
|
||||
import {
|
||||
type TEnvelopeReminderSettings,
|
||||
ZEnvelopeReminderSettings,
|
||||
} from '@documenso/lib/constants/envelope-reminder';
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
|
|
@ -32,6 +36,7 @@ import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'
|
|||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
|
|
@ -76,6 +81,7 @@ export type TDocumentPreferencesFormSchema = {
|
|||
delegateDocumentOwnership: boolean | null;
|
||||
aiFeaturesEnabled: boolean | null;
|
||||
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
|
||||
reminderSettings: TEnvelopeReminderSettings | null;
|
||||
};
|
||||
|
||||
type SettingsSubset = Pick<
|
||||
|
|
@ -94,6 +100,7 @@ type SettingsSubset = Pick<
|
|||
| 'delegateDocumentOwnership'
|
||||
| 'aiFeaturesEnabled'
|
||||
| 'envelopeExpirationPeriod'
|
||||
| 'reminderSettings'
|
||||
>;
|
||||
|
||||
export type DocumentPreferencesFormProps = {
|
||||
|
|
@ -134,6 +141,7 @@ export const DocumentPreferencesForm = ({
|
|||
delegateDocumentOwnership: z.boolean().nullable(),
|
||||
aiFeaturesEnabled: z.boolean().nullable(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
|
||||
reminderSettings: ZEnvelopeReminderSettings.nullable(),
|
||||
});
|
||||
|
||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||
|
|
@ -155,6 +163,7 @@ export const DocumentPreferencesForm = ({
|
|||
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
|
||||
reminderSettings: settings.reminderSettings ?? null,
|
||||
},
|
||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||
});
|
||||
|
|
@ -707,6 +716,35 @@ export const DocumentPreferencesForm = ({
|
|||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reminderSettings"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Signing Reminders</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<ReminderSettingsPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls when and how often reminder emails are sent to recipients who have not
|
||||
yet completed signing.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAiFeaturesConfigured && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||
import { KeyRoundIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
|
@ -16,7 +18,8 @@ import { z } from 'zod';
|
|||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
|
|
@ -101,6 +104,10 @@ export const SignInForm = ({
|
|||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
|
||||
const redirectPath = useMemo(() => {
|
||||
|
|
@ -217,6 +224,7 @@ export const SignInForm = ({
|
|||
password,
|
||||
totpCode,
|
||||
backupCode,
|
||||
captchaToken: captchaToken ?? undefined,
|
||||
redirectPath,
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
@ -251,6 +259,10 @@ export const SignInForm = ({
|
|||
AuthenticationErrorCode.InvalidTwoFactorCode,
|
||||
() => msg`The two-factor authentication code provided is incorrect.`,
|
||||
)
|
||||
.with(
|
||||
AppErrorCode.INVALID_CAPTCHA,
|
||||
() => msg`We were unable to verify that you're human. Please try again.`,
|
||||
)
|
||||
.otherwise(() => handleFallbackErrorMessages(error.code));
|
||||
|
||||
toast({
|
||||
|
|
@ -258,6 +270,9 @@ export const SignInForm = ({
|
|||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
turnstileRef.current?.reset();
|
||||
setCaptchaToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -378,6 +393,18 @@ export const SignInForm = ({
|
|||
)}
|
||||
/>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
options={{
|
||||
size: 'invisible',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FaIdCardClip } from 'react-icons/fa6';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
|
|
@ -15,6 +17,7 @@ import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
|||
import { authClient } from '@documenso/auth/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
|
@ -89,6 +92,11 @@ export const SignUpForm = ({
|
|||
|
||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||
|
||||
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
|
|
@ -111,6 +119,7 @@ export const SignUpForm = ({
|
|||
email,
|
||||
password,
|
||||
signature,
|
||||
captchaToken: captchaToken ?? undefined,
|
||||
});
|
||||
|
||||
await navigate(returnTo ? returnTo : '/unverified-account');
|
||||
|
|
@ -139,6 +148,9 @@ export const SignUpForm = ({
|
|||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
turnstileRef.current?.reset();
|
||||
setCaptchaToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -246,13 +258,7 @@ export const SignUpForm = ({
|
|||
className="flex w-full flex-1 flex-col gap-y-4"
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<fieldset
|
||||
className={cn(
|
||||
'flex h-[550px] w-full flex-col gap-y-4',
|
||||
hasSocialAuthEnabled && 'h-[650px]',
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
|
|
@ -324,6 +330,19 @@ export const SignUpForm = ({
|
|||
)}
|
||||
/>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
options={{
|
||||
size: 'flexible',
|
||||
appearance: 'interaction-only',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasSocialAuthEnabled && (
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
|
@ -14,7 +14,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||
import { StackAvatar } from './stack-avatar';
|
||||
|
||||
export type AvatarWithRecipientProps = {
|
||||
recipient: Recipient;
|
||||
recipient: TRecipientLite;
|
||||
documentStatus: DocumentStatus;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
|||
const trpcUtils = trpcReact.useUtils();
|
||||
|
||||
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [isSaveAsTemplateDialogOpen, setSaveAsTemplateDialogOpen] = useState(false);
|
||||
|
||||
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
|
|
@ -135,17 +136,10 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
|||
}
|
||||
/>
|
||||
|
||||
<EnvelopeSaveAsTemplateDialog
|
||||
envelopeId={envelope.id}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<FileOutputIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Save as Template</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuItem onClick={() => setSaveAsTemplateDialogOpen(true)}>
|
||||
<FileOutputIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Save as Template</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<EnvelopeDeleteDialog
|
||||
id={envelope.id}
|
||||
|
|
@ -207,6 +201,12 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
|||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
<EnvelopeSaveAsTemplateDialog
|
||||
envelopeId={envelope.id}
|
||||
open={isSaveAsTemplateDialogOpen}
|
||||
onOpenChange={setSaveAsTemplateDialogOpen}
|
||||
/>
|
||||
|
||||
<EnvelopeRenameDialog
|
||||
id={envelope.id}
|
||||
initialTitle={envelope.title}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
|
|
@ -11,6 +10,7 @@ import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to
|
|||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
|
|
@ -29,7 +29,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||
|
||||
export type DocumentRecipientLinkCopyDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
};
|
||||
|
||||
export const DocumentRecipientLinkCopyDialog = ({
|
||||
|
|
@ -88,7 +88,7 @@ export const DocumentRecipientLinkCopyDialog = ({
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ul className="text-muted-foreground divide-y rounded-lg border">
|
||||
<ul className="divide-y rounded-lg border text-muted-foreground">
|
||||
{recipients.length === 0 && (
|
||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||
<Trans>No recipients</Trans>
|
||||
|
|
@ -99,9 +99,9 @@ export const DocumentRecipientLinkCopyDialog = ({
|
|||
<li key={recipient.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<AvatarWithText
|
||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||
primaryText={<p className="text-sm text-muted-foreground">{recipient.email}</p>}
|
||||
secondaryText={
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
TemplateType,
|
||||
} from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
|
||||
import { BellRingIcon, InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
DOCUMENT_SIGNATURE_TYPES,
|
||||
} from '@documenso/lib/constants/document';
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
|
|
@ -69,6 +70,7 @@ import {
|
|||
DocumentVisibilityTooltip,
|
||||
} from '@documenso/ui/components/document/document-visibility-select';
|
||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
||||
import {
|
||||
TemplateTypeSelect,
|
||||
TemplateTypeTooltip,
|
||||
|
|
@ -145,10 +147,11 @@ export const ZAddSettingsFormSchema = z.object({
|
|||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
reminderSettings: ZEnvelopeReminderSettings.nullish(),
|
||||
}),
|
||||
});
|
||||
|
||||
type EnvelopeEditorSettingsTabType = 'general' | 'email' | 'security';
|
||||
type EnvelopeEditorSettingsTabType = 'general' | 'reminders' | 'email' | 'security';
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
|
|
@ -157,6 +160,12 @@ const tabs = [
|
|||
icon: SettingsIcon,
|
||||
description: msg`Configure document settings and options before sending.`,
|
||||
},
|
||||
{
|
||||
id: 'reminders',
|
||||
title: msg`Reminders`,
|
||||
icon: BellRingIcon,
|
||||
description: msg`Configure signing reminder settings for the document.`,
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: msg`Email`,
|
||||
|
|
@ -222,6 +231,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
|
||||
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
|
||||
envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null,
|
||||
reminderSettings: envelope.documentMeta?.reminderSettings ?? null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -270,6 +280,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||
subject,
|
||||
emailReplyTo,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
} = data.meta;
|
||||
|
||||
const parsedGlobalAccessAuth = z
|
||||
|
|
@ -300,6 +311,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -381,6 +393,10 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||
return null;
|
||||
}
|
||||
|
||||
if (tab.id === 'reminders' && !settings.allowConfigureReminders) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={tab.id}
|
||||
|
|
@ -751,6 +767,44 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||
)}
|
||||
</>
|
||||
))
|
||||
.with(
|
||||
{ activeTab: 'reminders', settings: { allowConfigureReminders: true } },
|
||||
() => (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.reminderSettings"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Signing Reminders</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="max-w-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Configure when and how often reminder emails are sent to
|
||||
recipients who have not yet completed signing. Uses the team
|
||||
default when set to inherit.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<ReminderSettingsPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
)
|
||||
.with(
|
||||
{ activeTab: 'email', settings: { allowConfigureDistribution: true } },
|
||||
() => (
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ import type { I18n } from '@lingui/core';
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { Check, ChevronsUpDown, Info } from 'lucide-react';
|
||||
import { sortBy } from 'remeda';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
|
@ -26,9 +27,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
|||
|
||||
export interface EnvelopeRecipientSelectorProps {
|
||||
className?: string;
|
||||
selectedRecipient: Recipient | null;
|
||||
onSelectedRecipientChange: (recipient: Recipient) => void;
|
||||
recipients: Recipient[];
|
||||
selectedRecipient: TEnvelopeRecipientLite | null;
|
||||
onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
|
||||
recipients: TEnvelopeRecipientLite[];
|
||||
fields: Field[];
|
||||
align?: 'center' | 'end' | 'start';
|
||||
}
|
||||
|
|
@ -46,7 +47,7 @@ export const EnvelopeRecipientSelector = ({
|
|||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||
|
||||
const getRecipientLabel = useCallback(
|
||||
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
(recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
[recipients],
|
||||
);
|
||||
|
||||
|
|
@ -91,9 +92,9 @@ export const EnvelopeRecipientSelector = ({
|
|||
|
||||
interface EnvelopeRecipientSelectorCommandProps {
|
||||
className?: string;
|
||||
selectedRecipient: Recipient | null;
|
||||
onSelectedRecipientChange: (recipient: Recipient) => void;
|
||||
recipients: Recipient[];
|
||||
selectedRecipient: TEnvelopeRecipientLite | null;
|
||||
onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
|
||||
recipients: TEnvelopeRecipientLite[];
|
||||
fields: Field[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
|
@ -109,7 +110,7 @@ export const EnvelopeRecipientSelectorCommand = ({
|
|||
const { t, i18n } = useLingui();
|
||||
|
||||
const recipientsByRole = useCallback(() => {
|
||||
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
|
||||
const recipientsByRole: Record<RecipientRole, TEnvelopeRecipientLite[]> = {
|
||||
CC: [],
|
||||
VIEWER: [],
|
||||
SIGNER: [],
|
||||
|
|
@ -141,7 +142,7 @@ export const EnvelopeRecipientSelectorCommand = ({
|
|||
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
|
||||
[(r) => r.id, 'asc'],
|
||||
),
|
||||
] as [RecipientRole, Recipient[]],
|
||||
] as [RecipientRole, TEnvelopeRecipientLite[]],
|
||||
);
|
||||
}, [recipientsByRole]);
|
||||
|
||||
|
|
@ -156,7 +157,7 @@ export const EnvelopeRecipientSelectorCommand = ({
|
|||
);
|
||||
|
||||
const getRecipientLabel = useCallback(
|
||||
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
(recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
[recipients],
|
||||
);
|
||||
|
||||
|
|
@ -247,7 +248,11 @@ export const EnvelopeRecipientSelectorCommand = ({
|
|||
);
|
||||
};
|
||||
|
||||
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[], i18n: I18n) => {
|
||||
const extractRecipientLabel = (
|
||||
recipient: TEnvelopeRecipientLite,
|
||||
recipients: TEnvelopeRecipientLite[],
|
||||
i18n: I18n,
|
||||
) => {
|
||||
if (recipient.name && recipient.email) {
|
||||
return `${recipient.name} (${recipient.email})`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ import { useMemo } from 'react';
|
|||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type DocumentStatus, type Recipient } from '@prisma/client';
|
||||
import type { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { RecipientStatusType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ import { StackAvatars } from './stack-avatars';
|
|||
|
||||
export type StackAvatarsWithTooltipProps = {
|
||||
documentStatus: DocumentStatus;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
position?: 'top' | 'bottom';
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
|
@ -74,7 +75,7 @@ export const StackAvatarsWithTooltip = ({
|
|||
<h1 className="text-base font-medium">
|
||||
<Trans>Completed</Trans>
|
||||
</h1>
|
||||
{completedRecipients.map((recipient: Recipient) => (
|
||||
{completedRecipients.map((recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
|
|
@ -98,7 +99,7 @@ export const StackAvatarsWithTooltip = ({
|
|||
<h1 className="text-base font-medium">
|
||||
<Trans>Rejected</Trans>
|
||||
</h1>
|
||||
{rejectedRecipients.map((recipient: Recipient) => (
|
||||
{rejectedRecipients.map((recipient) => (
|
||||
<div key={recipient.id} className="my-1 flex items-center gap-2">
|
||||
<StackAvatar
|
||||
first={true}
|
||||
|
|
@ -122,7 +123,7 @@ export const StackAvatarsWithTooltip = ({
|
|||
<h1 className="text-base font-medium">
|
||||
<Trans>Waiting</Trans>
|
||||
</h1>
|
||||
{waitingRecipients.map((recipient: Recipient) => (
|
||||
{waitingRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
|
|
@ -137,7 +138,7 @@ export const StackAvatarsWithTooltip = ({
|
|||
<h1 className="text-base font-medium">
|
||||
<Trans>Opened</Trans>
|
||||
</h1>
|
||||
{openedRecipients.map((recipient: Recipient) => (
|
||||
{openedRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
|
|
@ -152,7 +153,7 @@ export const StackAvatarsWithTooltip = ({
|
|||
<h1 className="text-base font-medium">
|
||||
<Trans>Uncompleted</Trans>
|
||||
</h1>
|
||||
{uncompletedRecipients.map((recipient: Recipient) => (
|
||||
{uncompletedRecipients.map((recipient) => (
|
||||
<AvatarWithRecipient
|
||||
key={recipient.id}
|
||||
recipient={recipient}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,21 @@
|
|||
import React from 'react';
|
||||
|
||||
import type { Recipient } from '@prisma/client';
|
||||
|
||||
import {
|
||||
getExtraRecipientsType,
|
||||
getRecipientType,
|
||||
} from '@documenso/lib/client-only/recipient-type';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
|
||||
export function StackAvatars({ recipients }: { recipients: Recipient[] }) {
|
||||
const renderStackAvatars = (recipients: Recipient[]) => {
|
||||
export function StackAvatars({ recipients }: { recipients: TRecipientLite[] }) {
|
||||
const renderStackAvatars = (recipients: TRecipientLite[]) => {
|
||||
const zIndex = 50;
|
||||
const itemsToRender = recipients.slice(0, 5);
|
||||
const remainingItems = recipients.length - itemsToRender.length;
|
||||
|
||||
return itemsToRender.map((recipient: Recipient, index: number) => {
|
||||
return itemsToRender.map((recipient, index: number) => {
|
||||
const first = index === 0;
|
||||
|
||||
if (index === 4 && remainingItems > 0) {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { PenIcon, PlusIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
export type TemplatePageViewRecipientsProps = {
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
envelopeId: string;
|
||||
templateRootPath: string;
|
||||
readOnly?: boolean;
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export const DocumentsTableActionDropdown = ({
|
|||
const trpcUtils = trpcReact.useUtils();
|
||||
|
||||
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [isSaveAsTemplateDialogOpen, setSaveAsTemplateDialogOpen] = useState(false);
|
||||
|
||||
const recipient = findRecipientByEmail({
|
||||
recipients: row.recipients,
|
||||
|
|
@ -175,17 +176,10 @@ export const DocumentsTableActionDropdown = ({
|
|||
}
|
||||
/>
|
||||
|
||||
<EnvelopeSaveAsTemplateDialog
|
||||
envelopeId={row.envelopeId}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<FileOutputIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Save as Template</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuItem onClick={() => setSaveAsTemplateDialogOpen(true)}>
|
||||
<FileOutputIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Save as Template</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{onMoveDocument && canManageDocument && (
|
||||
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
|
||||
|
|
@ -250,6 +244,12 @@ export const DocumentsTableActionDropdown = ({
|
|||
/>
|
||||
</DropdownMenuContent>
|
||||
|
||||
<EnvelopeSaveAsTemplateDialog
|
||||
envelopeId={row.envelopeId}
|
||||
open={isSaveAsTemplateDialogOpen}
|
||||
onOpenChange={setSaveAsTemplateDialogOpen}
|
||||
/>
|
||||
|
||||
<EnvelopeRenameDialog
|
||||
id={row.envelopeId}
|
||||
initialTitle={row.title}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -44,7 +45,7 @@ export type TemplatesTableActionDropdownProps = {
|
|||
folderId?: string | null;
|
||||
envelopeId: string;
|
||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
};
|
||||
templateRootPath: string;
|
||||
teamId: number;
|
||||
|
|
|
|||
|
|
@ -48,12 +48,17 @@ export default function OrganisationSettingsBrandingPage() {
|
|||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
|
||||
|
||||
let uploadedBrandingLogo: string | undefined = '';
|
||||
let uploadedBrandingLogo: string | undefined = undefined;
|
||||
|
||||
if (brandingLogo) {
|
||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
||||
}
|
||||
|
||||
// Empty the branding logo if the user unsets it.
|
||||
if (brandingLogo === null) {
|
||||
uploadedBrandingLogo = '';
|
||||
}
|
||||
|
||||
await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
} = data;
|
||||
|
||||
if (
|
||||
|
|
@ -93,6 +94,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||
delegateDocumentOwnership: delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
|
||||
reminderSettings: reminderSettings ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -34,12 +34,13 @@ export default function TeamsSettingsPage() {
|
|||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
|
||||
|
||||
let uploadedBrandingLogo = teamWithSettings?.teamSettings?.brandingLogo;
|
||||
let uploadedBrandingLogo: string | undefined = undefined;
|
||||
|
||||
if (brandingLogo) {
|
||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
||||
}
|
||||
|
||||
// Empty the branding logo if the user unsets it.
|
||||
if (brandingLogo === null) {
|
||||
uploadedBrandingLogo = '';
|
||||
}
|
||||
|
|
@ -48,7 +49,7 @@ export default function TeamsSettingsPage() {
|
|||
teamId: team.id,
|
||||
data: {
|
||||
brandingEnabled,
|
||||
brandingLogo: uploadedBrandingLogo || null,
|
||||
brandingLogo: uploadedBrandingLogo,
|
||||
brandingUrl: brandingUrl || null,
|
||||
brandingCompanyDetails: brandingCompanyDetails || null,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export default function TeamsSettingsPage() {
|
|||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
} = data;
|
||||
|
||||
await updateTeamSettings({
|
||||
|
|
@ -70,6 +71,7 @@ export default function TeamsSettingsPage() {
|
|||
defaultRecipients,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
...(signatureTypes.length === 0
|
||||
? {
|
||||
typedSignatureEnabled: null,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { sha256 } from '@documenso/lib/universal/crypto';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { loadLogo } from '@documenso/lib/utils/images/logo';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { Route } from './+types/branding.logo.organisation.$orgId';
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const CACHE_CONTROL = 'public, max-age=0, stale-while-revalidate=86400';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const organisationId = params.orgId;
|
||||
|
||||
if (!organisationId) {
|
||||
|
|
@ -48,6 +51,18 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
);
|
||||
}
|
||||
|
||||
const etag = `"${Buffer.from(sha256(settings.brandingLogo)).toString('hex')}"`;
|
||||
|
||||
if (request.headers.get('If-None-Match') === etag) {
|
||||
return new Response(null, {
|
||||
status: 304,
|
||||
headers: {
|
||||
ETag: etag,
|
||||
'Cache-Control': CACHE_CONTROL,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const file = await getFileServerSide(JSON.parse(settings.brandingLogo)).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
|
|
@ -68,8 +83,8 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': content.length.toString(),
|
||||
// Stale while revalidate for 1 hours to 24 hours
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
||||
'Cache-Control': CACHE_CONTROL,
|
||||
ETag: etag,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||
import { sha256 } from '@documenso/lib/universal/crypto';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { loadLogo } from '@documenso/lib/utils/images/logo';
|
||||
|
||||
import type { Route } from './+types/branding.logo.team.$teamId';
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const CACHE_CONTROL = 'public, max-age=0, stale-while-revalidate=86400';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const teamId = Number(params.teamId);
|
||||
|
||||
if (teamId === 0 || Number.isNaN(teamId)) {
|
||||
|
|
@ -41,6 +44,18 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
);
|
||||
}
|
||||
|
||||
const etag = `"${Buffer.from(sha256(settings.brandingLogo)).toString('hex')}"`;
|
||||
|
||||
if (request.headers.get('If-None-Match') === etag) {
|
||||
return new Response(null, {
|
||||
status: 304,
|
||||
headers: {
|
||||
ETag: etag,
|
||||
'Cache-Control': CACHE_CONTROL,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const file = await getFileServerSide(JSON.parse(settings.brandingLogo)).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
|
|
@ -61,8 +76,8 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': content.length.toString(),
|
||||
// Stale while revalidate for 1 hours to 24 hours
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
||||
'Cache-Control': CACHE_CONTROL,
|
||||
ETag: etag,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
"colord": "^2.9.3",
|
||||
"content-disposition": "^1.0.1",
|
||||
"framer-motion": "^12.23.24",
|
||||
"hono": "^4.12.5",
|
||||
"hono": "^4.12.14",
|
||||
"hono-react-router-adapter": "^0.6.5",
|
||||
"input-otp": "^1.4.2",
|
||||
"isbot": "^5.1.32",
|
||||
|
|
@ -106,5 +106,5 @@
|
|||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.8.1"
|
||||
"version": "2.9.0"
|
||||
}
|
||||
|
|
|
|||
5896
package-lock.json
generated
5896
package-lock.json
generated
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
|
@ -5,7 +5,7 @@
|
|||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.8.1",
|
||||
"version": "2.9.0",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"build": "turbo run build",
|
||||
|
|
@ -41,9 +41,9 @@
|
|||
"translate:extract": "lingui extract --clean",
|
||||
"translate:compile": "lingui compile"
|
||||
},
|
||||
"packageManager": "npm@10.7.0",
|
||||
"packageManager": "npm@11.11.0",
|
||||
"engines": {
|
||||
"npm": ">=10.7.0",
|
||||
"npm": ">=11.11.0",
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -62,10 +62,11 @@
|
|||
"dotenv-cli": "^11.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"husky": "^9.1.7",
|
||||
"inngest-cli": "^1.16.1",
|
||||
"inngest": "^3.54.0",
|
||||
"inngest-cli": "^1.17.9",
|
||||
"lint-staged": "^16.2.7",
|
||||
"nanoid": "^5.1.6",
|
||||
"nodemailer": "^7.0.10",
|
||||
"nodemailer": "^8.0.5",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
|
|
@ -98,10 +99,11 @@
|
|||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
"typescript": "5.6.2",
|
||||
"@marsidev/react-turnstile": "^1.5.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"overrides": {
|
||||
"lodash": "4.17.23",
|
||||
"lodash": "4.18.1",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"typescript": "5.6.2",
|
||||
"zod": "$zod",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ const TEST_SETTINGS_VALUES = {
|
|||
expirationMode: 'Custom duration',
|
||||
expirationAmount: 5,
|
||||
expirationUnit: 'Weeks',
|
||||
reminderMode: 'Enabled',
|
||||
reminderSendAfterAmount: 3,
|
||||
reminderSendAfterUnit: 'Days',
|
||||
reminderRepeatMode: 'Custom interval',
|
||||
reminderRepeatAmount: 7,
|
||||
reminderRepeatUnit: 'Days',
|
||||
accessAuth: 'Require account',
|
||||
actionAuth: 'Require password',
|
||||
visibility: 'Managers and above',
|
||||
|
|
@ -42,6 +48,10 @@ const DB_EXPECTED_VALUES = {
|
|||
timezone: 'Europe/London',
|
||||
distributionMethod: DocumentDistributionMethod.NONE,
|
||||
envelopeExpirationPeriod: { unit: 'week', amount: 5 },
|
||||
reminderSettings: {
|
||||
sendAfter: { unit: 'day', amount: 3 },
|
||||
repeatEvery: { unit: 'day', amount: 7 },
|
||||
},
|
||||
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
globalAccessAuth: ['ACCOUNT'],
|
||||
globalActionAuth: ['PASSWORD'],
|
||||
|
|
@ -130,6 +140,66 @@ const runSettingsFlow = async (
|
|||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.expirationUnit }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
// Configure reminder settings.
|
||||
await root.getByRole('button', { name: 'Reminders' }).click();
|
||||
|
||||
await root.locator('[data-testid="reminder-mode-select"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderMode }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.locator('[data-testid="reminder-send-after-amount"]').clear();
|
||||
await root
|
||||
.locator('[data-testid="reminder-send-after-amount"]')
|
||||
.fill(String(TEST_SETTINGS_VALUES.reminderSendAfterAmount));
|
||||
|
||||
await root.locator('[data-testid="reminder-send-after-unit"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderSendAfterUnit }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.locator('[data-testid="reminder-repeat-mode-select"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatMode }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.locator('[data-testid="reminder-repeat-amount"]').clear();
|
||||
await root
|
||||
.locator('[data-testid="reminder-repeat-amount"]')
|
||||
.fill(String(TEST_SETTINGS_VALUES.reminderRepeatAmount));
|
||||
|
||||
await root.locator('[data-testid="reminder-repeat-unit"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatUnit }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
const spinbuttons = root.getByRole('spinbutton');
|
||||
await spinbuttons.first().clear();
|
||||
await spinbuttons.first().fill(String(TEST_SETTINGS_VALUES.reminderSendAfterAmount));
|
||||
|
||||
const sendAfterUnitTrigger = root
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Days|Weeks|Months/ })
|
||||
.first();
|
||||
await sendAfterUnitTrigger.click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderSendAfterUnit }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
const repeatModeSelect = root
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Custom interval|Don't repeat/ })
|
||||
.first();
|
||||
await repeatModeSelect.click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatMode }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await spinbuttons.last().clear();
|
||||
await spinbuttons.last().fill(String(TEST_SETTINGS_VALUES.reminderRepeatAmount));
|
||||
|
||||
const repeatUnitTrigger = root
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Days|Weeks|Months/ })
|
||||
.last();
|
||||
await repeatUnitTrigger.click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatUnit }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.getByRole('button', { name: 'Email' }).click();
|
||||
await root.locator('#recipientSigned').click();
|
||||
await root.locator('#recipientSigningRequest').click();
|
||||
|
|
@ -200,6 +270,27 @@ const runSettingsFlow = async (
|
|||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Verify reminder settings persisted in UI.
|
||||
await root.getByRole('button', { name: 'Reminders' }).click();
|
||||
await expect(root.locator('[data-testid="reminder-mode-select"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.reminderMode,
|
||||
);
|
||||
await expect(root.locator('[data-testid="reminder-send-after-amount"]')).toHaveValue(
|
||||
String(TEST_SETTINGS_VALUES.reminderSendAfterAmount),
|
||||
);
|
||||
await expect(root.locator('[data-testid="reminder-send-after-unit"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.reminderSendAfterUnit,
|
||||
);
|
||||
await expect(root.locator('[data-testid="reminder-repeat-mode-select"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.reminderRepeatMode,
|
||||
);
|
||||
await expect(root.locator('[data-testid="reminder-repeat-amount"]')).toHaveValue(
|
||||
String(TEST_SETTINGS_VALUES.reminderRepeatAmount),
|
||||
);
|
||||
await expect(root.locator('[data-testid="reminder-repeat-unit"]')).toContainText(
|
||||
TEST_SETTINGS_VALUES.reminderRepeatUnit,
|
||||
);
|
||||
|
||||
await root.getByRole('button', { name: 'Email' }).click();
|
||||
await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#recipientSigningRequest')).toHaveAttribute('aria-checked', 'false');
|
||||
|
|
@ -285,6 +376,7 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({
|
|||
expect(envelope.documentMeta.envelopeExpirationPeriod).toEqual(
|
||||
DB_EXPECTED_VALUES.envelopeExpirationPeriod,
|
||||
);
|
||||
expect(envelope.documentMeta.reminderSettings).toEqual(DB_EXPECTED_VALUES.reminderSettings);
|
||||
expect(envelope.documentMeta.redirectUrl).toBe(TEST_SETTINGS_VALUES.redirectUrl);
|
||||
expect(envelope.documentMeta.emailReplyTo).toBe(TEST_SETTINGS_VALUES.replyTo);
|
||||
expect(envelope.documentMeta.subject).toBe(TEST_SETTINGS_VALUES.subject);
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
|
|||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
|
||||
// Change the amount to 2.
|
||||
const amountInput = page.getByRole('spinbutton');
|
||||
const amountInput = page.getByTestId('envelope-expiration-amount');
|
||||
await amountInput.clear();
|
||||
await amountInput.fill('2');
|
||||
|
||||
|
|
@ -33,9 +33,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
|
|||
// In the duration mode, there's a mode select and a unit select.
|
||||
// The unit select is inside the duration row, after the number input.
|
||||
// Let's find the select trigger that contains the unit text.
|
||||
const unitTrigger = page
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Months|Days|Weeks|Years/ });
|
||||
const unitTrigger = page.getByTestId('envelope-expiration-unit');
|
||||
|
||||
await unitTrigger.click();
|
||||
await page.getByRole('option', { name: 'Weeks' }).click();
|
||||
|
|
@ -65,9 +63,7 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({
|
|||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
|
||||
// Find the mode select (shows "Custom duration") and change to "Never expires".
|
||||
const modeTrigger = page
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: 'Custom duration' });
|
||||
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
||||
await modeTrigger.click();
|
||||
await page.getByRole('option', { name: 'Never expires' }).click();
|
||||
|
||||
|
|
@ -118,11 +114,8 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
|
|||
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
|
||||
// Scope to the "Default Envelope Expiration" form field section.
|
||||
const expirationSection = page.getByText('Default Envelope Expiration').locator('..');
|
||||
|
||||
// The expiration picker mode select should show "Inherit from organisation" by default.
|
||||
const modeTrigger = expirationSection.locator('button[role="combobox"]').first();
|
||||
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
||||
await expect(modeTrigger).toBeVisible();
|
||||
|
||||
// Switch to custom duration.
|
||||
|
|
@ -130,13 +123,11 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
|
|||
await page.getByRole('option', { name: 'Custom duration' }).click();
|
||||
|
||||
// Set to 5 days.
|
||||
const amountInput = expirationSection.getByRole('spinbutton');
|
||||
const amountInput = page.getByTestId('envelope-expiration-amount');
|
||||
await amountInput.clear();
|
||||
await amountInput.fill('5');
|
||||
|
||||
const unitTrigger = expirationSection
|
||||
.locator('button[role="combobox"]')
|
||||
.filter({ hasText: /Months|Days|Weeks|Years/ });
|
||||
const unitTrigger = page.getByTestId('envelope-expiration-unit');
|
||||
await unitTrigger.click();
|
||||
await page.getByRole('option', { name: 'Days' }).click();
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
"@oslojs/encoding": "^1.1.0",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"arctic": "^3.7.0",
|
||||
"hono": "^4.12.5",
|
||||
"hono": "^4.12.14",
|
||||
"luxon": "^3.7.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"ts-pattern": "^5.9.0",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { isTwoFactorAuthenticationEnabled } from '@documenso/lib/server-only/2fa
|
|||
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
|
||||
import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa';
|
||||
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
|
||||
import { verifyCaptchaToken } from '@documenso/lib/server-only/captcha/verify-captcha';
|
||||
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
|
||||
import {
|
||||
forgotPasswordRateLimit,
|
||||
|
|
@ -60,7 +61,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
|||
.post('/authorize', sValidator('json', ZSignInSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const { email, password, totpCode, backupCode, csrfToken } = c.req.valid('json');
|
||||
const { email, password, totpCode, backupCode, csrfToken, captchaToken } = c.req.valid('json');
|
||||
|
||||
const loginLimitResult = await loginRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
|
|
@ -84,6 +85,11 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
|||
});
|
||||
}
|
||||
|
||||
await verifyCaptchaToken({
|
||||
token: captchaToken,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
});
|
||||
|
||||
if (
|
||||
email.toLowerCase() === legacyServiceAccountEmail() ||
|
||||
email.toLowerCase() === deletedServiceAccountEmail()
|
||||
|
|
@ -188,7 +194,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
|||
});
|
||||
}
|
||||
|
||||
const { name, email, password, signature } = c.req.valid('json');
|
||||
const { name, email, password, signature, captchaToken } = c.req.valid('json');
|
||||
|
||||
const signupLimitResult = await signupRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
|
|
@ -202,6 +208,11 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
|||
});
|
||||
}
|
||||
|
||||
await verifyCaptchaToken({
|
||||
token: captchaToken,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
});
|
||||
|
||||
if (!isEmailDomainAllowedForSignup(email)) {
|
||||
throw new AppError(AuthenticationErrorCode.SignupDisabled, {
|
||||
statusCode: 400,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export const ZSignInSchema = z.object({
|
|||
totpCode: z.string().trim().optional(),
|
||||
backupCode: z.string().trim().optional(),
|
||||
csrfToken: z.string().trim(),
|
||||
captchaToken: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export type TSignInSchema = z.infer<typeof ZSignInSchema>;
|
||||
|
|
@ -39,6 +40,7 @@ export const ZSignUpSchema = z.object({
|
|||
email: zEmail(),
|
||||
password: ZPasswordSchema,
|
||||
signature: z.string().nullish(),
|
||||
captchaToken: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export type TSignUpSchema = z.infer<typeof ZSignUpSchema>;
|
||||
|
|
|
|||
|
|
@ -37,12 +37,12 @@
|
|||
"@react-email/section": "0.0.16",
|
||||
"@react-email/tailwind": "^2.0.1",
|
||||
"@react-email/text": "0.1.5",
|
||||
"nodemailer": "^7.0.10",
|
||||
"nodemailer": "^8.0.5",
|
||||
"react-email": "^5.0.6",
|
||||
"resend": "^6.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tsconfig": "*",
|
||||
"@types/nodemailer": "^7.0.4"
|
||||
"@types/nodemailer": "^8.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export interface TemplateDocumentReminderProps {
|
||||
recipientName: string;
|
||||
documentName: string;
|
||||
signDocumentLink: string;
|
||||
assetBaseUrl: string;
|
||||
role: RecipientRole;
|
||||
}
|
||||
|
||||
export const TemplateDocumentReminder = ({
|
||||
recipientName,
|
||||
documentName,
|
||||
signDocumentLink,
|
||||
assetBaseUrl,
|
||||
role,
|
||||
}: TemplateDocumentReminderProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
<Trans>
|
||||
Reminder: Please {_(actionVerb).toLowerCase()} your document
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Trans>Hi {recipientName},</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Continue by signing the document.</Trans>)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Continue by approving the document.</Trans>)
|
||||
.with(RecipientRole.CC, () => '')
|
||||
.with(RecipientRole.ASSISTANT, () => (
|
||||
<Trans>Continue by assisting with the document.</Trans>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={signDocumentLink}
|
||||
>
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||
.with(RecipientRole.CC, () => '')
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateDocumentReminder;
|
||||
91
packages/email/templates/document-reminder.tsx
Normal file
91
packages/email/templates/document-reminder.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateCustomMessageBody } from '../template-components/template-custom-message-body';
|
||||
import { TemplateDocumentReminder } from '../template-components/template-document-reminder';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export type DocumentReminderEmailTemplateProps = {
|
||||
recipientName: string;
|
||||
documentName: string;
|
||||
signDocumentLink: string;
|
||||
assetBaseUrl?: string;
|
||||
customBody?: string;
|
||||
role: RecipientRole;
|
||||
};
|
||||
|
||||
export const DocumentReminderEmailTemplate = ({
|
||||
recipientName = 'John Doe',
|
||||
documentName = 'Open Source Pledge.pdf',
|
||||
signDocumentLink = 'https://documenso.com',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
customBody,
|
||||
role = RecipientRole.SIGNER,
|
||||
}: DocumentReminderEmailTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const action = _(RECIPIENT_ROLES_DESCRIPTION[role].actionVerb).toLowerCase();
|
||||
|
||||
const previewText = msg`Reminder to ${action} ${documentName}`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateDocumentReminder
|
||||
recipientName={recipientName}
|
||||
documentName={documentName}
|
||||
signDocumentLink={signDocumentLink}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
role={role}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
{customBody && (
|
||||
<Container className="mx-auto mt-12 max-w-xl">
|
||||
<Section>
|
||||
<Text className="mt-2 text-base text-slate-400">
|
||||
<TemplateCustomMessageBody text={customBody} />
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
)}
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentReminderEmailTemplate;
|
||||
|
|
@ -54,13 +54,11 @@ export class MailChannelsTransport implements Transport<SentMessageInfo> {
|
|||
const mailCc = this.toMailChannelsAddresses(mail.data.cc);
|
||||
const mailBcc = this.toMailChannelsAddresses(mail.data.bcc);
|
||||
|
||||
const from: MailChannelsAddress =
|
||||
typeof mail.data.from === 'string'
|
||||
? { email: mail.data.from }
|
||||
: {
|
||||
email: mail.data.from?.address,
|
||||
name: mail.data.from?.name,
|
||||
};
|
||||
const [from] = this.toMailChannelsAddresses(mail.data.from);
|
||||
|
||||
if (!from) {
|
||||
return callback(new Error('Missing required field "from"'), null);
|
||||
}
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
module.exports = {
|
||||
extends: ['next', 'turbo', 'eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
extends: ['turbo', 'eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
|
||||
plugins: ['unused-imports'],
|
||||
|
||||
|
|
@ -22,8 +22,7 @@ module.exports = {
|
|||
},
|
||||
|
||||
rules: {
|
||||
'@next/next/no-html-link-for-pages': 'off',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
// 'react/no-unescaped-entities': 'off',
|
||||
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^15",
|
||||
"eslint-config-turbo": "^1.13.4",
|
||||
"eslint-plugin-package-json": "^0.85.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
|
@ -61,7 +61,7 @@ type UseEditorFieldsResponse = {
|
|||
getFieldsByRecipient: (recipientId: number) => TLocalField[];
|
||||
|
||||
// Selected recipient
|
||||
selectedRecipient: Recipient | null;
|
||||
selectedRecipient: TEditorEnvelope['recipients'][number] | null;
|
||||
setSelectedRecipient: (recipientId: number | null) => void;
|
||||
|
||||
resetForm: (fields?: Field[]) => void;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useId } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { DocumentSigningOrder, type Recipient, RecipientRole } from '@prisma/client';
|
||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { prop, sortBy } from 'remeda';
|
||||
|
|
@ -39,7 +39,7 @@ type EditorRecipientsProps = {
|
|||
};
|
||||
|
||||
type ResetFormOptions = {
|
||||
recipients?: Recipient[];
|
||||
recipients?: TEditorEnvelope['recipients'];
|
||||
documentMeta?: TEditorEnvelope['documentMeta'];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
type RecipientForType = Pick<Recipient, 'role' | 'signingStatus' | 'readStatus' | 'sendStatus'>;
|
||||
|
||||
export enum RecipientStatusType {
|
||||
COMPLETED = 'completed',
|
||||
OPENED = 'opened',
|
||||
|
|
@ -16,7 +18,7 @@ export enum RecipientStatusType {
|
|||
}
|
||||
|
||||
export const getRecipientType = (
|
||||
recipient: Recipient,
|
||||
recipient: RecipientForType,
|
||||
distributionMethod: DocumentDistributionMethod = DocumentDistributionMethod.EMAIL,
|
||||
) => {
|
||||
if (recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED) {
|
||||
|
|
@ -45,7 +47,7 @@ export const getRecipientType = (
|
|||
return RecipientStatusType.UNSIGNED;
|
||||
};
|
||||
|
||||
export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
|
||||
export const getExtraRecipientsType = (extraRecipients: RecipientForType[]) => {
|
||||
const types = extraRecipients.map((r) => getRecipientType(r));
|
||||
|
||||
if (types.includes(RecipientStatusType.UNSIGNED)) {
|
||||
|
|
|
|||
|
|
@ -19,4 +19,7 @@ export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
|
|||
[DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: {
|
||||
description: 'Document completed',
|
||||
},
|
||||
[DOCUMENT_EMAIL_TYPE.REMINDER]: {
|
||||
description: 'Signing Reminder',
|
||||
},
|
||||
} satisfies Record<keyof typeof DOCUMENT_EMAIL_TYPE, unknown>;
|
||||
|
|
|
|||
86
packages/lib/constants/envelope-reminder.ts
Normal file
86
packages/lib/constants/envelope-reminder.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { DurationLikeObject } from 'luxon';
|
||||
import { Duration } from 'luxon';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZEnvelopeReminderDurationPeriod = z.object({
|
||||
unit: z.enum(['day', 'week', 'month']),
|
||||
amount: z.number().int().min(1),
|
||||
});
|
||||
|
||||
export const ZEnvelopeReminderDisabledPeriod = z.object({
|
||||
disabled: z.literal(true),
|
||||
});
|
||||
|
||||
export const ZEnvelopeReminderPeriod = z.union([
|
||||
ZEnvelopeReminderDurationPeriod,
|
||||
ZEnvelopeReminderDisabledPeriod,
|
||||
]);
|
||||
|
||||
export type TEnvelopeReminderPeriod = z.infer<typeof ZEnvelopeReminderPeriod>;
|
||||
export type TEnvelopeReminderDurationPeriod = z.infer<typeof ZEnvelopeReminderDurationPeriod>;
|
||||
|
||||
export const ZEnvelopeReminderSettings = z.object({
|
||||
sendAfter: ZEnvelopeReminderPeriod,
|
||||
repeatEvery: ZEnvelopeReminderPeriod,
|
||||
});
|
||||
|
||||
export type TEnvelopeReminderSettings = z.infer<typeof ZEnvelopeReminderSettings>;
|
||||
|
||||
export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = {
|
||||
sendAfter: { unit: 'day', amount: 5 },
|
||||
repeatEvery: { unit: 'day', amount: 2 },
|
||||
};
|
||||
|
||||
const UNIT_TO_LUXON_KEY: Record<TEnvelopeReminderDurationPeriod['unit'], keyof DurationLikeObject> =
|
||||
{
|
||||
day: 'days',
|
||||
week: 'weeks',
|
||||
month: 'months',
|
||||
};
|
||||
|
||||
export const getEnvelopeReminderDuration = (period: TEnvelopeReminderDurationPeriod): Duration => {
|
||||
return Duration.fromObject({ [UNIT_TO_LUXON_KEY[period.unit]]: period.amount });
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the next reminder timestamp from the config and the last reminder sent time.
|
||||
*
|
||||
* - `null` config means reminders are disabled (inherit = no override, resolved as disabled).
|
||||
* - `{ sendAfter: { disabled: true }, ... }` means never send the first reminder.
|
||||
* - `{ repeatEvery: { disabled: true }, ... }` means don't repeat after the first reminder.
|
||||
*
|
||||
* `sentAt` is when the signing request was sent to this specific recipient.
|
||||
*
|
||||
* Returns the next Date the reminder should be sent, or null if no reminder should be sent.
|
||||
*/
|
||||
export const resolveNextReminderAt = (options: {
|
||||
config: TEnvelopeReminderSettings | null;
|
||||
sentAt: Date;
|
||||
lastReminderSentAt: Date | null;
|
||||
}): Date | null => {
|
||||
const { config, sentAt, lastReminderSentAt } = options;
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we haven't sent the first reminder yet, use sendAfter.
|
||||
if (!lastReminderSentAt) {
|
||||
if ('disabled' in config.sendAfter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const delay = getEnvelopeReminderDuration(config.sendAfter);
|
||||
|
||||
return new Date(sentAt.getTime() + delay.toMillis());
|
||||
}
|
||||
|
||||
// For subsequent reminders, use repeatEvery.
|
||||
if ('disabled' in config.repeatEvery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const interval = getEnvelopeReminderDuration(config.repeatEvery);
|
||||
|
||||
return new Date(lastReminderSentAt.getTime() + interval.toMillis());
|
||||
};
|
||||
|
|
@ -13,6 +13,7 @@ export enum AppErrorCode {
|
|||
'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED',
|
||||
'NOT_FOUND' = 'NOT_FOUND',
|
||||
'NOT_SETUP' = 'NOT_SETUP',
|
||||
'INVALID_CAPTCHA' = 'INVALID_CAPTCHA',
|
||||
'UNAUTHORIZED' = 'UNAUTHORIZED',
|
||||
'UNKNOWN_ERROR' = 'UNKNOWN_ERROR',
|
||||
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
|
||||
|
|
@ -29,6 +30,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
|||
[AppErrorCode.EXPIRED_CODE]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.INVALID_BODY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.INVALID_REQUEST]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.INVALID_CAPTCHA]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
|
||||
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/clean
|
|||
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
|
||||
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
|
||||
import { PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION } from './definitions/internal/process-recipient-expired';
|
||||
import { PROCESS_SIGNING_REMINDER_JOB_DEFINITION } from './definitions/internal/process-signing-reminder';
|
||||
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
|
||||
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
|
||||
import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
|
||||
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
|
||||
|
||||
/**
|
||||
|
|
@ -43,6 +45,8 @@ export const jobsClient = new JobClient([
|
|||
EXECUTE_WEBHOOK_JOB_DEFINITION,
|
||||
EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION,
|
||||
PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION,
|
||||
SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION,
|
||||
PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
|
||||
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
||||
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
||||
] as const);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
} from '../../../constants/recipient-roles';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
|
||||
|
|
@ -206,6 +207,8 @@ export const run = async ({
|
|||
});
|
||||
}
|
||||
|
||||
const sentAt = new Date();
|
||||
|
||||
await io.runTask('update-recipient', async () => {
|
||||
await prisma.recipient.update({
|
||||
where: {
|
||||
|
|
@ -213,26 +216,33 @@ export const run = async ({
|
|||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
sentAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await io.runTask('store-audit-log', async () => {
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
envelopeId: envelope.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientId: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
recipientEmail: recipient.email,
|
||||
recipientRole: recipient.role,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
// Compute the first reminder time based on the envelope's effective settings.
|
||||
await updateRecipientNextReminder({
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
sentAt,
|
||||
lastReminderSentAt: null,
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
envelopeId: envelope.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientId: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
recipientEmail: recipient.email,
|
||||
recipientRole: recipient.role,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import {
|
||||
DocumentDistributionMethod,
|
||||
DocumentStatus,
|
||||
OrganisationType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentReminderEmailTemplate from '@documenso/email/templates/document-reminder';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
|
||||
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
} from '../../../types/webhook-payload';
|
||||
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
|
||||
import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TProcessSigningReminderJobDefinition } from './process-signing-reminder';
|
||||
|
||||
export const run = async ({
|
||||
payload,
|
||||
io,
|
||||
}: {
|
||||
payload: TProcessSigningReminderJobDefinition;
|
||||
io: JobRunIO;
|
||||
}) => {
|
||||
const { recipientId } = payload;
|
||||
const now = new Date();
|
||||
|
||||
// Atomically claim this reminder by setting lastReminderSentAt and clearing
|
||||
// nextReminderAt so no other sweep picks it up.
|
||||
const updatedCount = await prisma.recipient.updateMany({
|
||||
where: {
|
||||
id: recipientId,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
role: { not: RecipientRole.CC },
|
||||
envelope: {
|
||||
status: DocumentStatus.PENDING,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
lastReminderSentAt: now,
|
||||
nextReminderAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedCount.count === 0) {
|
||||
io.logger.info(`Recipient ${recipientId} no longer eligible for reminder, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: { id: recipientId },
|
||||
include: {
|
||||
envelope: {
|
||||
include: {
|
||||
documentMeta: true,
|
||||
user: true,
|
||||
recipients: true,
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
io.logger.warn(`Recipient ${recipientId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { envelope } = recipient;
|
||||
|
||||
if (!envelope.documentMeta) {
|
||||
io.logger.warn(`Envelope ${envelope.id} missing documentMeta`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if distribution method is NONE (manual link sharing, no emails).
|
||||
if (envelope.documentMeta.distributionMethod === DocumentDistributionMethod.NONE) {
|
||||
io.logger.info(`Envelope ${envelope.id} uses manual distribution, skipping email reminder`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientSigningRequest) {
|
||||
io.logger.info(`Envelope ${envelope.id} has email signing requests disabled, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
|
||||
await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
const recipientActionVerb = i18n
|
||||
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
|
||||
.toLowerCase();
|
||||
|
||||
let emailSubject = i18n._(
|
||||
msg`Reminder: Please ${recipientActionVerb} the document "${envelope.title}"`,
|
||||
);
|
||||
|
||||
if (organisationType === OrganisationType.ORGANISATION) {
|
||||
emailSubject = i18n._(
|
||||
msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`,
|
||||
);
|
||||
}
|
||||
|
||||
const customEmailTemplate = {
|
||||
'signer.name': recipient.name,
|
||||
'signer.email': recipient.email,
|
||||
'document.name': envelope.title,
|
||||
};
|
||||
|
||||
if (envelope.documentMeta.subject) {
|
||||
emailSubject = renderCustomEmailTemplate(
|
||||
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
|
||||
customEmailTemplate,
|
||||
);
|
||||
}
|
||||
|
||||
const emailMessage = envelope.documentMeta.message
|
||||
? renderCustomEmailTemplate(envelope.documentMeta.message, customEmailTemplate)
|
||||
: undefined;
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||
|
||||
io.logger.info(
|
||||
`Sending signing reminder for envelope ${envelope.id} to recipient ${recipient.id} (${recipient.email})`,
|
||||
);
|
||||
|
||||
const template = createElement(DocumentReminderEmailTemplate, {
|
||||
recipientName: recipient.name,
|
||||
documentName: envelope.title,
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: emailMessage,
|
||||
role: recipient.role,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: emailSubject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
emailType: DOCUMENT_EMAIL_TYPE.REMINDER,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
|
||||
// Compute the next reminder time (repeat interval).
|
||||
if (recipient.sentAt) {
|
||||
await updateRecipientNextReminder({
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
sentAt: recipient.sentAt,
|
||||
lastReminderSentAt: now,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID = 'internal.process-signing-reminder';
|
||||
|
||||
const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA = z.object({
|
||||
recipientId: z.number(),
|
||||
});
|
||||
|
||||
export type TProcessSigningReminderJobDefinition = z.infer<
|
||||
typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const PROCESS_SIGNING_REMINDER_JOB_DEFINITION = {
|
||||
id: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
|
||||
name: 'Process Signing Reminder',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
|
||||
schema: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./process-signing-reminder.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
|
||||
TProcessSigningReminderJobDefinition
|
||||
>;
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { jobs } from '../../client';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendSigningRemindersSweepJobDefinition } from './send-signing-reminders-sweep';
|
||||
|
||||
export const run = async ({
|
||||
io,
|
||||
}: {
|
||||
payload: TSendSigningRemindersSweepJobDefinition;
|
||||
io: JobRunIO;
|
||||
}) => {
|
||||
const now = new Date();
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
nextReminderAt: { lte: now },
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
role: { not: RecipientRole.CC },
|
||||
envelope: {
|
||||
status: DocumentStatus.PENDING,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
take: 1000,
|
||||
});
|
||||
|
||||
if (recipients.length === 0) {
|
||||
io.logger.info('No recipients need signing reminders');
|
||||
return;
|
||||
}
|
||||
|
||||
io.logger.info(`Found ${recipients.length} recipients needing signing reminders`);
|
||||
|
||||
await Promise.allSettled(
|
||||
recipients.map(async (recipient) => {
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.process-signing-reminder',
|
||||
payload: {
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID = 'internal.send-signing-reminders-sweep';
|
||||
|
||||
const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA = z.object({});
|
||||
|
||||
export type TSendSigningRemindersSweepJobDefinition = z.infer<
|
||||
typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION = {
|
||||
id: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
|
||||
name: 'Send Signing Reminders Sweep',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
|
||||
schema: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA,
|
||||
cron: '*/15 * * * *', // Every 15 minutes.
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-signing-reminders-sweep.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
|
||||
TSendSigningRemindersSweepJobDefinition
|
||||
>;
|
||||
|
|
@ -46,11 +46,11 @@
|
|||
"ai": "^5.0.104",
|
||||
"bullmq": "^5.71.1",
|
||||
"csv-parse": "^6.1.0",
|
||||
"inngest": "^3.45.1",
|
||||
"inngest": "^3.54.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"jose": "^6.1.2",
|
||||
"konva": "^10.0.9",
|
||||
"kysely": "0.28.8",
|
||||
"kysely": "0.28.16",
|
||||
"luxon": "^3.7.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"oslo": "^0.17.0",
|
||||
|
|
|
|||
107
packages/lib/server-only/captcha/verify-captcha.ts
Normal file
107
packages/lib/server-only/captcha/verify-captcha.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
type TurnstileVerifyResponse = {
|
||||
success: boolean;
|
||||
'error-codes': string[];
|
||||
challenge_ts?: string;
|
||||
hostname?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a captcha token server-side.
|
||||
*
|
||||
* Currently supports Cloudflare Turnstile. This is a no-op if
|
||||
* `NEXT_PRIVATE_TURNSTILE_SECRET_KEY` is not configured, making captcha
|
||||
* verification an opt-in feature.
|
||||
*/
|
||||
export const verifyCaptchaToken = async ({
|
||||
token,
|
||||
ipAddress,
|
||||
}: {
|
||||
token?: string | null;
|
||||
ipAddress?: string | null;
|
||||
}) => {
|
||||
const secretKey = process.env.NEXT_PRIVATE_TURNSTILE_SECRET_KEY;
|
||||
|
||||
// If no secret key is configured, skip verification.
|
||||
if (!secretKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
logger.warn({
|
||||
msg: 'Captcha verification rejected: missing token',
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
|
||||
message: 'Captcha token is required',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const formData = new URLSearchParams();
|
||||
|
||||
formData.append('secret', secretKey);
|
||||
formData.append('response', token);
|
||||
|
||||
if (ipAddress) {
|
||||
formData.append('remoteip', ipAddress);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(TURNSTILE_VERIFY_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData.toString(),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({
|
||||
msg: 'Captcha verification failed: network error calling siteverify',
|
||||
err,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
|
||||
message: 'Captcha verification failed',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error({
|
||||
msg: 'Captcha verification failed: non-2xx response from siteverify',
|
||||
status: response.status,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
|
||||
message: `Captcha verification request failed with status ${response.status}`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const result: TurnstileVerifyResponse = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn({
|
||||
msg: 'Captcha verification rejected by provider',
|
||||
errorCodes: result['error-codes'],
|
||||
hostname: result.hostname,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_CAPTCHA, {
|
||||
message: `Captcha verification failed: ${result['error-codes']?.join(', ') ?? 'unknown'}`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -442,6 +442,7 @@ export const completeDocumentWithToken = async ({
|
|||
where: { id: nextRecipient.id },
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
|
||||
? {
|
||||
name: nextSigner.name,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ export const viewedDocument = async ({
|
|||
// This handles cases where distribution is done manually
|
||||
sendStatus: SendStatus.SENT,
|
||||
readStatus: ReadStatus.OPENED,
|
||||
// Only set sentAt if not already set (email may have been sent before they opened).
|
||||
...(!recipient.sentAt ? { sentAt: new Date() } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -92,6 +94,9 @@ export const viewedDocument = async ({
|
|||
});
|
||||
});
|
||||
|
||||
// Don't schedule reminders for manually distributed documents —
|
||||
// there's no email pathway to send them through.
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: {
|
||||
id: recipient.envelopeId,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
|
||||
import { recomputeNextReminderForEnvelope } from '../recipient/update-recipient-next-reminder';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { getEnvelopeWhereInput } from './get-envelope-by-id';
|
||||
|
||||
|
|
@ -354,6 +355,11 @@ export const updateEnvelope = async ({
|
|||
return result;
|
||||
});
|
||||
|
||||
// Recompute reminders for active recipients when reminder settings change.
|
||||
if (meta && 'reminderSettings' in meta) {
|
||||
await recomputeNextReminderForEnvelope(envelope.id);
|
||||
}
|
||||
|
||||
if (envelope.type === EnvelopeType.TEMPLATE) {
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.TEMPLATE_UPDATED,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { createRateLimit } from './rate-limit';
|
|||
|
||||
export const signupRateLimit = createRateLimit({
|
||||
action: 'auth.signup',
|
||||
max: 10,
|
||||
window: '1h',
|
||||
max: 3,
|
||||
window: '3h',
|
||||
});
|
||||
|
||||
export const forgotPasswordRateLimit = createRateLimit({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
import {
|
||||
DocumentDistributionMethod,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import {
|
||||
ZEnvelopeReminderSettings,
|
||||
resolveNextReminderAt,
|
||||
} from '../../constants/envelope-reminder';
|
||||
|
||||
/**
|
||||
* Compute and store `nextReminderAt` for a single recipient.
|
||||
*
|
||||
* Call this after:
|
||||
* - Sending the signing email (sentAt is set)
|
||||
* - Sending a reminder (lastReminderSentAt is updated)
|
||||
*
|
||||
* If `reminderSettings` is provided it's used directly, avoiding a query.
|
||||
* Otherwise it's read from the envelope's documentMeta (already resolved
|
||||
* from the org/team cascade at envelope creation time).
|
||||
*/
|
||||
export const updateRecipientNextReminder = async (options: {
|
||||
recipientId: number;
|
||||
envelopeId: string;
|
||||
sentAt: Date;
|
||||
lastReminderSentAt: Date | null;
|
||||
reminderSettings?: ReturnType<typeof ZEnvelopeReminderSettings.parse> | null;
|
||||
}) => {
|
||||
const { recipientId, envelopeId, sentAt, lastReminderSentAt } = options;
|
||||
|
||||
let settings = options.reminderSettings;
|
||||
|
||||
if (settings === undefined) {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: { id: envelopeId },
|
||||
select: { documentMeta: { select: { reminderSettings: true } } },
|
||||
});
|
||||
|
||||
settings = envelope?.documentMeta?.reminderSettings
|
||||
? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings)
|
||||
: null;
|
||||
}
|
||||
|
||||
const nextReminderAt = resolveNextReminderAt({
|
||||
config: settings,
|
||||
sentAt,
|
||||
lastReminderSentAt,
|
||||
});
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipientId },
|
||||
data: { nextReminderAt },
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Recompute `nextReminderAt` for all active (unsigned, sent) recipients
|
||||
* of a given envelope. Call when document-level reminder settings change.
|
||||
*/
|
||||
export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: { id: envelopeId },
|
||||
select: {
|
||||
documentMeta: {
|
||||
select: { reminderSettings: true, distributionMethod: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// No reminders for manually distributed documents.
|
||||
const isEmailDistribution =
|
||||
envelope?.documentMeta?.distributionMethod !== DocumentDistributionMethod.NONE;
|
||||
|
||||
const settings =
|
||||
isEmailDistribution && envelope?.documentMeta?.reminderSettings
|
||||
? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings)
|
||||
: null;
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
envelopeId,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
sentAt: { not: null },
|
||||
role: { not: RecipientRole.CC },
|
||||
},
|
||||
select: { id: true, sentAt: true, lastReminderSentAt: true },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
recipients.map(async (recipient) => {
|
||||
if (!recipient.sentAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextReminderAt = resolveNextReminderAt({
|
||||
config: settings,
|
||||
sentAt: recipient.sentAt,
|
||||
lastReminderSentAt: recipient.lastReminderSentAt,
|
||||
});
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: { nextReminderAt },
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
@ -705,6 +705,7 @@ export const createDocumentFromDirectTemplate = async ({
|
|||
where: { id: nextRecipient.id },
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
...(nextSigner && documentMeta?.allowDictateNextSigner
|
||||
? {
|
||||
name: nextSigner.name,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export const ZDocumentAuditLogEmailTypeSchema = z.enum([
|
|||
'ASSISTING_REQUEST',
|
||||
'CC',
|
||||
'DOCUMENT_COMPLETED',
|
||||
'REMINDER',
|
||||
]);
|
||||
|
||||
export const ZDocumentMetaDiffTypeSchema = z.enum([
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { z } from 'zod';
|
|||
|
||||
import { VALID_DATE_FORMAT_VALUES } from '@documenso/lib/constants/date-formats';
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
||||
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
|
|
@ -131,6 +132,7 @@ export const ZDocumentMetaCreateSchema = z.object({
|
|||
emailReplyTo: zEmail().nullish(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
reminderSettings: ZEnvelopeReminderSettings.nullish(),
|
||||
});
|
||||
|
||||
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
|||
emailId: true,
|
||||
emailReplyTo: true,
|
||||
envelopeExpirationPeriod: true,
|
||||
reminderSettings: true,
|
||||
}).extend({
|
||||
password: z.string().nullable().default(null),
|
||||
documentId: z.number().default(-1).optional(),
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export const ZEnvelopeEditorSettingsSchema = z.object({
|
|||
allowConfigureRedirectUrl: z.boolean(),
|
||||
allowConfigureDistribution: z.boolean(),
|
||||
allowConfigureExpirationPeriod: z.boolean(),
|
||||
allowConfigureReminders: z.boolean(),
|
||||
allowConfigureEmailSender: z.boolean(),
|
||||
allowConfigureEmailReplyTo: z.boolean(),
|
||||
})
|
||||
|
|
@ -122,6 +123,7 @@ export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
|
|||
allowConfigureRedirectUrl: true,
|
||||
allowConfigureDistribution: true,
|
||||
allowConfigureExpirationPeriod: true,
|
||||
allowConfigureReminders: true,
|
||||
allowConfigureEmailSender: true,
|
||||
allowConfigureEmailReplyTo: true,
|
||||
},
|
||||
|
|
@ -180,6 +182,7 @@ export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
|
|||
allowConfigureRedirectUrl: true,
|
||||
allowConfigureDistribution: true,
|
||||
allowConfigureExpirationPeriod: true,
|
||||
allowConfigureReminders: true,
|
||||
allowConfigureEmailSender: true,
|
||||
allowConfigureEmailReplyTo: true,
|
||||
},
|
||||
|
|
@ -271,6 +274,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
|
|||
emailId: true,
|
||||
emailReplyTo: true,
|
||||
envelopeExpirationPeriod: true,
|
||||
reminderSettings: true,
|
||||
}),
|
||||
recipients: ZEnvelopeRecipientLiteSchema.array(),
|
||||
fields: ZEnvelopeFieldSchema.array(),
|
||||
|
|
|
|||
|
|
@ -118,6 +118,13 @@ export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
|
|||
templateId: true,
|
||||
});
|
||||
|
||||
export type TRecipientSchema = z.infer<typeof ZRecipientSchema>;
|
||||
export type TRecipientLite = z.infer<typeof ZRecipientLiteSchema>;
|
||||
export type TRecipientMany = z.infer<typeof ZRecipientManySchema>;
|
||||
export type TEnvelopeRecipientSchema = z.infer<typeof ZEnvelopeRecipientSchema>;
|
||||
export type TEnvelopeRecipientLite = z.infer<typeof ZEnvelopeRecipientLiteSchema>;
|
||||
export type TEnvelopeRecipientMany = z.infer<typeof ZEnvelopeRecipientManySchema>;
|
||||
|
||||
export const ZRecipientEmailSchema = z.union([
|
||||
z.literal(''),
|
||||
zEmail('Invalid email').trim().toLowerCase().max(254),
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ export const extractDerivedDocumentMeta = (
|
|||
// Envelope expiration.
|
||||
envelopeExpirationPeriod:
|
||||
meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod ?? null,
|
||||
|
||||
// Reminder settings.
|
||||
reminderSettings: meta.reminderSettings ?? settings.reminderSettings ?? null,
|
||||
} satisfies Omit<DocumentMeta, 'id'>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ export const buildEmbeddedFeatures = (
|
|||
allowConfigureExpirationPeriod:
|
||||
features.settings?.allowConfigureExpirationPeriod ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureExpirationPeriod,
|
||||
allowConfigureReminders:
|
||||
features.settings?.allowConfigureReminders ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureReminders,
|
||||
allowConfigureEmailSender:
|
||||
features.settings?.allowConfigureEmailSender ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureEmailSender,
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ export type EnvelopeItemPermissions = {
|
|||
|
||||
export const getEnvelopeItemPermissions = (
|
||||
envelope: Pick<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>,
|
||||
recipients: Recipient[],
|
||||
recipients: Pick<Recipient, 'role' | 'signingStatus' | 'sendStatus'>[],
|
||||
): EnvelopeItemPermissions => {
|
||||
// Always reject completed/rejected/deleted envelopes.
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/orga
|
|||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats';
|
||||
import { DEFAULT_ENVELOPE_EXPIRATION_PERIOD } from '../constants/envelope-expiration';
|
||||
import { DEFAULT_ENVELOPE_REMINDER_SETTINGS } from '../constants/envelope-reminder';
|
||||
import {
|
||||
LOWEST_ORGANISATION_ROLE,
|
||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||
|
|
@ -142,6 +143,8 @@ export const generateDefaultOrganisationSettings = (): Omit<
|
|||
|
||||
envelopeExpirationPeriod: DEFAULT_ENVELOPE_EXPIRATION_PERIOD,
|
||||
|
||||
reminderSettings: DEFAULT_ENVELOPE_REMINDER_SETTINGS,
|
||||
|
||||
aiFeaturesEnabled: false,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import type { Envelope } from '@prisma/client';
|
||||
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
import type { TRecipientLite } from '../types/recipient';
|
||||
import { extractLegacyIds } from '../universal/id';
|
||||
import { zEmail } from './zod';
|
||||
|
||||
|
|
@ -20,7 +21,7 @@ export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as con
|
|||
*
|
||||
* Currently only SIGNERs are validated - they must have at least one signature field.
|
||||
*/
|
||||
export const getRecipientsWithMissingFields = <T extends Pick<Recipient, 'id' | 'role'>>(
|
||||
export const getRecipientsWithMissingFields = <T extends Pick<TRecipientLite, 'id' | 'role'>>(
|
||||
recipients: T[],
|
||||
fields: Pick<Field, 'type' | 'recipientId'>[],
|
||||
): T[] => {
|
||||
|
|
@ -42,7 +43,10 @@ export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}
|
|||
/**
|
||||
* Whether a recipient can be modified by the document owner.
|
||||
*/
|
||||
export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) => {
|
||||
export const canRecipientBeModified = (
|
||||
recipient: TRecipientLite,
|
||||
fields: Pick<Field, 'recipientId' | 'inserted'>[],
|
||||
) => {
|
||||
if (!recipient) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -72,7 +76,10 @@ export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) =>
|
|||
* - They are not a Viewer or CCer
|
||||
* - They can be modified (canRecipientBeModified)
|
||||
*/
|
||||
export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field[]) => {
|
||||
export const canRecipientFieldsBeModified = (
|
||||
recipient: TRecipientLite,
|
||||
fields: Pick<Field, 'recipientId' | 'inserted'>[],
|
||||
) => {
|
||||
if (!canRecipientBeModified(recipient, fields)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -81,7 +88,7 @@ export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field
|
|||
};
|
||||
|
||||
export const mapRecipientToLegacyRecipient = (
|
||||
recipient: Recipient,
|
||||
recipient: TRecipientLite,
|
||||
envelope: Pick<Envelope, 'type' | 'secondaryId'>,
|
||||
) => {
|
||||
const legacyId = extractLegacyIds(envelope);
|
||||
|
|
@ -92,6 +99,7 @@ export const mapRecipientToLegacyRecipient = (
|
|||
};
|
||||
};
|
||||
|
||||
|
||||
export const findRecipientByEmail = <T extends { email: string }>({
|
||||
recipients,
|
||||
userEmail,
|
||||
|
|
@ -102,7 +110,7 @@ export const findRecipientByEmail = <T extends { email: string }>({
|
|||
teamEmail?: string | null;
|
||||
}) => recipients.find((r) => r.email === userEmail || (teamEmail && r.email === teamEmail));
|
||||
|
||||
export const isRecipientEmailValidForSending = (recipient: Pick<Recipient, 'email'>) => {
|
||||
export const isRecipientEmailValidForSending = (recipient: Pick<TRecipientLite, 'email'>) => {
|
||||
return zEmail().safeParse(recipient.email).success;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -208,6 +208,8 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
|||
|
||||
envelopeExpirationPeriod: null,
|
||||
|
||||
reminderSettings: null,
|
||||
|
||||
aiFeaturesEnabled: null,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "DocumentMeta" ADD COLUMN "reminderSettings" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "reminderSettings" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "reminderSettings" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Recipient" ADD COLUMN "sentAt" TIMESTAMP(3),
|
||||
ADD COLUMN "lastReminderSentAt" TIMESTAMP(3),
|
||||
ADD COLUMN "nextReminderAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Recipient_nextReminderAt_idx" ON "Recipient"("nextReminderAt");
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.0",
|
||||
"kysely": "0.28.8",
|
||||
"kysely": "0.28.16",
|
||||
"nanoid": "^5.1.6",
|
||||
"prisma": "^6.19.0",
|
||||
"prisma-extension-kysely": "^3.0.0",
|
||||
|
|
|
|||
|
|
@ -507,7 +507,7 @@ enum DocumentDistributionMethod {
|
|||
NONE
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';"])
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';", "import { ZEnvelopeReminderSettings as ZEnvelopeReminderSettingsSchema } from '@documenso/lib/constants/envelope-reminder';"])
|
||||
model DocumentMeta {
|
||||
id String @id @default(cuid())
|
||||
subject String?
|
||||
|
|
@ -531,6 +531,8 @@ model DocumentMeta {
|
|||
|
||||
envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema)
|
||||
|
||||
reminderSettings Json? /// [EnvelopeReminderSettings] @zod.custom.use(ZEnvelopeReminderSettingsSchema)
|
||||
|
||||
envelope Envelope?
|
||||
}
|
||||
|
||||
|
|
@ -587,7 +589,10 @@ model Recipient {
|
|||
expired DateTime? // deprecated Not in use. To be removed in a future migration.
|
||||
expiresAt DateTime?
|
||||
expirationNotifiedAt DateTime?
|
||||
sentAt DateTime?
|
||||
signedAt DateTime?
|
||||
lastReminderSentAt DateTime?
|
||||
nextReminderAt DateTime?
|
||||
authOptions Json? /// [RecipientAuthOptions] @zod.custom.use(ZRecipientAuthOptionsSchema)
|
||||
signingOrder Int? /// @zod.number.describe("The order in which the recipient should sign the document. Only works if the document is set to sequential signing.")
|
||||
rejectionReason String?
|
||||
|
|
@ -607,6 +612,7 @@ model Recipient {
|
|||
@@index([email, documentDeletedAt, envelopeId], map: "Recipient_email_documentDeletedAt_envelopeId_idx")
|
||||
@@index([email, envelopeId], map: "Recipient_email_envelopeId_idx")
|
||||
@@index([email, signingStatus, envelopeId, role], map: "Recipient_email_signingStatus_envelopeId_role_idx")
|
||||
@@index([nextReminderAt])
|
||||
@@index([email(ops: raw("gin_trgm_ops"))], map: "Recipient_email_trgm_idx", type: Gin)
|
||||
@@index([name(ops: raw("gin_trgm_ops"))], map: "Recipient_name_trgm_idx", type: Gin)
|
||||
}
|
||||
|
|
@ -825,7 +831,7 @@ enum OrganisationMemberInviteStatus {
|
|||
DECLINED
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';"])
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';", "import { ZEnvelopeReminderSettings as ZEnvelopeReminderSettingsSchema } from '@documenso/lib/constants/envelope-reminder';"])
|
||||
model OrganisationGlobalSettings {
|
||||
id String @id
|
||||
organisation Organisation?
|
||||
|
|
@ -859,11 +865,13 @@ model OrganisationGlobalSettings {
|
|||
|
||||
envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema)
|
||||
|
||||
reminderSettings Json? /// [EnvelopeReminderSettings] @zod.custom.use(ZEnvelopeReminderSettingsSchema)
|
||||
|
||||
// AI features settings.
|
||||
aiFeaturesEnabled Boolean @default(false)
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';"])
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';", "import { ZEnvelopeReminderSettings as ZEnvelopeReminderSettingsSchema } from '@documenso/lib/constants/envelope-reminder';"])
|
||||
model TeamGlobalSettings {
|
||||
id String @id
|
||||
team Team?
|
||||
|
|
@ -898,6 +906,8 @@ model TeamGlobalSettings {
|
|||
|
||||
envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema)
|
||||
|
||||
reminderSettings Json? /// [EnvelopeReminderSettings] @zod.custom.use(ZEnvelopeReminderSettingsSchema)
|
||||
|
||||
// AI features settings.
|
||||
aiFeaturesEnabled Boolean?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,14 +120,16 @@ export const findAdminOrganisations = async ({
|
|||
};
|
||||
}
|
||||
|
||||
const orderBy: Prisma.OrganisationOrderByWithRelationInput[] = query
|
||||
? [{ subscription: { status: 'asc' } }, { name: 'asc' }]
|
||||
: [{ createdAt: 'desc' }];
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.organisation.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
orderBy,
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ import { authenticatedProcedure } from '../trpc';
|
|||
import {
|
||||
ZUpdateDocumentRequestSchema,
|
||||
ZUpdateDocumentResponseSchema,
|
||||
updateDocumentMeta,
|
||||
} from './update-document.types';
|
||||
import { updateDocumentMeta as updateDocumentTrpcMeta } from './update-document.types';
|
||||
|
||||
/**
|
||||
* Public route.
|
||||
*/
|
||||
export const updateDocumentRoute = authenticatedProcedure
|
||||
.meta(updateDocumentTrpcMeta)
|
||||
.meta(updateDocumentMeta)
|
||||
.input(ZUpdateDocumentRequestSchema)
|
||||
.output(ZUpdateDocumentResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||
defaultRecipients,
|
||||
delegateDocumentOwnership,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled,
|
||||
|
|
@ -151,6 +152,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
|
||||
envelopeExpirationPeriod:
|
||||
envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
|
||||
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
||||
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
|
|
@ -28,6 +29,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
|
|||
defaultRecipients: ZDefaultRecipientsSchema.nullish(),
|
||||
delegateDocumentOwnership: z.boolean().nullish(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(),
|
||||
reminderSettings: ZEnvelopeReminderSettings.optional(),
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled: z.boolean().optional(),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
|||
drawSignatureEnabled,
|
||||
delegateDocumentOwnership,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled,
|
||||
|
|
@ -157,6 +158,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
|||
delegateDocumentOwnership,
|
||||
envelopeExpirationPeriod:
|
||||
envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
|
||||
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
||||
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
|
|
@ -31,6 +32,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
|
|||
drawSignatureEnabled: z.boolean().nullish(),
|
||||
delegateDocumentOwnership: z.boolean().nullish(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
reminderSettings: ZEnvelopeReminderSettings.nullish(),
|
||||
|
||||
// Branding related settings.
|
||||
brandingEnabled: z.boolean().nullish(),
|
||||
|
|
|
|||
6
packages/tsconfig/process-env.d.ts
vendored
6
packages/tsconfig/process-env.d.ts
vendored
|
|
@ -102,6 +102,12 @@ declare namespace NodeJS {
|
|||
POSTGRES_PRISMA_URL?: string;
|
||||
POSTGRES_URL_NON_POOLING?: string;
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile environment variables
|
||||
*/
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY?: string;
|
||||
NEXT_PRIVATE_TURNSTILE_SECRET_KEY?: string;
|
||||
|
||||
/**
|
||||
* Google Vertex AI environment variables
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ import { useState } from 'react';
|
|||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, Field, Recipient } from '@prisma/client';
|
||||
import type { DocumentMeta, Field } from '@prisma/client';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { Clock, EyeOffIcon } from 'lucide-react';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||
|
|
@ -34,7 +35,7 @@ const getRecipientDisplayText = (recipient: { name: string; email: string }) =>
|
|||
};
|
||||
|
||||
export type DocumentField = Field & {
|
||||
recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus'>;
|
||||
recipient: Pick<TRecipientLite, 'name' | 'email' | 'signingStatus'>;
|
||||
};
|
||||
|
||||
export type DocumentReadOnlyFieldsProps = {
|
||||
|
|
@ -68,7 +69,7 @@ export type DocumentReadOnlyFieldsProps = {
|
|||
|
||||
export const mapFieldsWithRecipients = (
|
||||
fields: Field[],
|
||||
recipients: Recipient[],
|
||||
recipients: TRecipientLite[],
|
||||
): DocumentField[] => {
|
||||
return fields.map((field) => {
|
||||
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export const ExpirationPeriodPicker = ({
|
|||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select value={mode} onValueChange={onModeChange} disabled={disabled}>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectTrigger className="bg-background" data-testid="envelope-expiration-mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
|
|
@ -113,13 +113,14 @@ export const ExpirationPeriodPicker = ({
|
|||
type="number"
|
||||
min={1}
|
||||
className="w-20 bg-background"
|
||||
data-testid="envelope-expiration-amount"
|
||||
value={amount}
|
||||
onChange={(e) => onAmountChange(Number(e.target.value))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Select value={unit} onValueChange={onUnitChange} disabled={disabled}>
|
||||
<SelectTrigger className="flex-1 bg-background">
|
||||
<SelectTrigger className="flex-1 bg-background" data-testid="envelope-expiration-unit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
|
|
|
|||
266
packages/ui/components/document/reminder-settings-picker.tsx
Normal file
266
packages/ui/components/document/reminder-settings-picker.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
|
||||
import type {
|
||||
TEnvelopeReminderDurationPeriod,
|
||||
TEnvelopeReminderPeriod,
|
||||
TEnvelopeReminderSettings,
|
||||
} from '@documenso/lib/constants/envelope-reminder';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
type ReminderMode = 'enabled' | 'disabled' | 'inherit';
|
||||
|
||||
const getMode = (value: TEnvelopeReminderSettings | null | undefined): ReminderMode => {
|
||||
if (value === null || value === undefined) {
|
||||
return 'inherit';
|
||||
}
|
||||
|
||||
if ('disabled' in value.sendAfter) {
|
||||
return 'disabled';
|
||||
}
|
||||
|
||||
return 'enabled';
|
||||
};
|
||||
|
||||
const getPeriodAmount = (period: TEnvelopeReminderPeriod | undefined): number => {
|
||||
if (period && 'amount' in period) {
|
||||
return period.amount;
|
||||
}
|
||||
|
||||
return 1;
|
||||
};
|
||||
|
||||
const getPeriodUnit = (
|
||||
period: TEnvelopeReminderPeriod | undefined,
|
||||
): TEnvelopeReminderDurationPeriod['unit'] => {
|
||||
if (period && 'unit' in period) {
|
||||
return period.unit;
|
||||
}
|
||||
|
||||
return 'day';
|
||||
};
|
||||
|
||||
export type ReminderSettingsPickerProps = {
|
||||
value: TEnvelopeReminderSettings | null | undefined;
|
||||
onChange: (value: TEnvelopeReminderSettings | null) => void;
|
||||
disabled?: boolean;
|
||||
inheritLabel?: string;
|
||||
};
|
||||
|
||||
export const ReminderSettingsPicker = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
inheritLabel,
|
||||
}: ReminderSettingsPickerProps) => {
|
||||
const mode = getMode(value);
|
||||
|
||||
const sendAfterAmount = getPeriodAmount(value?.sendAfter);
|
||||
const sendAfterUnit = getPeriodUnit(value?.sendAfter);
|
||||
const repeatEveryAmount = getPeriodAmount(value?.repeatEvery);
|
||||
const repeatEveryUnit = getPeriodUnit(value?.repeatEvery);
|
||||
|
||||
const onModeChange = (newMode: string) => {
|
||||
if (newMode === 'inherit') {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newMode === 'disabled') {
|
||||
onChange({
|
||||
sendAfter: { disabled: true },
|
||||
repeatEvery: { disabled: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({
|
||||
sendAfter: { unit: sendAfterUnit, amount: sendAfterAmount },
|
||||
repeatEvery: { unit: repeatEveryUnit, amount: repeatEveryAmount },
|
||||
});
|
||||
};
|
||||
|
||||
const updateSendAfter = (
|
||||
updates: Partial<{ amount: number; unit: TEnvelopeReminderDurationPeriod['unit'] }>,
|
||||
) => {
|
||||
const newAmount = Math.max(1, Math.floor(updates.amount ?? sendAfterAmount));
|
||||
const newUnit = updates.unit ?? sendAfterUnit;
|
||||
|
||||
onChange({
|
||||
sendAfter: { unit: newUnit, amount: newAmount },
|
||||
repeatEvery: value?.repeatEvery ?? { unit: repeatEveryUnit, amount: repeatEveryAmount },
|
||||
});
|
||||
};
|
||||
|
||||
const updateRepeatEvery = (
|
||||
updates: Partial<{ amount: number; unit: TEnvelopeReminderDurationPeriod['unit'] }>,
|
||||
) => {
|
||||
const newAmount = Math.max(1, Math.floor(updates.amount ?? repeatEveryAmount));
|
||||
const newUnit = updates.unit ?? repeatEveryUnit;
|
||||
|
||||
onChange({
|
||||
sendAfter: value?.sendAfter ?? { unit: sendAfterUnit, amount: sendAfterAmount },
|
||||
repeatEvery: { unit: newUnit, amount: newAmount },
|
||||
});
|
||||
};
|
||||
|
||||
const onRepeatModeChange = (newMode: string) => {
|
||||
if (newMode === 'disabled') {
|
||||
onChange({
|
||||
sendAfter: value?.sendAfter ?? { unit: sendAfterUnit, amount: sendAfterAmount },
|
||||
repeatEvery: { disabled: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({
|
||||
sendAfter: value?.sendAfter ?? { unit: sendAfterUnit, amount: sendAfterAmount },
|
||||
repeatEvery: { unit: repeatEveryUnit, amount: repeatEveryAmount },
|
||||
});
|
||||
};
|
||||
|
||||
const repeatMode = value?.repeatEvery && 'disabled' in value.repeatEvery ? 'disabled' : 'enabled';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4" data-testid="reminder-settings-picker">
|
||||
<Select value={mode} onValueChange={onModeChange} disabled={disabled}>
|
||||
<SelectTrigger className="bg-background" data-testid="reminder-mode-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="enabled">
|
||||
<Trans>Enabled</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="disabled">
|
||||
<Trans>No reminders</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{inheritLabel !== undefined && <SelectItem value="inherit">{inheritLabel}</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{mode === 'enabled' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
<Trans>Send first reminder after</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-20 bg-background"
|
||||
value={sendAfterAmount}
|
||||
onChange={(e) => updateSendAfter({ amount: Number(e.target.value) })}
|
||||
disabled={disabled}
|
||||
data-testid="reminder-send-after-amount"
|
||||
/>
|
||||
|
||||
<UnitSelect
|
||||
value={sendAfterUnit}
|
||||
amount={sendAfterAmount}
|
||||
onChange={(unit) =>
|
||||
updateSendAfter({
|
||||
unit: unit as TEnvelopeReminderDurationPeriod['unit'],
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
testId="reminder-send-after-unit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
<Trans>Then repeat every</Trans>
|
||||
</Label>
|
||||
|
||||
<Select value={repeatMode} onValueChange={onRepeatModeChange} disabled={disabled}>
|
||||
<SelectTrigger className="bg-background" data-testid="reminder-repeat-mode-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="enabled">
|
||||
<Trans>Custom interval</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="disabled">
|
||||
<Trans>Don't repeat</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{repeatMode === 'enabled' && (
|
||||
<div className="flex flex-row gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-20 bg-background"
|
||||
value={repeatEveryAmount}
|
||||
onChange={(e) => updateRepeatEvery({ amount: Number(e.target.value) })}
|
||||
disabled={disabled}
|
||||
data-testid="reminder-repeat-amount"
|
||||
/>
|
||||
|
||||
<UnitSelect
|
||||
value={repeatEveryUnit}
|
||||
amount={repeatEveryAmount}
|
||||
onChange={(unit) =>
|
||||
updateRepeatEvery({
|
||||
unit: unit as TEnvelopeReminderDurationPeriod['unit'],
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
testId="reminder-repeat-unit"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UnitSelect = ({
|
||||
value,
|
||||
amount,
|
||||
onChange,
|
||||
disabled,
|
||||
testId,
|
||||
}: {
|
||||
value: string;
|
||||
amount: number;
|
||||
onChange: (value: string) => void;
|
||||
disabled: boolean;
|
||||
testId: string;
|
||||
}) => (
|
||||
<Select value={value} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger className="flex-1 bg-background" data-testid={testId}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="day">
|
||||
<Plural value={amount} one="Day" other="Days" />
|
||||
</SelectItem>
|
||||
<SelectItem value="week">
|
||||
<Plural value={amount} one="Week" other="Weeks" />
|
||||
</SelectItem>
|
||||
<SelectItem value="month">
|
||||
<Plural value={amount} one="Month" other="Months" />
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
|
@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { FieldType, Prisma, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import {
|
||||
CalendarDays,
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
type TFieldMetaSchema as FieldMeta,
|
||||
ZFieldMetaSchema,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { validateFieldsUninserted } from '@documenso/lib/utils/fields';
|
||||
|
|
@ -83,7 +84,7 @@ export type FieldFormType = {
|
|||
export type AddFieldsFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
hideRecipients?: boolean;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
fields: Field[];
|
||||
onSubmit: (_data: TAddFieldsFormSchema) => void;
|
||||
onAutoSave: (_data: TAddFieldsFormSchema) => Promise<void>;
|
||||
|
|
@ -172,7 +173,7 @@ export const AddFieldsFormPartial = ({
|
|||
});
|
||||
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
|
||||
const [selectedSigner, setSelectedSigner] = useState<TRecipientLite | null>(null);
|
||||
const [lastActiveField, setLastActiveField] = useState<TAddFieldsFormSchema['fields'][0] | null>(
|
||||
null,
|
||||
);
|
||||
|
|
@ -536,7 +537,7 @@ export const AddFieldsFormPartial = ({
|
|||
}, [recipients]);
|
||||
|
||||
const recipientsByRole = useMemo(() => {
|
||||
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
|
||||
const recipientsByRole: Record<RecipientRole, TRecipientLite[]> = {
|
||||
CC: [],
|
||||
VIEWER: [],
|
||||
SIGNER: [],
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
type Field,
|
||||
type Recipient,
|
||||
SendStatus,
|
||||
TeamMemberRole,
|
||||
} from '@prisma/client';
|
||||
|
|
@ -21,6 +20,7 @@ import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
|
|||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import type { TDocument } from '@documenso/lib/types/document';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
import {
|
||||
|
|
@ -74,7 +74,7 @@ import type { DocumentFlowStep } from './types';
|
|||
|
||||
export type AddSettingsFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
fields: Field[];
|
||||
isDocumentPdfLoaded: boolean;
|
||||
document: TDocument;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { GripVerticalIcon, HelpCircle, Plus, Trash } from 'lucide-react';
|
||||
|
|
@ -19,6 +19,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
|
|||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
|
|
@ -54,12 +55,12 @@ import { SigningOrderConfirmation } from './signing-order-confirmation';
|
|||
import type { DocumentFlowStep } from './types';
|
||||
|
||||
type AutoSaveResponse = {
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
};
|
||||
|
||||
export type AddSignersFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
fields: Field[];
|
||||
signingOrder?: DocumentSigningOrder | null;
|
||||
allowDictateNextSigner?: boolean;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentStatus, RecipientRole } from '@prisma/client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
|
|
@ -15,6 +15,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
|
|||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TDocument } from '@documenso/lib/types/document';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||
|
|
@ -59,7 +60,7 @@ import type { DocumentFlowStep } from './types';
|
|||
|
||||
export type AddSubjectFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
fields: Field[];
|
||||
document: TDocument;
|
||||
onSubmit: (_data: TAddSubjectFormSchema) => void;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { useCallback, useMemo, useState } from 'react';
|
|||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { Check, ChevronsUpDown, Info } from 'lucide-react';
|
||||
import { sortBy } from 'remeda';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
|
||||
import { getRecipientColorStyles } from '../lib/recipient-colors';
|
||||
import { cn } from '../lib/utils';
|
||||
|
|
@ -18,9 +18,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
|
|||
|
||||
export interface RecipientSelectorProps {
|
||||
className?: string;
|
||||
selectedRecipient: Recipient | null;
|
||||
onSelectedRecipientChange: (recipient: Recipient) => void;
|
||||
recipients: Recipient[];
|
||||
selectedRecipient: TRecipientLite | null;
|
||||
onSelectedRecipientChange: (recipient: TRecipientLite) => void;
|
||||
recipients: TRecipientLite[];
|
||||
align?: 'center' | 'end' | 'start';
|
||||
}
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ export const RecipientSelector = ({
|
|||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||
|
||||
const recipientsByRole = useMemo(() => {
|
||||
const recipientsWithRole: Record<RecipientRole, Recipient[]> = {
|
||||
const recipientsWithRole: Record<RecipientRole, TRecipientLite[]> = {
|
||||
CC: [],
|
||||
VIEWER: [],
|
||||
SIGNER: [],
|
||||
|
|
@ -67,12 +67,12 @@ export const RecipientSelector = ({
|
|||
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
|
||||
[(r) => r.id, 'asc'],
|
||||
),
|
||||
] as [RecipientRole, Recipient[]],
|
||||
] as [RecipientRole, TRecipientLite[]],
|
||||
);
|
||||
}, [recipientsByRole]);
|
||||
|
||||
const getRecipientLabel = useCallback(
|
||||
(recipient: Recipient) => {
|
||||
(recipient: TRecipientLite) => {
|
||||
if (recipient.name && recipient.email) {
|
||||
return `${recipient.name} (${recipient.email})`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { FieldType, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import {
|
||||
CalendarDays,
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
type TFieldMetaSchema as FieldMeta,
|
||||
ZFieldMetaSchema,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
||||
|
|
@ -75,7 +76,7 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
|
|||
|
||||
export type AddTemplateFieldsFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
fields: Field[];
|
||||
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
|
||||
onAutoSave: (_data: TAddTemplateFieldsFormSchema) => Promise<void>;
|
||||
|
|
@ -154,7 +155,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||
});
|
||||
|
||||
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
|
||||
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
|
||||
const [selectedSigner, setSelectedSigner] = useState<TRecipientLite | null>(null);
|
||||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||
|
||||
const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
|
||||
|
|
@ -491,7 +492,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||
}, [recipients]);
|
||||
|
||||
const recipientsByRole = useMemo(() => {
|
||||
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
|
||||
const recipientsByRole: Record<RecipientRole, TRecipientLite[]> = {
|
||||
CC: [],
|
||||
VIEWER: [],
|
||||
SIGNER: [],
|
||||
|
|
@ -520,7 +521,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||
|
||||
const recipientsByRoleToDisplay = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter(
|
||||
return (Object.entries(recipientsByRole) as [RecipientRole, TRecipientLite[]][]).filter(
|
||||
([role]) =>
|
||||
role !== RecipientRole.CC &&
|
||||
role !== RecipientRole.VIEWER &&
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { msg } from '@lingui/core/macro';
|
|||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TemplateDirectLink } from '@prisma/client';
|
||||
import { DocumentSigningOrder, type Field, type Recipient, RecipientRole } from '@prisma/client';
|
||||
import { DocumentSigningOrder, type Field, RecipientRole } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
|
@ -17,6 +17,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
|
|||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
|
|
@ -49,12 +50,12 @@ import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-
|
|||
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
|
||||
|
||||
type AutoSaveResponse = {
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
};
|
||||
|
||||
export type AddTemplatePlaceholderRecipientsFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
fields: Field[];
|
||||
signingOrder?: DocumentSigningOrder | null;
|
||||
allowDictateNextSigner?: boolean;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useEffect } from 'react';
|
|||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentVisibility, TeamMemberRole, TemplateType } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, type Field, type Recipient } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, type Field } from '@prisma/client';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
|
|
@ -19,6 +19,7 @@ import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
|||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import type { TDocumentMetaDateFormat } from '@documenso/lib/types/document-meta';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import type { TTemplate } from '@documenso/lib/types/template';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
|
|
@ -81,7 +82,7 @@ import { ZAddTemplateSettingsFormSchema } from './add-template-settings.types';
|
|||
|
||||
export type AddTemplateSettingsFormProps = {
|
||||
documentFlow: DocumentFlowStep;
|
||||
recipients: Recipient[];
|
||||
recipients: TRecipientLite[];
|
||||
fields: Field[];
|
||||
isDocumentPdfLoaded: boolean;
|
||||
template: TTemplate;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue