mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 08:54:20 +10:00
Merge remote-tracking branch 'origin/main' into pr-2468
# Conflicts: # packages/lib/types/document-audit-logs.ts # packages/lib/utils/document-audit-logs.ts # packages/prisma/schema.prisma
This commit is contained in:
@@ -1,19 +1,9 @@
|
||||
import type { Subscription } from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
|
||||
import { OrganisationType } from '@prisma/client';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../constants/app';
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
import type { StripeOrganisationCreateMetadata } from '../types/subscription';
|
||||
|
||||
export const generateStripeOrganisationCreateMetadata = (organisationName: string, userId: number) => {
|
||||
const metadata: StripeOrganisationCreateMetadata = {
|
||||
organisationName,
|
||||
userId,
|
||||
};
|
||||
|
||||
return {
|
||||
organisationCreateData: JSON.stringify(metadata),
|
||||
};
|
||||
};
|
||||
import { INTERNAL_CLAIM_ID } from '../types/subscription';
|
||||
|
||||
/**
|
||||
* Throws an error if billing is enabled and no subscription is found.
|
||||
@@ -33,3 +23,36 @@ export const validateIfSubscriptionIsRequired = (subscription?: Subscription | n
|
||||
|
||||
return subscription;
|
||||
};
|
||||
|
||||
type PendingPaymentOrganisation = {
|
||||
type: OrganisationType;
|
||||
subscription?: unknown;
|
||||
organisationClaim: {
|
||||
originalSubscriptionClaimId: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the organisation was created ahead of a paid checkout and is still awaiting
|
||||
* its first successful payment.
|
||||
*
|
||||
* Such organisations have no subscription row and still carry the copied "free" claim,
|
||||
* and must be treated as restricted until the Stripe webhook sync activates them.
|
||||
*
|
||||
* Always returns false when billing is disabled (self-hosted).
|
||||
*/
|
||||
export const isOrganisationPendingPayment = (organisation: PendingPaymentOrganisation) => {
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (organisation.type !== OrganisationType.ORGANISATION) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (organisation.subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.FREE;
|
||||
};
|
||||
|
||||
@@ -355,6 +355,14 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
|
||||
you: msg`You deleted the document`,
|
||||
user: msg`${user} deleted the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CANCELLED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Document cancelled`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
you: msg`You cancelled the document`,
|
||||
user: msg`${user} cancelled the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `System auto inserted fields`,
|
||||
@@ -501,11 +509,31 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
|
||||
user: msg`${user} completed their task`,
|
||||
}));
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, () => ({
|
||||
anonymous: msg`Recipient rejected the document`,
|
||||
you: msg`You rejected the document`,
|
||||
user: msg`${user} rejected the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => {
|
||||
if (data.isExternal) {
|
||||
const onBehalfOf = data.onBehalfOfUserName || data.onBehalfOfUserEmail;
|
||||
|
||||
if (onBehalfOf) {
|
||||
return {
|
||||
anonymous: msg`The document was rejected externally by ${onBehalfOf} on behalf of the recipient`,
|
||||
you: msg`The document was rejected externally by ${onBehalfOf} on behalf of the recipient`,
|
||||
user: msg`The document was rejected externally by ${onBehalfOf} on behalf of ${user}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
anonymous: msg`Recipient rejected the document externally`,
|
||||
you: msg`The document was rejected externally on behalf of the recipient`,
|
||||
user: msg`The document was rejected externally on behalf of ${user}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
anonymous: msg`Recipient rejected the document`,
|
||||
you: msg`You rejected the document`,
|
||||
user: msg`${user} rejected the document`,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, () => ({
|
||||
anonymous: msg`Recipient requested a 2FA token for the document`,
|
||||
you: msg`You requested a 2FA token for the document`,
|
||||
@@ -639,6 +667,31 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
|
||||
});
|
||||
return { anonymous: message, you: message, user: message };
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATED }, () => ({
|
||||
anonymous: msg`Recipient authenticated with the signing provider`,
|
||||
you: msg`You authenticated with the signing provider`,
|
||||
user: msg`${user} authenticated with the signing provider`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED }, () => ({
|
||||
anonymous: msg`Recipient's signing provider authentication failed`,
|
||||
you: msg`Your signing provider authentication failed`,
|
||||
user: msg`${user}'s signing provider authentication failed`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGN_REQUESTED }, () => ({
|
||||
anonymous: msg`Recipient requested a remote signature`,
|
||||
you: msg`You requested a remote signature`,
|
||||
user: msg`${user} requested a remote signature`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHORIZED }, () => ({
|
||||
anonymous: msg`Recipient authorised the remote signature`,
|
||||
you: msg`You authorised the remote signature`,
|
||||
user: msg`${user} authorised the remote signature`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGNED }, () => ({
|
||||
anonymous: msg`Recipient's remote signature was applied`,
|
||||
you: msg`Your remote signature was applied`,
|
||||
user: msg`${user}'s remote signature was applied`,
|
||||
}))
|
||||
.exhaustive();
|
||||
|
||||
let selectedDescription = description.anonymous;
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { DocumentMeta, Envelope, OrganisationGlobalSettings, Recipient, Team, User } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentSigningOrder, DocumentStatus } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../constants/time-zones';
|
||||
import { resolveSigningOrder } from '../server-only/signature-level/resolve-signing-order';
|
||||
import type { TDocumentLite, TDocumentMany } from '../types/document';
|
||||
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
|
||||
import { SignatureLevel } from '../types/signature-level';
|
||||
import { mapSecondaryIdToDocumentId } from './envelope';
|
||||
import { mapRecipientToLegacyRecipient } from './recipients';
|
||||
|
||||
export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | DocumentStatus) => {
|
||||
const status = typeof document === 'string' ? document : document.status;
|
||||
|
||||
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
|
||||
return (
|
||||
status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED || status === DocumentStatus.CANCELLED
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -23,11 +27,17 @@ export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | Documen
|
||||
*
|
||||
* @param settings - The merged organisation/team settings.
|
||||
* @param overrideMeta - The meta to override the settings with.
|
||||
* @param signatureLevel - The envelope's signature level. Optional; defaults
|
||||
* to `SES` for backward compatibility, which preserves the legacy `PARALLEL`
|
||||
* signing-order default. New callers should pass the resolved level so the
|
||||
* TSP envelopes get the `SEQUENTIAL` default + assertion against explicit
|
||||
* `PARALLEL`.
|
||||
* @returns The derived document meta.
|
||||
*/
|
||||
export const extractDerivedDocumentMeta = (
|
||||
settings: Omit<OrganisationGlobalSettings, 'id'>,
|
||||
overrideMeta: Partial<DocumentMeta> | undefined | null,
|
||||
signatureLevel: string = SignatureLevel.SES,
|
||||
) => {
|
||||
const meta = overrideMeta ?? {};
|
||||
|
||||
@@ -41,7 +51,7 @@ export const extractDerivedDocumentMeta = (
|
||||
subject: meta.subject || null,
|
||||
redirectUrl: meta.redirectUrl || null,
|
||||
|
||||
signingOrder: meta.signingOrder || DocumentSigningOrder.PARALLEL,
|
||||
signingOrder: resolveSigningOrder({ signatureLevel, requested: meta.signingOrder }),
|
||||
allowDictateNextSigner: meta.allowDictateNextSigner ?? false,
|
||||
distributionMethod: meta.distributionMethod || DocumentDistributionMethod.EMAIL, // Todo: Make this a setting.
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { colord } from 'colord';
|
||||
|
||||
import { DEFAULT_BRAND_COLORS } from '../constants/theme';
|
||||
import type { TCssVarsSchema } from '../types/css-vars';
|
||||
|
||||
/**
|
||||
* The `brandingColors` tokens that emails actually render — the canonical
|
||||
* subset of `TCssVarsSchema`. `TCssVarsSchema` is the single source of truth
|
||||
* for token names; this tuple just selects the ones email templates use, and
|
||||
* both the `EmailBrandingColors` type and the resolver below derive from it.
|
||||
*/
|
||||
export const EMAIL_BRANDING_COLOR_KEYS = [
|
||||
'background',
|
||||
'foreground',
|
||||
'muted',
|
||||
'mutedForeground',
|
||||
'primary',
|
||||
'primaryForeground',
|
||||
'secondary',
|
||||
'secondaryForeground',
|
||||
'accent',
|
||||
'accentForeground',
|
||||
'destructive',
|
||||
'destructiveForeground',
|
||||
'warning',
|
||||
'border',
|
||||
] as const satisfies readonly (keyof TCssVarsSchema)[];
|
||||
|
||||
export type EmailBrandingColorKey = (typeof EMAIL_BRANDING_COLOR_KEYS)[number];
|
||||
|
||||
/**
|
||||
* Resolved, email-ready brand colour set.
|
||||
*
|
||||
* Emails cannot use CSS variables, so every value here is a concrete hex
|
||||
* string. This is the shape carried through the email branding context and
|
||||
* injected into the per-render Tailwind config.
|
||||
*
|
||||
* Derived from `TCssVarsSchema` (the persisted shape) by narrowing to the
|
||||
* email token subset and making every field required: the resolver fills every
|
||||
* token (tenant value or Documenso default), so consumers never see `undefined`.
|
||||
*
|
||||
* Produced by `resolveEmailBrandingColors`, or `null` when the tenant has no
|
||||
* usable/safe colour set (callers fall back to the default Documenso palette).
|
||||
*/
|
||||
export type EmailBrandingColors = Required<Pick<TCssVarsSchema, EmailBrandingColorKey>>;
|
||||
|
||||
/**
|
||||
* Normalise an arbitrary stored colour value (hex or any colord-parseable
|
||||
* string) to a hex string. Returns `null` for missing/invalid input.
|
||||
*
|
||||
* `brandingColors` is validated loosely (`z.string()`) so values are not
|
||||
* guaranteed to be valid colours — parse defensively.
|
||||
*/
|
||||
export const normalizeColorToHex = (value: string | null | undefined): string | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = colord(value);
|
||||
|
||||
if (!parsed.isValid()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.toHex();
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a tenant's stored `brandingColors` into an email-ready colour set.
|
||||
*
|
||||
* Each token is taken from the tenant value when it parses to a valid colour,
|
||||
* otherwise the Documenso default. We do NOT enforce contrast or readability —
|
||||
* if a tenant picks a low-contrast combination that is their choice; the
|
||||
* preview UI can hint at it, but the renderer just applies what was set.
|
||||
*
|
||||
* Returns `null` (⇒ caller uses the default Documenso palette) only when there
|
||||
* is no `brandingColors` object at all.
|
||||
*/
|
||||
export const resolveEmailBrandingColors = (
|
||||
brandingColors: TCssVarsSchema | null | undefined,
|
||||
): EmailBrandingColors | null => {
|
||||
if (!brandingColors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolve = (value: string | null | undefined, fallback: string): string =>
|
||||
normalizeColorToHex(value) ?? fallback;
|
||||
|
||||
return {
|
||||
background: resolve(brandingColors.background, DEFAULT_BRAND_COLORS.background),
|
||||
foreground: resolve(brandingColors.foreground, DEFAULT_BRAND_COLORS.foreground),
|
||||
muted: resolve(brandingColors.muted, DEFAULT_BRAND_COLORS.muted),
|
||||
mutedForeground: resolve(brandingColors.mutedForeground, DEFAULT_BRAND_COLORS.mutedForeground),
|
||||
primary: resolve(brandingColors.primary, DEFAULT_BRAND_COLORS.primary),
|
||||
primaryForeground: resolve(brandingColors.primaryForeground, DEFAULT_BRAND_COLORS.primaryForeground),
|
||||
secondary: resolve(brandingColors.secondary, DEFAULT_BRAND_COLORS.secondary),
|
||||
secondaryForeground: resolve(brandingColors.secondaryForeground, DEFAULT_BRAND_COLORS.secondaryForeground),
|
||||
accent: resolve(brandingColors.accent, DEFAULT_BRAND_COLORS.accent),
|
||||
accentForeground: resolve(brandingColors.accentForeground, DEFAULT_BRAND_COLORS.accentForeground),
|
||||
destructive: resolve(brandingColors.destructive, DEFAULT_BRAND_COLORS.destructive),
|
||||
destructiveForeground: resolve(brandingColors.destructiveForeground, DEFAULT_BRAND_COLORS.destructiveForeground),
|
||||
warning: resolve(brandingColors.warning, DEFAULT_BRAND_COLORS.warning),
|
||||
border: resolve(brandingColors.border, DEFAULT_BRAND_COLORS.border),
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
/// <reference types="@documenso/tsconfig/process-env.d.ts" />
|
||||
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__ENV__?: Record<string, string | undefined>;
|
||||
@@ -20,6 +22,30 @@ export const env = <K extends EnvKey>(variable: K): EnvValue<K> => {
|
||||
return (typeof process !== 'undefined' ? process?.env?.[variable] : undefined) as EnvValue<K>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read an env var and assert it is set and non-empty. Throws `error` when
|
||||
* provided, otherwise an `AppError(MISSING_ENV_VAR)` naming the missing
|
||||
* variable.
|
||||
*
|
||||
* Empty-string is treated as unset — a shell-supplied `FOO=` is functionally
|
||||
* equivalent to omission.
|
||||
*/
|
||||
export const requireEnv = <K extends EnvKey>(variable: K, error?: Error): NonNullable<EnvValue<K>> => {
|
||||
const value = env(variable);
|
||||
|
||||
if (!value) {
|
||||
throw (
|
||||
error ??
|
||||
new AppError(AppErrorCode.MISSING_ENV_VAR, {
|
||||
message: `Required environment variable "${String(variable)}" is unset.`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return value as NonNullable<EnvValue<K>>;
|
||||
};
|
||||
|
||||
export const createPublicEnv = () => ({
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_PUBLIC_'))),
|
||||
// Derived from the private URL so the public flag cannot drift from the
|
||||
@@ -27,4 +53,7 @@ export const createPublicEnv = () => ({
|
||||
// env var with the same name.
|
||||
// The `? 'true' : 'false'` might seem dumb but it's because we're expecting env var strings.
|
||||
NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED: process.env.NEXT_PRIVATE_DOCUMENT_CONVERSION_URL ? 'true' : 'false',
|
||||
// Derived from the private transport so the client can detect CSC mode for
|
||||
// authoring UI gating without exposing the raw transport value.
|
||||
NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC: process.env.NEXT_PRIVATE_SIGNING_TRANSPORT === 'csc' ? 'true' : 'false',
|
||||
});
|
||||
|
||||
@@ -242,12 +242,13 @@ export const getEnvelopeItemPermissions = (
|
||||
envelope: Pick<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>,
|
||||
recipients: Pick<Recipient, 'role' | 'signingStatus' | 'sendStatus'>[],
|
||||
): EnvelopeItemPermissions => {
|
||||
// Always reject completed/rejected/deleted envelopes.
|
||||
// Always reject completed/rejected/cancelled/deleted envelopes.
|
||||
if (
|
||||
envelope.completedAt ||
|
||||
envelope.deletedAt ||
|
||||
envelope.status === DocumentStatus.REJECTED ||
|
||||
envelope.status === DocumentStatus.COMPLETED
|
||||
envelope.status === DocumentStatus.COMPLETED ||
|
||||
envelope.status === DocumentStatus.CANCELLED
|
||||
) {
|
||||
return {
|
||||
canTitleBeChanged: false,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Utilities for detecting overlapping fields in the envelope editor.
|
||||
*
|
||||
* Fields can be unintentionally placed on top of each other during the authoring
|
||||
* process. This does not render well in the editor and behaves unpredictably during
|
||||
* signing (fields can sit on top of one another depending on their state), so we warn
|
||||
* the user when a significant overlap is detected.
|
||||
*
|
||||
* All positional values are expected as percentages (0-100) of the page dimensions,
|
||||
* matching how fields are stored in the editor and database.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The minimum proportion (0-1) of the smaller field's area that must be covered by
|
||||
* another field for the pair to be considered an "overlap" worth warning about.
|
||||
*
|
||||
* A small amount of overlap (e.g. touching edges) is common and harmless, so we only
|
||||
* flag pairs where one field covers at least this fraction of the other.
|
||||
*/
|
||||
export const FIELD_OVERLAP_THRESHOLD = 0.4;
|
||||
|
||||
type OverlapFieldInput = {
|
||||
/**
|
||||
* A stable identifier used to reference the field in the returned pairs.
|
||||
* Use the client-side `formId` in the editor, or the database `id` elsewhere.
|
||||
*/
|
||||
id: string | number;
|
||||
envelopeItemId: string;
|
||||
page: number;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type TFieldOverlapPair<T extends OverlapFieldInput> = {
|
||||
fieldA: T;
|
||||
fieldB: T;
|
||||
/**
|
||||
* The proportion (0-1) of the smaller field's area covered by the intersection.
|
||||
*/
|
||||
overlapRatio: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the area of the intersection between two fields, in squared percentage units.
|
||||
*
|
||||
* Returns 0 when the fields do not intersect.
|
||||
*/
|
||||
const getIntersectionArea = (fieldA: OverlapFieldInput, fieldB: OverlapFieldInput): number => {
|
||||
const overlapX = Math.max(
|
||||
0,
|
||||
Math.min(fieldA.positionX + fieldA.width, fieldB.positionX + fieldB.width) -
|
||||
Math.max(fieldA.positionX, fieldB.positionX),
|
||||
);
|
||||
|
||||
const overlapY = Math.max(
|
||||
0,
|
||||
Math.min(fieldA.positionY + fieldA.height, fieldB.positionY + fieldB.height) -
|
||||
Math.max(fieldA.positionY, fieldB.positionY),
|
||||
);
|
||||
|
||||
return overlapX * overlapY;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects pairs of fields that overlap by at least the given threshold.
|
||||
*
|
||||
* Two fields are only compared when they share the same envelope item and page.
|
||||
* The overlap ratio is measured against the smaller of the two fields, so a small
|
||||
* field that is mostly covered by a large field is still flagged.
|
||||
*
|
||||
* @param fields The fields to check. Positional values must be percentages (0-100).
|
||||
* @param threshold The minimum overlap ratio (0-1) to flag. Defaults to {@link FIELD_OVERLAP_THRESHOLD}.
|
||||
*/
|
||||
export const getOverlappingFieldPairs = <T extends OverlapFieldInput>(
|
||||
fields: T[],
|
||||
threshold: number = FIELD_OVERLAP_THRESHOLD,
|
||||
): TFieldOverlapPair<T>[] => {
|
||||
const pairs: TFieldOverlapPair<T>[] = [];
|
||||
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
for (let j = i + 1; j < fields.length; j++) {
|
||||
const fieldA = fields[i];
|
||||
const fieldB = fields[j];
|
||||
|
||||
if (fieldA.envelopeItemId !== fieldB.envelopeItemId || fieldA.page !== fieldB.page) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldAArea = fieldA.width * fieldA.height;
|
||||
const fieldBArea = fieldB.width * fieldB.height;
|
||||
|
||||
if (fieldAArea <= 0 || fieldBArea <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const intersectionArea = getIntersectionArea(fieldA, fieldB);
|
||||
|
||||
if (intersectionArea <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlapRatio = intersectionArea / Math.min(fieldAArea, fieldBArea);
|
||||
|
||||
if (overlapRatio >= threshold) {
|
||||
pairs.push({ fieldA, fieldB, overlapRatio });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pairs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if any pair of fields overlaps by at least the given threshold.
|
||||
*/
|
||||
export const hasOverlappingFields = <T extends OverlapFieldInput>(
|
||||
fields: T[],
|
||||
threshold: number = FIELD_OVERLAP_THRESHOLD,
|
||||
): boolean => {
|
||||
return getOverlappingFieldPairs(fields, threshold).length > 0;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isHttpUrl, toSafeHref } from './is-http-url';
|
||||
|
||||
describe('isHttpUrl', () => {
|
||||
it('accepts http and https URLs', () => {
|
||||
expect(isHttpUrl('http://example.com')).toBe(true);
|
||||
expect(isHttpUrl('https://example.com/path?q=1#hash')).toBe(true);
|
||||
expect(isHttpUrl('HTTPS://EXAMPLE.COM')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-http(s) schemes', () => {
|
||||
expect(isHttpUrl('javascript:alert(1)')).toBe(false);
|
||||
expect(isHttpUrl('JavaScript:alert(1)')).toBe(false);
|
||||
expect(isHttpUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
|
||||
expect(isHttpUrl('vbscript:msgbox(1)')).toBe(false);
|
||||
expect(isHttpUrl('file:///etc/passwd')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-absolute or unparseable values', () => {
|
||||
expect(isHttpUrl('not a url')).toBe(false);
|
||||
expect(isHttpUrl('/relative/path')).toBe(false);
|
||||
expect(isHttpUrl('')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat leading whitespace tricks as safe', () => {
|
||||
// `new URL` trims leading control chars; ensure a smuggled scheme is rejected.
|
||||
expect(isHttpUrl(' javascript:alert(1)')).toBe(false);
|
||||
expect(isHttpUrl('java\tscript:alert(1)')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSafeHref', () => {
|
||||
it('returns the URL when it is http(s)', () => {
|
||||
expect(toSafeHref('https://example.com')).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('returns undefined for dangerous or empty values', () => {
|
||||
expect(toSafeHref('javascript:alert(1)')).toBeUndefined();
|
||||
expect(toSafeHref('data:text/html,x')).toBeUndefined();
|
||||
expect(toSafeHref('')).toBeUndefined();
|
||||
expect(toSafeHref(null)).toBeUndefined();
|
||||
expect(toSafeHref(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
const ALLOWED_PROTOCOLS = ['http', 'https'];
|
||||
|
||||
/**
|
||||
* Returns true only when `value` parses as an absolute URL using the http or
|
||||
* https protocol.
|
||||
*
|
||||
* Zod's `.url()` accepts any parseable URL, including non-web schemes. Use this
|
||||
* to restrict user-supplied URLs to http(s) before they are stored or rendered
|
||||
* as a link.
|
||||
*/
|
||||
export const isHttpUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
|
||||
return ALLOWED_PROTOCOLS.includes(url.protocol.slice(0, -1).toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the value to use for a link `href` only when it is an http(s) URL,
|
||||
* otherwise `undefined`. Use this when rendering user-supplied URLs as anchors,
|
||||
* including for older rows stored before URL validation was in place.
|
||||
*/
|
||||
export const toSafeHref = (value: string | null | undefined): string | undefined => {
|
||||
if (!value || !isHttpUrl(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
@@ -1,4 +1,7 @@
|
||||
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT } from '@documenso/ee/server-only/limits/constants';
|
||||
import {
|
||||
DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||
DEFAULT_RECIPIENT_COUNT,
|
||||
} from '@documenso/ee/server-only/limits/constants';
|
||||
import type { SubscriptionClaim } from '@prisma/client';
|
||||
|
||||
export const generateDefaultSubscriptionClaim = (): Omit<
|
||||
@@ -10,7 +13,16 @@ export const generateDefaultSubscriptionClaim = (): Omit<
|
||||
teamCount: 1,
|
||||
memberCount: 1,
|
||||
envelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||
recipientCount: DEFAULT_RECIPIENT_COUNT,
|
||||
locked: false,
|
||||
flags: {},
|
||||
|
||||
documentRateLimits: [],
|
||||
documentQuota: null,
|
||||
emailRateLimits: [],
|
||||
emailQuota: null,
|
||||
apiRateLimits: [],
|
||||
apiQuota: null,
|
||||
emailTransportId: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { OrganisationGlobalSettings } from '@prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
import { ZCssVarsSchema } from '../types/css-vars';
|
||||
import { resolveEmailBrandingColors } from './email-branding-colors';
|
||||
|
||||
export const teamGlobalSettingsToBranding = (
|
||||
settings: Omit<OrganisationGlobalSettings, 'id'>,
|
||||
teamId: number,
|
||||
hidePoweredBy: boolean,
|
||||
) => {
|
||||
const parsedColors = settings.brandingColors ? ZCssVarsSchema.safeParse(settings.brandingColors) : null;
|
||||
const resolvedBrandingColors = resolveEmailBrandingColors(parsedColors?.success ? parsedColors.data : null);
|
||||
|
||||
return {
|
||||
...settings,
|
||||
brandingLogo:
|
||||
@@ -14,6 +19,7 @@ export const teamGlobalSettingsToBranding = (
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${teamId}`
|
||||
: '',
|
||||
brandingHidePoweredBy: hidePoweredBy,
|
||||
brandingColors: resolvedBrandingColors ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,6 +28,9 @@ export const organisationGlobalSettingsToBranding = (
|
||||
organisationId: string,
|
||||
hidePoweredBy: boolean,
|
||||
) => {
|
||||
const parsedColors = settings.brandingColors ? ZCssVarsSchema.safeParse(settings.brandingColors) : null;
|
||||
const resolvedBrandingColors = resolveEmailBrandingColors(parsedColors?.success ? parsedColors.data : null);
|
||||
|
||||
return {
|
||||
...settings,
|
||||
brandingLogo:
|
||||
@@ -29,5 +38,6 @@ export const organisationGlobalSettingsToBranding = (
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisationId}`
|
||||
: '',
|
||||
brandingHidePoweredBy: hidePoweredBy,
|
||||
brandingColors: resolvedBrandingColors ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TeamGroup, 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';
|
||||
@@ -235,3 +235,11 @@ export const extractDerivedTeamSettings = (
|
||||
|
||||
return derivedSettings;
|
||||
};
|
||||
|
||||
export const isMemberManagerOrAbove = (role: TeamMemberRole) => {
|
||||
return role === TeamMemberRole.ADMIN || role === TeamMemberRole.MANAGER;
|
||||
};
|
||||
|
||||
export const isMemberAdmin = (role: TeamMemberRole) => {
|
||||
return role === TeamMemberRole.ADMIN;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user