mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 15:53:02 +10:00
feat: add document auth
This commit is contained in:
31
packages/lib/constants/document-auth.ts
Normal file
31
packages/lib/constants/document-auth.ts
Normal 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>;
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
86
packages/lib/server-only/document/is-recipient-authorized.ts
Normal file
86
packages/lib/server-only/document/is-recipient-authorized.ts
Normal 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();
|
||||
};
|
||||
162
packages/lib/server-only/document/update-document-settings.ts
Normal file
162
packages/lib/server-only/document/update-document-settings.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
121
packages/lib/types/document-auth.ts
Normal file
121
packages/lib/types/document-auth.ts
Normal 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>;
|
||||
@ -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',
|
||||
|
||||
72
packages/lib/utils/document-auth.ts
Normal file
72
packages/lib/utils/document-auth.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Document" ADD COLUMN "authOptions" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Recipient" ADD COLUMN "authOptions" JSONB;
|
||||
@ -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)
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<
|
||||
|
||||
@ -23,7 +23,6 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next }) => {
|
||||
return await next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
|
||||
user: ctx.user,
|
||||
session: ctx.session,
|
||||
},
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
351
packages/ui/primitives/document-flow/add-settings.tsx
Normal file
351
packages/ui/primitives/document-flow/add-settings.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
42
packages/ui/primitives/document-flow/add-settings.types.ts
Normal file
42
packages/ui/primitives/document-flow/add-settings.types.ts
Normal 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>;
|
||||
@ -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}
|
||||
|
||||
@ -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(),
|
||||
),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>;
|
||||
@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user