feat: add document auth

This commit is contained in:
David Nguyen
2024-03-11 19:36:22 +08:00
parent d5c4885c67
commit 8d1b0adbb2
53 changed files with 2548 additions and 713 deletions

View File

@ -0,0 +1,31 @@
import type { TDocumentAuth } from '../types/document-auth';
import { DocumentAuth } from '../types/document-auth';
type DocumentAuthTypeData = {
key: TDocumentAuth;
value: string;
/**
* Whether this authentication event will require the user to halt and
* redirect.
*
* Defaults to false.
*/
isAuthRedirectRequired?: boolean;
};
export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
[DocumentAuth.ACCOUNT]: {
key: DocumentAuth.ACCOUNT,
value: 'Require account',
isAuthRedirectRequired: true,
},
// [DocumentAuthType.PASSKEY]: {
// key: DocumentAuthType.PASSKEY,
// value: 'Require passkey',
// },
[DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE,
value: 'None (Overrides global settings)',
},
} satisfies Record<TDocumentAuth, DocumentAuthTypeData>;

View File

@ -137,12 +137,16 @@ export class AppError extends Error {
}
static parseFromJSONString(jsonString: string): AppError | null {
const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
try {
const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
if (!parsed.success) {
if (!parsed.success) {
return null;
}
return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
} catch {
return null;
}
return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
}
}

View File

@ -7,13 +7,19 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TRecipientActionAuth } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { isRecipientAuthorized } from './is-recipient-authorized';
import { sealDocument } from './seal-document';
import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
documentId: number;
userId?: number;
authOptions?: TRecipientActionAuth;
requestMetadata?: RequestMetadata;
};
@ -40,6 +46,8 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
export const completeDocumentWithToken = async ({
token,
documentId,
userId,
authOptions,
requestMetadata,
}: CompleteDocumentWithTokenOptions) => {
'use server';
@ -71,32 +79,52 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
},
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
documentId: document.id,
user: {
name: recipient.name,
email: recipient.email,
const isValid = await isRecipientAuthorized({
type: 'ACTION',
document: document,
recipient: recipient,
userId,
authOptions,
});
if (!isValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
}
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {
id: recipient.id,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
},
}),
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
documentId: document.id,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
actionAuth: derivedRecipientActionAuth || undefined,
},
}),
});
});
const pendingRecipients = await prisma.recipient.count({

View File

@ -1,16 +1,43 @@
import { prisma } from '@documenso/prisma';
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { isRecipientAuthorized } from './is-recipient-authorized';
export interface GetDocumentAndSenderByTokenOptions {
token: string;
userId?: number;
accessAuth?: TDocumentAuthMethods;
/**
* Whether we enforce the access requirement.
*
* Defaults to true.
*/
requireAccessAuth?: boolean;
}
export interface GetDocumentAndRecipientByTokenOptions {
token: string;
userId?: number;
accessAuth?: TDocumentAuthMethods;
/**
* Whether we enforce the access requirement.
*
* Defaults to true.
*/
requireAccessAuth?: boolean;
}
export type DocumentAndSender = Awaited<ReturnType<typeof getDocumentAndSenderByToken>>;
export const getDocumentAndSenderByToken = async ({
token,
userId,
accessAuth,
requireAccessAuth = true,
}: GetDocumentAndSenderByTokenOptions) => {
if (!token) {
throw new Error('Missing token');
@ -28,12 +55,40 @@ export const getDocumentAndSenderByToken = async ({
User: true,
documentData: true,
documentMeta: true,
Recipient: {
where: {
token,
},
},
},
});
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const { password: _password, ...User } = result.User;
const recipient = result.Recipient[0];
// Sanity check, should not be possible.
if (!recipient) {
throw new Error('Missing recipient');
}
let documentAccessValid = true;
if (requireAccessAuth) {
documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document: result,
recipient,
userId,
authOptions: accessAuth,
});
}
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
}
return {
...result,
User,
@ -45,6 +100,9 @@ export const getDocumentAndSenderByToken = async ({
*/
export const getDocumentAndRecipientByToken = async ({
token,
userId,
accessAuth,
requireAccessAuth = true,
}: GetDocumentAndRecipientByTokenOptions): Promise<DocumentWithRecipient> => {
if (!token) {
throw new Error('Missing token');
@ -68,6 +126,29 @@ export const getDocumentAndRecipientByToken = async ({
},
});
const recipient = result.Recipient[0];
// Sanity check, should not be possible.
if (!recipient) {
throw new Error('Missing recipient');
}
let documentAccessValid = true;
if (requireAccessAuth) {
documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
document: result,
recipient,
userId,
authOptions: accessAuth,
});
}
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
}
return {
...result,
Recipient: result.Recipient,

View File

@ -0,0 +1,86 @@
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { Document, Recipient } from '@documenso/prisma/client';
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
type IsRecipientAuthorizedOptions = {
type: 'ACCESS' | 'ACTION';
document: Document;
recipient: Recipient;
/**
* The ID of the user who initiated the request.
*/
userId?: number;
/**
* The auth details to check.
*
* Optional because there are scenarios where no auth options are required such as
* using the user ID.
*/
authOptions?: TDocumentAuthMethods;
};
const getRecipient = async (email: string) => {
return await prisma.user.findFirst({
where: {
email,
},
select: {
id: true,
},
});
};
/**
* Whether the recipient is authorized to perform the requested operation on a
* document, given the provided auth options.
*
* @returns True if the recipient can perform the requested operation.
*/
export const isRecipientAuthorized = async ({
type,
document,
recipient,
userId,
authOptions,
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
const authMethod: TDocumentAuth | null =
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
// Early true return when auth is not required.
if (!authMethod || authMethod === DocumentAuth.EXPLICIT_NONE) {
return true;
}
// Authentication required does not match provided method.
if (authOptions && authOptions.type !== authMethod) {
return false;
}
return await match(authMethod)
.with(DocumentAuth.ACCOUNT, async () => {
if (userId === undefined) {
return false;
}
const recipientUser = await getRecipient(recipient.email);
if (!recipientUser) {
return false;
}
return recipientUser.id === userId;
})
.exhaustive();
};

View File

@ -0,0 +1,162 @@
'use server';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
export type UpdateDocumentSettingsOptions = {
userId: number;
teamId?: number;
documentId: number;
data: {
title?: string;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
};
requestMetadata?: RequestMetadata;
};
export const updateDocumentSettings = async ({
userId,
teamId,
documentId,
data,
requestMetadata,
}: UpdateDocumentSettingsOptions) => {
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
// If the new global auth values aren't passed in, fallback to the current document values.
const newGlobalAccessAuth =
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
const newGlobalActionAuth =
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
const isTitleSame = data.title === document.title;
const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth;
const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth;
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
if (!isTitleSame && document.status !== DocumentStatus.DRAFT) {
throw new AppError(
AppErrorCode.INVALID_BODY,
'You cannot update the title if the document has been sent',
);
}
if (!isTitleSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: document.title,
to: data.title || '',
},
}),
);
}
if (!isGlobalAccessSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: documentGlobalAccessAuth,
to: newGlobalAccessAuth,
},
}),
);
}
if (!isGlobalActionSame) {
auditLogs.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED,
documentId,
user,
requestMetadata,
data: {
from: documentGlobalActionAuth,
to: newGlobalActionAuth,
},
}),
);
}
// Early return if nothing is required.
if (auditLogs.length === 0) {
return document;
}
return await prisma.$transaction(async (tx) => {
const authOptions = createDocumentAuthOptions({
globalAccessAuth: newGlobalAccessAuth,
globalActionAuth: newGlobalActionAuth,
});
const updatedDocument = await tx.document.update({
where: {
id: documentId,
},
data: {
title: data.title,
authOptions,
},
});
await tx.documentAuditLog.createMany({
data: auditLogs,
});
return updatedDocument;
});
};

View File

@ -5,15 +5,21 @@ import { prisma } from '@documenso/prisma';
import { ReadStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import type { TDocumentAccessAuthTypes } from '../../types/document-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getDocumentAndRecipientByToken } from './get-document-by-token';
export type ViewedDocumentOptions = {
token: string;
recipientAccessAuth?: TDocumentAccessAuthTypes | null;
requestMetadata?: RequestMetadata;
};
export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentOptions) => {
export const viewedDocument = async ({
token,
recipientAccessAuth,
requestMetadata,
}: ViewedDocumentOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
token,
@ -51,12 +57,13 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO
recipientId: recipient.id,
recipientName: recipient.name,
recipientRole: recipient.role,
accessAuth: recipientAccessAuth || undefined,
},
}),
});
});
const document = await getDocumentAndRecipientByToken({ token });
const document = await getDocumentAndRecipientByToken({ token, requireAccessAuth: false });
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_OPENED,

View File

@ -8,15 +8,21 @@ import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/clie
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuth } from '../../types/document-auth';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
export type SignFieldWithTokenOptions = {
token: string;
fieldId: number;
value: string;
isBase64?: boolean;
userId?: number;
authOptions?: TRecipientActionAuth;
requestMetadata?: RequestMetadata;
};
@ -25,6 +31,8 @@ export const signFieldWithToken = async ({
fieldId,
value,
isBase64,
userId,
authOptions,
requestMetadata,
}: SignFieldWithTokenOptions) => {
const field = await prisma.field.findFirstOrThrow({
@ -71,6 +79,23 @@ export const signFieldWithToken = async ({
throw new Error(`Field ${fieldId} has no recipientId`);
}
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
const isValid = await isRecipientAuthorized({
type: 'ACTION',
document: document,
recipient: recipient,
userId,
authOptions,
});
if (!isValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
}
const documentMeta = await prisma.documentMeta.findFirst({
where: {
documentId: document.id,
@ -158,9 +183,11 @@ export const signFieldWithToken = async ({
data: updatedField.customText,
}))
.exhaustive(),
fieldSecurity: {
type: 'NONE',
},
fieldSecurity: derivedRecipientActionAuth
? {
type: derivedRecipientActionAuth,
}
: undefined,
},
}),
});

View File

@ -1,8 +1,9 @@
import { prisma } from '@documenso/prisma';
import type { FieldType, Team } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { createDocumentAuditLogData, diffFieldChanges } from '../../utils/document-audit-logs';
export type UpdateFieldOptions = {
fieldId: number;
@ -33,7 +34,7 @@ export const updateField = async ({
pageHeight,
requestMetadata,
}: UpdateFieldOptions) => {
const field = await prisma.field.update({
const oldField = await prisma.field.findFirstOrThrow({
where: {
id: fieldId,
Document: {
@ -55,23 +56,49 @@ export const updateField = async ({
}),
},
},
data: {
recipientId,
type,
page: pageNumber,
positionX: pageX,
positionY: pageY,
width: pageWidth,
height: pageHeight,
},
include: {
Recipient: true,
},
});
if (!field) {
throw new Error('Field not found');
}
const field = prisma.$transaction(async (tx) => {
const updatedField = await tx.field.update({
where: {
id: fieldId,
},
data: {
recipientId,
type,
page: pageNumber,
positionX: pageX,
positionY: pageY,
width: pageWidth,
height: pageHeight,
},
include: {
Recipient: true,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
fieldId: updatedField.secondaryId,
fieldRecipientEmail: updatedField.Recipient?.email ?? '',
fieldRecipientId: recipientId ?? -1,
fieldType: updatedField.type,
changes: diffFieldChanges(oldField, updatedField),
},
requestMetadata,
}),
});
return updatedField;
});
const user = await prisma.user.findFirstOrThrow({
where: {
@ -99,24 +126,5 @@ export const updateField = async ({
});
}
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'FIELD_UPDATED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.Recipient?.email ?? '',
fieldRecipientId: recipientId ?? -1,
fieldType: field.type,
},
requestMetadata,
}),
});
return field;
};

View File

@ -1,10 +1,15 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid } from '@documenso/lib/universal/id';
import {
createDocumentAuditLogData,
diffRecipientChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
@ -18,6 +23,7 @@ export interface SetRecipientsForDocumentOptions {
email: string;
name: string;
role: RecipientRole;
actionAuth?: TRecipientActionAuthTypes | null;
}[];
requestMetadata?: RequestMetadata;
}
@ -111,6 +117,15 @@ export const setRecipientsForDocument = async ({
const persistedRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
linkedRecipients.map(async (recipient) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions);
if (recipient.actionAuth !== undefined) {
authOptions = createRecipientAuthOptions({
accessAuth: authOptions.accessAuth,
actionAuth: recipient.actionAuth,
});
}
const upsertedRecipient = await tx.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
@ -124,6 +139,7 @@ export const setRecipientsForDocument = async ({
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions,
},
create: {
name: recipient.name,
@ -134,6 +150,7 @@ export const setRecipientsForDocument = async ({
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions,
},
});
@ -187,7 +204,10 @@ export const setRecipientsForDocument = async ({
documentId: documentId,
user,
requestMetadata,
data: baseAuditLog,
data: {
...baseAuditLog,
actionAuth: recipient.actionAuth || undefined,
},
}),
});
}

View File

@ -8,6 +8,8 @@ import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
import { ZRecipientActionAuthTypesSchema } from './document-auth';
export const ZDocumentAuditLogTypeSchema = z.enum([
// Document actions.
'EMAIL_SENT',
@ -26,6 +28,8 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_DELETED', // When the document is soft deleted.
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
'DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED', // When the global access authentication is updated.
'DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED', // When the global action authentication is updated.
'DOCUMENT_META_UPDATED', // When the document meta data is updated.
'DOCUMENT_OPENED', // When the document is opened by a recipient.
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
@ -51,7 +55,13 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([
]);
export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']);
export const ZRecipientDiffTypeSchema = z.enum(['NAME', 'ROLE', 'EMAIL']);
export const ZRecipientDiffTypeSchema = z.enum([
'NAME',
'ROLE',
'EMAIL',
'ACCESS_AUTH',
'ACTION_AUTH',
]);
export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum;
export const DOCUMENT_EMAIL_TYPE = ZDocumentAuditLogEmailTypeSchema.Enum;
@ -107,25 +117,34 @@ export const ZDocumentAuditLogFieldDiffSchema = z.union([
ZFieldDiffPositionSchema,
]);
export const ZRecipientDiffNameSchema = z.object({
export const ZGenericFromToSchema = z.object({
from: z.string().nullable(),
to: z.string().nullable(),
});
export const ZRecipientDiffActionAuthSchema = ZGenericFromToSchema.extend({
type: z.literal(RECIPIENT_DIFF_TYPE.ACCESS_AUTH),
});
export const ZRecipientDiffAccessAuthSchema = ZGenericFromToSchema.extend({
type: z.literal(RECIPIENT_DIFF_TYPE.ACTION_AUTH),
});
export const ZRecipientDiffNameSchema = ZGenericFromToSchema.extend({
type: z.literal(RECIPIENT_DIFF_TYPE.NAME),
from: z.string(),
to: z.string(),
});
export const ZRecipientDiffRoleSchema = z.object({
export const ZRecipientDiffRoleSchema = ZGenericFromToSchema.extend({
type: z.literal(RECIPIENT_DIFF_TYPE.ROLE),
from: z.string(),
to: z.string(),
});
export const ZRecipientDiffEmailSchema = z.object({
export const ZRecipientDiffEmailSchema = ZGenericFromToSchema.extend({
type: z.literal(RECIPIENT_DIFF_TYPE.EMAIL),
from: z.string(),
to: z.string(),
});
export const ZDocumentAuditLogRecipientDiffSchema = z.union([
export const ZDocumentAuditLogRecipientDiffSchema = z.discriminatedUnion('type', [
ZRecipientDiffActionAuthSchema,
ZRecipientDiffAccessAuthSchema,
ZRecipientDiffNameSchema,
ZRecipientDiffRoleSchema,
ZRecipientDiffEmailSchema,
@ -217,11 +236,11 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
data: z.string(),
}),
]),
// Todo: Replace with union once we have more field security types.
fieldSecurity: z.object({
type: z.literal('NONE'),
}),
fieldSecurity: z
.object({
type: ZRecipientActionAuthTypesSchema,
})
.optional(),
}),
});
@ -236,6 +255,22 @@ export const ZDocumentAuditLogEventDocumentFieldUninsertedSchema = z.object({
}),
});
/**
* Event: Document global authentication access updated.
*/
export const ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED),
data: ZGenericFromToSchema,
});
/**
* Event: Document global authentication action updated.
*/
export const ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED),
data: ZGenericFromToSchema,
});
/**
* Event: Document meta updated.
*/
@ -251,7 +286,9 @@ export const ZDocumentAuditLogEventDocumentMetaUpdatedSchema = z.object({
*/
export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED),
data: ZBaseRecipientDataSchema,
data: ZBaseRecipientDataSchema.extend({
accessAuth: z.string().optional(),
}),
});
/**
@ -259,7 +296,9 @@ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
*/
export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED),
data: ZBaseRecipientDataSchema,
data: ZBaseRecipientDataSchema.extend({
actionAuth: z.string().optional(),
}),
});
/**
@ -303,7 +342,9 @@ export const ZDocumentAuditLogEventFieldRemovedSchema = z.object({
export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED),
data: ZBaseFieldEventDataSchema.extend({
changes: z.array(ZDocumentAuditLogFieldDiffSchema),
// Provide an empty array as a migration workaround due to a mistake where we were
// not passing through any changes via API/v1 due to a type error.
changes: z.preprocess((x) => x || [], z.array(ZDocumentAuditLogFieldDiffSchema)),
}),
});
@ -312,7 +353,9 @@ export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({
*/
export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED),
data: ZBaseRecipientDataSchema,
data: ZBaseRecipientDataSchema.extend({
actionAuth: ZRecipientActionAuthTypesSchema.optional(),
}),
});
/**
@ -352,6 +395,8 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentDeletedSchema,
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema,
ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema,
ZDocumentAuditLogEventDocumentMetaUpdatedSchema,
ZDocumentAuditLogEventDocumentOpenedSchema,
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,

View File

@ -0,0 +1,121 @@
import { z } from 'zod';
/**
* All the available types of document authentication options for both access and action.
*/
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'EXPLICIT_NONE']);
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
const ZDocumentAuthAccountSchema = z.object({
type: z.literal(DocumentAuth.ACCOUNT),
});
const ZDocumentAuthExplicitNoneSchema = z.object({
type: z.literal(DocumentAuth.EXPLICIT_NONE),
});
/**
* All the document auth methods for both accessing and actioning.
*/
export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthExplicitNoneSchema,
]);
/**
* The global document access auth methods.
*
* Must keep these two in sync.
*/
export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]);
export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
/**
* The global document action auth methods.
*
* Must keep these two in sync.
*/
export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]); // Todo: Add passkeys here.
export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
/**
* The recipient access auth methods.
*
* Must keep these two in sync.
*/
export const ZRecipientAccessAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
]);
export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
/**
* The recipient action auth methods.
*
* Must keep these two in sync.
*/
export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema, // Todo: Add passkeys here.
ZDocumentAuthExplicitNoneSchema,
]);
export const ZRecipientActionAuthTypesSchema = z.enum([
DocumentAuth.ACCOUNT,
DocumentAuth.EXPLICIT_NONE,
]);
export const DocumentAccessAuth = ZDocumentAccessAuthTypesSchema.Enum;
export const DocumentActionAuth = ZDocumentActionAuthTypesSchema.Enum;
export const RecipientAccessAuth = ZRecipientAccessAuthTypesSchema.Enum;
export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum;
/**
* Authentication options attached to the document.
*/
export const ZDocumentAuthOptionsSchema = z.preprocess(
(unknownValue) => {
if (unknownValue) {
return unknownValue;
}
return {
globalAccessAuth: null,
globalActionAuth: null,
};
},
z.object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable(),
}),
);
/**
* Authentication options attached to the recipient.
*/
export const ZRecipientAuthOptionsSchema = z.preprocess(
(unknownValue) => {
if (unknownValue) {
return unknownValue;
}
return {
accessAuth: null,
actionAuth: null,
};
},
z.object({
accessAuth: ZRecipientAccessAuthTypesSchema.nullable(),
actionAuth: ZRecipientActionAuthTypesSchema.nullable(),
}),
);
export type TDocumentAuth = z.infer<typeof ZDocumentAuthTypesSchema>;
export type TDocumentAuthMethods = z.infer<typeof ZDocumentAuthMethodsSchema>;
export type TDocumentAuthOptions = z.infer<typeof ZDocumentAuthOptionsSchema>;
export type TDocumentAccessAuth = z.infer<typeof ZDocumentAccessAuthSchema>;
export type TDocumentAccessAuthTypes = z.infer<typeof ZDocumentAccessAuthTypesSchema>;
export type TDocumentActionAuth = z.infer<typeof ZDocumentActionAuthSchema>;
export type TDocumentActionAuthTypes = z.infer<typeof ZDocumentActionAuthTypesSchema>;
export type TRecipientAccessAuth = z.infer<typeof ZRecipientAccessAuthSchema>;
export type TRecipientAccessAuthTypes = z.infer<typeof ZRecipientAccessAuthTypesSchema>;
export type TRecipientActionAuth = z.infer<typeof ZRecipientActionAuthSchema>;
export type TRecipientActionAuthTypes = z.infer<typeof ZRecipientActionAuthTypesSchema>;
export type TRecipientAuthOptions = z.infer<typeof ZRecipientAuthOptionsSchema>;

View File

@ -22,6 +22,7 @@ import {
RECIPIENT_DIFF_TYPE,
ZDocumentAuditLogSchema,
} from '../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../types/document-auth';
import type { RequestMetadata } from '../universal/extract-request-metadata';
type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
@ -32,20 +33,20 @@ type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
requestMetadata?: RequestMetadata;
};
type CreateDocumentAuditLogDataResponse = Pick<
export type CreateDocumentAuditLogDataResponse = Pick<
DocumentAuditLog,
'type' | 'ipAddress' | 'userAgent' | 'email' | 'userId' | 'name' | 'documentId'
> & {
data: TDocumentAuditLog['data'];
};
export const createDocumentAuditLogData = ({
export const createDocumentAuditLogData = <T extends TDocumentAuditLog['type']>({
documentId,
type,
data,
user,
requestMetadata,
}: CreateDocumentAuditLogDataOptions): CreateDocumentAuditLogDataResponse => {
}: CreateDocumentAuditLogDataOptions<T>): CreateDocumentAuditLogDataResponse => {
return {
type,
data,
@ -68,6 +69,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument
// Handle any required migrations here.
if (!data.success) {
// Todo: Alert us.
console.error(data.error);
throw new Error('Migration required');
}
@ -75,7 +77,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument
return data.data;
};
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role'>;
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role' | 'authOptions'>;
export const diffRecipientChanges = (
oldRecipient: PartialRecipient,
@ -83,6 +85,32 @@ export const diffRecipientChanges = (
): TDocumentAuditLogRecipientDiffSchema[] => {
const diffs: TDocumentAuditLogRecipientDiffSchema[] = [];
const oldAuthOptions = ZRecipientAuthOptionsSchema.parse(oldRecipient.authOptions);
const oldAccessAuth = oldAuthOptions.accessAuth;
const oldActionAuth = oldAuthOptions.actionAuth;
const newAuthOptions = ZRecipientAuthOptionsSchema.parse(newRecipient.authOptions);
const newAccessAuth =
newAuthOptions?.accessAuth === undefined ? oldAccessAuth : newAuthOptions.accessAuth;
const newActionAuth =
newAuthOptions?.actionAuth === undefined ? oldActionAuth : newAuthOptions.actionAuth;
if (oldAccessAuth !== newAccessAuth) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.ACCESS_AUTH,
from: oldAccessAuth ?? '',
to: newAccessAuth ?? '',
});
}
if (oldActionAuth !== newActionAuth) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.ACTION_AUTH,
from: oldActionAuth ?? '',
to: newActionAuth ?? '',
});
}
if (oldRecipient.email !== newRecipient.email) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.EMAIL,
@ -166,7 +194,13 @@ export const diffDocumentMetaChanges = (
const oldPassword = oldData?.password ?? null;
const oldRedirectUrl = oldData?.redirectUrl ?? '';
if (oldDateFormat !== newData.dateFormat) {
const newDateFormat = newData?.dateFormat ?? '';
const newMessage = newData?.message ?? '';
const newSubject = newData?.subject ?? '';
const newTimezone = newData?.timezone ?? '';
const newRedirectUrl = newData?.redirectUrl ?? '';
if (oldDateFormat !== newDateFormat) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.DATE_FORMAT,
from: oldData?.dateFormat ?? '',
@ -174,35 +208,35 @@ export const diffDocumentMetaChanges = (
});
}
if (oldMessage !== newData.message) {
if (oldMessage !== newMessage) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.MESSAGE,
from: oldMessage,
to: newData.message,
to: newMessage,
});
}
if (oldSubject !== newData.subject) {
if (oldSubject !== newSubject) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.SUBJECT,
from: oldSubject,
to: newData.subject,
to: newSubject,
});
}
if (oldTimezone !== newData.timezone) {
if (oldTimezone !== newTimezone) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.TIMEZONE,
from: oldTimezone,
to: newData.timezone,
to: newTimezone,
});
}
if (oldRedirectUrl !== newData.redirectUrl) {
if (oldRedirectUrl !== newRedirectUrl) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.REDIRECT_URL,
from: oldRedirectUrl,
to: newData.redirectUrl,
to: newRedirectUrl,
});
}
@ -278,6 +312,14 @@ export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId
anonymous: 'Field unsigned',
identified: 'unsigned a field',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({
anonymous: 'Document access auth updated',
identified: 'updated the document access auth requirements',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, () => ({
anonymous: 'Document signing auth updated',
identified: 'updated the document signing auth requirements',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
anonymous: 'Document updated',
identified: 'updated the document',

View File

@ -0,0 +1,72 @@
import type { Document, Recipient } from '@documenso/prisma/client';
import type {
TDocumentAuthOptions,
TRecipientAccessAuthTypes,
TRecipientActionAuthTypes,
TRecipientAuthOptions,
} from '../types/document-auth';
import { DocumentAuth } from '../types/document-auth';
import { ZDocumentAuthOptionsSchema, ZRecipientAuthOptionsSchema } from '../types/document-auth';
type ExtractDocumentAuthMethodsOptions = {
documentAuth: Document['authOptions'];
recipientAuth?: Recipient['authOptions'];
};
/**
* Parses and extracts the document and recipient authentication values.
*
* Will combine the recipient and document auth values to derive the final
* auth values for a recipient if possible.
*/
export const extractDocumentAuthMethods = ({
documentAuth,
recipientAuth,
}: ExtractDocumentAuthMethodsOptions) => {
const documentAuthOption = ZDocumentAuthOptionsSchema.parse(documentAuth);
const recipientAuthOption = ZRecipientAuthOptionsSchema.parse(recipientAuth);
const derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null =
recipientAuthOption.accessAuth || documentAuthOption.globalAccessAuth;
const derivedRecipientActionAuth: TRecipientActionAuthTypes | null =
recipientAuthOption.actionAuth || documentAuthOption.globalActionAuth;
const recipientAccessAuthRequired = derivedRecipientAccessAuth !== null;
const recipientActionAuthRequired =
derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE &&
derivedRecipientActionAuth !== null;
return {
derivedRecipientAccessAuth,
derivedRecipientActionAuth,
recipientAccessAuthRequired,
recipientActionAuthRequired,
documentAuthOption,
recipientAuthOption,
};
};
/**
* Create document auth options in a type safe way.
*/
export const createDocumentAuthOptions = (options: TDocumentAuthOptions): TDocumentAuthOptions => {
return {
globalAccessAuth: options?.globalAccessAuth ?? null,
globalActionAuth: options?.globalActionAuth ?? null,
};
};
/**
* Create recipient auth options in a type safe way.
*/
export const createRecipientAuthOptions = (
options: TRecipientAuthOptions,
): TRecipientAuthOptions => {
return {
accessAuth: options?.accessAuth ?? null,
actionAuth: options?.actionAuth ?? null,
};
};

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "authOptions" JSONB;
-- AlterTable
ALTER TABLE "Recipient" ADD COLUMN "authOptions" JSONB;

View File

@ -226,6 +226,7 @@ model Document {
id Int @id @default(autoincrement())
userId Int
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
authOptions Json?
title String
status DocumentStatus @default(DRAFT)
Recipient Recipient[]
@ -323,6 +324,7 @@ model Recipient {
token String
expired DateTime?
signedAt DateTime?
authOptions Json?
role RecipientRole @default(SIGNER)
readStatus ReadStatus @default(NOT_OPENED)
signingStatus SigningStatus @default(NOT_SIGNED)

View File

@ -12,6 +12,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocumentSettings } from '@documenso/lib/server-only/document/update-document-settings';
import { updateTitle } from '@documenso/lib/server-only/document/update-title';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@ -27,6 +28,7 @@ import {
ZSearchDocumentsMutationSchema,
ZSendDocumentMutationSchema,
ZSetPasswordForDocumentMutationSchema,
ZSetSettingsForDocumentMutationSchema,
ZSetTitleForDocumentMutationSchema,
} from './schema';
@ -49,22 +51,25 @@ export const documentRouter = router({
}
}),
getDocumentByToken: procedure.input(ZGetDocumentByTokenQuerySchema).query(async ({ input }) => {
try {
const { token } = input;
getDocumentByToken: procedure
.input(ZGetDocumentByTokenQuerySchema)
.query(async ({ input, ctx }) => {
try {
const { token } = input;
return await getDocumentAndSenderByToken({
token,
});
} catch (err) {
console.error(err);
return await getDocumentAndSenderByToken({
token,
userId: ctx.user?.id,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find this document. Please try again later.',
});
}
}),
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find this document. Please try again later.',
});
}
}),
createDocument: authenticatedProcedure
.input(ZCreateDocumentMutationSchema)
@ -150,6 +155,46 @@ export const documentRouter = router({
}
}),
// Todo: Add API
setSettingsForDocument: authenticatedProcedure
.input(ZSetSettingsForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, teamId, data, meta } = input;
const userId = ctx.user.id;
const requestMetadata = extractNextApiRequestMetadata(ctx.req);
if (meta.timezone || meta.dateFormat || meta.redirectUrl) {
await upsertDocumentMeta({
documentId,
dateFormat: meta.dateFormat,
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
userId: ctx.user.id,
requestMetadata,
});
}
return await updateDocumentSettings({
userId,
teamId,
documentId,
data,
requestMetadata,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'We were unable to update the settings for this document. Please try again later.',
});
}
}),
setTitleForDocument: authenticatedProcedure
.input(ZSetTitleForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {

View File

@ -1,6 +1,10 @@
import { z } from 'zod';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
@ -37,6 +41,30 @@ export const ZCreateDocumentMutationSchema = z.object({
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
export const ZSetSettingsForDocumentMutationSchema = z.object({
documentId: z.number(),
teamId: z.number().min(1).optional(),
data: z.object({
title: z.string().min(1).optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
}),
meta: z.object({
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});
export type TSetGeneralSettingsForDocumentMutationSchema = z.infer<
typeof ZSetSettingsForDocumentMutationSchema
>;
export const ZSetTitleForDocumentMutationSchema = z.object({
documentId: z.number(),
teamId: z.number().min(1).optional(),
@ -88,8 +116,8 @@ export const ZSendDocumentMutationSchema = z.object({
meta: z.object({
subject: z.string(),
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
timezone: z.string().optional(),
dateFormat: z.string().optional(),
redirectUrl: z
.string()
.optional()

View File

@ -1,5 +1,6 @@
import { TRPCError } from '@trpc/server';
import { AppError } from '@documenso/lib/errors/app-error';
import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
@ -71,22 +72,21 @@ export const fieldRouter = router({
.input(ZSignFieldWithTokenMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { token, fieldId, value, isBase64 } = input;
const { token, fieldId, value, isBase64, authOptions } = input;
return await signFieldWithToken({
token,
fieldId,
value,
isBase64,
userId: ctx.user?.id,
authOptions,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to sign this field. Please try again later.',
});
throw AppError.parseErrorToTRPCError(err);
}
}),

View File

@ -1,5 +1,6 @@
import { z } from 'zod';
import { ZRecipientActionAuthSchema } from '@documenso/lib/types/document-auth';
import { FieldType } from '@documenso/prisma/client';
export const ZAddFieldsMutationSchema = z.object({
@ -45,6 +46,7 @@ export const ZSignFieldWithTokenMutationSchema = z.object({
fieldId: z.number(),
value: z.string().trim(),
isBase64: z.boolean().optional(),
authOptions: ZRecipientActionAuthSchema.optional(),
});
export type TSignFieldWithTokenMutationSchema = z.infer<typeof ZSignFieldWithTokenMutationSchema>;

View File

@ -28,6 +28,7 @@ export const recipientRouter = router({
email: signer.email,
name: signer.name,
role: signer.role,
actionAuth: signer.actionAuth,
})),
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
@ -71,11 +72,13 @@ export const recipientRouter = router({
.input(ZCompleteDocumentWithTokenMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { token, documentId } = input;
const { token, documentId, authOptions } = input;
return await completeDocumentWithToken({
token,
documentId,
authOptions,
userId: ctx.user?.id,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {

View File

@ -1,5 +1,9 @@
import { z } from 'zod';
import {
ZRecipientActionAuthSchema,
ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { RecipientRole } from '@documenso/prisma/client';
export const ZAddSignersMutationSchema = z
@ -12,6 +16,7 @@ export const ZAddSignersMutationSchema = z
email: z.string().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
}),
),
})
@ -54,6 +59,7 @@ export type TAddTemplateSignersMutationSchema = z.infer<typeof ZAddTemplateSigne
export const ZCompleteDocumentWithTokenMutationSchema = z.object({
token: z.string(),
documentId: z.number(),
authOptions: ZRecipientActionAuthSchema.optional(),
});
export type TCompleteDocumentWithTokenMutationSchema = z.infer<

View File

@ -23,7 +23,6 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next }) => {
return await next({
ctx: {
...ctx,
user: ctx.user,
session: ctx.session,
},

View File

@ -5,11 +5,17 @@ import { motion } from 'framer-motion';
type AnimateGenericFadeInOutProps = {
children: React.ReactNode;
className?: string;
key?: string;
};
export const AnimateGenericFadeInOut = ({ children, className }: AnimateGenericFadeInOutProps) => {
export const AnimateGenericFadeInOut = ({
children,
className,
key,
}: AnimateGenericFadeInOutProps) => {
return (
<motion.section
key={key}
initial={{
opacity: 0,
}}

View File

@ -60,7 +60,7 @@ export const DocumentDownloadButton = ({
loading={isLoading}
{...props}
>
<Download className="mr-2 h-5 w-5" />
{!isLoading && <Download className="mr-2 h-5 w-5" />}
Download
</Button>
);

View File

@ -16,7 +16,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'border-input ring-offset-background focus-visible:ring-ring data-[state=checked]:border-primary peer h-4 w-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'border-input bg-background ring-offset-background focus-visible:ring-ring data-[state=checked]:border-primary peer h-4 w-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}

View File

@ -0,0 +1,351 @@
'use client';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DocumentAccessAuth, DocumentActionAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Combobox } from '../combobox';
import { Input } from '../input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import type { TAddSettingsFormSchema } from './add-settings.types';
import { ZAddSettingsFormSchema } from './add-settings.types';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
import type { DocumentFlowStep } from './types';
export type AddSettingsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
document: DocumentWithData;
onSubmit: (_data: TAddSettingsFormSchema) => void;
};
export const AddSettingsFormPartial = ({
documentFlow,
recipients,
fields,
document,
onSubmit,
}: AddSettingsFormProps) => {
const { documentAuthOption } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
const form = useForm<TAddSettingsFormSchema>({
resolver: zodResolver(ZAddSettingsFormSchema),
defaultValues: {
title: document.title,
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
meta: {
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: document.documentMeta?.redirectUrl ?? '',
},
},
});
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
const documentHasBeenSent = recipients.some(
(recipient) => recipient.sendStatus === SendStatus.SENT,
);
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
if (!form.formState.touchedFields.meta?.timezone && !documentHasBeenSent) {
form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [documentHasBeenSent, form, form.setValue, form.formState.touchedFields.meta?.timezone]);
return (
<>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
{fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
<Form {...form}>
<fieldset
className="flex h-full flex-col space-y-6"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel required>Title</FormLabel>
<FormControl>
<Input
className="bg-background"
{...field}
disabled={document.status !== DocumentStatus.DRAFT || field.disabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="globalAccessAuth"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Document access
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<p>The authentication requirement for recipients to view the document.</p>
<ul className="space-y-0.5">
<li>
<strong>Require account</strong> - The recipient must have an account,
and be signed in to view the document
</li>
<li>
<strong>None</strong> - The document can be accessed directly by the URL
sent to the recipient
</li>
</ul>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DocumentAccessAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>None</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="globalActionAuth"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Recipient signing authentication
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<p>The authentication requirement for recipients to sign fields.</p>
<p>
You can also override this global setting by setting the authentication
requirements directly on each recipient in the next step.
</p>
<ul className="space-y-0.5">
<li>
<strong>Require account</strong> - The recipient must have an account,
and be signed in to sign fields
</li>
<li>
<strong>None</strong> - The recipient does not need any authentication
to sign fields
</li>
</ul>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DocumentActionAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>None</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<Accordion type="multiple" className="mt-6">
<AccordionItem value="advanced-options" className="border-none">
<AccordionTrigger className="text-foreground mb-2 rounded border px-3 py-2 text-left hover:bg-neutral-200/30 hover:no-underline">
Advanced Options
</AccordionTrigger>
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-2 text-sm leading-relaxed">
<div className="flex flex-col space-y-6 ">
<FormField
control={form.control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>Date Format</FormLabel>
<FormControl>
<Select
{...field}
onValueChange={field.onChange}
disabled={documentHasBeenSent}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>Time Zone</FormLabel>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
{...field}
onChange={(value) => value && field.onChange(value)}
disabled={documentHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.redirectUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Redirect URL{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
Add a URL to redirect the user to once the document is signed
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</fieldset>
</Form>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={currentStep}
maxStep={totalSteps}
/>
<DocumentFlowFormContainerActions
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting}
canGoBack={stepIndex !== 0}
onGoBackClick={previousStep}
onGoNextClick={form.handleSubmit(onSubmit)}
/>
</DocumentFlowFormContainerFooter>
</>
);
};

View File

@ -0,0 +1,42 @@
import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
export const ZMapNegativeOneToUndefinedSchema = z
.string()
.optional()
.transform((val) => {
if (val === '-1') {
return undefined;
}
return val;
});
export const ZAddSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentActionAuthTypesSchema.optional(),
),
meta: z.object({
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});
export type TAddSettingsFormSchema = z.infer<typeof ZAddSettingsFormSchema>;

View File

@ -1,25 +1,33 @@
'use client';
import React, { useId } from 'react';
import React, { useId, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { Plus, Trash } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { motion } from 'framer-motion';
import { InfoIcon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import {
RecipientActionAuth,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { RecipientRole, SendStatus } from '@documenso/prisma/client';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '../button';
import { Checkbox } from '../checkbox';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { Label } from '../label';
import { ROLE_ICONS } from '../recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import { useToast } from '../use-toast';
import type { TAddSignersFormSchema } from './add-signers.types';
import { ZAddSignersFormSchema } from './add-signers.types';
@ -37,14 +45,12 @@ export type AddSignersFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
document: DocumentWithData;
onSubmit: (_data: TAddSignersFormSchema) => void;
};
export const AddSignersFormPartial = ({
documentFlow,
recipients,
document,
fields,
onSubmit,
}: AddSignersFormProps) => {
@ -55,11 +61,7 @@ export const AddSignersFormPartial = ({
const { currentStep, totalSteps, previousStep } = useStep();
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddSignersFormSchema>({
const form = useForm<TAddSignersFormSchema>({
resolver: zodResolver(ZAddSignersFormSchema),
defaultValues: {
signers:
@ -70,6 +72,8 @@ export const AddSignersFormPartial = ({
name: recipient.name,
email: recipient.email,
role: recipient.role,
actionAuth:
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
}))
: [
{
@ -77,12 +81,33 @@ export const AddSignersFormPartial = ({
name: '',
email: '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
},
],
},
});
const onFormSubmit = handleSubmit(onSubmit);
// Always show advanced settings if any recipient has auth options.
const alwaysShowAdvancedSettings = useMemo(() => {
const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth;
});
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth);
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
const {
formState: { errors, isSubmitting },
control,
} = form;
const onFormSubmit = form.handleSubmit(onSubmit);
const {
append: appendSigner,
@ -112,6 +137,7 @@ export const AddSignersFormPartial = ({
name: '',
email: '',
role: RecipientRole.SIGNER,
actionAuth: undefined,
});
};
@ -144,105 +170,190 @@ export const AddSignersFormPartial = ({
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
<div className="flex w-full flex-col gap-y-4">
{fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
{fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
<AnimatePresence>
{signers.map((signer, index) => (
<motion.div
key={signer.id}
data-native-id={signer.nativeId}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1">
<Label htmlFor={`signer-${signer.id}-email`}>
Email
<span className="text-destructive ml-1 inline-block font-medium">*</span>
</Label>
<Controller
control={control}
<AnimateGenericFadeInOut key={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
<div className="flex w-full flex-col gap-y-2">
{signers.map((signer, index) => (
<motion.div
key={signer.id}
data-native-id={signer.nativeId}
className={cn('grid grid-cols-8 gap-4 pb-4', {
'border-b pt-2': showAdvancedSettings,
})}
>
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<Input
id={`signer-${signer.id}-email`}
type="email"
className="bg-background mt-2"
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
onKeyDown={onKeyDown}
{...field}
/>
<FormItem
className={cn('relative', {
'col-span-3': !showAdvancedSettings,
'col-span-4': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>Email</FormLabel>
)}
<FormControl>
<Input
type="email"
placeholder="Email"
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
{...field}
onKeyDown={onKeyDown}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex-1">
<Label htmlFor={`signer-${signer.id}-name`}>Name</Label>
<Controller
control={control}
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<Input
id={`signer-${signer.id}-name`}
type="text"
className="bg-background mt-2"
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
onKeyDown={onKeyDown}
{...field}
/>
<FormItem
className={cn({
'col-span-3': !showAdvancedSettings,
'col-span-4': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>Name</FormLabel>
)}
<FormControl>
<Input
placeholder="Name"
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
{...field}
onKeyDown={onKeyDown}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-[60px]">
<Controller
control={control}
{showAdvancedSettings && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem className="col-span-6">
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder="Inherit authentication method" />
<Tooltip>
<TooltipTrigger className="-mr-1 ml-auto">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md p-4">
<p>
The authentication requirements for recipients to sign fields.
</p>
<p className="mt-2">This will override any global settings.</p>
<ul className="mt-2 space-y-0.5">
<li>
<strong>Inherit authentication method</strong> - Use the
global recipient signing authentication method configured in
the "General Settings" step
</li>
<li>
<strong>Require account</strong> - The recipient must have
an account, and be signed in to sign fields
</li>
<li>
<strong>None</strong> - The recipient does not need any
authentication to sign fields
</li>
</ul>
</TooltipContent>
</Tooltip>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value="-1">Inherit authentication method</SelectItem>
{Object.values(RecipientActionAuth).map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
name={`signers.${index}.role`}
render={({ field: { value, onChange } }) => (
<Select value={value} onValueChange={(x) => onChange(x)}>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
render={({ field }) => (
<FormItem className="col-span-1 mt-auto">
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background w-[60px]">
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
{ROLE_ICONS[field.value as RecipientRole]}
</SelectTrigger>
<SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Signer
</div>
</SelectItem>
<SelectContent align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Signer
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Approver
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Approver
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div>
<button
type="button"
className="justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
disabled={
isSubmitting ||
hasBeenSentToRecipientId(signer.nativeId) ||
@ -252,33 +363,51 @@ export const AddSignersFormPartial = ({
>
<Trash className="h-5 w-5" />
</button>
</motion.div>
))}
</div>
<FormErrorMessage
className="mt-2"
// Dirty hack to handle errors when .root is populated for an array type
error={'signers__root' in errors && errors['signers__root']}
/>
<div
className={cn('mt-2 flex flex-row items-center space-x-4', {
'mt-4': showAdvancedSettings,
})}
>
<Button
type="button"
disabled={isSubmitting || signers.length >= remaining.recipients}
onClick={() => onAddSigner()}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Signer
</Button>
{!alwaysShowAdvancedSettings && (
<div className="flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/>
<label
className="text-muted-foreground ml-2 text-sm"
htmlFor="showAdvancedRecipientSettings"
>
Show advanced settings
</label>
</div>
<div className="w-full">
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.email} />
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.name} />
</div>
</motion.div>
))}
</AnimatePresence>
</div>
<FormErrorMessage
className="mt-2"
// Dirty hack to handle errors when .root is populated for an array type
error={'signers__root' in errors && errors['signers__root']}
/>
<div className="mt-4">
<Button
type="button"
disabled={isSubmitting || signers.length >= remaining.recipients}
onClick={() => onAddSigner()}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Signer
</Button>
</div>
)}
</div>
</Form>
</AnimateGenericFadeInOut>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
@ -289,7 +418,6 @@ export const AddSignersFormPartial = ({
/>
<DocumentFlowFormContainerActions
canGoBack={document.status === DocumentStatus.DRAFT}
loading={isSubmitting}
disabled={isSubmitting}
onGoBackClick={previousStep}

View File

@ -1,5 +1,8 @@
import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
import { RecipientRole } from '.prisma/client';
export const ZAddSignersFormSchema = z
@ -11,6 +14,9 @@ export const ZAddSignersFormSchema = z
email: z.string().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
}),
),
})

View File

@ -1,33 +1,12 @@
'use client';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Info } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { Combobox } from '../combobox';
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { Label } from '../label';
@ -60,19 +39,14 @@ export const AddSubjectFormPartial = ({
onSubmit,
}: AddSubjectFormProps) => {
const {
control,
register,
handleSubmit,
formState: { errors, isSubmitting, touchedFields },
setValue,
formState: { errors, isSubmitting },
} = useForm<TAddSubjectFormSchema>({
defaultValues: {
meta: {
subject: document.documentMeta?.subject ?? '',
message: document.documentMeta?.message ?? '',
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: document.documentMeta?.redirectUrl ?? '',
},
},
resolver: zodResolver(ZAddSubjectFormSchema),
@ -81,20 +55,6 @@ export const AddSubjectFormPartial = ({
const onFormSubmit = handleSubmit(onSubmit);
const { currentStep, totalSteps, previousStep } = useStep();
const hasDateField = fields.find((field) => field.type === 'DATE');
const documentHasBeenSent = recipients.some(
(recipient) => recipient.sendStatus === SendStatus.SENT,
);
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
if (!touchedFields.meta?.timezone && !documentHasBeenSent) {
setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [documentHasBeenSent, setValue, touchedFields.meta?.timezone]);
return (
<>
<DocumentFlowFormContainerHeader
@ -167,95 +127,6 @@ export const AddSubjectFormPartial = ({
</li>
</ul>
</div>
<Accordion type="multiple" className="mt-8 border-none">
<AccordionItem value="advanced-options" className="border-none">
<AccordionTrigger className="mb-2 border-b text-left hover:no-underline">
Advanced Options
</AccordionTrigger>
<AccordionContent className="text-muted-foreground -mx-1 flex max-w-prose flex-col px-1 pt-2 text-sm leading-relaxed">
{hasDateField && (
<>
<div className="flex flex-col">
<Label htmlFor="date-format">
Date Format <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name={`meta.dateFormat`}
disabled={documentHasBeenSent}
render={({ field: { value, onChange, disabled } }) => (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="bg-background mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="mt-4 flex flex-col">
<Label htmlFor="time-zone">
Time Zone <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name={`meta.timezone`}
render={({ field: { value, onChange } }) => (
<Combobox
className="bg-background"
options={TIME_ZONES}
value={value}
onChange={(value) => value && onChange(value)}
disabled={documentHasBeenSent}
/>
)}
/>
</div>
</>
)}
<div className="mt-2 flex flex-col">
<div className="flex flex-col gap-y-4">
<div>
<Label htmlFor="redirectUrl" className="flex items-center">
Redirect URL{' '}
<Tooltip>
<TooltipTrigger>
<Info className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
Add a URL to redirect the user to once the document is signed
</TooltipContent>
</Tooltip>
</Label>
<Input
id="redirectUrl"
type="url"
className="bg-background my-2"
{...register('meta.redirectUrl')}
/>
<FormErrorMessage className="mt-2" error={errors.meta?.redirectUrl} />
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</DocumentFlowFormContainerContent>

View File

@ -1,21 +1,9 @@
import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
export const ZAddSubjectFormSchema = z.object({
meta: z.object({
subject: z.string(),
message: z.string(),
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});

View File

@ -1,103 +0,0 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import type { Field, Recipient } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { Label } from '../label';
import { useStep } from '../stepper';
import type { TAddTitleFormSchema } from './add-title.types';
import { ZAddTitleFormSchema } from './add-title.types';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
import type { DocumentFlowStep } from './types';
export type AddTitleFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
document: DocumentWithData;
onSubmit: (_data: TAddTitleFormSchema) => void;
};
export const AddTitleFormPartial = ({
documentFlow,
recipients,
fields,
document,
onSubmit,
}: AddTitleFormProps) => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddTitleFormSchema>({
resolver: zodResolver(ZAddTitleFormSchema),
defaultValues: {
title: document.title,
},
});
const onFormSubmit = handleSubmit(onSubmit);
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
return (
<>
<DocumentFlowFormContainerHeader
title={documentFlow.title}
description={documentFlow.description}
/>
<DocumentFlowFormContainerContent>
{fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
<div className="flex flex-col">
<div className="flex flex-col gap-y-4">
<div>
<Label htmlFor="title">
Title<span className="text-destructive ml-1 inline-block font-medium">*</span>
</Label>
<Input
id="title"
className="bg-background my-2"
disabled={isSubmitting}
{...register('title')}
/>
<FormErrorMessage className="mt-2" error={errors.title} />
</div>
</div>
</div>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={currentStep}
maxStep={totalSteps}
/>
<DocumentFlowFormContainerActions
loading={isSubmitting}
disabled={isSubmitting}
canGoBack={stepIndex !== 0}
onGoBackClick={previousStep}
onGoNextClick={() => void onFormSubmit()}
/>
</DocumentFlowFormContainerFooter>
</>
);
};

View File

@ -1,7 +0,0 @@
import { z } from 'zod';
export const ZAddTitleFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
});
export type TAddTitleFormSchema = z.infer<typeof ZAddTitleFormSchema>;

View File

@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
'bg-background border-input ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'bg-background border-input ring-offset-background placeholder:text-muted-foreground/40 focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
{
'ring-2 !ring-red-500 transition-all': props['aria-invalid'],