Compare commits

...

11 Commits

Author SHA1 Message Date
4fc4a8ba7f fix: merge conflicts 2025-05-28 00:36:27 +00:00
c4cb6eeb94 chore: minor updates 2025-05-28 00:32:54 +00:00
93aece9644 chore: dependency updates (#1808) 2025-05-22 14:30:22 +10:00
abd4fddf31 chore: test reo integration (#1806)
---
name: Pull Request
about: Submit changes to the project for review and inclusion
---

## Description

Experimental Short-Term Reo Integration
2025-05-21 15:24:46 +02:00
eb2b9dd099 chore: update tests 2025-05-01 10:55:32 +00:00
311adb4d1e chore: refactor 2025-04-30 23:48:48 +00:00
30a4f2c7b4 feat: resend email countdown 2025-04-30 22:23:50 +00:00
d48705024e feat: email verification for document signing 2FA 2025-04-30 21:55:41 +00:00
7a3763bb66 refactor: refine document 2FA components 2025-04-30 20:09:25 +00:00
3bf056aa43 fix: build errors 2025-04-29 10:43:49 +00:00
35db8182f0 feat: complete document 2fa (wip) 2025-04-23 08:26:52 +00:00
56 changed files with 8482 additions and 13457 deletions

2
.npmrc
View File

@ -1 +1,3 @@
auto-install-peers = true
legacy-peer-deps = true
prefer-dedupe = true

View File

@ -1,3 +1,5 @@
import nextra from 'nextra';
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: [
@ -9,9 +11,10 @@ const nextConfig = {
],
};
const withNextra = require('nextra')({
const withNextra = nextra({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.tsx',
codeHighlight: true,
});
module.exports = withNextra(nextConfig);
export default withNextra(nextConfig);

View File

@ -15,7 +15,7 @@
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"next": "14.2.6",
"next": "14.2.28",
"next-plausible": "^3.12.0",
"nextra": "^2.13.4",
"nextra-theme-docs": "^2.13.4",

View File

@ -19,6 +19,22 @@ const themeConfig: DocsThemeConfig = {
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<script
dangerouslySetInnerHTML={{
__html: `
!function(){
if (location.hostname === 'localhost') return;
var e="6c236490c9a68c1",
t=function(){Reo.init({ clientID: e })},
n=document.createElement("script");
n.src="https://static.reo.dev/"+e+"/reo.js";
n.defer=true;
n.onload=t;
document.head.appendChild(n);
}();
`,
}}
/>
</>
);
},

View File

@ -12,7 +12,7 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.5.0",
"next": "14.2.6"
"next": "14.2.28"
},
"devDependencies": {
"@types/node": "^20",

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
@ -6,9 +6,9 @@ import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import {
@ -20,6 +20,8 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
@ -51,6 +53,7 @@ export const DocumentSigningAuth2FA = ({
}: DocumentSigningAuth2FAProps) => {
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
const { toast } = useToast();
const form = useForm<T2FAAuthFormSchema>({
resolver: zodResolver(Z2FAAuthFormSchema),
@ -60,27 +63,104 @@ export const DocumentSigningAuth2FA = ({
});
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false);
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
const [isEmailCodeSent, setIsEmailCodeSent] = useState(false);
const [isEmailCodeSending, setIsEmailCodeSending] = useState(false);
const [canResendEmail, setCanResendEmail] = useState(true);
const [resendCountdown, setResendCountdown] = useState(0);
const countdownTimerRef = useRef<NodeJS.Timeout | null>(null);
const [verificationMethod, setVerificationMethod] = useState<'app' | 'email'>(
user?.twoFactorEnabled ? 'app' : 'email',
);
const emailSendInitiatedRef = useRef(false);
const sendVerificationMutation = trpc.auth.sendEmailVerification.useMutation({
onSuccess: () => {
setIsEmailCodeSent(true);
setCanResendEmail(false);
setResendCountdown(60);
countdownTimerRef.current = setInterval(() => {
setResendCountdown((prev) => {
if (prev <= 1) {
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
}
setCanResendEmail(true);
return 0;
}
return prev - 1;
});
}, 1000);
toast({
title: 'Verification code sent',
description: `A verification code has been sent to ${recipient.email}`,
});
},
onError: (error) => {
console.error('Failed to send verification code', error);
toast({
title: 'Failed to send verification code',
description: 'Please try again or contact support',
variant: 'destructive',
});
},
onSettled: () => {
setIsEmailCodeSending(false);
},
});
const verifyCodeMutation = trpc.auth.verifyEmailCode.useMutation();
const sendEmailVerificationCode = async () => {
try {
setIsEmailCodeSending(true);
await sendVerificationMutation.mutateAsync({
recipientId: recipient.id,
});
} catch (error) {
toast({
title: 'Failed to send verification code',
description: 'Please try again.',
variant: 'destructive',
});
}
};
useEffect(() => {
return () => {
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
}
};
}, []);
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
try {
setIsCurrentlyAuthenticating(true);
if (verificationMethod === 'email') {
await verifyCodeMutation.mutateAsync({
code: token,
recipientId: recipient.id,
});
}
await onReauthFormSubmit({
type: DocumentAuth.TWO_FACTOR_AUTH,
token,
});
setIsCurrentlyAuthenticating(false);
onOpenChange(false);
} catch (err) {
setIsCurrentlyAuthenticating(false);
const error = AppError.parseError(err);
setFormErrorCode(error.code);
// Todo: Alert.
toast({
title: 'Unauthorized',
description: 'We were unable to verify your details.',
variant: 'destructive',
});
}
};
@ -90,21 +170,47 @@ export const DocumentSigningAuth2FA = ({
});
setIs2FASetupSuccessful(false);
setFormErrorCode(null);
setIsEmailCodeSent(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
if (open && !user?.twoFactorEnabled) {
setVerificationMethod('email');
}
}, [open, user?.twoFactorEnabled, form]);
if (!user?.twoFactorEnabled && !is2FASetupSuccessful) {
useEffect(() => {
if (!open || verificationMethod !== 'email') {
emailSendInitiatedRef.current = false;
}
}, [open, verificationMethod]);
useEffect(() => {
if (open && verificationMethod === 'email' && !isEmailCodeSent && !isEmailCodeSending) {
if (!emailSendInitiatedRef.current) {
emailSendInitiatedRef.current = true;
void sendEmailVerificationCode();
}
}
}, [open, verificationMethod, isEmailCodeSent, isEmailCodeSending]);
if (verificationMethod === 'app' && !user?.twoFactorEnabled && !is2FASetupSuccessful) {
return (
<div className="space-y-4">
<Tabs
value={verificationMethod}
onValueChange={(val) => setVerificationMethod(val as 'app' | 'email')}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="app">Authenticator App</TabsTrigger>
<TabsTrigger value="email">Email Verification</TabsTrigger>
</TabsList>
</Tabs>
<Alert variant="warning">
<AlertDescription>
<p>
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? (
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
) : (
// Todo: Translate
`You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`
)}
</p>
@ -129,59 +235,106 @@ export const DocumentSigningAuth2FA = ({
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem>
<FormLabel required>2FA token</FormLabel>
<div className="space-y-4">
{user?.twoFactorEnabled && (
<Tabs
value={verificationMethod}
onValueChange={(val) => setVerificationMethod(val as 'app' | 'email')}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="app">Authenticator App</TabsTrigger>
<TabsTrigger value="email">Email Verification</TabsTrigger>
</TabsList>
</Tabs>
)}
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{formErrorCode && (
<Alert variant="destructive">
<AlertTitle>
<Trans>Unauthorized</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
We were unable to verify your details. Please try again or contact support
</Trans>
</AlertDescription>
</Alert>
{verificationMethod === 'email' && (
<Alert variant="secondary">
<AlertDescription>
{isEmailCodeSent ? (
<p>
<Trans>
A verification code has been sent to {recipient.email}. Please enter it below to
continue.
</Trans>
</p>
) : (
<p>
<Trans>
We'll send a verification code to {recipient.email} to verify your identity.
</Trans>
</p>
)}
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem>
<FormLabel required>
{verificationMethod === 'app' ? (
<Trans>2FA token</Trans>
) : (
<Trans>Verification code</Trans>
)}
</FormLabel>
<Button type="submit" loading={isCurrentlyAuthenticating}>
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{verificationMethod === 'email' && (
<div className="flex justify-center">
<Button
type="button"
variant="link"
disabled={isEmailCodeSending || !canResendEmail}
onClick={() => void sendEmailVerificationCode()}
>
{isEmailCodeSending ? (
<Trans>Sending...</Trans>
) : !canResendEmail ? (
<Trans>Resend code ({resendCountdown}s)</Trans>
) : (
<Trans>Resend code</Trans>
)}
</Button>
</div>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
<Trans>{actionTarget === 'DOCUMENT' ? 'Sign Document' : 'Sign Field'}</Trans>
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
</div>
);
};

View File

@ -27,7 +27,6 @@ export type DocumentSigningAuthDialogProps = {
actionTarget: FieldType | 'DOCUMENT';
open: boolean;
onOpenChange: (value: boolean) => void;
/**
* The callback to run when the reauth form is filled out.
*/
@ -38,6 +37,7 @@ export const DocumentSigningAuthDialog = ({
title,
description,
documentAuthType,
actionTarget,
open,
onOpenChange,
onReauthFormSubmit,
@ -56,10 +56,22 @@ export const DocumentSigningAuthDialog = ({
<Dialog open={open} onOpenChange={handleOnOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title || <Trans>Sign field</Trans>}</DialogTitle>
<DialogTitle>
{title ||
(actionTarget === 'DOCUMENT' ? (
<Trans>Sign document</Trans>
) : (
<Trans>Sign field</Trans>
))}
</DialogTitle>
<DialogDescription>
{description || <Trans>Reauthentication is required to sign this field</Trans>}
{description || (
<Trans>
Reauthentication is required to sign this{' '}
{actionTarget === 'DOCUMENT' ? 'document' : 'field'}
</Trans>
)}
</DialogDescription>
</DialogHeader>
@ -78,6 +90,7 @@ export const DocumentSigningAuthDialog = ({
))
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
<DocumentSigningAuth2FA
actionTarget={actionTarget === 'DOCUMENT' ? 'DOCUMENT' : 'FIELD'}
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}

View File

@ -43,6 +43,7 @@ export type DocumentSigningAuthContextValue = {
setPreferredPasskeyId: (_value: string | null) => void;
user?: SessionUser | null;
refetchPasskeys: () => Promise<void>;
isEnterprise: boolean;
};
const DocumentSigningAuthContext = createContext<DocumentSigningAuthContextValue | null>(null);
@ -66,6 +67,7 @@ export interface DocumentSigningAuthProviderProps {
recipient: Recipient;
user?: SessionUser | null;
children: React.ReactNode;
isEnterprise: boolean;
}
export const DocumentSigningAuthProvider = ({
@ -73,6 +75,7 @@ export const DocumentSigningAuthProvider = ({
recipient: initialRecipient,
user,
children,
isEnterprise,
}: DocumentSigningAuthProviderProps) => {
const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions);
const [recipient, setRecipient] = useState(initialRecipient);
@ -138,8 +141,13 @@ export const DocumentSigningAuthProvider = ({
.exhaustive();
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
// Directly run callback if no auth required.
if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) {
// Determine if authentication is required based on enterprise status and action target.
const requiresAuthTrigger = isEnterprise
? derivedRecipientActionAuth && options.actionTarget === FieldType.SIGNATURE
: derivedRecipientActionAuth && options.actionTarget === 'DOCUMENT';
// Directly run callback if no auth trigger is needed.
if (!requiresAuthTrigger) {
await options.onReauthFormSubmit();
return;
}
@ -198,6 +206,7 @@ export const DocumentSigningAuthProvider = ({
preferredPasskeyId,
setPreferredPasskeyId,
refetchPasskeys,
isEnterprise,
}}
>
{children}
@ -218,6 +227,8 @@ export const DocumentSigningAuthProvider = ({
type ExecuteActionAuthProcedureOptions = Omit<
DocumentSigningAuthDialogProps,
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole'
>;
> & {
actionTarget: FieldType | 'DOCUMENT';
};
DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider';

View File

@ -28,6 +28,7 @@ import {
AssistantConfirmationDialog,
type NextSigner,
} from '../../dialogs/assistant-confirmation-dialog';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
@ -39,6 +40,7 @@ export type DocumentSigningFormProps = {
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void;
isEnterprise: boolean;
};
export const DocumentSigningForm = ({
@ -49,6 +51,7 @@ export const DocumentSigningForm = ({
isRecipientsTurn,
allRecipients = [],
setSelectedSignerId,
isEnterprise,
}: DocumentSigningFormProps) => {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
@ -62,6 +65,7 @@ export const DocumentSigningForm = ({
const assistantSignersId = useId();
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
@ -114,11 +118,16 @@ export const DocumentSigningForm = ({
setIsAssistantSubmitting(true);
try {
await completeDocument(undefined, nextSigner);
await executeActionAuthProcedure({
actionTarget: 'DOCUMENT',
onReauthFormSubmit: async (authOptions) => {
await completeDocument(authOptions, nextSigner);
},
});
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while completing the document. Please try again.',
title: _(msg`Error`),
description: _(msg`An error occurred while completing the document. Please try again.`),
variant: 'destructive',
});
@ -229,7 +238,12 @@ export const DocumentSigningForm = ({
fields={fields}
fieldsValidated={fieldsValidated}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
await executeActionAuthProcedure({
actionTarget: 'DOCUMENT',
onReauthFormSubmit: async (authOptions) => {
await completeDocument(authOptions, nextSigner);
},
});
}}
role={recipient.role}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
@ -409,7 +423,12 @@ export const DocumentSigningForm = ({
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
await executeActionAuthProcedure({
actionTarget: 'DOCUMENT',
onReauthFormSubmit: async (authOptions) => {
await completeDocument(authOptions, nextSigner);
},
});
}}
role={recipient.role}
allowDictateNextSigner={

View File

@ -47,6 +47,7 @@ export type DocumentSigningPageViewProps = {
completedFields: CompletedField[];
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
isEnterprise: boolean;
};
export const DocumentSigningPageView = ({
@ -56,6 +57,7 @@ export const DocumentSigningPageView = ({
completedFields,
isRecipientsTurn,
allRecipients = [],
isEnterprise,
}: DocumentSigningPageViewProps) => {
const { documentData, documentMeta } = document;
@ -153,6 +155,7 @@ export const DocumentSigningPageView = ({
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
setSelectedSignerId={setSelectedSignerId}
isEnterprise={isEnterprise}
/>
</div>
</div>

View File

@ -152,7 +152,7 @@ export const TemplateEditForm = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while updating the document settings.`),
description: _(msg`An error occurred while updating the template settings.`),
variant: 'destructive',
});
}

View File

@ -94,6 +94,7 @@ export default function DirectTemplatePage() {
documentAuthOptions={template.authOptions}
recipient={directTemplateRecipient}
user={user}
isEnterprise={false}
>
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<h1

View File

@ -6,6 +6,7 @@ import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
@ -60,6 +61,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
const isEnterprise = user?.id
? await isUserEnterprise({ userId: user.id }).catch(() => false)
: false;
const recipientWithFields = { ...recipient, fields };
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
@ -115,6 +120,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
isDocumentAccessValid: false,
recipientEmail: recipient.email,
recipientHasAccount,
isEnterprise,
} as const);
}
@ -149,6 +155,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
completedFields,
recipientSignature,
isRecipientsTurn,
isEnterprise,
} as const);
}
@ -176,6 +183,7 @@ export default function SigningPage() {
isRecipientsTurn,
allRecipients,
recipientWithFields,
isEnterprise,
} = data;
if (document.deletedAt || document.status === DocumentStatus.REJECTED) {
@ -241,6 +249,7 @@ export default function SigningPage() {
documentAuthOptions={document.authOptions}
recipient={recipient}
user={user}
isEnterprise={isEnterprise}
>
<DocumentSigningPageView
recipient={recipientWithFields}
@ -249,6 +258,7 @@ export default function SigningPage() {
completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
isEnterprise={isEnterprise}
/>
</DocumentSigningAuthProvider>
</DocumentSigningProvider>

View File

@ -143,6 +143,7 @@ export default function EmbedDirectTemplatePage() {
documentAuthOptions={template.authOptions}
recipient={recipient}
user={user}
isEnterprise={isEnterpriseDocument}
>
<DocumentSigningRecipientProvider recipient={recipient}>
<EmbedDirectTemplateClientPage

View File

@ -168,6 +168,7 @@ export default function EmbedSignDocumentPage() {
documentAuthOptions={document.authOptions}
recipient={recipient}
user={user}
isEnterprise={isEnterpriseDocument}
>
<EmbedSignDocumentClientPage
token={token}

View File

@ -33,8 +33,8 @@
"@lingui/react": "^5.2.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@react-router/node": "^7.1.5",
"@react-router/serve": "^7.1.5",
"@react-router/node": "^7.6.0",
"@react-router/serve": "^7.6.0",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"autoprefixer": "^10.4.13",
@ -49,8 +49,8 @@
"luxon": "^3.4.0",
"papaparse": "^5.4.1",
"plausible-tracker": "^0.3.9",
"posthog-js": "^1.224.0",
"posthog-node": "^4.8.1",
"posthog-js": "^1.245.0",
"posthog-node": "^4.17.0",
"react": "^18",
"react-call": "^1.3.0",
"react-dom": "^18",
@ -59,7 +59,7 @@
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^5.4.0",
"react-rnd": "^10.4.1",
"react-router": "^7.1.5",
"react-router": "^7.6.0",
"recharts": "^2.7.2",
"remeda": "^2.17.3",
"remix-themes": "^2.0.4",
@ -75,9 +75,9 @@
"@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.26.0",
"@lingui/babel-plugin-lingui-macro": "^5.2.0",
"@lingui/vite-plugin": "^5.2.0",
"@react-router/dev": "^7.1.5",
"@react-router/remix-routes-option-adapter": "^7.1.5",
"@lingui/vite-plugin": "^5.3.1",
"@react-router/dev": "^7.6.0",
"@react-router/remix-routes-option-adapter": "^7.6.0",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^28.0.2",
"@rollup/plugin-node-resolve": "^16.0.0",
@ -91,12 +91,12 @@
"@types/react-dom": "^18",
"@types/ua-parser-js": "^0.7.39",
"cross-env": "^7.0.3",
"esbuild": "0.24.2",
"esbuild": "^0.25.4",
"remix-flat-routes": "^0.8.4",
"rollup": "^4.34.5",
"tsx": "^4.19.2",
"typescript": "5.6.2",
"vite": "^6.1.0",
"vite": "^6.3.5",
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},

View File

@ -35,12 +35,27 @@ export default defineConfig({
],
ssr: {
noExternal: ['react-dropzone', 'plausible-tracker', 'pdfjs-dist'],
external: ['@node-rs/bcrypt', '@prisma/client', '@documenso/tailwind-config'],
external: [
'@node-rs/bcrypt',
'@prisma/client',
'@documenso/tailwind-config',
'playwright',
'playwright-core',
'@playwright/browser-chromium',
],
},
optimizeDeps: {
entries: ['./app/**/*', '../../packages/ui/**/*', '../../packages/lib/**/*'],
include: ['prop-types', 'file-selector', 'attr-accept'],
exclude: ['node_modules', '@node-rs/bcrypt', '@documenso/pdf-sign', 'sharp'],
exclude: [
'node_modules',
'@node-rs/bcrypt',
'@documenso/pdf-sign',
'sharp',
'playwright',
'playwright-core',
'@playwright/browser-chromium',
],
},
resolve: {
alias: {
@ -68,7 +83,8 @@ export default defineConfig({
'@documenso/pdf-sign',
'@aws-sdk/cloudfront-signer',
'nodemailer',
'playwright',
/playwright/,
'@playwright/browser-chromium',
],
},
},

View File

@ -114,4 +114,4 @@ COPY --chown=nodejs:nodejs ./docker/start.sh /app/apps/remix/start.sh
WORKDIR /app/apps/remix
CMD ["sh", "start.sh"]
CMD ["sh", "start.sh"]

20588
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -44,18 +44,22 @@
"@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0",
"@lingui/cli": "^5.2.0",
"@trigger.dev/cli": "^2.3.18",
"dotenv": "^16.3.1",
"dotenv-cli": "^7.3.0",
"dotenv": "^16.5.0",
"dotenv-cli": "^8.0.0",
"eslint": "^8.40.0",
"eslint-config-custom": "*",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"playwright": "1.43.0",
"playwright": "1.52.0",
"prettier": "^3.3.3",
"rimraf": "^5.0.1",
"turbo": "^1.9.3",
"vite": "^6.1.0"
"vite": "^6.3.5",
"@prisma/client": "^6.8.2",
"prisma": "^6.8.2",
"prisma-extension-kysely": "^3.0.0",
"prisma-kysely": "^1.8.0",
"nodemailer": "^6.10.1"
},
"name": "@documenso/root",
"workspaces": [
@ -80,4 +84,4 @@
"trigger.dev": {
"endpointId": "documenso-app"
}
}
}

View File

@ -20,11 +20,11 @@
"@ts-rest/core": "^3.30.5",
"@ts-rest/open-api": "^3.33.0",
"@ts-rest/serverless": "^3.30.5",
"@types/swagger-ui-react": "^4.18.3",
"@types/swagger-ui-react": "^5.18.0",
"luxon": "^3.4.0",
"superjson": "^1.13.1",
"swagger-ui-react": "^5.11.0",
"swagger-ui-react": "^5.21.0",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
}
}
}

View File

@ -116,15 +116,15 @@ test.describe('[EE_ONLY]', () => {
redirectPath: `/documents/${document.id}/edit`,
});
// Global action auth should not be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Global action auth should now be visible for all users
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
// Next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Advanced settings should not be visible.
await expect(page.getByLabel('Show advanced settings')).not.toBeVisible();
// Advanced settings should now be visible for all users
await expect(page.getByLabel('Show advanced settings')).toBeVisible();
});
});
@ -146,8 +146,8 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Action auth should now be visible for all users
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
// Save the settings by going to the next step.

View File

@ -256,10 +256,16 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
});
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn')
.click();
await expect(async () => {
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn')
.click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
}).toPass();
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).click();
@ -267,11 +273,16 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
await page.waitForTimeout(1000);
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn')
.click();
await expect(async () => {
await page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn')
.click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
}).toPass();
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).click();

View File

@ -342,7 +342,13 @@ test('user can move a document to a document folder', async ({ page }) => {
redirectPath: '/documents',
});
await page.getByTestId('document-table-action-btn').click();
await expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Proposals' }).click();
@ -379,7 +385,13 @@ test('user can move a document from folder to the root', async ({ page }) => {
await page.getByText('Proposals').click();
await page.getByTestId('document-table-action-btn').click();
await expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Root' }).click();
@ -791,7 +803,13 @@ test('user can move a template to a template folder', async ({ page }) => {
redirectPath: '/templates',
});
await page.getByTestId('template-table-action-btn').click();
await expect(async () => {
await page.getByTestId('template-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Client Templates' }).click();
@ -828,7 +846,13 @@ test('user can move a template from a folder to the root', async ({ page }) => {
await page.getByText('Client Templates').click();
await page.getByTestId('template-table-action-btn').click();
await expect(async () => {
await page.getByTestId('template-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Root' }).click();

View File

@ -230,13 +230,21 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await page.getByRole('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Resend' }).click();
await expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem').filter({ hasText: 'Resend' }).click();
await page.getByLabel('test.documenso.com').first().click();
await page.getByRole('button', { name: 'Send reminder' }).click();
await expect(page.getByRole('status')).toContainText('Document re-sent');
await expect(
page.getByRole('status').filter({ hasText: 'Document re-sent' }).first(),
).toBeVisible();
});
test('[TEAMS]: delete draft team document', async ({ page }) => {
@ -248,7 +256,13 @@ test('[TEAMS]: delete draft team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=DRAFT`,
});
await page.getByRole('row').getByRole('button').nth(1).click();
await expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
@ -286,7 +300,13 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await page.getByRole('row').getByRole('button').nth(1).click();
await expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
@ -325,7 +345,13 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
await page.getByRole('row').getByRole('button').nth(2).click();
await expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');

View File

@ -113,8 +113,8 @@ test.describe('[EE_ONLY]', () => {
redirectPath: `/templates/${template.id}/edit`,
});
// Global action auth should not be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Global action auth should now be visible for all users
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
// Next step.
await page.getByRole('button', { name: 'Continue' }).click();
@ -143,8 +143,8 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Action auth should now be visible for all users
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();

View File

@ -1,5 +1,6 @@
import { type Page, expect, test } from '@playwright/test';
import { alphaid } from '@documenso/lib/universal/id';
import {
extractUserVerificationToken,
seedTestEmail,
@ -23,9 +24,11 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page
await signSignaturePad(page);
await page.getByRole('button', { name: 'Next', exact: true }).click();
await page.getByLabel('Public profile username').fill(Date.now().toString());
await page.getByRole('button', { name: 'Complete', exact: true }).click();
await page.getByLabel('Public profile username').fill(alphaid(10));
await page.getByLabel('Public profile username').blur();
await page.getByRole('button', { name: 'Complete' }).click();
await page.waitForURL('/unverified-account');

View File

@ -12,13 +12,13 @@
"keywords": [],
"author": "",
"devDependencies": {
"@playwright/test": "^1.18.1",
"@playwright/test": "1.52.0",
"@types/node": "^20",
"@documenso/lib": "*",
"@documenso/prisma": "*",
"pdf-lib": "^1.17.1"
},
"dependencies": {
"start-server-and-test": "^2.0.1"
"start-server-and-test": "^2.0.12"
}
}
}

View File

@ -17,7 +17,7 @@ export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: false,
workers: '50%',
workers: 1,
maxFailures: process.env.CI ? 1 : undefined,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,

View File

@ -18,8 +18,8 @@
"arctic": "^3.1.0",
"hono": "4.7.0",
"luxon": "^3.5.0",
"nanoid": "^4.0.2",
"nanoid": "^5.1.5",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
}
}
}

View File

@ -36,7 +36,7 @@
"@react-email/section": "0.0.10",
"@react-email/tailwind": "0.0.9",
"@react-email/text": "0.0.6",
"nodemailer": "6.9.9",
"nodemailer": "^6.10.1",
"react-email": "1.9.5",
"resend": "2.0.0"
},

View File

@ -0,0 +1,43 @@
import { Trans } from '@lingui/react/macro';
import { Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export type TemplateVerificationCodeProps = {
verificationCode: string;
assetBaseUrl: string;
};
export const TemplateVerificationCode = ({
verificationCode,
assetBaseUrl,
}: TemplateVerificationCodeProps) => {
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
<Trans>Your verification code</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Trans>Please use the code below to verify your identity for document signing.</Trans>
</Text>
<Text className="my-6 text-center text-3xl font-bold tracking-widest">
{verificationCode}
</Text>
<Text className="my-1 text-center text-sm text-slate-400">
<Trans>
If you did not request this code, you can ignore this email. The code will expire after
10 minutes.
</Trans>
</Text>
</Section>
</>
);
};
export default TemplateVerificationCode;

View File

@ -0,0 +1,62 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Hr, Html, Img, Preview, Section } from '../components';
import { useBranding } from '../providers/branding';
import { TemplateFooter } from '../template-components/template-footer';
import type { TemplateVerificationCodeProps } from '../template-components/template-verification-code';
import { TemplateVerificationCode } from '../template-components/template-verification-code';
export type VerificationCodeTemplateProps = Partial<TemplateVerificationCodeProps>;
export const VerificationCodeTemplate = ({
verificationCode = '000000',
assetBaseUrl = 'http://localhost:3002',
}: VerificationCodeTemplateProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`Your verification code for document signing`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
<Section className="p-2">
{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"
/>
)}
<TemplateVerificationCode
verificationCode={verificationCode}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default VerificationCodeTemplate;

View File

@ -1,13 +1,7 @@
module.exports = {
extends: [
'next',
'turbo',
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:package-json/recommended',
],
extends: ['next', 'turbo', 'eslint:recommended', 'plugin:@typescript-eslint/recommended'],
plugins: ['package-json', 'unused-imports'],
plugins: ['unused-imports'],
env: {
es2022: true,

View File

@ -10,11 +10,11 @@
"@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1",
"eslint": "^8.57.0",
"eslint-config-next": "^14.1.3",
"eslint-config-next": "^14.2.28",
"eslint-config-turbo": "^1.12.5",
"eslint-plugin-package-json": "^0.10.4",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-unused-imports": "^3.1.0",
"eslint-plugin-package-json": "^0.31.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-unused-imports": "^4.1.4",
"typescript": "5.6.2"
}
}

View File

@ -15,7 +15,6 @@
"clean": "rimraf node_modules"
},
"dependencies": {
"@auth/kysely-adapter": "^0.6.0",
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/cloudfront-signer": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
@ -41,12 +40,13 @@
"kysely": "0.26.3",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"nanoid": "^4.0.2",
"nanoid": "^5.1.5",
"oslo": "^0.17.0",
"pdf-lib": "^1.17.1",
"pg": "^8.11.3",
"playwright": "1.43.0",
"posthog-js": "^1.224.0",
"playwright": "1.52.0",
"posthog-js": "^1.245.0",
"posthog-node": "^4.17.0",
"react": "^18",
"remeda": "^2.17.3",
"sharp": "0.32.6",
@ -55,7 +55,7 @@
"zod": "3.24.1"
},
"devDependencies": {
"@playwright/browser-chromium": "1.43.0",
"@playwright/browser-chromium": "1.52.0",
"@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4"
}

View File

@ -0,0 +1,120 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { randomInt } from 'crypto';
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
import { mailer } from '@documenso/email/mailer';
import { VerificationCodeTemplate } from '@documenso/email/templates/verification-code';
import { AppError } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
const ExtendedAuthErrorCode = {
...AuthenticationErrorCode,
InternalError: 'INTERNAL_ERROR',
VerificationNotFound: 'VERIFICATION_NOT_FOUND',
VerificationExpired: 'VERIFICATION_EXPIRED',
};
const VERIFICATION_CODE_EXPIRY = 10 * 60 * 1000;
export type SendEmailVerificationOptions = {
userId: number;
email: string;
};
export const sendEmailVerification = async ({ userId, email }: SendEmailVerificationOptions) => {
try {
const verificationCode = randomInt(100000, 1000000).toString();
const i18n = await getI18nInstance();
await prisma.userTwoFactorEmailVerification.upsert({
where: {
userId,
},
create: {
userId,
verificationCode,
expiresAt: new Date(Date.now() + VERIFICATION_CODE_EXPIRY),
},
update: {
verificationCode,
expiresAt: new Date(Date.now() + VERIFICATION_CODE_EXPIRY),
},
});
const template = createElement(VerificationCodeTemplate, {
verificationCode,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: 'en' }),
renderEmailWithI18N(template, { lang: 'en', plainText: true }),
]);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`Your verification code for document signing`),
html,
text,
});
return { success: true };
} catch (error) {
console.error('Error sending email verification', error);
throw new AppError(ExtendedAuthErrorCode.InternalError);
}
};
export type VerifyEmailCodeOptions = {
userId: number;
code: string;
};
export const verifyEmailCode = async ({ userId, code }: VerifyEmailCodeOptions) => {
try {
const verification = await prisma.userTwoFactorEmailVerification.findUnique({
where: {
userId,
},
});
if (!verification) {
throw new AppError(ExtendedAuthErrorCode.VerificationNotFound);
}
if (verification.expiresAt < new Date()) {
throw new AppError(ExtendedAuthErrorCode.VerificationExpired);
}
if (verification.verificationCode !== code) {
throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode);
}
await prisma.userTwoFactorEmailVerification.delete({
where: {
userId,
},
});
return { success: true };
} catch (error) {
console.error('Error verifying email code', error);
if (error instanceof AppError) {
throw error;
}
throw new AppError(ExtendedAuthErrorCode.InternalError);
}
};

View File

@ -1,8 +1,6 @@
import { DocumentVisibility } from '@prisma/client';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
@ -117,7 +115,6 @@ export const updateDocument = async ({
}
}
// If no data just return the document since this function is normally chained after a meta update.
if (!data || Object.values(data).length === 0) {
console.log('no data');
return document;
@ -130,26 +127,11 @@ export const updateDocument = async ({
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
// If the new global auth values aren't passed in, fallback to the current document values.
const newGlobalAccessAuth =
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
const newGlobalActionAuth =
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
}
const isTitleSame = data.title === undefined || data.title === document.title;
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
const isGlobalAccessSame =

View File

@ -2,6 +2,7 @@ import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
@ -13,7 +14,7 @@ import { prisma } from '@documenso/prisma';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuth } from '../../types/document-auth';
import type { TRecipientActionAuth, TRecipientActionAuthTypes } from '../../types/document-auth';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
@ -23,6 +24,7 @@ import {
} from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { validateFieldAuth } from '../document/validate-field-auth';
export type SignFieldWithTokenOptions = {
@ -169,13 +171,24 @@ export const signFieldWithToken = async ({
}
}
const derivedRecipientActionAuth = await validateFieldAuth({
documentAuthOptions: document.authOptions,
recipient,
field,
userId,
authOptions,
});
const isEnterprise = userId ? await isUserEnterprise({ userId }) : false;
let requiredAuthType: TRecipientActionAuthTypes | null = null;
if (isEnterprise) {
requiredAuthType = await validateFieldAuth({
documentAuthOptions: document.authOptions,
recipient,
field,
userId,
authOptions,
});
} else {
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
requiredAuthType = derivedRecipientActionAuth;
}
const documentMeta = await prisma.documentMeta.findFirst({
where: {
@ -286,9 +299,9 @@ export const signFieldWithToken = async ({
}),
)
.exhaustive(),
fieldSecurity: derivedRecipientActionAuth
fieldSecurity: requiredAuthType
? {
type: derivedRecipientActionAuth,
type: requiredAuthType,
}
: undefined,
},

View File

@ -5,6 +5,7 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
export type UpdateTemplateOptions = {
@ -74,7 +75,11 @@ export const updateTemplate = async ({
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
// Only ACCOUNT and PASSKEY require enterprise permissions
if (
newGlobalActionAuth &&
(newGlobalActionAuth === DocumentAuth.ACCOUNT || newGlobalActionAuth === DocumentAuth.PASSKEY)
) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
@ -82,7 +87,7 @@ export const updateTemplate = async ({
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
message: 'You do not have permission to set this action auth type',
});
}
}

View File

@ -111,11 +111,6 @@ msgstr "{0, plural, one {1 Empfänger} other {# Empfänger}}"
msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}"
msgstr "{0, plural, one {Warte auf 1 Empfänger} other {Warte auf # Empfänger}}"
#. placeholder {0}: selectedValues.length
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
msgid "{0, plural, zero {Select values} other {# selected...}}"
msgstr "{0, plural, zero {Werte auswählen} other {# ausgewählt...}}"
#. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType])
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
msgid "{0}"
@ -873,6 +868,7 @@ msgstr "Erweiterte Optionen"
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
#: packages/ui/primitives/template-flow/add-template-fields.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Advanced settings"
msgstr "Erweiterte Einstellungen"
@ -2725,9 +2721,14 @@ msgstr "Aufgrund einer unbezahlten Rechnung wurde Ihrem Team der Zugriff eingesc
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
msgid "Duplicate"
msgstr "Duplizieren"
#: packages/ui/primitives/document-flow/field-item.tsx
msgid "Duplicate on all pages"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
#: apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
@ -3915,7 +3916,6 @@ msgstr "Keine gültigen direkten Vorlagen gefunden"
msgid "No valid recipients found"
msgstr "Keine gültigen Empfänger gefunden"
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
#: apps/remix/app/components/general/multiselect-role-combobox.tsx
#: packages/ui/primitives/multi-select-combobox.tsx
#: packages/ui/primitives/combobox.tsx
@ -4607,6 +4607,7 @@ msgstr "Erinnerung: Bitte {recipientActionVerb} dein Dokument"
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Remove"
msgstr "Entfernen"
@ -4849,6 +4850,11 @@ msgstr "Standardoption auswählen"
msgid "Select passkey"
msgstr "Passkey auswählen"
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
msgid "Select triggers"
msgstr ""
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx

View File

@ -106,11 +106,6 @@ msgstr "{0, plural, one {1 Recipient} other {# Recipients}}"
msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}"
msgstr "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}"
#. placeholder {0}: selectedValues.length
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
msgid "{0, plural, zero {Select values} other {# selected...}}"
msgstr "{0, plural, zero {Select values} other {# selected...}}"
#. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType])
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
msgid "{0}"
@ -868,6 +863,7 @@ msgstr "Advanced Options"
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
#: packages/ui/primitives/template-flow/add-template-fields.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Advanced settings"
msgstr "Advanced settings"
@ -2720,9 +2716,14 @@ msgstr "Due to an unpaid invoice, your team has been restricted. Please settle t
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
msgid "Duplicate"
msgstr "Duplicate"
#: packages/ui/primitives/document-flow/field-item.tsx
msgid "Duplicate on all pages"
msgstr "Duplicate on all pages"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
#: apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
@ -3910,7 +3911,6 @@ msgstr "No valid direct templates found"
msgid "No valid recipients found"
msgstr "No valid recipients found"
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
#: apps/remix/app/components/general/multiselect-role-combobox.tsx
#: packages/ui/primitives/multi-select-combobox.tsx
#: packages/ui/primitives/combobox.tsx
@ -4602,6 +4602,7 @@ msgstr "Reminder: Please {recipientActionVerb} your document"
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Remove"
msgstr "Remove"
@ -4844,6 +4845,11 @@ msgstr "Select default option"
msgid "Select passkey"
msgstr "Select passkey"
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
msgid "Select triggers"
msgstr "Select triggers"
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx

View File

@ -111,11 +111,6 @@ msgstr "{0, plural, one {1 Destinatario} other {# Destinatarios}}"
msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}"
msgstr "{0, plural, one {Esperando 1 destinatario} other {Esperando # destinatarios}}"
#. placeholder {0}: selectedValues.length
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
msgid "{0, plural, zero {Select values} other {# selected...}}"
msgstr "{0, plural, zero {Selecciona valores} other {# seleccionados...}}"
#. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType])
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
msgid "{0}"
@ -873,6 +868,7 @@ msgstr "Opciones avanzadas"
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
#: packages/ui/primitives/template-flow/add-template-fields.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Advanced settings"
msgstr "Configuraciones avanzadas"
@ -2725,9 +2721,14 @@ msgstr "Debido a una factura impaga, tu equipo ha sido restringido. Realiza el p
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
msgid "Duplicate"
msgstr "Duplicar"
#: packages/ui/primitives/document-flow/field-item.tsx
msgid "Duplicate on all pages"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
#: apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
@ -3915,7 +3916,6 @@ msgstr "No se encontraron plantillas directas válidas"
msgid "No valid recipients found"
msgstr "No se encontraron destinatarios válidos"
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
#: apps/remix/app/components/general/multiselect-role-combobox.tsx
#: packages/ui/primitives/multi-select-combobox.tsx
#: packages/ui/primitives/combobox.tsx
@ -4607,6 +4607,7 @@ msgstr "Recordatorio: Por favor {recipientActionVerb} tu documento"
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Remove"
msgstr "Eliminar"
@ -4849,6 +4850,11 @@ msgstr "Seleccionar opción predeterminada"
msgid "Select passkey"
msgstr "Seleccionar clave de acceso"
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
msgid "Select triggers"
msgstr ""
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx

View File

@ -111,11 +111,6 @@ msgstr "{0, plural, one {1 Destinataire} other {# Destinataires}}"
msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}"
msgstr "{0, plural, one {En attente d'1 destinataire} other {En attente de # destinataires}}"
#. placeholder {0}: selectedValues.length
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
msgid "{0, plural, zero {Select values} other {# selected...}}"
msgstr "{0, plural, zero {Sélectionner des valeurs} other {# sélectionnées...}}"
#. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType])
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
msgid "{0}"
@ -873,6 +868,7 @@ msgstr "Options avancées"
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
#: packages/ui/primitives/template-flow/add-template-fields.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Advanced settings"
msgstr "Paramètres avancés"
@ -2725,9 +2721,14 @@ msgstr "En raison d'une facture impayée, votre équipe a été restreinte. Veui
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
msgid "Duplicate"
msgstr "Dupliquer"
#: packages/ui/primitives/document-flow/field-item.tsx
msgid "Duplicate on all pages"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
#: apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
@ -3915,7 +3916,6 @@ msgstr "Aucun modèle direct valide trouvé"
msgid "No valid recipients found"
msgstr "Aucun destinataire valide trouvé"
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
#: apps/remix/app/components/general/multiselect-role-combobox.tsx
#: packages/ui/primitives/multi-select-combobox.tsx
#: packages/ui/primitives/combobox.tsx
@ -4607,6 +4607,7 @@ msgstr "Rappel : Veuillez {recipientActionVerb} votre document"
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Remove"
msgstr "Retirer"
@ -4849,6 +4850,11 @@ msgstr "Sélectionner l'option par défaut"
msgid "Select passkey"
msgstr "Sélectionner la clé d'authentification"
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
msgid "Select triggers"
msgstr ""
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx

View File

@ -111,11 +111,6 @@ msgstr "{0, plural, one {1 destinatario} other {# destinatari}}"
msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}"
msgstr "{0, plural, one {In attesa di 1 destinatario} other {In attesa di # destinatari}}"
#. placeholder {0}: selectedValues.length
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
msgid "{0, plural, zero {Select values} other {# selected...}}"
msgstr "{0, plural, zero {Seleziona valori} other {# selezionati...}}"
#. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType])
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
msgid "{0}"
@ -873,6 +868,7 @@ msgstr "Opzioni avanzate"
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
#: packages/ui/primitives/template-flow/add-template-fields.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Advanced settings"
msgstr "Impostazioni avanzate"
@ -2725,9 +2721,14 @@ msgstr "A causa di una fattura non pagata, il vostro team è stato limitato. Si
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
msgid "Duplicate"
msgstr "Duplica"
#: packages/ui/primitives/document-flow/field-item.tsx
msgid "Duplicate on all pages"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
#: apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
@ -3915,7 +3916,6 @@ msgstr "Nessun modello diretto valido trovato"
msgid "No valid recipients found"
msgstr "Nessun destinatario valido trovato"
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
#: apps/remix/app/components/general/multiselect-role-combobox.tsx
#: packages/ui/primitives/multi-select-combobox.tsx
#: packages/ui/primitives/combobox.tsx
@ -4607,6 +4607,7 @@ msgstr "Promemoria: per favore {recipientActionVerb} il tuo documento"
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Remove"
msgstr "Rimuovi"
@ -4849,6 +4850,11 @@ msgstr "Seleziona opzione predefinita"
msgid "Select passkey"
msgstr "Seleziona una chiave di accesso"
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
msgid "Select triggers"
msgstr ""
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx

View File

@ -111,11 +111,6 @@ msgstr "{0, plural, one {1 odbiorca} few {# odbiorców} many {# odbiorców} othe
msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}"
msgstr "{0, plural, one {Czekam na 1 odbiorcę} few {Czekam na # odbiorców} many {Czekam na # odbiorców} other {Czekam na # odbiorców}}"
#. placeholder {0}: selectedValues.length
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
msgid "{0, plural, zero {Select values} other {# selected...}}"
msgstr "{0, plural, zero {Wybierz wartości} one {# wybrana...} few {# wybrane...} many {# wybranych...} other {# wybranych...}}"
#. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType])
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
msgid "{0}"
@ -873,6 +868,7 @@ msgstr "Opcje zaawansowane"
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
#: packages/ui/primitives/template-flow/add-template-fields.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Advanced settings"
msgstr "Ustawienia zaawansowane"
@ -2725,9 +2721,14 @@ msgstr "Z powodu nieopłaconej faktury Twój zespół został ograniczony. Prosz
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
msgid "Duplicate"
msgstr "Zduplikuj"
#: packages/ui/primitives/document-flow/field-item.tsx
msgid "Duplicate on all pages"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
#: apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
@ -3915,7 +3916,6 @@ msgstr "Nie znaleziono ważnych szablonów bezpośrednich"
msgid "No valid recipients found"
msgstr "Nie znaleziono ważnych odbiorców"
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
#: apps/remix/app/components/general/multiselect-role-combobox.tsx
#: packages/ui/primitives/multi-select-combobox.tsx
#: packages/ui/primitives/combobox.tsx
@ -4607,6 +4607,7 @@ msgstr "Przypomnienie: Proszę {recipientActionVerb} Twój dokument"
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
#: packages/ui/primitives/document-flow/field-item.tsx
#: packages/ui/primitives/document-flow/add-fields.tsx
msgid "Remove"
msgstr "Usuń"
@ -4849,6 +4850,11 @@ msgstr "Wybierz domyślną opcję"
msgid "Select passkey"
msgstr "Wybierz klucz uwierzytelniający"
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
msgid "Select triggers"
msgstr ""
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx

View File

@ -68,6 +68,16 @@ export const ZDocumentActionAuthTypesSchema = z
'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.',
);
/**
* The non-enterprise document action auth methods.
*
* Only includes options available to non-enterprise users.
*/
export const ZNonEnterpriseDocumentActionAuthTypesSchema = z.enum([
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXPLICIT_NONE,
]);
/**
* The recipient access auth methods.
*
@ -102,6 +112,7 @@ export const ZRecipientActionAuthTypesSchema = z
export const DocumentAccessAuth = ZDocumentAccessAuthTypesSchema.Enum;
export const DocumentActionAuth = ZDocumentActionAuthTypesSchema.Enum;
export const NonEnterpriseDocumentActionAuth = ZNonEnterpriseDocumentActionAuthTypesSchema.Enum;
export const RecipientAccessAuth = ZRecipientAccessAuthTypesSchema.Enum;
export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum;
@ -152,6 +163,9 @@ export type TDocumentAccessAuth = z.infer<typeof ZDocumentAccessAuthSchema>;
export type TDocumentAccessAuthTypes = z.infer<typeof ZDocumentAccessAuthTypesSchema>;
export type TDocumentActionAuth = z.infer<typeof ZDocumentActionAuthSchema>;
export type TDocumentActionAuthTypes = z.infer<typeof ZDocumentActionAuthTypesSchema>;
export type TNonEnterpriseDocumentActionAuthTypes = z.infer<
typeof ZNonEnterpriseDocumentActionAuthTypesSchema
>;
export type TRecipientAccessAuth = z.infer<typeof ZRecipientAccessAuthSchema>;
export type TRecipientAccessAuthTypes = z.infer<typeof ZRecipientAccessAuthTypesSchema>;
export type TRecipientActionAuth = z.infer<typeof ZRecipientActionAuthSchema>;

View File

@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "UserTwoFactorEmailVerification" (
"userId" INTEGER NOT NULL,
"verificationCode" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserTwoFactorEmailVerification_pkey" PRIMARY KEY ("userId")
);
-- AddForeignKey
ALTER TABLE "UserTwoFactorEmailVerification" ADD CONSTRAINT "UserTwoFactorEmailVerification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -21,18 +21,18 @@
"seed": "tsx ./seed-database.ts"
},
"dependencies": {
"@prisma/client": "^5.4.2",
"@prisma/client": "^6.8.2",
"kysely": "0.26.3",
"prisma": "^5.4.2",
"prisma-extension-kysely": "^2.1.0",
"prisma": "^6.8.2",
"prisma-extension-kysely": "^3.0.0",
"prisma-kysely": "^1.8.0",
"prisma-json-types-generator": "^3.2.2",
"ts-pattern": "^5.0.6",
"zod-prisma-types": "3.1.9"
"zod-prisma-types": "3.2.4"
},
"devDependencies": {
"dotenv": "^16.3.1",
"dotenv-cli": "^7.3.0",
"dotenv": "^16.5.0",
"dotenv-cli": "^8.0.0",
"tsx": "^4.19.2",
"typescript": "5.6.2"
}

View File

@ -53,19 +53,20 @@ model User {
avatarImageId String?
disabled Boolean @default(false)
accounts Account[]
sessions Session[]
documents Document[]
folders Folder[]
subscriptions Subscription[]
passwordResetTokens PasswordResetToken[]
ownedTeams Team[]
ownedPendingTeams TeamPending[]
teamMembers TeamMember[]
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String?
url String? @unique
accounts Account[]
sessions Session[]
documents Document[]
folders Folder[]
subscriptions Subscription[]
passwordResetTokens PasswordResetToken[]
ownedTeams Team[]
ownedPendingTeams TeamPending[]
teamMembers TeamMember[]
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String?
url String? @unique
twoFactorEmailVerification UserTwoFactorEmailVerification?
profile UserProfile?
verificationTokens VerificationToken[]
@ -839,3 +840,12 @@ model AvatarImage {
team Team[]
user User[]
}
model UserTwoFactorEmailVerification {
userId Int @id
verificationCode String
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

View File

@ -18,6 +18,6 @@
"ts-pattern": "^5.0.5"
},
"devDependencies": {
"vitest": "^2.1.8"
"vitest": "^3.1.4"
}
}

View File

@ -1,5 +1,10 @@
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
import { AppError } from '@documenso/lib/errors/app-error';
import {
sendEmailVerification,
verifyEmailCode,
} from '@documenso/lib/server-only/2fa/send-email-verification';
import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options';
@ -8,6 +13,7 @@ import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey';
import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
@ -15,7 +21,9 @@ import {
ZCreatePasskeyMutationSchema,
ZDeletePasskeyMutationSchema,
ZFindPasskeysQuerySchema,
ZSendEmailVerificationMutationSchema,
ZUpdatePasskeyMutationSchema,
ZVerifyEmailCodeMutationSchema,
} from './schema';
export const authRouter = router({
@ -98,4 +106,68 @@ export const authRouter = router({
requestMetadata: ctx.metadata.requestMetadata,
});
}),
// Email verification for document signing
sendEmailVerification: authenticatedProcedure
.input(ZSendEmailVerificationMutationSchema)
.mutation(async ({ ctx, input }) => {
const { recipientId } = input;
const userId = ctx.user.id;
let email = ctx.user.email;
// If recipientId is provided, fetch that recipient's details
if (recipientId) {
const recipient = await prisma.recipient.findUnique({
where: {
id: recipientId,
},
select: {
email: true,
},
});
if (!recipient) {
throw new AppError('NOT_FOUND', {
message: 'Recipient not found',
});
}
email = recipient.email;
}
return sendEmailVerification({
userId,
email,
});
}),
verifyEmailCode: authenticatedProcedure
.input(ZVerifyEmailCodeMutationSchema)
.mutation(async ({ ctx, input }) => {
const { code, recipientId } = input;
const userId = ctx.user.id;
// If recipientId is provided, check that the user has access to it
if (recipientId) {
const recipient = await prisma.recipient.findUnique({
where: {
id: recipientId,
},
select: {
email: true,
},
});
if (!recipient) {
throw new AppError('NOT_FOUND', {
message: 'Recipient not found',
});
}
}
return verifyEmailCode({
userId,
code,
});
}),
});

View File

@ -71,3 +71,18 @@ export const ZFindPasskeysQuerySchema = ZFindSearchParamsSchema.extend({
});
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
export const ZSendEmailVerificationMutationSchema = z.object({
recipientId: z.number().optional(),
});
export type TSendEmailVerificationMutationSchema = z.infer<
typeof ZSendEmailVerificationMutationSchema
>;
export const ZVerifyEmailCodeMutationSchema = z.object({
code: z.string().min(6).max(6),
recipientId: z.number().optional(),
});
export type TVerifyEmailCodeMutationSchema = z.infer<typeof ZVerifyEmailCodeMutationSchema>;

View File

@ -7,7 +7,11 @@ import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth';
import {
DocumentActionAuth,
DocumentAuth,
NonEnterpriseDocumentActionAuth,
} from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
@ -17,38 +21,47 @@ import {
} from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentGlobalAuthActionSelect = forwardRef<HTMLButtonElement, SelectProps>(
(props, ref) => {
const { _ } = useLingui();
interface DocumentGlobalAuthActionSelectProps extends SelectProps {
isDocumentEnterprise?: boolean;
}
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue
ref={ref}
data-testid="documentActionSelectValue"
placeholder={_(msg`No restrictions`)}
/>
</SelectTrigger>
export const DocumentGlobalAuthActionSelect = forwardRef<
HTMLButtonElement,
DocumentGlobalAuthActionSelectProps
>(({ isDocumentEnterprise, ...props }, ref) => {
const { _ } = useLingui();
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>
<Trans>No restrictions</Trans>
const authTypes = isDocumentEnterprise
? Object.values(DocumentActionAuth).filter((auth) => auth !== DocumentAuth.ACCOUNT)
: Object.values(NonEnterpriseDocumentActionAuth).filter(
(auth) => auth !== DocumentAuth.EXPLICIT_NONE,
);
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue
ref={ref}
data-testid="documentActionSelectValue"
placeholder={_(msg`No restrictions`)}
/>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>
<Trans>No restrictions</Trans>
</SelectItem>
{authTypes.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
{Object.values(DocumentActionAuth)
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
);
))}
</SelectContent>
</Select>
);
});
DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect';

View File

@ -1,10 +1,15 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useLingui } from '@lingui/react/macro';
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@prisma/client';
import { Trans, useLingui } from '@lingui/react/macro';
import {
DocumentStatus,
DocumentVisibility,
type Field,
type Recipient,
SendStatus,
TeamMemberRole,
} from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
@ -274,24 +279,22 @@ export const AddSettingsFormPartial = ({
/>
)}
{isDocumentEnterprise && (
<FormField
control={form.control}
name="globalActionAuth"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Recipient action authentication</Trans>
<DocumentGlobalAuthActionTooltip />
</FormLabel>
<FormField
control={form.control}
name="globalActionAuth"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Recipient action authentication</Trans>
<DocumentGlobalAuthActionTooltip />
</FormLabel>
<FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
)}
<FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<Accordion type="multiple" className="mt-6">
<AccordionItem value="advanced-options" className="border-none">

View File

@ -1,10 +1,14 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentDistributionMethod, type Field, type Recipient } from '@prisma/client';
import { Trans, useLingui } from '@lingui/react/macro';
import {
DocumentDistributionMethod,
DocumentVisibility,
type Field,
type Recipient,
TeamMemberRole,
} from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
@ -366,24 +370,26 @@ export const AddTemplateSettingsFormPartial = ({
)}
/>
{isEnterprise && (
<FormField
control={form.control}
name="globalActionAuth"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Recipient action authentication</Trans>
<DocumentGlobalAuthActionTooltip />
</FormLabel>
<FormField
control={form.control}
name="globalActionAuth"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Recipient action authentication</Trans>
<DocumentGlobalAuthActionTooltip />
</FormLabel>
<FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
)}
<FormControl>
<DocumentGlobalAuthActionSelect
{...field}
onValueChange={field.onChange}
isDocumentEnterprise={isEnterprise}
/>
</FormControl>
</FormItem>
)}
/>
{distributionMethod === DocumentDistributionMethod.EMAIL && (
<Accordion type="multiple">