chore: merged main

This commit is contained in:
Catalin Pit
2024-09-11 08:40:23 +03:00
389 changed files with 19630 additions and 4241 deletions

View File

@ -2,25 +2,33 @@ import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { AppError } from '../../errors/app-error';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { validateTwoFactorAuthentication } from './validate-2fa';
type DisableTwoFactorAuthenticationOptions = {
user: User;
token: string;
totpCode?: string;
backupCode?: string;
requestMetadata?: RequestMetadata;
};
export const disableTwoFactorAuthentication = async ({
token,
totpCode,
backupCode,
user,
requestMetadata,
}: DisableTwoFactorAuthenticationOptions) => {
let isValid = await validateTwoFactorAuthentication({ totpCode: token, user });
let isValid = false;
if (!isValid) {
isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
if (!totpCode && !backupCode) {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
if (totpCode) {
isValid = await validateTwoFactorAuthentication({ totpCode, user });
} else if (backupCode) {
isValid = await validateTwoFactorAuthentication({ backupCode, user });
}
if (!isValid) {

View File

@ -147,7 +147,7 @@ export const getDocumentAndRecipientByToken = async ({
},
});
const recipient = result.Recipient[0];
const [recipient] = result.Recipient;
// Sanity check, should not be possible.
if (!recipient) {

View File

@ -5,7 +5,7 @@ import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLES_DESCRIPTION_ENG,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '@documenso/lib/constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -97,8 +97,8 @@ export const resendDocument = async ({
const { email, name } = recipient;
const selfSigner = email === user.email;
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
const recipientActionVerb = actionVerb.toLowerCase();
const recipientActionVerb =
RECIPIENT_ROLES_DESCRIPTION_ENG[recipient.role].actionVerb.toLowerCase();
let emailMessage = customEmail?.message || '';
let emailSubject = `Reminder: Please ${recipientActionVerb} this document`;

View File

@ -5,7 +5,11 @@ import { getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
NEXT_PUBLIC_MARKETING_URL,
NEXT_PUBLIC_WEBAPP_URL,
} from '../../constants/app';
import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
/**
@ -46,6 +50,10 @@ export default async function handlerFeatureFlagAll(req: Request) {
if (origin.startsWith(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
if (origin.startsWith(NEXT_PRIVATE_INTERNAL_WEBAPP_URL ?? 'http://localhost:3000')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
}
return res;

View File

@ -7,7 +7,11 @@ import { getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
NEXT_PUBLIC_MARKETING_URL,
NEXT_PUBLIC_WEBAPP_URL,
} from '../../constants/app';
/**
* Evaluate a single feature flag based on the current user if possible.
@ -67,6 +71,10 @@ export default async function handleFeatureFlagGet(req: Request) {
if (origin.startsWith(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
if (origin.startsWith(NEXT_PRIVATE_INTERNAL_WEBAPP_URL ?? 'http://localhost:3000')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
}
return res;

View File

@ -1,6 +1,16 @@
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { FieldType, Team } from '@documenso/prisma/client';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import type { TFieldMetaSchema as FieldMeta } from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
@ -15,6 +25,7 @@ export type CreateFieldOptions = {
pageY: number;
pageWidth: number;
pageHeight: number;
fieldMeta?: FieldMeta;
requestMetadata?: RequestMetadata;
};
@ -29,6 +40,7 @@ export const createField = async ({
pageY,
pageWidth,
pageHeight,
fieldMeta,
requestMetadata,
}: CreateFieldOptions) => {
const document = await prisma.document.findFirst({
@ -85,6 +97,39 @@ export const createField = async ({
});
}
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(type);
if (advancedField && !fieldMeta) {
throw new Error(
'Field meta is required for this type of field. Please provide the appropriate field meta object.',
);
}
if (fieldMeta && fieldMeta.type.toLowerCase() !== String(type).toLowerCase()) {
throw new Error('Field meta type does not match the field type');
}
const result = match(type)
.with('RADIO', () => ZRadioFieldMeta.safeParse(fieldMeta))
.with('CHECKBOX', () => ZCheckboxFieldMeta.safeParse(fieldMeta))
.with('DROPDOWN', () => ZDropdownFieldMeta.safeParse(fieldMeta))
.with('NUMBER', () => ZNumberFieldMeta.safeParse(fieldMeta))
.with('TEXT', () => ZTextFieldMeta.safeParse(fieldMeta))
.with('SIGNATURE', 'INITIALS', 'DATE', 'EMAIL', 'NAME', () => ({
success: true,
data: {},
}))
.with('FREE_SIGNATURE', () => ({
success: false,
error: 'FREE_SIGNATURE is not supported',
data: {},
}))
.exhaustive();
if (!result.success) {
throw new Error('Field meta parsing failed');
}
const field = await prisma.field.create({
data: {
documentId,
@ -97,6 +142,7 @@ export const createField = async ({
height: pageHeight,
customText: '',
inserted: false,
fieldMeta: result.data,
},
include: {
Recipient: true,

View File

@ -107,7 +107,10 @@ export const setFieldsForTemplate = async ({
}
}
if (field.type === FieldType.CHECKBOX && field.fieldMeta) {
if (field.type === FieldType.CHECKBOX) {
if (!field.fieldMeta) {
throw new Error('Checkbox field is missing required metadata');
}
const checkboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const errors = validateCheckboxField(
checkboxFieldParsedMeta?.values?.map((item) => item.value) ?? [],
@ -118,7 +121,10 @@ export const setFieldsForTemplate = async ({
}
}
if (field.type === FieldType.RADIO && field.fieldMeta) {
if (field.type === FieldType.RADIO) {
if (!field.fieldMeta) {
throw new Error('Radio field is missing required metadata');
}
const radioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedRadioFieldValue = radioFieldParsedMeta.values?.find(
(option) => option.checked,
@ -129,7 +135,10 @@ export const setFieldsForTemplate = async ({
}
}
if (field.type === FieldType.DROPDOWN && field.fieldMeta) {
if (field.type === FieldType.DROPDOWN) {
if (!field.fieldMeta) {
throw new Error('Dropdown field is missing required metadata');
}
const dropdownFieldParsedMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const errors = validateDropdownField(undefined, dropdownFieldParsedMeta);
if (errors.length > 0) {

View File

@ -37,6 +37,10 @@ export const updateField = async ({
requestMetadata,
fieldMeta,
}: UpdateFieldOptions) => {
if (type === 'FREE_SIGNATURE') {
throw new Error('Cannot update a FREE_SIGNATURE field');
}
const oldField = await prisma.field.findFirstOrThrow({
where: {
id: fieldId,
@ -61,6 +65,11 @@ export const updateField = async ({
},
});
const newFieldMeta = {
...(oldField.fieldMeta as FieldMeta),
...fieldMeta,
};
const field = prisma.$transaction(async (tx) => {
const updatedField = await tx.field.update({
where: {
@ -74,13 +83,39 @@ export const updateField = async ({
positionY: pageY,
width: pageWidth,
height: pageHeight,
fieldMeta,
fieldMeta: newFieldMeta,
},
include: {
Recipient: true,
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
@ -104,31 +139,5 @@ export const updateField = async ({
return updatedField;
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
return field;
};

View File

@ -1,11 +0,0 @@
import { headers } from 'next/headers';
export const getLocale = () => {
const headerItems = headers();
const locales = headerItems.get('accept-language') ?? 'en-US';
const [locale] = locales.split(',');
return locale;
};

View File

@ -4,5 +4,5 @@ import { cookies } from 'next/headers';
// eslint-disable-next-line @typescript-eslint/require-await
export const switchI18NLanguage = async (lang: string) => {
cookies().set('i18n', lang);
cookies().set('language', lang);
};

View File

@ -1,12 +1,12 @@
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
DIRECT_TEMPLATE_RECIPIENT_NAME,
} from '@documenso/lib/constants/direct-templates';
import { prisma } from '@documenso/prisma';
import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
DIRECT_TEMPLATE_RECIPIENT_NAME,
} from '../../constants/template';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
type TRecipientActionAuthTypes,

View File

@ -1,9 +1,16 @@
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { prisma } from '@documenso/prisma';
import type { RecipientRole, Team } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '../../types/document-auth';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData, diffRecipientChanges } from '../../utils/document-audit-logs';
import { createRecipientAuthOptions } from '../../utils/document-auth';
export type UpdateRecipientOptions = {
documentId: number;
@ -11,6 +18,7 @@ export type UpdateRecipientOptions = {
email?: string;
name?: string;
role?: RecipientRole;
actionAuth?: TRecipientActionAuthTypes | null;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
@ -22,6 +30,7 @@ export const updateRecipient = async ({
email,
name,
role,
actionAuth,
userId,
teamId,
requestMetadata,
@ -48,6 +57,9 @@ export const updateRecipient = async ({
}),
},
},
include: {
Document: true,
},
});
let team: Team | null = null;
@ -75,6 +87,22 @@ export const updateRecipient = async ({
throw new Error('Recipient not found');
}
if (actionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
}
}
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
const updatedRecipient = await prisma.$transaction(async (tx) => {
const persisted = await prisma.recipient.update({
where: {
@ -84,6 +112,10 @@ export const updateRecipient = async ({
email: email?.toLowerCase() ?? recipient.email,
name: name ?? recipient.name,
role: role ?? recipient.role,
authOptions: createRecipientAuthOptions({
accessAuth: recipientAuthOptions.accessAuth,
actionAuth: actionAuth ?? null,
}),
},
});

View File

@ -0,0 +1,21 @@
'use server';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type GetActiveSubscriptionsByUserIdOptions = {
userId: number;
};
export const getActiveSubscriptionsByUserId = async ({
userId,
}: GetActiveSubscriptionsByUserIdOptions) => {
return await prisma.subscription.findMany({
where: {
userId,
status: {
not: SubscriptionStatus.INACTIVE,
},
},
});
};

View File

@ -1,6 +1,7 @@
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client';
@ -22,6 +23,9 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat
where: {
teamId,
email: user.email,
status: {
not: TeamMemberInviteStatus.DECLINED,
},
},
include: {
team: {
@ -37,6 +41,10 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat
},
});
if (teamMemberInvite.status === TeamMemberInviteStatus.ACCEPTED) {
return;
}
const { team } = teamMemberInvite;
const teamMember = await tx.teamMember.create({
@ -47,10 +55,13 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat
},
});
await tx.teamMemberInvite.delete({
await tx.teamMemberInvite.update({
where: {
id: teamMemberInvite.id,
},
data: {
status: TeamMemberInviteStatus.ACCEPTED,
},
});
if (IS_BILLING_ENABLED() && team.subscription) {

View File

@ -28,11 +28,24 @@ export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOpti
const { team, userId: newOwnerUserId } = teamTransferVerification;
await tx.teamTransferVerification.delete({
where: {
teamId: team.id,
},
});
await Promise.all([
tx.teamTransferVerification.updateMany({
where: {
teamId: team.id,
},
data: {
completed: true,
},
}),
tx.teamTransferVerification.deleteMany({
where: {
teamId: team.id,
expiresAt: {
lt: new Date(),
},
},
}),
]);
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {

View File

@ -210,7 +210,7 @@ export const createDocumentFromDirectTemplate = async ({
const initialRequestTime = new Date();
const { documentId, directRecipientToken } = await prisma.$transaction(async (tx) => {
const { documentId, recipientId, token } = await prisma.$transaction(async (tx) => {
const documentData = await tx.documentData.create({
data: {
type: template.templateDocumentData.type,
@ -539,8 +539,9 @@ export const createDocumentFromDirectTemplate = async ({
});
return {
token: createdDirectRecipient.token,
documentId: document.id,
directRecipientToken: createdDirectRecipient.token,
recipientId: createdDirectRecipient.id,
};
});
@ -559,5 +560,9 @@ export const createDocumentFromDirectTemplate = async ({
// Log and reseal as required until we configure middleware.
}
return directRecipientToken;
return {
token,
documentId,
recipientId,
};
};

View File

@ -2,13 +2,13 @@
import { nanoid } from 'nanoid';
import { prisma } from '@documenso/prisma';
import type { Recipient, TemplateDirectLink } from '@documenso/prisma/client';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
DIRECT_TEMPLATE_RECIPIENT_NAME,
} from '../../constants/template';
} from '@documenso/lib/constants/direct-templates';
import { prisma } from '@documenso/prisma';
import type { Recipient, TemplateDirectLink } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type CreateTemplateDirectLinkOptions = {

View File

@ -4,6 +4,13 @@ import { prisma } from '@documenso/prisma';
import { jobsClient } from '../../jobs/client';
export const EMAIL_VERIFICATION_STATE = {
NOT_FOUND: 'NOT_FOUND',
VERIFIED: 'VERIFIED',
EXPIRED: 'EXPIRED',
ALREADY_VERIFIED: 'ALREADY_VERIFIED',
} as const;
export type VerifyEmailProps = {
token: string;
};
@ -19,7 +26,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
});
if (!verificationToken) {
return null;
return EMAIL_VERIFICATION_STATE.NOT_FOUND;
}
// check if the token is valid or expired
@ -48,10 +55,14 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
});
}
return valid;
return EMAIL_VERIFICATION_STATE.EXPIRED;
}
const [updatedUser, deletedToken] = await prisma.$transaction([
if (verificationToken.completed) {
return EMAIL_VERIFICATION_STATE.ALREADY_VERIFIED;
}
const [updatedUser] = await prisma.$transaction([
prisma.user.update({
where: {
id: verificationToken.userId,
@ -60,16 +71,28 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
emailVerified: new Date(),
},
}),
prisma.verificationToken.updateMany({
where: {
userId: verificationToken.userId,
},
data: {
completed: true,
},
}),
// Tidy up old expired tokens
prisma.verificationToken.deleteMany({
where: {
userId: verificationToken.userId,
expires: {
lt: new Date(),
},
},
}),
]);
if (!updatedUser || !deletedToken) {
if (!updatedUser) {
throw new Error('Something went wrong while verifying your email. Please try again.');
}
return !!updatedUser && !!deletedToken;
return EMAIL_VERIFICATION_STATE.VERIFIED;
};

View File

@ -1,6 +1,6 @@
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../../constants/app';
import { sign } from '../../crypto/sign';
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
@ -29,7 +29,7 @@ export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWeb
const signature = sign(body);
await Promise.race([
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/webhook/trigger`, {
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL}/api/webhook/trigger`, {
method: 'POST',
headers: {
'content-type': 'application/json',