Merge branch 'main' into feat/expiry-links

This commit is contained in:
Lucas Smith
2025-10-06 16:39:34 +11:00
committed by GitHub
183 changed files with 10954 additions and 1513 deletions

View File

@ -0,0 +1 @@
export const TWO_FACTOR_EMAIL_EXPIRATION_MINUTES = 5;

View File

@ -0,0 +1,38 @@
import { hmac } from '@noble/hashes/hmac';
import { sha256 } from '@noble/hashes/sha256';
import { createTOTPKeyURI } from 'oslo/otp';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../../constants/crypto';
const ISSUER = 'Documenso Email 2FA';
export type GenerateTwoFactorCredentialsFromEmailOptions = {
documentId: number;
email: string;
};
/**
* Generate an encrypted token containing a 6-digit 2FA code for email verification.
*
* @param options - The options for generating the token
* @returns Object containing the token and the 6-digit code
*/
export const generateTwoFactorCredentialsFromEmail = ({
documentId,
email,
}: GenerateTwoFactorCredentialsFromEmailOptions) => {
if (!DOCUMENSO_ENCRYPTION_KEY) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
}
const identity = `email-2fa|v1|email:${email}|id:${documentId}`;
const secret = hmac(sha256, DOCUMENSO_ENCRYPTION_KEY, identity);
const uri = createTOTPKeyURI(ISSUER, email, secret);
return {
uri,
secret,
};
};

View File

@ -0,0 +1,23 @@
import { generateHOTP } from 'oslo/otp';
import { generateTwoFactorCredentialsFromEmail } from './generate-2fa-credentials-from-email';
export type GenerateTwoFactorTokenFromEmailOptions = {
documentId: number;
email: string;
period?: number;
};
export const generateTwoFactorTokenFromEmail = async ({
email,
documentId,
period = 30_000,
}: GenerateTwoFactorTokenFromEmailOptions) => {
const { secret } = generateTwoFactorCredentialsFromEmail({ email, documentId });
const counter = Math.floor(Date.now() / period);
const token = await generateHOTP(secret, counter);
return token;
};

View File

@ -0,0 +1,124 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { getEmailContext } from '../../email/get-email-context';
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from './constants';
import { generateTwoFactorTokenFromEmail } from './generate-2fa-token-from-email';
export type Send2FATokenEmailOptions = {
token: string;
documentId: number;
};
export const send2FATokenEmail = async ({ token, documentId }: Send2FATokenEmailOptions) => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
recipients: {
some: {
token,
},
},
},
include: {
recipients: {
where: {
token,
},
},
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
},
},
},
});
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const [recipient] = document.recipients;
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const twoFactorTokenToken = await generateTwoFactorTokenFromEmail({
documentId,
email: recipient.email,
});
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta,
});
const i18n = await getI18nInstance(emailLanguage);
const subject = i18n._(msg`Your two-factor authentication code`);
const template = createElement(AccessAuth2FAEmailTemplate, {
documentTitle: document.title,
userName: recipient.name,
userEmail: recipient.email,
code: twoFactorTokenToken,
expiresInMinutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject,
html,
text,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
documentId: document.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
},
}),
});
},
{ timeout: 30_000 },
);
};

View File

@ -0,0 +1,37 @@
import { generateHOTP } from 'oslo/otp';
import { generateTwoFactorCredentialsFromEmail } from './generate-2fa-credentials-from-email';
export type ValidateTwoFactorTokenFromEmailOptions = {
documentId: number;
email: string;
code: string;
period?: number;
window?: number;
};
export const validateTwoFactorTokenFromEmail = async ({
documentId,
email,
code,
period = 30_000,
window = 1,
}: ValidateTwoFactorTokenFromEmailOptions) => {
const { secret } = generateTwoFactorCredentialsFromEmail({ email, documentId });
let now = Date.now();
for (let i = 0; i < window; i++) {
const counter = Math.floor(now / period);
const hotp = await generateHOTP(secret, counter);
if (code === hotp) {
return true;
}
now -= period;
}
return false;
};

View File

@ -8,7 +8,10 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { env } from '../../utils/env';
import {
DOCUMENSO_INTERNAL_EMAIL,
USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
} from '../../constants/email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendConfirmationEmailProps {
@ -16,15 +19,15 @@ export interface SendConfirmationEmailProps {
}
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
const NEXT_PRIVATE_SMTP_FROM_NAME = env('NEXT_PRIVATE_SMTP_FROM_NAME');
const NEXT_PRIVATE_SMTP_FROM_ADDRESS = env('NEXT_PRIVATE_SMTP_FROM_ADDRESS');
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
verificationTokens: {
where: {
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
orderBy: {
createdAt: 'desc',
},
@ -41,8 +44,6 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
const senderName = NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
const senderAddress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
assetBaseUrl,
@ -61,10 +62,7 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
address: user.email,
name: user.name || '',
},
from: {
name: senderName,
address: senderAddress,
},
from: DOCUMENSO_INTERNAL_EMAIL,
subject: i18n._(msg`Please confirm your email`),
html,
text,

View File

@ -0,0 +1,28 @@
import * as fs from 'node:fs';
import { env } from '@documenso/lib/utils/env';
export const getCertificateStatus = () => {
if (env('NEXT_PRIVATE_SIGNING_TRANSPORT') !== 'local') {
return { isAvailable: true };
}
if (env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS')) {
return { isAvailable: true };
}
const defaultPath =
env('NODE_ENV') === 'production' ? '/opt/documenso/cert.p12' : './example/cert.p12';
const filePath = env('NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH') || defaultPath;
try {
fs.accessSync(filePath, fs.constants.F_OK | fs.constants.R_OK);
const stats = fs.statSync(filePath);
return { isAvailable: stats.size > 0 };
} catch {
return { isAvailable: false };
}
};

View File

@ -18,7 +18,8 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import type { TRecipientActionAuth } from '../../types/document-auth';
import type { TRecipientAccessAuth, TRecipientActionAuth } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
@ -26,6 +27,7 @@ import {
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { isRecipientAuthorized } from './is-recipient-authorized';
import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
@ -33,6 +35,7 @@ export type CompleteDocumentWithTokenOptions = {
documentId: number;
userId?: number;
authOptions?: TRecipientActionAuth;
accessAuthOptions?: TRecipientAccessAuth;
requestMetadata?: RequestMetadata;
nextSigner?: {
email: string;
@ -64,6 +67,8 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
export const completeDocumentWithToken = async ({
token,
documentId,
userId,
accessAuthOptions,
requestMetadata,
nextSigner,
}: CompleteDocumentWithTokenOptions) => {
@ -111,24 +116,57 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
// Document reauth for completing documents is currently not required.
// Check ACCESS AUTH 2FA validation during document completion
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
// const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
// documentAuth: document.authOptions,
// recipientAuth: recipient.authOptions,
// });
if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
if (!accessAuthOptions) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Access authentication required',
});
}
// const isValid = await isRecipientAuthorized({
// type: 'ACTION',
// document: document,
// recipient: recipient,
// userId,
// authOptions,
// });
const isValid = await isRecipientAuthorized({
type: 'ACCESS_2FA',
documentAuthOptions: document.authOptions,
recipient: recipient,
userId, // Can be undefined for non-account recipients
authOptions: accessAuthOptions,
});
// if (!isValid) {
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
// }
if (!isValid) {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED,
documentId: document.id,
data: {
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
},
}),
});
throw new AppError(AppErrorCode.TWO_FACTOR_AUTH_FAILED, {
message: 'Invalid 2FA authentication',
});
}
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED,
documentId: document.id,
data: {
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
},
}),
});
}
await prisma.$transaction(async (tx) => {
await tx.recipient.update({

View File

@ -91,6 +91,12 @@ export const getDocumentAndSenderByToken = async ({
select: {
name: true,
teamEmail: true,
teamGlobalSettings: {
select: {
brandingEnabled: true,
brandingLogo: true,
},
},
},
},
},

View File

@ -4,6 +4,7 @@ import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { validateTwoFactorTokenFromEmail } from '../2fa/email/validate-2fa-token-from-email';
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
import { verifyPassword } from '../2fa/verify-password';
import { AppError, AppErrorCode } from '../../errors/app-error';
@ -14,9 +15,10 @@ import { getAuthenticatorOptions } from '../../utils/authenticator';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
type IsRecipientAuthorizedOptions = {
type: 'ACCESS' | 'ACTION';
// !: Probably find a better name than 'ACCESS_2FA' if requirements change.
type: 'ACCESS' | 'ACCESS_2FA' | 'ACTION';
documentAuthOptions: Document['authOptions'];
recipient: Pick<Recipient, 'authOptions' | 'email'>;
recipient: Pick<Recipient, 'authOptions' | 'email' | 'documentId'>;
/**
* The ID of the user who initiated the request.
@ -61,8 +63,11 @@ export const isRecipientAuthorized = async ({
recipientAuth: recipient.authOptions,
});
const authMethods: TDocumentAuth[] =
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
const authMethods: TDocumentAuth[] = match(type)
.with('ACCESS', () => derivedRecipientAccessAuth)
.with('ACCESS_2FA', () => derivedRecipientAccessAuth)
.with('ACTION', () => derivedRecipientActionAuth)
.exhaustive();
// Early true return when auth is not required.
if (
@ -72,6 +77,11 @@ export const isRecipientAuthorized = async ({
return true;
}
// Early true return for ACCESS auth if all methods are 2FA since validation happens in ACCESS_2FA.
if (type === 'ACCESS' && authMethods.every((method) => method === DocumentAuth.TWO_FACTOR_AUTH)) {
return true;
}
// Create auth options when none are passed for account.
if (!authOptions && authMethods.some((method) => method === DocumentAuth.ACCOUNT)) {
authOptions = {
@ -80,12 +90,16 @@ export const isRecipientAuthorized = async ({
}
// Authentication required does not match provided method.
if (!authOptions || !authMethods.includes(authOptions.type) || !userId) {
if (!authOptions || !authMethods.includes(authOptions.type)) {
return false;
}
return await match(authOptions)
.with({ type: DocumentAuth.ACCOUNT }, async () => {
if (!userId) {
return false;
}
const recipientUser = await getUserByEmail(recipient.email);
if (!recipientUser) {
@ -95,13 +109,40 @@ export const isRecipientAuthorized = async ({
return recipientUser.id === userId;
})
.with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
if (!userId) {
return false;
}
return await isPasskeyAuthValid({
userId,
authenticationResponse,
tokenReference,
});
})
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token }) => {
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token, method }) => {
if (type === 'ACCESS') {
return true;
}
if (type === 'ACCESS_2FA' && method === 'email') {
if (!recipient.documentId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document ID is required for email 2FA verification',
});
}
return await validateTwoFactorTokenFromEmail({
documentId: recipient.documentId,
email: recipient.email,
code: token,
window: 10, // 5 minutes worth of tokens
});
}
if (!userId) {
return false;
}
const user = await prisma.user.findFirst({
where: {
id: userId,
@ -115,6 +156,7 @@ export const isRecipientAuthorized = async ({
});
}
// For ACTION auth or authenticator method, use TOTP
return await verifyTwoFactorAuthenticationToken({
user,
totpCode: token,
@ -122,6 +164,10 @@ export const isRecipientAuthorized = async ({
});
})
.with({ type: DocumentAuth.PASSWORD }, async ({ password }) => {
if (!userId) {
return false;
}
return await verifyPassword({
userId,
password,

View File

@ -7,7 +7,7 @@ import { isRecipientAuthorized } from './is-recipient-authorized';
export type ValidateFieldAuthOptions = {
documentAuthOptions: Document['authOptions'];
recipient: Pick<Recipient, 'authOptions' | 'email'>;
recipient: Pick<Recipient, 'authOptions' | 'email' | 'documentId'>;
field: Field;
userId?: number;
authOptions?: TRecipientActionAuth;

View File

@ -84,9 +84,7 @@ export const setFieldsForDocument = async ({
const linkedFields = fields.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id);
const recipient = document.recipients.find(
(recipient) => recipient.email.toLowerCase() === field.signerEmail.toLowerCase(),
);
const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
// Each field MUST have a recipient associated with it.
if (!recipient) {
@ -226,10 +224,8 @@ export const setFieldsForDocument = async ({
},
recipient: {
connect: {
documentId_email: {
documentId,
email: fieldSignerEmail,
},
id: field.recipientId,
documentId,
},
},
},
@ -330,6 +326,7 @@ type FieldData = {
id?: number | null;
type: FieldType;
signerEmail: string;
recipientId: number;
pageNumber: number;
pageX: number;
pageY: number;

View File

@ -26,6 +26,7 @@ export type SetFieldsForTemplateOptions = {
id?: number | null;
type: FieldType;
signerEmail: string;
recipientId: number;
pageNumber: number;
pageX: number;
pageY: number;
@ -169,10 +170,8 @@ export const setFieldsForTemplate = async ({
},
recipient: {
connect: {
templateId_email: {
templateId,
email: field.signerEmail.toLowerCase(),
},
id: field.recipientId,
templateId,
},
},
},

View File

@ -1,3 +1,4 @@
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@ -23,11 +24,7 @@ export const acceptOrganisationInvitation = async ({
include: {
organisation: {
include: {
groups: {
include: {
teamGroups: true,
},
},
groups: true,
},
},
},
@ -45,6 +42,9 @@ export const acceptOrganisationInvitation = async ({
where: {
email: organisationMemberInvite.email,
},
select: {
id: true,
},
});
if (!user) {
@ -55,10 +55,49 @@ export const acceptOrganisationInvitation = async ({
const { organisation } = organisationMemberInvite;
const organisationGroupToUse = organisation.groups.find(
const isUserPartOfOrganisation = await prisma.organisationMember.findFirst({
where: {
userId: user.id,
organisationId: organisation.id,
},
});
if (isUserPartOfOrganisation) {
return;
}
await addUserToOrganisation({
userId: user.id,
organisationId: organisation.id,
organisationGroups: organisation.groups,
organisationMemberRole: organisationMemberInvite.organisationRole,
});
await prisma.organisationMemberInvite.update({
where: {
id: organisationMemberInvite.id,
},
data: {
status: OrganisationMemberInviteStatus.ACCEPTED,
},
});
};
export const addUserToOrganisation = async ({
userId,
organisationId,
organisationGroups,
organisationMemberRole,
}: {
userId: number;
organisationId: string;
organisationGroups: OrganisationGroup[];
organisationMemberRole: OrganisationMemberRole;
}) => {
const organisationGroupToUse = organisationGroups.find(
(group) =>
group.type === OrganisationGroupType.INTERNAL_ORGANISATION &&
group.organisationRole === organisationMemberInvite.organisationRole,
group.organisationRole === organisationMemberRole,
);
if (!organisationGroupToUse) {
@ -72,8 +111,8 @@ export const acceptOrganisationInvitation = async ({
await tx.organisationMember.create({
data: {
id: generateDatabaseId('member'),
userId: user.id,
organisationId: organisation.id,
userId,
organisationId,
organisationGroupMembers: {
create: {
id: generateDatabaseId('group_member'),
@ -83,20 +122,11 @@ export const acceptOrganisationInvitation = async ({
},
});
await tx.organisationMemberInvite.update({
where: {
id: organisationMemberInvite.id,
},
data: {
status: OrganisationMemberInviteStatus.ACCEPTED,
},
});
await jobs.triggerJob({
name: 'send.organisation-member-joined.email',
payload: {
organisationId: organisation.id,
memberUserId: user.id,
organisationId,
memberUserId: userId,
},
});
},

View File

@ -75,6 +75,16 @@ export const createOrganisation = async ({
},
});
const organisationAuthenticationPortal = await tx.organisationAuthenticationPortal.create({
data: {
id: generateDatabaseId('org_sso'),
enabled: false,
clientId: '',
clientSecret: '',
wellKnownUrl: '',
},
});
const orgIdAndUrl = prefixedId('org');
const organisation = await tx.organisation
@ -87,6 +97,7 @@ export const createOrganisation = async ({
ownerUserId: userId,
organisationGlobalSettingsId: organisationSetting.id,
organisationClaimId: organisationClaim.id,
organisationAuthenticationPortalId: organisationAuthenticationPortal.id,
groups: {
create: ORGANISATION_INTERNAL_GROUPS.map((group) => ({
...group,

View File

@ -85,20 +85,6 @@ export const createDocumentRecipients = async ({
email: recipient.email.toLowerCase(),
}));
const duplicateRecipients = normalizedRecipients.filter((newRecipient) => {
const existingRecipient = document.recipients.find(
(existingRecipient) => existingRecipient.email === newRecipient.email,
);
return existingRecipient !== undefined;
});
if (duplicateRecipients.length > 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`,
});
}
const createdRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
normalizedRecipients.map(async (recipient) => {

View File

@ -71,20 +71,6 @@ export const createTemplateRecipients = async ({
email: recipient.email.toLowerCase(),
}));
const duplicateRecipients = normalizedRecipients.filter((newRecipient) => {
const existingRecipient = template.recipients.find(
(existingRecipient) => existingRecipient.email === newRecipient.email,
);
return existingRecipient !== undefined;
});
if (duplicateRecipients.length > 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`,
});
}
const createdRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
normalizedRecipients.map(async (recipient) => {

View File

@ -0,0 +1,108 @@
import { Prisma } from '@prisma/client';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
export type GetRecipientSuggestionsOptions = {
userId: number;
teamId?: number;
query: string;
};
export const getRecipientSuggestions = async ({
userId,
teamId,
query,
}: GetRecipientSuggestionsOptions) => {
const trimmedQuery = query.trim();
const nameEmailFilter = trimmedQuery
? {
OR: [
{
name: {
contains: trimmedQuery,
mode: Prisma.QueryMode.insensitive,
},
},
{
email: {
contains: trimmedQuery,
mode: Prisma.QueryMode.insensitive,
},
},
],
}
: {};
const recipients = await prisma.recipient.findMany({
where: {
document: {
team: buildTeamWhereQuery({ teamId, userId }),
},
...nameEmailFilter,
},
select: {
name: true,
email: true,
document: {
select: {
createdAt: true,
},
},
},
distinct: ['email'],
orderBy: {
document: {
createdAt: 'desc',
},
},
take: 5,
});
if (teamId) {
const teamMembers = await prisma.organisationMember.findMany({
where: {
user: {
...nameEmailFilter,
NOT: { id: userId },
},
organisationGroupMembers: {
some: {
group: {
teamGroups: {
some: { teamId },
},
},
},
},
},
include: {
user: {
select: {
email: true,
name: true,
},
},
},
take: 5,
});
const uniqueTeamMember = teamMembers.find(
(member) => !recipients.some((r) => r.email === member.user.email),
);
if (uniqueTeamMember) {
const teamMemberSuggestion = {
email: uniqueTeamMember.user.email,
name: uniqueTeamMember.user.name,
};
const allSuggestions = [...recipients.slice(0, 4), teamMemberSuggestion];
return allSuggestions;
}
}
return recipients;
};

View File

@ -122,16 +122,12 @@ export const setDocumentRecipients = async ({
const removedRecipients = existingRecipients.filter(
(existingRecipient) =>
!normalizedRecipients.find(
(recipient) =>
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
),
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
);
const linkedRecipients = normalizedRecipients.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
(existingRecipient) => existingRecipient.id === recipient.id,
);
const canPersistedRecipientBeModified =

View File

@ -94,10 +94,7 @@ export const setTemplateRecipients = async ({
const removedRecipients = existingRecipients.filter(
(existingRecipient) =>
!normalizedRecipients.find(
(recipient) =>
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
),
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
);
if (template.directLink !== null) {
@ -124,8 +121,7 @@ export const setTemplateRecipients = async ({
const linkedRecipients = normalizedRecipients.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
(existingRecipient) => existingRecipient.id === recipient.id,
);
return {

View File

@ -91,17 +91,6 @@ export const updateDocumentRecipients = async ({
});
}
const duplicateRecipientWithSameEmail = document.recipients.find(
(existingRecipient) =>
existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id,
);
if (duplicateRecipientWithSameEmail) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`,
});
}
if (!canRecipientBeModified(originalRecipient, document.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot modify a recipient who has already interacted with the document',

View File

@ -80,17 +80,6 @@ export const updateTemplateRecipients = async ({
});
}
const duplicateRecipientWithSameEmail = template.recipients.find(
(existingRecipient) =>
existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id,
);
if (duplicateRecipientWithSameEmail) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`,
});
}
return {
originalRecipient,
recipientUpdateData: recipient,

View File

@ -159,6 +159,7 @@ export const createDocumentFromDirectTemplate = async ({
// Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
.with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail)
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct templates
.with(undefined, () => true)
.exhaustive();
@ -205,6 +206,7 @@ export const createDocumentFromDirectTemplate = async ({
recipient: {
authOptions: directTemplateRecipient.authOptions,
email: directRecipientEmail,
documentId: template.id,
},
field: templateField,
userId: user?.id,

View File

@ -19,6 +19,8 @@ export type CreateDocumentFromTemplateLegacyOptions = {
}[];
};
// !TODO: Make this work
/**
* Legacy server function for /api/v1
*/
@ -58,6 +60,15 @@ export const createDocumentFromTemplateLegacy = async ({
},
});
const recipientsToCreate = template.recipients.map((recipient) => ({
id: recipient.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
}));
const document = await prisma.document.create({
data: {
qrToken: prefixedId('qr'),
@ -70,12 +81,12 @@ export const createDocumentFromTemplateLegacy = async ({
documentDataId: documentData.id,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
recipients: {
create: template.recipients.map((recipient) => ({
create: recipientsToCreate.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
token: recipient.token,
})),
},
documentMeta: {
@ -95,9 +106,11 @@ export const createDocumentFromTemplateLegacy = async ({
await prisma.field.createMany({
data: template.fields.map((field) => {
const recipient = template.recipients.find((recipient) => recipient.id === field.recipientId);
const recipient = recipientsToCreate.find((recipient) => recipient.id === field.recipientId);
const documentRecipient = document.recipients.find((doc) => doc.email === recipient?.email);
const documentRecipient = document.recipients.find(
(documentRecipient) => documentRecipient.token === recipient?.token,
);
if (!documentRecipient) {
throw new Error('Recipient not found.');
@ -118,28 +131,32 @@ export const createDocumentFromTemplateLegacy = async ({
}),
});
// Replicate the old logic, get by index and create if we exceed the number of existing recipients.
if (recipients && recipients.length > 0) {
document.recipients = await Promise.all(
await Promise.all(
recipients.map(async (recipient, index) => {
const existingRecipient = document.recipients.at(index);
return await prisma.recipient.upsert({
where: {
documentId_email: {
if (existingRecipient) {
return await prisma.recipient.update({
where: {
id: existingRecipient.id,
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
},
create: {
data: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
},
});
}
return await prisma.recipient.create({
data: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
@ -149,5 +166,18 @@ export const createDocumentFromTemplateLegacy = async ({
);
}
return document;
// Gross but we need to do the additional fetch since we mutate above.
const updatedRecipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
orderBy: {
id: 'asc',
},
});
return {
...document,
recipients: updatedRecipients,
};
};

View File

@ -54,7 +54,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type FinalRecipient = Pick<
Recipient,
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder'
'name' | 'email' | 'role' | 'authOptions' | 'signingOrder' | 'token'
> & {
templateRecipientId: number;
fields: Field[];
@ -353,6 +353,7 @@ export const createDocumentFromTemplate = async ({
role: templateRecipient.role,
signingOrder: foundRecipient?.signingOrder ?? templateRecipient.signingOrder,
authOptions: templateRecipient.authOptions,
token: nanoid(),
};
});
@ -459,7 +460,7 @@ export const createDocumentFromTemplate = async ({
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
expired: recipientExpiryDate,
token: nanoid(),
token: recipient.token,
};
}),
},
@ -518,8 +519,8 @@ export const createDocumentFromTemplate = async ({
}
}
Object.values(finalRecipients).forEach(({ email, fields }) => {
const recipient = document.recipients.find((recipient) => recipient.email === email);
Object.values(finalRecipients).forEach(({ token, fields }) => {
const recipient = document.recipients.find((recipient) => recipient.token === token);
if (!recipient) {
throw new Error('Recipient not found.');

View File

@ -1,41 +0,0 @@
import crypto from 'crypto';
import { prisma } from '@documenso/prisma';
import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
const IDENTIFIER = 'confirmation-email';
export const generateConfirmationToken = async ({ email }: { email: string }) => {
const token = crypto.randomBytes(20).toString('hex');
const user = await prisma.user.findFirst({
where: {
email: email,
},
});
if (!user) {
throw new Error('User not found');
}
const createdToken = await prisma.verificationToken.create({
data: {
identifier: IDENTIFIER,
token: token,
expires: new Date(Date.now() + ONE_HOUR),
user: {
connect: {
id: user.id,
},
},
},
});
if (!createdToken) {
throw new Error(`Failed to create the verification token`);
}
return sendConfirmationEmail({ userId: user.id });
};

View File

@ -0,0 +1,21 @@
import { prisma } from '@documenso/prisma';
import { USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
export type getMostRecentEmailVerificationTokenOptions = {
userId: number;
};
export const getMostRecentEmailVerificationToken = async ({
userId,
}: getMostRecentEmailVerificationTokenOptions) => {
return await prisma.verificationToken.findFirst({
where: {
userId,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@ -1,18 +0,0 @@
import { prisma } from '@documenso/prisma';
export type GetMostRecentVerificationTokenByUserIdOptions = {
userId: number;
};
export const getMostRecentVerificationTokenByUserId = async ({
userId,
}: GetMostRecentVerificationTokenByUserIdOptions) => {
return await prisma.verificationToken.findFirst({
where: {
userId,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@ -3,11 +3,10 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
import { getMostRecentVerificationTokenByUserId } from './get-most-recent-verification-token-by-user-id';
const IDENTIFIER = 'confirmation-email';
import { getMostRecentEmailVerificationToken } from './get-most-recent-email-verification-token';
type SendConfirmationTokenOptions = { email: string; force?: boolean };
@ -31,7 +30,7 @@ export const sendConfirmationToken = async ({
throw new Error('Email verified');
}
const mostRecentToken = await getMostRecentVerificationTokenByUserId({ userId: user.id });
const mostRecentToken = await getMostRecentEmailVerificationToken({ userId: user.id });
// If we've sent a token in the last 5 minutes, don't send another one
if (
@ -44,7 +43,7 @@ export const sendConfirmationToken = async ({
const createdToken = await prisma.verificationToken.create({
data: {
identifier: IDENTIFIER,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
token: token,
expires: new Date(Date.now() + ONE_HOUR),
user: {

View File

@ -2,7 +2,10 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { EMAIL_VERIFICATION_STATE } from '../../constants/email';
import {
EMAIL_VERIFICATION_STATE,
USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
} from '../../constants/email';
import { jobsClient } from '../../jobs/client';
export type VerifyEmailProps = {
@ -22,6 +25,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
},
where: {
token,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
});