chore: merge main, resolve biome formatting conflicts

Reconcile toolbar-filters changes with main's Kysely-based query rewrite
and biome reformatting. Notable:

- find-documents.ts: rebase on main's Kysely impl while preserving PR
  semantics (status[] / source[] filters via OR-of-predicates,
  period 'all' bypass).
- get-stats.ts: skip period filter when 'all'.
- find-templates.ts: preserve PR templateType array + query filter on
  top of main's where/include structure.
- find-documents-internal: drop preloaded team plumbing (main resolves
  team internally) and switch appMetaTags to msg`...`.
- Restore packages/lib/translations/ from HEAD (autogenerated).
This commit is contained in:
ephraimduncan
2026-05-12 12:12:37 +00:00
1936 changed files with 92316 additions and 47005 deletions
@@ -41,11 +41,9 @@ export const isRequiredField = (field: Field) => {
/**
* Whether the provided field is required and not inserted.
*/
export const isFieldUnsignedAndRequired = (field: Field) =>
isRequiredField(field) && !field.inserted;
export const isFieldUnsignedAndRequired = (field: Field) => isRequiredField(field) && !field.inserted;
/**
* Whether the provided fields contains a field that is required to be inserted.
*/
export const fieldsContainUnsignedRequiredField = (fields: Field[]) =>
fields.some(isFieldUnsignedAndRequired);
export const fieldsContainUnsignedRequiredField = (fields: Field[]) => fields.some(isFieldUnsignedAndRequired);
+2 -6
View File
@@ -1,14 +1,10 @@
import type { Subscription } from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import { IS_BILLING_ENABLED } from '../constants/app';
import { AppErrorCode } from '../errors/app-error';
import { AppError } from '../errors/app-error';
import { AppError, AppErrorCode } from '../errors/app-error';
import type { StripeOrganisationCreateMetadata } from '../types/subscription';
export const generateStripeOrganisationCreateMetadata = (
organisationName: string,
userId: number,
) => {
export const generateStripeOrganisationCreateMetadata = (organisationName: string, userId: number) => {
const metadata: StripeOrganisationCreateMetadata = {
organisationName,
userId,
+28 -13
View File
@@ -102,10 +102,8 @@ export const diffRecipientChanges = (
const oldActionAuth = oldAuthOptions.actionAuth;
const newAuthOptions = ZRecipientAuthOptionsSchema.parse(newRecipient.authOptions);
const newAccessAuth =
newAuthOptions?.accessAuth === undefined ? oldAccessAuth : newAuthOptions.accessAuth;
const newActionAuth =
newAuthOptions?.actionAuth === undefined ? oldActionAuth : newAuthOptions.actionAuth;
const newAccessAuth = newAuthOptions?.accessAuth === undefined ? oldAccessAuth : newAuthOptions.accessAuth;
const newActionAuth = newAuthOptions?.actionAuth === undefined ? oldActionAuth : newAuthOptions.actionAuth;
if (!isDeepEqual(oldAccessAuth, newAccessAuth)) {
diffs.push({
@@ -150,10 +148,7 @@ export const diffRecipientChanges = (
return diffs;
};
export const diffFieldChanges = (
oldField: Field,
newField: Field,
): TDocumentAuditLogFieldDiffSchema[] => {
export const diffFieldChanges = (oldField: Field, newField: Field): TDocumentAuditLogFieldDiffSchema[] => {
const diffs: TDocumentAuditLogFieldDiffSchema[] = [];
if (
@@ -289,11 +284,7 @@ export const diffDocumentMetaChanges = (
*
* Provide a userId to prefix the action with the user, example 'X did Y'.
*/
export const formatDocumentAuditLogAction = (
i18n: I18n,
auditLog: TDocumentAuditLog,
userId?: number,
) => {
export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAuditLog, userId?: number) => {
const isCurrentUser = userId === auditLog.userId;
const user = auditLog.name || auditLog.email || '';
@@ -571,6 +562,30 @@ export const formatDocumentAuditLogAction = (
you: msg`You deleted an envelope item with title ${data.envelopeItemTitle}`,
user: msg`${user} deleted an envelope item with title ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_UPDATED }, () => ({
anonymous: msg({
message: `Envelope item updated`,
context: `Audit log format`,
}),
you: msg`You updated an envelope item`,
user: msg`${user} updated an envelope item`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_PDF_REPLACED }, ({ data }) => ({
anonymous: msg({
message: `Envelope item PDF replaced`,
context: `Audit log format`,
}),
you: msg`You replaced the PDF for envelope item ${data.envelopeItemTitle}`,
user: msg`${user} replaced the PDF for envelope item ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED }, ({ data }) => ({
anonymous: msg({
message: `Recipient signing window expired`,
context: `Audit log format`,
}),
you: msg`Signing window expired for ${data.recipientName || data.recipientEmail}`,
user: msg`Signing window expired for ${data.recipientName || data.recipientEmail}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED }, ({ data }) => {
const message = msg({
message: `The document ownership was delegated to ${data.delegatedOwnerName || data.delegatedOwnerEmail} on behalf of ${data.teamName}`,
+6 -17
View File
@@ -6,8 +6,7 @@ import type {
TRecipientActionAuthTypes,
TRecipientAuthOptions,
} from '../types/document-auth';
import { DocumentAuth } from '../types/document-auth';
import { ZDocumentAuthOptionsSchema, ZRecipientAuthOptionsSchema } from '../types/document-auth';
import { DocumentAuth, ZDocumentAuthOptionsSchema, ZRecipientAuthOptionsSchema } from '../types/document-auth';
type ExtractDocumentAuthMethodsOptions = {
documentAuth: Envelope['authOptions'];
@@ -20,28 +19,20 @@ type ExtractDocumentAuthMethodsOptions = {
* Will combine the recipient and document auth values to derive the final
* auth values for a recipient if possible.
*/
export const extractDocumentAuthMethods = ({
documentAuth,
recipientAuth,
}: ExtractDocumentAuthMethodsOptions) => {
export const extractDocumentAuthMethods = ({ documentAuth, recipientAuth }: ExtractDocumentAuthMethodsOptions) => {
const documentAuthOption = ZDocumentAuthOptionsSchema.parse(documentAuth);
const recipientAuthOption = ZRecipientAuthOptionsSchema.parse(recipientAuth);
const derivedRecipientAccessAuth: TRecipientAccessAuthTypes[] =
recipientAuthOption.accessAuth.length > 0
? recipientAuthOption.accessAuth
: documentAuthOption.globalAccessAuth;
recipientAuthOption.accessAuth.length > 0 ? recipientAuthOption.accessAuth : documentAuthOption.globalAccessAuth;
const derivedRecipientActionAuth: TRecipientActionAuthTypes[] =
recipientAuthOption.actionAuth.length > 0
? recipientAuthOption.actionAuth
: documentAuthOption.globalActionAuth;
recipientAuthOption.actionAuth.length > 0 ? recipientAuthOption.actionAuth : documentAuthOption.globalActionAuth;
const recipientAccessAuthRequired = derivedRecipientAccessAuth.length > 0;
const recipientActionAuthRequired =
derivedRecipientActionAuth.length > 0 &&
!derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE);
derivedRecipientActionAuth.length > 0 && !derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE);
return {
derivedRecipientAccessAuth,
@@ -66,9 +57,7 @@ export const createDocumentAuthOptions = (options: TDocumentAuthOptions): TDocum
/**
* Create recipient auth options in a type safe way.
*/
export const createRecipientAuthOptions = (
options: TRecipientAuthOptions,
): TRecipientAuthOptions => {
export const createRecipientAuthOptions = (options: TRecipientAuthOptions): TRecipientAuthOptions => {
return {
accessAuth: options?.accessAuth ?? [],
actionAuth: options?.actionAuth ?? [],
+10 -16
View File
@@ -1,11 +1,4 @@
import type {
DocumentMeta,
Envelope,
OrganisationGlobalSettings,
Recipient,
Team,
User,
} from '@prisma/client';
import type { DocumentMeta, Envelope, OrganisationGlobalSettings, Recipient, Team, User } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder, DocumentStatus } from '@prisma/client';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../constants/time-zones';
@@ -60,8 +53,13 @@ export const extractDerivedDocumentMeta = (
// Email settings.
emailId: meta.emailId ?? settings.emailId,
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
emailSettings:
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
emailSettings: meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
// Envelope expiration.
envelopeExpirationPeriod: meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod ?? null,
// Reminder settings.
reminderSettings: meta.reminderSettings ?? settings.reminderSettings ?? null,
} satisfies Omit<DocumentMeta, 'id'>;
};
@@ -108,9 +106,7 @@ type MapEnvelopeToDocumentManyOptions = Envelope & {
*
* Do not use spread operator here to avoid unexpected behavior.
*/
export const mapEnvelopesToDocumentMany = (
envelope: MapEnvelopeToDocumentManyOptions,
): TDocumentMany => {
export const mapEnvelopesToDocumentMany = (envelope: MapEnvelopeToDocumentManyOptions): TDocumentMany => {
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
return {
@@ -143,8 +139,6 @@ export const mapEnvelopesToDocumentMany = (
id: envelope.teamId,
url: envelope.team.url,
},
recipients: envelope.recipients.map((recipient) =>
mapRecipientToLegacyRecipient(recipient, envelope),
),
recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
};
};
+132
View File
@@ -0,0 +1,132 @@
import type { EnvelopeEditorConfig } from '../types/envelope-editor';
import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '../types/envelope-editor';
export const PRESIGNED_ENVELOPE_ITEM_ID_PREFIX = 'PRESIGNED_';
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
/**
* Takes parsed `features` from the embedding hash and an `embedded` config,
* and produces a complete `EnvelopeEditorConfig` with sensible embedded-mode defaults.
*
* Any explicitly provided feature flag overrides the embedded default.
*/
export function buildEmbeddedEditorOptions(
features: DeepPartial<EnvelopeEditorConfig>,
embedded: EnvelopeEditorConfig['embedded'],
): EnvelopeEditorConfig {
return {
embedded,
...buildEmbeddedFeatures(features),
};
}
export const buildEmbeddedFeatures = (features: DeepPartial<EnvelopeEditorConfig>): EnvelopeEditorConfig => {
return {
general: {
allowConfigureEnvelopeTitle:
features.general?.allowConfigureEnvelopeTitle ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowConfigureEnvelopeTitle,
allowUploadAndRecipientStep:
features.general?.allowUploadAndRecipientStep ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowUploadAndRecipientStep,
allowAddFieldsStep:
features.general?.allowAddFieldsStep ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowAddFieldsStep,
allowPreviewStep: features.general?.allowPreviewStep ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowPreviewStep,
minimizeLeftSidebar:
features.general?.minimizeLeftSidebar ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.general.minimizeLeftSidebar,
},
settings:
features.settings !== null
? {
allowConfigureSignatureTypes:
features.settings?.allowConfigureSignatureTypes ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureSignatureTypes,
allowConfigureLanguage:
features.settings?.allowConfigureLanguage ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureLanguage,
allowConfigureDateFormat:
features.settings?.allowConfigureDateFormat ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureDateFormat,
allowConfigureTimezone:
features.settings?.allowConfigureTimezone ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureTimezone,
allowConfigureRedirectUrl:
features.settings?.allowConfigureRedirectUrl ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureRedirectUrl,
allowConfigureDistribution:
features.settings?.allowConfigureDistribution ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureDistribution,
allowConfigureExpirationPeriod:
features.settings?.allowConfigureExpirationPeriod ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureExpirationPeriod,
allowConfigureReminders:
features.settings?.allowConfigureReminders ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureReminders,
allowConfigureEmailSender:
features.settings?.allowConfigureEmailSender ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureEmailSender,
allowConfigureEmailReplyTo:
features.settings?.allowConfigureEmailReplyTo ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureEmailReplyTo,
}
: null,
actions: {
allowAttachments: features.actions?.allowAttachments ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowAttachments,
allowDistributing:
features.actions?.allowDistributing ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDistributing,
allowDirectLink: features.actions?.allowDirectLink ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDirectLink,
allowDuplication: features.actions?.allowDuplication ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDuplication,
allowSaveAsTemplate:
features.actions?.allowSaveAsTemplate ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowSaveAsTemplate,
allowDownloadPDF: features.actions?.allowDownloadPDF ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDownloadPDF,
allowDeletion: features.actions?.allowDeletion ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDeletion,
},
envelopeItems:
features.envelopeItems !== null
? {
allowConfigureTitle:
features.envelopeItems?.allowConfigureTitle ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowConfigureTitle,
allowConfigureOrder:
features.envelopeItems?.allowConfigureOrder ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowConfigureOrder,
allowUpload:
features.envelopeItems?.allowUpload ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowUpload,
allowDelete:
features.envelopeItems?.allowDelete ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowDelete,
allowReplace:
features.envelopeItems?.allowReplace ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowReplace,
}
: null,
recipients:
features.recipients !== null
? {
allowAIDetection:
features.recipients?.allowAIDetection ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowAIDetection,
allowConfigureSigningOrder:
features.recipients?.allowConfigureSigningOrder ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowConfigureSigningOrder,
allowConfigureDictateNextSigner:
features.recipients?.allowConfigureDictateNextSigner ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowConfigureDictateNextSigner,
allowApproverRole:
features.recipients?.allowApproverRole ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowApproverRole,
allowViewerRole:
features.recipients?.allowViewerRole ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowViewerRole,
allowCCerRole:
features.recipients?.allowCCerRole ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowCCerRole,
allowAssistantRole:
features.recipients?.allowAssistantRole ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowAssistantRole,
}
: null,
fields: {
allowAIDetection: features.fields?.allowAIDetection ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.fields.allowAIDetection,
},
};
};
+1 -3
View File
@@ -8,9 +8,7 @@ declare global {
// eslint-disable-next-line @typescript-eslint/ban-types
type EnvKey = keyof NodeJS.ProcessEnv | (string & {});
type EnvValue<K extends EnvKey> = K extends keyof NodeJS.ProcessEnv
? NodeJS.ProcessEnv[K]
: string | undefined;
type EnvValue<K extends EnvKey> = K extends keyof NodeJS.ProcessEnv ? NodeJS.ProcessEnv[K] : string | undefined;
export const env = <K extends EnvKey>(variable: K): EnvValue<K> => {
if (typeof window !== 'undefined' && typeof window.__ENV__ === 'object') {
+57 -1
View File
@@ -1,13 +1,18 @@
import type { DocumentDataVersion } from '@documenso/lib/types/document';
import type { EnvelopeItem } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
/**
* `pending` is only supported when there is no recipient token (team/owner-side downloads
* via the session-authed file route). The recipient-token route does not accept `pending`.
*/
export type EnvelopeItemPdfUrlOptions =
| {
type: 'download';
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined;
version: 'original' | 'signed';
version: 'original' | 'signed' | 'pending';
presignToken?: undefined;
}
| {
@@ -34,3 +39,54 @@ export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => {
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}${presignToken ? `?presignToken=${presignToken}` : ''}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}${presignToken ? `?token=${presignToken}` : ''}`;
};
export type DocumentDataUrlOptions = {
envelopeId: string;
envelopeItemId: string;
documentDataId: string;
token: string | undefined;
presignToken?: string | undefined;
version: DocumentDataVersion;
};
/**
* The difference between this and `getEnvelopeItemPdfUrl` is that this will
* hard cache since we add the `documentDataId` to the URL.
*
* Since `documentDataId` should change when the document is changed/signed, this is a
* good way to cache an envelope item by.
*/
export const getDocumentDataUrl = (options: DocumentDataUrlOptions) => {
const { envelopeId, envelopeItemId, documentDataId, token, presignToken, version } = options;
const partialUrl = `envelope/${envelopeId}/envelopeItem/${envelopeItemId}/dataId/${documentDataId}/${version}/item.pdf`;
// Recipient token endpoint.
if (token) {
return `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/${partialUrl}`;
}
// Endpoint authenticated by session or presigned token.
const baseUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/${partialUrl}`;
if (presignToken) {
return `${baseUrl}?presignToken=${presignToken}`;
}
return baseUrl;
};
/**
* Gets a PDF url for the PDF viewer.
*
* Returns `null` if invalid.
*/
export const getDocumentDataUrlForPdfViewer = (options: DocumentDataUrlOptions): string | null => {
const { envelopeId, envelopeItemId, documentDataId } = options;
if (!envelopeId || !envelopeItemId || !documentDataId) {
return null;
}
return getDocumentDataUrl(options);
};
+9 -15
View File
@@ -1,9 +1,3 @@
import type { Field } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern';
import { z } from 'zod';
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
@@ -21,8 +15,14 @@ import {
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { toCheckboxCustomText, toRadioCustomText } from '@documenso/lib/utils/fields';
import { zEmail } from '@documenso/lib/utils/zod';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import type { Field } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { DateTime } from 'luxon';
import { match, P } from 'ts-pattern';
import { z } from 'zod';
export type ExtractFieldInsertionValuesOptions = {
fieldValue: TSignEnvelopeFieldValue;
@@ -37,7 +37,7 @@ export const extractFieldInsertionValues = ({
}: ExtractFieldInsertionValuesOptions): { customText: string; inserted: boolean } => {
return match(fieldValue)
.with({ type: FieldType.EMAIL }, (fieldValue) => {
const parsedEmailValue = z.string().email().nullable().safeParse(fieldValue.value);
const parsedEmailValue = zEmail().nullable().safeParse(fieldValue.value);
if (!parsedEmailValue.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
@@ -183,16 +183,10 @@ export const extractFieldInsertionValues = ({
const { validationRule, validationLength } = parsedCheckboxFieldParsedMeta;
if (validationRule && validationLength) {
const checkboxValidationRule = checkboxValidationSigns.find(
(sign) => sign.label === validationRule,
);
const checkboxValidationRule = checkboxValidationSigns.find((sign) => sign.label === validationRule);
if (checkboxValidationRule) {
const isValid = validateCheckboxLength(
selectedValues.length,
checkboxValidationRule.value,
validationLength,
);
const isValid = validateCheckboxLength(selectedValues.length, checkboxValidationRule.value, validationLength);
if (!isValid) {
throw new AppError(AppErrorCode.INVALID_BODY, {
+56 -39
View File
@@ -1,11 +1,5 @@
import type { Envelope, Recipient } from '@prisma/client';
import {
DocumentStatus,
EnvelopeType,
RecipientRole,
SendStatus,
SigningStatus,
} from '@prisma/client';
import { DocumentStatus, EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { match } from 'ts-pattern';
import { z } from 'zod';
@@ -53,10 +47,7 @@ export type EnvelopeIdsOptions =
*
* This is UNSAFE because does not validate access, it just builds the query for ID and TYPE.
*/
export const unsafeBuildEnvelopeIdQuery = (
options: EnvelopeIdOptions,
expectedEnvelopeType: EnvelopeType | null,
) => {
export const unsafeBuildEnvelopeIdQuery = (options: EnvelopeIdOptions, expectedEnvelopeType: EnvelopeType | null) => {
return match(options)
.with({ type: 'envelopeId' }, (value) => {
const parsed = ZEnvelopeIdSchema.safeParse(value.id);
@@ -112,10 +103,7 @@ export const unsafeBuildEnvelopeIdQuery = (
*
* @throws AppError if any ID is invalid or if the array exceeds the maximum limit
*/
export const unsafeBuildEnvelopeIdsQuery = (
options: EnvelopeIdsOptions,
expectedEnvelopeType: EnvelopeType | null,
) => {
export const unsafeBuildEnvelopeIdsQuery = (options: EnvelopeIdsOptions, expectedEnvelopeType: EnvelopeType | null) => {
if (!options.ids || options.ids.length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'At least one ID is required',
@@ -244,28 +232,57 @@ export const mapSecondaryIdToTemplateId = (secondaryId: string) => {
return parseInt(parsed.data.split('_')[1]);
};
export const canEnvelopeItemsBeModified = (
envelope: Pick<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>,
recipients: Recipient[],
) => {
if (envelope.completedAt || envelope.deletedAt || envelope.status !== DocumentStatus.DRAFT) {
return false;
}
if (envelope.type === EnvelopeType.TEMPLATE) {
return true;
}
if (
recipients.some(
(recipient) =>
recipient.role !== RecipientRole.CC &&
(recipient.signingStatus === SigningStatus.SIGNED ||
recipient.sendStatus === SendStatus.SENT),
)
) {
return false;
}
return true;
export type EnvelopeItemPermissions = {
canTitleBeChanged: boolean;
canFileBeChanged: boolean;
canOrderBeChanged: boolean;
};
export const getEnvelopeItemPermissions = (
envelope: Pick<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>,
recipients: Pick<Recipient, 'role' | 'signingStatus' | 'sendStatus'>[],
): EnvelopeItemPermissions => {
// Always reject completed/rejected/deleted envelopes.
if (
envelope.completedAt ||
envelope.deletedAt ||
envelope.status === DocumentStatus.REJECTED ||
envelope.status === DocumentStatus.COMPLETED
) {
return {
canTitleBeChanged: false,
canFileBeChanged: false,
canOrderBeChanged: false,
};
}
// Templates can always be modified.
if (envelope.type === EnvelopeType.TEMPLATE) {
return {
canTitleBeChanged: true,
canFileBeChanged: true,
canOrderBeChanged: true,
};
}
const hasActiveRecipients = recipients.some(
(recipient) =>
recipient.role !== RecipientRole.CC &&
(recipient.signingStatus === SigningStatus.SIGNED ||
recipient.signingStatus === SigningStatus.REJECTED ||
recipient.sendStatus === SendStatus.SENT),
);
return match(envelope.status)
.with(DocumentStatus.DRAFT, () => ({
canTitleBeChanged: true,
canFileBeChanged: true,
canOrderBeChanged: true,
}))
.with(DocumentStatus.PENDING, () => ({
canTitleBeChanged: true,
canFileBeChanged: false,
canOrderBeChanged: !hasActiveRecipients, // Only allow order changes if no active recipients.
}))
.exhaustive();
};
+29 -12
View File
@@ -1,3 +1,4 @@
import { PDF_VIEWER_CONTENT_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { type Envelope, type Field, FieldType } from '@prisma/client';
@@ -23,25 +24,44 @@ export const sortFieldsByPosition = (fields: Field[]): Field[] => {
*/
export const validateFieldsInserted = (fields: Field[]): boolean => {
const fieldCardElements = document.getElementsByClassName('field-card-container');
const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR);
// Attach validate attribute on all fields.
const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted));
// All fields are inserted — clear the validation signal.
if (uninsertedFields.length === 0) {
pdfContent?.removeAttribute('data-validate-fields');
return true;
}
// Attach validate attribute on all fields currently in the DOM.
Array.from(fieldCardElements).forEach((element) => {
element.setAttribute('data-validate', 'true');
});
const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted));
// Also set a signal on the PDF viewer container so that field elements that
// mount later (e.g. after the virtual list scrolls to a new page) can pick
// up the validation state.
pdfContent?.setAttribute('data-validate-fields', 'true');
const firstUninsertedField = uninsertedFields[0];
const firstUninsertedFieldElement =
firstUninsertedField && document.getElementById(`field-${firstUninsertedField.id}`);
if (firstUninsertedField) {
// Try direct element scroll first (works if the field's page is currently rendered).
const firstUninsertedFieldElement = document.getElementById(`field-${firstUninsertedField.id}`);
if (firstUninsertedFieldElement) {
firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false;
if (firstUninsertedFieldElement) {
firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
// Field not in DOM (page virtualized away) — signal the PDF viewer to
// scroll to the correct page via the data attribute.
if (pdfContent) {
pdfContent.setAttribute('data-scroll-to-page', String(firstUninsertedField.page));
}
}
}
return uninsertedFields.length === 0;
return false;
};
export const validateFieldsUninserted = (): boolean => {
@@ -68,10 +88,7 @@ export const validateFieldsUninserted = (): boolean => {
return errorElements.length === 0;
};
export const mapFieldToLegacyField = (
field: Field,
envelope: Pick<Envelope, 'type' | 'secondaryId'>,
) => {
export const mapFieldToLegacyField = (field: Field, envelope: Pick<Envelope, 'type' | 'secondaryId'>) => {
const legacyId = extractLegacyIds(envelope);
return {
+1 -4
View File
@@ -1,10 +1,7 @@
import sharp from 'sharp';
export const optimiseAvatar = async (bytes: string) => {
return await sharp(Buffer.from(bytes, 'base64'))
.resize(512, 512)
.toFormat('jpeg', { quality: 75 })
.toBuffer();
return await sharp(Buffer.from(bytes, 'base64')).resize(512, 512).toFormat('jpeg', { quality: 75 }).toBuffer();
};
export const loadAvatar = async (bytes: string) => {
@@ -11,9 +11,6 @@ type ResizeImageToGeminiImageOptions = {
* Resize image to 1000x1000 using fill strategy.
* Scales to cover the target area and crops any overflow.
*/
export const resizeImageToGeminiImage = async ({
image,
size = TARGET_SIZE,
}: ResizeImageToGeminiImageOptions) => {
export const resizeImageToGeminiImage = async ({ image, size = TARGET_SIZE }: ResizeImageToGeminiImageOptions) => {
return await sharp(image).resize(size, size, { fit: 'fill' }).toBuffer();
};
+1 -1
View File
@@ -1,4 +1,4 @@
import { type TransportTargetOptions, pino } from 'pino';
import { pino, type TransportTargetOptions } from 'pino';
import type { BaseApiLog } from '../types/api-logs';
import { extractRequestMetadata } from '../universal/extract-request-metadata';
@@ -1,6 +1,5 @@
import type { User } from '@prisma/client';
import type { EnvelopeWithRecipients } from '@documenso/prisma/types/document-with-recipient';
import type { User } from '@prisma/client';
export type MaskRecipientTokensForDocumentOptions<T extends EnvelopeWithRecipients> = {
document: T;
@@ -0,0 +1,46 @@
import type { TCssVarsSchema } from '../types/css-vars';
/**
* Normalise a branding-colours payload coming from a settings form.
*
* The colour-pickers store empty strings for cleared fields, and
* `ZCssVarsSchema.default({})` produces `{}` when the form is submitted
* without any colour overrides. Persisting either as a non-null value would
* silently mask the org's defaults for a team, and produce noisy "this is
* an override of nothing" rows in the database.
*
* This helper:
* - strips keys whose value is `undefined`, `null`, or an empty string
* - returns `null` if the result has no remaining keys
* - leaves all other keys verbatim (validation against ZCssVarsSchema is
* expected to have happened at the request boundary)
*
* `undefined` input means "no change" — the caller should not pass it
* through to Prisma. We pass it through unchanged so handlers can keep their
* existing `=== undefined` branches.
*/
export const normalizeBrandingColors = (
input: TCssVarsSchema | null | undefined,
): TCssVarsSchema | null | undefined => {
if (input === undefined) {
return undefined;
}
if (input === null) {
return null;
}
const cleaned: Record<string, string> = {};
for (const [key, value] of Object.entries(input)) {
if (typeof value === 'string' && value.trim() !== '') {
cleaned[key] = value;
}
}
if (Object.keys(cleaned).length === 0) {
return null;
}
return cleaned as TCssVarsSchema;
};
+1 -2
View File
@@ -1,6 +1,5 @@
import type { SubscriptionClaim } from '@prisma/client';
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT } from '@documenso/ee/server-only/limits/constants';
import type { SubscriptionClaim } from '@prisma/client';
export const generateDefaultSubscriptionClaim = (): Omit<
SubscriptionClaim,
+13 -13
View File
@@ -1,13 +1,10 @@
import type { Organisation, OrganisationGlobalSettings, Prisma } from '@prisma/client';
import {
DocumentVisibility,
type OrganisationGroup,
type OrganisationMemberRole,
} from '@prisma/client';
import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import type { Organisation, OrganisationGlobalSettings, Prisma } from '@prisma/client';
import { DocumentVisibility, type OrganisationGroup, type OrganisationMemberRole } from '@prisma/client';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats';
import { DEFAULT_ENVELOPE_EXPIRATION_PERIOD } from '../constants/envelope-expiration';
import { DEFAULT_ENVELOPE_REMINDER_SETTINGS } from '../constants/envelope-reminder';
import {
LOWEST_ORGANISATION_ROLE,
ORGANISATION_MEMBER_ROLE_HIERARCHY,
@@ -55,8 +52,7 @@ export const getHighestOrganisationRoleInGroup = (
groups.forEach((group) => {
const currentRolePriority = ORGANISATION_MEMBER_ROLE_HIERARCHY[group.organisationRole].length;
const highestOrganisationRolePriority =
ORGANISATION_MEMBER_ROLE_HIERARCHY[highestOrganisationRole].length;
const highestOrganisationRolePriority = ORGANISATION_MEMBER_ROLE_HIERARCHY[highestOrganisationRole].length;
if (currentRolePriority > highestOrganisationRolePriority) {
highestOrganisationRole = group.organisationRole;
@@ -108,10 +104,7 @@ export const buildOrganisationWhereQuery = ({
};
};
export const generateDefaultOrganisationSettings = (): Omit<
OrganisationGlobalSettings,
'id' | 'organisation'
> => {
export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSettings, 'id' | 'organisation'> => {
return {
documentVisibility: DocumentVisibility.EVERYONE,
documentLanguage: 'en',
@@ -131,6 +124,8 @@ export const generateDefaultOrganisationSettings = (): Omit<
brandingLogo: '',
brandingUrl: '',
brandingCompanyDetails: '',
brandingColors: null,
brandingCss: '',
emailId: null,
emailReplyTo: null,
@@ -138,6 +133,11 @@ export const generateDefaultOrganisationSettings = (): Omit<
emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS,
defaultRecipients: null,
envelopeExpirationPeriod: DEFAULT_ENVELOPE_EXPIRATION_PERIOD,
reminderSettings: DEFAULT_ENVELOPE_REMINDER_SETTINGS,
aiFeaturesEnabled: false,
};
};
+2 -8
View File
@@ -1,15 +1,9 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
export const formatUserProfilePath = (
profileUrl: string,
options: { excludeBaseUrl?: boolean } = {},
) => {
export const formatUserProfilePath = (profileUrl: string, options: { excludeBaseUrl?: boolean } = {}) => {
return `${!options?.excludeBaseUrl ? NEXT_PUBLIC_WEBAPP_URL() : ''}/p/${profileUrl}`;
};
export const formatTeamProfilePath = (
profileUrl: string,
options: { excludeBaseUrl?: boolean } = {},
) => {
export const formatTeamProfilePath = (profileUrl: string, options: { excludeBaseUrl?: boolean } = {}) => {
return `${!options?.excludeBaseUrl ? NEXT_PUBLIC_WEBAPP_URL() : ''}/p/${profileUrl}`;
};
+47 -10
View File
@@ -1,11 +1,12 @@
import type { Envelope } from '@prisma/client';
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
import { z } from 'zod';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { Envelope } from '@prisma/client';
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { AppError, AppErrorCode } from '../errors/app-error';
import type { TRecipientLite } from '../types/recipient';
import { extractLegacyIds } from '../universal/id';
import { zEmail } from './zod';
/**
* Roles that require fields to be assigned before a document can be distributed.
@@ -19,7 +20,7 @@ export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as con
*
* Currently only SIGNERs are validated - they must have at least one signature field.
*/
export const getRecipientsWithMissingFields = <T extends Pick<Recipient, 'id' | 'role'>>(
export const getRecipientsWithMissingFields = <T extends Pick<TRecipientLite, 'id' | 'role'>>(
recipients: T[],
fields: Pick<Field, 'type' | 'recipientId'>[],
): T[] => {
@@ -41,7 +42,10 @@ export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}
/**
* Whether a recipient can be modified by the document owner.
*/
export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) => {
export const canRecipientBeModified = (
recipient: TRecipientLite,
fields: Pick<Field, 'recipientId' | 'inserted'>[],
) => {
if (!recipient) {
return false;
}
@@ -71,7 +75,10 @@ export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) =>
* - They are not a Viewer or CCer
* - They can be modified (canRecipientBeModified)
*/
export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field[]) => {
export const canRecipientFieldsBeModified = (
recipient: TRecipientLite,
fields: Pick<Field, 'recipientId' | 'inserted'>[],
) => {
if (!canRecipientBeModified(recipient, fields)) {
return false;
}
@@ -80,7 +87,7 @@ export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field
};
export const mapRecipientToLegacyRecipient = (
recipient: Recipient,
recipient: TRecipientLite,
envelope: Pick<Envelope, 'type' | 'secondaryId'>,
) => {
const legacyId = extractLegacyIds(envelope);
@@ -91,6 +98,36 @@ export const mapRecipientToLegacyRecipient = (
};
};
export const isRecipientEmailValidForSending = (recipient: Pick<Recipient, 'email'>) => {
return z.string().email().safeParse(recipient.email).success;
export const findRecipientByEmail = <T extends { email: string }>({
recipients,
userEmail,
teamEmail,
}: {
recipients: T[];
userEmail: string;
teamEmail?: string | null;
}) => recipients.find((r) => r.email === userEmail || (teamEmail && r.email === teamEmail));
export const isRecipientEmailValidForSending = (recipient: Pick<TRecipientLite, 'email'>) => {
return zEmail().safeParse(recipient.email).success;
};
/**
* Whether the recipient's signing window has expired.
*/
export const isRecipientExpired = (recipient: { expiresAt: Date | null }) => {
return Boolean(recipient.expiresAt && new Date(recipient.expiresAt) <= new Date());
};
/**
* Asserts that the recipient's signing window has not expired.
*
* Throws an AppError with RECIPIENT_EXPIRED if the expiration date has passed.
*/
export const assertRecipientNotExpired = (recipient: { expiresAt: Date | null }) => {
if (isRecipientExpired(recipient)) {
throw new AppError(AppErrorCode.RECIPIENT_EXPIRED, {
message: 'Recipient signing window has expired',
});
}
};
@@ -1,7 +1,4 @@
export const renderCustomEmailTemplate = <T extends Record<string, string>>(
template: string,
variables: T,
): string => {
export const renderCustomEmailTemplate = <T extends Record<string, string>>(template: string, variables: T): string => {
return template.replace(/\{(\S+)\}/g, (_, key) => {
if (key in variables) {
return variables[key];
@@ -2,11 +2,7 @@ import type { RenderOptions } from '@documenso/email/render';
import { renderWithI18N } from '@documenso/email/render';
import { getI18nInstance } from '../client-only/providers/i18n-server';
import {
APP_I18N_OPTIONS,
type SupportedLanguageCodes,
isValidLanguageCode,
} from '../constants/i18n';
import { APP_I18N_OPTIONS, isValidLanguageCode, type SupportedLanguageCodes } from '../constants/i18n';
export const renderEmailWithI18N = async (
component: React.ReactElement,
@@ -0,0 +1,436 @@
import { describe, expect, it } from 'vitest';
import { sanitizeBrandingCss } from './sanitize-branding-css';
const normalize = (css: string) => css.replace(/\s+/g, ' ').trim();
/**
* The sanitiser does NOT scope selectors. Scoping is applied at render time
* by wrapping the entire sanitised output in `.documenso-branded { ... }` via
* native CSS nesting (see `RecipientBranding`). These tests assert that
* selectors are preserved verbatim and only validated.
*/
describe('sanitizeBrandingCss', () => {
describe('empty input', () => {
it('returns empty output for an empty string', () => {
const result = sanitizeBrandingCss('');
expect(result.css).toBe('');
expect(result.warnings).toEqual([]);
});
it('returns empty output for whitespace-only input', () => {
const result = sanitizeBrandingCss(' \n\t \n');
expect(result.css).toBe('');
expect(result.warnings).toEqual([]);
});
});
describe('selector preservation', () => {
it('preserves a bare class selector', () => {
const result = sanitizeBrandingCss('.foo { color: red; }');
expect(normalize(result.css)).toBe('.foo { color: red; }');
expect(result.warnings).toEqual([]);
});
it('preserves a tag selector', () => {
const result = sanitizeBrandingCss('h1 { color: red; }');
expect(normalize(result.css)).toBe('h1 { color: red; }');
expect(result.warnings).toEqual([]);
});
it('preserves combinators', () => {
const result = sanitizeBrandingCss('.a > .b + .c ~ .d { color: red; }');
expect(normalize(result.css)).toBe('.a > .b + .c ~ .d { color: red; }');
expect(result.warnings).toEqual([]);
});
it('preserves comma-separated selectors', () => {
const result = sanitizeBrandingCss('.a, .b { color: red; }');
expect(normalize(result.css)).toBe('.a, .b { color: red; }');
expect(result.warnings).toEqual([]);
});
it('preserves body/html/:root verbatim (will no-op once nested at render)', () => {
const result = sanitizeBrandingCss('body { background: black; }');
// Selector is left as-is. At render time this becomes
// `.documenso-branded body { ... }`, which won't match anything since
// <body> is an ancestor of the wrapper. Documented tradeoff.
expect(normalize(result.css)).toBe('body { background: black; }');
expect(result.warnings).toEqual([]);
});
});
describe('pseudo-elements', () => {
it('drops a rule containing ::before', () => {
const result = sanitizeBrandingCss(".foo::before { content: 'x'; }");
expect(result.css.trim()).toBe('');
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].kind).toBe('selector');
});
it('drops a rule containing ::after', () => {
const result = sanitizeBrandingCss(".foo::after { content: 'x'; }");
expect(result.css.trim()).toBe('');
expect(result.warnings).toHaveLength(1);
});
it('drops a rule containing ::backdrop', () => {
const result = sanitizeBrandingCss('.foo::backdrop { color: red; }');
expect(result.css.trim()).toBe('');
expect(result.warnings).toHaveLength(1);
});
it('drops a rule containing ::marker', () => {
const result = sanitizeBrandingCss('li::marker { color: red; }');
expect(result.css.trim()).toBe('');
expect(result.warnings).toHaveLength(1);
});
it('drops a rule using legacy single-colon :before', () => {
const result = sanitizeBrandingCss(".foo:before { content: 'x'; }");
expect(result.css.trim()).toBe('');
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].kind).toBe('selector');
});
it('drops a rule using legacy single-colon :after', () => {
const result = sanitizeBrandingCss(".foo:after { content: 'x'; }");
expect(result.css.trim()).toBe('');
expect(result.warnings).toHaveLength(1);
});
it('keeps ::placeholder verbatim', () => {
const result = sanitizeBrandingCss('input::placeholder { color: gray; }');
expect(normalize(result.css)).toBe('input::placeholder { color: gray; }');
expect(result.warnings).toEqual([]);
});
it('keeps ::selection verbatim', () => {
const result = sanitizeBrandingCss('p::selection { background: yellow; }');
expect(normalize(result.css)).toBe('p::selection { background: yellow; }');
expect(result.warnings).toEqual([]);
});
});
describe('universal selector', () => {
it('drops a bare * selector rule', () => {
const result = sanitizeBrandingCss('* { color: red; }');
expect(result.css.trim()).toBe('');
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].kind).toBe('selector');
});
it('drops a rule with * combined with descendant', () => {
const result = sanitizeBrandingCss('* .x { color: red; }');
expect(result.css.trim()).toBe('');
expect(result.warnings).toHaveLength(1);
});
it('keeps attribute selectors that include * inside', () => {
const result = sanitizeBrandingCss('[class*="foo"] { color: red; }');
expect(normalize(result.css)).toBe('[class*="foo"] { color: red; }');
expect(result.warnings).toEqual([]);
});
});
describe('blocked properties', () => {
const blockedProperties = [
'display',
'visibility',
'opacity',
'pointer-events',
'position',
'inset',
'top',
'right',
'bottom',
'left',
'z-index',
'transform',
'clip',
'clip-path',
'mask',
'mask-image',
'content',
'width',
'height',
'min-width',
'min-height',
'max-width',
'max-height',
'overflow',
'overflow-x',
'overflow-y',
'font-size',
'letter-spacing',
'word-spacing',
'line-height',
'text-indent',
];
for (const prop of blockedProperties) {
it(`strips the "${prop}" property`, () => {
const result = sanitizeBrandingCss(`.x { ${prop}: 10px; color: red; }`);
expect(result.css).not.toContain(`${prop}:`);
expect(result.css).toContain('color: red');
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].kind).toBe('property');
expect(result.warnings[0].detail).toContain(prop);
});
}
it('is case-insensitive on property names', () => {
const result = sanitizeBrandingCss('.x { DISPLAY: none; color: red; }');
expect(result.css).not.toMatch(/display/i);
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].kind).toBe('property');
});
const allowedProperties: Array<[string, string]> = [
['color', 'red'],
['background', '#fff'],
['border', '1px solid black'],
['border-radius', '4px'],
['font-family', 'sans-serif'],
['font-weight', '600'],
];
for (const [prop, value] of allowedProperties) {
it(`keeps the "${prop}" property`, () => {
const result = sanitizeBrandingCss(`.x { ${prop}: ${value}; }`);
expect(result.css).toContain(`${prop}: ${value}`);
expect(result.warnings).toEqual([]);
});
}
});
describe('blocked values', () => {
it('drops a declaration containing url(', () => {
const result = sanitizeBrandingCss('.x { background: url(http://evil); }');
expect(result.css).not.toContain('url(');
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].kind).toBe('value');
});
it('drops a declaration containing expression(', () => {
const result = sanitizeBrandingCss('.x { background: expression(alert(1)); }');
expect(result.css).not.toContain('expression(');
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].kind).toBe('value');
});
it('drops a declaration containing javascript: in a quoted value', () => {
// PostCSS would throw on bare `javascript:alert(1)` (looks like a
// malformed selector inside a declaration). Use a quoted value to
// exercise the substring match cleanly.
const result = sanitizeBrandingCss('.x { font-family: "javascript:alert"; }');
expect(result.css).not.toContain('javascript:');
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].kind).toBe('value');
});
});
describe('!important stripping', () => {
it('strips !important from a retained declaration', () => {
const result = sanitizeBrandingCss('.x { color: red !important; }');
expect(result.css).not.toContain('!important');
expect(result.css).toContain('color: red');
expect(result.warnings).toEqual([]);
});
});
describe('at-rules', () => {
it('drops @import', () => {
const result = sanitizeBrandingCss('@import url("https://evil.example/x.css");');
expect(result.css.trim()).toBe('');
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].kind).toBe('at-rule');
});
it('drops @font-face', () => {
const result = sanitizeBrandingCss('@font-face { font-family: "X"; src: url("x.woff2"); }');
expect(result.css.trim()).toBe('');
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].kind).toBe('at-rule');
});
it('drops @keyframes', () => {
const result = sanitizeBrandingCss('@keyframes spin { to { transform: rotate(360deg); } }');
expect(result.css.trim()).toBe('');
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].kind).toBe('at-rule');
});
it('drops @supports', () => {
const result = sanitizeBrandingCss('@supports (display: grid) { .x { color: red; } }');
expect(result.css.trim()).toBe('');
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].kind).toBe('at-rule');
});
it('keeps @media with min-width and preserves inner selectors verbatim', () => {
const result = sanitizeBrandingCss('@media (min-width: 600px) { .x { color: red; } }');
expect(normalize(result.css)).toBe('@media (min-width: 600px) { .x { color: red; } }');
expect(result.warnings).toEqual([]);
});
it('keeps @media with prefers-color-scheme and preserves body inside', () => {
const result = sanitizeBrandingCss('@media (prefers-color-scheme: dark) { body { background: black; } }');
expect(normalize(result.css)).toBe('@media (prefers-color-scheme: dark) { body { background: black; } }');
expect(result.warnings).toEqual([]);
});
it('strips blocked properties inside @media', () => {
const result = sanitizeBrandingCss('@media (min-width: 600px) { .x { display: none; color: red; } }');
expect(result.css).not.toContain('display');
expect(result.css).toContain('color: red');
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].kind).toBe('property');
});
});
describe('combined input', () => {
it('keeps valid rules verbatim and reports each drop', () => {
const input = `
.ok { color: red !important; }
.bad-prop { display: none; background: blue; }
.bad-pseudo::before { content: 'x'; }
* { color: red; }
@import "evil.css";
body { background: black; }
@media (min-width: 600px) {
.responsive { color: green; }
}
`;
const result = sanitizeBrandingCss(input);
// Valid bits present, unchanged.
expect(result.css).toContain('.ok');
expect(result.css).toContain('color: red');
expect(result.css).toContain('.bad-prop');
expect(result.css).toContain('background: blue');
expect(result.css).toContain('body { background: black');
expect(result.css).toContain('@media (min-width: 600px)');
expect(result.css).toContain('.responsive');
// Invalid bits gone.
expect(result.css).not.toContain('!important');
expect(result.css).not.toContain('display');
expect(result.css).not.toContain('::before');
expect(result.css).not.toContain('@import');
// Warning kinds.
const kinds = result.warnings.map((w) => w.kind).sort();
expect(kinds).toEqual(['at-rule', 'property', 'selector', 'selector'].sort());
});
});
describe('style-close-tag defence', () => {
// The sanitised output is inlined into a `<style>` element via SSR. The
// browser's HTML parser terminates the element on a literal `</style`
// anywhere in the content. PostCSS's serializer normally escapes `<` to
// `\3c` whenever it would form `</...`, so the literal sequence should
// never reach the output for any of these inputs. These tests pin that
// invariant.
it('escapes </style> inside a string value', () => {
const result = sanitizeBrandingCss('.x { font-family: "</style><img src=x onerror=alert(1)>"; }');
expect(result.css.toLowerCase()).not.toContain('</style');
// Whatever else happens, the canonical close-tag bytes must not appear.
});
it('escapes </style> inside a CSS comment', () => {
const result = sanitizeBrandingCss('.x { color: red; /* </style><script>alert(1)</script> */ }');
expect(result.css.toLowerCase()).not.toContain('</style');
});
it('escapes </style> inside an at-rule params block', () => {
const result = sanitizeBrandingCss(
'@media screen and (foo: bar)</style><script>x()</script> { .x { color: red; } }',
);
expect(result.css.toLowerCase()).not.toContain('</style');
});
it('escapes mixed-case </StYlE> in a value', () => {
const result = sanitizeBrandingCss('.x { font-family: "</StYlE>foo"; }');
expect(result.css.toLowerCase()).not.toContain('</style');
});
it('escapes </style> in an attribute selector value', () => {
const result = sanitizeBrandingCss('[data-x="</style><script>alert(1)</script>"] { color: red; }');
expect(result.css.toLowerCase()).not.toContain('</style');
});
it('preserves benign < not followed by /', () => {
// `<script>` (no slash) is not a tag close; PostCSS leaves it as text
// and the HTML parser treats it as text inside <style> rawtext mode.
const result = sanitizeBrandingCss('.x { font-family: "<script>alert(1)</script>"; }');
// The output keeps the literal `<script>` (harmless) but escapes the
// `</script>` end tag's `<` for the same reason it'd escape `</style>`.
expect(result.css).toContain('<script>');
expect(result.css.toLowerCase()).not.toContain('</style');
});
});
describe('malformed CSS', () => {
// PostCSS is forgiving; an empty value parses without throwing.
it('handles a declaration with an empty value gracefully', () => {
const result = sanitizeBrandingCss('.x { color: }');
expect(result.warnings.filter((w) => w.kind === 'parse-error')).toEqual([]);
expect(result.css).toContain('.x');
});
it('reports a parse-error for clearly broken CSS', () => {
// Unclosed brace.
const result = sanitizeBrandingCss('.x { color: red');
// PostCSS may or may not throw on this; if it does, we get a
// parse-error warning. If it tolerates it, the rule is sanitized.
if (result.css === '') {
expect(result.warnings.some((w) => w.kind === 'parse-error')).toBe(true);
} else {
expect(result.css).toContain('.x');
}
});
});
});
+323
View File
@@ -0,0 +1,323 @@
import type { AtRule, Container, Declaration, Rule } from 'postcss';
import postcss from 'postcss';
import selectorParser from 'postcss-selector-parser';
import { z } from 'zod';
export const ZSanitizeBrandingCssWarningSchema = z.object({
kind: z.enum(['selector', 'property', 'value', 'at-rule', 'parse-error']),
detail: z.string(),
line: z.number().optional(),
});
export type SanitizeBrandingCssWarning = z.infer<typeof ZSanitizeBrandingCssWarningSchema>;
export type SanitizeBrandingCssResult = {
css: string;
warnings: SanitizeBrandingCssWarning[];
};
/**
* The class name the sanitised CSS will be wrapped in at render time using
* CSS nesting (`.documenso-branded { <user css> }`). The sanitiser itself
* does NOT prefix selectors — the wrapper is applied by `RecipientBranding`
* on every render so we keep the user's original CSS intact in the database.
*/
export const SANITIZE_BRANDING_SCOPE_CLASS = 'documenso-branded';
const BLOCKED_PROPERTIES = new Set([
'display',
'visibility',
'opacity',
'pointer-events',
'position',
'inset',
'top',
'right',
'bottom',
'left',
'z-index',
'transform',
'clip',
'clip-path',
'mask',
'mask-image',
'content',
'width',
'height',
'min-width',
'min-height',
'max-width',
'max-height',
'overflow',
'overflow-x',
'overflow-y',
'font-size',
'letter-spacing',
'word-spacing',
'line-height',
'text-indent',
]);
const BLOCKED_VALUE_SUBSTRINGS = ['url(', 'expression(', '@import', 'javascript:'];
const BLOCKED_PSEUDO_ELEMENTS = new Set([
'::before',
'::after',
'::backdrop',
'::marker',
// Single-colon legacy forms.
':before',
':after',
]);
const BLOCKED_AT_RULES = new Set([
'import',
'font-face',
'keyframes',
'charset',
'namespace',
'supports',
'page',
'document',
'viewport',
]);
type SelectorValidationResult = { kind: 'ok' } | { kind: 'drop'; reason: string };
/**
* Validate a selector for the rules we care about, but DO NOT rewrite it.
* The sanitised output is later wrapped in `.documenso-branded { ... }` via
* native CSS nesting by `RecipientBranding`, so scoping happens at render.
*/
const validateSelector = (rawSelector: string): SelectorValidationResult => {
let dropReason: string | null = null;
const transform = selectorParser((selectors) => {
selectors.each((selector) => {
selector.walk((node) => {
// Pseudo-element check (works at any depth — even nested pseudos like
// `:is(::before)` should be rejected).
if (node.type === 'pseudo') {
const value = node.value;
if (BLOCKED_PSEUDO_ELEMENTS.has(value)) {
dropReason = `pseudo-element "${value}" not allowed`;
}
}
});
if (dropReason !== null) {
return;
}
// Universal selector check — only when it is a direct child of the
// top-level compound (i.e. `* { ... }` or `* .foo { ... }`).
// Universal nodes nested inside attribute selectors (`[class*="x"]`)
// are a different node type and won't appear here.
selector.each((node) => {
if (node.type === 'universal') {
dropReason = 'universal "*" selector not allowed';
}
});
});
});
try {
// We don't keep the result — we only care about parsing and walking
// to populate dropReason.
transform.processSync(rawSelector);
} catch (error) {
return {
kind: 'drop',
reason: error instanceof Error ? error.message : 'failed to parse selector',
};
}
if (dropReason !== null) {
return { kind: 'drop', reason: dropReason };
}
return { kind: 'ok' };
};
const valueIsBlocked = (rawValue: string): boolean => {
const lowered = rawValue.toLowerCase();
return BLOCKED_VALUE_SUBSTRINGS.some((needle) => lowered.includes(needle));
};
const sanitizeDeclarations = (container: Container, warnings: SanitizeBrandingCssWarning[]): void => {
const toRemove: Declaration[] = [];
container.each((node) => {
if (node.type !== 'decl') {
return;
}
const decl = node;
const propLower = decl.prop.toLowerCase();
if (BLOCKED_PROPERTIES.has(propLower)) {
warnings.push({
kind: 'property',
detail: `property "${decl.prop}" is not allowed`,
line: decl.source?.start?.line,
});
toRemove.push(decl);
return;
}
if (valueIsBlocked(decl.value)) {
warnings.push({
kind: 'value',
detail: `value of "${decl.prop}" contains a disallowed token`,
line: decl.source?.start?.line,
});
toRemove.push(decl);
return;
}
if (decl.important) {
decl.important = false;
}
});
for (const decl of toRemove) {
decl.remove();
}
};
const sanitizeRule = (rule: Rule, warnings: SanitizeBrandingCssWarning[]): void => {
const line = rule.source?.start?.line;
const validation = validateSelector(rule.selector);
if (validation.kind === 'drop') {
warnings.push({ kind: 'selector', detail: validation.reason, line });
rule.remove();
return;
}
// Selector is left as-is. Scoping is applied at render time by wrapping
// the entire sanitised CSS in `.documenso-branded { ... }` (CSS nesting).
sanitizeDeclarations(rule, warnings);
// If the rule has no declarations left, leave the empty rule in place — the
// output is still valid CSS and the user can see what happened. (Removing
// it would also be acceptable; we keep it to make warnings easier to map.)
};
const sanitizeAtRule = (atRule: AtRule, warnings: SanitizeBrandingCssWarning[]): void => {
const name = atRule.name.toLowerCase();
const line = atRule.source?.start?.line;
if (BLOCKED_AT_RULES.has(name)) {
warnings.push({
kind: 'at-rule',
detail: `at-rule "@${atRule.name}" is not allowed`,
line,
});
atRule.remove();
return;
}
if (name !== 'media') {
warnings.push({
kind: 'at-rule',
detail: `at-rule "@${atRule.name}" is not allowed`,
line,
});
atRule.remove();
return;
}
// Recurse into @media children.
const children = atRule.nodes ? [...atRule.nodes] : [];
for (const child of children) {
if (child.type === 'rule') {
sanitizeRule(child, warnings);
} else if (child.type === 'atrule') {
sanitizeAtRule(child, warnings);
}
// Comments and stray declarations inside @media are left alone /
// declarations directly under @media are invalid CSS anyway.
}
};
/**
* Defence in depth against `<style>` element breakout.
*
* The sanitised CSS is inlined into a `<style>` element via SSR
* `dangerouslySetInnerHTML`. The browser's HTML parser (in RAWTEXT mode for
* `<style>` content) terminates the element on a literal `</style` —
* regardless of whether it appears inside a CSS string, comment, or at-rule
* params. PostCSS's serializer escapes `<` to `\3c` whenever it would form
* `</...`, which means a normal round-trip is already safe.
*
* That escape is implicit in PostCSS, not enforced by our own logic. If a
* future PostCSS version, plugin, or alternative serializer regresses, we
* still want the output to be safe to inline. This regex is the explicit
* tripwire — case-insensitive `</style` anywhere in the final output is a
* hard fail.
*/
const STYLE_CLOSE_TAG_REGEX = /<\s*\/\s*style/i;
export const sanitizeBrandingCss = (input: string): SanitizeBrandingCssResult => {
const warnings: SanitizeBrandingCssWarning[] = [];
if (input.trim() === '') {
return { css: '', warnings };
}
let root;
try {
root = postcss.parse(input);
} catch (error) {
return {
css: '',
warnings: [
{
kind: 'parse-error',
detail: error instanceof Error ? error.message : 'failed to parse CSS',
},
],
};
}
// Iterate over a snapshot of top-level children so removal during the loop
// is safe.
const topLevelChildren = root.nodes ? [...root.nodes] : [];
for (const node of topLevelChildren) {
if (node.type === 'rule') {
sanitizeRule(node, warnings);
} else if (node.type === 'atrule') {
sanitizeAtRule(node, warnings);
}
// Top-level decls / comments are left as-is.
}
const output = root.toString();
if (STYLE_CLOSE_TAG_REGEX.test(output)) {
return {
css: '',
warnings: [
...warnings,
{
kind: 'parse-error',
detail: 'output contained a literal </style sequence and was rejected',
},
],
};
}
return { css: output, warnings };
};
+9 -9
View File
@@ -1,12 +1,5 @@
import type {
DocumentVisibility,
OrganisationGlobalSettings,
Prisma,
TeamGlobalSettings,
} from '@prisma/client';
import type { TeamGroup } from '@documenso/prisma/generated/types';
import type { TeamMemberRole } from '@documenso/prisma/generated/types';
import type { TeamGroup, TeamMemberRole } from '@documenso/prisma/generated/types';
import type { DocumentVisibility, OrganisationGlobalSettings, Prisma, TeamGlobalSettings } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import {
@@ -198,6 +191,8 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
brandingLogo: null,
brandingUrl: null,
brandingCompanyDetails: null,
brandingColors: null,
brandingCss: null,
emailDocumentSettings: null,
emailId: null,
@@ -205,6 +200,11 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
// emailReplyToName: null,
defaultRecipients: null,
envelopeExpirationPeriod: null,
reminderSettings: null,
aiFeaturesEnabled: null,
};
};
+2 -3
View File
@@ -1,5 +1,4 @@
import type { Envelope } from '@prisma/client';
import { type Recipient } from '@prisma/client';
import type { Envelope, Recipient } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import type { TTemplateLite } from '../types/template';
@@ -51,7 +50,7 @@ export const mapEnvelopeToTemplateLite = (envelope: Envelope): TTemplateLite =>
return {
id: legacyTemplateId,
envelopeId: envelope.secondaryId,
envelopeId: envelope.id,
type: envelope.templateType,
visibility: envelope.visibility,
externalId: envelope.externalId,
+34
View File
@@ -0,0 +1,34 @@
/**
* Race a promise against a timeout. Returns `null` if the timeout
* fires before the promise settles.
*/
export const withTimeout = async <T>(promise: Promise<T>, timeoutMs: number) =>
await Promise.race<T | null>([
promise,
new Promise<null>((resolve) => {
setTimeout(() => resolve(null), timeoutMs);
}),
]);
/**
* Wrapper around `fetch` that aborts the request after `timeoutMs`.
* Throws with a descriptive message on timeout.
*/
export const fetchWithTimeout = async (input: string | URL | Request, init: RequestInit & { timeoutMs: number }) => {
const { timeoutMs, ...fetchInit } = init;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(input, { ...fetchInit, signal: controller.signal });
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timeout);
}
};
+38
View File
@@ -0,0 +1,38 @@
import { z } from 'zod';
/**
* RFC 5322 compliant email regex.
*
* This is more permissive than Zod's built-in `.email()` validator which rejects
* valid international characters (e.g. "Søren@gmail.com").
*
* Compiled once at module level to avoid re-compilation on every validation call.
*/
const EMAIL_REGEX =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~\u{0080}-\u{FFFF}-]+@[a-zA-Z0-9\u{0080}-\u{FFFF}](?:[a-zA-Z0-9\u{0080}-\u{FFFF}-]{0,61}[a-zA-Z0-9\u{0080}-\u{FFFF}])?(?:\.[a-zA-Z0-9\u{0080}-\u{FFFF}](?:[a-zA-Z0-9\u{0080}-\u{FFFF}-]{0,61}[a-zA-Z0-9\u{0080}-\u{FFFF}])?)*$/u;
const DEFAULT_EMAIL_MESSAGE = 'Invalid email address';
/**
* Creates a Zod email schema using an RFC 5322 compliant regex.
*
* Supports international characters in the local part and domain
* (e.g. "Søren@gmail.com", "user@dömain.com").
*
* Returns a standard `ZodString` so all string methods are chainable:
* `.min()`, `.max()`, `.trim()`, `.toLowerCase()`, `.optional()`, `.nullish()`, etc.
*
* @example
* ```ts
* zEmail()
* zEmail().min(1).max(254)
* zEmail().trim().toLowerCase()
* zEmail('Email is invalid')
* zEmail({ message: 'Email is invalid' })
* ```
*/
export const zEmail = (options?: string | { message?: string }) => {
const message = typeof options === 'string' ? options : (options?.message ?? DEFAULT_EMAIL_MESSAGE);
return z.string().regex(EMAIL_REGEX, { message });
};