Compare commits

..

1 Commits

Author SHA1 Message Date
Lucas Smith 607f22513a feat: allow adding audit logs to compeleted document 2025-05-19 09:10:08 +10:00
73 changed files with 13985 additions and 9198 deletions
-2
View File
@@ -1,3 +1 @@
auto-install-peers = true
legacy-peer-deps = true
prefer-dedupe = true
+2 -5
View File
@@ -1,5 +1,3 @@
import nextra from 'nextra';
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: [
@@ -11,10 +9,9 @@ const nextConfig = {
],
};
const withNextra = nextra({
const withNextra = require('nextra')({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.tsx',
codeHighlight: true,
});
export default withNextra(nextConfig);
module.exports = withNextra(nextConfig);
+1 -1
View File
@@ -15,7 +15,7 @@
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"next": "14.2.28",
"next": "14.2.6",
"next-plausible": "^3.12.0",
"nextra": "^2.13.4",
"nextra-theme-docs": "^2.13.4",
-16
View File
@@ -19,22 +19,6 @@ 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);
}();
`,
}}
/>
</>
);
},
+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.5.0",
"next": "14.2.28"
"next": "14.2.6"
},
"devDependencies": {
"@types/node": "^20",
@@ -44,6 +44,7 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
includeSenderDetails: z.boolean(),
includeSigningCertificate: z.boolean(),
includeAuditLog: z.boolean(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
@@ -77,6 +78,7 @@ export const TeamDocumentPreferencesForm = ({
: 'en',
includeSenderDetails: settings?.includeSenderDetails ?? false,
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
includeAuditLog: settings?.includeAuditLog ?? false,
signatureTypes: extractTeamSignatureSettings(settings),
},
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
@@ -91,6 +93,7 @@ export const TeamDocumentPreferencesForm = ({
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
signatureTypes,
} = data;
@@ -101,6 +104,7 @@ export const TeamDocumentPreferencesForm = ({
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
@@ -307,7 +311,7 @@ export const TeamDocumentPreferencesForm = ({
<FormDescription>
<Trans>
Controls whether the signing certificate will be included in the document when
Controls whether the signing certificate will be included with the document when
it is downloaded. The signing certificate can still be downloaded from the logs
page separately.
</Trans>
@@ -316,6 +320,36 @@ export const TeamDocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="includeAuditLog"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Audit Log in the Document</Trans>
</FormLabel>
<div>
<FormControl className="block">
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormDescription>
<Trans>
Controls whether the audit log will be included with the document when it is
downloaded. The audit log can still be downloaded from the logs page separately.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, 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 { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import {
@@ -20,8 +20,6 @@ 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';
@@ -53,7 +51,6 @@ export const DocumentSigningAuth2FA = ({
}: DocumentSigningAuth2FAProps) => {
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
const { toast } = useToast();
const form = useForm<T2FAAuthFormSchema>({
resolver: zodResolver(Z2FAAuthFormSchema),
@@ -63,104 +60,27 @@ export const DocumentSigningAuth2FA = ({
});
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false);
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 [formErrorCode, setFormErrorCode] = useState<string | null>(null);
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);
toast({
title: 'Unauthorized',
description: 'We were unable to verify your details.',
variant: 'destructive',
});
const error = AppError.parseError(err);
setFormErrorCode(error.code);
// Todo: Alert.
}
};
@@ -170,47 +90,21 @@ export const DocumentSigningAuth2FA = ({
});
setIs2FASetupSuccessful(false);
setIsEmailCodeSent(false);
setFormErrorCode(null);
if (open && !user?.twoFactorEnabled) {
setVerificationMethod('email');
}
}, [open, user?.twoFactorEnabled, form]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
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) {
if (!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>
@@ -235,106 +129,59 @@ export const DocumentSigningAuth2FA = ({
}
return (
<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>
)}
<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>
{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>
)}
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<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>
<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>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
{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>
)}
<Button type="submit" loading={isCurrentlyAuthenticating}>
<Trans>{actionTarget === 'DOCUMENT' ? 'Sign Document' : 'Sign Field'}</Trans>
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
);
};
@@ -27,6 +27,7 @@ export type DocumentSigningAuthDialogProps = {
actionTarget: FieldType | 'DOCUMENT';
open: boolean;
onOpenChange: (value: boolean) => void;
/**
* The callback to run when the reauth form is filled out.
*/
@@ -37,7 +38,6 @@ export const DocumentSigningAuthDialog = ({
title,
description,
documentAuthType,
actionTarget,
open,
onOpenChange,
onReauthFormSubmit,
@@ -56,22 +56,10 @@ export const DocumentSigningAuthDialog = ({
<Dialog open={open} onOpenChange={handleOnOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{title ||
(actionTarget === 'DOCUMENT' ? (
<Trans>Sign document</Trans>
) : (
<Trans>Sign field</Trans>
))}
</DialogTitle>
<DialogTitle>{title || <Trans>Sign field</Trans>}</DialogTitle>
<DialogDescription>
{description || (
<Trans>
Reauthentication is required to sign this{' '}
{actionTarget === 'DOCUMENT' ? 'document' : 'field'}
</Trans>
)}
{description || <Trans>Reauthentication is required to sign this field</Trans>}
</DialogDescription>
</DialogHeader>
@@ -90,7 +78,6 @@ export const DocumentSigningAuthDialog = ({
))
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
<DocumentSigningAuth2FA
actionTarget={actionTarget === 'DOCUMENT' ? 'DOCUMENT' : 'FIELD'}
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
@@ -43,7 +43,6 @@ export type DocumentSigningAuthContextValue = {
setPreferredPasskeyId: (_value: string | null) => void;
user?: SessionUser | null;
refetchPasskeys: () => Promise<void>;
isEnterprise: boolean;
};
const DocumentSigningAuthContext = createContext<DocumentSigningAuthContextValue | null>(null);
@@ -67,7 +66,6 @@ export interface DocumentSigningAuthProviderProps {
recipient: Recipient;
user?: SessionUser | null;
children: React.ReactNode;
isEnterprise: boolean;
}
export const DocumentSigningAuthProvider = ({
@@ -75,7 +73,6 @@ export const DocumentSigningAuthProvider = ({
recipient: initialRecipient,
user,
children,
isEnterprise,
}: DocumentSigningAuthProviderProps) => {
const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions);
const [recipient, setRecipient] = useState(initialRecipient);
@@ -141,13 +138,8 @@ export const DocumentSigningAuthProvider = ({
.exhaustive();
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
// 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) {
// Directly run callback if no auth required.
if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) {
await options.onReauthFormSubmit();
return;
}
@@ -206,7 +198,6 @@ export const DocumentSigningAuthProvider = ({
preferredPasskeyId,
setPreferredPasskeyId,
refetchPasskeys,
isEnterprise,
}}
>
{children}
@@ -227,8 +218,6 @@ export const DocumentSigningAuthProvider = ({
type ExecuteActionAuthProcedureOptions = Omit<
DocumentSigningAuthDialogProps,
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole'
> & {
actionTarget: FieldType | 'DOCUMENT';
};
>;
DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider';
@@ -166,7 +166,7 @@ export const DocumentSigningFieldContainer = ({
</TooltipTrigger>
<TooltipContent
className="border-0 bg-orange-300 fill-orange-300 text-orange-900"
className="border-0 bg-orange-300 fill-orange-300 font-bold text-orange-900"
sideOffset={2}
>
{tooltipText && <p>{tooltipText}</p>}
@@ -28,7 +28,6 @@ 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';
@@ -40,7 +39,6 @@ export type DocumentSigningFormProps = {
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void;
isEnterprise: boolean;
};
export const DocumentSigningForm = ({
@@ -51,7 +49,6 @@ export const DocumentSigningForm = ({
isRecipientsTurn,
allRecipients = [],
setSelectedSignerId,
isEnterprise,
}: DocumentSigningFormProps) => {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
@@ -65,7 +62,6 @@ 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);
@@ -118,16 +114,11 @@ export const DocumentSigningForm = ({
setIsAssistantSubmitting(true);
try {
await executeActionAuthProcedure({
actionTarget: 'DOCUMENT',
onReauthFormSubmit: async (authOptions) => {
await completeDocument(authOptions, nextSigner);
},
});
await completeDocument(undefined, nextSigner);
} catch (err) {
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while completing the document. Please try again.`),
title: 'Error',
description: 'An error occurred while completing the document. Please try again.',
variant: 'destructive',
});
@@ -238,12 +229,7 @@ export const DocumentSigningForm = ({
fields={fields}
fieldsValidated={fieldsValidated}
onSignatureComplete={async (nextSigner) => {
await executeActionAuthProcedure({
actionTarget: 'DOCUMENT',
onReauthFormSubmit: async (authOptions) => {
await completeDocument(authOptions, nextSigner);
},
});
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
@@ -423,12 +409,7 @@ export const DocumentSigningForm = ({
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await executeActionAuthProcedure({
actionTarget: 'DOCUMENT',
onReauthFormSubmit: async (authOptions) => {
await completeDocument(authOptions, nextSigner);
},
});
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={
@@ -47,7 +47,6 @@ export type DocumentSigningPageViewProps = {
completedFields: CompletedField[];
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
isEnterprise: boolean;
};
export const DocumentSigningPageView = ({
@@ -57,7 +56,6 @@ export const DocumentSigningPageView = ({
completedFields,
isRecipientsTurn,
allRecipients = [],
isEnterprise,
}: DocumentSigningPageViewProps) => {
const { documentData, documentMeta } = document;
@@ -155,7 +153,6 @@ export const DocumentSigningPageView = ({
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
setSelectedSignerId={setSelectedSignerId}
isEnterprise={isEnterprise}
/>
</div>
</div>
@@ -12,7 +12,14 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
SplitButton,
SplitButtonAction,
SplitButtonDropdown,
SplitButtonDropdownItem,
} from '@documenso/ui/primitives/split-button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentPageViewButtonProps = {
@@ -42,6 +49,12 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
? `${documentsPath}/f/${document.folderId}/${document.id}/edit`
: `${documentsPath}/${document.id}/edit`;
const { mutateAsync: downloadCertificate, isPending: isDownloadingCertificate } =
trpc.document.downloadCertificate.useMutation();
const { mutateAsync: downloadAuditLogs, isPending: isDownloadingAuditLogs } =
trpc.document.downloadAuditLogs.useMutation();
const onDownloadClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query(
@@ -71,6 +84,125 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
}
};
const onDownloadOriginalClick = async () => {
try {
const documentWithData = await trpcClient.document.getDocumentById.query(
{
documentId: document.id,
},
{
context: {
teamId: document.team?.id?.toString(),
},
},
);
const documentData = documentWithData?.documentData;
if (!documentData) {
throw new Error('No document available');
}
await downloadPDF({ documentData, fileName: documentWithData.title, version: 'original' });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
const onDownloadCertificateClick = async () => {
try {
const { url } = await downloadCertificate({ documentId: document.id });
const iframe = Object.assign(window.document.createElement('iframe'), {
src: url,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
window.document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
window.document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Sorry, we were unable to download the certificate. Please try again later.`,
),
variant: 'destructive',
});
}
};
const onDownloadAuditLogClick = async () => {
try {
const { url } = await downloadAuditLogs({ documentId: document.id });
const iframe = Object.assign(window.document.createElement('iframe'), {
src: url,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
window.document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
window.document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Sorry, we were unable to download the audit logs. Please try again later.`,
),
variant: 'destructive',
});
}
};
return match({
isRecipient,
isPending,
@@ -110,10 +242,26 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
</Button>
))
.with({ isComplete: true }, () => (
<Button className="w-full" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
<SplitButton className="w-full">
<SplitButtonAction onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</SplitButtonAction>
<SplitButtonDropdown>
<SplitButtonDropdownItem onClick={onDownloadOriginalClick}>
<Trans>Download Original Document</Trans>
</SplitButtonDropdownItem>
<SplitButtonDropdownItem onClick={onDownloadCertificateClick}>
<Trans>Download Document Certificate</Trans>
</SplitButtonDropdownItem>
<SplitButtonDropdownItem onClick={onDownloadAuditLogClick}>
<Trans>Download Audit Log</Trans>
</SplitButtonDropdownItem>
</SplitButtonDropdown>
</SplitButton>
))
.otherwise(() => null);
};
@@ -152,7 +152,7 @@ export const TemplateEditForm = ({
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while updating the template settings.`),
description: _(msg`An error occurred while updating the document settings.`),
variant: 'destructive',
});
}
@@ -1,47 +1,96 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useEffect, useState } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { WebhookTriggerEvents } from '@prisma/client';
import { Check, ChevronsUpDown } from 'lucide-react';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@documenso/ui/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { truncateTitle } from '~/utils/truncate-title';
type WebhookMultiSelectComboboxProps = {
listValues: string[];
onChange: (_values: string[]) => void;
};
const triggerEvents = Object.values(WebhookTriggerEvents).map((event) => ({
value: event,
label: toFriendlyWebhookEventName(event),
}));
export const WebhookMultiSelectCombobox = ({
listValues,
onChange,
}: WebhookMultiSelectComboboxProps) => {
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const value = listValues.map((event) => ({
value: event,
label: toFriendlyWebhookEventName(event),
}));
const triggerEvents = Object.values(WebhookTriggerEvents);
const onMutliSelectChange = (options: Option[]) => {
onChange(options.map((option) => option.value));
useEffect(() => {
setSelectedValues(listValues);
}, [listValues]);
const allEvents = [...new Set([...triggerEvents, ...selectedValues])];
const handleSelect = (currentValue: string) => {
let newSelectedValues;
if (selectedValues.includes(currentValue)) {
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
} else {
newSelectedValues = [...selectedValues, currentValue];
}
setSelectedValues(newSelectedValues);
onChange(newSelectedValues);
setIsOpen(false);
};
return (
<MultiSelect
commandProps={{
label: _(msg`Select triggers`),
}}
defaultOptions={triggerEvents}
value={value}
onChange={onMutliSelectChange}
placeholder={_(msg`Select triggers`)}
hideClearAllButton
hidePlaceholderWhenSelected
emptyIndicator={<p className="text-center text-sm">No triggers available</p>}
/>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isOpen}
className="w-[200px] justify-between"
>
<Plural value={selectedValues.length} zero="Select values" other="# selected..." />
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="z-9999 w-full max-w-[280px] p-0">
<Command>
<CommandInput
placeholder={truncateTitle(
selectedValues.map((v) => toFriendlyWebhookEventName(v)).join(', '),
15,
)}
/>
<CommandEmpty>
<Trans>No value found.</Trans>
</CommandEmpty>
<CommandGroup>
{allEvents.map((value: string, i: number) => (
<CommandItem key={i} onSelect={() => handleSelect(value)}>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
)}
/>
{toFriendlyWebhookEventName(value)}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
};
@@ -17,7 +17,6 @@ import {
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getTranslations } from '@documenso/lib/utils/i18n';
@@ -63,10 +62,6 @@ export async function loader({ request }: Route.LoaderArgs) {
throw redirect('/');
}
const team = document.teamId
? await getTeamById({ teamId: document.teamId, userId: document.userId })
: null;
const isPlatformDocument = await isDocumentPlatform(document);
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
@@ -79,7 +74,6 @@ export async function loader({ request }: Route.LoaderArgs) {
return {
document,
team,
documentLanguage,
isPlatformDocument,
auditLogs,
@@ -97,7 +91,7 @@ export async function loader({ request }: Route.LoaderArgs) {
* Update: Maybe <Trans> tags work now after RR7 migration.
*/
export default function SigningCertificate({ loaderData }: Route.ComponentProps) {
const { document, team, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData;
const { document, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData;
const { i18n, _ } = useLingui();
@@ -349,7 +343,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</CardContent>
</Card>
{!isPlatformDocument && !team?.teamGlobalSettings?.brandingHidePoweredBy && (
{isPlatformDocument && (
<div className="my-8 flex-row-reverse space-y-4">
<div className="flex items-end justify-end gap-x-4">
<div
@@ -94,7 +94,6 @@ 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
@@ -6,7 +6,6 @@ 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';
@@ -61,10 +60,6 @@ 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 });
@@ -120,7 +115,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
isDocumentAccessValid: false,
recipientEmail: recipient.email,
recipientHasAccount,
isEnterprise,
} as const);
}
@@ -155,7 +149,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
completedFields,
recipientSignature,
isRecipientsTurn,
isEnterprise,
} as const);
}
@@ -183,7 +176,6 @@ export default function SigningPage() {
isRecipientsTurn,
allRecipients,
recipientWithFields,
isEnterprise,
} = data;
if (document.deletedAt || document.status === DocumentStatus.REJECTED) {
@@ -249,7 +241,6 @@ export default function SigningPage() {
documentAuthOptions={document.authOptions}
recipient={recipient}
user={user}
isEnterprise={isEnterprise}
>
<DocumentSigningPageView
recipient={recipientWithFields}
@@ -258,7 +249,6 @@ export default function SigningPage() {
completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
isEnterprise={isEnterprise}
/>
</DocumentSigningAuthProvider>
</DocumentSigningProvider>
@@ -143,7 +143,6 @@ export default function EmbedDirectTemplatePage() {
documentAuthOptions={template.authOptions}
recipient={recipient}
user={user}
isEnterprise={isEnterpriseDocument}
>
<DocumentSigningRecipientProvider recipient={recipient}>
<EmbedDirectTemplateClientPage
@@ -168,7 +168,6 @@ export default function EmbedSignDocumentPage() {
documentAuthOptions={document.authOptions}
recipient={recipient}
user={user}
isEnterprise={isEnterpriseDocument}
>
<EmbedSignDocumentClientPage
token={token}
+11 -11
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.6.0",
"@react-router/serve": "^7.6.0",
"@react-router/node": "^7.1.5",
"@react-router/serve": "^7.1.5",
"@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.245.0",
"posthog-node": "^4.17.0",
"posthog-js": "^1.224.0",
"posthog-node": "^4.8.1",
"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.6.0",
"react-router": "^7.1.5",
"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.3.1",
"@react-router/dev": "^7.6.0",
"@react-router/remix-routes-option-adapter": "^7.6.0",
"@lingui/vite-plugin": "^5.2.0",
"@react-router/dev": "^7.1.5",
"@react-router/remix-routes-option-adapter": "^7.1.5",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^28.0.2",
"@rollup/plugin-node-resolve": "^16.0.0",
@@ -91,14 +91,14 @@
"@types/react-dom": "^18",
"@types/ua-parser-js": "^0.7.39",
"cross-env": "^7.0.3",
"esbuild": "^0.25.4",
"esbuild": "0.24.2",
"remix-flat-routes": "^0.8.4",
"rollup": "^4.34.5",
"tsx": "^4.19.2",
"typescript": "5.6.2",
"vite": "^6.3.5",
"vite": "^6.1.0",
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.11.1"
"version": "1.10.3"
}
+3 -19
View File
@@ -35,27 +35,12 @@ export default defineConfig({
],
ssr: {
noExternal: ['react-dropzone', 'plausible-tracker', 'pdfjs-dist'],
external: [
'@node-rs/bcrypt',
'@prisma/client',
'@documenso/tailwind-config',
'playwright',
'playwright-core',
'@playwright/browser-chromium',
],
external: ['@node-rs/bcrypt', '@prisma/client', '@documenso/tailwind-config'],
},
optimizeDeps: {
entries: ['./app/**/*', '../../packages/ui/**/*', '../../packages/lib/**/*'],
include: ['prop-types', 'file-selector', 'attr-accept'],
exclude: [
'node_modules',
'@node-rs/bcrypt',
'@documenso/pdf-sign',
'sharp',
'playwright',
'playwright-core',
'@playwright/browser-chromium',
],
exclude: ['node_modules', '@node-rs/bcrypt', '@documenso/pdf-sign', 'sharp'],
},
resolve: {
alias: {
@@ -83,8 +68,7 @@ export default defineConfig({
'@documenso/pdf-sign',
'@aws-sdk/cloudfront-signer',
'nodemailer',
/playwright/,
'@playwright/browser-chromium',
'playwright',
],
},
},
+1 -1
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"]
+13195 -7503
View File
File diff suppressed because it is too large Load Diff
+7 -11
View File
@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.11.1",
"version": "1.10.3",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
@@ -44,22 +44,18 @@
"@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0",
"@lingui/cli": "^5.2.0",
"dotenv": "^16.5.0",
"dotenv-cli": "^8.0.0",
"@trigger.dev/cli": "^2.3.18",
"dotenv": "^16.3.1",
"dotenv-cli": "^7.3.0",
"eslint": "^8.40.0",
"eslint-config-custom": "*",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"playwright": "1.52.0",
"playwright": "1.43.0",
"prettier": "^3.3.3",
"rimraf": "^5.0.1",
"turbo": "^1.9.3",
"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"
"vite": "^6.1.0"
},
"name": "@documenso/root",
"workspaces": [
@@ -84,4 +80,4 @@
"trigger.dev": {
"endpointId": "documenso-app"
}
}
}
+3 -3
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": "^5.18.0",
"@types/swagger-ui-react": "^4.18.3",
"luxon": "^3.4.0",
"superjson": "^1.13.1",
"swagger-ui-react": "^5.21.0",
"swagger-ui-react": "^5.11.0",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
}
}
}
+1 -1
View File
@@ -188,7 +188,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}
const { url } = await getPresignGetUrl(
downloadOriginalDocument ? document.documentData.initialData : document.documentData.data,
downloadOriginalDocument ? document.documentData.data : document.documentData.initialData,
);
return {
@@ -116,15 +116,15 @@ test.describe('[EE_ONLY]', () => {
redirectPath: `/documents/${document.id}/edit`,
});
// Global action auth should now be visible for all users
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
// Global action auth should not be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Advanced settings should now be visible for all users
await expect(page.getByLabel('Show advanced settings')).toBeVisible();
// Advanced settings should not be visible.
await expect(page.getByLabel('Show advanced settings')).not.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 now be visible for all users
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
// Action auth should NOT be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Save the settings by going to the next step.
@@ -256,16 +256,10 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
});
// Open document action menu.
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();
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).click();
@@ -273,16 +267,11 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
await page.waitForTimeout(1000);
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();
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).click();
@@ -342,13 +342,7 @@ test('user can move a document to a document folder', async ({ page }) => {
redirectPath: '/documents',
});
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.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Proposals' }).click();
@@ -385,13 +379,7 @@ test('user can move a document from folder to the root', async ({ page }) => {
await page.getByText('Proposals').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.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Root' }).click();
@@ -803,13 +791,7 @@ test('user can move a template to a template folder', async ({ page }) => {
redirectPath: '/templates',
});
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.getByTestId('template-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Client Templates' }).click();
@@ -846,13 +828,7 @@ test('user can move a template from a folder to the root', async ({ page }) => {
await page.getByText('Client Templates').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.getByTestId('template-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Root' }).click();
@@ -230,21 +230,13 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.getByRole('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Resend' }).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').filter({ hasText: 'Document re-sent' }).first(),
).toBeVisible();
await expect(page.getByRole('status')).toContainText('Document re-sent');
});
test('[TEAMS]: delete draft team document', async ({ page }) => {
@@ -256,13 +248,7 @@ test('[TEAMS]: delete draft team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=DRAFT`,
});
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('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
@@ -300,13 +286,7 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
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('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
@@ -345,13 +325,7 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
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('row').getByRole('button').nth(2).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
@@ -113,8 +113,8 @@ test.describe('[EE_ONLY]', () => {
redirectPath: `/templates/${template.id}/edit`,
});
// Global action auth should now be visible for all users
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
// Global action auth should not be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.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 now be visible for all users
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
// Action auth should NOT be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
@@ -1,6 +1,5 @@
import { type Page, expect, test } from '@playwright/test';
import { alphaid } from '@documenso/lib/universal/id';
import {
extractUserVerificationToken,
seedTestEmail,
@@ -24,11 +23,9 @@ 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.getByLabel('Public profile username').fill(alphaid(10));
await page.getByLabel('Public profile username').blur();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Complete', exact: true }).click();
await page.waitForURL('/unverified-account');
+3 -3
View File
@@ -12,13 +12,13 @@
"keywords": [],
"author": "",
"devDependencies": {
"@playwright/test": "1.52.0",
"@playwright/test": "^1.18.1",
"@types/node": "^20",
"@documenso/lib": "*",
"@documenso/prisma": "*",
"pdf-lib": "^1.17.1"
},
"dependencies": {
"start-server-and-test": "^2.0.12"
"start-server-and-test": "^2.0.1"
}
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: false,
workers: 1,
workers: '50%',
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,
+2 -2
View File
@@ -18,8 +18,8 @@
"arctic": "^3.1.0",
"hono": "4.7.0",
"luxon": "^3.5.0",
"nanoid": "^5.1.5",
"nanoid": "^4.0.2",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
}
}
}
+1 -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.10.1",
"nodemailer": "6.9.9",
"react-email": "1.9.5",
"resend": "2.0.0"
},
@@ -1,43 +0,0 @@
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;
@@ -1,62 +0,0 @@
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;
+8 -2
View File
@@ -1,7 +1,13 @@
module.exports = {
extends: ['next', 'turbo', 'eslint:recommended', 'plugin:@typescript-eslint/recommended'],
extends: [
'next',
'turbo',
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:package-json/recommended',
],
plugins: ['unused-imports'],
plugins: ['package-json', 'unused-imports'],
env: {
es2022: true,
+4 -4
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.2.28",
"eslint-config-next": "^14.1.3",
"eslint-config-turbo": "^1.12.5",
"eslint-plugin-package-json": "^0.31.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-unused-imports": "^4.1.4",
"eslint-plugin-package-json": "^0.10.4",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-unused-imports": "^3.1.0",
"typescript": "5.6.2"
}
}
@@ -16,6 +16,7 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
documentLanguage: z.string(),
includeSenderDetails: z.boolean(),
includeSigningCertificate: z.boolean(),
includeAuditLog: z.boolean(),
brandingEnabled: z.boolean(),
brandingLogo: z.string(),
brandingUrl: z.string(),
@@ -9,6 +9,7 @@ import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getAuditLogPdf } from '../../../server-only/htmltopdf/get-audit-log-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
@@ -52,6 +53,7 @@ export const run = async ({
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
includeAuditLog: true,
},
},
},
@@ -152,6 +154,13 @@ export const run = async ({
}).catch(() => null)
: null;
const auditLogData =
(document.team?.teamGlobalSettings?.includeAuditLog ?? false)
? await getAuditLogPdf({
documentId,
}).catch(() => null)
: null;
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
const pdfDoc = await PDFDocument.load(pdfData);
@@ -178,6 +187,16 @@ export const run = async ({
});
}
if (auditLogData) {
const auditLogDoc = await PDFDocument.load(auditLogData);
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
auditLogPages.forEach((page) => {
pdfDoc.addPage(page);
});
}
for (const field of fields) {
if (field.inserted) {
document.useLegacyFieldInsertion
+5 -5
View File
@@ -15,6 +15,7 @@
"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",
@@ -40,13 +41,12 @@
"kysely": "0.26.3",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"nanoid": "^5.1.5",
"nanoid": "^4.0.2",
"oslo": "^0.17.0",
"pdf-lib": "^1.17.1",
"pg": "^8.11.3",
"playwright": "1.52.0",
"posthog-js": "^1.245.0",
"posthog-node": "^4.17.0",
"playwright": "1.43.0",
"posthog-js": "^1.224.0",
"react": "^18",
"remeda": "^2.17.3",
"sharp": "0.32.6",
@@ -55,7 +55,7 @@
"zod": "3.24.1"
},
"devDependencies": {
"@playwright/browser-chromium": "1.52.0",
"@playwright/browser-chromium": "1.43.0",
"@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4"
}
@@ -1,120 +0,0 @@
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);
}
};
@@ -17,6 +17,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
import { getAuditLogPdf } from '../htmltopdf/get-audit-log-pdf';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
@@ -53,6 +54,7 @@ export const sealDocument = async ({
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
includeAuditLog: true,
},
},
},
@@ -124,6 +126,13 @@ export const sealDocument = async ({
}).catch(() => null)
: null;
const auditLogData =
(document.team?.teamGlobalSettings?.includeAuditLog ?? false)
? await getAuditLogPdf({
documentId,
}).catch(() => null)
: null;
const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
@@ -146,6 +155,16 @@ export const sealDocument = async ({
});
}
if (auditLogData) {
const auditLog = await PDFDocument.load(auditLogData);
const auditLogPages = await doc.copyPages(auditLog, auditLog.getPageIndices());
auditLogPages.forEach((page) => {
doc.addPage(page);
});
}
for (const field of fields) {
document.useLegacyFieldInsertion
? await legacy_insertFieldInPDF(doc, field)
@@ -1,6 +1,8 @@
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DocumentStatus, 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';
@@ -115,6 +117,7 @@ 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;
@@ -127,11 +130,26 @@ 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 =
@@ -2,7 +2,6 @@ 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';
@@ -14,7 +13,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, TRecipientActionAuthTypes } from '../../types/document-auth';
import type { TRecipientActionAuth } from '../../types/document-auth';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
@@ -24,7 +23,6 @@ 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 = {
@@ -171,24 +169,13 @@ export const signFieldWithToken = async ({
}
}
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 derivedRecipientActionAuth = await validateFieldAuth({
documentAuthOptions: document.authOptions,
recipient,
field,
userId,
authOptions,
});
const documentMeta = await prisma.documentMeta.findFirst({
where: {
@@ -299,9 +286,9 @@ export const signFieldWithToken = async ({
}),
)
.exhaustive(),
fieldSecurity: requiredAuthType
fieldSecurity: derivedRecipientActionAuth
? {
type: requiredAuthType,
type: derivedRecipientActionAuth,
}
: undefined,
},
@@ -0,0 +1,69 @@
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
import { env } from '../../utils/env';
import { encryptSecondaryData } from '../crypto/encrypt';
export type GetAuditLogPdfOptions = {
documentId: number;
// eslint-disable-next-line @typescript-eslint/ban-types
language?: SupportedLanguageCodes | (string & {});
};
export const getAuditLogPdf = async ({ documentId, language }: GetAuditLogPdfOptions) => {
const { chromium } = await import('playwright');
const encryptedId = encryptSecondaryData({
data: documentId.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
});
let browser: Browser;
const browserlessUrl = env('NEXT_PRIVATE_BROWSERLESS_URL');
if (browserlessUrl) {
// !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
browser = await chromium.connectOverCDP(browserlessUrl);
} else {
browser = await chromium.launch();
}
if (!browser) {
throw new Error(
'Failed to establish a browser, please ensure you have either a Browserless.io url or chromium browser installed',
);
}
const browserContext = await browser.newContext();
const page = await browserContext.newPage();
const lang = isValidLanguageCode(language) ? language : 'en';
await page.context().addCookies([
{
name: 'language',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
},
]);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
const result = await page.pdf({
format: 'A4',
});
await browserContext.close();
void browser.close();
return result;
};
@@ -5,7 +5,6 @@ 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 = {
@@ -75,11 +74,7 @@ export const updateTemplate = async ({
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
// Only ACCOUNT and PASSKEY require enterprise permissions
if (
newGlobalActionAuth &&
(newGlobalActionAuth === DocumentAuth.ACCOUNT || newGlobalActionAuth === DocumentAuth.PASSKEY)
) {
if (newGlobalActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
@@ -87,7 +82,7 @@ export const updateTemplate = async ({
if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set this action auth type',
message: 'You do not have permission to set the action auth',
});
}
}
+6 -12
View File
@@ -111,6 +111,11 @@ 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}"
@@ -868,7 +873,6 @@ 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"
@@ -2721,14 +2725,9 @@ 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
@@ -3916,6 +3915,7 @@ 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,7 +4607,6 @@ 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"
@@ -4850,11 +4849,6 @@ 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
+6 -12
View File
@@ -106,6 +106,11 @@ 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}"
@@ -863,7 +868,6 @@ 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"
@@ -2716,14 +2720,9 @@ 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
@@ -3911,6 +3910,7 @@ 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,7 +4602,6 @@ 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"
@@ -4845,11 +4844,6 @@ 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
+6 -12
View File
@@ -111,6 +111,11 @@ 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}"
@@ -868,7 +873,6 @@ 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"
@@ -2721,14 +2725,9 @@ 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
@@ -3916,6 +3915,7 @@ 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,7 +4607,6 @@ 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"
@@ -4850,11 +4849,6 @@ 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
+6 -12
View File
@@ -111,6 +111,11 @@ 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}"
@@ -868,7 +873,6 @@ 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"
@@ -2721,14 +2725,9 @@ 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
@@ -3916,6 +3915,7 @@ 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,7 +4607,6 @@ 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"
@@ -4850,11 +4849,6 @@ 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
+6 -12
View File
@@ -111,6 +111,11 @@ 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}"
@@ -868,7 +873,6 @@ 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"
@@ -2721,14 +2725,9 @@ 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
@@ -3916,6 +3915,7 @@ 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,7 +4607,6 @@ 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"
@@ -4850,11 +4849,6 @@ 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
+6 -12
View File
@@ -111,6 +111,11 @@ 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}"
@@ -868,7 +873,6 @@ 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"
@@ -2721,14 +2725,9 @@ 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
@@ -3916,6 +3915,7 @@ 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,7 +4607,6 @@ 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ń"
@@ -4850,11 +4849,6 @@ 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
-14
View File
@@ -68,16 +68,6 @@ 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.
*
@@ -112,7 +102,6 @@ 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;
@@ -163,9 +152,6 @@ 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>;
@@ -1,12 +0,0 @@
-- 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;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "includeAuditLog" BOOLEAN NOT NULL DEFAULT false;
+6 -6
View File
@@ -21,18 +21,18 @@
"seed": "tsx ./seed-database.ts"
},
"dependencies": {
"@prisma/client": "^6.8.2",
"@prisma/client": "^5.4.2",
"kysely": "0.26.3",
"prisma": "^6.8.2",
"prisma-extension-kysely": "^3.0.0",
"prisma": "^5.4.2",
"prisma-extension-kysely": "^2.1.0",
"prisma-kysely": "^1.8.0",
"prisma-json-types-generator": "^3.2.2",
"ts-pattern": "^5.0.6",
"zod-prisma-types": "3.2.4"
"zod-prisma-types": "3.1.9"
},
"devDependencies": {
"dotenv": "^16.5.0",
"dotenv-cli": "^8.0.0",
"dotenv": "^16.3.1",
"dotenv-cli": "^7.3.0",
"tsx": "^4.19.2",
"typescript": "5.6.2"
}
+20 -28
View File
@@ -53,20 +53,19 @@ 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
twoFactorEmailVerification UserTwoFactorEmailVerification?
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
profile UserProfile?
verificationTokens VerificationToken[]
@@ -583,11 +582,13 @@ enum TeamMemberInviteStatus {
}
model TeamGlobalSettings {
teamId Int @unique
documentVisibility DocumentVisibility @default(EVERYONE)
documentLanguage String @default("en")
includeSenderDetails Boolean @default(true)
includeSigningCertificate Boolean @default(true)
teamId Int @unique
documentVisibility DocumentVisibility @default(EVERYONE)
documentLanguage String @default("en")
includeSenderDetails Boolean @default(true)
includeSigningCertificate Boolean @default(true)
includeAuditLog Boolean @default(false)
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
@@ -840,12 +841,3 @@ 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)
}
+1 -1
View File
@@ -18,6 +18,6 @@
"ts-pattern": "^5.0.5"
},
"devDependencies": {
"vitest": "^3.1.4"
"vitest": "^2.1.8"
}
}
@@ -1,10 +1,5 @@
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';
@@ -13,7 +8,6 @@ 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 {
@@ -21,9 +15,7 @@ import {
ZCreatePasskeyMutationSchema,
ZDeletePasskeyMutationSchema,
ZFindPasskeysQuerySchema,
ZSendEmailVerificationMutationSchema,
ZUpdatePasskeyMutationSchema,
ZVerifyEmailCodeMutationSchema,
} from './schema';
export const authRouter = router({
@@ -106,68 +98,4 @@ 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,
});
}),
});
@@ -71,18 +71,3 @@ 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>;
@@ -23,6 +23,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
@@ -54,6 +55,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
@@ -63,6 +65,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
@@ -14,6 +14,7 @@ export const ZUpdateTeamDocumentSettingsRequestSchema = z.object({
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).optional().default('en'),
includeSenderDetails: z.boolean().optional().default(false),
includeSigningCertificate: z.boolean().optional().default(true),
includeAuditLog: z.boolean().optional().default(false),
typedSignatureEnabled: z.boolean().optional().default(true),
uploadSignatureEnabled: z.boolean().optional().default(true),
drawSignatureEnabled: z.boolean().optional().default(true),
@@ -7,11 +7,7 @@ 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,
NonEnterpriseDocumentActionAuth,
} from '@documenso/lib/types/document-auth';
import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth';
import {
Select,
SelectContent,
@@ -21,47 +17,38 @@ import {
} from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
interface DocumentGlobalAuthActionSelectProps extends SelectProps {
isDocumentEnterprise?: boolean;
}
export const DocumentGlobalAuthActionSelect = forwardRef<HTMLButtonElement, SelectProps>(
(props, ref) => {
const { _ } = useLingui();
export const DocumentGlobalAuthActionSelect = forwardRef<
HTMLButtonElement,
DocumentGlobalAuthActionSelectProps
>(({ isDocumentEnterprise, ...props }, ref) => {
const { _ } = useLingui();
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue
ref={ref}
data-testid="documentActionSelectValue"
placeholder={_(msg`No restrictions`)}
/>
</SelectTrigger>
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}
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>
<Trans>No restrictions</Trans>
</SelectItem>
))}
</SelectContent>
</Select>
);
});
{Object.values(DocumentActionAuth)
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
);
DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect';
+1 -13
View File
@@ -38,14 +38,6 @@ interface FieldToolTipProps extends VariantProps<typeof tooltipVariants> {
export function FieldToolTip({ children, color, className = '', field }: FieldToolTipProps) {
const coords = useFieldPageCoords(field);
const onTooltipContentClick = () => {
const $fieldEl = document.querySelector<HTMLButtonElement>(`#field-${field.id} > button`);
if ($fieldEl) {
$fieldEl.click();
}
};
return createPortal(
<div
className={cn('pointer-events-none absolute')}
@@ -60,11 +52,7 @@ export function FieldToolTip({ children, color, className = '', field }: FieldTo
<Tooltip delayDuration={0} open={!field.inserted || !field.fieldMeta}>
<TooltipTrigger className="absolute inset-0 w-full"></TooltipTrigger>
<TooltipContent
className={tooltipVariants({ color, className })}
sideOffset={2}
onClick={onTooltipContentClick}
>
<TooltipContent className={tooltipVariants({ color, className })} sideOffset={2}>
{children}
<TooltipArrow />
</TooltipContent>
-15
View File
@@ -1,15 +0,0 @@
import React, { useEffect } from 'react';
export const useDebounce = <T>(value: T, delay?: number): T => {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
};
@@ -1,15 +1,10 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import {
DocumentStatus,
DocumentVisibility,
type Field,
type Recipient,
SendStatus,
TeamMemberRole,
} from '@prisma/client';
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 { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
@@ -279,22 +274,24 @@ export const AddSettingsFormPartial = ({
/>
)}
<FormField
control={form.control}
name="globalActionAuth"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Recipient action authentication</Trans>
<DocumentGlobalAuthActionTooltip />
</FormLabel>
{isDocumentEnterprise && (
<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">
-585
View File
@@ -1,585 +0,0 @@
'use client';
import * as React from 'react';
import { useEffect } from 'react';
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
import { XIcon } from 'lucide-react';
import { useDebounce } from '../lib/use-debounce';
import { cn } from '../lib/utils';
import { Command, CommandGroup, CommandItem, CommandList } from './command';
export interface Option {
value: string;
label: string;
disable?: boolean;
/** fixed option that can't be removed. */
fixed?: boolean;
/** Group the options by providing key. */
[key: string]: string | boolean | undefined;
}
interface GroupOption {
[key: string]: Option[];
}
interface MultiSelectProps {
value?: Option[];
defaultOptions?: Option[];
/** manually controlled options */
options?: Option[];
placeholder?: string;
/** Loading component. */
loadingIndicator?: React.ReactNode;
/** Empty component. */
emptyIndicator?: React.ReactNode;
/** Debounce time for async search. Only work with `onSearch`. */
delay?: number;
/**
* Only work with `onSearch` prop. Trigger search when `onFocus`.
* For example, when user click on the input, it will trigger the search to get initial options.
**/
triggerSearchOnFocus?: boolean;
/** async search */
onSearch?: (value: string) => Promise<Option[]>;
/**
* sync search. This search will not showing loadingIndicator.
* The rest props are the same as async search.
* i.e.: creatable, groupBy, delay.
**/
onSearchSync?: (value: string) => Option[];
onChange?: (options: Option[]) => void;
/** Limit the maximum number of selected options. */
maxSelected?: number;
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
onMaxSelected?: (maxLimit: number) => void;
/** Hide the placeholder when there are options selected. */
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
/** Group the options base on provided key. */
groupBy?: string;
className?: string;
badgeClassName?: string;
/**
* First item selected is a default behavior by cmdk. That is why the default is true.
* This is a workaround solution by add a dummy item.
*
* @reference: https://github.com/pacocoursey/cmdk/issues/171
*/
selectFirstItem?: boolean;
/** Allow user to create option when there is no option matched. */
creatable?: boolean;
/** Props of `Command` */
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
/** Props of `CommandInput` */
inputProps?: Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
'value' | 'placeholder' | 'disabled'
>;
/** hide the clear all button. */
hideClearAllButton?: boolean;
}
export interface MultiSelectRef {
selectedValue: Option[];
input: HTMLInputElement;
focus: () => void;
reset: () => void;
}
function transToGroupOption(options: Option[], groupBy?: string) {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
'': options,
};
}
const groupOption: GroupOption = {};
options.forEach((option) => {
const key = (option[groupBy] as string) || '';
if (!groupOption[key]) {
groupOption[key] = [];
}
groupOption[key].push(option);
});
return groupOption;
}
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value));
}
return cloneOption;
}
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
for (const [, value] of Object.entries(groupOption)) {
if (value.some((option) => targetOption.find((p) => p.value === option.value))) {
return true;
}
}
return false;
}
const CommandEmpty = ({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) => {
const render = useCommandState((state) => state.filtered.count === 0);
if (!render) return null;
return (
<div
className={cn('px-2 py-4 text-center text-sm', className)}
cmdk-empty=""
role="presentation"
{...props}
/>
);
};
CommandEmpty.displayName = 'CommandEmpty';
const MultiSelect = ({
value,
onChange,
placeholder,
defaultOptions: arrayDefaultOptions = [],
options: arrayOptions,
delay,
onSearch,
onSearchSync,
loadingIndicator,
emptyIndicator,
maxSelected = Number.MAX_SAFE_INTEGER,
onMaxSelected,
hidePlaceholderWhenSelected,
disabled,
groupBy,
className,
badgeClassName,
selectFirstItem = true,
creatable = false,
triggerSearchOnFocus = false,
commandProps,
inputProps,
hideClearAllButton = false,
}: MultiSelectProps) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [onScrollbar, setOnScrollbar] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null); // Added this
const [selected, setSelected] = React.useState<Option[]>(value || []);
const [options, setOptions] = React.useState<GroupOption>(
transToGroupOption(arrayDefaultOptions, groupBy),
);
const [inputValue, setInputValue] = React.useState('');
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setOpen(false);
inputRef.current.blur();
}
};
const handleUnselect = React.useCallback(
(option: Option) => {
const newOptions = selected.filter((s) => s.value !== option.value);
setSelected(newOptions);
onChange?.(newOptions);
},
[onChange, selected],
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (input.value === '' && selected.length > 0) {
const lastSelectOption = selected[selected.length - 1];
// If last item is fixed, we should not remove it.
if (!lastSelectOption.fixed) {
handleUnselect(selected[selected.length - 1]);
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === 'Escape') {
input.blur();
}
}
},
[handleUnselect, selected],
);
useEffect(() => {
if (open) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchend', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
};
}, [open]);
useEffect(() => {
if (value) {
setSelected(value);
}
}, [value]);
useEffect(() => {
/** If `onSearch` is provided, do not trigger options updated. */
if (!arrayOptions || onSearch) {
return;
}
const newOption = transToGroupOption(arrayOptions || [], groupBy);
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption);
}
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);
useEffect(() => {
/** sync search */
const doSearchSync = () => {
const res = onSearchSync?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
};
// eslint-disable-next-line @typescript-eslint/require-await
const exec = async () => {
if (!onSearchSync || !open) return;
if (triggerSearchOnFocus) {
doSearchSync();
}
if (debouncedSearchTerm) {
doSearchSync();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
useEffect(() => {
/** async search */
const doSearch = async () => {
setIsLoading(true);
const res = await onSearch?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
setIsLoading(false);
};
const exec = async () => {
if (!onSearch || !open) return;
if (triggerSearchOnFocus) {
await doSearch();
}
if (debouncedSearchTerm) {
await doSearch();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
const CreatableItem = () => {
if (!creatable) return undefined;
if (
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
selected.find((s) => s.value === inputValue)
) {
return undefined;
}
const Item = (
<CommandItem
value={inputValue}
className="cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value: string) => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, { value, label: value }];
setSelected(newOptions);
onChange?.(newOptions);
}}
>
{`Create "${inputValue}"`}
</CommandItem>
);
// For normal creatable
if (!onSearch && inputValue.length > 0) {
return Item;
}
// For async search creatable. avoid showing creatable item before loading at first.
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
return Item;
}
return undefined;
};
const EmptyItem = React.useCallback(() => {
if (!emptyIndicator) return undefined;
// For async search that showing emptyIndicator
if (onSearch && !creatable && Object.keys(options).length === 0) {
return (
<CommandItem value="-" disabled>
{emptyIndicator}
</CommandItem>
);
}
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
}, [creatable, emptyIndicator, onSearch, options]);
const selectables = React.useMemo<GroupOption>(
() => removePickedOption(options, selected),
[options, selected],
);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = React.useCallback(() => {
if (commandProps?.filter) {
return commandProps.filter;
}
if (creatable) {
return (value: string, search: string) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
}
// Using default filter in `cmdk`. We don&lsquo;t have to provide it.
return undefined;
}, [creatable, commandProps?.filter]);
return (
<Command
ref={dropdownRef}
{...commandProps}
onKeyDown={(e) => {
handleKeyDown(e);
commandProps?.onKeyDown?.(e);
}}
className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)}
shouldFilter={
commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch
} // When onSearch is provided, we don&lsquo;t want to filter the options. You can still override it.
filter={commandFilter()}
>
<div
className={cn(
'border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 relative min-h-[38px] rounded-md border text-sm outline-none transition-[color,box-shadow] focus-within:ring-[3px]',
{
'p-1': selected.length !== 0,
'cursor-text': !disabled && selected.length !== 0,
},
!hideClearAllButton && 'pe-9',
className,
)}
onClick={() => {
if (disabled) return;
inputRef?.current?.focus();
}}
>
<div className="flex flex-wrap gap-1">
{selected.map((option) => {
return (
<div
key={option.value}
className={cn(
'animate-fadeIn bg-background text-secondary-foreground hover:bg-background data-fixed:pe-2 relative inline-flex h-7 cursor-default items-center rounded-md border pe-7 pl-2 ps-2 text-xs font-medium transition-all disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
badgeClassName,
)}
data-fixed={option.fixed}
data-disabled={disabled || undefined}
>
{option.label}
<button
className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute -inset-y-px -end-px flex size-7 items-center justify-center rounded-e-md border border-transparent p-0 outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUnselect(option);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(option)}
aria-label="Remove"
>
<XIcon size={14} aria-hidden="true" />
</button>
</div>
);
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
{...inputProps}
ref={inputRef}
value={inputValue}
disabled={disabled}
onValueChange={(value) => {
setInputValue(value);
inputProps?.onValueChange?.(value);
}}
onBlur={(event) => {
if (!onScrollbar) {
setOpen(false);
}
inputProps?.onBlur?.(event);
}}
onFocus={(event) => {
setOpen(true);
if (triggerSearchOnFocus) {
void onSearch?.(debouncedSearchTerm);
}
inputProps?.onFocus?.(event);
}}
placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder}
className={cn(
'placeholder:text-muted-foreground/70 flex-1 bg-transparent outline-none disabled:cursor-not-allowed',
{
'w-full': hidePlaceholderWhenSelected,
'px-3 py-2': selected.length === 0,
'ml-1': selected.length !== 0,
},
inputProps?.className,
)}
/>
<button
type="button"
onClick={() => {
setSelected(selected.filter((s) => s.fixed));
onChange?.(selected.filter((s) => s.fixed));
}}
className={cn(
'text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute end-0 top-0 flex size-9 items-center justify-center rounded-md border border-transparent outline-none transition-[color,box-shadow] focus-visible:ring-[3px]',
(hideClearAllButton ||
disabled ||
selected.length < 1 ||
selected.filter((s) => s.fixed).length === selected.length) &&
'hidden',
)}
aria-label="Clear all"
>
<XIcon size={16} aria-hidden="true" />
</button>
</div>
</div>
<div className="relative">
<div
className={cn(
'border-input absolute top-2 z-10 w-full overflow-hidden rounded-md border',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
!open && 'hidden',
)}
data-state={open ? 'open' : 'closed'}
>
{open && (
<CommandList
className="bg-popover text-popover-foreground shadow-lg outline-none"
onMouseLeave={() => {
setOnScrollbar(false);
}}
onMouseEnter={() => {
setOnScrollbar(true);
}}
onMouseUp={() => {
inputRef?.current?.focus();
}}
>
{isLoading ? (
<>{loadingIndicator}</>
) : (
<>
{EmptyItem()}
{CreatableItem()}
{!selectFirstItem && <CommandItem value="-" className="hidden" />}
{Object.entries(selectables).map(([key, dropdowns]) => (
<CommandGroup key={key} heading={key} className="h-full overflow-auto">
<>
{dropdowns.map((option) => {
return (
<CommandItem
key={option.value}
value={option.value}
disabled={option.disable}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, option];
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
'cursor-pointer',
option.disable &&
'pointer-events-none cursor-not-allowed opacity-50',
)}
>
{option.label}
</CommandItem>
);
})}
</>
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</div>
</Command>
);
};
MultiSelect.displayName = 'MultiSelect';
export { MultiSelect };
+83
View File
@@ -0,0 +1,83 @@
import * as React from 'react';
import { ChevronDown } from 'lucide-react';
import { cn } from '../lib/utils';
import { Button } from './button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './dropdown-menu';
const SplitButtonContext = React.createContext<{
variant?: React.ComponentProps<typeof Button>['variant'];
size?: React.ComponentProps<typeof Button>['size'];
}>({});
const SplitButton = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
variant?: React.ComponentProps<typeof Button>['variant'];
size?: React.ComponentProps<typeof Button>['size'];
}
>(({ className, children, variant = 'default', size = 'default', ...props }, ref) => {
return (
<SplitButtonContext.Provider value={{ variant, size }}>
<div ref={ref} className={cn('inline-flex', className)} {...props}>
{children}
</div>
</SplitButtonContext.Provider>
);
});
SplitButton.displayName = 'SplitButton';
const SplitButtonAction = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, children, ...props }, ref) => {
const { variant, size } = React.useContext(SplitButtonContext);
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn('flex-1 rounded-r-none border-r-0', className)}
{...props}
>
{children}
</Button>
);
});
SplitButtonAction.displayName = 'SplitButtonAction';
const SplitButtonDropdown = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ children, ...props }, ref) => {
const { variant, size } = React.useContext(SplitButtonContext);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={variant}
size={size}
className="rounded-l-none px-2 focus-visible:ring-offset-0"
>
<ChevronDown className="h-4 w-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" {...props} ref={ref}>
{children}
</DropdownMenuContent>
</DropdownMenu>
);
},
);
SplitButtonDropdown.displayName = 'SplitButtonDropdown';
const SplitButtonDropdownItem = DropdownMenuItem;
export { SplitButton, SplitButtonAction, SplitButtonDropdown, SplitButtonDropdownItem };
@@ -1,14 +1,10 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import {
DocumentDistributionMethod,
DocumentVisibility,
type Field,
type Recipient,
TeamMemberRole,
} from '@prisma/client';
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 { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
@@ -370,26 +366,24 @@ export const AddTemplateSettingsFormPartial = ({
)}
/>
<FormField
control={form.control}
name="globalActionAuth"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Recipient action authentication</Trans>
<DocumentGlobalAuthActionTooltip />
</FormLabel>
{isEnterprise && (
<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}
isDocumentEnterprise={isEnterprise}
/>
</FormControl>
</FormItem>
)}
/>
<FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
)}
{distributionMethod === DocumentDistributionMethod.EMAIL && (
<Accordion type="multiple">