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:
ephraimduncan
2026-07-02 06:48:23 +00:00
584 changed files with 38071 additions and 6339 deletions
+35 -12
View File
@@ -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;
};
+58 -5
View File
@@ -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;
+13 -3
View File
@@ -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.
+105
View File
@@ -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),
};
};
+29
View File
@@ -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',
});
+3 -2
View File
@@ -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,
+123
View File
@@ -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;
};
+45
View File
@@ -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();
});
});
+32
View File
@@ -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;
};
+13 -1
View File
@@ -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,
};
};
+9 -1
View File
@@ -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;
};