feat: password reauthentication for documents and recipients (#1827)

Adds password reauthentication to our existing reauth providers,
additionally swaps from an exclusive provider to an inclusive type where
multiple methods can be selected to offer a this or that experience.
This commit is contained in:
Lucas Smith
2025-06-07 00:27:19 +10:00
committed by GitHub
parent ce66da0055
commit 55c8632620
62 changed files with 985 additions and 466 deletions

View File

@ -130,7 +130,7 @@ export const DirectTemplateConfigureForm = ({
{...field} {...field}
disabled={ disabled={
field.disabled || field.disabled ||
derivedRecipientAccessAuth !== null || derivedRecipientAccessAuth.length > 0 ||
user?.email !== undefined user?.email !== undefined
} }
placeholder="recipient@documenso.com" placeholder="recipient@documenso.com"

View File

@ -1,5 +1,8 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client'; import type { FieldType } from '@prisma/client';
import { ChevronLeftIcon } from 'lucide-react';
import { P, match } from 'ts-pattern'; import { P, match } from 'ts-pattern';
import { import {
@ -7,6 +10,7 @@ import {
type TRecipientActionAuth, type TRecipientActionAuth,
type TRecipientActionAuthTypes, type TRecipientActionAuthTypes,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -18,11 +22,12 @@ import {
import { DocumentSigningAuth2FA } from './document-signing-auth-2fa'; import { DocumentSigningAuth2FA } from './document-signing-auth-2fa';
import { DocumentSigningAuthAccount } from './document-signing-auth-account'; import { DocumentSigningAuthAccount } from './document-signing-auth-account';
import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey'; import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey';
import { DocumentSigningAuthPassword } from './document-signing-auth-password';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthDialogProps = { export type DocumentSigningAuthDialogProps = {
title?: string; title?: string;
documentAuthType: TRecipientActionAuthTypes; availableAuthTypes: TRecipientActionAuthTypes[];
description?: string; description?: string;
actionTarget: FieldType | 'DOCUMENT'; actionTarget: FieldType | 'DOCUMENT';
open: boolean; open: boolean;
@ -37,54 +42,158 @@ export type DocumentSigningAuthDialogProps = {
export const DocumentSigningAuthDialog = ({ export const DocumentSigningAuthDialog = ({
title, title,
description, description,
documentAuthType, availableAuthTypes,
open, open,
onOpenChange, onOpenChange,
onReauthFormSubmit, onReauthFormSubmit,
}: DocumentSigningAuthDialogProps) => { }: DocumentSigningAuthDialogProps) => {
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext(); const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
// Filter out EXPLICIT_NONE from available auth types for the chooser
const validAuthTypes = availableAuthTypes.filter(
(authType) => authType !== DocumentAuth.EXPLICIT_NONE,
);
const [selectedAuthType, setSelectedAuthType] = useState<TRecipientActionAuthTypes | null>(() => {
// Auto-select if there's only one valid option
if (validAuthTypes.length === 1) {
return validAuthTypes[0];
}
// Return null if multiple options - show chooser
return null;
});
const handleOnOpenChange = (value: boolean) => { const handleOnOpenChange = (value: boolean) => {
if (isCurrentlyAuthenticating) { if (isCurrentlyAuthenticating) {
return; return;
} }
// Reset selected auth type when dialog closes
if (!value) {
setSelectedAuthType(() => {
if (validAuthTypes.length === 1) {
return validAuthTypes[0];
}
return null;
});
}
onOpenChange(value); onOpenChange(value);
}; };
const handleBackToChooser = () => {
setSelectedAuthType(null);
};
// If no valid auth types available, don't render anything
if (validAuthTypes.length === 0) {
return null;
}
return ( return (
<Dialog open={open} onOpenChange={handleOnOpenChange}> <Dialog open={open} onOpenChange={handleOnOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{title || <Trans>Sign field</Trans>}</DialogTitle> <DialogTitle>
{selectedAuthType && validAuthTypes.length > 1 && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleBackToChooser}
className="h-6 w-6 p-0"
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<span>{title || <Trans>Sign field</Trans>}</span>
</div>
)}
{(!selectedAuthType || validAuthTypes.length === 1) &&
(title || <Trans>Sign field</Trans>)}
</DialogTitle>
<DialogDescription> <DialogDescription>
{description || <Trans>Reauthentication is required to sign this field</Trans>} {description || <Trans>Reauthentication is required to sign this field</Trans>}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{match({ documentAuthType, user }) {/* Show chooser if no auth type is selected and there are multiple options */}
.with( {!selectedAuthType && validAuthTypes.length > 1 && (
{ documentAuthType: DocumentAuth.ACCOUNT }, <div className="space-y-4">
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in. <p className="text-muted-foreground text-sm">
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />, <Trans>Choose your preferred authentication method:</Trans>
) </p>
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => ( <div className="grid gap-2">
<DocumentSigningAuthPasskey {validAuthTypes.map((authType) => (
open={open} <Button
onOpenChange={onOpenChange} key={authType}
onReauthFormSubmit={onReauthFormSubmit} type="button"
/> variant="outline"
)) className="h-auto justify-start p-4"
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => ( onClick={() => setSelectedAuthType(authType)}
<DocumentSigningAuth2FA >
open={open} <div className="text-left">
onOpenChange={onOpenChange} <div className="font-medium">
onReauthFormSubmit={onReauthFormSubmit} {match(authType)
/> .with(DocumentAuth.ACCOUNT, () => <Trans>Account</Trans>)
)) .with(DocumentAuth.PASSKEY, () => <Trans>Passkey</Trans>)
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null) .with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>2FA</Trans>)
.exhaustive()} .with(DocumentAuth.PASSWORD, () => <Trans>Password</Trans>)
.exhaustive()}
</div>
<div className="text-muted-foreground text-sm">
{match(authType)
.with(DocumentAuth.ACCOUNT, () => <Trans>Sign in to your account</Trans>)
.with(DocumentAuth.PASSKEY, () => (
<Trans>Use your passkey for authentication</Trans>
))
.with(DocumentAuth.TWO_FACTOR_AUTH, () => (
<Trans>Enter your 2FA code</Trans>
))
.with(DocumentAuth.PASSWORD, () => <Trans>Enter your password</Trans>)
.exhaustive()}
</div>
</div>
</Button>
))}
</div>
</div>
)}
{/* Show the selected auth component */}
{selectedAuthType &&
match({ documentAuthType: selectedAuthType, user })
.with(
{ documentAuthType: DocumentAuth.ACCOUNT },
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
)
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
<DocumentSigningAuthPasskey
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
<DocumentSigningAuth2FA
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.PASSWORD }, () => (
<DocumentSigningAuthPassword
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
.exhaustive()}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -0,0 +1,148 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
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 { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthPasswordProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
};
const ZPasswordAuthFormSchema = z.object({
password: z
.string()
.min(1, { message: 'Password is required' })
.max(72, { message: 'Password must be at most 72 characters long' }),
});
type TPasswordAuthFormSchema = z.infer<typeof ZPasswordAuthFormSchema>;
export const DocumentSigningAuthPassword = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
}: DocumentSigningAuthPasswordProps) => {
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
const form = useForm<TPasswordAuthFormSchema>({
resolver: zodResolver(ZPasswordAuthFormSchema),
defaultValues: {
password: '',
},
});
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => {
try {
setIsCurrentlyAuthenticating(true);
await onReauthFormSubmit({
type: DocumentAuth.PASSWORD,
password,
});
setIsCurrentlyAuthenticating(false);
onOpenChange(false);
} catch (err) {
setIsCurrentlyAuthenticating(false);
const error = AppError.parseError(err);
setFormErrorCode(error.code);
// Todo: Alert.
}
};
useEffect(() => {
form.reset({
password: '',
});
setFormErrorCode(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
{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>
)}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Password</Trans>
</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
autoComplete="current-password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -1,7 +1,6 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { type Document, FieldType, type Passkey, type Recipient } from '@prisma/client'; import { type Document, FieldType, type Passkey, type Recipient } from '@prisma/client';
import { match } from 'ts-pattern';
import type { SessionUser } from '@documenso/auth/server/lib/session/session'; import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth'; import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
@ -33,8 +32,8 @@ export type DocumentSigningAuthContextValue = {
recipient: Recipient; recipient: Recipient;
recipientAuthOption: TRecipientAuthOptions; recipientAuthOption: TRecipientAuthOptions;
setRecipient: (_value: Recipient) => void; setRecipient: (_value: Recipient) => void;
derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null; derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
derivedRecipientActionAuth: TRecipientActionAuthTypes | null; derivedRecipientActionAuth: TRecipientActionAuthTypes[];
isAuthRedirectRequired: boolean; isAuthRedirectRequired: boolean;
isCurrentlyAuthenticating: boolean; isCurrentlyAuthenticating: boolean;
setIsCurrentlyAuthenticating: (_value: boolean) => void; setIsCurrentlyAuthenticating: (_value: boolean) => void;
@ -100,7 +99,7 @@ export const DocumentSigningAuthProvider = ({
}, },
{ {
placeholderData: (previousData) => previousData, placeholderData: (previousData) => previousData,
enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY, enabled: derivedRecipientActionAuth?.includes(DocumentAuth.PASSKEY) ?? false,
}, },
); );
@ -121,21 +120,28 @@ export const DocumentSigningAuthProvider = ({
* Will be `null` if the user still requires authentication, or if they don't need * Will be `null` if the user still requires authentication, or if they don't need
* authentication. * authentication.
*/ */
const preCalculatedActionAuthOptions = match(derivedRecipientActionAuth) const preCalculatedActionAuthOptions = useMemo(() => {
.with(DocumentAuth.ACCOUNT, () => { if (
if (recipient.email !== user?.email) { !derivedRecipientActionAuth ||
return null; derivedRecipientActionAuth.length === 0 ||
} derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE)
) {
return {
type: DocumentAuth.EXPLICIT_NONE,
};
}
if (
derivedRecipientActionAuth.includes(DocumentAuth.ACCOUNT) &&
user?.email == recipient.email
) {
return { return {
type: DocumentAuth.ACCOUNT, type: DocumentAuth.ACCOUNT,
}; };
}) }
.with(DocumentAuth.EXPLICIT_NONE, () => ({
type: DocumentAuth.EXPLICIT_NONE, return null;
})) }, [derivedRecipientActionAuth, user, recipient]);
.with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null)
.exhaustive();
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
// Directly run callback if no auth required. // Directly run callback if no auth required.
@ -170,7 +176,8 @@ export const DocumentSigningAuthProvider = ({
// Assume that a user must be logged in for any auth requirements. // Assume that a user must be logged in for any auth requirements.
const isAuthRedirectRequired = Boolean( const isAuthRedirectRequired = Boolean(
derivedRecipientActionAuth && derivedRecipientActionAuth &&
derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE && derivedRecipientActionAuth.length > 0 &&
!derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) &&
user?.email !== recipient.email, user?.email !== recipient.email,
); );
@ -208,7 +215,7 @@ export const DocumentSigningAuthProvider = ({
onOpenChange={() => setDocumentAuthDialogPayload(null)} onOpenChange={() => setDocumentAuthDialogPayload(null)}
onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit} onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit}
actionTarget={documentAuthDialogPayload.actionTarget} actionTarget={documentAuthDialogPayload.actionTarget}
documentAuthType={derivedRecipientActionAuth} availableAuthTypes={derivedRecipientActionAuth}
/> />
)} )}
</DocumentSigningAuthContext.Provider> </DocumentSigningAuthContext.Provider>
@ -217,7 +224,7 @@ export const DocumentSigningAuthProvider = ({
type ExecuteActionAuthProcedureOptions = Omit< type ExecuteActionAuthProcedureOptions = Omit<
DocumentSigningAuthDialogProps, DocumentSigningAuthDialogProps,
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' 'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' | 'availableAuthTypes'
>; >;
DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider'; DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider';

View File

@ -44,6 +44,7 @@ const AUTO_SIGNABLE_FIELD_TYPES: string[] = [
// other field types. // other field types.
const NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES: string[] = [ const NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES: string[] = [
DocumentAuth.PASSKEY, DocumentAuth.PASSKEY,
DocumentAuth.PASSWORD,
DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.TWO_FACTOR_AUTH,
]; ];
@ -96,8 +97,8 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
return true; return true;
}); });
const actionAuthAllowsAutoSign = !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes( const actionAuthAllowsAutoSign = derivedRecipientActionAuth.every(
derivedRecipientActionAuth ?? '', (actionAuth) => !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(actionAuth),
); );
const onSubmit = async () => { const onSubmit = async () => {
@ -110,16 +111,16 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
.with(FieldType.DATE, () => new Date().toISOString()) .with(FieldType.DATE, () => new Date().toISOString())
.otherwise(() => ''); .otherwise(() => '');
const authOptions = match(derivedRecipientActionAuth) const authOptions = match(derivedRecipientActionAuth.at(0))
.with(DocumentAuth.ACCOUNT, () => ({ .with(DocumentAuth.ACCOUNT, () => ({
type: DocumentAuth.ACCOUNT, type: DocumentAuth.ACCOUNT,
})) }))
.with(DocumentAuth.EXPLICIT_NONE, () => ({ .with(DocumentAuth.EXPLICIT_NONE, () => ({
type: DocumentAuth.EXPLICIT_NONE, type: DocumentAuth.EXPLICIT_NONE,
})) }))
.with(null, () => undefined) .with(undefined, () => undefined)
.with( .with(
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH), P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD),
// This is a bit dirty, but the sentinel value used here is incredibly short-lived. // This is a bit dirty, but the sentinel value used here is incredibly short-lived.
() => 'NOT_SUPPORTED' as const, () => 'NOT_SUPPORTED' as const,
) )

View File

@ -92,8 +92,6 @@ export const DocumentSigningCompleteDialog = ({
}; };
const onFormSubmit = async (data: TNextSignerFormSchema) => { const onFormSubmit = async (data: TNextSignerFormSchema) => {
console.log('data', data);
console.log('form.formState.errors', form.formState.errors);
try { try {
if (allowDictateNextSigner && data.name && data.email) { if (allowDictateNextSigner && data.name && data.email) {
await onSignatureComplete({ name: data.name, email: data.email }); await onSignatureComplete({ name: data.name, email: data.email });

View File

@ -183,8 +183,8 @@ export const DocumentEditForm = ({
title: data.title, title: data.title,
externalId: data.externalId || null, externalId: data.externalId || null,
visibility: data.visibility, visibility: data.visibility,
globalAccessAuth: data.globalAccessAuth ?? null, globalAccessAuth: data.globalAccessAuth ?? [],
globalActionAuth: data.globalActionAuth ?? null, globalActionAuth: data.globalActionAuth ?? [],
}, },
meta: { meta: {
timezone, timezone,
@ -229,7 +229,7 @@ export const DocumentEditForm = ({
recipients: data.signers.map((signer) => ({ recipients: data.signers.map((signer) => ({
...signer, ...signer,
// Explicitly set to null to indicate we want to remove auth if required. // Explicitly set to null to indicate we want to remove auth if required.
actionAuth: signer.actionAuth || null, actionAuth: signer.actionAuth ?? [],
})), })),
}), }),
]); ]);

View File

@ -81,11 +81,15 @@ export const DocumentHistorySheet = ({
* @param text The text to format * @param text The text to format
* @returns The formatted text * @returns The formatted text
*/ */
const formatGenericText = (text?: string | null) => { const formatGenericText = (text?: string | string[] | null): string => {
if (!text) { if (!text) {
return ''; return '';
} }
if (Array.isArray(text)) {
return text.map((t) => formatGenericText(t)).join(', ');
}
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' '); return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
}; };
@ -245,11 +249,19 @@ export const DocumentHistorySheet = ({
values={[ values={[
{ {
key: 'Old', key: 'Old',
value: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None', value: Array.isArray(data.from)
? data.from
.map((f) => DOCUMENT_AUTH_TYPES[f]?.value || 'None')
.join(', ')
: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None',
}, },
{ {
key: 'New', key: 'New',
value: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None', value: Array.isArray(data.to)
? data.to
.map((f) => DOCUMENT_AUTH_TYPES[f]?.value || 'None')
.join(', ')
: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None',
}, },
]} ]}
/> />

View File

@ -134,8 +134,8 @@ export const TemplateEditForm = ({
title: data.title, title: data.title,
externalId: data.externalId || null, externalId: data.externalId || null,
visibility: data.visibility, visibility: data.visibility,
globalAccessAuth: data.globalAccessAuth ?? null, globalAccessAuth: data.globalAccessAuth ?? [],
globalActionAuth: data.globalActionAuth ?? null, globalActionAuth: data.globalActionAuth ?? [],
}, },
meta: { meta: {
...data.meta, ...data.meta,

View File

@ -3,6 +3,7 @@ import { useLingui } from '@lingui/react';
import { FieldType, SigningStatus } from '@prisma/client'; import { FieldType, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { redirect } from 'react-router'; import { redirect } from 'react-router';
import { prop, sortBy } from 'remeda';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js'; import { UAParser } from 'ua-parser-js';
import { renderSVG } from 'uqr'; import { renderSVG } from 'uqr';
@ -133,18 +134,30 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
recipientAuth: recipient.authOptions, recipientAuth: recipient.authOptions,
}); });
let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth) const insertedAuditLogsWithFieldAuth = sortBy(
auditLogs.DOCUMENT_FIELD_INSERTED.filter(
(log) => log.data.recipientId === recipient.id && log.data.fieldSecurity,
),
[prop('createdAt'), 'desc'],
);
const actionAuthMethod = insertedAuditLogsWithFieldAuth.at(0)?.data?.fieldSecurity?.type;
let authLevel = match(actionAuthMethod)
.with('ACCOUNT', () => _(msg`Account Re-Authentication`)) .with('ACCOUNT', () => _(msg`Account Re-Authentication`))
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`)) .with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`))
.with('PASSWORD', () => _(msg`Password Re-Authentication`))
.with('PASSKEY', () => _(msg`Passkey Re-Authentication`)) .with('PASSKEY', () => _(msg`Passkey Re-Authentication`))
.with('EXPLICIT_NONE', () => _(msg`Email`)) .with('EXPLICIT_NONE', () => _(msg`Email`))
.with(null, () => null) .with(undefined, () => null)
.exhaustive(); .exhaustive();
if (!authLevel) { if (!authLevel) {
authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth) const accessAuthMethod = extractedAuthMethods.derivedRecipientAccessAuth.at(0);
authLevel = match(accessAuthMethod)
.with('ACCOUNT', () => _(msg`Account Authentication`)) .with('ACCOUNT', () => _(msg`Account Authentication`))
.with(null, () => _(msg`Email`)) .with(undefined, () => _(msg`Email`))
.exhaustive(); .exhaustive();
} }

View File

@ -47,9 +47,9 @@ export async function loader({ params, request }: Route.LoaderArgs) {
}); });
// Ensure typesafety when we add more options. // Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth) const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user)) .with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user))
.with(null, () => true) .with(undefined, () => true)
.exhaustive(); .exhaustive();
if (!isAccessAuthValid) { if (!isAccessAuthValid) {

View File

@ -68,9 +68,9 @@ export async function loader({ params, request }: Route.LoaderArgs) {
}), }),
]); ]);
const isAccessAuthValid = match(derivedRecipientAccessAuth) const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => user !== null) .with(DocumentAccessAuth.ACCOUNT, () => !!user)
.with(null, () => true) .with(undefined, () => true)
.exhaustive(); .exhaustive();
if (!isAccessAuthValid) { if (!isAccessAuthValid) {

View File

@ -81,9 +81,9 @@ export async function loader({ params, request }: Route.LoaderArgs) {
documentAuth: document.authOptions, documentAuth: document.authOptions,
}); });
const isAccessAuthValid = match(derivedRecipientAccessAuth) const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => user !== null) .with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
.with(null, () => true) .with(undefined, () => true)
.exhaustive(); .exhaustive();
if (!isAccessAuthValid) { if (!isAccessAuthValid) {

View File

@ -38,8 +38,6 @@ export async function loader({ request }: Route.LoaderArgs) {
const recipient = await getRecipientByToken({ token }); const recipient = await getRecipientByToken({ token });
console.log('document', document.id);
return { document, recipient }; return { document, recipient };
}), }),
); );

View File

@ -821,7 +821,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
name, name,
role, role,
signingOrder, signingOrder,
actionAuth: authOptions?.actionAuth ?? null, actionAuth: authOptions?.actionAuth ?? [],
}, },
], ],
requestMetadata: metadata, requestMetadata: metadata,
@ -888,7 +888,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
name, name,
role, role,
signingOrder, signingOrder,
actionAuth: authOptions?.actionAuth, actionAuth: authOptions?.actionAuth ?? [],
requestMetadata: metadata.requestMetadata, requestMetadata: metadata.requestMetadata,
}).catch(() => null); }).catch(() => null);

View File

@ -177,8 +177,8 @@ export const ZCreateDocumentMutationSchema = z.object({
.default({}), .default({}),
authOptions: z authOptions: z
.object({ .object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(), globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(), globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
}) })
.optional() .optional()
.openapi({ .openapi({
@ -237,8 +237,8 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
.optional(), .optional(),
authOptions: z authOptions: z
.object({ .object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(), globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(), globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
}) })
.optional(), .optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
@ -310,8 +310,8 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
.optional(), .optional(),
authOptions: z authOptions: z
.object({ .object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(), globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(), globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
}) })
.optional(), .optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
@ -350,7 +350,7 @@ export const ZCreateRecipientMutationSchema = z.object({
signingOrder: z.number().nullish(), signingOrder: z.number().nullish(),
authOptions: z authOptions: z
.object({ .object({
actionAuth: ZRecipientActionAuthTypesSchema.optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}) })
.optional() .optional()
.openapi({ .openapi({

View File

@ -42,8 +42,8 @@ test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page
{ {
createDocumentOptions: { createDocumentOptions: {
authOptions: createDocumentAuthOptions({ authOptions: createDocumentAuthOptions({
globalAccessAuth: 'ACCOUNT', globalAccessAuth: ['ACCOUNT'],
globalActionAuth: null, globalActionAuth: [],
}), }),
}, },
}, },

View File

@ -65,8 +65,8 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa
recipients: [recipientWithAccount], recipients: [recipientWithAccount],
updateDocumentOptions: { updateDocumentOptions: {
authOptions: createDocumentAuthOptions({ authOptions: createDocumentAuthOptions({
globalAccessAuth: null, globalAccessAuth: [],
globalActionAuth: 'ACCOUNT', globalActionAuth: ['ACCOUNT'],
}), }),
}, },
}); });
@ -116,8 +116,8 @@ test.skip('[DOCUMENT_AUTH]: should deny signing document when required for globa
recipients: [recipientWithAccount], recipients: [recipientWithAccount],
updateDocumentOptions: { updateDocumentOptions: {
authOptions: createDocumentAuthOptions({ authOptions: createDocumentAuthOptions({
globalAccessAuth: null, globalAccessAuth: [],
globalActionAuth: 'ACCOUNT', globalActionAuth: ['ACCOUNT'],
}), }),
}, },
}); });
@ -147,8 +147,8 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth'
recipients: [recipientWithAccount, seedTestEmail()], recipients: [recipientWithAccount, seedTestEmail()],
updateDocumentOptions: { updateDocumentOptions: {
authOptions: createDocumentAuthOptions({ authOptions: createDocumentAuthOptions({
globalAccessAuth: null, globalAccessAuth: [],
globalActionAuth: 'ACCOUNT', globalActionAuth: ['ACCOUNT'],
}), }),
}, },
}); });
@ -193,20 +193,20 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
recipientsCreateOptions: [ recipientsCreateOptions: [
{ {
authOptions: createRecipientAuthOptions({ authOptions: createRecipientAuthOptions({
accessAuth: null, accessAuth: [],
actionAuth: null, actionAuth: [],
}), }),
}, },
{ {
authOptions: createRecipientAuthOptions({ authOptions: createRecipientAuthOptions({
accessAuth: null, accessAuth: [],
actionAuth: 'EXPLICIT_NONE', actionAuth: ['EXPLICIT_NONE'],
}), }),
}, },
{ {
authOptions: createRecipientAuthOptions({ authOptions: createRecipientAuthOptions({
accessAuth: null, accessAuth: [],
actionAuth: 'ACCOUNT', actionAuth: ['ACCOUNT'],
}), }),
}, },
], ],
@ -218,7 +218,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
// This document has no global action auth, so only account should require auth. // This document has no global action auth, so only account should require auth.
const isAuthRequired = actionAuth === 'ACCOUNT'; const isAuthRequired = actionAuth.includes('ACCOUNT');
const signUrl = `/sign/${token}`; const signUrl = `/sign/${token}`;
@ -292,28 +292,28 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
recipientsCreateOptions: [ recipientsCreateOptions: [
{ {
authOptions: createRecipientAuthOptions({ authOptions: createRecipientAuthOptions({
accessAuth: null, accessAuth: [],
actionAuth: null, actionAuth: [],
}), }),
}, },
{ {
authOptions: createRecipientAuthOptions({ authOptions: createRecipientAuthOptions({
accessAuth: null, accessAuth: [],
actionAuth: 'EXPLICIT_NONE', actionAuth: ['EXPLICIT_NONE'],
}), }),
}, },
{ {
authOptions: createRecipientAuthOptions({ authOptions: createRecipientAuthOptions({
accessAuth: null, accessAuth: [],
actionAuth: 'ACCOUNT', actionAuth: ['ACCOUNT'],
}), }),
}, },
], ],
fields: [FieldType.DATE, FieldType.SIGNATURE], fields: [FieldType.DATE, FieldType.SIGNATURE],
updateDocumentOptions: { updateDocumentOptions: {
authOptions: createDocumentAuthOptions({ authOptions: createDocumentAuthOptions({
globalAccessAuth: null, globalAccessAuth: [],
globalActionAuth: 'ACCOUNT', globalActionAuth: ['ACCOUNT'],
}), }),
}, },
}); });
@ -323,7 +323,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
// This document HAS global action auth, so account and inherit should require auth. // This document HAS global action auth, so account and inherit should require auth.
const isAuthRequired = actionAuth === 'ACCOUNT' || actionAuth === null; const isAuthRequired = actionAuth.includes('ACCOUNT') || actionAuth.length === 0;
const signUrl = `/sign/${token}`; const signUrl = `/sign/${token}`;

View File

@ -40,7 +40,7 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth. // Set EE action auth.
await page.getByTestId('documentActionSelectValue').click(); await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click(); await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step. // Save the settings by going to the next step.
@ -82,7 +82,7 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth. // Set EE action auth.
await page.getByTestId('documentActionSelectValue').click(); await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click(); await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step. // Save the settings by going to the next step.
@ -143,7 +143,7 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
// Set access auth. // Set access auth.
await page.getByTestId('documentAccessSelectValue').click(); await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('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 NOT be visible.

View File

@ -37,7 +37,7 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth. // Set EE action auth.
await page.getByTestId('documentActionSelectValue').click(); await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click(); await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step. // Save the settings by going to the next step.
@ -79,7 +79,7 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth. // Set EE action auth.
await page.getByTestId('documentActionSelectValue').click(); await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click(); await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step. // Save the settings by going to the next step.
@ -140,7 +140,7 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
// Set access auth. // Set access auth.
await page.getByTestId('documentAccessSelectValue').click(); await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('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 NOT be visible.

View File

@ -58,8 +58,8 @@ test.describe('[EE_ONLY]', () => {
// Add advanced settings for a single recipient. // Add advanced settings for a single recipient.
await page.getByLabel('Show advanced settings').check(); await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click(); await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').click(); await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
// Navigate to the next step and back. // Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();

View File

@ -48,13 +48,13 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
// Set template document access. // Set template document access.
await page.getByTestId('documentAccessSelectValue').click(); await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('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');
// Set EE action auth. // Set EE action auth.
if (isBillingEnabled) { if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click(); await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click(); await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
} }
@ -85,8 +85,8 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
// Apply require passkey for Recipient 1. // Apply require passkey for Recipient 1.
if (isBillingEnabled) { if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check(); await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click(); await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').click(); await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
} }
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@ -119,10 +119,12 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
}); });
expect(document.title).toEqual('TEMPLATE_TITLE'); expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT'); expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null, if (isBillingEnabled) {
); expect(documentAuth.documentAuthOption.globalActionAuth).toContain('PASSKEY');
}
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a'); expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE'); expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com'); expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
@ -143,11 +145,11 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
}); });
if (isBillingEnabled) { if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY'); expect(recipientOneAuth.derivedRecipientActionAuth).toContain('PASSKEY');
} }
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); expect(recipientOneAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); expect(recipientTwoAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
}); });
/** /**
@ -183,13 +185,13 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
// Set template document access. // Set template document access.
await page.getByTestId('documentAccessSelectValue').click(); await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('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');
// Set EE action auth. // Set EE action auth.
if (isBillingEnabled) { if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click(); await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click(); await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
} }
@ -220,8 +222,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
// Apply require passkey for Recipient 1. // Apply require passkey for Recipient 1.
if (isBillingEnabled) { if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check(); await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click(); await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').click(); await page.getByRole('option').filter({ hasText: 'Require passkey' }).click();
} }
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@ -256,10 +258,12 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
}); });
expect(document.title).toEqual('TEMPLATE_TITLE'); expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT'); expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null, if (isBillingEnabled) {
); expect(documentAuth.documentAuthOption.globalActionAuth).toContain('PASSKEY');
}
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a'); expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE'); expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com'); expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
@ -280,11 +284,11 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
}); });
if (isBillingEnabled) { if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY'); expect(recipientOneAuth.derivedRecipientActionAuth).toContain('PASSKEY');
} }
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); expect(recipientOneAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); expect(recipientTwoAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
}); });
/** /**

View File

@ -172,8 +172,8 @@ test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) =>
userId: user.id, userId: user.id,
createTemplateOptions: { createTemplateOptions: {
authOptions: createDocumentAuthOptions({ authOptions: createDocumentAuthOptions({
globalAccessAuth: 'ACCOUNT', globalAccessAuth: ['ACCOUNT'],
globalActionAuth: null, globalActionAuth: [],
}), }),
}, },
}); });

View File

@ -19,6 +19,10 @@ export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
key: DocumentAuth.TWO_FACTOR_AUTH, key: DocumentAuth.TWO_FACTOR_AUTH,
value: 'Require 2FA', value: 'Require 2FA',
}, },
[DocumentAuth.PASSWORD]: {
key: DocumentAuth.PASSWORD,
value: 'Require password',
},
[DocumentAuth.EXPLICIT_NONE]: { [DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE, key: DocumentAuth.EXPLICIT_NONE,
value: 'None (Overrides global settings)', value: 'None (Overrides global settings)',

View File

@ -0,0 +1,20 @@
import { compare } from '@node-rs/bcrypt';
import { prisma } from '@documenso/prisma';
type VerifyPasswordOptions = {
userId: number;
password: string;
};
export const verifyPassword = async ({ userId, password }: VerifyPasswordOptions) => {
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user || !user.password) {
return false;
}
return await compare(password, user.password);
};

View File

@ -23,6 +23,7 @@ import {
ZWebhookDocumentSchema, ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload, mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload'; } from '../../types/webhook-payload';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn'; import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendPendingEmail } from './send-pending-email'; import { sendPendingEmail } from './send-pending-email';
@ -140,6 +141,11 @@ export const completeDocumentWithToken = async ({
}, },
}); });
const authOptions = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
@ -154,6 +160,7 @@ export const completeDocumentWithToken = async ({
recipientName: recipient.name, recipientName: recipient.name,
recipientId: recipient.id, recipientId: recipient.id,
recipientRole: recipient.role, recipientRole: recipient.role,
actionAuth: authOptions.derivedRecipientActionAuth,
}, },
}), }),
}); });

View File

@ -39,8 +39,8 @@ export type CreateDocumentOptions = {
title: string; title: string;
externalId?: string; externalId?: string;
visibility?: DocumentVisibility; visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes; globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes; globalActionAuth?: TDocumentActionAuthTypes[];
formValues?: TDocumentFormValues; formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients']; recipients: TCreateDocumentV2Request['recipients'];
}; };
@ -113,14 +113,16 @@ export const createDocumentV2 = async ({
} }
const authOptions = createDocumentAuthOptions({ const authOptions = createDocumentAuthOptions({
globalAccessAuth: data?.globalAccessAuth || null, globalAccessAuth: data?.globalAccessAuth || [],
globalActionAuth: data?.globalActionAuth || null, globalActionAuth: data?.globalActionAuth || [],
}); });
const recipientsHaveActionAuth = data.recipients?.some((recipient) => recipient.actionAuth); const recipientsHaveActionAuth = data.recipients?.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth. // Check if user has permission to set the global action auth.
if (authOptions.globalActionAuth || recipientsHaveActionAuth) { if (authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({ const isDocumentEnterprise = await isUserEnterprise({
userId, userId,
teamId, teamId,
@ -171,8 +173,8 @@ export const createDocumentV2 = async ({
await Promise.all( await Promise.all(
(data.recipients || []).map(async (recipient) => { (data.recipients || []).map(async (recipient) => {
const recipientAuthOptions = createRecipientAuthOptions({ const recipientAuthOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null, accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth || null, actionAuth: recipient.actionAuth ?? [],
}); });
await tx.recipient.create({ await tx.recipient.create({

View File

@ -17,6 +17,7 @@ export const getDocumentCertificateAuditLogs = async ({
in: [ in: [
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
], ],
@ -36,6 +37,9 @@ export const getDocumentCertificateAuditLogs = async ({
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter( [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
), ),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
),
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter( [DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter(
(log) => (log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT &&

View File

@ -5,6 +5,7 @@ import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token'; import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
import { verifyPassword } from '../2fa/verify-password';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth'; import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth'; import { DocumentAuth } from '../../types/document-auth';
@ -60,23 +61,26 @@ export const isRecipientAuthorized = async ({
recipientAuth: recipient.authOptions, recipientAuth: recipient.authOptions,
}); });
const authMethod: TDocumentAuth | null = const authMethods: TDocumentAuth[] =
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth; type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
// Early true return when auth is not required. // Early true return when auth is not required.
if (!authMethod || authMethod === DocumentAuth.EXPLICIT_NONE) { if (
authMethods.length === 0 ||
authMethods.some((method) => method === DocumentAuth.EXPLICIT_NONE)
) {
return true; return true;
} }
// Create auth options when none are passed for account. // Create auth options when none are passed for account.
if (!authOptions && authMethod === DocumentAuth.ACCOUNT) { if (!authOptions && authMethods.some((method) => method === DocumentAuth.ACCOUNT)) {
authOptions = { authOptions = {
type: DocumentAuth.ACCOUNT, type: DocumentAuth.ACCOUNT,
}; };
} }
// Authentication required does not match provided method. // Authentication required does not match provided method.
if (!authOptions || authOptions.type !== authMethod || !userId) { if (!authOptions || !authMethods.includes(authOptions.type) || !userId) {
return false; return false;
} }
@ -117,6 +121,15 @@ export const isRecipientAuthorized = async ({
window: 10, // 5 minutes worth of tokens window: 10, // 5 minutes worth of tokens
}); });
}) })
.with({ type: DocumentAuth.PASSWORD }, async ({ password }) => {
return await verifyPassword({
userId,
password,
});
})
.with({ type: DocumentAuth.EXPLICIT_NONE }, () => {
return true;
})
.exhaustive(); .exhaustive();
}; };
@ -160,7 +173,7 @@ const verifyPasskey = async ({
}: VerifyPasskeyOptions): Promise<void> => { }: VerifyPasskeyOptions): Promise<void> => {
const passkey = await prisma.passkey.findFirst({ const passkey = await prisma.passkey.findFirst({
where: { where: {
credentialId: Buffer.from(authenticationResponse.id, 'base64'), credentialId: new Uint8Array(Buffer.from(authenticationResponse.id, 'base64')),
userId, userId,
}, },
}); });

View File

@ -21,8 +21,8 @@ export type UpdateDocumentOptions = {
title?: string; title?: string;
externalId?: string | null; externalId?: string | null;
visibility?: DocumentVisibility | null; visibility?: DocumentVisibility | null;
globalAccessAuth?: TDocumentAccessAuthTypes | null; globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes | null; globalActionAuth?: TDocumentActionAuthTypes[];
useLegacyFieldInsertion?: boolean; useLegacyFieldInsertion?: boolean;
}; };
requestMetadata: ApiRequestMetadata; requestMetadata: ApiRequestMetadata;
@ -119,7 +119,6 @@ export const updateDocument = async ({
// If no data just return the document since this function is normally chained after a meta update. // If no data just return the document since this function is normally chained after a meta update.
if (!data || Object.values(data).length === 0) { if (!data || Object.values(data).length === 0) {
console.log('no data');
return document; return document;
} }
@ -137,7 +136,7 @@ export const updateDocument = 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) { if (newGlobalActionAuth && newGlobalActionAuth.length > 0) {
const isDocumentEnterprise = await isUserEnterprise({ const isDocumentEnterprise = await isUserEnterprise({
userId, userId,
teamId, teamId,

View File

@ -3,7 +3,6 @@ import { FieldType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TRecipientActionAuth } from '../../types/document-auth'; import type { TRecipientActionAuth } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { isRecipientAuthorized } from './is-recipient-authorized'; import { isRecipientAuthorized } from './is-recipient-authorized';
export type ValidateFieldAuthOptions = { export type ValidateFieldAuthOptions = {
@ -26,14 +25,9 @@ export const validateFieldAuth = async ({
userId, userId,
authOptions, authOptions,
}: ValidateFieldAuthOptions) => { }: ValidateFieldAuthOptions) => {
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: documentAuthOptions,
recipientAuth: recipient.authOptions,
});
// Override all non-signature fields to not require any auth. // Override all non-signature fields to not require any auth.
if (field.type !== FieldType.SIGNATURE) { if (field.type !== FieldType.SIGNATURE) {
return null; return undefined;
} }
const isValid = await isRecipientAuthorized({ const isValid = await isRecipientAuthorized({
@ -50,5 +44,5 @@ export const validateFieldAuth = async ({
}); });
} }
return derivedRecipientActionAuth; return authOptions?.type;
}; };

View File

@ -15,7 +15,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type ViewedDocumentOptions = { export type ViewedDocumentOptions = {
token: string; token: string;
recipientAccessAuth?: TDocumentAccessAuthTypes | null; recipientAccessAuth?: TDocumentAccessAuthTypes[];
requestMetadata?: RequestMetadata; requestMetadata?: RequestMetadata;
}; };
@ -63,7 +63,7 @@ export const viewedDocument = async ({
recipientId: recipient.id, recipientId: recipient.id,
recipientName: recipient.name, recipientName: recipient.name,
recipientRole: recipient.role, recipientRole: recipient.role,
accessAuth: recipientAccessAuth || undefined, accessAuth: recipientAccessAuth ?? [],
}, },
}), }),
}); });

View File

@ -27,7 +27,6 @@ export const createEmbeddingPresignToken = async ({
// In development mode, allow setting expiresIn to 0 for testing // In development mode, allow setting expiresIn to 0 for testing
// In production, enforce a minimum expiration time // In production, enforce a minimum expiration time
const isDevelopment = env('NODE_ENV') !== 'production'; const isDevelopment = env('NODE_ENV') !== 'production';
console.log('isDevelopment', isDevelopment);
const minExpirationMinutes = isDevelopment ? 0 : 5; const minExpirationMinutes = isDevelopment ? 0 : 5;
// Ensure expiresIn is at least the minimum allowed value // Ensure expiresIn is at least the minimum allowed value

View File

@ -22,8 +22,8 @@ export interface CreateDocumentRecipientsOptions {
name: string; name: string;
role: RecipientRole; role: RecipientRole;
signingOrder?: number | null; signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null; accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes | null; actionAuth?: TRecipientActionAuthTypes[];
}[]; }[];
requestMetadata: ApiRequestMetadata; requestMetadata: ApiRequestMetadata;
} }
@ -71,7 +71,9 @@ export const createDocumentRecipients = async ({
}); });
} }
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth); const recipientsHaveActionAuth = recipientsToCreate.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth. // Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) { if (recipientsHaveActionAuth) {
@ -110,8 +112,8 @@ export const createDocumentRecipients = async ({
return await Promise.all( return await Promise.all(
normalizedRecipients.map(async (recipient) => { normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({ const authOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null, accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth || null, actionAuth: recipient.actionAuth ?? [],
}); });
const createdRecipient = await tx.recipient.create({ const createdRecipient = await tx.recipient.create({
@ -140,8 +142,8 @@ export const createDocumentRecipients = async ({
recipientName: createdRecipient.name, recipientName: createdRecipient.name,
recipientId: createdRecipient.id, recipientId: createdRecipient.id,
recipientRole: createdRecipient.role, recipientRole: createdRecipient.role,
accessAuth: recipient.accessAuth || undefined, accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth || undefined, actionAuth: recipient.actionAuth ?? [],
}, },
}), }),
}); });

View File

@ -19,8 +19,8 @@ export interface CreateTemplateRecipientsOptions {
name: string; name: string;
role: RecipientRole; role: RecipientRole;
signingOrder?: number | null; signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null; accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes | null; actionAuth?: TRecipientActionAuthTypes[];
}[]; }[];
} }
@ -60,7 +60,9 @@ export const createTemplateRecipients = async ({
}); });
} }
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth); const recipientsHaveActionAuth = recipientsToCreate.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth. // Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) { if (recipientsHaveActionAuth) {
@ -99,8 +101,8 @@ export const createTemplateRecipients = async ({
return await Promise.all( return await Promise.all(
normalizedRecipients.map(async (recipient) => { normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({ const authOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth || null, accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth || null, actionAuth: recipient.actionAuth ?? [],
}); });
const createdRecipient = await tx.recipient.create({ const createdRecipient = await tx.recipient.create({

View File

@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
import type { Recipient } from '@prisma/client'; import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client'; import { SendStatus, SigningStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { mailer } from '@documenso/email/mailer'; import { mailer } from '@documenso/email/mailer';
@ -96,7 +97,9 @@ export const setDocumentRecipients = async ({
throw new Error('Document already complete'); throw new Error('Document already complete');
} }
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth. // Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) { if (recipientsHaveActionAuth) {
@ -245,8 +248,8 @@ export const setDocumentRecipients = async ({
metadata: requestMetadata, metadata: requestMetadata,
data: { data: {
...baseAuditLog, ...baseAuditLog,
accessAuth: recipient.accessAuth || undefined, accessAuth: recipient.accessAuth || [],
actionAuth: recipient.actionAuth || undefined, actionAuth: recipient.actionAuth || [],
}, },
}), }),
}); });
@ -361,8 +364,8 @@ type RecipientData = {
name: string; name: string;
role: RecipientRole; role: RecipientRole;
signingOrder?: number | null; signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null; accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes | null; actionAuth?: TRecipientActionAuthTypes[];
}; };
type RecipientDataWithClientId = Recipient & { type RecipientDataWithClientId = Recipient & {
@ -372,15 +375,15 @@ type RecipientDataWithClientId = Recipient & {
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => { const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
const newRecipientAccessAuth = newRecipientData.accessAuth || null; const newRecipientAccessAuth = newRecipientData.accessAuth || [];
const newRecipientActionAuth = newRecipientData.actionAuth || null; const newRecipientActionAuth = newRecipientData.actionAuth || [];
return ( return (
recipient.email !== newRecipientData.email || recipient.email !== newRecipientData.email ||
recipient.name !== newRecipientData.name || recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role || recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder || recipient.signingOrder !== newRecipientData.signingOrder ||
authOptions.accessAuth !== newRecipientAccessAuth || !isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
authOptions.actionAuth !== newRecipientActionAuth !isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
); );
}; };

View File

@ -26,7 +26,7 @@ export type SetTemplateRecipientsOptions = {
name: string; name: string;
role: RecipientRole; role: RecipientRole;
signingOrder?: number | null; signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes | null; actionAuth?: TRecipientActionAuthTypes[];
}[]; }[];
}; };
@ -64,7 +64,9 @@ export const setTemplateRecipients = async ({
throw new Error('Template not found'); throw new Error('Template not found');
} }
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth. // Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) { if (recipientsHaveActionAuth) {

View File

@ -1,6 +1,7 @@
import type { Recipient } from '@prisma/client'; import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client'; import { SendStatus, SigningStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -72,7 +73,9 @@ export const updateDocumentRecipients = async ({
}); });
} }
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth. // Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) { if (recipientsHaveActionAuth) {
@ -218,8 +221,8 @@ type RecipientData = {
name?: string; name?: string;
role?: RecipientRole; role?: RecipientRole;
signingOrder?: number | null; signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null; accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes | null; actionAuth?: TRecipientActionAuthTypes[];
}; };
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => { const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
@ -233,7 +236,7 @@ const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: Recipie
recipient.name !== newRecipientData.name || recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role || recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder || recipient.signingOrder !== newRecipientData.signingOrder ||
authOptions.accessAuth !== newRecipientAccessAuth || !isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
authOptions.actionAuth !== newRecipientActionAuth !isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
); );
}; };

View File

@ -20,7 +20,7 @@ export type UpdateRecipientOptions = {
name?: string; name?: string;
role?: RecipientRole; role?: RecipientRole;
signingOrder?: number | null; signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes | null; actionAuth?: TRecipientActionAuthTypes[];
userId: number; userId: number;
teamId?: number; teamId?: number;
requestMetadata?: RequestMetadata; requestMetadata?: RequestMetadata;
@ -90,7 +90,7 @@ export const updateRecipient = async ({
throw new Error('Recipient not found'); throw new Error('Recipient not found');
} }
if (actionAuth) { if (actionAuth && actionAuth.length > 0) {
const isDocumentEnterprise = await isUserEnterprise({ const isDocumentEnterprise = await isUserEnterprise({
userId, userId,
teamId, teamId,
@ -117,7 +117,7 @@ export const updateRecipient = async ({
signingOrder, signingOrder,
authOptions: createRecipientAuthOptions({ authOptions: createRecipientAuthOptions({
accessAuth: recipientAuthOptions.accessAuth, accessAuth: recipientAuthOptions.accessAuth,
actionAuth: actionAuth ?? null, actionAuth: actionAuth ?? [],
}), }),
}, },
}); });

View File

@ -22,8 +22,8 @@ export interface UpdateTemplateRecipientsOptions {
name?: string; name?: string;
role?: RecipientRole; role?: RecipientRole;
signingOrder?: number | null; signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes | null; accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes | null; actionAuth?: TRecipientActionAuthTypes[];
}[]; }[];
} }
@ -63,7 +63,9 @@ export const updateTemplateRecipients = async ({
}); });
} }
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth. // Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) { if (recipientsHaveActionAuth) {

View File

@ -70,7 +70,7 @@ export type CreateDocumentFromDirectTemplateOptions = {
type CreatedDirectRecipientField = { type CreatedDirectRecipientField = {
field: Field & { signature?: Signature | null }; field: Field & { signature?: Signature | null };
derivedRecipientActionAuth: TRecipientActionAuthTypes | null; derivedRecipientActionAuth?: TRecipientActionAuthTypes;
}; };
export const ZCreateDocumentFromDirectTemplateResponseSchema = z.object({ export const ZCreateDocumentFromDirectTemplateResponseSchema = z.object({
@ -151,9 +151,9 @@ export const createDocumentFromDirectTemplate = async ({
const directRecipientName = user?.name || initialDirectRecipientName; const directRecipientName = user?.name || initialDirectRecipientName;
// Ensure typesafety when we add more options. // Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth) const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail) .with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail)
.with(null, () => true) .with(undefined, () => true)
.exhaustive(); .exhaustive();
if (!isAccessAuthValid) { if (!isAccessAuthValid) {
@ -460,7 +460,7 @@ export const createDocumentFromDirectTemplate = async ({
const createdDirectRecipientFields: CreatedDirectRecipientField[] = [ const createdDirectRecipientFields: CreatedDirectRecipientField[] = [
...createdDirectRecipient.fields.map((field) => ({ ...createdDirectRecipient.fields.map((field) => ({
field, field,
derivedRecipientActionAuth: null, derivedRecipientActionAuth: undefined,
})), })),
...createdDirectRecipientSignatureFields, ...createdDirectRecipientSignatureFields,
]; ];
@ -567,6 +567,7 @@ export const createDocumentFromDirectTemplate = async ({
recipientId: createdDirectRecipient.id, recipientId: createdDirectRecipient.id,
recipientName: createdDirectRecipient.name, recipientName: createdDirectRecipient.name,
recipientRole: createdDirectRecipient.role, recipientRole: createdDirectRecipient.role,
actionAuth: createdDirectRecipient.authOptions?.actionAuth ?? [],
}, },
}), }),
]; ];

View File

@ -15,8 +15,8 @@ export type UpdateTemplateOptions = {
title?: string; title?: string;
externalId?: string | null; externalId?: string | null;
visibility?: DocumentVisibility; visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes | null; globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes | null; globalActionAuth?: TDocumentActionAuthTypes[];
publicTitle?: string; publicTitle?: string;
publicDescription?: string; publicDescription?: string;
type?: Template['type']; type?: Template['type'];
@ -74,7 +74,7 @@ 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) { if (newGlobalActionAuth && newGlobalActionAuth.length > 0) {
const isDocumentEnterprise = await isUserEnterprise({ const isDocumentEnterprise = await isUserEnterprise({
userId, userId,
teamId, teamId,

View File

@ -123,8 +123,8 @@ export const ZDocumentAuditLogFieldDiffSchema = z.union([
]); ]);
export const ZGenericFromToSchema = z.object({ export const ZGenericFromToSchema = z.object({
from: z.string().nullable(), from: z.union([z.string(), z.array(z.string())]).nullable(),
to: z.string().nullable(), to: z.union([z.string(), z.array(z.string())]).nullable(),
}); });
export const ZRecipientDiffActionAuthSchema = ZGenericFromToSchema.extend({ export const ZRecipientDiffActionAuthSchema = ZGenericFromToSchema.extend({
@ -296,7 +296,7 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
}, },
z z
.object({ .object({
type: ZRecipientActionAuthTypesSchema, type: ZRecipientActionAuthTypesSchema.optional(),
}) })
.optional(), .optional(),
), ),
@ -384,7 +384,7 @@ export const ZDocumentAuditLogEventDocumentFieldPrefilledSchema = z.object({
}, },
z z
.object({ .object({
type: ZRecipientActionAuthTypesSchema, type: ZRecipientActionAuthTypesSchema.optional(),
}) })
.optional(), .optional(),
), ),
@ -428,7 +428,13 @@ export const ZDocumentAuditLogEventDocumentMetaUpdatedSchema = z.object({
export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED), type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED),
data: ZBaseRecipientDataSchema.extend({ data: ZBaseRecipientDataSchema.extend({
accessAuth: z.string().optional(), accessAuth: z.preprocess((unknownValue) => {
if (!unknownValue) {
return [];
}
return Array.isArray(unknownValue) ? unknownValue : [unknownValue];
}, z.array(ZRecipientAccessAuthTypesSchema)),
}), }),
}); });
@ -438,7 +444,13 @@ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED), type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED),
data: ZBaseRecipientDataSchema.extend({ data: ZBaseRecipientDataSchema.extend({
actionAuth: z.string().optional(), actionAuth: z.preprocess((unknownValue) => {
if (!unknownValue) {
return [];
}
return Array.isArray(unknownValue) ? unknownValue : [unknownValue];
}, z.array(ZRecipientActionAuthTypesSchema)),
}), }),
}); });
@ -516,8 +528,20 @@ export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({
export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({ export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED), type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED),
data: ZBaseRecipientDataSchema.extend({ data: ZBaseRecipientDataSchema.extend({
accessAuth: ZRecipientAccessAuthTypesSchema.optional(), accessAuth: z.preprocess((unknownValue) => {
actionAuth: ZRecipientActionAuthTypesSchema.optional(), if (!unknownValue) {
return [];
}
return Array.isArray(unknownValue) ? unknownValue : [unknownValue];
}, z.array(ZRecipientAccessAuthTypesSchema)),
actionAuth: z.preprocess((unknownValue) => {
if (!unknownValue) {
return [];
}
return Array.isArray(unknownValue) ? unknownValue : [unknownValue];
}, z.array(ZRecipientActionAuthTypesSchema)),
}), }),
}); });

View File

@ -9,8 +9,10 @@ export const ZDocumentAuthTypesSchema = z.enum([
'ACCOUNT', 'ACCOUNT',
'PASSKEY', 'PASSKEY',
'TWO_FACTOR_AUTH', 'TWO_FACTOR_AUTH',
'PASSWORD',
'EXPLICIT_NONE', 'EXPLICIT_NONE',
]); ]);
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum; export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
const ZDocumentAuthAccountSchema = z.object({ const ZDocumentAuthAccountSchema = z.object({
@ -27,6 +29,11 @@ const ZDocumentAuthPasskeySchema = z.object({
tokenReference: z.string().min(1), tokenReference: z.string().min(1),
}); });
const ZDocumentAuthPasswordSchema = z.object({
type: z.literal(DocumentAuth.PASSWORD),
password: z.string().min(1),
});
const ZDocumentAuth2FASchema = z.object({ const ZDocumentAuth2FASchema = z.object({
type: z.literal(DocumentAuth.TWO_FACTOR_AUTH), type: z.literal(DocumentAuth.TWO_FACTOR_AUTH),
token: z.string().min(4).max(10), token: z.string().min(4).max(10),
@ -40,6 +47,7 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
ZDocumentAuthExplicitNoneSchema, ZDocumentAuthExplicitNoneSchema,
ZDocumentAuthPasskeySchema, ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema, ZDocumentAuth2FASchema,
ZDocumentAuthPasswordSchema,
]); ]);
/** /**
@ -61,9 +69,15 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema, ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema, ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema, ZDocumentAuth2FASchema,
ZDocumentAuthPasswordSchema,
]); ]);
export const ZDocumentActionAuthTypesSchema = z export const ZDocumentActionAuthTypesSchema = z
.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH]) .enum([
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
])
.describe( .describe(
'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.',
); );
@ -89,6 +103,7 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema, ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema, ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema, ZDocumentAuth2FASchema,
ZDocumentAuthPasswordSchema,
ZDocumentAuthExplicitNoneSchema, ZDocumentAuthExplicitNoneSchema,
]); ]);
export const ZRecipientActionAuthTypesSchema = z export const ZRecipientActionAuthTypesSchema = z
@ -96,6 +111,7 @@ export const ZRecipientActionAuthTypesSchema = z
DocumentAuth.ACCOUNT, DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY, DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
DocumentAuth.EXPLICIT_NONE, DocumentAuth.EXPLICIT_NONE,
]) ])
.describe('The type of authentication required for the recipient to sign the document.'); .describe('The type of authentication required for the recipient to sign the document.');
@ -110,18 +126,26 @@ export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum;
*/ */
export const ZDocumentAuthOptionsSchema = z.preprocess( export const ZDocumentAuthOptionsSchema = z.preprocess(
(unknownValue) => { (unknownValue) => {
if (unknownValue) { if (!unknownValue || typeof unknownValue !== 'object') {
return unknownValue; return {
globalAccessAuth: [],
globalActionAuth: [],
};
} }
const globalAccessAuth =
'globalAccessAuth' in unknownValue ? processAuthValue(unknownValue.globalAccessAuth) : [];
const globalActionAuth =
'globalActionAuth' in unknownValue ? processAuthValue(unknownValue.globalActionAuth) : [];
return { return {
globalAccessAuth: null, globalAccessAuth,
globalActionAuth: null, globalActionAuth,
}; };
}, },
z.object({ z.object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable(), globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable(), globalActionAuth: z.array(ZDocumentActionAuthTypesSchema),
}), }),
); );
@ -130,21 +154,46 @@ export const ZDocumentAuthOptionsSchema = z.preprocess(
*/ */
export const ZRecipientAuthOptionsSchema = z.preprocess( export const ZRecipientAuthOptionsSchema = z.preprocess(
(unknownValue) => { (unknownValue) => {
if (unknownValue) { if (!unknownValue || typeof unknownValue !== 'object') {
return unknownValue; return {
accessAuth: [],
actionAuth: [],
};
} }
const accessAuth =
'accessAuth' in unknownValue ? processAuthValue(unknownValue.accessAuth) : [];
const actionAuth =
'actionAuth' in unknownValue ? processAuthValue(unknownValue.actionAuth) : [];
return { return {
accessAuth: null, accessAuth,
actionAuth: null, actionAuth,
}; };
}, },
z.object({ z.object({
accessAuth: ZRecipientAccessAuthTypesSchema.nullable(), accessAuth: z.array(ZRecipientAccessAuthTypesSchema),
actionAuth: ZRecipientActionAuthTypesSchema.nullable(), actionAuth: z.array(ZRecipientActionAuthTypesSchema),
}), }),
); );
/**
* Utility function to process the auth value.
*
* Converts the old singular auth value to an array of auth values.
*/
const processAuthValue = (value: unknown) => {
if (value === null || value === undefined) {
return [];
}
if (Array.isArray(value)) {
return value;
}
return [value];
};
export type TDocumentAuth = z.infer<typeof ZDocumentAuthTypesSchema>; export type TDocumentAuth = z.infer<typeof ZDocumentAuthTypesSchema>;
export type TDocumentAuthMethods = z.infer<typeof ZDocumentAuthMethodsSchema>; export type TDocumentAuthMethods = z.infer<typeof ZDocumentAuthMethodsSchema>;
export type TDocumentAuthOptions = z.infer<typeof ZDocumentAuthOptionsSchema>; export type TDocumentAuthOptions = z.infer<typeof ZDocumentAuthOptionsSchema>;

View File

@ -2,6 +2,7 @@ import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@prisma/client'; import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import type { import type {
@ -106,7 +107,7 @@ export const diffRecipientChanges = (
const newActionAuth = const newActionAuth =
newAuthOptions?.actionAuth === undefined ? oldActionAuth : newAuthOptions.actionAuth; newAuthOptions?.actionAuth === undefined ? oldActionAuth : newAuthOptions.actionAuth;
if (oldAccessAuth !== newAccessAuth) { if (!isDeepEqual(oldAccessAuth, newAccessAuth)) {
diffs.push({ diffs.push({
type: RECIPIENT_DIFF_TYPE.ACCESS_AUTH, type: RECIPIENT_DIFF_TYPE.ACCESS_AUTH,
from: oldAccessAuth ?? '', from: oldAccessAuth ?? '',
@ -114,7 +115,7 @@ export const diffRecipientChanges = (
}); });
} }
if (oldActionAuth !== newActionAuth) { if (!isDeepEqual(oldActionAuth, newActionAuth)) {
diffs.push({ diffs.push({
type: RECIPIENT_DIFF_TYPE.ACTION_AUTH, type: RECIPIENT_DIFF_TYPE.ACTION_AUTH,
from: oldActionAuth ?? '', from: oldActionAuth ?? '',

View File

@ -27,17 +27,21 @@ export const extractDocumentAuthMethods = ({
const documentAuthOption = ZDocumentAuthOptionsSchema.parse(documentAuth); const documentAuthOption = ZDocumentAuthOptionsSchema.parse(documentAuth);
const recipientAuthOption = ZRecipientAuthOptionsSchema.parse(recipientAuth); const recipientAuthOption = ZRecipientAuthOptionsSchema.parse(recipientAuth);
const derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null = const derivedRecipientAccessAuth: TRecipientAccessAuthTypes[] =
recipientAuthOption.accessAuth || documentAuthOption.globalAccessAuth; recipientAuthOption.accessAuth.length > 0
? recipientAuthOption.accessAuth
: documentAuthOption.globalAccessAuth;
const derivedRecipientActionAuth: TRecipientActionAuthTypes | null = const derivedRecipientActionAuth: TRecipientActionAuthTypes[] =
recipientAuthOption.actionAuth || documentAuthOption.globalActionAuth; recipientAuthOption.actionAuth.length > 0
? recipientAuthOption.actionAuth
: documentAuthOption.globalActionAuth;
const recipientAccessAuthRequired = derivedRecipientAccessAuth !== null; const recipientAccessAuthRequired = derivedRecipientAccessAuth.length > 0;
const recipientActionAuthRequired = const recipientActionAuthRequired =
derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE && derivedRecipientActionAuth.length > 0 &&
derivedRecipientActionAuth !== null; !derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE);
return { return {
derivedRecipientAccessAuth, derivedRecipientAccessAuth,
@ -54,8 +58,8 @@ export const extractDocumentAuthMethods = ({
*/ */
export const createDocumentAuthOptions = (options: TDocumentAuthOptions): TDocumentAuthOptions => { export const createDocumentAuthOptions = (options: TDocumentAuthOptions): TDocumentAuthOptions => {
return { return {
globalAccessAuth: options?.globalAccessAuth ?? null, globalAccessAuth: options?.globalAccessAuth ?? [],
globalActionAuth: options?.globalActionAuth ?? null, globalActionAuth: options?.globalActionAuth ?? [],
}; };
}; };
@ -66,7 +70,7 @@ export const createRecipientAuthOptions = (
options: TRecipientAuthOptions, options: TRecipientAuthOptions,
): TRecipientAuthOptions => { ): TRecipientAuthOptions => {
return { return {
accessAuth: options?.accessAuth ?? null, accessAuth: options?.accessAuth ?? [],
actionAuth: options?.actionAuth ?? null, actionAuth: options?.actionAuth ?? [],
}; };
}; };

View File

@ -206,8 +206,8 @@ export const ZCreateDocumentV2RequestSchema = z.object({
title: ZDocumentTitleSchema, title: ZDocumentTitleSchema,
externalId: ZDocumentExternalIdSchema.optional(), externalId: ZDocumentExternalIdSchema.optional(),
visibility: ZDocumentVisibilitySchema.optional(), visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(), globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(), globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
formValues: ZDocumentFormValuesSchema.optional(), formValues: ZDocumentFormValuesSchema.optional(),
recipients: z recipients: z
.array( .array(

View File

@ -42,8 +42,8 @@ export const ZUpdateDocumentRequestSchema = z.object({
title: ZDocumentTitleSchema.optional(), title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(), externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(), visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullish(), globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullish(), globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
useLegacyFieldInsertion: z.boolean().optional(), useLegacyFieldInsertion: z.boolean().optional(),
}) })
.optional(), .optional(),

View File

@ -26,8 +26,8 @@ export const ZCreateRecipientSchema = z.object({
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), signingOrder: z.number().optional(),
accessAuth: ZRecipientAccessAuthTypesSchema.optional().nullable(), accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}); });
export const ZUpdateRecipientSchema = z.object({ export const ZUpdateRecipientSchema = z.object({
@ -36,8 +36,8 @@ export const ZUpdateRecipientSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
role: z.nativeEnum(RecipientRole).optional(), role: z.nativeEnum(RecipientRole).optional(),
signingOrder: z.number().optional(), signingOrder: z.number().optional(),
accessAuth: ZRecipientAccessAuthTypesSchema.optional().nullable(), accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}); });
export const ZCreateDocumentRecipientRequestSchema = z.object({ export const ZCreateDocumentRecipientRequestSchema = z.object({
@ -106,7 +106,7 @@ export const ZSetDocumentRecipientsRequestSchema = z
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), signingOrder: z.number().optional(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}), }),
), ),
}) })
@ -190,7 +190,7 @@ export const ZSetTemplateRecipientsRequestSchema = z
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), signingOrder: z.number().optional(),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}), }),
), ),
}) })

View File

@ -133,8 +133,8 @@ export const ZUpdateTemplateRequestSchema = z.object({
title: z.string().min(1).optional(), title: z.string().min(1).optional(),
externalId: z.string().nullish(), externalId: z.string().nullish(),
visibility: z.nativeEnum(DocumentVisibility).optional(), visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(), globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(), globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
publicTitle: z publicTitle: z
.string() .string()
.trim() .trim()

View File

@ -1,51 +1,75 @@
import { forwardRef } from 'react'; import React from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { SelectProps } from '@radix-ui/react-select';
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 { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
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 const DocumentGlobalAuthAccessSelect = forwardRef<HTMLButtonElement, SelectProps>( export interface DocumentGlobalAuthAccessSelectProps {
(props, ref) => { value?: string[];
const { _ } = useLingui(); defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
return ( export const DocumentGlobalAuthAccessSelect = ({
<Select {...props}> value,
<SelectTrigger ref={ref} className="bg-background text-muted-foreground"> defaultValue,
<SelectValue onValueChange,
data-testid="documentAccessSelectValue" disabled,
placeholder={_(msg`No restrictions`)} placeholder,
/> }: DocumentGlobalAuthAccessSelectProps) => {
</SelectTrigger> const { _ } = useLingui();
<SelectContent position="popper"> // Convert auth types to MultiSelect options
{/* Note: -1 is remapped in the Zod schema to the required value. */} const authOptions: Option[] = [
<SelectItem value={'-1'}> {
<Trans>No restrictions</Trans> value: '-1',
</SelectItem> label: _(msg`No restrictions`),
},
...Object.values(DocumentAccessAuth).map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
{Object.values(DocumentAccessAuth).map((authType) => ( // Convert string array to Option array for MultiSelect
<SelectItem key={authType} value={authType}> const selectedOptions =
{DOCUMENT_AUTH_TYPES[authType].value} (value
</SelectItem> ?.map((val) => authOptions.find((option) => option.value === val))
))} .filter(Boolean) as Option[]) || [];
</SelectContent>
</Select> // Convert default value to Option array
); const defaultOptions =
}, (defaultValue
); ?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
};
return (
<MultiSelect
value={selectedOptions}
defaultOptions={defaultOptions}
options={authOptions}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder || _(msg`Select access methods`)}
className="bg-background text-muted-foreground"
hideClearAllButton={false}
data-testid="documentAccessSelectValue"
/>
);
};
DocumentGlobalAuthAccessSelect.displayName = 'DocumentGlobalAuthAccessSelect'; DocumentGlobalAuthAccessSelect.displayName = 'DocumentGlobalAuthAccessSelect';
@ -63,7 +87,11 @@ export const DocumentGlobalAuthAccessTooltip = () => (
</h2> </h2>
<p> <p>
<Trans>The authentication required for recipients to view the document.</Trans> <Trans>The authentication methods required for recipients to view the document.</Trans>
</p>
<p className="mt-2">
<Trans>Multiple access methods can be selected.</Trans>
</p> </p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2"> <ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">

View File

@ -1,54 +1,75 @@
import { forwardRef } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { SelectProps } from '@radix-ui/react-select';
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 { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth';
import { import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
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 const DocumentGlobalAuthActionSelect = forwardRef<HTMLButtonElement, SelectProps>( export interface DocumentGlobalAuthActionSelectProps {
(props, ref) => { value?: string[];
const { _ } = useLingui(); defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
return ( export const DocumentGlobalAuthActionSelect = ({
<Select {...props}> value,
<SelectTrigger className="bg-background text-muted-foreground"> defaultValue,
<SelectValue onValueChange,
ref={ref} disabled,
data-testid="documentActionSelectValue" placeholder,
placeholder={_(msg`No restrictions`)} }: DocumentGlobalAuthActionSelectProps) => {
/> const { _ } = useLingui();
</SelectTrigger>
<SelectContent position="popper"> // Convert auth types to MultiSelect options
{/* Note: -1 is remapped in the Zod schema to the required value. */} const authOptions: Option[] = [
<SelectItem value={'-1'}> {
<Trans>No restrictions</Trans> value: '-1',
</SelectItem> label: _(msg`No restrictions`),
},
...Object.values(DocumentActionAuth)
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
.map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
{Object.values(DocumentActionAuth) // Convert string array to Option array for MultiSelect
.filter((auth) => auth !== DocumentAuth.ACCOUNT) const selectedOptions =
.map((authType) => ( (value
<SelectItem key={authType} value={authType}> ?.map((val) => authOptions.find((option) => option.value === val))
{DOCUMENT_AUTH_TYPES[authType].value} .filter(Boolean) as Option[]) || [];
</SelectItem>
))} // Convert default value to Option array
</SelectContent> const defaultOptions =
</Select> (defaultValue
); ?.map((val) => authOptions.find((option) => option.value === val))
}, .filter(Boolean) as Option[]) || [];
);
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
};
return (
<MultiSelect
value={selectedOptions}
defaultOptions={defaultOptions}
options={authOptions}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder || _(msg`Select authentication methods`)}
className="bg-background text-muted-foreground"
hideClearAllButton={false}
data-testid="documentActionSelectValue"
/>
);
};
DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect'; DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect';
@ -64,20 +85,19 @@ export const DocumentGlobalAuthActionTooltip = () => (
</h2> </h2>
<p> <p>
<Trans>The authentication required for recipients to sign the signature field.</Trans> <Trans>
The authentication methods required for recipients to sign the signature field.
</Trans>
</p> </p>
<p> <p>
<Trans> <Trans>
This can be overriden by setting the authentication requirements directly on each These can be overriden by setting the authentication requirements directly on each
recipient in the next step. recipient in the next step. Multiple methods can be selected.
</Trans> </Trans>
</p> </p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2"> <ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
{/* <li>
<strong>Require account</strong> - The recipient must be signed in
</li> */}
<li> <li>
<Trans> <Trans>
<strong>Require passkey</strong> - The recipient must have an account and passkey <strong>Require passkey</strong> - The recipient must have an account and passkey
@ -90,6 +110,14 @@ export const DocumentGlobalAuthActionTooltip = () => (
their settings their settings
</Trans> </Trans>
</li> </li>
<li>
<Trans>
<strong>Require password</strong> - The recipient must have an account and password
configured via their settings, the password will be verified during signing
</Trans>
</li>
<li> <li>
<Trans> <Trans>
<strong>No restrictions</strong> - No authentication required <strong>No restrictions</strong> - No authentication required

View File

@ -3,97 +3,124 @@ import React from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { SelectProps } from '@radix-ui/react-select';
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 { RecipientActionAuth } from '@documenso/lib/types/document-auth'; import { RecipientActionAuth } from '@documenso/lib/types/document-auth';
import { import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
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 type RecipientActionAuthSelectProps = SelectProps; export interface RecipientActionAuthSelectProps {
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
disabled?: boolean;
placeholder?: string;
}
export const RecipientActionAuthSelect = (props: RecipientActionAuthSelectProps) => { export const RecipientActionAuthSelect = ({
value,
defaultValue,
onValueChange,
disabled,
placeholder,
}: RecipientActionAuthSelectProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
// Convert auth types to MultiSelect options
const authOptions: Option[] = [
{
value: '-1',
label: _(msg`Inherit authentication method`),
},
...Object.values(RecipientActionAuth)
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
.map((authType) => ({
value: authType,
label: DOCUMENT_AUTH_TYPES[authType].value,
})),
];
// Convert string array to Option array for MultiSelect
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
// Convert default value to Option array
const defaultOptions =
(defaultValue
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
onValueChange?.(values);
};
return ( return (
<Select {...props}> <div className="relative">
<SelectTrigger className="bg-background text-muted-foreground"> <MultiSelect
<SelectValue placeholder={_(msg`Inherit authentication method`)} /> value={selectedOptions}
defaultOptions={defaultOptions}
options={authOptions}
onChange={handleChange}
disabled={disabled}
placeholder={placeholder || _(msg`Select authentication methods`)}
className="bg-background text-muted-foreground"
maxSelected={4} // Allow selecting up to 4 auth methods
hideClearAllButton={false}
/>
<Tooltip> <Tooltip>
<TooltipTrigger className="-mr-1 ml-auto"> <TooltipTrigger className="absolute right-2 top-1/2 -translate-y-1/2">
<InfoIcon className="mx-2 h-4 w-4" /> <InfoIcon className="h-4 w-4" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="text-foreground max-w-md p-4"> <TooltipContent className="text-foreground max-w-md p-4">
<h2> <h2>
<strong> <strong>
<Trans>Recipient action authentication</Trans> <Trans>Recipient action authentication</Trans>
</strong> </strong>
</h2> </h2>
<p> <p>
<Trans>The authentication required for recipients to sign fields</Trans> <Trans>The authentication methods required for recipients to sign fields</Trans>
</p> </p>
<p className="mt-2"> <p className="mt-2">
<Trans>This will override any global settings.</Trans> <Trans>
</p> These will override any global settings. Multiple methods can be selected.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2"> <ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li> <li>
<Trans> <Trans>
<strong>Inherit authentication method</strong> - Use the global action signing <strong>Inherit authentication method</strong> - Use the global action signing
authentication method configured in the "General Settings" step authentication method configured in the "General Settings" step
</Trans> </Trans>
</li> </li>
{/* <li> <li>
<strong>Require account</strong> - The recipient must be <Trans>
signed in <strong>Require passkey</strong> - The recipient must have an account and passkey
</li> */} configured via their settings
<li> </Trans>
<Trans> </li>
<strong>Require passkey</strong> - The recipient must have an account and passkey <li>
configured via their settings <Trans>
</Trans> <strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled
</li> via their settings
<li> </Trans>
<Trans> </li>
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled <li>
via their settings <Trans>
</Trans> <strong>None</strong> - No authentication required
</li> </Trans>
<li> </li>
<Trans> </ul>
<strong>None</strong> - No authentication required </TooltipContent>
</Trans> </Tooltip>
</li> </div>
</ul>
</TooltipContent>
</Tooltip>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value="-1">
<Trans>Inherit authentication method</Trans>
</SelectItem>
{Object.values(RecipientActionAuth)
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
); );
}; };

View File

@ -98,8 +98,8 @@ export const AddSettingsFormPartial = ({
title: document.title, title: document.title,
externalId: document.externalId || '', externalId: document.externalId || '',
visibility: document.visibility || '', visibility: document.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined, globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || undefined, globalActionAuth: documentAuthOption?.globalActionAuth || [],
meta: { meta: {
timezone: timezone:
@ -131,6 +131,12 @@ export const AddSettingsFormPartial = ({
) )
.otherwise(() => false); .otherwise(() => false);
const onFormSubmit = form.handleSubmit(onSubmit);
const onGoNextClick = () => {
void onFormSubmit().catch(console.error);
};
// We almost always want to set the timezone to the user's local timezone to avoid confusion // We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed. // when the document is signed.
useEffect(() => { useEffect(() => {
@ -214,7 +220,11 @@ export const AddSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select {...field} onValueChange={field.onChange}> <Select
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background"> <SelectTrigger className="bg-background">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@ -244,7 +254,11 @@ export const AddSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<DocumentGlobalAuthAccessSelect {...field} onValueChange={field.onChange} /> <DocumentGlobalAuthAccessSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@ -286,7 +300,11 @@ export const AddSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} /> <DocumentGlobalAuthActionSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@ -370,7 +388,7 @@ export const AddSettingsFormPartial = ({
<FormControl> <FormControl>
<Select <Select
{...field} value={field.value}
onValueChange={field.onChange} onValueChange={field.onChange}
disabled={documentHasBeenSent} disabled={documentHasBeenSent}
> >
@ -406,7 +424,7 @@ export const AddSettingsFormPartial = ({
<Combobox <Combobox
className="bg-background" className="bg-background"
options={TIME_ZONES} options={TIME_ZONES}
{...field} value={field.value}
onChange={(value) => value && field.onChange(value)} onChange={(value) => value && field.onChange(value)}
disabled={documentHasBeenSent} disabled={documentHasBeenSent}
/> />
@ -461,7 +479,7 @@ export const AddSettingsFormPartial = ({
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
canGoBack={stepIndex !== 0} canGoBack={stepIndex !== 0}
onGoBackClick={previousStep} onGoBackClick={previousStep}
onGoNextClick={form.handleSubmit(onSubmit)} onGoNextClick={onGoNextClick}
/> />
</DocumentFlowFormContainerFooter> </DocumentFlowFormContainerFooter>
</> </>

View File

@ -16,17 +16,6 @@ import {
ZDocumentMetaTimezoneSchema, ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema'; } from '@documenso/trpc/server/document-router/schema';
export const ZMapNegativeOneToUndefinedSchema = z
.string()
.optional()
.transform((val) => {
if (val === '-1') {
return undefined;
}
return val;
});
export const ZAddSettingsFormSchema = z.object({ export const ZAddSettingsFormSchema = z.object({
title: z title: z
.string() .string()
@ -34,12 +23,8 @@ export const ZAddSettingsFormSchema = z.object({
.min(1, { message: msg`Title cannot be empty`.id }), .min(1, { message: msg`Title cannot be empty`.id }),
externalId: z.string().optional(), externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(), visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe( globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema),
ZDocumentAccessAuthTypesSchema.optional(), globalActionAuth: z.array(ZDocumentActionAuthTypesSchema),
),
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentActionAuthTypesSchema.optional(),
),
meta: z.object({ meta: z.object({
timezone: ZDocumentMetaTimezoneSchema.optional().default(DEFAULT_DOCUMENT_TIME_ZONE), timezone: ZDocumentMetaTimezoneSchema.optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: ZDocumentMetaDateFormatSchema.optional().default(DEFAULT_DOCUMENT_DATE_FORMAT), dateFormat: ZDocumentMetaDateFormatSchema.optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),

View File

@ -85,7 +85,7 @@ export const AddSignersFormPartial = ({
email: '', email: '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
signingOrder: 1, signingOrder: 1,
actionAuth: undefined, actionAuth: [],
}, },
]; ];
@ -119,10 +119,14 @@ export const AddSignersFormPartial = ({
const recipientHasAuthOptions = recipients.find((recipient) => { const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth; return (
recipientAuthOptions.accessAuth.length > 0 || recipientAuthOptions.actionAuth.length > 0
);
}); });
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth); const formHasActionAuth = form
.getValues('signers')
.find((signer) => signer.actionAuth.length > 0);
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined; return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]); }, [recipients, form]);
@ -190,7 +194,7 @@ export const AddSignersFormPartial = ({
name: '', name: '',
email: '', email: '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
actionAuth: undefined, actionAuth: [],
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
}); });
}; };
@ -226,7 +230,7 @@ export const AddSignersFormPartial = ({
name: user?.name ?? '', name: user?.name ?? '',
email: user?.email ?? '', email: user?.email ?? '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
actionAuth: undefined, actionAuth: [],
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
}); });
} }

View File

@ -4,8 +4,6 @@ import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
export const ZAddSignersFormSchema = z export const ZAddSignersFormSchema = z
.object({ .object({
signers: z.array( signers: z.array(
@ -19,9 +17,7 @@ export const ZAddSignersFormSchema = z
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe( actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
ZRecipientActionAuthTypesSchema.optional(),
),
}), }),
), ),
signingOrder: z.nativeEnum(DocumentSigningOrder), signingOrder: z.nativeEnum(DocumentSigningOrder),

View File

@ -78,6 +78,8 @@ interface MultiSelectProps {
>; >;
/** hide the clear all button. */ /** hide the clear all button. */
hideClearAllButton?: boolean; hideClearAllButton?: boolean;
/** test id for the select value. */
'data-testid'?: string;
} }
export interface MultiSelectRef { export interface MultiSelectRef {
@ -170,6 +172,7 @@ const MultiSelect = ({
commandProps, commandProps,
inputProps, inputProps,
hideClearAllButton = false, hideClearAllButton = false,
'data-testid': dataTestId,
}: MultiSelectProps) => { }: MultiSelectProps) => {
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
@ -403,6 +406,7 @@ const MultiSelect = ({
commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch
} // When onSearch is provided, we don&lsquo;t want to filter the options. You can still override it. } // When onSearch is provided, we don&lsquo;t want to filter the options. You can still override it.
filter={commandFilter()} filter={commandFilter()}
data-testid={dataTestId}
> >
<div <div
className={cn( className={cn(

View File

@ -86,7 +86,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
{ {
formId: initialId, formId: initialId,
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
actionAuth: undefined, actionAuth: [],
...generateRecipientPlaceholder(1), ...generateRecipientPlaceholder(1),
signingOrder: 1, signingOrder: 1,
}, },
@ -136,10 +136,14 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const recipientHasAuthOptions = recipients.find((recipient) => { const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth; return (
recipientAuthOptions.accessAuth.length > 0 || recipientAuthOptions.actionAuth.length > 0
);
}); });
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth); const formHasActionAuth = form
.getValues('signers')
.find((signer) => signer.actionAuth.length > 0);
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined; return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]); }, [recipients, form]);
@ -179,6 +183,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
email: user.email ?? '', email: user.email ?? '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
actionAuth: [],
}); });
}; };
@ -188,6 +193,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
...generateRecipientPlaceholder(placeholderRecipientCount), ...generateRecipientPlaceholder(placeholderRecipientCount),
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
actionAuth: [],
}); });
setPlaceholderRecipientCount((count) => count + 1); setPlaceholderRecipientCount((count) => count + 1);

View File

@ -3,8 +3,6 @@ import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
export const ZAddTemplatePlacholderRecipientsFormSchema = z export const ZAddTemplatePlacholderRecipientsFormSchema = z
.object({ .object({
signers: z.array( signers: z.array(
@ -15,9 +13,7 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe( actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
ZRecipientActionAuthTypesSchema.optional(),
),
}), }),
), ),
signingOrder: z.nativeEnum(DocumentSigningOrder), signingOrder: z.nativeEnum(DocumentSigningOrder),

View File

@ -106,8 +106,8 @@ export const AddTemplateSettingsFormPartial = ({
title: template.title, title: template.title,
externalId: template.externalId || undefined, externalId: template.externalId || undefined,
visibility: template.visibility || '', visibility: template.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined, globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || undefined, globalActionAuth: documentAuthOption?.globalActionAuth || [],
meta: { meta: {
subject: template.templateMeta?.subject ?? '', subject: template.templateMeta?.subject ?? '',
message: template.templateMeta?.message ?? '', message: template.templateMeta?.message ?? '',
@ -237,7 +237,11 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<DocumentGlobalAuthAccessSelect {...field} onValueChange={field.onChange} /> <DocumentGlobalAuthAccessSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@ -378,7 +382,11 @@ export const AddTemplateSettingsFormPartial = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} /> <DocumentGlobalAuthActionSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}

View File

@ -18,18 +18,12 @@ import {
ZDocumentMetaTimezoneSchema, ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema'; } from '@documenso/trpc/server/document-router/schema';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
export const ZAddTemplateSettingsFormSchema = z.object({ export const ZAddTemplateSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }), title: z.string().trim().min(1, { message: "Title can't be empty" }),
externalId: z.string().optional(), externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(), visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe( globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]),
ZDocumentAccessAuthTypesSchema.optional(), globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]),
),
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentActionAuthTypesSchema.optional(),
),
meta: z.object({ meta: z.object({
subject: z.string(), subject: z.string(),
message: z.string(), message: z.string(),