mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
24 Commits
v1.10.3
...
feat/docum
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fc4a8ba7f | |||
| c4cb6eeb94 | |||
| 93aece9644 | |||
| abd4fddf31 | |||
| 44bc769e60 | |||
| c8f80f7be0 | |||
| 8540f24de0 | |||
| 67203d4bd7 | |||
| 9d1e638f0f | |||
| bd64ad9fef | |||
| 99b0ad574e | |||
| 9594e1fee8 | |||
| 5e3a2b8f76 | |||
| f928503a33 | |||
| c389670785 | |||
| 99ad2eb645 | |||
| 2f48679b0b | |||
| eb2b9dd099 | |||
| 311adb4d1e | |||
| 30a4f2c7b4 | |||
| d48705024e | |||
| 7a3763bb66 | |||
| 3bf056aa43 | |||
| 35db8182f0 |
2
.npmrc
2
.npmrc
@ -1 +1,3 @@
|
||||
auto-install-peers = true
|
||||
legacy-peer-deps = true
|
||||
prefer-dedupe = true
|
||||
@ -1,3 +1,5 @@
|
||||
import nextra from 'nextra';
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: [
|
||||
@ -9,9 +11,10 @@ const nextConfig = {
|
||||
],
|
||||
};
|
||||
|
||||
const withNextra = require('nextra')({
|
||||
const withNextra = nextra({
|
||||
theme: 'nextra-theme-docs',
|
||||
themeConfig: './theme.config.tsx',
|
||||
codeHighlight: true,
|
||||
});
|
||||
|
||||
module.exports = withNextra(nextConfig);
|
||||
export default withNextra(nextConfig);
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"next": "14.2.6",
|
||||
"next": "14.2.28",
|
||||
"next-plausible": "^3.12.0",
|
||||
"nextra": "^2.13.4",
|
||||
"nextra-theme-docs": "^2.13.4",
|
||||
|
||||
@ -19,6 +19,22 @@ const themeConfig: DocsThemeConfig = {
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
!function(){
|
||||
if (location.hostname === 'localhost') return;
|
||||
var e="6c236490c9a68c1",
|
||||
t=function(){Reo.init({ clientID: e })},
|
||||
n=document.createElement("script");
|
||||
n.src="https://static.reo.dev/"+e+"/reo.js";
|
||||
n.defer=true;
|
||||
n.onload=t;
|
||||
document.head.appendChild(n);
|
||||
}();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.5.0",
|
||||
"next": "14.2.6"
|
||||
"next": "14.2.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
|
||||
@ -173,34 +173,59 @@ export const ConfigureFieldsView = ({
|
||||
});
|
||||
|
||||
const onFieldCopy = useCallback(
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
|
||||
const { duplicate = false } = options ?? {};
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||
|
||||
if (lastActiveField) {
|
||||
event?.preventDefault();
|
||||
|
||||
if (!duplicate) {
|
||||
setFieldClipboard(lastActiveField);
|
||||
if (duplicate) {
|
||||
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
|
||||
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
};
|
||||
|
||||
toast({
|
||||
title: 'Copied field',
|
||||
description: 'Copied field to clipboard',
|
||||
append(newField);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (duplicateAll) {
|
||||
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
||||
|
||||
pages.forEach((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
|
||||
if (pageNumber === lastActiveField.pageNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
|
||||
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
|
||||
pageNumber,
|
||||
};
|
||||
|
||||
append(newField);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
|
||||
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
};
|
||||
setFieldClipboard(lastActiveField);
|
||||
|
||||
append(newField);
|
||||
toast({
|
||||
title: 'Copied field',
|
||||
description: 'Copied field to clipboard',
|
||||
});
|
||||
}
|
||||
},
|
||||
[append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast],
|
||||
@ -533,6 +558,7 @@ export const ConfigureFieldsView = ({
|
||||
onMove={(node) => onFieldMove(node, index)}
|
||||
onRemove={() => remove(index)}
|
||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||
onFocus={() => setLastActiveField(field)}
|
||||
onBlur={() => setLastActiveField(null)}
|
||||
onAdvancedSettings={() => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@ -6,9 +6,9 @@ import { RecipientRole } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
@ -20,6 +20,8 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||
|
||||
@ -51,6 +53,7 @@ export const DocumentSigningAuth2FA = ({
|
||||
}: DocumentSigningAuth2FAProps) => {
|
||||
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
|
||||
useRequiredDocumentSigningAuthContext();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<T2FAAuthFormSchema>({
|
||||
resolver: zodResolver(Z2FAAuthFormSchema),
|
||||
@ -60,27 +63,104 @@ export const DocumentSigningAuth2FA = ({
|
||||
});
|
||||
|
||||
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false);
|
||||
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||
const [isEmailCodeSent, setIsEmailCodeSent] = useState(false);
|
||||
const [isEmailCodeSending, setIsEmailCodeSending] = useState(false);
|
||||
const [canResendEmail, setCanResendEmail] = useState(true);
|
||||
const [resendCountdown, setResendCountdown] = useState(0);
|
||||
const countdownTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [verificationMethod, setVerificationMethod] = useState<'app' | 'email'>(
|
||||
user?.twoFactorEnabled ? 'app' : 'email',
|
||||
);
|
||||
const emailSendInitiatedRef = useRef(false);
|
||||
|
||||
const sendVerificationMutation = trpc.auth.sendEmailVerification.useMutation({
|
||||
onSuccess: () => {
|
||||
setIsEmailCodeSent(true);
|
||||
setCanResendEmail(false);
|
||||
setResendCountdown(60);
|
||||
|
||||
countdownTimerRef.current = setInterval(() => {
|
||||
setResendCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
if (countdownTimerRef.current) {
|
||||
clearInterval(countdownTimerRef.current);
|
||||
}
|
||||
setCanResendEmail(true);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
toast({
|
||||
title: 'Verification code sent',
|
||||
description: `A verification code has been sent to ${recipient.email}`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to send verification code', error);
|
||||
toast({
|
||||
title: 'Failed to send verification code',
|
||||
description: 'Please try again or contact support',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsEmailCodeSending(false);
|
||||
},
|
||||
});
|
||||
|
||||
const verifyCodeMutation = trpc.auth.verifyEmailCode.useMutation();
|
||||
|
||||
const sendEmailVerificationCode = async () => {
|
||||
try {
|
||||
setIsEmailCodeSending(true);
|
||||
await sendVerificationMutation.mutateAsync({
|
||||
recipientId: recipient.id,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to send verification code',
|
||||
description: 'Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (countdownTimerRef.current) {
|
||||
clearInterval(countdownTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
|
||||
try {
|
||||
setIsCurrentlyAuthenticating(true);
|
||||
|
||||
if (verificationMethod === 'email') {
|
||||
await verifyCodeMutation.mutateAsync({
|
||||
code: token,
|
||||
recipientId: recipient.id,
|
||||
});
|
||||
}
|
||||
|
||||
await onReauthFormSubmit({
|
||||
type: DocumentAuth.TWO_FACTOR_AUTH,
|
||||
token,
|
||||
});
|
||||
|
||||
setIsCurrentlyAuthenticating(false);
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setIsCurrentlyAuthenticating(false);
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
setFormErrorCode(error.code);
|
||||
|
||||
// Todo: Alert.
|
||||
toast({
|
||||
title: 'Unauthorized',
|
||||
description: 'We were unable to verify your details.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -90,21 +170,47 @@ export const DocumentSigningAuth2FA = ({
|
||||
});
|
||||
|
||||
setIs2FASetupSuccessful(false);
|
||||
setFormErrorCode(null);
|
||||
setIsEmailCodeSent(false);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
if (open && !user?.twoFactorEnabled) {
|
||||
setVerificationMethod('email');
|
||||
}
|
||||
}, [open, user?.twoFactorEnabled, form]);
|
||||
|
||||
if (!user?.twoFactorEnabled && !is2FASetupSuccessful) {
|
||||
useEffect(() => {
|
||||
if (!open || verificationMethod !== 'email') {
|
||||
emailSendInitiatedRef.current = false;
|
||||
}
|
||||
}, [open, verificationMethod]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && verificationMethod === 'email' && !isEmailCodeSent && !isEmailCodeSending) {
|
||||
if (!emailSendInitiatedRef.current) {
|
||||
emailSendInitiatedRef.current = true;
|
||||
void sendEmailVerificationCode();
|
||||
}
|
||||
}
|
||||
}, [open, verificationMethod, isEmailCodeSent, isEmailCodeSending]);
|
||||
|
||||
if (verificationMethod === 'app' && !user?.twoFactorEnabled && !is2FASetupSuccessful) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs
|
||||
value={verificationMethod}
|
||||
onValueChange={(val) => setVerificationMethod(val as 'app' | 'email')}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="app">Authenticator App</TabsTrigger>
|
||||
<TabsTrigger value="email">Email Verification</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? (
|
||||
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
|
||||
) : (
|
||||
// Todo: Translate
|
||||
`You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`
|
||||
)}
|
||||
</p>
|
||||
@ -129,59 +235,106 @@ export const DocumentSigningAuth2FA = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>2FA token</FormLabel>
|
||||
<div className="space-y-4">
|
||||
{user?.twoFactorEnabled && (
|
||||
<Tabs
|
||||
value={verificationMethod}
|
||||
onValueChange={(val) => setVerificationMethod(val as 'app' | 'email')}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="app">Authenticator App</TabsTrigger>
|
||||
<TabsTrigger value="email">Email Verification</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{formErrorCode && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>
|
||||
<Trans>Unauthorized</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
We were unable to verify your details. Please try again or contact support
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{verificationMethod === 'email' && (
|
||||
<Alert variant="secondary">
|
||||
<AlertDescription>
|
||||
{isEmailCodeSent ? (
|
||||
<p>
|
||||
<Trans>
|
||||
A verification code has been sent to {recipient.email}. Please enter it below to
|
||||
continue.
|
||||
</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<Trans>
|
||||
We'll send a verification code to {recipient.email} to verify your identity.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
{verificationMethod === 'app' ? (
|
||||
<Trans>2FA token</Trans>
|
||||
) : (
|
||||
<Trans>Verification code</Trans>
|
||||
)}
|
||||
</FormLabel>
|
||||
|
||||
<Button type="submit" loading={isCurrentlyAuthenticating}>
|
||||
<Trans>Sign</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
<FormControl>
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{verificationMethod === 'email' && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
disabled={isEmailCodeSending || !canResendEmail}
|
||||
onClick={() => void sendEmailVerificationCode()}
|
||||
>
|
||||
{isEmailCodeSending ? (
|
||||
<Trans>Sending...</Trans>
|
||||
) : !canResendEmail ? (
|
||||
<Trans>Resend code ({resendCountdown}s)</Trans>
|
||||
) : (
|
||||
<Trans>Resend code</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isCurrentlyAuthenticating}>
|
||||
<Trans>{actionTarget === 'DOCUMENT' ? 'Sign Document' : 'Sign Field'}</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -27,7 +27,6 @@ export type DocumentSigningAuthDialogProps = {
|
||||
actionTarget: FieldType | 'DOCUMENT';
|
||||
open: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
|
||||
/**
|
||||
* The callback to run when the reauth form is filled out.
|
||||
*/
|
||||
@ -38,6 +37,7 @@ export const DocumentSigningAuthDialog = ({
|
||||
title,
|
||||
description,
|
||||
documentAuthType,
|
||||
actionTarget,
|
||||
open,
|
||||
onOpenChange,
|
||||
onReauthFormSubmit,
|
||||
@ -56,10 +56,22 @@ export const DocumentSigningAuthDialog = ({
|
||||
<Dialog open={open} onOpenChange={handleOnOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title || <Trans>Sign field</Trans>}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{title ||
|
||||
(actionTarget === 'DOCUMENT' ? (
|
||||
<Trans>Sign document</Trans>
|
||||
) : (
|
||||
<Trans>Sign field</Trans>
|
||||
))}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{description || <Trans>Reauthentication is required to sign this field</Trans>}
|
||||
{description || (
|
||||
<Trans>
|
||||
Reauthentication is required to sign this{' '}
|
||||
{actionTarget === 'DOCUMENT' ? 'document' : 'field'}
|
||||
</Trans>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@ -78,6 +90,7 @@ export const DocumentSigningAuthDialog = ({
|
||||
))
|
||||
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
|
||||
<DocumentSigningAuth2FA
|
||||
actionTarget={actionTarget === 'DOCUMENT' ? 'DOCUMENT' : 'FIELD'}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
|
||||
@ -43,6 +43,7 @@ export type DocumentSigningAuthContextValue = {
|
||||
setPreferredPasskeyId: (_value: string | null) => void;
|
||||
user?: SessionUser | null;
|
||||
refetchPasskeys: () => Promise<void>;
|
||||
isEnterprise: boolean;
|
||||
};
|
||||
|
||||
const DocumentSigningAuthContext = createContext<DocumentSigningAuthContextValue | null>(null);
|
||||
@ -66,6 +67,7 @@ export interface DocumentSigningAuthProviderProps {
|
||||
recipient: Recipient;
|
||||
user?: SessionUser | null;
|
||||
children: React.ReactNode;
|
||||
isEnterprise: boolean;
|
||||
}
|
||||
|
||||
export const DocumentSigningAuthProvider = ({
|
||||
@ -73,6 +75,7 @@ export const DocumentSigningAuthProvider = ({
|
||||
recipient: initialRecipient,
|
||||
user,
|
||||
children,
|
||||
isEnterprise,
|
||||
}: DocumentSigningAuthProviderProps) => {
|
||||
const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions);
|
||||
const [recipient, setRecipient] = useState(initialRecipient);
|
||||
@ -138,8 +141,13 @@ export const DocumentSigningAuthProvider = ({
|
||||
.exhaustive();
|
||||
|
||||
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
|
||||
// Directly run callback if no auth required.
|
||||
if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) {
|
||||
// Determine if authentication is required based on enterprise status and action target.
|
||||
const requiresAuthTrigger = isEnterprise
|
||||
? derivedRecipientActionAuth && options.actionTarget === FieldType.SIGNATURE
|
||||
: derivedRecipientActionAuth && options.actionTarget === 'DOCUMENT';
|
||||
|
||||
// Directly run callback if no auth trigger is needed.
|
||||
if (!requiresAuthTrigger) {
|
||||
await options.onReauthFormSubmit();
|
||||
return;
|
||||
}
|
||||
@ -198,6 +206,7 @@ export const DocumentSigningAuthProvider = ({
|
||||
preferredPasskeyId,
|
||||
setPreferredPasskeyId,
|
||||
refetchPasskeys,
|
||||
isEnterprise,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@ -218,6 +227,8 @@ export const DocumentSigningAuthProvider = ({
|
||||
type ExecuteActionAuthProcedureOptions = Omit<
|
||||
DocumentSigningAuthDialogProps,
|
||||
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole'
|
||||
>;
|
||||
> & {
|
||||
actionTarget: FieldType | 'DOCUMENT';
|
||||
};
|
||||
|
||||
DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider';
|
||||
|
||||
@ -166,7 +166,7 @@ export const DocumentSigningFieldContainer = ({
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent
|
||||
className="border-0 bg-orange-300 fill-orange-300 font-bold text-orange-900"
|
||||
className="border-0 bg-orange-300 fill-orange-300 text-orange-900"
|
||||
sideOffset={2}
|
||||
>
|
||||
{tooltipText && <p>{tooltipText}</p>}
|
||||
|
||||
@ -28,6 +28,7 @@ import {
|
||||
AssistantConfirmationDialog,
|
||||
type NextSigner,
|
||||
} from '../../dialogs/assistant-confirmation-dialog';
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||
|
||||
@ -39,6 +40,7 @@ export type DocumentSigningFormProps = {
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
setSelectedSignerId?: (id: number | null) => void;
|
||||
isEnterprise: boolean;
|
||||
};
|
||||
|
||||
export const DocumentSigningForm = ({
|
||||
@ -49,6 +51,7 @@ export const DocumentSigningForm = ({
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
setSelectedSignerId,
|
||||
isEnterprise,
|
||||
}: DocumentSigningFormProps) => {
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
@ -62,6 +65,7 @@ export const DocumentSigningForm = ({
|
||||
const assistantSignersId = useId();
|
||||
|
||||
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
||||
@ -114,11 +118,16 @@ export const DocumentSigningForm = ({
|
||||
setIsAssistantSubmitting(true);
|
||||
|
||||
try {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
await executeActionAuthProcedure({
|
||||
actionTarget: 'DOCUMENT',
|
||||
onReauthFormSubmit: async (authOptions) => {
|
||||
await completeDocument(authOptions, nextSigner);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while completing the document. Please try again.',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while completing the document. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@ -229,7 +238,12 @@ export const DocumentSigningForm = ({
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
await executeActionAuthProcedure({
|
||||
actionTarget: 'DOCUMENT',
|
||||
onReauthFormSubmit: async (authOptions) => {
|
||||
await completeDocument(authOptions, nextSigner);
|
||||
},
|
||||
});
|
||||
}}
|
||||
role={recipient.role}
|
||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||
@ -409,7 +423,12 @@ export const DocumentSigningForm = ({
|
||||
fieldsValidated={fieldsValidated}
|
||||
disabled={!isRecipientsTurn}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
await executeActionAuthProcedure({
|
||||
actionTarget: 'DOCUMENT',
|
||||
onReauthFormSubmit: async (authOptions) => {
|
||||
await completeDocument(authOptions, nextSigner);
|
||||
},
|
||||
});
|
||||
}}
|
||||
role={recipient.role}
|
||||
allowDictateNextSigner={
|
||||
|
||||
@ -47,6 +47,7 @@ export type DocumentSigningPageViewProps = {
|
||||
completedFields: CompletedField[];
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
isEnterprise: boolean;
|
||||
};
|
||||
|
||||
export const DocumentSigningPageView = ({
|
||||
@ -56,6 +57,7 @@ export const DocumentSigningPageView = ({
|
||||
completedFields,
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
isEnterprise,
|
||||
}: DocumentSigningPageViewProps) => {
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
@ -153,6 +155,7 @@ export const DocumentSigningPageView = ({
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
setSelectedSignerId={setSelectedSignerId}
|
||||
isEnterprise={isEnterprise}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -152,7 +152,7 @@ export const TemplateEditForm = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while updating the document settings.`),
|
||||
description: _(msg`An error occurred while updating the template settings.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,96 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { WebhookTriggerEvents } from '@prisma/client';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
|
||||
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';
|
||||
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
|
||||
|
||||
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 [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
const { _ } = useLingui();
|
||||
|
||||
const triggerEvents = Object.values(WebhookTriggerEvents);
|
||||
const value = listValues.map((event) => ({
|
||||
value: event,
|
||||
label: toFriendlyWebhookEventName(event),
|
||||
}));
|
||||
|
||||
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);
|
||||
const onMutliSelectChange = (options: Option[]) => {
|
||||
onChange(options.map((option) => option.value));
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -10,6 +10,8 @@ import {
|
||||
Download,
|
||||
Edit,
|
||||
EyeIcon,
|
||||
FileDown,
|
||||
FolderInput,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
MoveRight,
|
||||
@ -182,7 +184,7 @@ export const DocumentsTableActionDropdown = ({
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
<Trans>Download Original</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@ -201,7 +203,7 @@ export const DocumentsTableActionDropdown = ({
|
||||
|
||||
{onMoveDocument && (
|
||||
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
|
||||
<MoveRight className="mr-2 h-4 w-4" />
|
||||
<FolderInput className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Folder</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@ -69,8 +69,6 @@ export const TemplatesTableActionDropdown = ({
|
||||
? `${templateRootPath}/f/${row.folderId}/${row.id}/edit`
|
||||
: `${templateRootPath}/${row.id}/edit`;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger data-testid="template-table-action-btn">
|
||||
|
||||
@ -17,6 +17,7 @@ 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';
|
||||
@ -62,6 +63,10 @@ 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);
|
||||
@ -74,6 +79,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
|
||||
return {
|
||||
document,
|
||||
team,
|
||||
documentLanguage,
|
||||
isPlatformDocument,
|
||||
auditLogs,
|
||||
@ -91,7 +97,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, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData;
|
||||
const { document, team, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData;
|
||||
|
||||
const { i18n, _ } = useLingui();
|
||||
|
||||
@ -343,7 +349,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isPlatformDocument && (
|
||||
{!isPlatformDocument && !team?.teamGlobalSettings?.brandingHidePoweredBy && (
|
||||
<div className="my-8 flex-row-reverse space-y-4">
|
||||
<div className="flex items-end justify-end gap-x-4">
|
||||
<div
|
||||
|
||||
@ -94,6 +94,7 @@ export default function DirectTemplatePage() {
|
||||
documentAuthOptions={template.authOptions}
|
||||
recipient={directTemplateRecipient}
|
||||
user={user}
|
||||
isEnterprise={false}
|
||||
>
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1
|
||||
|
||||
@ -6,6 +6,7 @@ import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
|
||||
|
||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||
@ -60,6 +61,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const isEnterprise = user?.id
|
||||
? await isUserEnterprise({ userId: user.id }).catch(() => false)
|
||||
: false;
|
||||
|
||||
const recipientWithFields = { ...recipient, fields };
|
||||
|
||||
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
|
||||
@ -115,6 +120,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
isDocumentAccessValid: false,
|
||||
recipientEmail: recipient.email,
|
||||
recipientHasAccount,
|
||||
isEnterprise,
|
||||
} as const);
|
||||
}
|
||||
|
||||
@ -149,6 +155,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
completedFields,
|
||||
recipientSignature,
|
||||
isRecipientsTurn,
|
||||
isEnterprise,
|
||||
} as const);
|
||||
}
|
||||
|
||||
@ -176,6 +183,7 @@ export default function SigningPage() {
|
||||
isRecipientsTurn,
|
||||
allRecipients,
|
||||
recipientWithFields,
|
||||
isEnterprise,
|
||||
} = data;
|
||||
|
||||
if (document.deletedAt || document.status === DocumentStatus.REJECTED) {
|
||||
@ -241,6 +249,7 @@ export default function SigningPage() {
|
||||
documentAuthOptions={document.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
isEnterprise={isEnterprise}
|
||||
>
|
||||
<DocumentSigningPageView
|
||||
recipient={recipientWithFields}
|
||||
@ -249,6 +258,7 @@ export default function SigningPage() {
|
||||
completedFields={completedFields}
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
isEnterprise={isEnterprise}
|
||||
/>
|
||||
</DocumentSigningAuthProvider>
|
||||
</DocumentSigningProvider>
|
||||
|
||||
@ -119,7 +119,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'-mx-4 flex flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
|
||||
'-mx-4 flex flex-col items-center overflow-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
|
||||
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
|
||||
)}
|
||||
>
|
||||
|
||||
@ -143,6 +143,7 @@ export default function EmbedDirectTemplatePage() {
|
||||
documentAuthOptions={template.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
isEnterprise={isEnterpriseDocument}
|
||||
>
|
||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||
<EmbedDirectTemplateClientPage
|
||||
|
||||
@ -168,6 +168,7 @@ export default function EmbedSignDocumentPage() {
|
||||
documentAuthOptions={document.authOptions}
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
isEnterprise={isEnterpriseDocument}
|
||||
>
|
||||
<EmbedSignDocumentClientPage
|
||||
token={token}
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { createCookie } from 'react-router';
|
||||
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
export const langCookie = createCookie('lang', {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 365 * 2,
|
||||
httpOnly: true,
|
||||
secure: env('NODE_ENV') === 'production',
|
||||
});
|
||||
|
||||
@ -33,8 +33,8 @@
|
||||
"@lingui/react": "^5.2.0",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@react-router/node": "^7.1.5",
|
||||
"@react-router/serve": "^7.1.5",
|
||||
"@react-router/node": "^7.6.0",
|
||||
"@react-router/serve": "^7.6.0",
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"autoprefixer": "^10.4.13",
|
||||
@ -49,8 +49,8 @@
|
||||
"luxon": "^3.4.0",
|
||||
"papaparse": "^5.4.1",
|
||||
"plausible-tracker": "^0.3.9",
|
||||
"posthog-js": "^1.224.0",
|
||||
"posthog-node": "^4.8.1",
|
||||
"posthog-js": "^1.245.0",
|
||||
"posthog-node": "^4.17.0",
|
||||
"react": "^18",
|
||||
"react-call": "^1.3.0",
|
||||
"react-dom": "^18",
|
||||
@ -59,7 +59,7 @@
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"react-router": "^7.1.5",
|
||||
"react-router": "^7.6.0",
|
||||
"recharts": "^2.7.2",
|
||||
"remeda": "^2.17.3",
|
||||
"remix-themes": "^2.0.4",
|
||||
@ -75,9 +75,9 @@
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@lingui/babel-plugin-lingui-macro": "^5.2.0",
|
||||
"@lingui/vite-plugin": "^5.2.0",
|
||||
"@react-router/dev": "^7.1.5",
|
||||
"@react-router/remix-routes-option-adapter": "^7.1.5",
|
||||
"@lingui/vite-plugin": "^5.3.1",
|
||||
"@react-router/dev": "^7.6.0",
|
||||
"@react-router/remix-routes-option-adapter": "^7.6.0",
|
||||
"@rollup/plugin-babel": "^6.0.4",
|
||||
"@rollup/plugin-commonjs": "^28.0.2",
|
||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||
@ -91,14 +91,14 @@
|
||||
"@types/react-dom": "^18",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "0.24.2",
|
||||
"esbuild": "^0.25.4",
|
||||
"remix-flat-routes": "^0.8.4",
|
||||
"rollup": "^4.34.5",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "5.6.2",
|
||||
"vite": "^6.1.0",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "1.10.3"
|
||||
"version": "1.11.1"
|
||||
}
|
||||
|
||||
@ -35,12 +35,27 @@ export default defineConfig({
|
||||
],
|
||||
ssr: {
|
||||
noExternal: ['react-dropzone', 'plausible-tracker', 'pdfjs-dist'],
|
||||
external: ['@node-rs/bcrypt', '@prisma/client', '@documenso/tailwind-config'],
|
||||
external: [
|
||||
'@node-rs/bcrypt',
|
||||
'@prisma/client',
|
||||
'@documenso/tailwind-config',
|
||||
'playwright',
|
||||
'playwright-core',
|
||||
'@playwright/browser-chromium',
|
||||
],
|
||||
},
|
||||
optimizeDeps: {
|
||||
entries: ['./app/**/*', '../../packages/ui/**/*', '../../packages/lib/**/*'],
|
||||
include: ['prop-types', 'file-selector', 'attr-accept'],
|
||||
exclude: ['node_modules', '@node-rs/bcrypt', '@documenso/pdf-sign', 'sharp'],
|
||||
exclude: [
|
||||
'node_modules',
|
||||
'@node-rs/bcrypt',
|
||||
'@documenso/pdf-sign',
|
||||
'sharp',
|
||||
'playwright',
|
||||
'playwright-core',
|
||||
'@playwright/browser-chromium',
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
@ -68,7 +83,8 @@ export default defineConfig({
|
||||
'@documenso/pdf-sign',
|
||||
'@aws-sdk/cloudfront-signer',
|
||||
'nodemailer',
|
||||
'playwright',
|
||||
/playwright/,
|
||||
'@playwright/browser-chromium',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@ -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"]
|
||||
20594
package-lock.json
generated
20594
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.10.3",
|
||||
"version": "1.11.1",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
@ -44,18 +44,22 @@
|
||||
"@commitlint/cli": "^17.7.1",
|
||||
"@commitlint/config-conventional": "^17.7.0",
|
||||
"@lingui/cli": "^5.2.0",
|
||||
"@trigger.dev/cli": "^2.3.18",
|
||||
"dotenv": "^16.3.1",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-config-custom": "*",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"playwright": "1.43.0",
|
||||
"playwright": "1.52.0",
|
||||
"prettier": "^3.3.3",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3",
|
||||
"vite": "^6.1.0"
|
||||
"vite": "^6.3.5",
|
||||
"@prisma/client": "^6.8.2",
|
||||
"prisma": "^6.8.2",
|
||||
"prisma-extension-kysely": "^3.0.0",
|
||||
"prisma-kysely": "^1.8.0",
|
||||
"nodemailer": "^6.10.1"
|
||||
},
|
||||
"name": "@documenso/root",
|
||||
"workspaces": [
|
||||
@ -80,4 +84,4 @@
|
||||
"trigger.dev": {
|
||||
"endpointId": "documenso-app"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -20,11 +20,11 @@
|
||||
"@ts-rest/core": "^3.30.5",
|
||||
"@ts-rest/open-api": "^3.33.0",
|
||||
"@ts-rest/serverless": "^3.30.5",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"luxon": "^3.4.0",
|
||||
"superjson": "^1.13.1",
|
||||
"swagger-ui-react": "^5.11.0",
|
||||
"swagger-ui-react": "^5.21.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "3.24.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ import {
|
||||
ZDeleteDocumentMutationSchema,
|
||||
ZDeleteFieldMutationSchema,
|
||||
ZDeleteRecipientMutationSchema,
|
||||
ZDownloadDocumentQuerySchema,
|
||||
ZDownloadDocumentSuccessfulSchema,
|
||||
ZFindTeamMembersResponseSchema,
|
||||
ZGenerateDocumentFromTemplateMutationResponseSchema,
|
||||
@ -71,6 +72,7 @@ export const ApiContractV1 = c.router(
|
||||
downloadSignedDocument: {
|
||||
method: 'GET',
|
||||
path: '/api/v1/documents/:id/download',
|
||||
query: ZDownloadDocumentQuerySchema,
|
||||
responses: {
|
||||
200: ZDownloadDocumentSuccessfulSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
|
||||
@ -142,6 +142,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
|
||||
downloadSignedDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId } = args.params;
|
||||
const { downloadOriginalDocument } = args.query;
|
||||
|
||||
try {
|
||||
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
|
||||
@ -177,7 +178,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDocumentCompleted(document.status)) {
|
||||
if (!downloadOriginalDocument && !isDocumentCompleted(document.status)) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
@ -186,7 +187,9 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
};
|
||||
}
|
||||
|
||||
const { url } = await getPresignGetUrl(document.documentData.data);
|
||||
const { url } = await getPresignGetUrl(
|
||||
downloadOriginalDocument ? document.documentData.initialData : document.documentData.data,
|
||||
);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
|
||||
@ -119,6 +119,15 @@ export const ZUploadDocumentSuccessfulSchema = z.object({
|
||||
key: z.string(),
|
||||
});
|
||||
|
||||
export const ZDownloadDocumentQuerySchema = z.object({
|
||||
downloadOriginalDocument: z
|
||||
.preprocess((val) => String(val) === 'true' || String(val) === '1', z.boolean())
|
||||
.optional()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export type TDownloadDocumentQuerySchema = z.infer<typeof ZDownloadDocumentQuerySchema>;
|
||||
|
||||
export const ZDownloadDocumentSuccessfulSchema = z.object({
|
||||
downloadUrl: z.string(),
|
||||
});
|
||||
|
||||
@ -116,15 +116,15 @@ test.describe('[EE_ONLY]', () => {
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Global action auth should not be visible.
|
||||
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
|
||||
// Global action auth should now be visible for all users
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
|
||||
|
||||
// Next step.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Advanced settings should not be visible.
|
||||
await expect(page.getByLabel('Show advanced settings')).not.toBeVisible();
|
||||
// Advanced settings should now be visible for all users
|
||||
await expect(page.getByLabel('Show advanced settings')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@ -146,8 +146,8 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
|
||||
await page.getByLabel('Require account').getByText('Require account').click();
|
||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
|
||||
// Action auth should NOT be visible.
|
||||
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
|
||||
// Action auth should now be visible for all users
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
|
||||
|
||||
// Save the settings by going to the next step.
|
||||
|
||||
|
||||
@ -256,10 +256,16 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
await expect(async () => {
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
// Delete document.
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).click();
|
||||
@ -267,11 +273,16 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
await expect(async () => {
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
// Delete document.
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).click();
|
||||
|
||||
@ -342,7 +342,13 @@ test('user can move a document to a document folder', async ({ page }) => {
|
||||
redirectPath: '/documents',
|
||||
});
|
||||
|
||||
await page.getByTestId('document-table-action-btn').click();
|
||||
await expect(async () => {
|
||||
await page.getByTestId('document-table-action-btn').first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Proposals' }).click();
|
||||
@ -379,7 +385,13 @@ test('user can move a document from folder to the root', async ({ page }) => {
|
||||
|
||||
await page.getByText('Proposals').click();
|
||||
|
||||
await page.getByTestId('document-table-action-btn').click();
|
||||
await expect(async () => {
|
||||
await page.getByTestId('document-table-action-btn').first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Root' }).click();
|
||||
@ -791,7 +803,13 @@ test('user can move a template to a template folder', async ({ page }) => {
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
await page.getByTestId('template-table-action-btn').click();
|
||||
await expect(async () => {
|
||||
await page.getByTestId('template-table-action-btn').first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Client Templates' }).click();
|
||||
@ -828,7 +846,13 @@ test('user can move a template from a folder to the root', async ({ page }) => {
|
||||
|
||||
await page.getByText('Client Templates').click();
|
||||
|
||||
await page.getByTestId('template-table-action-btn').click();
|
||||
await expect(async () => {
|
||||
await page.getByTestId('template-table-action-btn').first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Root' }).click();
|
||||
|
||||
@ -230,13 +230,21 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await page.getByRole('row').getByRole('button').nth(1).click();
|
||||
await page.getByRole('menuitem', { name: 'Resend' }).click();
|
||||
await expect(async () => {
|
||||
await page.getByTestId('document-table-action-btn').first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
await page.getByRole('menuitem').filter({ hasText: 'Resend' }).click();
|
||||
await page.getByLabel('test.documenso.com').first().click();
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
await expect(page.getByRole('status')).toContainText('Document re-sent');
|
||||
await expect(
|
||||
page.getByRole('status').filter({ hasText: 'Document re-sent' }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete draft team document', async ({ page }) => {
|
||||
@ -248,7 +256,13 @@ test('[TEAMS]: delete draft team document', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents?status=DRAFT`,
|
||||
});
|
||||
|
||||
await page.getByRole('row').getByRole('button').nth(1).click();
|
||||
await expect(async () => {
|
||||
await page.getByTestId('document-table-action-btn').first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
@ -286,7 +300,13 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await page.getByRole('row').getByRole('button').nth(1).click();
|
||||
await expect(async () => {
|
||||
await page.getByTestId('document-table-action-btn').first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
@ -325,7 +345,13 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
|
||||
});
|
||||
|
||||
await page.getByRole('row').getByRole('button').nth(2).click();
|
||||
await expect(async () => {
|
||||
await page.getByTestId('document-table-action-btn').first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
|
||||
@ -113,8 +113,8 @@ test.describe('[EE_ONLY]', () => {
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Global action auth should not be visible.
|
||||
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
|
||||
// Global action auth should now be visible for all users
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
|
||||
|
||||
// Next step.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@ -143,8 +143,8 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
|
||||
await page.getByLabel('Require account').getByText('Require account').click();
|
||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
|
||||
// Action auth should NOT be visible.
|
||||
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
|
||||
// Action auth should now be visible for all users
|
||||
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
|
||||
|
||||
// Save the settings by going to the next step.
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
import {
|
||||
extractUserVerificationToken,
|
||||
seedTestEmail,
|
||||
@ -23,9 +24,11 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page
|
||||
await signSignaturePad(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Next', exact: true }).click();
|
||||
await page.getByLabel('Public profile username').fill(Date.now().toString());
|
||||
|
||||
await page.getByRole('button', { name: 'Complete', exact: true }).click();
|
||||
await page.getByLabel('Public profile username').fill(alphaid(10));
|
||||
await page.getByLabel('Public profile username').blur();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
|
||||
await page.waitForURL('/unverified-account');
|
||||
|
||||
|
||||
@ -12,13 +12,13 @@
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.18.1",
|
||||
"@playwright/test": "1.52.0",
|
||||
"@types/node": "^20",
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"pdf-lib": "^1.17.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"start-server-and-test": "^2.0.1"
|
||||
"start-server-and-test": "^2.0.12"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,7 @@ export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
workers: '50%',
|
||||
workers: 1,
|
||||
maxFailures: process.env.CI ? 1 : undefined,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
@ -18,8 +18,8 @@
|
||||
"arctic": "^3.1.0",
|
||||
"hono": "4.7.0",
|
||||
"luxon": "^3.5.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"nanoid": "^5.1.5",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "3.24.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -36,7 +36,7 @@
|
||||
"@react-email/section": "0.0.10",
|
||||
"@react-email/tailwind": "0.0.9",
|
||||
"@react-email/text": "0.0.6",
|
||||
"nodemailer": "6.9.9",
|
||||
"nodemailer": "^6.10.1",
|
||||
"react-email": "1.9.5",
|
||||
"resend": "2.0.0"
|
||||
},
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export type TemplateVerificationCodeProps = {
|
||||
verificationCode: string;
|
||||
assetBaseUrl: string;
|
||||
};
|
||||
|
||||
export const TemplateVerificationCode = ({
|
||||
verificationCode,
|
||||
assetBaseUrl,
|
||||
}: TemplateVerificationCodeProps) => {
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
<Trans>Your verification code</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Trans>Please use the code below to verify your identity for document signing.</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-6 text-center text-3xl font-bold tracking-widest">
|
||||
{verificationCode}
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-sm text-slate-400">
|
||||
<Trans>
|
||||
If you did not request this code, you can ignore this email. The code will expire after
|
||||
10 minutes.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateVerificationCode;
|
||||
62
packages/email/templates/verification-code.tsx
Normal file
62
packages/email/templates/verification-code.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import type { TemplateVerificationCodeProps } from '../template-components/template-verification-code';
|
||||
import { TemplateVerificationCode } from '../template-components/template-verification-code';
|
||||
|
||||
export type VerificationCodeTemplateProps = Partial<TemplateVerificationCodeProps>;
|
||||
|
||||
export const VerificationCodeTemplate = ({
|
||||
verificationCode = '000000',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: VerificationCodeTemplateProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Your verification code for document signing`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
|
||||
) : (
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TemplateVerificationCode
|
||||
verificationCode={verificationCode}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationCodeTemplate;
|
||||
@ -1,13 +1,7 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'next',
|
||||
'turbo',
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:package-json/recommended',
|
||||
],
|
||||
extends: ['next', 'turbo', 'eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
|
||||
plugins: ['package-json', 'unused-imports'],
|
||||
plugins: ['unused-imports'],
|
||||
|
||||
env: {
|
||||
es2022: true,
|
||||
|
||||
@ -10,11 +10,11 @@
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||
"@typescript-eslint/parser": "^7.1.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.1.3",
|
||||
"eslint-config-next": "^14.2.28",
|
||||
"eslint-config-turbo": "^1.12.5",
|
||||
"eslint-plugin-package-json": "^0.10.4",
|
||||
"eslint-plugin-react": "^7.34.0",
|
||||
"eslint-plugin-unused-imports": "^3.1.0",
|
||||
"eslint-plugin-package-json": "^0.31.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,6 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/kysely-adapter": "^0.6.0",
|
||||
"@aws-sdk/client-s3": "^3.410.0",
|
||||
"@aws-sdk/cloudfront-signer": "^3.410.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.410.0",
|
||||
@ -41,12 +40,13 @@
|
||||
"kysely": "0.26.3",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"nanoid": "^4.0.2",
|
||||
"nanoid": "^5.1.5",
|
||||
"oslo": "^0.17.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.11.3",
|
||||
"playwright": "1.43.0",
|
||||
"posthog-js": "^1.224.0",
|
||||
"playwright": "1.52.0",
|
||||
"posthog-js": "^1.245.0",
|
||||
"posthog-node": "^4.17.0",
|
||||
"react": "^18",
|
||||
"remeda": "^2.17.3",
|
||||
"sharp": "0.32.6",
|
||||
@ -55,7 +55,7 @@
|
||||
"zod": "3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/browser-chromium": "1.43.0",
|
||||
"@playwright/browser-chromium": "1.52.0",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/pg": "^8.11.4"
|
||||
}
|
||||
|
||||
120
packages/lib/server-only/2fa/send-email-verification.ts
Normal file
120
packages/lib/server-only/2fa/send-email-verification.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { randomInt } from 'crypto';
|
||||
|
||||
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { VerificationCodeTemplate } from '@documenso/email/templates/verification-code';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
|
||||
const ExtendedAuthErrorCode = {
|
||||
...AuthenticationErrorCode,
|
||||
InternalError: 'INTERNAL_ERROR',
|
||||
VerificationNotFound: 'VERIFICATION_NOT_FOUND',
|
||||
VerificationExpired: 'VERIFICATION_EXPIRED',
|
||||
};
|
||||
|
||||
const VERIFICATION_CODE_EXPIRY = 10 * 60 * 1000;
|
||||
|
||||
export type SendEmailVerificationOptions = {
|
||||
userId: number;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const sendEmailVerification = async ({ userId, email }: SendEmailVerificationOptions) => {
|
||||
try {
|
||||
const verificationCode = randomInt(100000, 1000000).toString();
|
||||
const i18n = await getI18nInstance();
|
||||
|
||||
await prisma.userTwoFactorEmailVerification.upsert({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
verificationCode,
|
||||
expiresAt: new Date(Date.now() + VERIFICATION_CODE_EXPIRY),
|
||||
},
|
||||
update: {
|
||||
verificationCode,
|
||||
expiresAt: new Date(Date.now() + VERIFICATION_CODE_EXPIRY),
|
||||
},
|
||||
});
|
||||
|
||||
const template = createElement(VerificationCodeTemplate, {
|
||||
verificationCode,
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: 'en' }),
|
||||
renderEmailWithI18N(template, { lang: 'en', plainText: true }),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: i18n._(msg`Your verification code for document signing`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error sending email verification', error);
|
||||
throw new AppError(ExtendedAuthErrorCode.InternalError);
|
||||
}
|
||||
};
|
||||
|
||||
export type VerifyEmailCodeOptions = {
|
||||
userId: number;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export const verifyEmailCode = async ({ userId, code }: VerifyEmailCodeOptions) => {
|
||||
try {
|
||||
const verification = await prisma.userTwoFactorEmailVerification.findUnique({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification) {
|
||||
throw new AppError(ExtendedAuthErrorCode.VerificationNotFound);
|
||||
}
|
||||
|
||||
if (verification.expiresAt < new Date()) {
|
||||
throw new AppError(ExtendedAuthErrorCode.VerificationExpired);
|
||||
}
|
||||
|
||||
if (verification.verificationCode !== code) {
|
||||
throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode);
|
||||
}
|
||||
|
||||
await prisma.userTwoFactorEmailVerification.delete({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error verifying email code', error);
|
||||
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(ExtendedAuthErrorCode.InternalError);
|
||||
}
|
||||
};
|
||||
@ -1,9 +1,14 @@
|
||||
import { DocumentSource, type Prisma } from '@prisma/client';
|
||||
import { DocumentSource, type Prisma, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { prefixedId } from '../../universal/id';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export interface DuplicateDocumentOptions {
|
||||
@ -86,7 +91,24 @@ export const duplicateDocument = async ({
|
||||
};
|
||||
}
|
||||
|
||||
const createdDocument = await prisma.document.create(createDocumentArguments);
|
||||
const createdDocument = await prisma.document.create({
|
||||
...createDocumentArguments,
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: ZWebhookDocumentSchema.parse({
|
||||
...mapDocumentToWebhookDocumentPayload(createdDocument),
|
||||
recipients: createdDocument.recipients,
|
||||
documentMeta: createdDocument.documentMeta,
|
||||
}),
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
});
|
||||
|
||||
return {
|
||||
documentId: createdDocument.id,
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
|
||||
@ -117,7 +115,6 @@ export const updateDocument = async ({
|
||||
}
|
||||
}
|
||||
|
||||
// If no data just return the document since this function is normally chained after a meta update.
|
||||
if (!data || Object.values(data).length === 0) {
|
||||
console.log('no data');
|
||||
return document;
|
||||
@ -130,26 +127,11 @@ export const updateDocument = async ({
|
||||
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
|
||||
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
|
||||
|
||||
// If the new global auth values aren't passed in, fallback to the current document values.
|
||||
const newGlobalAccessAuth =
|
||||
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
|
||||
const newGlobalActionAuth =
|
||||
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (newGlobalActionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isTitleSame = data.title === undefined || data.title === document.title;
|
||||
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
|
||||
const isGlobalAccessSame =
|
||||
|
||||
@ -2,6 +2,7 @@ import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
|
||||
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
|
||||
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
|
||||
@ -13,7 +14,7 @@ import { prisma } from '@documenso/prisma';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import type { TRecipientActionAuth, TRecipientActionAuthTypes } from '../../types/document-auth';
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
@ -23,6 +24,7 @@ import {
|
||||
} from '../../types/field-meta';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||
|
||||
export type SignFieldWithTokenOptions = {
|
||||
@ -169,13 +171,24 @@ export const signFieldWithToken = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const derivedRecipientActionAuth = await validateFieldAuth({
|
||||
documentAuthOptions: document.authOptions,
|
||||
recipient,
|
||||
field,
|
||||
userId,
|
||||
authOptions,
|
||||
});
|
||||
const isEnterprise = userId ? await isUserEnterprise({ userId }) : false;
|
||||
let requiredAuthType: TRecipientActionAuthTypes | null = null;
|
||||
|
||||
if (isEnterprise) {
|
||||
requiredAuthType = await validateFieldAuth({
|
||||
documentAuthOptions: document.authOptions,
|
||||
recipient,
|
||||
field,
|
||||
userId,
|
||||
authOptions,
|
||||
});
|
||||
} else {
|
||||
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
requiredAuthType = derivedRecipientActionAuth;
|
||||
}
|
||||
|
||||
const documentMeta = await prisma.documentMeta.findFirst({
|
||||
where: {
|
||||
@ -286,9 +299,9 @@ export const signFieldWithToken = async ({
|
||||
}),
|
||||
)
|
||||
.exhaustive(),
|
||||
fieldSecurity: derivedRecipientActionAuth
|
||||
fieldSecurity: requiredAuthType
|
||||
? {
|
||||
type: derivedRecipientActionAuth,
|
||||
type: requiredAuthType,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
|
||||
@ -9,11 +9,13 @@ import {
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
@ -508,10 +510,8 @@ export const createDocumentFromTemplate = async ({
|
||||
fieldsToCreate = fieldsToCreate.concat(
|
||||
fields.map((field) => {
|
||||
const prefillField = prefillFields?.find((value) => value.id === field.id);
|
||||
// Use type assertion to help TypeScript understand the structure
|
||||
const updatedFieldMeta = getUpdatedFieldMeta(field, prefillField);
|
||||
|
||||
return {
|
||||
const payload = {
|
||||
documentId: document.id,
|
||||
recipientId: recipient.id,
|
||||
type: field.type,
|
||||
@ -522,8 +522,38 @@ export const createDocumentFromTemplate = async ({
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: updatedFieldMeta,
|
||||
fieldMeta: field.fieldMeta,
|
||||
};
|
||||
|
||||
if (prefillField) {
|
||||
match(prefillField)
|
||||
.with({ type: 'date' }, (selector) => {
|
||||
if (!selector.value) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Date value is required for field ${field.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const date = new Date(selector.value);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid date value for field ${field.id}: ${selector.value}`,
|
||||
});
|
||||
}
|
||||
|
||||
payload.customText = DateTime.fromJSDate(date).toFormat(
|
||||
template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
);
|
||||
|
||||
payload.inserted = true;
|
||||
})
|
||||
.otherwise((selector) => {
|
||||
payload.fieldMeta = getUpdatedFieldMeta(field, selector);
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||
import { DocumentAuth } from '../../types/document-auth';
|
||||
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
|
||||
export type UpdateTemplateOptions = {
|
||||
@ -74,7 +75,11 @@ export const updateTemplate = async ({
|
||||
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (newGlobalActionAuth) {
|
||||
// Only ACCOUNT and PASSKEY require enterprise permissions
|
||||
if (
|
||||
newGlobalActionAuth &&
|
||||
(newGlobalActionAuth === DocumentAuth.ACCOUNT || newGlobalActionAuth === DocumentAuth.PASSKEY)
|
||||
) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
@ -82,7 +87,7 @@ export const updateTemplate = async ({
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
message: 'You do not have permission to set this action auth type',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,11 +111,6 @@ msgstr "{0, plural, one {1 Empfänger} other {# Empfänger}}"
|
||||
msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}"
|
||||
msgstr "{0, plural, one {Warte auf 1 Empfänger} other {Warte auf # Empfänger}}"
|
||||
|
||||
#. placeholder {0}: selectedValues.length
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
msgid "{0, plural, zero {Select values} other {# selected...}}"
|
||||
msgstr "{0, plural, zero {Werte auswählen} other {# ausgewählt...}}"
|
||||
|
||||
#. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType])
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
|
||||
msgid "{0}"
|
||||
@ -873,6 +868,7 @@ msgstr "Erweiterte Optionen"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "Advanced settings"
|
||||
msgstr "Erweiterte Einstellungen"
|
||||
@ -2725,9 +2721,14 @@ msgstr "Aufgrund einer unbezahlten Rechnung wurde Ihrem Team der Zugriff eingesc
|
||||
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
msgid "Duplicate"
|
||||
msgstr "Duplizieren"
|
||||
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
msgid "Duplicate on all pages"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx
|
||||
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
|
||||
@ -3915,7 +3916,6 @@ msgstr "Keine gültigen direkten Vorlagen gefunden"
|
||||
msgid "No valid recipients found"
|
||||
msgstr "Keine gültigen Empfänger gefunden"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/multiselect-role-combobox.tsx
|
||||
#: packages/ui/primitives/multi-select-combobox.tsx
|
||||
#: packages/ui/primitives/combobox.tsx
|
||||
@ -4607,6 +4607,7 @@ msgstr "Erinnerung: Bitte {recipientActionVerb} dein Dokument"
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "Remove"
|
||||
msgstr "Entfernen"
|
||||
@ -4849,6 +4850,11 @@ msgstr "Standardoption auswählen"
|
||||
msgid "Select passkey"
|
||||
msgstr "Passkey auswählen"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
msgid "Select triggers"
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/add-subject.tsx
|
||||
|
||||
@ -106,11 +106,6 @@ msgstr "{0, plural, one {1 Recipient} other {# Recipients}}"
|
||||
msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}"
|
||||
msgstr "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}"
|
||||
|
||||
#. placeholder {0}: selectedValues.length
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
msgid "{0, plural, zero {Select values} other {# selected...}}"
|
||||
msgstr "{0, plural, zero {Select values} other {# selected...}}"
|
||||
|
||||
#. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType])
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
|
||||
msgid "{0}"
|
||||
@ -868,6 +863,7 @@ msgstr "Advanced Options"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "Advanced settings"
|
||||
msgstr "Advanced settings"
|
||||
@ -2720,9 +2716,14 @@ msgstr "Due to an unpaid invoice, your team has been restricted. Please settle t
|
||||
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
msgid "Duplicate"
|
||||
msgstr "Duplicate"
|
||||
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
msgid "Duplicate on all pages"
|
||||
msgstr "Duplicate on all pages"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx
|
||||
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
|
||||
@ -3910,7 +3911,6 @@ msgstr "No valid direct templates found"
|
||||
msgid "No valid recipients found"
|
||||
msgstr "No valid recipients found"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/multiselect-role-combobox.tsx
|
||||
#: packages/ui/primitives/multi-select-combobox.tsx
|
||||
#: packages/ui/primitives/combobox.tsx
|
||||
@ -4602,6 +4602,7 @@ msgstr "Reminder: Please {recipientActionVerb} your document"
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "Remove"
|
||||
msgstr "Remove"
|
||||
@ -4844,6 +4845,11 @@ msgstr "Select default option"
|
||||
msgid "Select passkey"
|
||||
msgstr "Select passkey"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
msgid "Select triggers"
|
||||
msgstr "Select triggers"
|
||||
|
||||
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/add-subject.tsx
|
||||
|
||||
@ -111,11 +111,6 @@ msgstr "{0, plural, one {1 Destinatario} other {# Destinatarios}}"
|
||||
msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}"
|
||||
msgstr "{0, plural, one {Esperando 1 destinatario} other {Esperando # destinatarios}}"
|
||||
|
||||
#. placeholder {0}: selectedValues.length
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
msgid "{0, plural, zero {Select values} other {# selected...}}"
|
||||
msgstr "{0, plural, zero {Selecciona valores} other {# seleccionados...}}"
|
||||
|
||||
#. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType])
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
|
||||
msgid "{0}"
|
||||
@ -873,6 +868,7 @@ msgstr "Opciones avanzadas"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "Advanced settings"
|
||||
msgstr "Configuraciones avanzadas"
|
||||
@ -2725,9 +2721,14 @@ msgstr "Debido a una factura impaga, tu equipo ha sido restringido. Realiza el p
|
||||
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
msgid "Duplicate"
|
||||
msgstr "Duplicar"
|
||||
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
msgid "Duplicate on all pages"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx
|
||||
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
|
||||
@ -3915,7 +3916,6 @@ msgstr "No se encontraron plantillas directas válidas"
|
||||
msgid "No valid recipients found"
|
||||
msgstr "No se encontraron destinatarios válidos"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/multiselect-role-combobox.tsx
|
||||
#: packages/ui/primitives/multi-select-combobox.tsx
|
||||
#: packages/ui/primitives/combobox.tsx
|
||||
@ -4607,6 +4607,7 @@ msgstr "Recordatorio: Por favor {recipientActionVerb} tu documento"
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "Remove"
|
||||
msgstr "Eliminar"
|
||||
@ -4849,6 +4850,11 @@ msgstr "Seleccionar opción predeterminada"
|
||||
msgid "Select passkey"
|
||||
msgstr "Seleccionar clave de acceso"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
msgid "Select triggers"
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/add-subject.tsx
|
||||
|
||||
@ -111,11 +111,6 @@ msgstr "{0, plural, one {1 Destinataire} other {# Destinataires}}"
|
||||
msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}"
|
||||
msgstr "{0, plural, one {En attente d'1 destinataire} other {En attente de # destinataires}}"
|
||||
|
||||
#. placeholder {0}: selectedValues.length
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
msgid "{0, plural, zero {Select values} other {# selected...}}"
|
||||
msgstr "{0, plural, zero {Sélectionner des valeurs} other {# sélectionnées...}}"
|
||||
|
||||
#. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType])
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
|
||||
msgid "{0}"
|
||||
@ -873,6 +868,7 @@ msgstr "Options avancées"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "Advanced settings"
|
||||
msgstr "Paramètres avancés"
|
||||
@ -2725,9 +2721,14 @@ msgstr "En raison d'une facture impayée, votre équipe a été restreinte. Veui
|
||||
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
msgid "Duplicate"
|
||||
msgstr "Dupliquer"
|
||||
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
msgid "Duplicate on all pages"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx
|
||||
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
|
||||
@ -3915,7 +3916,6 @@ msgstr "Aucun modèle direct valide trouvé"
|
||||
msgid "No valid recipients found"
|
||||
msgstr "Aucun destinataire valide trouvé"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/multiselect-role-combobox.tsx
|
||||
#: packages/ui/primitives/multi-select-combobox.tsx
|
||||
#: packages/ui/primitives/combobox.tsx
|
||||
@ -4607,6 +4607,7 @@ msgstr "Rappel : Veuillez {recipientActionVerb} votre document"
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "Remove"
|
||||
msgstr "Retirer"
|
||||
@ -4849,6 +4850,11 @@ msgstr "Sélectionner l'option par défaut"
|
||||
msgid "Select passkey"
|
||||
msgstr "Sélectionner la clé d'authentification"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
msgid "Select triggers"
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/add-subject.tsx
|
||||
|
||||
@ -111,11 +111,6 @@ msgstr "{0, plural, one {1 destinatario} other {# destinatari}}"
|
||||
msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}"
|
||||
msgstr "{0, plural, one {In attesa di 1 destinatario} other {In attesa di # destinatari}}"
|
||||
|
||||
#. placeholder {0}: selectedValues.length
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
msgid "{0, plural, zero {Select values} other {# selected...}}"
|
||||
msgstr "{0, plural, zero {Seleziona valori} other {# selezionati...}}"
|
||||
|
||||
#. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType])
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
|
||||
msgid "{0}"
|
||||
@ -873,6 +868,7 @@ msgstr "Opzioni avanzate"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "Advanced settings"
|
||||
msgstr "Impostazioni avanzate"
|
||||
@ -2725,9 +2721,14 @@ msgstr "A causa di una fattura non pagata, il vostro team è stato limitato. Si
|
||||
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
msgid "Duplicate"
|
||||
msgstr "Duplica"
|
||||
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
msgid "Duplicate on all pages"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx
|
||||
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
|
||||
@ -3915,7 +3916,6 @@ msgstr "Nessun modello diretto valido trovato"
|
||||
msgid "No valid recipients found"
|
||||
msgstr "Nessun destinatario valido trovato"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/multiselect-role-combobox.tsx
|
||||
#: packages/ui/primitives/multi-select-combobox.tsx
|
||||
#: packages/ui/primitives/combobox.tsx
|
||||
@ -4607,6 +4607,7 @@ msgstr "Promemoria: per favore {recipientActionVerb} il tuo documento"
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "Remove"
|
||||
msgstr "Rimuovi"
|
||||
@ -4849,6 +4850,11 @@ msgstr "Seleziona opzione predefinita"
|
||||
msgid "Select passkey"
|
||||
msgstr "Seleziona una chiave di accesso"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
msgid "Select triggers"
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/add-subject.tsx
|
||||
|
||||
@ -111,11 +111,6 @@ msgstr "{0, plural, one {1 odbiorca} few {# odbiorców} many {# odbiorców} othe
|
||||
msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}"
|
||||
msgstr "{0, plural, one {Czekam na 1 odbiorcę} few {Czekam na # odbiorców} many {Czekam na # odbiorców} other {Czekam na # odbiorców}}"
|
||||
|
||||
#. placeholder {0}: selectedValues.length
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
msgid "{0, plural, zero {Select values} other {# selected...}}"
|
||||
msgstr "{0, plural, zero {Wybierz wartości} one {# wybrana...} few {# wybrane...} many {# wybranych...} other {# wybranych...}}"
|
||||
|
||||
#. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType])
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx
|
||||
msgid "{0}"
|
||||
@ -873,6 +868,7 @@ msgstr "Opcje zaawansowane"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
|
||||
#: packages/ui/primitives/template-flow/add-template-fields.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "Advanced settings"
|
||||
msgstr "Ustawienia zaawansowane"
|
||||
@ -2725,9 +2721,14 @@ msgstr "Z powodu nieopłaconej faktury Twój zespół został ograniczony. Prosz
|
||||
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/document-duplicate-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
msgid "Duplicate"
|
||||
msgstr "Zduplikuj"
|
||||
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
msgid "Duplicate on all pages"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/webhooks._index.tsx
|
||||
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
|
||||
@ -3915,7 +3916,6 @@ msgstr "Nie znaleziono ważnych szablonów bezpośrednich"
|
||||
msgid "No valid recipients found"
|
||||
msgstr "Nie znaleziono ważnych odbiorców"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/multiselect-role-combobox.tsx
|
||||
#: packages/ui/primitives/multi-select-combobox.tsx
|
||||
#: packages/ui/primitives/combobox.tsx
|
||||
@ -4607,6 +4607,7 @@ msgstr "Przypomnienie: Proszę {recipientActionVerb} Twój dokument"
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/field-item.tsx
|
||||
#: packages/ui/primitives/document-flow/add-fields.tsx
|
||||
msgid "Remove"
|
||||
msgstr "Usuń"
|
||||
@ -4849,6 +4850,11 @@ msgstr "Wybierz domyślną opcję"
|
||||
msgid "Select passkey"
|
||||
msgstr "Wybierz klucz uwierzytelniający"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
#: apps/remix/app/components/general/webhook-multiselect-combobox.tsx
|
||||
msgid "Select triggers"
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
|
||||
#: packages/ui/primitives/document-flow/add-subject.tsx
|
||||
|
||||
@ -68,6 +68,16 @@ export const ZDocumentActionAuthTypesSchema = z
|
||||
'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.',
|
||||
);
|
||||
|
||||
/**
|
||||
* The non-enterprise document action auth methods.
|
||||
*
|
||||
* Only includes options available to non-enterprise users.
|
||||
*/
|
||||
export const ZNonEnterpriseDocumentActionAuthTypesSchema = z.enum([
|
||||
DocumentAuth.TWO_FACTOR_AUTH,
|
||||
DocumentAuth.EXPLICIT_NONE,
|
||||
]);
|
||||
|
||||
/**
|
||||
* The recipient access auth methods.
|
||||
*
|
||||
@ -102,6 +112,7 @@ export const ZRecipientActionAuthTypesSchema = z
|
||||
|
||||
export const DocumentAccessAuth = ZDocumentAccessAuthTypesSchema.Enum;
|
||||
export const DocumentActionAuth = ZDocumentActionAuthTypesSchema.Enum;
|
||||
export const NonEnterpriseDocumentActionAuth = ZNonEnterpriseDocumentActionAuthTypesSchema.Enum;
|
||||
export const RecipientAccessAuth = ZRecipientAccessAuthTypesSchema.Enum;
|
||||
export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum;
|
||||
|
||||
@ -152,6 +163,9 @@ export type TDocumentAccessAuth = z.infer<typeof ZDocumentAccessAuthSchema>;
|
||||
export type TDocumentAccessAuthTypes = z.infer<typeof ZDocumentAccessAuthTypesSchema>;
|
||||
export type TDocumentActionAuth = z.infer<typeof ZDocumentActionAuthSchema>;
|
||||
export type TDocumentActionAuthTypes = z.infer<typeof ZDocumentActionAuthTypesSchema>;
|
||||
export type TNonEnterpriseDocumentActionAuthTypes = z.infer<
|
||||
typeof ZNonEnterpriseDocumentActionAuthTypesSchema
|
||||
>;
|
||||
export type TRecipientAccessAuth = z.infer<typeof ZRecipientAccessAuthSchema>;
|
||||
export type TRecipientAccessAuthTypes = z.infer<typeof ZRecipientAccessAuthTypesSchema>;
|
||||
export type TRecipientActionAuth = z.infer<typeof ZRecipientActionAuthSchema>;
|
||||
|
||||
@ -155,6 +155,10 @@ export const ZFieldMetaPrefillFieldsSchema = z
|
||||
label: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('date'),
|
||||
value: z.string().optional(),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserTwoFactorEmailVerification" (
|
||||
"userId" INTEGER NOT NULL,
|
||||
"verificationCode" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "UserTwoFactorEmailVerification_pkey" PRIMARY KEY ("userId")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserTwoFactorEmailVerification" ADD CONSTRAINT "UserTwoFactorEmailVerification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -21,18 +21,18 @@
|
||||
"seed": "tsx ./seed-database.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
"@prisma/client": "^6.8.2",
|
||||
"kysely": "0.26.3",
|
||||
"prisma": "^5.4.2",
|
||||
"prisma-extension-kysely": "^2.1.0",
|
||||
"prisma": "^6.8.2",
|
||||
"prisma-extension-kysely": "^3.0.0",
|
||||
"prisma-kysely": "^1.8.0",
|
||||
"prisma-json-types-generator": "^3.2.2",
|
||||
"ts-pattern": "^5.0.6",
|
||||
"zod-prisma-types": "3.1.9"
|
||||
"zod-prisma-types": "3.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "^16.3.1",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
|
||||
@ -53,19 +53,20 @@ model User {
|
||||
avatarImageId String?
|
||||
disabled Boolean @default(false)
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
documents Document[]
|
||||
folders Folder[]
|
||||
subscriptions Subscription[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
ownedTeams Team[]
|
||||
ownedPendingTeams TeamPending[]
|
||||
teamMembers TeamMember[]
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorBackupCodes String?
|
||||
url String? @unique
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
documents Document[]
|
||||
folders Folder[]
|
||||
subscriptions Subscription[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
ownedTeams Team[]
|
||||
ownedPendingTeams TeamPending[]
|
||||
teamMembers TeamMember[]
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorBackupCodes String?
|
||||
url String? @unique
|
||||
twoFactorEmailVerification UserTwoFactorEmailVerification?
|
||||
|
||||
profile UserProfile?
|
||||
verificationTokens VerificationToken[]
|
||||
@ -839,3 +840,12 @@ model AvatarImage {
|
||||
team Team[]
|
||||
user User[]
|
||||
}
|
||||
|
||||
model UserTwoFactorEmailVerification {
|
||||
userId Int @id
|
||||
verificationCode String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
@ -18,6 +18,6 @@
|
||||
"ts-pattern": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^2.1.8"
|
||||
"vitest": "^3.1.4"
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,10 @@
|
||||
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
|
||||
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import {
|
||||
sendEmailVerification,
|
||||
verifyEmailCode,
|
||||
} from '@documenso/lib/server-only/2fa/send-email-verification';
|
||||
import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
|
||||
import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
|
||||
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options';
|
||||
@ -8,6 +13,7 @@ import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey';
|
||||
import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
|
||||
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import {
|
||||
@ -15,7 +21,9 @@ import {
|
||||
ZCreatePasskeyMutationSchema,
|
||||
ZDeletePasskeyMutationSchema,
|
||||
ZFindPasskeysQuerySchema,
|
||||
ZSendEmailVerificationMutationSchema,
|
||||
ZUpdatePasskeyMutationSchema,
|
||||
ZVerifyEmailCodeMutationSchema,
|
||||
} from './schema';
|
||||
|
||||
export const authRouter = router({
|
||||
@ -98,4 +106,68 @@ export const authRouter = router({
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
});
|
||||
}),
|
||||
|
||||
// Email verification for document signing
|
||||
sendEmailVerification: authenticatedProcedure
|
||||
.input(ZSendEmailVerificationMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { recipientId } = input;
|
||||
const userId = ctx.user.id;
|
||||
let email = ctx.user.email;
|
||||
|
||||
// If recipientId is provided, fetch that recipient's details
|
||||
if (recipientId) {
|
||||
const recipient = await prisma.recipient.findUnique({
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError('NOT_FOUND', {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
email = recipient.email;
|
||||
}
|
||||
|
||||
return sendEmailVerification({
|
||||
userId,
|
||||
email,
|
||||
});
|
||||
}),
|
||||
|
||||
verifyEmailCode: authenticatedProcedure
|
||||
.input(ZVerifyEmailCodeMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { code, recipientId } = input;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
// If recipientId is provided, check that the user has access to it
|
||||
if (recipientId) {
|
||||
const recipient = await prisma.recipient.findUnique({
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError('NOT_FOUND', {
|
||||
message: 'Recipient not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return verifyEmailCode({
|
||||
userId,
|
||||
code,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@ -71,3 +71,18 @@ export const ZFindPasskeysQuerySchema = ZFindSearchParamsSchema.extend({
|
||||
});
|
||||
|
||||
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
|
||||
|
||||
export const ZSendEmailVerificationMutationSchema = z.object({
|
||||
recipientId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TSendEmailVerificationMutationSchema = z.infer<
|
||||
typeof ZSendEmailVerificationMutationSchema
|
||||
>;
|
||||
|
||||
export const ZVerifyEmailCodeMutationSchema = z.object({
|
||||
code: z.string().min(6).max(6),
|
||||
recipientId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TVerifyEmailCodeMutationSchema = z.infer<typeof ZVerifyEmailCodeMutationSchema>;
|
||||
|
||||
@ -7,7 +7,11 @@ import type { SelectProps } from '@radix-ui/react-select';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
|
||||
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||
import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
DocumentActionAuth,
|
||||
DocumentAuth,
|
||||
NonEnterpriseDocumentActionAuth,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -17,38 +21,47 @@ import {
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
export const DocumentGlobalAuthActionSelect = forwardRef<HTMLButtonElement, SelectProps>(
|
||||
(props, ref) => {
|
||||
const { _ } = useLingui();
|
||||
interface DocumentGlobalAuthActionSelectProps extends SelectProps {
|
||||
isDocumentEnterprise?: boolean;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select {...props}>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue
|
||||
ref={ref}
|
||||
data-testid="documentActionSelectValue"
|
||||
placeholder={_(msg`No restrictions`)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
export const DocumentGlobalAuthActionSelect = forwardRef<
|
||||
HTMLButtonElement,
|
||||
DocumentGlobalAuthActionSelectProps
|
||||
>(({ isDocumentEnterprise, ...props }, ref) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
<SelectContent position="popper">
|
||||
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>No restrictions</Trans>
|
||||
const authTypes = isDocumentEnterprise
|
||||
? Object.values(DocumentActionAuth).filter((auth) => auth !== DocumentAuth.ACCOUNT)
|
||||
: Object.values(NonEnterpriseDocumentActionAuth).filter(
|
||||
(auth) => auth !== DocumentAuth.EXPLICIT_NONE,
|
||||
);
|
||||
|
||||
return (
|
||||
<Select {...props}>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue
|
||||
ref={ref}
|
||||
data-testid="documentActionSelectValue"
|
||||
placeholder={_(msg`No restrictions`)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>No restrictions</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{authTypes.map((authType) => (
|
||||
<SelectItem key={authType} value={authType}>
|
||||
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||
</SelectItem>
|
||||
|
||||
{Object.values(DocumentActionAuth)
|
||||
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
|
||||
.map((authType) => (
|
||||
<SelectItem key={authType} value={authType}>
|
||||
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
},
|
||||
);
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
});
|
||||
|
||||
DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect';
|
||||
|
||||
|
||||
@ -38,6 +38,14 @@ 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')}
|
||||
@ -52,7 +60,11 @@ 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}>
|
||||
<TooltipContent
|
||||
className={tooltipVariants({ color, className })}
|
||||
sideOffset={2}
|
||||
onClick={onTooltipContentClick}
|
||||
>
|
||||
{children}
|
||||
<TooltipArrow />
|
||||
</TooltipContent>
|
||||
|
||||
@ -24,7 +24,7 @@ export const SigningCard = ({
|
||||
signingCelebrationImage,
|
||||
}: SigningCardProps) => {
|
||||
return (
|
||||
<div className={cn('relative w-full max-w-xs md:max-w-sm', className)}>
|
||||
<div className={cn('relative w-full max-w-sm md:max-w-md', className)}>
|
||||
<SigningCardContent name={name} signature={signature} />
|
||||
|
||||
{signingCelebrationImage && (
|
||||
@ -48,7 +48,7 @@ export const SigningCard3D = ({
|
||||
|
||||
const [trackMouse, setTrackMouse] = useState(false);
|
||||
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
const timeoutRef = useRef<number | undefined>();
|
||||
|
||||
const cardX = useMotionValue(0);
|
||||
const cardY = useMotionValue(0);
|
||||
@ -103,7 +103,7 @@ export const SigningCard3D = ({
|
||||
clearTimeout(timeoutRef.current);
|
||||
|
||||
// Revert the card back to the center position after the mouse stops moving.
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
void animate(cardX, 0, { duration: 2, ease: 'backInOut' });
|
||||
void animate(cardY, 0, { duration: 2, ease: 'backInOut' });
|
||||
|
||||
@ -120,12 +120,15 @@ export const SigningCard3D = ({
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
if (timeoutRef.current) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [onMouseMove]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative w-full max-w-xs md:max-w-sm', className)}
|
||||
className={cn('relative w-full max-w-sm md:max-w-md', className)}
|
||||
style={{ perspective: 800 }}
|
||||
>
|
||||
<motion.div
|
||||
|
||||
15
packages/ui/lib/use-debounce.ts
Normal file
15
packages/ui/lib/use-debounce.ts
Normal file
@ -0,0 +1,15 @@
|
||||
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;
|
||||
};
|
||||
@ -400,35 +400,60 @@ export const AddFieldsFormPartial = ({
|
||||
);
|
||||
|
||||
const onFieldCopy = useCallback(
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
|
||||
const { duplicate = false } = options ?? {};
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||
|
||||
if (lastActiveField) {
|
||||
event?.preventDefault();
|
||||
|
||||
if (!duplicate) {
|
||||
setFieldClipboard(lastActiveField);
|
||||
if (duplicate) {
|
||||
const newField: TAddFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
};
|
||||
|
||||
toast({
|
||||
title: 'Copied field',
|
||||
description: 'Copied field to clipboard',
|
||||
append(newField);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (duplicateAll) {
|
||||
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
||||
|
||||
pages.forEach((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
|
||||
if (pageNumber === lastActiveField.pageNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newField: TAddFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
pageNumber,
|
||||
};
|
||||
|
||||
append(newField);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newField: TAddFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
};
|
||||
setFieldClipboard(lastActiveField);
|
||||
|
||||
append(newField);
|
||||
toast({
|
||||
title: 'Copied field',
|
||||
description: 'Copied field to clipboard',
|
||||
});
|
||||
}
|
||||
},
|
||||
[append, lastActiveField, selectedSigner?.email, toast],
|
||||
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
|
||||
);
|
||||
|
||||
const onFieldPaste = useCallback(
|
||||
@ -641,6 +666,7 @@ export const AddFieldsFormPartial = ({
|
||||
onMove={(options) => onFieldMove(options, index)}
|
||||
onRemove={() => remove(index)}
|
||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||
onAdvancedSettings={() => {
|
||||
setCurrentField(field);
|
||||
handleAdvancedSettings();
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
||||
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@prisma/client';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
DocumentStatus,
|
||||
DocumentVisibility,
|
||||
type Field,
|
||||
type Recipient,
|
||||
SendStatus,
|
||||
TeamMemberRole,
|
||||
} from '@prisma/client';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
@ -274,24 +279,22 @@ export const AddSettingsFormPartial = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDocumentEnterprise && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="globalActionAuth"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Recipient action authentication</Trans>
|
||||
<DocumentGlobalAuthActionTooltip />
|
||||
</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="globalActionAuth"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Recipient action authentication</Trans>
|
||||
<DocumentGlobalAuthActionTooltip />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormControl>
|
||||
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Accordion type="multiple" className="mt-6">
|
||||
<AccordionItem value="advanced-options" className="border-none">
|
||||
|
||||
@ -311,6 +311,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
|
||||
/>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
|
||||
{errors.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<ul>
|
||||
@ -323,6 +324,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
|
||||
</div>
|
||||
)}
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
<DocumentFlowFormContainerFooter className="mt-auto">
|
||||
<DocumentFlowFormContainerActions
|
||||
goNextLabel={msg`Save`}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { CopyPlus, Settings2, Trash } from 'lucide-react';
|
||||
import { CopyPlus, Settings2, SquareStack, Trash } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Rnd } from 'react-rnd';
|
||||
|
||||
@ -29,6 +31,7 @@ export type FieldItemProps = {
|
||||
onMove?: (_node: HTMLElement) => void;
|
||||
onRemove?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onDuplicateAllPages?: () => void;
|
||||
onAdvancedSettings?: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
@ -55,15 +58,18 @@ export const FieldItem = ({
|
||||
onMove,
|
||||
onRemove,
|
||||
onDuplicate,
|
||||
onDuplicateAllPages,
|
||||
onAdvancedSettings,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onAdvancedSettings,
|
||||
recipientIndex = 0,
|
||||
hasErrors,
|
||||
active,
|
||||
onFieldActivate,
|
||||
onFieldDeactivate,
|
||||
}: FieldItemProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [coords, setCoords] = useState({
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
@ -304,6 +310,7 @@ export const FieldItem = ({
|
||||
<div className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
|
||||
{advancedField && (
|
||||
<button
|
||||
title={_(msg`Advanced settings`)}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={onAdvancedSettings}
|
||||
onTouchEnd={onAdvancedSettings}
|
||||
@ -313,6 +320,7 @@ export const FieldItem = ({
|
||||
)}
|
||||
|
||||
<button
|
||||
title={_(msg`Duplicate`)}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={onDuplicate}
|
||||
onTouchEnd={onDuplicate}
|
||||
@ -321,6 +329,16 @@ export const FieldItem = ({
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={_(msg`Duplicate on all pages`)}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={onDuplicateAllPages}
|
||||
onTouchEnd={onDuplicateAllPages}
|
||||
>
|
||||
<SquareStack className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={_(msg`Remove`)}
|
||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||
onClick={onRemove}
|
||||
onTouchEnd={onRemove}
|
||||
|
||||
585
packages/ui/primitives/multiselect.tsx
Normal file
585
packages/ui/primitives/multiselect.tsx
Normal file
@ -0,0 +1,585 @@
|
||||
'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‘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‘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 };
|
||||
@ -139,44 +139,64 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
);
|
||||
|
||||
const onFieldCopy = useCallback(
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
|
||||
const { duplicate = false } = options ?? {};
|
||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||
|
||||
if (lastActiveField) {
|
||||
event?.preventDefault();
|
||||
|
||||
if (!duplicate) {
|
||||
setFieldClipboard(lastActiveField);
|
||||
if (duplicate) {
|
||||
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
||||
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
};
|
||||
|
||||
toast({
|
||||
title: 'Copied field',
|
||||
description: 'Copied field to clipboard',
|
||||
append(newField);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (duplicateAll) {
|
||||
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
||||
|
||||
pages.forEach((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
|
||||
if (pageNumber === lastActiveField.pageNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
nativeId: undefined,
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
||||
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
||||
pageNumber,
|
||||
};
|
||||
|
||||
append(newField);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
|
||||
...structuredClone(lastActiveField),
|
||||
formId: nanoid(12),
|
||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
||||
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
||||
pageX: lastActiveField.pageX + 3,
|
||||
pageY: lastActiveField.pageY + 3,
|
||||
};
|
||||
setFieldClipboard(lastActiveField);
|
||||
|
||||
append(newField);
|
||||
toast({
|
||||
title: 'Copied field',
|
||||
description: 'Copied field to clipboard',
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
append,
|
||||
lastActiveField,
|
||||
selectedSigner?.email,
|
||||
selectedSigner?.id,
|
||||
selectedSigner?.token,
|
||||
toast,
|
||||
],
|
||||
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
|
||||
);
|
||||
|
||||
const onFieldPaste = useCallback(
|
||||
@ -543,6 +563,7 @@ export const AddTemplateFieldsFormPartial = ({
|
||||
onMove={(options) => onFieldMove(options, index)}
|
||||
onRemove={() => remove(index)}
|
||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||
onAdvancedSettings={() => {
|
||||
setCurrentField(field);
|
||||
handleAdvancedSettings();
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, type Field, type Recipient } from '@prisma/client';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
DocumentDistributionMethod,
|
||||
DocumentVisibility,
|
||||
type Field,
|
||||
type Recipient,
|
||||
TeamMemberRole,
|
||||
} from '@prisma/client';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
@ -366,24 +370,26 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{isEnterprise && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="globalActionAuth"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Recipient action authentication</Trans>
|
||||
<DocumentGlobalAuthActionTooltip />
|
||||
</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="globalActionAuth"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Recipient action authentication</Trans>
|
||||
<DocumentGlobalAuthActionTooltip />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormControl>
|
||||
<DocumentGlobalAuthActionSelect
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
isDocumentEnterprise={isEnterprise}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{distributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||
<Accordion type="multiple">
|
||||
|
||||
Reference in New Issue
Block a user