mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 15:53:02 +10:00
Compare commits
4 Commits
a66a56042c
...
feat/docum
| Author | SHA1 | Date | |
|---|---|---|---|
| 35410e100e | |||
| e52af336b4 | |||
| 04e3e1eeb9 | |||
| 43810c4357 |
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
@ -6,9 +6,9 @@ import { RecipientRole } from '@prisma/client';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
|
||||||
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
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 { Button } from '@documenso/ui/primitives/button';
|
||||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||||
import {
|
import {
|
||||||
@ -20,6 +20,8 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
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';
|
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||||
|
|
||||||
@ -51,6 +53,7 @@ export const DocumentSigningAuth2FA = ({
|
|||||||
}: DocumentSigningAuth2FAProps) => {
|
}: DocumentSigningAuth2FAProps) => {
|
||||||
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
|
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
|
||||||
useRequiredDocumentSigningAuthContext();
|
useRequiredDocumentSigningAuthContext();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const form = useForm<T2FAAuthFormSchema>({
|
const form = useForm<T2FAAuthFormSchema>({
|
||||||
resolver: zodResolver(Z2FAAuthFormSchema),
|
resolver: zodResolver(Z2FAAuthFormSchema),
|
||||||
@ -60,27 +63,104 @@ export const DocumentSigningAuth2FA = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false);
|
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) => {
|
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
|
||||||
try {
|
try {
|
||||||
setIsCurrentlyAuthenticating(true);
|
setIsCurrentlyAuthenticating(true);
|
||||||
|
|
||||||
|
if (verificationMethod === 'email') {
|
||||||
|
await verifyCodeMutation.mutateAsync({
|
||||||
|
code: token,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await onReauthFormSubmit({
|
await onReauthFormSubmit({
|
||||||
type: DocumentAuth.TWO_FACTOR_AUTH,
|
type: DocumentAuth.TWO_FACTOR_AUTH,
|
||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsCurrentlyAuthenticating(false);
|
setIsCurrentlyAuthenticating(false);
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setIsCurrentlyAuthenticating(false);
|
setIsCurrentlyAuthenticating(false);
|
||||||
|
|
||||||
const error = AppError.parseError(err);
|
toast({
|
||||||
setFormErrorCode(error.code);
|
title: 'Unauthorized',
|
||||||
|
description: 'We were unable to verify your details.',
|
||||||
// Todo: Alert.
|
variant: 'destructive',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -90,21 +170,46 @@ export const DocumentSigningAuth2FA = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
setIs2FASetupSuccessful(false);
|
setIs2FASetupSuccessful(false);
|
||||||
setFormErrorCode(null);
|
setIsEmailCodeSent(false);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
if (open && !user?.twoFactorEnabled) {
|
||||||
}, [open]);
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<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">
|
<Alert variant="warning">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p>
|
<p>
|
||||||
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? (
|
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? (
|
||||||
<Trans>You need to setup 2FA to mark this document as viewed.</Trans>
|
<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()}.`
|
`You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@ -129,59 +234,106 @@ export const DocumentSigningAuth2FA = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<div className="space-y-4">
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
{user?.twoFactorEnabled && (
|
||||||
<fieldset disabled={isCurrentlyAuthenticating}>
|
<Tabs
|
||||||
<div className="space-y-4">
|
value={verificationMethod}
|
||||||
<FormField
|
onValueChange={(val) => setVerificationMethod(val as 'app' | 'email')}
|
||||||
control={form.control}
|
>
|
||||||
name="token"
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
render={({ field }) => (
|
<TabsTrigger value="app">Authenticator App</TabsTrigger>
|
||||||
<FormItem>
|
<TabsTrigger value="email">Email Verification</TabsTrigger>
|
||||||
<FormLabel required>2FA token</FormLabel>
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormControl>
|
{verificationMethod === 'email' && (
|
||||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
<Alert variant="secondary">
|
||||||
{Array(6)
|
<AlertDescription>
|
||||||
.fill(null)
|
{isEmailCodeSent ? (
|
||||||
.map((_, i) => (
|
<p>
|
||||||
<PinInputGroup key={i}>
|
<Trans>
|
||||||
<PinInputSlot index={i} />
|
A verification code has been sent to {recipient.email}. Please enter it below to
|
||||||
</PinInputGroup>
|
continue.
|
||||||
))}
|
</Trans>
|
||||||
</PinInput>
|
</p>
|
||||||
</FormControl>
|
) : (
|
||||||
|
<p>
|
||||||
<FormMessage />
|
<Trans>
|
||||||
</FormItem>
|
We'll send a verification code to {recipient.email} to verify your identity.
|
||||||
)}
|
</Trans>
|
||||||
/>
|
</p>
|
||||||
|
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<Form {...form}>
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<Trans>Cancel</Trans>
|
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||||
</Button>
|
<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}>
|
<FormControl>
|
||||||
<Trans>Sign</Trans>
|
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||||
</Button>
|
{Array(6)
|
||||||
</DialogFooter>
|
.fill(null)
|
||||||
</div>
|
.map((_, i) => (
|
||||||
</fieldset>
|
<PinInputGroup key={i}>
|
||||||
</form>
|
<PinInputSlot index={i} />
|
||||||
</Form>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -43,6 +43,7 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
availableAuthTypes,
|
availableAuthTypes,
|
||||||
|
actionTarget,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onReauthFormSubmit,
|
onReauthFormSubmit,
|
||||||
@ -107,15 +108,32 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
>
|
>
|
||||||
<ChevronLeftIcon className="h-4 w-4" />
|
<ChevronLeftIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span>{title || <Trans>Sign field</Trans>}</span>
|
<span>
|
||||||
|
{title ||
|
||||||
|
(actionTarget === 'DOCUMENT' ? (
|
||||||
|
<Trans>Sign document</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Sign field</Trans>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(!selectedAuthType || validAuthTypes.length === 1) &&
|
{(!selectedAuthType || validAuthTypes.length === 1) &&
|
||||||
(title || <Trans>Sign field</Trans>)}
|
(title ||
|
||||||
|
(actionTarget === 'DOCUMENT' ? (
|
||||||
|
<Trans>Sign document</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Sign field</Trans>
|
||||||
|
)))}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@ -180,6 +198,7 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
))
|
))
|
||||||
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
|
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
|
||||||
<DocumentSigningAuth2FA
|
<DocumentSigningAuth2FA
|
||||||
|
actionTarget={actionTarget === 'DOCUMENT' ? 'DOCUMENT' : 'FIELD'}
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
onReauthFormSubmit={onReauthFormSubmit}
|
onReauthFormSubmit={onReauthFormSubmit}
|
||||||
|
|||||||
@ -42,6 +42,7 @@ export type DocumentSigningAuthContextValue = {
|
|||||||
setPreferredPasskeyId: (_value: string | null) => void;
|
setPreferredPasskeyId: (_value: string | null) => void;
|
||||||
user?: SessionUser | null;
|
user?: SessionUser | null;
|
||||||
refetchPasskeys: () => Promise<void>;
|
refetchPasskeys: () => Promise<void>;
|
||||||
|
isEnterprise: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DocumentSigningAuthContext = createContext<DocumentSigningAuthContextValue | null>(null);
|
const DocumentSigningAuthContext = createContext<DocumentSigningAuthContextValue | null>(null);
|
||||||
@ -65,6 +66,7 @@ export interface DocumentSigningAuthProviderProps {
|
|||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
user?: SessionUser | null;
|
user?: SessionUser | null;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
isEnterprise: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DocumentSigningAuthProvider = ({
|
export const DocumentSigningAuthProvider = ({
|
||||||
@ -72,6 +74,7 @@ export const DocumentSigningAuthProvider = ({
|
|||||||
recipient: initialRecipient,
|
recipient: initialRecipient,
|
||||||
user,
|
user,
|
||||||
children,
|
children,
|
||||||
|
isEnterprise,
|
||||||
}: DocumentSigningAuthProviderProps) => {
|
}: DocumentSigningAuthProviderProps) => {
|
||||||
const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions);
|
const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions);
|
||||||
const [recipient, setRecipient] = useState(initialRecipient);
|
const [recipient, setRecipient] = useState(initialRecipient);
|
||||||
@ -144,8 +147,13 @@ export const DocumentSigningAuthProvider = ({
|
|||||||
}, [derivedRecipientActionAuth, user, recipient]);
|
}, [derivedRecipientActionAuth, user, recipient]);
|
||||||
|
|
||||||
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
|
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
|
||||||
// Directly run callback if no auth required.
|
// Determine if authentication is required based on enterprise status and action target.
|
||||||
if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) {
|
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();
|
await options.onReauthFormSubmit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -205,6 +213,7 @@ export const DocumentSigningAuthProvider = ({
|
|||||||
preferredPasskeyId,
|
preferredPasskeyId,
|
||||||
setPreferredPasskeyId,
|
setPreferredPasskeyId,
|
||||||
refetchPasskeys,
|
refetchPasskeys,
|
||||||
|
isEnterprise,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@ -225,6 +234,8 @@ export const DocumentSigningAuthProvider = ({
|
|||||||
type ExecuteActionAuthProcedureOptions = Omit<
|
type ExecuteActionAuthProcedureOptions = Omit<
|
||||||
DocumentSigningAuthDialogProps,
|
DocumentSigningAuthDialogProps,
|
||||||
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' | 'availableAuthTypes'
|
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' | 'availableAuthTypes'
|
||||||
>;
|
> & {
|
||||||
|
actionTarget: FieldType | 'DOCUMENT';
|
||||||
|
};
|
||||||
|
|
||||||
DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider';
|
DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider';
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import {
|
|||||||
AssistantConfirmationDialog,
|
AssistantConfirmationDialog,
|
||||||
type NextSigner,
|
type NextSigner,
|
||||||
} from '../../dialogs/assistant-confirmation-dialog';
|
} from '../../dialogs/assistant-confirmation-dialog';
|
||||||
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
||||||
|
|
||||||
@ -38,6 +39,7 @@ export type DocumentSigningFormProps = {
|
|||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
setSelectedSignerId?: (id: number | null) => void;
|
setSelectedSignerId?: (id: number | null) => void;
|
||||||
|
isEnterprise: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningForm = ({
|
export const DocumentSigningForm = ({
|
||||||
@ -48,6 +50,7 @@ export const DocumentSigningForm = ({
|
|||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
setSelectedSignerId,
|
setSelectedSignerId,
|
||||||
|
isEnterprise,
|
||||||
}: DocumentSigningFormProps) => {
|
}: DocumentSigningFormProps) => {
|
||||||
const { sessionData } = useOptionalSession();
|
const { sessionData } = useOptionalSession();
|
||||||
const user = sessionData?.user;
|
const user = sessionData?.user;
|
||||||
@ -61,6 +64,7 @@ export const DocumentSigningForm = ({
|
|||||||
const assistantSignersId = useId();
|
const assistantSignersId = useId();
|
||||||
|
|
||||||
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
||||||
|
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
||||||
@ -113,11 +117,16 @@ export const DocumentSigningForm = ({
|
|||||||
setIsAssistantSubmitting(true);
|
setIsAssistantSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await completeDocument(undefined, nextSigner);
|
await executeActionAuthProcedure({
|
||||||
|
actionTarget: 'DOCUMENT',
|
||||||
|
onReauthFormSubmit: async (authOptions) => {
|
||||||
|
await completeDocument(authOptions, nextSigner);
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while completing the document. Please try again.',
|
description: _(msg`An error occurred while completing the document. Please try again.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -207,7 +216,12 @@ export const DocumentSigningForm = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner) => {
|
||||||
await completeDocument(undefined, nextSigner);
|
await executeActionAuthProcedure({
|
||||||
|
actionTarget: 'DOCUMENT',
|
||||||
|
onReauthFormSubmit: async (authOptions) => {
|
||||||
|
await completeDocument(authOptions, nextSigner);
|
||||||
|
},
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
role={recipient.role}
|
role={recipient.role}
|
||||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
||||||
@ -367,7 +381,12 @@ export const DocumentSigningForm = ({
|
|||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
disabled={!isRecipientsTurn}
|
disabled={!isRecipientsTurn}
|
||||||
onSignatureComplete={async (nextSigner) => {
|
onSignatureComplete={async (nextSigner) => {
|
||||||
await completeDocument(undefined, nextSigner);
|
await executeActionAuthProcedure({
|
||||||
|
actionTarget: 'DOCUMENT',
|
||||||
|
onReauthFormSubmit: async (authOptions) => {
|
||||||
|
await completeDocument(authOptions, nextSigner);
|
||||||
|
},
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
role={recipient.role}
|
role={recipient.role}
|
||||||
allowDictateNextSigner={
|
allowDictateNextSigner={
|
||||||
|
|||||||
@ -50,6 +50,7 @@ export type DocumentSigningPageViewProps = {
|
|||||||
isRecipientsTurn: boolean;
|
isRecipientsTurn: boolean;
|
||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
includeSenderDetails: boolean;
|
includeSenderDetails: boolean;
|
||||||
|
isEnterprise: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningPageView = ({
|
export const DocumentSigningPageView = ({
|
||||||
@ -60,6 +61,7 @@ export const DocumentSigningPageView = ({
|
|||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
|
isEnterprise,
|
||||||
}: DocumentSigningPageViewProps) => {
|
}: DocumentSigningPageViewProps) => {
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
@ -208,6 +210,7 @@ export const DocumentSigningPageView = ({
|
|||||||
isRecipientsTurn={isRecipientsTurn}
|
isRecipientsTurn={isRecipientsTurn}
|
||||||
allRecipients={allRecipients}
|
allRecipients={allRecipients}
|
||||||
setSelectedSignerId={setSelectedSignerId}
|
setSelectedSignerId={setSelectedSignerId}
|
||||||
|
isEnterprise={isEnterprise}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -157,7 +157,7 @@ export const TemplateEditForm = ({
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Error`),
|
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',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,10 +9,10 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
|
|
||||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||||
|
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
|
||||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
|
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return appMetaTags('Templates');
|
return appMetaTags('Templates');
|
||||||
|
|||||||
@ -94,6 +94,7 @@ export default function DirectTemplatePage() {
|
|||||||
documentAuthOptions={template.authOptions}
|
documentAuthOptions={template.authOptions}
|
||||||
recipient={directTemplateRecipient}
|
recipient={directTemplateRecipient}
|
||||||
user={user}
|
user={user}
|
||||||
|
isEnterprise={false}
|
||||||
>
|
>
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<h1
|
<h1
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get
|
|||||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||||
|
import { isUserEnterprise } from '@documenso/lib/server-only/user/is-user-enterprise';
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
|
|
||||||
@ -41,6 +42,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw new Response('Not Found', { status: 404 });
|
throw new Response('Not Found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isEnterprise = user?.id
|
||||||
|
? await isUserEnterprise({ userId: user.id }).catch(() => false)
|
||||||
|
: false;
|
||||||
|
|
||||||
const [document, recipient, fields, completedFields] = await Promise.all([
|
const [document, recipient, fields, completedFields] = await Promise.all([
|
||||||
getDocumentAndSenderByToken({
|
getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
@ -116,6 +121,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
isDocumentAccessValid: false,
|
isDocumentAccessValid: false,
|
||||||
recipientEmail: recipient.email,
|
recipientEmail: recipient.email,
|
||||||
recipientHasAccount,
|
recipientHasAccount,
|
||||||
|
isEnterprise,
|
||||||
} as const);
|
} as const);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,6 +159,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
recipientSignature,
|
recipientSignature,
|
||||||
isRecipientsTurn,
|
isRecipientsTurn,
|
||||||
includeSenderDetails: settings.includeSenderDetails,
|
includeSenderDetails: settings.includeSenderDetails,
|
||||||
|
isEnterprise,
|
||||||
} as const);
|
} as const);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,6 +188,7 @@ export default function SigningPage() {
|
|||||||
allRecipients,
|
allRecipients,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
recipientWithFields,
|
recipientWithFields,
|
||||||
|
isEnterprise,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
if (document.deletedAt || document.status === DocumentStatus.REJECTED) {
|
if (document.deletedAt || document.status === DocumentStatus.REJECTED) {
|
||||||
@ -246,6 +254,7 @@ export default function SigningPage() {
|
|||||||
documentAuthOptions={document.authOptions}
|
documentAuthOptions={document.authOptions}
|
||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
user={user}
|
user={user}
|
||||||
|
isEnterprise={isEnterprise}
|
||||||
>
|
>
|
||||||
<DocumentSigningPageView
|
<DocumentSigningPageView
|
||||||
recipient={recipientWithFields}
|
recipient={recipientWithFields}
|
||||||
@ -255,6 +264,7 @@ export default function SigningPage() {
|
|||||||
isRecipientsTurn={isRecipientsTurn}
|
isRecipientsTurn={isRecipientsTurn}
|
||||||
allRecipients={allRecipients}
|
allRecipients={allRecipients}
|
||||||
includeSenderDetails={includeSenderDetails}
|
includeSenderDetails={includeSenderDetails}
|
||||||
|
isEnterprise={isEnterprise}
|
||||||
/>
|
/>
|
||||||
</DocumentSigningAuthProvider>
|
</DocumentSigningAuthProvider>
|
||||||
</DocumentSigningProvider>
|
</DocumentSigningProvider>
|
||||||
|
|||||||
@ -88,6 +88,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
|
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
|
||||||
|
|
||||||
|
const isEnterpriseDocument = Boolean(organisationClaim);
|
||||||
|
|
||||||
return superLoaderJson({
|
return superLoaderJson({
|
||||||
token,
|
token,
|
||||||
user,
|
user,
|
||||||
@ -96,12 +98,21 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
fields,
|
fields,
|
||||||
hidePoweredBy,
|
hidePoweredBy,
|
||||||
allowEmbedSigningWhitelabel,
|
allowEmbedSigningWhitelabel,
|
||||||
|
isEnterpriseDocument,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EmbedDirectTemplatePage() {
|
export default function EmbedDirectTemplatePage() {
|
||||||
const { token, user, template, recipient, fields, hidePoweredBy, allowEmbedSigningWhitelabel } =
|
const {
|
||||||
useSuperLoaderData<typeof loader>();
|
token,
|
||||||
|
user,
|
||||||
|
template,
|
||||||
|
recipient,
|
||||||
|
fields,
|
||||||
|
hidePoweredBy,
|
||||||
|
allowEmbedSigningWhitelabel,
|
||||||
|
isEnterpriseDocument,
|
||||||
|
} = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningProvider
|
<DocumentSigningProvider
|
||||||
@ -116,6 +127,7 @@ export default function EmbedDirectTemplatePage() {
|
|||||||
documentAuthOptions={template.authOptions}
|
documentAuthOptions={template.authOptions}
|
||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
user={user}
|
user={user}
|
||||||
|
isEnterprise={isEnterpriseDocument}
|
||||||
>
|
>
|
||||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||||
<EmbedDirectTemplateClientPage
|
<EmbedDirectTemplateClientPage
|
||||||
|
|||||||
@ -109,6 +109,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const isEnterpriseDocument = Boolean(organisationClaim);
|
||||||
|
|
||||||
return superLoaderJson({
|
return superLoaderJson({
|
||||||
token,
|
token,
|
||||||
user,
|
user,
|
||||||
@ -119,6 +121,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
completedFields,
|
completedFields,
|
||||||
hidePoweredBy,
|
hidePoweredBy,
|
||||||
allowEmbedSigningWhitelabel,
|
allowEmbedSigningWhitelabel,
|
||||||
|
isEnterpriseDocument,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,6 +136,7 @@ export default function EmbedSignDocumentPage() {
|
|||||||
completedFields,
|
completedFields,
|
||||||
hidePoweredBy,
|
hidePoweredBy,
|
||||||
allowEmbedSigningWhitelabel,
|
allowEmbedSigningWhitelabel,
|
||||||
|
isEnterpriseDocument,
|
||||||
} = useSuperLoaderData<typeof loader>();
|
} = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -148,6 +152,7 @@ export default function EmbedSignDocumentPage() {
|
|||||||
documentAuthOptions={document.authOptions}
|
documentAuthOptions={document.authOptions}
|
||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
user={user}
|
user={user}
|
||||||
|
isEnterprise={isEnterpriseDocument}
|
||||||
>
|
>
|
||||||
<EmbedSignDocumentClientPage
|
<EmbedSignDocumentClientPage
|
||||||
token={token}
|
token={token}
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
user,
|
user,
|
||||||
hidePoweredBy: false,
|
hidePoweredBy: false,
|
||||||
allowWhitelabelling: false,
|
allowWhitelabelling: false,
|
||||||
|
isEnterprise: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,17 +56,19 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
const allowWhitelabelling = organisationClaim.flags.embedSigningWhiteLabel;
|
const allowWhitelabelling = organisationClaim.flags.embedSigningWhiteLabel;
|
||||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||||
|
const isEnterprise = Boolean(organisationClaim.flags.cfr21);
|
||||||
|
|
||||||
return superLoaderJson({
|
return superLoaderJson({
|
||||||
envelopes,
|
envelopes,
|
||||||
user,
|
user,
|
||||||
hidePoweredBy,
|
hidePoweredBy,
|
||||||
allowWhitelabelling,
|
allowWhitelabelling,
|
||||||
|
isEnterprise,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MultisignPage() {
|
export default function MultisignPage() {
|
||||||
const { envelopes, user, hidePoweredBy, allowWhitelabelling } =
|
const { envelopes, user, hidePoweredBy, allowWhitelabelling, isEnterprise } =
|
||||||
useSuperLoaderData<typeof loader>();
|
useSuperLoaderData<typeof loader>();
|
||||||
const revalidator = useRevalidator();
|
const revalidator = useRevalidator();
|
||||||
|
|
||||||
@ -264,6 +267,7 @@ export default function MultisignPage() {
|
|||||||
documentAuthOptions={selectedDocument.authOptions}
|
documentAuthOptions={selectedDocument.authOptions}
|
||||||
recipient={selectedRecipient}
|
recipient={selectedRecipient}
|
||||||
user={user}
|
user={user}
|
||||||
|
isEnterprise={isEnterprise}
|
||||||
>
|
>
|
||||||
<DocumentSigningRecipientProvider recipient={selectedRecipient} targetSigner={null}>
|
<DocumentSigningRecipientProvider recipient={selectedRecipient} targetSigner={null}>
|
||||||
<MultiSignDocumentSigningView
|
<MultiSignDocumentSigningView
|
||||||
|
|||||||
@ -27,8 +27,8 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
|
|||||||
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
|
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
|
||||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||||
|
|
||||||
// Action auth should NOT be visible.
|
// Action auth should now be visible for all users
|
||||||
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
|
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
|
||||||
|
|
||||||
// Save the settings by going to the next step.
|
// Save the settings by going to the next step.
|
||||||
|
|
||||||
|
|||||||
@ -25,8 +25,8 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
|
|||||||
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
|
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
|
||||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||||
|
|
||||||
// Action auth should NOT be visible.
|
// Action auth should now be visible for all users
|
||||||
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
|
await expect(page.getByTestId('documentActionSelectValue')).toBeVisible();
|
||||||
|
|
||||||
// Save the settings by going to the next step.
|
// Save the settings by going to the next step.
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|||||||
@ -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;
|
||||||
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,5 +1,4 @@
|
|||||||
import { DocumentVisibility } from '@prisma/client';
|
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
||||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
|
||||||
import { isDeepEqual } from 'remeda';
|
import { isDeepEqual } from 'remeda';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -119,13 +118,6 @@ export const updateDocument = async ({
|
|||||||
const newGlobalActionAuth =
|
const newGlobalActionAuth =
|
||||||
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
||||||
|
|
||||||
// Check if user has permission to set the global action auth.
|
|
||||||
if (newGlobalActionAuth.length > 0 && !document.team.organisation.organisationClaim.flags.cfr21) {
|
|
||||||
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 isTitleSame = data.title === undefined || data.title === document.title;
|
||||||
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
|
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
|
||||||
const isGlobalAccessSame =
|
const isGlobalAccessSame =
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import { AUTO_SIGNABLE_FIELD_TYPES } from '../../constants/autosign';
|
|||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
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 {
|
import {
|
||||||
ZCheckboxFieldMeta,
|
ZCheckboxFieldMeta,
|
||||||
ZDropdownFieldMeta,
|
ZDropdownFieldMeta,
|
||||||
@ -25,7 +25,9 @@ import {
|
|||||||
} from '../../types/field-meta';
|
} from '../../types/field-meta';
|
||||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
|
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||||
|
import { isUserEnterprise } from '../user/is-user-enterprise';
|
||||||
|
|
||||||
export type SignFieldWithTokenOptions = {
|
export type SignFieldWithTokenOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
@ -171,13 +173,25 @@ export const signFieldWithToken = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const derivedRecipientActionAuth = await validateFieldAuth({
|
const isEnterprise = userId ? await isUserEnterprise({ userId }) : false;
|
||||||
documentAuthOptions: document.authOptions,
|
let requiredAuthType: TRecipientActionAuthTypes | null = null;
|
||||||
recipient,
|
|
||||||
field,
|
if (isEnterprise) {
|
||||||
userId,
|
const authType = await validateFieldAuth({
|
||||||
authOptions,
|
documentAuthOptions: document.authOptions,
|
||||||
});
|
recipient,
|
||||||
|
field,
|
||||||
|
userId,
|
||||||
|
authOptions,
|
||||||
|
});
|
||||||
|
requiredAuthType = authType ?? null;
|
||||||
|
} else {
|
||||||
|
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipient.authOptions,
|
||||||
|
});
|
||||||
|
requiredAuthType = derivedRecipientActionAuth.length > 0 ? derivedRecipientActionAuth[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
const documentMeta = await prisma.documentMeta.findFirst({
|
const documentMeta = await prisma.documentMeta.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@ -311,9 +325,9 @@ export const signFieldWithToken = async ({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.exhaustive(),
|
.exhaustive(),
|
||||||
fieldSecurity: derivedRecipientActionAuth
|
fieldSecurity: requiredAuthType
|
||||||
? {
|
? {
|
||||||
type: derivedRecipientActionAuth,
|
type: requiredAuthType,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -37,3 +37,23 @@ export const getOrganisationClaimByTeamId = async ({ teamId }: { teamId: number
|
|||||||
|
|
||||||
return organisationClaim;
|
return organisationClaim;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getOrganisationClaimByUserId = async ({ userId }: { userId: number }) => {
|
||||||
|
const organisationClaim = await prisma.organisationClaim.findFirst({
|
||||||
|
where: {
|
||||||
|
organisation: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organisationClaim) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return organisationClaim;
|
||||||
|
};
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import { prisma } from '@documenso/prisma';
|
|||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||||
|
import { DocumentAuth } from '../../types/document-auth';
|
||||||
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
|
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
|
import { isUserEnterprise } from '../user/is-user-enterprise';
|
||||||
|
|
||||||
export type UpdateTemplateOptions = {
|
export type UpdateTemplateOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
@ -76,10 +78,21 @@ export const updateTemplate = async ({
|
|||||||
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
||||||
|
|
||||||
// Check if user has permission to set the global action auth.
|
// Check if user has permission to set the global action auth.
|
||||||
if (newGlobalActionAuth.length > 0 && !template.team.organisation.organisationClaim.flags.cfr21) {
|
// Only ACCOUNT and PASSKEY require enterprise permissions
|
||||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
if (
|
||||||
message: 'You do not have permission to set the action auth',
|
newGlobalActionAuth &&
|
||||||
|
(newGlobalActionAuth.includes(DocumentAuth.ACCOUNT) ||
|
||||||
|
newGlobalActionAuth.includes(DocumentAuth.PASSKEY))
|
||||||
|
) {
|
||||||
|
const isDocumentEnterprise = await isUserEnterprise({
|
||||||
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!isDocumentEnterprise) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'You do not have permission to set this action auth type',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const authOptions = createDocumentAuthOptions({
|
const authOptions = createDocumentAuthOptions({
|
||||||
|
|||||||
14
packages/lib/server-only/user/is-user-enterprise.ts
Normal file
14
packages/lib/server-only/user/is-user-enterprise.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { getOrganisationClaimByUserId } from '../organisation/get-organisation-claims';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user has enterprise features enabled (cfr21 flag).
|
||||||
|
*/
|
||||||
|
export const isUserEnterprise = async ({ userId }: { userId: number }): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const organisationClaim = await getOrganisationClaimByUserId({ userId });
|
||||||
|
return Boolean(organisationClaim.flags.cfr21);
|
||||||
|
} catch {
|
||||||
|
// If we can't find the organisation claim, assume non-enterprise
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -82,6 +82,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 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.
|
* The recipient access auth methods.
|
||||||
*
|
*
|
||||||
@ -118,6 +128,7 @@ export const ZRecipientActionAuthTypesSchema = z
|
|||||||
|
|
||||||
export const DocumentAccessAuth = ZDocumentAccessAuthTypesSchema.Enum;
|
export const DocumentAccessAuth = ZDocumentAccessAuthTypesSchema.Enum;
|
||||||
export const DocumentActionAuth = ZDocumentActionAuthTypesSchema.Enum;
|
export const DocumentActionAuth = ZDocumentActionAuthTypesSchema.Enum;
|
||||||
|
export const NonEnterpriseDocumentActionAuth = ZNonEnterpriseDocumentActionAuthTypesSchema.Enum;
|
||||||
export const RecipientAccessAuth = ZRecipientAccessAuthTypesSchema.Enum;
|
export const RecipientAccessAuth = ZRecipientAccessAuthTypesSchema.Enum;
|
||||||
export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum;
|
export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum;
|
||||||
|
|
||||||
@ -201,6 +212,9 @@ export type TDocumentAccessAuth = z.infer<typeof ZDocumentAccessAuthSchema>;
|
|||||||
export type TDocumentAccessAuthTypes = z.infer<typeof ZDocumentAccessAuthTypesSchema>;
|
export type TDocumentAccessAuthTypes = z.infer<typeof ZDocumentAccessAuthTypesSchema>;
|
||||||
export type TDocumentActionAuth = z.infer<typeof ZDocumentActionAuthSchema>;
|
export type TDocumentActionAuth = z.infer<typeof ZDocumentActionAuthSchema>;
|
||||||
export type TDocumentActionAuthTypes = z.infer<typeof ZDocumentActionAuthTypesSchema>;
|
export type TDocumentActionAuthTypes = z.infer<typeof ZDocumentActionAuthTypesSchema>;
|
||||||
|
export type TNonEnterpriseDocumentActionAuthTypes = z.infer<
|
||||||
|
typeof ZNonEnterpriseDocumentActionAuthTypesSchema
|
||||||
|
>;
|
||||||
export type TRecipientAccessAuth = z.infer<typeof ZRecipientAccessAuthSchema>;
|
export type TRecipientAccessAuth = z.infer<typeof ZRecipientAccessAuthSchema>;
|
||||||
export type TRecipientAccessAuthTypes = z.infer<typeof ZRecipientAccessAuthTypesSchema>;
|
export type TRecipientAccessAuthTypes = z.infer<typeof ZRecipientAccessAuthTypesSchema>;
|
||||||
export type TRecipientActionAuth = z.infer<typeof ZRecipientActionAuthSchema>;
|
export type TRecipientActionAuth = z.infer<typeof ZRecipientActionAuthSchema>;
|
||||||
|
|||||||
@ -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;
|
||||||
@ -59,9 +59,10 @@ model User {
|
|||||||
ownedOrganisations Organisation[]
|
ownedOrganisations Organisation[]
|
||||||
organisationMember OrganisationMember[]
|
organisationMember OrganisationMember[]
|
||||||
|
|
||||||
twoFactorSecret String?
|
twoFactorSecret String?
|
||||||
twoFactorEnabled Boolean @default(false)
|
twoFactorEnabled Boolean @default(false)
|
||||||
twoFactorBackupCodes String?
|
twoFactorBackupCodes String?
|
||||||
|
twoFactorEmailVerification UserTwoFactorEmailVerification?
|
||||||
|
|
||||||
folders Folder[]
|
folders Folder[]
|
||||||
documents Document[]
|
documents Document[]
|
||||||
@ -985,6 +986,15 @@ model AvatarImage {
|
|||||||
organisation Organisation[]
|
organisation Organisation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model UserTwoFactorEmailVerification {
|
||||||
|
userId Int @id
|
||||||
|
verificationCode String
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
enum EmailDomainStatus {
|
enum EmailDomainStatus {
|
||||||
PENDING
|
PENDING
|
||||||
ACTIVE
|
ACTIVE
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
|
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 { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
|
||||||
import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
|
import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
|
||||||
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-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 { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
|
||||||
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
|
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@ -15,7 +21,9 @@ import {
|
|||||||
ZCreatePasskeyMutationSchema,
|
ZCreatePasskeyMutationSchema,
|
||||||
ZDeletePasskeyMutationSchema,
|
ZDeletePasskeyMutationSchema,
|
||||||
ZFindPasskeysQuerySchema,
|
ZFindPasskeysQuerySchema,
|
||||||
|
ZSendEmailVerificationMutationSchema,
|
||||||
ZUpdatePasskeyMutationSchema,
|
ZUpdatePasskeyMutationSchema,
|
||||||
|
ZVerifyEmailCodeMutationSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
export const authRouter = router({
|
export const authRouter = router({
|
||||||
@ -110,4 +118,68 @@ export const authRouter = router({
|
|||||||
requestMetadata: ctx.metadata.requestMetadata,
|
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 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>;
|
||||||
|
|||||||
@ -4,70 +4,70 @@ import { Trans } from '@lingui/react/macro';
|
|||||||
import { InfoIcon } from 'lucide-react';
|
import { InfoIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||||
import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth';
|
import {
|
||||||
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
|
DocumentActionAuth,
|
||||||
|
DocumentAuth,
|
||||||
|
NonEnterpriseDocumentActionAuth,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
export interface DocumentGlobalAuthActionSelectProps {
|
export interface DocumentGlobalAuthActionSelectProps {
|
||||||
value?: string[];
|
value?: string[];
|
||||||
defaultValue?: string[];
|
|
||||||
onValueChange?: (value: string[]) => void;
|
onValueChange?: (value: string[]) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
isDocumentEnterprise?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DocumentGlobalAuthActionSelect = ({
|
export const DocumentGlobalAuthActionSelect = ({
|
||||||
value,
|
value,
|
||||||
defaultValue,
|
|
||||||
onValueChange,
|
onValueChange,
|
||||||
disabled,
|
disabled,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
isDocumentEnterprise,
|
||||||
}: DocumentGlobalAuthActionSelectProps) => {
|
}: DocumentGlobalAuthActionSelectProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
// Convert auth types to MultiSelect options
|
const authTypes = isDocumentEnterprise
|
||||||
const authOptions: Option[] = [
|
? Object.values(DocumentActionAuth).filter((auth) => auth !== DocumentAuth.ACCOUNT)
|
||||||
{
|
: Object.values(NonEnterpriseDocumentActionAuth).filter(
|
||||||
value: '-1',
|
(auth) => auth !== DocumentAuth.EXPLICIT_NONE,
|
||||||
label: _(msg`No restrictions`),
|
);
|
||||||
},
|
|
||||||
...Object.values(DocumentActionAuth)
|
|
||||||
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
|
|
||||||
.map((authType) => ({
|
|
||||||
value: authType,
|
|
||||||
label: DOCUMENT_AUTH_TYPES[authType].value,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Convert string array to Option array for MultiSelect
|
const selectedValue = value?.[0] || '';
|
||||||
const selectedOptions =
|
|
||||||
(value
|
|
||||||
?.map((val) => authOptions.find((option) => option.value === val))
|
|
||||||
.filter(Boolean) as Option[]) || [];
|
|
||||||
|
|
||||||
// Convert default value to Option array
|
const handleChange = (newValue: string) => {
|
||||||
const defaultOptions =
|
if (newValue === '-1') {
|
||||||
(defaultValue
|
onValueChange?.([]);
|
||||||
?.map((val) => authOptions.find((option) => option.value === val))
|
} else {
|
||||||
.filter(Boolean) as Option[]) || [];
|
onValueChange?.([newValue]);
|
||||||
|
}
|
||||||
const handleChange = (options: Option[]) => {
|
|
||||||
const values = options.map((option) => option.value);
|
|
||||||
onValueChange?.(values);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultiSelect
|
<Select value={selectedValue || '-1'} onValueChange={handleChange} disabled={disabled}>
|
||||||
value={selectedOptions}
|
<SelectTrigger
|
||||||
defaultOptions={defaultOptions}
|
className="bg-background text-muted-foreground"
|
||||||
options={authOptions}
|
data-testid="documentActionSelectValue"
|
||||||
onChange={handleChange}
|
>
|
||||||
disabled={disabled}
|
<SelectValue placeholder={placeholder || _(msg`Select authentication method`)} />
|
||||||
placeholder={placeholder || _(msg`Select authentication methods`)}
|
</SelectTrigger>
|
||||||
className="bg-background text-muted-foreground"
|
<SelectContent>
|
||||||
hideClearAllButton={false}
|
<SelectItem value="-1">{_(msg`No restrictions`)}</SelectItem>
|
||||||
data-testid="documentActionSelectValue"
|
{authTypes.map((authType) => (
|
||||||
/>
|
<SelectItem key={authType} value={authType}>
|
||||||
|
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -86,14 +86,14 @@ export const DocumentGlobalAuthActionTooltip = () => (
|
|||||||
|
|
||||||
<p>
|
<p>
|
||||||
<Trans>
|
<Trans>
|
||||||
The authentication methods required for recipients to sign the signature field.
|
The authentication method required for recipients to sign the signature field.
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<Trans>
|
<Trans>
|
||||||
These can be overriden by setting the authentication requirements directly on each
|
This can be overridden by setting the authentication requirements directly on each
|
||||||
recipient in the next step. Multiple methods can be selected.
|
recipient in the next step.
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@ -294,28 +294,27 @@ export const AddSettingsFormPartial = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{organisation.organisationClaim.flags.cfr21 && (
|
<FormField
|
||||||
<FormField
|
control={form.control}
|
||||||
control={form.control}
|
name="globalActionAuth"
|
||||||
name="globalActionAuth"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem>
|
||||||
<FormItem>
|
<FormLabel className="flex flex-row items-center">
|
||||||
<FormLabel className="flex flex-row items-center">
|
<Trans>Recipient action authentication</Trans>
|
||||||
<Trans>Recipient action authentication</Trans>
|
<DocumentGlobalAuthActionTooltip />
|
||||||
<DocumentGlobalAuthActionTooltip />
|
</FormLabel>
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<DocumentGlobalAuthActionSelect
|
<DocumentGlobalAuthActionSelect
|
||||||
value={field.value}
|
value={field.value}
|
||||||
disabled={field.disabled}
|
disabled={field.disabled}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
/>
|
isDocumentEnterprise={organisation?.organisationClaim?.flags?.cfr21 || false}
|
||||||
</FormControl>
|
/>
|
||||||
</FormItem>
|
</FormControl>
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
<Accordion type="multiple" className="mt-6">
|
<Accordion type="multiple" className="mt-6">
|
||||||
<AccordionItem value="advanced-options" className="border-none">
|
<AccordionItem value="advanced-options" className="border-none">
|
||||||
|
|||||||
@ -382,28 +382,27 @@ export const AddTemplateSettingsFormPartial = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{organisation.organisationClaim.flags.cfr21 && (
|
<FormField
|
||||||
<FormField
|
control={form.control}
|
||||||
control={form.control}
|
name="globalActionAuth"
|
||||||
name="globalActionAuth"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem>
|
||||||
<FormItem>
|
<FormLabel className="flex flex-row items-center">
|
||||||
<FormLabel className="flex flex-row items-center">
|
<Trans>Recipient action authentication</Trans>
|
||||||
<Trans>Recipient action authentication</Trans>
|
<DocumentGlobalAuthActionTooltip />
|
||||||
<DocumentGlobalAuthActionTooltip />
|
</FormLabel>
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<DocumentGlobalAuthActionSelect
|
<DocumentGlobalAuthActionSelect
|
||||||
value={field.value}
|
value={field.value}
|
||||||
disabled={field.disabled}
|
disabled={field.disabled}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
/>
|
isDocumentEnterprise={organisation.organisationClaim.flags.cfr21}
|
||||||
</FormControl>
|
/>
|
||||||
</FormItem>
|
</FormControl>
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{distributionMethod === DocumentDistributionMethod.EMAIL && (
|
{distributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||||
<Accordion type="multiple">
|
<Accordion type="multiple">
|
||||||
|
|||||||
Reference in New Issue
Block a user