mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 17:35:05 +10:00
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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 ?? [],
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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,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,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,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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user