Merge branch 'main' into feat/change-radio-direction

This commit is contained in:
Ephraim Duncan
2025-10-07 20:32:45 +00:00
committed by GitHub
335 changed files with 15880 additions and 3438 deletions

View File

@ -0,0 +1,64 @@
import { useCallback, useEffect, useRef } from 'react';
type SaveRequest<T, R> = {
data: T;
onResponse?: (response: R) => void;
};
export const useAutoSave = <T, R = void>(
onSave: (data: T) => Promise<R>,
options: { delay?: number } = {},
) => {
const { delay = 2000 } = options;
const saveTimeoutRef = useRef<NodeJS.Timeout>();
const saveQueueRef = useRef<SaveRequest<T, R>[]>([]);
const isProcessingRef = useRef(false);
const processQueue = async () => {
if (isProcessingRef.current || saveQueueRef.current.length === 0) {
return;
}
isProcessingRef.current = true;
while (saveQueueRef.current.length > 0) {
const request = saveQueueRef.current.shift()!;
try {
const response = await onSave(request.data);
request.onResponse?.(response);
} catch (error) {
console.error('Auto-save failed:', error);
}
}
isProcessingRef.current = false;
};
const saveFormData = async (data: T, onResponse?: (response: R) => void) => {
saveQueueRef.current.push({ data, onResponse });
await processQueue();
};
const scheduleSave = useCallback(
(data: T, onResponse?: (response: R) => void) => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => void saveFormData(data, onResponse), delay);
},
[delay],
);
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
return { scheduleSave };
};

View File

@ -23,6 +23,9 @@ export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL');
export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
ACCOUNT_SSO_LINK: 'Linked account to SSO',
ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO',
ORGANISATION_SSO_LINK: 'Linked account to organisation',
ORGANISATION_SSO_UNLINK: 'Unlinked account from organisation',
ACCOUNT_PROFILE_UPDATE: 'Profile updated',
AUTH_2FA_DISABLE: '2FA Disabled',
AUTH_2FA_ENABLE: '2FA Enabled',

View File

@ -7,14 +7,25 @@ export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a';
export const VALID_DATE_FORMAT_VALUES = [
DEFAULT_DOCUMENT_DATE_FORMAT,
'yyyy-MM-dd',
'dd/MM/yyyy',
'MM/dd/yyyy',
'yy-MM-dd',
'MMMM dd, yyyy',
'EEEE, MMMM dd, yyyy',
'dd/MM/yyyy hh:mm a',
'dd/MM/yyyy HH:mm',
'MM/dd/yyyy hh:mm a',
'MM/dd/yyyy HH:mm',
'dd.MM.yyyy',
'dd.MM.yyyy HH:mm',
'yyyy-MM-dd HH:mm',
'yy-MM-dd hh:mm a',
'yy-MM-dd HH:mm',
'yyyy-MM-dd HH:mm:ss',
'MMMM dd, yyyy hh:mm a',
'MMMM dd, yyyy HH:mm',
'EEEE, MMMM dd, yyyy hh:mm a',
'EEEE, MMMM dd, yyyy HH:mm',
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
] as const;
@ -22,10 +33,80 @@ export type ValidDateFormat = (typeof VALID_DATE_FORMAT_VALUES)[number];
export const DATE_FORMATS = [
{
key: 'yyyy-MM-dd_hh:mm_a',
label: 'YYYY-MM-DD HH:mm a',
key: 'yyyy-MM-dd_HH:mm_12H',
label: 'YYYY-MM-DD hh:mm AM/PM',
value: DEFAULT_DOCUMENT_DATE_FORMAT,
},
{
key: 'yyyy-MM-dd_HH:mm',
label: 'YYYY-MM-DD HH:mm',
value: 'yyyy-MM-dd HH:mm',
},
{
key: 'DDMMYYYY_TIME',
label: 'DD/MM/YYYY HH:mm',
value: 'dd/MM/yyyy HH:mm',
},
{
key: 'DDMMYYYY_TIME_12H',
label: 'DD/MM/YYYY HH:mm AM/PM',
value: 'dd/MM/yyyy hh:mm a',
},
{
key: 'MMDDYYYY_TIME',
label: 'MM/DD/YYYY HH:mm',
value: 'MM/dd/yyyy HH:mm',
},
{
key: 'MMDDYYYY_TIME_12H',
label: 'MM/DD/YYYY HH:mm AM/PM',
value: 'MM/dd/yyyy hh:mm a',
},
{
key: 'DDMMYYYYHHMM',
label: 'DD.MM.YYYY HH:mm',
value: 'dd.MM.yyyy HH:mm',
},
{
key: 'YYMMDD_TIME',
label: 'YY-MM-DD HH:mm',
value: 'yy-MM-dd HH:mm',
},
{
key: 'YYMMDD_TIME_12H',
label: 'YY-MM-DD HH:mm AM/PM',
value: 'yy-MM-dd hh:mm a',
},
{
key: 'YYYY_MM_DD_HH_MM_SS',
label: 'YYYY-MM-DD HH:mm:ss',
value: 'yyyy-MM-dd HH:mm:ss',
},
{
key: 'MonthDateYear_TIME',
label: 'Month Date, Year HH:mm',
value: 'MMMM dd, yyyy HH:mm',
},
{
key: 'MonthDateYear_TIME_12H',
label: 'Month Date, Year HH:mm AM/PM',
value: 'MMMM dd, yyyy hh:mm a',
},
{
key: 'DayMonthYear_TIME',
label: 'Day, Month Year HH:mm',
value: 'EEEE, MMMM dd, yyyy HH:mm',
},
{
key: 'DayMonthYear_TIME_12H',
label: 'Day, Month Year HH:mm AM/PM',
value: 'EEEE, MMMM dd, yyyy hh:mm a',
},
{
key: 'ISO8601',
label: 'ISO 8601',
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
},
{
key: 'YYYYMMDD',
label: 'YYYY-MM-DD',
@ -34,47 +115,32 @@ export const DATE_FORMATS = [
{
key: 'DDMMYYYY',
label: 'DD/MM/YYYY',
value: 'dd/MM/yyyy hh:mm a',
value: 'dd/MM/yyyy',
},
{
key: 'MMDDYYYY',
label: 'MM/DD/YYYY',
value: 'MM/dd/yyyy hh:mm a',
value: 'MM/dd/yyyy',
},
{
key: 'DDMMYYYYHHMM',
label: 'DD.MM.YYYY HH:mm',
value: 'dd.MM.yyyy HH:mm',
},
{
key: 'YYYYMMDDHHmm',
label: 'YYYY-MM-DD HH:mm',
value: 'yyyy-MM-dd HH:mm',
key: 'DDMMYYYY_DOT',
label: 'DD.MM.YYYY',
value: 'dd.MM.yyyy',
},
{
key: 'YYMMDD',
label: 'YY-MM-DD',
value: 'yy-MM-dd hh:mm a',
},
{
key: 'YYYYMMDDhhmmss',
label: 'YYYY-MM-DD HH:mm:ss',
value: 'yyyy-MM-dd HH:mm:ss',
value: 'yy-MM-dd',
},
{
key: 'MonthDateYear',
label: 'Month Date, Year',
value: 'MMMM dd, yyyy hh:mm a',
value: 'MMMM dd, yyyy',
},
{
key: 'DayMonthYear',
label: 'Day, Month Year',
value: 'EEEE, MMMM dd, yyyy hh:mm a',
},
{
key: 'ISO8601',
label: 'ISO 8601',
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
value: 'EEEE, MMMM dd, yyyy',
},
] satisfies {
key: string;

View File

@ -16,3 +16,5 @@ export const EMAIL_VERIFICATION_STATE = {
EXPIRED: 'EXPIRED',
ALREADY_VERIFIED: 'ALREADY_VERIFIED',
} as const;
export const USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER = 'confirmation-email';

View File

@ -126,3 +126,7 @@ export const PROTECTED_ORGANISATION_URLS = [
export const isOrganisationUrlProtected = (url: string) => {
return PROTECTED_ORGANISATION_URLS.some((protectedUrl) => url.startsWith(`/${protectedUrl}`));
};
export const ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER = 'organisation-account-link';
export const ORGANISATION_USER_ACCOUNT_TYPE = 'org-oidc';

View File

@ -17,6 +17,7 @@ export enum AppErrorCode {
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
'SCHEMA_FAILED' = 'SCHEMA_FAILED',
'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS',
'TWO_FACTOR_AUTH_FAILED' = 'TWO_FACTOR_AUTH_FAILED',
}
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> =
@ -32,6 +33,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.RETRY_EXCEPTION]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
[AppErrorCode.TWO_FACTOR_AUTH_FAILED]: { code: 'UNAUTHORIZED', status: 401 },
};
export const ZAppErrorJsonSchema = z.object({

View File

@ -29,7 +29,13 @@ export const run = async ({
id: documentId,
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
recipients: true,
team: {

View File

@ -39,7 +39,13 @@ export const run = async ({
},
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
},
},
@ -51,7 +57,13 @@ export const run = async ({
organisationId: payload.organisationId,
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});

View File

@ -39,7 +39,13 @@ export const run = async ({
},
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
},
},
@ -49,6 +55,11 @@ export const run = async ({
where: {
id: payload.memberUserId,
},
select: {
id: true,
email: true,
name: true,
},
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({

View File

@ -38,7 +38,13 @@ export const run = async ({
id: recipientId,
},
},
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
},
});

View File

@ -33,7 +33,13 @@ export const run = async ({
id: documentId,
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
team: {
select: {

View File

@ -42,6 +42,11 @@ export const run = async ({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
}),
prisma.document.findFirstOrThrow({
where: {

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

@ -10,13 +10,7 @@ export type UpdateUserOptions = {
};
export const updateUser = async ({ id, name, email, roles }: UpdateUserOptions) => {
await prisma.user.findFirstOrThrow({
where: {
id,
},
});
return await prisma.user.update({
await prisma.user.update({
where: {
id,
},

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

@ -15,7 +15,7 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { TCreateDocumentV2Request } from '@documenso/trpc/server/document-router/schema';
import type { TCreateDocumentTemporaryRequest } from '@documenso/trpc/server/document-router/create-document-temporary.types';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values';
@ -45,7 +45,7 @@ export type CreateDocumentOptions = {
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients'];
recipients: TCreateDocumentTemporaryRequest['recipients'];
folderId?: string;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;

View File

@ -49,6 +49,11 @@ export const findDocuments = async ({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
});
let team = null;
@ -267,7 +272,7 @@ export const findDocuments = async ({
const findDocumentsFilter = (
status: ExtendedDocumentStatus,
user: User,
user: Pick<User, 'id' | 'email' | 'name'>,
folderId?: string | null,
) => {
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)

View File

@ -73,7 +73,13 @@ export const getDocumentAndSenderByToken = async ({
},
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentData: true,
documentMeta: true,
recipients: {
@ -85,14 +91,17 @@ export const getDocumentAndSenderByToken = async ({
select: {
name: true,
teamEmail: true,
teamGlobalSettings: {
select: {
brandingEnabled: true,
brandingLogo: true,
},
},
},
},
},
});
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const { password: _password, ...user } = result.user;
const recipient = result.recipients[0];
// Sanity check, should not be possible.
@ -120,7 +129,11 @@ export const getDocumentAndSenderByToken = async ({
return {
...result,
user,
user: {
id: result.user.id,
email: result.user.email,
name: result.user.name,
},
};
};

View File

@ -7,14 +7,12 @@ export type GetDocumentWithDetailsByIdOptions = {
documentId: number;
userId: number;
teamId: number;
folderId?: string;
};
export const getDocumentWithDetailsById = async ({
documentId,
userId,
teamId,
folderId,
}: GetDocumentWithDetailsByIdOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
documentId,
@ -25,7 +23,6 @@ export const getDocumentWithDetailsById = async ({
const document = await prisma.document.findFirst({
where: {
...documentWhereInput,
folderId,
},
include: {
documentData: 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

@ -28,13 +28,7 @@ export async function rejectDocumentWithToken({
documentId,
},
include: {
document: {
include: {
user: true,
recipients: true,
documentMeta: true,
},
},
document: true,
},
});

View File

@ -33,7 +33,13 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
documentData: true,
documentMeta: true,
recipients: true,
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
team: {
select: {
id: true,

View File

@ -24,7 +24,13 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
id: documentId,
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
},
});

View File

@ -148,33 +148,6 @@ export const sendDocument = async ({
// throw new Error('Some signers have not been assigned a signature field.');
// }
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
// Only send email if one of the following is true:
// - It is explicitly set
// - The email is enabled for signing requests AND sendEmail is undefined
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId,
documentId,
recipientId: recipient.id,
requestMetadata: requestMetadata?.requestMetadata,
},
});
}),
);
}
const allRecipientsHaveNoActionToTake = document.recipients.every(
(recipient) =>
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
@ -227,6 +200,33 @@ export const sendDocument = async ({
});
});
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
).recipientSigningRequest;
// Only send email if one of the following is true:
// - It is explicitly set
// - The email is enabled for signing requests AND sendEmail is undefined
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId,
documentId,
recipientId: recipient.id,
requestMetadata: requestMetadata?.requestMetadata,
},
});
}),
);
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SENT,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),

View File

@ -30,7 +30,13 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
include: {
recipients: true,
documentMeta: true,
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});

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

@ -1,3 +1,5 @@
import { P, match } from 'ts-pattern';
import type { BrandingSettings } from '@documenso/email/providers/branding';
import { prisma } from '@documenso/prisma';
import type {
@ -104,7 +106,12 @@ export const getEmailContext = async (
}
const replyToEmail = meta?.emailReplyTo || emailContext.settings.emailReplyTo || undefined;
const senderEmailId = meta?.emailId === null ? null : emailContext.settings.emailId;
const senderEmailId = match(meta?.emailId)
.with(P.string, (emailId) => emailId) // Explicit string means to use the provided email ID.
.with(undefined, () => emailContext.settings.emailId) // Undefined means to use the inherited email ID.
.with(null, () => null) // Explicit null means to use the Documenso email.
.exhaustive();
const foundSenderEmail = emailContext.allowedEmails.find((email) => email.id === senderEmailId);

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

@ -25,7 +25,7 @@ export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOpt
});
}
return await prisma.apiToken.delete({
await prisma.apiToken.delete({
where: {
id,
teamId,

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

@ -1,5 +1,7 @@
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface GetRecipientsForTemplateOptions {
templateId: number;
userId: number;
@ -14,21 +16,12 @@ export const getRecipientsForTemplate = async ({
const recipients = await prisma.recipient.findMany({
where: {
templateId,
template: teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
},
template: {
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
},
orderBy: {
id: 'asc',

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

@ -105,7 +105,13 @@ export const createDocumentFromDirectTemplate = async ({
directLink: true,
templateDocumentData: true,
templateMeta: true,
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});
@ -153,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();
@ -199,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

@ -53,7 +53,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[];
@ -350,6 +350,7 @@ export const createDocumentFromTemplate = async ({
role: templateRecipient.role,
signingOrder: foundRecipient?.signingOrder ?? templateRecipient.signingOrder,
authOptions: templateRecipient.authOptions,
token: nanoid(),
};
});
@ -441,7 +442,7 @@ export const createDocumentFromTemplate = async ({
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
token: nanoid(),
token: recipient.token,
};
}),
},
@ -500,8 +501,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

@ -1,13 +1,31 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface GetUserByIdOptions {
id: number;
}
export const getUserById = async ({ id }: GetUserByIdOptions) => {
return await prisma.user.findFirstOrThrow({
const user = await prisma.user.findFirst({
where: {
id,
},
select: {
id: true,
name: true,
email: true,
emailVerified: true,
roles: true,
disabled: true,
twoFactorEnabled: true,
signature: true,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return user;
};

View File

@ -24,7 +24,14 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP
token,
},
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
password: true,
},
},
},
});

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

@ -24,7 +24,7 @@ export const updateProfile = async ({
},
});
return await prisma.$transaction(async (tx) => {
await prisma.$transaction(async (tx) => {
await tx.userSecurityAuditLog.create({
data: {
userId,
@ -34,7 +34,7 @@ export const updateProfile = async ({
},
});
return await tx.user.update({
await tx.user.update({
where: {
id: userId,
},

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 = {
@ -12,10 +15,17 @@ export type VerifyEmailProps = {
export const verifyEmail = async ({ token }: VerifyEmailProps) => {
const verificationToken = await prisma.verificationToken.findFirst({
include: {
user: true,
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
where: {
token,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -40,6 +40,11 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
// ACCESS AUTH 2FA events.
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated.
'DOCUMENT_ACCESS_AUTH_2FA_FAILED', // When ACCESS AUTH 2FA validation fails.
]);
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
@ -487,6 +492,42 @@ export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
}),
});
/**
* Event: Document recipient requested a 2FA token.
*/
export const ZDocumentAuditLogEventDocumentRecipientRequested2FAEmailSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED),
data: z.object({
recipientEmail: z.string(),
recipientName: z.string(),
recipientId: z.number(),
}),
});
/**
* Event: Document recipient validated a 2FA token.
*/
export const ZDocumentAuditLogEventDocumentRecipientValidated2FAEmailSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED),
data: z.object({
recipientEmail: z.string(),
recipientName: z.string(),
recipientId: z.number(),
}),
});
/**
* Event: Document recipient failed to validate a 2FA token.
*/
export const ZDocumentAuditLogEventDocumentRecipientFailed2FAEmailSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED),
data: z.object({
recipientEmail: z.string(),
recipientName: z.string(),
recipientId: z.number(),
}),
});
/**
* Event: Document sent.
*/
@ -627,6 +668,9 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentViewedSchema,
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
ZDocumentAuditLogEventDocumentRecipientRejectedSchema,
ZDocumentAuditLogEventDocumentRecipientRequested2FAEmailSchema,
ZDocumentAuditLogEventDocumentRecipientValidated2FAEmailSchema,
ZDocumentAuditLogEventDocumentRecipientFailed2FAEmailSchema,
ZDocumentAuditLogEventDocumentSentSchema,
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,

View File

@ -37,6 +37,7 @@ const ZDocumentAuthPasswordSchema = z.object({
const ZDocumentAuth2FASchema = z.object({
type: z.literal(DocumentAuth.TWO_FACTOR_AUTH),
token: z.string().min(4).max(10),
method: z.enum(['email', 'authenticator']).default('authenticator').optional(),
});
/**
@ -55,9 +56,12 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
*
* Must keep these two in sync.
*/
export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]);
export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuth2FASchema,
]);
export const ZDocumentAccessAuthTypesSchema = z
.enum([DocumentAuth.ACCOUNT])
.enum([DocumentAuth.ACCOUNT, DocumentAuth.TWO_FACTOR_AUTH])
.describe('The type of authentication required for the recipient to access the document.');
/**
@ -89,9 +93,10 @@ export const ZDocumentActionAuthTypesSchema = z
*/
export const ZRecipientAccessAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuth2FASchema,
]);
export const ZRecipientAccessAuthTypesSchema = z
.enum([DocumentAuth.ACCOUNT])
.enum([DocumentAuth.ACCOUNT, DocumentAuth.TWO_FACTOR_AUTH])
.describe('The type of authentication required for the recipient to access the document.');
/**

View File

@ -1,4 +1,4 @@
import type { z } from 'zod';
import { z } from 'zod';
import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema';
import { OrganisationSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
@ -43,3 +43,19 @@ export const ZOrganisationLiteSchema = OrganisationSchema.pick({
* A version of the organisation response schema when returning multiple organisations at once from a single API endpoint.
*/
export const ZOrganisationManySchema = ZOrganisationLiteSchema;
export const ZOrganisationAccountLinkMetadataSchema = z.object({
type: z.enum(['link', 'create']),
userId: z.number(),
organisationId: z.string(),
oauthConfig: z.object({
providerAccountId: z.string(),
accessToken: z.string(),
expiresAt: z.number(),
idToken: z.string(),
}),
});
export type TOrganisationAccountLinkMetadata = z.infer<
typeof ZOrganisationAccountLinkMetadataSchema
>;

View File

@ -28,6 +28,8 @@ export const ZClaimFlagsSchema = z.object({
embedSigningWhiteLabel: z.boolean().optional(),
cfr21: z.boolean().optional(),
authenticationPortal: z.boolean().optional(),
});
export type TClaimFlags = z.infer<typeof ZClaimFlagsSchema>;
@ -76,6 +78,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
key: 'cfr21',
label: '21 CFR',
},
authenticationPortal: {
key: 'authenticationPortal',
label: 'Authentication portal',
},
};
export enum INTERNAL_CLAIM_ID {
@ -157,6 +163,7 @@ export const internalClaims: InternalClaims = {
embedSigning: true,
embedSigningWhiteLabel: true,
cfr21: true,
authenticationPortal: true,
},
},
[INTERNAL_CLAIM_ID.EARLY_ADOPTER]: {

View File

@ -16,6 +16,7 @@ type DatabaseIdPrefix =
| 'org_email'
| 'org_claim'
| 'org_group'
| 'org_sso'
| 'org_setting'
| 'member'
| 'member_invite'

View File

@ -476,6 +476,36 @@ export const formatDocumentAuditLogAction = (
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, ({ data }) => {
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} requested a 2FA token for the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED }, ({ data }) => {
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} validated a 2FA token for the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED }, ({ data }) => {
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} failed to validate a 2FA token for the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
identified: data.isResending

View File

@ -4,7 +4,7 @@ import type { DocumentWithRecipients } from '@documenso/prisma/types/document-wi
export type MaskRecipientTokensForDocumentOptions<T extends DocumentWithRecipients> = {
document: T;
user?: User;
user?: Pick<User, 'id' | 'email' | 'name'>;
token?: string;
};

View File

@ -0,0 +1,13 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
export const formatOrganisationLoginUrl = (organisationUrl: string) => {
return NEXT_PUBLIC_WEBAPP_URL() + formatOrganisationLoginPath(organisationUrl);
};
export const formatOrganisationLoginPath = (organisationUrl: string) => {
return `/o/${organisationUrl}/signin`;
};
export const formatOrganisationCallbackUrl = (organisationUrl: string) => {
return `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/callback/oidc/org/${organisationUrl}`;
};