Merge branch 'main' into duncan/signing-reason-env

This commit is contained in:
Ephraim Duncan
2026-08-19 10:01:47 +00:00
committed by GitHub
478 changed files with 31812 additions and 14567 deletions
+17
View File
@@ -0,0 +1,17 @@
/**
* Read a cookie value from `document.cookie`.
*
* Client-side counterpart of `extractCookieFromHeaders`. Only works for cookies that
* are not `HttpOnly`, such as the preferred team URL cookie.
*/
export const extractCookieFromDocument = (cookieName: string): string | null => {
const cookiePairs = document.cookie.split(';');
const cookie = cookiePairs.find((pair) => pair.trim().startsWith(`${cookieName}=`));
if (!cookie) {
return null;
}
return cookie.split('=')[1].trim();
};
@@ -0,0 +1,178 @@
import { Zip, ZipPassThrough } from 'fflate';
export type ZipFileEntry = {
/**
* The path of the file within the archive. Forward slashes create folders.
* Individual path segments should be sanitized with
* {@link sanitizeZipPathSegment} when derived from user-controlled values.
*/
filename: string;
data: Blob;
};
/**
* Sanitizes a single path segment (folder or file name) for use inside a zip
* archive, replacing characters that are path separators or invalid on
* Windows extraction.
*/
export const sanitizeZipPathSegment = (segment: string): string => {
const sanitized = segment
.replace(/[\\/:*?"<>|\p{Cc}]/gu, '-')
.trim()
// Windows cannot extract folders or files ending with a dot.
.replace(/\.+$/, '');
return sanitized || 'untitled';
};
export type ZipWriter = {
/**
* Adds a file to the zip stream. Files are written incrementally so the
* input blob can be garbage collected once this resolves.
*/
addFile: (entry: ZipFileEntry) => Promise<void>;
/**
* Finishes the zip stream and returns the archive as a blob.
*/
finalize: () => Blob;
/**
* Discards the zip stream and any buffered output.
*/
abort: () => void;
};
/**
* How many bytes of a blob to materialise into the JS heap per read. Blobs
* (e.g. fetch responses) can be disk-backed by the browser, it is only
* `arrayBuffer()` that forces them into memory, so we read in slices.
*/
const READ_SLICE_BYTES = 4 * 1024 * 1024;
/**
* Once this many bytes of zip output have accumulated in the JS heap they are
* coalesced into an intermediate blob. Browsers can page blob storage to disk
* under memory pressure, and the final `new Blob(parts)` composes parts by
* reference, so this keeps the heap bounded regardless of archive size.
*/
const OUTPUT_COALESCE_BYTES = 16 * 1024 * 1024;
/**
* Creates an incremental client-side zip writer.
*
* Files are stored without compression (PDFs are already internally
* compressed) and streamed through the archive as they are added, so peak JS
* heap usage is bounded by roughly one read slice plus one output buffer
* rather than the total size of the archive.
*/
export const createZipWriter = (): ZipWriter => {
const usedNames = new Set<string>();
const outputParts: Blob[] = [];
let pendingChunks: Uint8Array[] = [];
let pendingSize = 0;
let zipError: Error | null = null;
const flushPendingChunks = () => {
if (pendingChunks.length === 0) {
return;
}
outputParts.push(new Blob(pendingChunks));
pendingChunks = [];
pendingSize = 0;
};
// ZipPassThrough is synchronous (no workers), so output callbacks have
// always fired by the time `push`/`end` return.
const zipStream = new Zip((error, chunk, isFinal) => {
if (error) {
zipError = error;
return;
}
pendingChunks.push(chunk);
pendingSize += chunk.length;
if (pendingSize >= OUTPUT_COALESCE_BYTES || isFinal) {
flushPendingChunks();
}
});
/**
* Deduplicates filenames case-insensitively (Windows extraction is
* case-insensitive) by appending " (n)" before the extension.
*/
const deduplicateFilename = (filename: string) => {
const match = filename.match(/^(.*?)(\.[^./]+)?$/);
const baseName = match?.[1] ?? filename;
const extension = match?.[2] ?? '';
let candidate = filename;
let counter = 1;
while (usedNames.has(candidate.toLowerCase())) {
candidate = `${baseName} (${counter})${extension}`;
counter += 1;
}
usedNames.add(candidate.toLowerCase());
return candidate;
};
const addFile = async ({ filename, data }: ZipFileEntry) => {
if (zipError) {
throw zipError;
}
const file = new ZipPassThrough(deduplicateFilename(filename));
zipStream.add(file);
for (let offset = 0; offset < data.size; offset += READ_SLICE_BYTES) {
const slice = data.slice(offset, offset + READ_SLICE_BYTES);
file.push(new Uint8Array(await slice.arrayBuffer()));
if (zipError) {
throw zipError;
}
}
file.push(new Uint8Array(0), true);
if (zipError) {
throw zipError;
}
};
const finalize = () => {
zipStream.end();
if (zipError) {
throw zipError;
}
flushPendingChunks();
return new Blob(outputParts, { type: 'application/zip' });
};
const abort = () => {
zipStream.terminate();
pendingChunks = [];
pendingSize = 0;
outputParts.length = 0;
};
return {
addFile,
finalize,
abort,
};
};
+22 -3
View File
@@ -32,7 +32,11 @@ const versionToFilenameSuffix = (version: DocumentVersion): string => {
}
};
export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
/**
* Fetches a PDF for an envelope item and returns it as a blob alongside the
* filename it should be saved as. Throws on non-OK responses.
*/
export const fetchPDF = async ({ envelopeItem, token, fileName, version = 'signed' }: DownloadPDFProps) => {
const downloadUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: envelopeItem,
@@ -40,12 +44,27 @@ export const downloadPDF = async ({ envelopeItem, token, fileName, version = 'si
version,
});
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`Failed to download PDF: ${response.status}`);
}
const blob = await response.blob();
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
downloadFile({
return {
filename: `${baseTitle}${versionToFilenameSuffix(version)}`,
blob,
};
};
export const downloadPDF = async (options: DownloadPDFProps) => {
const { filename, blob } = await fetchPDF(options);
downloadFile({
filename,
data: blob,
});
};
@@ -8,7 +8,7 @@ type SaveRequest<T, R> = {
export const useAutoSave = <T, R = void>(onSave: (data: T) => Promise<R>, options: { delay?: number } = {}) => {
const { delay = 2000 } = options;
const saveTimeoutRef = useRef<NodeJS.Timeout>();
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const saveQueueRef = useRef<SaveRequest<T, R>[]>([]);
const isProcessingRef = useRef(false);
@@ -0,0 +1,55 @@
import { useMatches } from 'react-router';
/**
* The layout treatment a route wants from its parent layout(s).
*
* - `'settings'` — the full-height unified settings layout: no centered page
* container, a full-width app header, and a viewport-height flex column so the
* settings shell can fill the available space and scroll internally.
* - `null` — the default layout (centered `<PageContainer />`, normal flow).
*/
export type LayoutMode = 'settings' | null;
/**
* Typed route `handle` export. Controls layout rendering.
*
* - `hideAppHeader` — tells the parent layout to skip rendering `<AppHeader />`.
* - `layoutMode` — selects the layout treatment the parent layout(s) apply. See
* {@link LayoutMode}.
*/
export type RouteHandle = {
hideAppHeader?: boolean;
layoutMode?: LayoutMode;
};
/**
* Returns layout flags from the deepest matching route that sets any.
* Layouts call this to decide whether to render certain elements.
*/
export function useChildRouteFlags(): { hideAppHeader: boolean; layoutMode: LayoutMode } {
const matches = useMatches();
let hideAppHeader = false;
let layoutMode: LayoutMode = null;
// Walk from deepest match backward so the leaf route wins per flag.
for (let i = matches.length - 1; i >= 0; i--) {
const handle = matches[i].handle;
if (handle == null || typeof handle !== 'object') {
continue;
}
const h = handle as RouteHandle;
if (layoutMode === null && h.layoutMode) {
layoutMode = h.layoutMode;
}
if (!hideAppHeader && h.hideAppHeader) {
hideAppHeader = true;
}
}
return { hideAppHeader, layoutMode };
}
@@ -7,9 +7,10 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { useId } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
import { z } from 'zod';
import { isCcRecipient, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from '../../utils/recipients';
const LocalRecipientSchema = z.object({
formId: z.string().min(1),
id: z.number().optional(),
@@ -94,13 +95,13 @@ export const useEditorRecipients = ({ envelope }: EditorRecipientsProps): UseEdi
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder ?? index + 1,
signingOrder: isCcRecipient(recipient) ? undefined : (recipient.signingOrder ?? index + 1),
actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
}));
const signers: TLocalRecipient[] =
formRecipients.length > 0
? sortBy(formRecipients, [prop('signingOrder'), 'asc'], [prop('id'), 'asc'])
? normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(formRecipients))
: [
{
formId: initialId,
@@ -1,84 +1,100 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Debounced autosave for the envelope editor (recipients, fields, settings).
*
* Only one save runs at a time and the latest edit always wins. If the user
* keeps editing while a save is on the wire, their newest changes get saved
* right after, never dropped.
*/
export function useEnvelopeAutosave<T>(saveFn: (data: T) => Promise<void>, delay = 1000) {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastArgsRef = useRef<T | null>(null);
const pendingPromiseRef = useRef<Promise<void> | null>(null);
// The edit waiting to be saved. Wrapped in an object so null always means "nothing queued".
const pendingRef = useRef<{ value: T } | null>(null);
// The save currently running, if any. Shared so we never kick off two at once.
const commitPromiseRef = useRef<Promise<void> | null>(null);
// saveFn closes over editor state, so keep the latest one around without
// making triggerSave/flush depend on it.
const saveFnRef = useRef(saveFn);
saveFnRef.current = saveFn;
const [isPending, setIsPending] = useState(false);
const [isCommiting, setIsCommiting] = useState(false);
/**
* Runs saves one at a time until the queue is empty. Anything queued
* mid-save gets picked up on the next loop.
*/
const commit = useCallback((): Promise<void> => {
if (commitPromiseRef.current) {
return commitPromiseRef.current;
}
if (!pendingRef.current) {
return Promise.resolve();
}
const pump = (async () => {
try {
setIsCommiting(true);
while (pendingRef.current) {
const { value } = pendingRef.current;
pendingRef.current = null;
await saveFnRef.current(value);
}
} finally {
// eslint-disable-next-line require-atomic-updates
commitPromiseRef.current = null;
setIsCommiting(false);
setIsPending(false);
}
})();
commitPromiseRef.current = pump;
return pump;
}, []);
const triggerSave = useCallback(
(data: T) => {
lastArgsRef.current = data;
pendingRef.current = { value: data };
// A debounce or promise means something is pending
setIsPending(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
timeoutRef.current = setTimeout(async () => {
if (!lastArgsRef.current) {
return;
}
const args = lastArgsRef.current;
lastArgsRef.current = null;
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
setIsCommiting(true);
pendingPromiseRef.current = saveFn(args);
try {
await pendingPromiseRef.current;
} finally {
// eslint-disable-next-line require-atomic-updates
pendingPromiseRef.current = null;
setIsCommiting(false);
setIsPending(false);
}
void commit();
}, delay);
},
[saveFn, delay],
[commit, delay],
);
/**
* Skip the debounce and save now. The editor calls this when it needs
* everything persisted, e.g. before sending or switching steps.
*/
const flush = useCallback(async () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (pendingPromiseRef.current) {
// Already running → wait for it
await pendingPromiseRef.current;
return;
}
if (lastArgsRef.current) {
const args = lastArgsRef.current;
lastArgsRef.current = null;
setIsCommiting(true);
setIsPending(true);
pendingPromiseRef.current = saveFn(args);
try {
await pendingPromiseRef.current;
} finally {
// eslint-disable-next-line require-atomic-updates
pendingPromiseRef.current = null;
setIsCommiting(false);
setIsPending(false);
}
}
}, [saveFn]);
await commit();
}, [commit]);
// Last-ditch attempt to save if the tab closes with unsaved edits.
useEffect(() => {
const handleBeforeUnload = () => {
if (timeoutRef.current || pendingPromiseRef.current) {
if (timeoutRef.current || pendingRef.current || commitPromiseRef.current) {
void flush();
}
};
@@ -15,7 +15,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { EnvelopeType, Prisma, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import type React from 'react';
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { createContext, useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useSearchParams } from 'react-router';
import type { TDocumentEmailSettings } from '../../types/document-email';
@@ -107,7 +107,39 @@ export const EnvelopeEditorProvider = ({
const [_searchParams, setSearchParams] = useSearchParams();
const [envelope, _setEnvelope] = useState(initialEnvelope);
/**
* The envelope is kept in a ref-backed external store instead of useState so
* that async consumers (debounced autosave callbacks, flushAutosave, resetForms)
* can synchronously read the latest value via `getEnvelope`.
*
* React subscribes to the store through useSyncExternalStore, keeping renders in
* sync without maintaining a separate copy of the state.
*/
const envelopeStoreRef = useRef(initialEnvelope);
const envelopeStoreSubscribersRef = useRef(new Set<() => void>());
const subscribeToEnvelopeStore = useCallback((onStoreChange: () => void) => {
envelopeStoreSubscribersRef.current.add(onStoreChange);
return () => {
envelopeStoreSubscribersRef.current.delete(onStoreChange);
};
}, []);
const getEnvelope = useCallback(() => envelopeStoreRef.current, []);
const setEnvelope = useCallback((action: React.SetStateAction<TEditorEnvelope>) => {
const next = typeof action === 'function' ? action(envelopeStoreRef.current) : action;
envelopeStoreRef.current = next;
for (const onStoreChange of envelopeStoreSubscribersRef.current) {
onStoreChange();
}
}, []);
const envelope = useSyncExternalStore(subscribeToEnvelopeStore, getEnvelope, getEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false);
const isCscMode = IS_INSTANCE_CSC_MODE();
@@ -135,8 +167,6 @@ export const EnvelopeEditorProvider = ({
};
}, [isCscMode, providedEditorConfig]);
const envelopeRef = useRef(initialEnvelope);
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
const pendingMutationsRef = useRef<Set<Promise<unknown>>>(new Set());
@@ -156,14 +186,6 @@ export const EnvelopeEditorProvider = ({
});
}, []);
const setEnvelope: typeof _setEnvelope = (action) => {
_setEnvelope((prev) => {
const next = typeof action === 'function' ? action(prev) : action;
envelopeRef.current = next;
return next;
});
};
const isEmbedded = editorConfig.embedded !== undefined;
const editorFields = useEditorFields({
@@ -192,16 +214,18 @@ export const EnvelopeEditorProvider = ({
try {
let recipients: TEditorEnvelope['recipients'] = [];
const currentEnvelope = getEnvelope();
if (!isEmbedded) {
const response = await setRecipientsMutation.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
envelopeId: currentEnvelope.id,
envelopeType: currentEnvelope.type,
recipients: localRecipients,
});
recipients = response.data;
} else {
recipients = mapLocalRecipientsToRecipients({ envelope, localRecipients });
recipients = mapLocalRecipientsToRecipients({ envelope: currentEnvelope, localRecipients });
}
setEnvelope((prev) => ({
@@ -211,9 +235,7 @@ export const EnvelopeEditorProvider = ({
}));
// Reset the local fields to ensure deleted recipient fields are removed.
editorFields.resetForm(
envelope.fields.filter((field) => recipients.some((recipient) => recipient.id === field.recipientId)),
);
editorFields.resetForm(getEnvelope().fields);
setAutosaveError(false);
} catch (err) {
@@ -248,16 +270,18 @@ export const EnvelopeEditorProvider = ({
try {
let fields: TSetEnvelopeFieldsResponse['data'] = [];
const currentEnvelope = getEnvelope();
if (!isEmbedded) {
const response = await setFieldsMutation.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
envelopeId: currentEnvelope.id,
envelopeType: currentEnvelope.type,
fields: localFields,
});
fields = response.data;
} else {
fields = mapLocalFieldsToFields({ envelope, localFields });
fields = mapLocalFieldsToFields({ envelope: currentEnvelope, localFields });
}
setEnvelope((prev) => ({
@@ -309,7 +333,7 @@ export const EnvelopeEditorProvider = ({
try {
const response = !isEmbedded
? await updateEnvelopeMutation.mutateAsync({
envelopeId: envelope.id,
envelopeId: getEnvelope().id,
data,
meta,
})
@@ -467,12 +491,14 @@ export const EnvelopeEditorProvider = ({
};
const resetForms = () => {
const currentEnvelope = getEnvelope();
editorRecipients.resetForm({
recipients: envelopeRef.current.recipients,
documentMeta: envelopeRef.current.documentMeta,
recipients: currentEnvelope.recipients,
documentMeta: currentEnvelope.documentMeta,
});
editorFields.resetForm(envelopeRef.current.fields);
editorFields.resetForm(currentEnvelope.fields);
};
const flushAutosave = async (): Promise<TEditorEnvelope> => {
@@ -488,7 +514,7 @@ export const EnvelopeEditorProvider = ({
await Promise.allSettled(Array.from(pendingMutationsRef.current));
}
return envelopeRef.current;
return getEnvelope();
};
return (
+60 -1
View File
@@ -6,6 +6,42 @@ export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_DOCUMENT_S
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL') ?? 'http://localhost:3000';
/**
* The sub-path the app is served under (no trailing slash), e.g. "/ESign".
* Returns an empty string when served at root.
*
* Prefers the explicit NEXT_PUBLIC_BASE_PATH (which is the same value baked
* into the Vite/React Router build). Falls back to the pathname of
* NEXT_PUBLIC_WEBAPP_URL so the function still works in dev when the env
* variable is unset.
*
* Avoid using this to build URLs, use {@link formatPath} instead. Reserve this
* for cases where the raw prefix itself is needed, such as path comparisons.
*/
export const getBasePath = (): string => {
const explicit = env('NEXT_PUBLIC_BASE_PATH');
if (explicit) {
return explicit.replace(/\/$/, '');
}
try {
return new URL(NEXT_PUBLIC_WEBAPP_URL()).pathname.replace(/\/$/, '');
} catch {
return '';
}
};
/**
* Prefix a root-relative path with the app's base path.
*
* `formatPath('/api/trpc')` -> `/ESign/api/trpc` under sub-path hosting,
* `/api/trpc` otherwise.
*/
export const formatPath = (path: string): string => {
return `${getBasePath()}${path}`;
};
export const NEXT_PUBLIC_SIGNING_CONTACT_INFO = () =>
env('NEXT_PUBLIC_SIGNING_CONTACT_INFO') ?? NEXT_PUBLIC_WEBAPP_URL();
@@ -17,6 +53,14 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
/**
* Whether this instance is Documenso Cloud (managed SaaS).
*
* Used so we can show a different UI for Documenso Cloud and self-hosted instances since
* there are things like billing, upsells, documenso links, etc that don't make sense for self-hosted instances.
*/
export const IS_DOCUMENSO_CLOUD = () => env('NEXT_PUBLIC_IS_DOCUMENSO_CLOUD') === 'true';
export const API_V2_BETA_URL = '/api/v2-beta';
export const API_V2_URL = '/api/v2';
@@ -24,7 +68,20 @@ export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@docume
export const USE_INTERNAL_URL_BROWSERLESS = () => env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';
export const IS_AI_FEATURES_CONFIGURED = () => !!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY');
/**
* Returns whether AI features are configured for this instance.
*
* Platform-aware:
* - On the server, checks the private Vertex credentials are configured.
* - On the client, reads the derived public flag injected via `window.__ENV__`.
*/
export const IS_AI_FEATURES_CONFIGURED = (): boolean => {
if (typeof window === 'undefined') {
return !!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY');
}
return env('NEXT_PUBLIC_AI_FEATURES_ENABLED') === 'true';
};
/**
* Temporary flag to toggle between Playwright-based and Konva-based PDF generation
@@ -87,3 +144,5 @@ export const CSC_INSTANCE_SIGNATURE_LEVEL = (): TSignatureLevel => {
return value;
};
export const DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL = 'https://documen.so/enterprise-cta';
-15
View File
@@ -1,25 +1,10 @@
import MailChecker from 'mailchecker';
import { z } from 'zod';
import { env } from '../utils/env';
import { NEXT_PUBLIC_WEBAPP_URL } from './app';
export const SALT_ROUNDS = 12;
export const URL_PATTERN = /https?:\/\/|www\./i;
/**
* Shared name schema that disallows URLs to prevent phishing via email rendering.
*/
export const ZNameSchema = z
.string()
.trim()
.min(3, { message: 'Please enter a valid name.' })
.max(255, { message: 'Name cannot be more than 255 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Name cannot contain URLs.',
});
export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
DOCUMENSO: 'Documenso',
GOOGLE: 'Google',
+10
View File
@@ -9,3 +9,13 @@
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
*/
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
/**
* Branding logo upload constraints. Enforced server-side at the TRPC request
* boundary (`zfdBrandingImageFile`) and reused by the client form for matching UX.
*/
export const BRANDING_LOGO_MAX_SIZE_MB = 5;
export const BRANDING_LOGO_MAX_SIZE_BYTES = BRANDING_LOGO_MAX_SIZE_MB * 1024 * 1024;
export const BRANDING_LOGO_ALLOWED_TYPES: string[] = ['image/jpeg', 'image/png', 'image/webp'];
+1
View File
@@ -0,0 +1 @@
export const PREFERRED_TEAM_URL_COOKIE = 'preferred-team-url';
+34
View File
@@ -36,6 +36,34 @@ export enum AppErrorCode {
*/
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
/**
* A completion request was made for a recipient that has already signed.
* Thrown for retried, stale or concurrent duplicate submissions so callers
* can resolve them idempotently instead of surfacing an error.
*/
RECIPIENT_ALREADY_SIGNED = 'RECIPIENT_ALREADY_SIGNED',
/**
* A completion request was made for a recipient that still has required
* fields which have not been inserted. Usually indicates the client's field
* state is out of sync with the server (e.g. a field insert failed to
* persist before submission).
*/
RECIPIENT_HAS_UNSIGNED_FIELDS = 'RECIPIENT_HAS_UNSIGNED_FIELDS',
/**
* A completion request was made by a recipient in a sequential signing flow
* before the preceding recipients have signed.
*/
RECIPIENT_OUT_OF_TURN = 'RECIPIENT_OUT_OF_TURN',
/**
* A signer recipient does not have a signature field assigned. Thrown when
* distributing an envelope or using a direct template where at least one
* signer has no signature field.
*/
MISSING_SIGNATURE_FIELD = 'MISSING_SIGNATURE_FIELD',
/**
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
* for the recovery taxonomy.
@@ -84,6 +112,9 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.RECIPIENT_OUT_OF_TURN]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
@@ -291,6 +322,9 @@ export class AppError extends Error {
AppErrorCode.ENVELOPE_CANCELLED,
AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.MISSING_SIGNATURE_FIELD,
AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS,
AppErrorCode.RECIPIENT_OUT_OF_TURN,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID,
+4
View File
@@ -17,6 +17,7 @@ import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emai
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal/admin-delete-organisation';
import { ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION } from './definitions/internal/alert-organisation-seat-drift';
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
@@ -29,6 +30,7 @@ import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-docume
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
import { SYNC_ORGANISATION_SEATS_JOB_DEFINITION } from './definitions/internal/sync-organisation-seats';
/**
* The `as const` assertion is load bearing as it provides the correct level of type inference for
@@ -64,7 +66,9 @@ export const jobsClient = new JobClient([
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION,
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
SYNC_ORGANISATION_SEATS_JOB_DEFINITION,
] as const);
export const jobs = jobsClient;
@@ -0,0 +1,67 @@
import { mailer } from '@documenso/email/mailer';
import { prisma } from '@documenso/prisma';
import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '../../../constants/app';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
import type { JobRunIO } from '../../client/_internal/job';
import type { TAlertOrganisationSeatDriftJobDefinition } from './alert-organisation-seat-drift';
/**
* Daily check for organisations whose member count exceeds their paid seat
* count (`organisationClaim.memberCount`, where `0` means unlimited).
*/
export const run = async ({ io }: { payload: TAlertOrganisationSeatDriftJobDefinition; io: JobRunIO }) => {
if (!IS_BILLING_ENABLED()) {
return;
}
const organisations = await prisma.organisation.findMany({
where: {
// Exclude unlimited-seat plans (memberCount === 0).
organisationClaim: {
memberCount: {
not: 0,
},
},
},
select: {
id: true,
name: true,
organisationClaim: {
select: {
memberCount: true,
},
},
_count: {
select: {
members: true,
},
},
},
});
const driftedOrganisations = organisations.filter(
(organisation) =>
organisation.organisationClaim !== null &&
organisation._count.members > organisation.organisationClaim.memberCount,
);
if (driftedOrganisations.length === 0) {
io.logger.info('No organisations exceed their paid seat count');
return;
}
await mailer.sendMail({
to: SUPPORT_EMAIL,
from: DOCUMENSO_INTERNAL_EMAIL,
subject: `[Billing] ${driftedOrganisations.length} organisation(s) exceed their paid seat count`,
text: [
`${driftedOrganisations.length} organisation(s) have more members than their paid seat count:`,
'',
...driftedOrganisations.map(
(organisation) =>
`- ${organisation.name} (${organisation.id}): ${organisation._count.members} members vs ${organisation.organisationClaim?.memberCount ?? 0} paid seats`,
),
].join('\n'),
});
};
@@ -0,0 +1,30 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID = 'internal.alert-organisation-seat-drift';
const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA = z.object({});
export type TAlertOrganisationSeatDriftJobDefinition = z.infer<
typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA
>;
export const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION = {
id: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
name: 'Alert Organisation Seat Drift',
version: '1.0.0',
trigger: {
name: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
schema: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA,
cron: '0 0 * * *', // Once a day at midnight.
},
handler: async ({ payload, io }) => {
const handler = await import('./alert-organisation-seat-drift.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
TAlertOrganisationSeatDriftJobDefinition
>;
@@ -0,0 +1,54 @@
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@prisma/client';
import { IS_BILLING_ENABLED } from '../../../constants/app';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSyncOrganisationSeatsJobDefinition } from './sync-organisation-seats';
export const run = async ({ payload }: { payload: TSyncOrganisationSeatsJobDefinition; io: JobRunIO }) => {
const { organisationId } = payload;
if (!IS_BILLING_ENABLED()) {
return;
}
const organisation = await prisma.organisation.findUnique({
where: {
id: organisationId,
},
include: {
subscription: true,
organisationClaim: true,
},
});
if (!organisation || !organisation.subscription) {
return;
}
// Skip canceled/terminal subscriptions — Stripe rejects quantity updates on a
// canceled subscription. PAST_DUE is still live and a no-proration shrink is
// safe, so it's allowed through.
if (organisation.subscription.status === SubscriptionStatus.INACTIVE) {
return;
}
const memberCount = await prisma.organisationMember.count({
where: {
organisationId,
},
});
// An organisation always retains its owner; guarding zero avoids writing the
// unlimited sentinel to the claim.
if (memberCount === 0) {
return;
}
await syncMemberCountWithStripeSeatPlan(
organisation.subscription,
organisation.organisationClaim,
memberCount,
'shrink',
);
};
@@ -0,0 +1,29 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID = 'internal.sync-organisation-seats';
const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA = z.object({
organisationId: z.string(),
});
export type TSyncOrganisationSeatsJobDefinition = z.infer<typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA>;
export const SYNC_ORGANISATION_SEATS_JOB_DEFINITION = {
id: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
name: 'Sync Organisation Seats',
version: '1.0.0',
trigger: {
name: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
schema: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./sync-organisation-seats.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
TSyncOrganisationSeatsJobDefinition
>;
+7 -7
View File
@@ -15,7 +15,7 @@
"clean": "rimraf node_modules"
},
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@ai-sdk/google-vertex": "5.0.48",
"@aws-sdk/client-s3": "^3.998.0",
"@aws-sdk/client-sesv2": "^3.998.0",
"@aws-sdk/cloudfront-signer": "^3.998.0",
@@ -29,6 +29,7 @@
"@documenso/email": "*",
"@documenso/prisma": "*",
"@documenso/signing": "*",
"@documenso/skia-canvas": "^3.0.8-documenso.3",
"@lingui/core": "^5.6.0",
"@lingui/macro": "^5.6.0",
"@lingui/react": "^5.6.0",
@@ -42,7 +43,7 @@
"@sindresorhus/slugify": "^3.0.0",
"@team-plain/typescript-sdk": "^5.11.0",
"@vvo/tzdb": "^6.196.0",
"ai": "^5.0.104",
"ai": "^7.0.58",
"bullmq": "^5.71.1",
"colord": "^2.9.3",
"csv-parse": "^6.1.0",
@@ -60,14 +61,13 @@
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
"playwright": "1.56.1",
"postcss": "^8.5.14",
"postcss-selector-parser": "^7.1.1",
"postcss": "^8.5.19",
"postcss-selector-parser": "^7.1.4",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
"react": "^19.2.7",
"remeda": "^2.32.0",
"sharp": "0.34.5",
"skia-canvas": "^3.0.8",
"sharp": "0.35.3",
"stripe": "^12.18.0",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
@@ -0,0 +1,372 @@
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
export const ADMIN_SEARCH_RESULTS_PER_TYPE = 5;
const MAX_POSTGRES_INT = 2147483647;
const GROUP_ORDER = ['document', 'user', 'organisation', 'team', 'recipient', 'subscription'] as const;
export type AdminGlobalSearchResultType = (typeof GROUP_ORDER)[number];
export type AdminGlobalSearchResult = {
label: string;
sublabel?: string;
path: string;
value: string;
};
export type AdminGlobalSearchGroup = {
type: AdminGlobalSearchResultType;
results: AdminGlobalSearchResult[];
};
export type AdminGlobalSearchOptions = {
query: string;
};
type PartialResults = Partial<Record<AdminGlobalSearchResultType, AdminGlobalSearchResult[]>>;
export const adminGlobalSearch = async ({ query }: AdminGlobalSearchOptions): Promise<AdminGlobalSearchGroup[]> => {
const trimmedQuery = query.trim();
if (trimmedQuery.length === 0) {
return [];
}
const resultsByType = await resolveSearch(trimmedQuery);
return GROUP_ORDER.map((type) => ({
type,
results: (resultsByType[type] ?? []).map((result) => ({
...result,
// Append the raw query so cmdk's client-side filter never hides
// server-verified results.
value: `${result.value} ${trimmedQuery}`,
})),
})).filter((group) => group.results.length > 0);
};
const resolveSearch = async (query: string): Promise<PartialResults> => {
// Recognized ID prefixes resolve to a single exact lookup.
if (query.startsWith('envelope_')) {
return { document: await findDocumentsByExactId({ id: query }) };
}
if (query.startsWith('document_')) {
return { document: await findDocumentsByExactId({ secondaryId: query }) };
}
if (query.startsWith('org_')) {
return { organisation: await findOrganisationsByIdOrUrl(query) };
}
// Bare numbers are treated as verified ID lookups only. Oversized numbers
// fall through to text search.
const numericId = Number(query);
if (/^\d+$/.test(query) && numericId <= MAX_POSTGRES_INT) {
const [document, user, team, recipient, subscription] = await Promise.all([
findDocumentsByExactId({ secondaryId: `document_${numericId}` }),
findUsersById(numericId),
findTeamsById(numericId),
findRecipientsById(numericId),
findSubscriptionsById(numericId),
]);
return { document, user, team, recipient, subscription };
}
// Free text searches all resource types in parallel.
const [document, user, organisation, team, recipient, subscription] = await Promise.all([
findDocumentsByText(query),
findUsersByText(query),
findOrganisationsByText(query),
findTeamsByText(query),
findRecipientsByText(query),
findSubscriptionsByText(query),
]);
return {
document,
user,
organisation,
team,
recipient,
subscription,
};
};
const joinSublabel = (parts: Array<string | null | undefined>) =>
parts.filter((part) => part && part.length > 0).join(' · ') || undefined;
// ─── Documents ────────────────────────────────────────────────────────────────
const documentSelect = {
id: true,
title: true,
secondaryId: true,
user: { select: { email: true } },
} as const;
type DocumentRow = {
id: string;
title: string;
secondaryId: string;
user: { email: string };
};
const mapDocument = (envelope: DocumentRow): AdminGlobalSearchResult => ({
label: envelope.title,
sublabel: joinSublabel([envelope.secondaryId, envelope.user.email]),
path: `/admin/documents/${envelope.id}`,
value: `document ${envelope.id} ${envelope.secondaryId} ${envelope.title} ${envelope.user.email}`,
});
const findDocumentsByExactId = async (where: { id: string } | { secondaryId: string }) => {
const envelope = await prisma.envelope.findFirst({
where: { ...where, type: EnvelopeType.DOCUMENT },
select: documentSelect,
});
return envelope ? [mapDocument(envelope)] : [];
};
const findDocumentsByText = async (query: string) => {
const envelopes = await prisma.envelope.findMany({
where: {
type: EnvelopeType.DOCUMENT,
title: { contains: query, mode: 'insensitive' },
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: documentSelect,
});
return envelopes.map(mapDocument);
};
// ─── Users ────────────────────────────────────────────────────────────────────
const userSelect = {
id: true,
name: true,
email: true,
} as const;
type UserRow = { id: number; name: string | null; email: string };
const mapUser = (user: UserRow): AdminGlobalSearchResult => ({
label: user.name || user.email,
sublabel: joinSublabel([`#${user.id}`, user.email]),
path: `/admin/users/${user.id}`,
value: `user ${user.id} ${user.name ?? ''} ${user.email}`,
});
const findUsersById = async (id: number) => {
const user = await prisma.user.findFirst({
where: { id },
select: userSelect,
});
return user ? [mapUser(user)] : [];
};
const findUsersByText = async (query: string) => {
const users = await prisma.user.findMany({
where: {
OR: [{ name: { contains: query, mode: 'insensitive' } }, { email: { contains: query, mode: 'insensitive' } }],
},
orderBy: { id: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: userSelect,
});
return users.map(mapUser);
};
// ─── Organisations ────────────────────────────────────────────────────────────
const organisationSelect = {
id: true,
name: true,
owner: { select: { email: true } },
} as const;
type OrganisationRow = { id: string; name: string; owner: { email: string } };
const mapOrganisation = (organisation: OrganisationRow): AdminGlobalSearchResult => ({
label: organisation.name,
sublabel: joinSublabel([organisation.id, organisation.owner.email]),
path: `/admin/organisations/${organisation.id}`,
value: `organisation ${organisation.id} ${organisation.name} ${organisation.owner.email}`,
});
const findOrganisationsByIdOrUrl = async (query: string) => {
const organisations = await prisma.organisation.findMany({
where: {
OR: [{ id: query }, { url: query }],
},
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: organisationSelect,
});
return organisations.map(mapOrganisation);
};
const findOrganisationsByText = async (query: string) => {
const organisations = await prisma.organisation.findMany({
where: {
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ url: { contains: query, mode: 'insensitive' } },
{ customerId: { contains: query, mode: 'insensitive' } },
{ owner: { email: { contains: query, mode: 'insensitive' } } },
],
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: organisationSelect,
});
return organisations.map(mapOrganisation);
};
// ─── Teams ────────────────────────────────────────────────────────────────────
const teamSelect = {
id: true,
name: true,
url: true,
organisation: { select: { name: true } },
} as const;
type TeamRow = { id: number; name: string; url: string; organisation: { name: string } };
const mapTeam = (team: TeamRow): AdminGlobalSearchResult => ({
label: team.name,
sublabel: joinSublabel([`#${team.id}`, `/${team.url}`, team.organisation.name]),
path: `/admin/teams/${team.id}`,
value: `team ${team.id} ${team.name} ${team.url} ${team.organisation.name}`,
});
const findTeamsById = async (id: number) => {
const team = await prisma.team.findFirst({
where: { id },
select: teamSelect,
});
return team ? [mapTeam(team)] : [];
};
const findTeamsByText = async (query: string) => {
const teams = await prisma.team.findMany({
where: {
OR: [{ name: { contains: query, mode: 'insensitive' } }, { url: { contains: query, mode: 'insensitive' } }],
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: teamSelect,
});
return teams.map(mapTeam);
};
// ─── Recipients ───────────────────────────────────────────────────────────────
const recipientSelect = {
id: true,
name: true,
email: true,
envelope: { select: { id: true, title: true } },
} as const;
type RecipientRow = {
id: number;
name: string;
email: string;
envelope: { id: string; title: string };
};
const mapRecipient = (recipient: RecipientRow): AdminGlobalSearchResult => ({
label: recipient.email,
sublabel: joinSublabel([`#${recipient.id}`, recipient.name, recipient.envelope.title]),
path: `/admin/documents/${recipient.envelope.id}`,
value: `recipient ${recipient.id} ${recipient.name} ${recipient.email} ${recipient.envelope.title}`,
});
const findRecipientsById = async (id: number) => {
const recipient = await prisma.recipient.findFirst({
where: {
id,
envelope: { type: EnvelopeType.DOCUMENT },
},
select: recipientSelect,
});
return recipient ? [mapRecipient(recipient)] : [];
};
const findRecipientsByText = async (query: string) => {
const recipients = await prisma.recipient.findMany({
where: {
envelope: { type: EnvelopeType.DOCUMENT },
OR: [{ email: { contains: query, mode: 'insensitive' } }, { name: { contains: query, mode: 'insensitive' } }],
},
orderBy: { id: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: recipientSelect,
});
return recipients.map(mapRecipient);
};
// ─── Subscriptions ────────────────────────────────────────────────────────────
const subscriptionSelect = {
id: true,
status: true,
planId: true,
customerId: true,
organisationId: true,
} as const;
type SubscriptionRow = {
id: number;
status: string;
planId: string;
customerId: string;
organisationId: string;
};
const mapSubscription = (subscription: SubscriptionRow): AdminGlobalSearchResult => ({
label: `Subscription #${subscription.id}`,
sublabel: joinSublabel([subscription.status, subscription.planId]),
path: `/admin/organisations/${subscription.organisationId}`,
value: `subscription ${subscription.id} ${subscription.planId} ${subscription.customerId}`,
});
const findSubscriptionsById = async (id: number) => {
const subscription = await prisma.subscription.findFirst({
where: { id },
select: subscriptionSelect,
});
return subscription ? [mapSubscription(subscription)] : [];
};
const findSubscriptionsByText = async (query: string) => {
const subscriptions = await prisma.subscription.findMany({
where: {
OR: [
{ planId: { contains: query, mode: 'insensitive' } },
{ customerId: { contains: query, mode: 'insensitive' } },
],
},
orderBy: { createdAt: 'desc' },
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
select: subscriptionSelect,
});
return subscriptions.map(mapSubscription);
};
@@ -13,7 +13,7 @@ export const getDocumentStats = async () => {
},
});
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX'>, number> = {
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX' | 'EXPIRED'>, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
+1 -1
View File
@@ -1,6 +1,6 @@
import { Canvas, Image, Path2D } from '@documenso/skia-canvas';
import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { Canvas, Image, Path2D } from 'skia-canvas';
// @ts-expect-error napi-rs/canvas satisfies the requirements
globalThis.Path2D = Path2D;
@@ -0,0 +1,26 @@
import { AppError, AppErrorCode } from '../../errors/app-error';
import { putFileServerSide } from '../../universal/upload/put-file.server';
import { optimiseBrandingLogo } from '../../utils/images/logo';
/**
* Validate, sanitise and store an uploaded branding logo. Returns the
* `JSON.stringify({ type, data })` reference persisted in the `brandingLogo`
* column (the same format the serving endpoints already expect).
*/
export const buildBrandingLogoData = async (file: File): Promise<string> => {
const buffer = Buffer.from(await file.arrayBuffer());
const optimised = await optimiseBrandingLogo(buffer).catch(() => {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'The branding logo must be a valid image file.',
});
});
const documentData = await putFileServerSide({
name: 'branding-logo.png',
type: 'image/png',
arrayBuffer: async () => Promise.resolve(optimised),
});
return JSON.stringify(documentData);
};
@@ -59,7 +59,7 @@ export const completeDocumentWithToken = async ({
nextSigner,
recipientOverride,
}: CompleteDocumentWithTokenOptions) => {
const envelope = await prisma.envelope.findFirstOrThrow({
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
recipients: {
@@ -78,24 +78,56 @@ export const completeDocumentWithToken = async ({
},
});
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
if (envelope.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${envelope.id} must be pending`);
// The most common cause is a stale signing page: the document was deleted,
// or the recipient was removed, after the link was opened. Surface a
// NOT_FOUND instead of leaking a Prisma P2025 as a 500.
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found for the provided signing token',
statusCode: 404,
});
}
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
if (envelope.recipients.length === 0) {
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Document ${envelope.id} has no recipient with the provided token`,
statusCode: 404,
});
}
const [recipient] = envelope.recipients;
assertRecipientNotExpired(recipient);
// A retried or duplicate completion request for an already signed
// recipient throws a code the router resolves idempotently. This must be
// checked before the envelope status guard since the envelope may have
// been completed and sealed by the recipient's original request.
if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
throw new AppError(AppErrorCode.RECIPIENT_ALREADY_SIGNED, {
message: `Recipient ${recipient.id} has already signed`,
statusCode: 400,
});
}
if (envelope.status !== DocumentStatus.PENDING) {
const envelopeStatusErrorCode: Record<DocumentStatus, AppErrorCode> = {
[DocumentStatus.DRAFT]: AppErrorCode.ENVELOPE_DRAFT,
[DocumentStatus.COMPLETED]: AppErrorCode.ENVELOPE_COMPLETED,
[DocumentStatus.REJECTED]: AppErrorCode.ENVELOPE_REJECTED,
[DocumentStatus.CANCELLED]: AppErrorCode.ENVELOPE_CANCELLED,
// Unreachable: guarded by the status check above.
[DocumentStatus.PENDING]: AppErrorCode.INVALID_REQUEST,
};
throw new AppError(envelopeStatusErrorCode[envelope.status], {
message: `Document ${envelope.id} must be pending to be completed, found ${envelope.status}`,
statusCode: 400,
});
}
assertRecipientNotExpired(recipient);
if (recipient.signingStatus === SigningStatus.REJECTED) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Recipient has already rejected the document',
@@ -109,7 +141,10 @@ export const completeDocumentWithToken = async ({
});
if (!isRecipientsTurn) {
throw new Error(`Recipient ${recipient.id} attempted to complete the document before it was their turn`);
throw new AppError(AppErrorCode.RECIPIENT_OUT_OF_TURN, {
message: `Recipient ${recipient.id} attempted to complete the document before it was their turn`,
statusCode: 400,
});
}
}
@@ -272,13 +307,22 @@ export const completeDocumentWithToken = async ({
}
if (fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
throw new AppError(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, {
message: `Recipient ${recipient.id} has unsigned fields`,
statusCode: 400,
});
}
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
// Conditional update so two concurrent completion requests can't both
// proceed: only the request that transitions the recipient to SIGNED
// continues, the loser sees a count of 0 and aborts.
const { count: updatedRecipientCount } = await tx.recipient.updateMany({
where: {
id: recipient.id,
signingStatus: {
not: SigningStatus.SIGNED,
},
},
data: {
signingStatus: SigningStatus.SIGNED,
@@ -288,6 +332,16 @@ export const completeDocumentWithToken = async ({
},
});
// A concurrent request completed the recipient between our initial read
// and this transaction. Abort so the winning request handles all side
// effects, the router resolves this code idempotently.
if (updatedRecipientCount === 0) {
throw new AppError(AppErrorCode.RECIPIENT_ALREADY_SIGNED, {
message: `Recipient ${recipient.id} has already signed`,
statusCode: 400,
});
}
if (recipientEmail !== recipient.email || recipientName !== recipient.name) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
@@ -16,6 +16,7 @@ import { match } from 'ts-pattern';
import type { FindResultResponse } from '../../types/search-params';
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
import { hasExpiredRecipient } from '../envelope/query-helpers';
import { getTeamById } from '../team/get-team';
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
@@ -36,6 +37,11 @@ export type FindDocumentsOptions = {
senderIds?: number[];
query?: string;
folderId?: string;
/**
* When true, restrict results to envelopes with at least one recipient whose signing
* link has expired. Orthogonal to `status` — applied additively.
*/
hasExpiredRecipients?: boolean;
/**
* When true (default), use a windowed count that caps early for faster pagination.
* When false, use a full COUNT(*) for exact totals — preferred for external API consumers.
@@ -115,6 +121,7 @@ export const findDocuments = async ({
senderIds,
query = '',
folderId,
hasExpiredRecipients,
useWindowedCount = true,
}: FindDocumentsOptions) => {
const user = await prisma.user.findFirstOrThrow({
@@ -199,6 +206,11 @@ export const findDocuments = async ({
);
}
// Expired recipient filter (orthogonal to status, additive)
if (hasExpiredRecipients) {
qb = qb.where((eb) => hasExpiredRecipient(eb));
}
return qb;
};
@@ -305,6 +317,15 @@ export const findDocuments = async ({
]),
),
)
.with(ExtendedDocumentStatus.EXPIRED, () =>
qb.where((eb) =>
eb.and([
personalDeletedFilter(eb),
hasExpiredRecipient(eb),
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
]),
),
)
.exhaustive();
};
@@ -455,6 +476,18 @@ export const findDocuments = async ({
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
}),
)
.with(ExtendedDocumentStatus.EXPIRED, () =>
qb.where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(recipientExists(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), hasExpiredRecipient(eb), eb.or(accessBranches)]);
}),
)
.exhaustive();
};
+18 -1
View File
@@ -8,6 +8,7 @@ import { DateTime } from 'luxon';
import { STATS_COUNT_CAP } from '../../constants/document';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { hasExpiredRecipient } from '../envelope/query-helpers';
import { getTeamById } from '../team/get-team';
// Kysely query builder type for Envelope queries.
@@ -253,6 +254,19 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// EXPIRED: docs visible to the team/user with at least one expired, unsigned recipient.
// Access control mirrors the EXPIRED branch in findDocuments so the count matches the listing.
const expiredQuery = buildBaseQuery().where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(recipientExists(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), hasExpiredRecipient(eb), eb.or(accessBranches)]);
});
// INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient
// Returns 0 if the team has no team email.
const inboxQuery = teamEmail
@@ -274,15 +288,17 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
// ─── Execute all counts in parallel ──────────────────────────────────
const [draft, pending, completed, rejected, cancelled, inbox] = await Promise.all([
const [draft, pending, completed, rejected, cancelled, expired, inbox] = await Promise.all([
cappedCount(draftQuery),
cappedCount(pendingQuery),
cappedCount(completedQuery),
cappedCount(rejectedQuery),
cappedCount(cancelledQuery),
cappedCount(expiredQuery),
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
]);
// `expired` is intentionally excluded from `all` — it overlaps PENDING.
const all = Math.min(draft + pending + completed + rejected + cancelled + inbox, STATS_COUNT_CAP);
const stats: Record<ExtendedDocumentStatus, number> = {
@@ -291,6 +307,7 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
[ExtendedDocumentStatus.COMPLETED]: completed,
[ExtendedDocumentStatus.REJECTED]: rejected,
[ExtendedDocumentStatus.CANCELLED]: cancelled,
[ExtendedDocumentStatus.EXPIRED]: expired,
[ExtendedDocumentStatus.INBOX]: inbox,
[ExtendedDocumentStatus.ALL]: all,
};
@@ -194,7 +194,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
.join(', ');
throw new AppError(AppErrorCode.INVALID_REQUEST, {
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
});
}
@@ -7,6 +7,7 @@ import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { FindResultResponse } from '../../types/search-params';
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
import { getTeamById } from '../team/get-team';
import { hasExpiredRecipient } from './query-helpers';
export type FindEnvelopesOptions = {
userId: number;
@@ -23,6 +24,11 @@ export type FindEnvelopesOptions = {
};
query?: string;
folderId?: string;
/**
* When true, restrict results to envelopes with at least one recipient whose signing
* link has expired. Orthogonal to `status` — applied additively.
*/
hasExpiredRecipients?: boolean;
/**
* When true (default), use a windowed count that caps early for faster pagination.
* When false, use a full COUNT(*) for exact totals — preferred for external API consumers.
@@ -106,6 +112,7 @@ export const findEnvelopes = async ({
orderBy,
query = '',
folderId,
hasExpiredRecipients,
useWindowedCount = true,
}: FindEnvelopesOptions) => {
const user = await prisma.user.findFirstOrThrow({
@@ -182,6 +189,11 @@ export const findEnvelopes = async ({
);
}
// Expired recipient filter (orthogonal to status, additive)
if (hasExpiredRecipients) {
qb = qb.where((eb) => hasExpiredRecipient(eb));
}
// ─── Access control ──────────────────────────────────────────────────
//
// An envelope is visible if ANY of:
@@ -5,6 +5,7 @@ import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { extractFieldAutoInsertValues } from '../document/send-document';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
@@ -125,6 +126,17 @@ export const getEnvelopeForDirectTemplateSigning = async ({
});
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(
envelope.recipients,
envelope.recipients.flatMap((envelopeRecipient) => envelopeRecipient.fields),
);
if (recipientsWithMissingFields.length > 0) {
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
message: 'One or more signers on this direct template are missing a signature field',
});
}
const settings = await getTeamSettings({ teamId: envelope.teamId });
const sender = settings.includeSenderDetails
@@ -5,7 +5,7 @@ import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/Envelope
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { z } from 'zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
@@ -266,6 +266,11 @@ export const getEnvelopeForRecipientSigning = async ({
if (envelope.documentMeta.signingOrder === DocumentSigningOrder.SEQUENTIAL && currentRecipientIndex !== -1) {
for (let i = 0; i < currentRecipientIndex; i++) {
// CC recipients have no action to take, so they can never block the flow.
if (envelope.recipients[i].role === RecipientRole.CC) {
continue;
}
if (envelope.recipients[i].signingStatus !== SigningStatus.SIGNED) {
isRecipientsTurn = false;
break;
@@ -0,0 +1,27 @@
import { sql } from '@documenso/prisma';
import type { DB } from '@documenso/prisma/generated/types';
import { RecipientRole, SigningStatus } from '@prisma/client';
import type { ExpressionBuilder } from 'kysely';
// Expression builder type scoped to the Envelope table context.
type EnvelopeExpressionBuilder = ExpressionBuilder<DB, 'Envelope'>;
/**
* Reusable EXISTS subquery: checks that the envelope has at least one recipient whose
* signing link has expired — `expiresAt` in the past, still unsigned, and not a CC.
*
* This is the single source of truth for the "expired recipient" predicate used by
* `findDocuments`, `findEnvelopes`, and `getStats`. It must stay in sync with
* `isRecipientExpired` (packages/lib/utils/recipients.ts).
*/
export const hasExpiredRecipient = (eb: EnvelopeExpressionBuilder) =>
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.expiresAt', 'is not', null)
.where('Recipient.expiresAt', '<=', new Date())
.where('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED))
.where('Recipient.role', '!=', sql.lit(RecipientRole.CC))
.select(sql.lit(1).as('one')),
);
@@ -2,8 +2,9 @@
* !: This is a workaround to fix the memory leak in the skia-canvas library.
* !: Internals are ported from the original `konva/skia-backend.js` file.
*/
import { Canvas, DOMMatrix, Image, Path2D } from '@documenso/skia-canvas';
import { Konva } from 'konva/lib/_CoreInternals';
import { Canvas, DOMMatrix, Image, Path2D } from 'skia-canvas';
// @ts-expect-error skia-canvas satisfies the requirements
global.DOMMatrix = DOMMatrix;
@@ -37,6 +38,6 @@ Konva.Util.createImageElement = () => {
return node as unknown as HTMLImageElement;
};
Konva._renderBackend = 'skia-canvas';
Konva._renderBackend = '@documenso/skia-canvas';
export default Konva;
@@ -1,7 +1,12 @@
import {
assertMemberCountWithinCap,
syncMemberCountWithStripeSeatPlan,
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { prisma } from '@documenso/prisma';
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
import { OrganisationGroupType, OrganisationMemberInviteStatus, SubscriptionStatus } from '@prisma/client';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { generateDatabaseId } from '../../universal/id';
@@ -22,6 +27,13 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
organisation: {
include: {
groups: true,
organisationClaim: true,
subscription: true,
members: {
select: {
id: true,
},
},
},
},
},
@@ -66,6 +78,35 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
return;
}
const newMemberCount = organisation.members.length + 1;
// Billing occurs when a user accepts an invite.
// Assert that the new member count is within the cap and sync the seat plan with Stripe.
if (IS_BILLING_ENABLED()) {
const { subscription, organisationClaim } = organisation;
// A canceled subscription cannot have its seat quantity updated in Stripe,
// and an organisation with lapsed billing should not gain new members.
// Throw a deliberate error so the invite page can render an accurate
// message instead of an opaque Stripe failure.
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
throw new AppError('SUBSCRIPTION_INACTIVE', {
message: 'The organisation subscription is inactive',
});
}
// Organisations can exist without a subscription (e.g. after being
// downgraded to the free plan). The claim cap remains authoritative in
// that case, surfacing LIMIT_EXCEEDED instead of an opaque "subscription
// not found" error.
await assertMemberCountWithinCap(subscription, organisationClaim, newMemberCount);
if (subscription) {
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount, 'grow');
}
}
// Todo: Logging
await addUserToOrganisation({
userId: user.id,
organisationId: organisation.id,
@@ -1,7 +1,3 @@
import {
assertMemberCountWithinCap,
syncMemberCountWithStripeSeatPlan,
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
@@ -17,7 +13,6 @@ import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { generateDatabaseId } from '../../universal/id';
import { validateIfSubscriptionIsRequired } from '../../utils/billing';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
@@ -62,8 +57,6 @@ export const createOrganisationMemberInvites = async ({
},
},
organisationGlobalSettings: true,
organisationClaim: true,
subscription: true,
},
});
@@ -71,10 +64,6 @@ export const createOrganisationMemberInvites = async ({
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { organisationClaim } = organisation;
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const currentOrganisationMemberRole = await getMemberOrganisationRole({
organisationId: organisation.id,
reference: {
@@ -120,19 +109,6 @@ export const createOrganisationMemberInvites = async ({
}),
);
const numberOfCurrentMembers = organisation.members.length;
const numberOfCurrentInvites = organisation.invites.length;
const numberOfNewInvites = organisationMemberInvites.length;
const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites;
// Enforce the seat cap and sync billing for seat based plans.
if (subscription) {
await assertMemberCountWithinCap(subscription, organisationClaim, totalMemberCountWithInvites);
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, totalMemberCountWithInvites);
}
await prisma.organisationMemberInvite.createMany({
data: organisationMemberInvites,
});
+1 -1
View File
@@ -1,8 +1,8 @@
import path from 'node:path';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FontLibrary } from '@documenso/skia-canvas';
import type { Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { FontLibrary } from 'skia-canvas';
import { match } from 'ts-pattern';
/**
@@ -2,8 +2,8 @@
import '../konva/skia-backend';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import type { Canvas } from '@documenso/skia-canvas';
import Konva from 'konva';
import type { Canvas } from 'skia-canvas';
import { renderField } from '../../universal/field-renderer/render-field';
import { ensureFontLibrary } from './helpers';
@@ -1,14 +1,16 @@
// sort-imports-ignore
import '../konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from '@documenso/skia-canvas';
import { Image as SkiaImage } from '@documenso/skia-canvas';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta, Envelope, RecipientRole } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import type { Canvas } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { match, P } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
@@ -1,14 +1,16 @@
// sort-imports-ignore
import '../konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from '@documenso/skia-canvas';
import { Image as SkiaImage } from '@documenso/skia-canvas';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { Field, RecipientRole, Signature } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import { DateTime } from 'luxon';
import type { Canvas } from 'skia-canvas';
import { Image as SkiaImage } from 'skia-canvas';
import { UAParser } from 'ua-parser-js';
import { renderSVG } from 'uqr';
@@ -1,4 +1,4 @@
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage';
export type QuotaFlags = {
isDocumentQuotaExceeded: boolean;
@@ -22,39 +22,6 @@ type ComputeQuotaFlagsOptions = {
};
};
/**
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
*/
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
/**
* A counter is "nearing" its quota once usage reaches the warning threshold
* (80% of the quota, rounded up) but has not yet been exceeded. Nearing and
* exceeded are mutually exclusive per counter.
*/
const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
return {
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
@@ -1,4 +1,4 @@
export const QUOTA_WARNING_THRESHOLD = 0.8;
import { getQuotaWarningCount } from '../../universal/quota-usage';
export type QuotaAlertKind = 'quota' | 'quotaNearing';
@@ -32,7 +32,7 @@ export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKin
// From here newCount < quota, so for tiny quotas (1-4) where the rounded-up
// warning threshold equals the quota itself, the warning can never fire — the
// exhausting request is handled by the quota branch above.
const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
const warningCount = getQuotaWarningCount(quota);
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
@@ -84,13 +84,13 @@ export const syncSubscriptionRateLimit = createRateLimit({
export const apiV1RateLimit = createRateLimit({
action: 'api.v1',
max: 100,
max: 1000,
window: '1m',
});
export const apiV2RateLimit = createRateLimit({
action: 'api.v2',
max: 100,
max: 1000,
window: '1m',
});
@@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma';
import { DocumentSigningOrder, EnvelopeType, SigningStatus } from '@prisma/client';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
export type GetIsRecipientTurnOptions = {
token: string;
@@ -38,6 +38,11 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
}
for (let i = 0; i < currentRecipientIndex; i++) {
// CC recipients have no action to take, so they can never block the flow.
if (recipients[i].role === RecipientRole.CC) {
continue;
}
if (recipients[i].signingStatus !== SigningStatus.SIGNED) {
return false;
}
@@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { mapDocumentIdToSecondaryId } from '../../utils/envelope';
@@ -16,6 +16,11 @@ export const getNextPendingRecipient = async ({
type: EnvelopeType.DOCUMENT,
secondaryId: mapDocumentIdToSecondaryId(documentId),
},
// CC recipients are informational only and never take part in signing,
// so they must never be offered as the next pending recipient.
role: {
not: RecipientRole.CC,
},
},
orderBy: [
{
@@ -1,37 +0,0 @@
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type GetTeamEmailByEmailOptions = {
email: string;
};
export const getTeamEmailByEmail = async ({ email }: GetTeamEmailByEmailOptions) => {
return await prisma.teamEmail.findFirst({
where: {
email,
},
include: {
team: {
select: {
id: true,
name: true,
url: true,
},
},
},
});
};
export const getTeamWithEmail = async ({ userId, teamUrl }: { userId: number; teamUrl: string }) => {
return await prisma.team.findFirstOrThrow({
where: {
...buildTeamWhereQuery({ teamId: undefined, userId }),
url: teamUrl,
},
include: {
teamEmail: true,
emailVerification: true,
},
});
};
+2 -2
View File
@@ -15,12 +15,12 @@ export type GetTeamsOptions = {
};
export const ZGetTeamsResponseSchema = TeamSchema.extend({
teamRole: z.nativeEnum(TeamMemberRole),
currentTeamRole: z.nativeEnum(TeamMemberRole),
}).array();
export type TGetTeamsResponse = z.infer<typeof ZGetTeamsResponseSchema>;
export const getTeams = async ({ userId, teamId }: GetTeamsOptions) => {
export const getTeams = async ({ userId, teamId }: GetTeamsOptions): Promise<TGetTeamsResponse> => {
const teams = await prisma.team.findMany({
where: buildTeamWhereQuery({ teamId, userId }),
include: {
@@ -40,6 +40,7 @@ import {
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
import { incrementDocumentId } from '../envelope/increment-id';
@@ -172,6 +173,17 @@ export const createDocumentFromDirectTemplate = async ({
});
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(
recipients,
recipients.flatMap((recipient) => recipient.fields),
);
if (recipientsWithMissingFields.length > 0) {
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
message: 'One or more signers on this direct template are missing a signature field',
});
}
if (directTemplateEnvelope.updatedAt.getTime() !== templateUpdatedAt.getTime()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
}
+21 -1
View File
@@ -1,6 +1,7 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { deleteOrganisation } from '../organisation/delete-organisation';
export type DeleteUserOptions = {
@@ -59,6 +60,13 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
})),
);
// Organisations the user is a member of (but not owner). Owned organisations
// are fully torn down below (including subscription cancellation), so only
// these need a seat sync after the user's memberships cascade away.
const memberOrganisationIds = user.organisationMember
.filter((member) => member.organisation.ownerUserId !== user.id)
.map((member) => member.organisationId);
// For organisations the user owns - fully tear them down (orphan envelopes,
// delete the organisation, and cancel any Stripe subscription). Without this
// the organisations would only cascade away when the user row is deleted,
@@ -82,9 +90,21 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
}),
);
return await prisma.user.delete({
const deletedUser = await prisma.user.delete({
where: {
id: user.id,
},
});
// The user's memberships were cascade-deleted with the user row — queue a
// seat sync for each organisation they belonged to so the Stripe quantity
// trues down to the new member count (no proration, no credit).
for (const organisationId of memberOrganisationIds) {
await jobs.triggerJob({
name: 'internal.sync-organisation-seats',
payload: { organisationId },
});
}
return deletedUser;
};
@@ -3,6 +3,7 @@ import { DateTime } from 'luxon';
import { EMAIL_VERIFICATION_STATE, USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER } from '../../constants/email';
import { jobsClient } from '../../jobs/client';
import { getMostRecentEmailVerificationToken } from './get-most-recent-email-verification-token';
export type VerifyEmailProps = {
token: string;
@@ -36,13 +37,8 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
const valid = verificationToken.expires > new Date();
if (!valid) {
const mostRecentToken = await prisma.verificationToken.findFirst({
where: {
userId: verificationToken.userId,
},
orderBy: {
createdAt: 'desc',
},
const mostRecentToken = await getMostRecentEmailVerificationToken({
userId: verificationToken.userId,
});
// If there isn't a recent token or it's older than 1 hour, send a new token
@@ -80,6 +76,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
prisma.verificationToken.updateMany({
where: {
userId: verificationToken.userId,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
},
data: {
completed: true,
@@ -89,6 +86,7 @@ export const verifyEmail = async ({ token }: VerifyEmailProps) => {
prisma.verificationToken.deleteMany({
where: {
userId: verificationToken.userId,
identifier: USER_SIGNUP_VERIFICATION_TOKEN_IDENTIFIER,
expires: {
lt: new Date(),
},
@@ -1,24 +1,26 @@
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
export const getWebhooksByTeamId = async (teamId: number, userId: number) => {
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
return await prisma.webhook.findMany({
where: {
team: {
id: teamId,
teamGroups: {
some: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
teamId,
},
orderBy: {
createdAt: 'desc',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import { ZNameSchema } from './name';
describe('ZNameSchema', () => {
describe('valid names', () => {
it('accepts a normal name', () => {
expect(ZNameSchema.safeParse('Example User')).toEqual({
success: true,
data: 'Example User',
});
});
it('accepts international characters', () => {
expect(ZNameSchema.safeParse('Døcumensø Üser')).toEqual({
success: true,
data: 'Døcumensø Üser',
});
});
it('trims surrounding whitespace', () => {
expect(ZNameSchema.safeParse(' Documenso User ')).toEqual({
success: true,
data: 'Documenso User',
});
});
it('accepts names at the minimum length', () => {
expect(ZNameSchema.safeParse('DU')).toEqual({
success: true,
data: 'DU',
});
});
it('accepts names at the maximum length', () => {
const name =
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Do';
expect(name.length).toBe(100);
expect(ZNameSchema.safeParse(name)).toEqual({
success: true,
data: name,
});
});
});
describe('length validation', () => {
it('rejects names shorter than 2 characters', () => {
expect(ZNameSchema.safeParse('D')).toMatchObject({
success: false,
error: {
issues: [{ message: 'Please enter a valid name.' }],
},
});
});
it('rejects names longer than 100 characters', () => {
const name =
'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Doc';
expect(name.length).toBe(101);
expect(ZNameSchema.safeParse(name)).toMatchObject({
success: false,
error: {
issues: [{ message: 'Name cannot be more than 100 characters.' }],
},
});
});
it('rejects whitespace-only input after trim', () => {
expect(ZNameSchema.safeParse(' ')).toMatchObject({
success: false,
});
});
});
describe('URL validation', () => {
it.each([
'https://example.com',
'http://example.com',
'HTTPS://EXAMPLE.COM',
'Northwind www.example.com',
'www.example.com',
])('rejects URLs in names: %s', (value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name cannot contain URLs.' })]),
},
});
});
});
describe('invalid character validation', () => {
it.each([
['NUL character', 'Acme\u0000Corp'],
['zero-width space', 'Acme\u200bCorp'],
['bidi override', 'Acme\u202eCorp'],
['byte order mark', 'Acme\ufeffCorp'],
['lone surrogate', 'Acme\ud800Corp'],
['tag character', `Acme${String.fromCodePoint(0xe0041)}Corp`],
['noncharacter', 'Acme\ufffeCorp'],
['private use character', 'Acme\ue000Corp'],
['Hangul filler', 'Acme\u3164Corp'],
['braille blank', 'Acme\u2800Corp'],
['combining grapheme joiner', 'Acme\u034fCorp'],
])('rejects names containing a %s', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
},
});
});
it.each([
['fixed form', String.raw`Acme\u200bCorp`],
['uppercase U', String.raw`Acme\U200BCorp`],
['braced form', String.raw`Acme\u{200b}Corp`],
['braced form with leading zeros', String.raw`Acme\u{0000200b}Corp`],
['lone surrogate', String.raw`Acme\ud800Corp`],
])('rejects literal \\u escape sequences stored as text (%s)', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: false,
error: {
issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]),
},
});
});
it.each([
['escape of a valid code point', String.raw`Acme\u0041Corp`],
['braced escape of a valid astral code point', String.raw`Acme\u{1F600}Corp`],
['braced escape beyond the Unicode range', String.raw`Acme\u{FFFFFFF}Corp`],
['incomplete escape sequence', String.raw`Acme\u00 Corp`],
['unterminated braced escape', String.raw`Acme\u{200bCorp`],
['astral characters such as emoji', 'Acme 😀 Corp'],
['emoji with a variation selector', 'I ❤️ Docs'],
])('accepts %s', (_label, value) => {
expect(ZNameSchema.safeParse(value)).toMatchObject({
success: true,
});
});
});
});
+68
View File
@@ -0,0 +1,68 @@
import { z } from 'zod';
export const URL_PATTERN = /https?:\/\/|www\./i;
/**
* Characters that render as empty/invisible or break text layout:
*
* - `\p{C}` - control, format, lone surrogate, private use and
* unassigned code points (NUL, zero-width spaces, bidi
* overrides, BOM, tag characters, noncharacters).
* - `\p{Zl}\p{Zp}` - line and paragraph separators.
* - `\u{034F}` - combining grapheme joiner (invisible). Kept outside the
* character class because it is a combining mark, which
* lint rules reject inside classes.
* - remaining - letters that render as blank (Hangul fillers, braille blank).
*
* The `\p{...}` classes are maintained by the Unicode database, so newly
* assigned characters in these categories are covered automatically.
*/
const INVALID_CHARACTER_REGEX = /[\p{C}\p{Zl}\p{Zp}\u{115F}\u{1160}\u{2800}\u{3164}\u{FFA0}]|\u{034F}/u;
const hasInvalidCharacter = (value: string) => INVALID_CHARACTER_REGEX.test(value);
/**
* Matches literal `\uXXXX` and `\u{XXXX}` escape sequences stored verbatim as
* text (e.g. the 6 characters `\`, `u`, `2`, `0`, `0`, `b`), which can still
* break rendering downstream if anything decodes them.
*/
const ESCAPE_SEQUENCE_PATTERN = /\\u(?:([0-9a-f]{4})|\{([0-9a-f]+)\})/gi;
const hasInvalidEscapeSequence = (value: string) => {
for (const [, fixedHex, bracedHex] of value.matchAll(ESCAPE_SEQUENCE_PATTERN)) {
const codePoint = parseInt(fixedHex ?? bracedHex, 16);
if (codePoint > 0x10ffff) {
continue;
}
// Decode the escape and run it through the same character policy as the
// unescaped check, so the two can never drift apart.
if (hasInvalidCharacter(String.fromCodePoint(codePoint))) {
return true;
}
}
return false;
};
export const hasInvalidTextCharacters = (value: string) =>
hasInvalidCharacter(value) || hasInvalidEscapeSequence(value);
/**
* Shared name schema that disallows URLs to prevent phishing via email rendering,
* and invisible/control characters that render as empty or break the UI.
*/
export const ZNameSchema = z
.string()
.trim()
.min(2, { message: 'Please enter a valid name.' })
.max(100, { message: 'Name cannot be more than 100 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Name cannot contain URLs.',
})
.refine((value) => !hasInvalidTextCharacters(value), {
message: 'Name contains invalid characters.',
});
export type TName = z.infer<typeof ZNameSchema>;
+32 -7
View File
@@ -6,14 +6,39 @@ import { z } from 'zod';
*
* Example: "5m", "1h", "1d"
*/
export const ZRateLimitWindowSchema = z.string().regex(/^\d+[smhd]$/);
export const RATE_LIMIT_WINDOW_REGEX = /^\d+[smhd]$/;
export const ZRateLimitArraySchema = z.array(
z.object({
window: ZRateLimitWindowSchema,
max: z.number().int().positive(),
}),
);
const RATE_LIMIT_WINDOW_ERROR_MESSAGE = 'Use a duration with a unit, e.g. 5m, 1h, or 24h';
const RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE = 'Use a unique window for each rate limit';
export const ZRateLimitWindowSchema = z.string().trim().regex(RATE_LIMIT_WINDOW_REGEX, {
message: RATE_LIMIT_WINDOW_ERROR_MESSAGE,
});
export const ZRateLimitArraySchema = z
.array(
z.object({
window: ZRateLimitWindowSchema,
max: z.number().int().positive(),
}),
)
.superRefine((entries, ctx) => {
const windows = new Set<string>();
entries.forEach((entry, index) => {
const window = entry.window.trim();
if (windows.has(window)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE,
path: [index, 'window'],
});
}
windows.add(window);
});
});
export type TRateLimitArray = z.infer<typeof ZRateLimitArraySchema>;
@@ -14,7 +14,7 @@ let SkiaImage: any;
void (async () => {
if (typeof window === 'undefined') {
const mod = await import('skia-canvas');
const mod = await import('@documenso/skia-canvas');
SkiaImage = mod.Image;
}
})();
+2 -2
View File
@@ -1,10 +1,10 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { NEXT_PUBLIC_WEBAPP_URL, getBasePath } from '../constants/app';
import { env } from '../utils/env';
export const getBaseUrl = () => {
if (typeof window !== 'undefined') {
return '';
return getBasePath();
}
const webAppUrl = NEXT_PUBLIC_WEBAPP_URL();
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import {
getQuotaUsagePercent,
getQuotaWarningCount,
isQuotaExceeded,
isQuotaNearing,
normalizeCapacityLimit,
} from './quota-usage';
describe('isQuotaExceeded', () => {
it('treats null quota as unlimited (never exceeded)', () => {
expect(isQuotaExceeded(null, 0)).toBe(false);
expect(isQuotaExceeded(null, 1_000_000)).toBe(false);
});
it('treats a zero quota as blocked (always exceeded)', () => {
expect(isQuotaExceeded(0, 0)).toBe(true);
expect(isQuotaExceeded(0, 5)).toBe(true);
});
it('is exceeded once usage reaches the quota (>= boundary)', () => {
expect(isQuotaExceeded(10, 9)).toBe(false);
expect(isQuotaExceeded(10, 10)).toBe(true);
expect(isQuotaExceeded(10, 11)).toBe(true);
});
});
describe('getQuotaWarningCount', () => {
it('rounds the 80% threshold up', () => {
expect(getQuotaWarningCount(10)).toBe(8);
expect(getQuotaWarningCount(100)).toBe(80);
// 5 * 0.8 = 4 exactly.
expect(getQuotaWarningCount(5)).toBe(4);
// 3 * 0.8 = 2.4 -> 3, so the warning count equals the quota itself.
expect(getQuotaWarningCount(3)).toBe(3);
});
});
describe('isQuotaNearing', () => {
it('is never nearing for unlimited or blocked quotas', () => {
expect(isQuotaNearing(null, 5)).toBe(false);
expect(isQuotaNearing(0, 5)).toBe(false);
});
it('is nearing from the warning threshold up to (but not including) the quota', () => {
expect(isQuotaNearing(10, 7)).toBe(false);
expect(isQuotaNearing(10, 8)).toBe(true);
expect(isQuotaNearing(10, 9)).toBe(true);
});
it('is not nearing once exceeded (nearing and exceeded are mutually exclusive)', () => {
expect(isQuotaNearing(10, 10)).toBe(false);
expect(isQuotaNearing(10, 11)).toBe(false);
});
it('can never fire for tiny quotas where the warning count equals the quota', () => {
// getQuotaWarningCount(3) === 3, so usage >= 3 is already exceeded.
expect(isQuotaNearing(3, 2)).toBe(false);
expect(isQuotaNearing(3, 3)).toBe(false);
});
it('agrees with the warning-count helper at the boundary', () => {
const quota = 250;
const warningCount = getQuotaWarningCount(quota);
expect(isQuotaNearing(quota, warningCount - 1)).toBe(false);
expect(isQuotaNearing(quota, warningCount)).toBe(true);
});
});
describe('getQuotaUsagePercent', () => {
it('returns 0 for unlimited or non-positive quotas', () => {
expect(getQuotaUsagePercent(5, null)).toBe(0);
expect(getQuotaUsagePercent(5, 0)).toBe(0);
expect(getQuotaUsagePercent(5, -10)).toBe(0);
});
it('rounds the percentage to the nearest integer', () => {
expect(getQuotaUsagePercent(1, 3)).toBe(33);
expect(getQuotaUsagePercent(2, 3)).toBe(67);
expect(getQuotaUsagePercent(50, 100)).toBe(50);
});
it('clamps the percentage to 100 when usage exceeds the quota', () => {
expect(getQuotaUsagePercent(150, 100)).toBe(100);
});
});
describe('normalizeCapacityLimit', () => {
it('maps 0 (unlimited for capacity limits) to null', () => {
expect(normalizeCapacityLimit(0)).toBeNull();
});
it('passes positive limits through unchanged', () => {
expect(normalizeCapacityLimit(1)).toBe(1);
expect(normalizeCapacityLimit(25)).toBe(25);
});
});
+57
View File
@@ -0,0 +1,57 @@
export const QUOTA_WARNING_THRESHOLD = 0.8;
/**
* Monthly quotas: `null` = unlimited, `0` = blocked. Usage `>=` quota is exceeded.
*/
export const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
/**
* The usage count at which a positive quota starts "nearing" (80% rounded up).
* The single source for the warning threshold math so the UI panel, quota flags,
* and the per-request alert path can't drift apart.
*/
export const getQuotaWarningCount = (quota: number): number => {
return Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
/**
* Nearing once usage reaches the warning threshold (80% rounded up) but is not exceeded.
*/
export const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= getQuotaWarningCount(quota);
};
export const getQuotaUsagePercent = (usage: number, quota: number | null): number => {
if (quota === null || quota <= 0) {
return 0;
}
return Math.min(100, Math.round((usage / quota) * 100));
};
/** Member/team capacity limits use `0` for unlimited. */
export const normalizeCapacityLimit = (limit: number): number | null => {
if (limit === 0) {
return null;
}
return limit;
};
+3 -1
View File
@@ -2,6 +2,8 @@ import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import { formatPath } from '../../constants/app';
export type GetFileOptions = {
type: DocumentDataType;
data: string;
@@ -36,7 +38,7 @@ const getFileFromBytes64 = (data: string) => {
};
const getFileFromS3 = async (key: string) => {
const getPresignedUrlResponse = await fetch(`/api/files/presigned-get-url`, {
const getPresignedUrlResponse = await fetch(formatPath('/api/files/presigned-get-url'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+3 -72
View File
@@ -1,10 +1,6 @@
import { env } from '@documenso/lib/utils/env';
import type { TGetPresignedPostUrlResponse, TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import { DocumentDataType } from '@prisma/client';
import { base64 } from '@scure/base';
import { match } from 'ts-pattern';
import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { formatPath } from '../../constants/app';
import { AppError } from '../../errors/app-error';
type File = {
@@ -43,7 +39,7 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
formData.append('file', properFile);
const response = await fetch('/api/files/upload-pdf', {
const response = await fetch(formatPath('/api/files/upload-pdf'), {
method: 'POST',
headers: buildUploadAuthHeaders(options),
body: formData,
@@ -58,68 +54,3 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => {
return result;
};
/**
* Uploads a file to the appropriate storage location.
*/
export const putFile = async (file: File, options?: PutFileOptions) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInObjectStorage(file, {}, options))
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }, options))
.otherwise(async () => putFileInDatabase(file));
};
const putFileInDatabase = async (file: File) => {
const contents = await file.arrayBuffer();
const binaryData = new Uint8Array(contents);
const asciiData = base64.encode(binaryData);
return {
type: DocumentDataType.BYTES_64,
data: asciiData,
};
};
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>, options?: PutFileOptions) => {
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildUploadAuthHeaders(options),
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
}),
});
if (!getPresignedUrlResponse.ok) {
throw new Error(`Failed to get presigned post url, failed with status code ${getPresignedUrlResponse.status}`);
}
const { url, key }: TGetPresignedPostUrlResponse = await getPresignedUrlResponse.json();
const body = await file.arrayBuffer();
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
...extraHeaders,
},
body,
});
if (!response.ok) {
throw new Error(`Failed to upload file "${file.name}", failed with status code ${response.status}`);
}
return {
type: DocumentDataType.S3_PATH,
data: key,
};
};
+4
View File
@@ -56,4 +56,8 @@ export const createPublicEnv = () => ({
// 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',
// Derived from the private Vertex credentials so the client can gate AI
// feature UI on a boolean.
NEXT_PUBLIC_AI_FEATURES_ENABLED:
process.env.GOOGLE_VERTEX_PROJECT_ID && process.env.GOOGLE_VERTEX_API_KEY ? 'true' : 'false',
});
+12
View File
@@ -8,3 +8,15 @@ export const loadLogo = async (file: Uint8Array) => {
content,
};
};
/**
* Validate and sanitise an uploaded branding logo. Re-encoding through `sharp`
* proves the bytes are a real raster image and strips any embedded payloads.
* Throws if the input cannot be parsed as an image.
*/
export const optimiseBrandingLogo = async (input: Buffer | Uint8Array): Promise<Buffer> => {
return await sharp(input)
.resize(512, 512, { fit: 'inside', withoutEnlargement: true })
.png({ quality: 80 })
.toBuffer();
};
+27 -3
View File
@@ -1,4 +1,15 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getBasePath, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
/**
* The origin of the web app, ignoring any sub-path NEXT_PUBLIC_WEBAPP_URL carries.
*/
const getWebAppOrigin = () => {
try {
return new URL(NEXT_PUBLIC_WEBAPP_URL()).origin;
} catch {
return NEXT_PUBLIC_WEBAPP_URL();
}
};
export const isValidReturnTo = (returnTo?: string) => {
if (!returnTo) {
@@ -10,7 +21,10 @@ export const isValidReturnTo = (returnTo?: string) => {
const decodedReturnTo = decodeURIComponent(returnTo);
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
if (returnToUrl.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
// Compare against the origin, not the raw env value: when the app is served
// under a sub-path NEXT_PUBLIC_WEBAPP_URL is e.g. "https://host/ESign", which
// never equals a URL's origin ("https://host").
if (returnToUrl.origin !== getWebAppOrigin()) {
return false;
}
@@ -30,7 +44,17 @@ export const normalizeReturnTo = (returnTo?: string) => {
const decodedReturnTo = decodeURIComponent(returnTo);
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
return `${returnToUrl.pathname}${returnToUrl.search}${returnToUrl.hash}`;
const basePath = getBasePath();
let pathname = returnToUrl.pathname;
// A root-relative returnTo ("/inbox") resolves to a pathname without the
// sub-path, so re-apply it when it is missing.
if (basePath && pathname !== basePath && !pathname.startsWith(`${basePath}/`)) {
pathname = `${basePath}${pathname}`;
}
return `${pathname}${returnToUrl.search}${returnToUrl.hash}`;
} catch {
return undefined;
}
+71
View File
@@ -0,0 +1,71 @@
import { RecipientRole } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { isAssistantLastSigner, normalizeRecipientSigningOrders, sortRecipientsForSigningOrder } from './recipients';
describe('recipient signing order helpers', () => {
it('sorts CC recipients after ordered active recipients', () => {
const recipients = [
{ id: 1, role: RecipientRole.CC, signingOrder: 1 },
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 2 },
{ id: 3, role: RecipientRole.APPROVER, signingOrder: 1 },
];
expect(sortRecipientsForSigningOrder(recipients).map((recipient) => recipient.id)).toEqual([3, 2, 1]);
});
it('keeps original order when recipients have the same signing order', () => {
const recipients = [
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 1 },
{ id: 1, role: RecipientRole.APPROVER, signingOrder: 1 },
];
expect(sortRecipientsForSigningOrder(recipients).map((recipient) => recipient.id)).toEqual([2, 1]);
});
it('sorts and normalizes active recipient signing order and removes it from CC recipients', () => {
const recipients = [
{ id: 1, role: RecipientRole.CC, signingOrder: 1 },
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 4 },
{ id: 3, role: RecipientRole.APPROVER, signingOrder: 2 },
];
expect(normalizeRecipientSigningOrders(sortRecipientsForSigningOrder(recipients))).toEqual([
{ id: 3, role: RecipientRole.APPROVER, signingOrder: 1 },
{ id: 2, role: RecipientRole.SIGNER, signingOrder: 2 },
{ id: 1, role: RecipientRole.CC, signingOrder: undefined },
]);
});
it('preserves caller order while normalizing signing order', () => {
const recipients = [
{ id: 2, role: RecipientRole.ASSISTANT, signingOrder: 2 },
{ id: 1, role: RecipientRole.SIGNER, signingOrder: 1 },
{ id: 3, role: RecipientRole.CC, signingOrder: 1 },
];
expect(normalizeRecipientSigningOrders(recipients)).toEqual([
{ id: 2, role: RecipientRole.ASSISTANT, signingOrder: 1 },
{ id: 1, role: RecipientRole.SIGNER, signingOrder: 2 },
{ id: 3, role: RecipientRole.CC, signingOrder: undefined },
]);
});
it('checks whether the last non-CC recipient is an assistant', () => {
expect(
isAssistantLastSigner([
{ role: RecipientRole.SIGNER },
{ role: RecipientRole.ASSISTANT },
{ role: RecipientRole.CC },
]),
).toBe(true);
expect(
isAssistantLastSigner([
{ role: RecipientRole.ASSISTANT },
{ role: RecipientRole.SIGNER },
{ role: RecipientRole.CC },
]),
).toBe(false);
});
});
+54 -2
View File
@@ -1,6 +1,6 @@
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { Envelope } from '@prisma/client';
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
import type { Envelope, Field, Recipient } from '@prisma/client';
import { RecipientRole, SigningStatus } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { AppError, AppErrorCode } from '../errors/app-error';
@@ -15,6 +15,58 @@ import { zEmail } from './zod';
*/
export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as const;
// signingOrder isn't required when submitting the recipient form (Zod: z.number().optional())
type RecipientWithSigningOrder = Pick<Recipient, 'role'> & Partial<Pick<Recipient, 'signingOrder'>>;
export const isCcRecipient = (recipient: Pick<Recipient, 'role'>) => {
return recipient.role === RecipientRole.CC;
};
export const isAssistantLastSigner = (recipients: Pick<Recipient, 'role'>[]) => {
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
const lastNonCcRecipient = nonCcRecipients[nonCcRecipients.length - 1];
return lastNonCcRecipient?.role === RecipientRole.ASSISTANT;
};
export const sortRecipientsForSigningOrder = <T extends RecipientWithSigningOrder>(recipients: T[]): T[] => {
return [...recipients].sort((r1, r2) => {
const r1IsCcRecipient = isCcRecipient(r1);
const r2IsCcRecipient = isCcRecipient(r2);
// CC recipients always sort after non-CC recipients.
if (r1IsCcRecipient !== r2IsCcRecipient) {
return r1IsCcRecipient ? 1 : -1;
}
// Order by signing order; missing orders sort last.
const r1SigningOrder = r1.signingOrder ?? Number.MAX_SAFE_INTEGER;
const r2SigningOrder = r2.signingOrder ?? Number.MAX_SAFE_INTEGER;
return r1SigningOrder - r2SigningOrder;
});
};
export const normalizeRecipientSigningOrders = <T extends RecipientWithSigningOrder>(
recipients: T[],
canUpdateRecipient: (recipient: T) => boolean = () => true,
): Array<T & { signingOrder?: number }> => {
const nonCcRecipients = recipients.filter((recipient) => !isCcRecipient(recipient));
const ccRecipients = recipients.filter((recipient) => isCcRecipient(recipient));
const normalizedNonCcRecipients = nonCcRecipients.map((recipient, index) => ({
...recipient,
signingOrder: canUpdateRecipient(recipient) ? index + 1 : (recipient.signingOrder ?? index + 1),
}));
const normalizedCcRecipients = ccRecipients.map((recipient) => ({
...recipient,
signingOrder: undefined,
}));
return [...normalizedNonCcRecipients, ...normalizedCcRecipients];
};
/**
* Returns recipients who are missing required fields for their role.
*
+299
View File
@@ -0,0 +1,299 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import type { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import {
BracesIcon,
Building2Icon,
CreditCardIcon,
Globe2Icon,
GroupIcon,
LockIcon,
MailboxIcon,
Settings2Icon,
SettingsIcon,
ShieldCheckIcon,
UserIcon,
Users2Icon,
WebhookIcon,
} from 'lucide-react';
import type { ComponentType } from 'react';
import { FaUsers } from 'react-icons/fa6';
import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '../constants/app';
import { canExecuteOrganisationAction } from './organisations';
import { canExecuteTeamAction } from './teams';
export type SettingsNavScope = 'organisation' | 'team' | 'account';
export type SettingsNavItem = {
key: string;
path: string;
label: MessageDescriptor;
icon?: ComponentType<{ className?: string }>;
isSubNav?: boolean;
isSubNavParent?: boolean;
};
export type SettingsNavGroup = {
scope: SettingsNavScope;
items: SettingsNavItem[];
};
export type SettingsNavGroups = {
organisation: SettingsNavGroup | null;
team: SettingsNavGroup | null;
account: SettingsNavGroup;
};
export type GetSettingsNavGroupsArgs = {
organisation: {
url: string;
currentOrganisationRole: OrganisationMemberRole;
organisationClaim: { flags: { emailDomains?: boolean; authenticationPortal?: boolean } };
} | null;
team: {
url: string;
currentTeamRole: TeamMemberRole;
} | null;
hasManageableBillingOrgs: boolean;
};
/**
* Build the nav-group structure for the unified settings sidebar.
*
* Pure data helper — given a current organisation, optional current team, and the billing-enabled
* flag, returns the items that should appear in each scope group. Groups the user has no manage
* permission for are returned as `null` (not empty arrays) so consumers can branch on visibility.
*
* Item ordering, claim-flag gating, and which sections are scope-specific are encoded here as the
* single source of truth for the unified-settings sidebar.
*/
export const getSettingsNavGroups = ({
organisation,
team,
hasManageableBillingOrgs,
}: GetSettingsNavGroupsArgs): SettingsNavGroups => {
const isBillingEnabled = IS_BILLING_ENABLED();
const isDocumensoCloud = IS_DOCUMENSO_CLOUD();
const canManageOrg =
organisation !== null && canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole);
const canManageTeam = team !== null && canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole);
const orgGroup: SettingsNavGroup | null = canManageOrg
? {
scope: 'organisation',
items: [
{
key: 'general',
path: `/o/${organisation.url}/settings/general`,
label: msg`General`,
icon: Building2Icon,
},
{
key: 'preferences',
path: `/o/${organisation.url}/settings/document`,
label: msg`Preferences`,
icon: Settings2Icon,
isSubNavParent: true,
},
{
key: 'preferences-document',
path: `/o/${organisation.url}/settings/document`,
label: msg`General`,
isSubNav: true,
},
{
key: 'preferences-branding',
path: `/o/${organisation.url}/settings/branding`,
label: msg`Branding`,
isSubNav: true,
},
{
key: 'preferences-email',
path: `/o/${organisation.url}/settings/email`,
label: msg`Email`,
isSubNav: true,
},
{
key: 'preferences-reminders',
path: `/o/${organisation.url}/settings/reminders`,
label: msg`Reminders`,
isSubNav: true,
},
{
key: 'preferences-certificates',
path: `/o/${organisation.url}/settings/certificates`,
label: msg`Certificates`,
isSubNav: true,
},
...((isBillingEnabled && organisation.organisationClaim.flags.emailDomains) || isDocumensoCloud
? [
{
key: 'email-domains',
path: `/o/${organisation.url}/settings/email-domains`,
label: msg`Email Domains`,
icon: MailboxIcon,
},
]
: []),
{
key: 'teams',
path: `/o/${organisation.url}/settings/teams`,
label: msg`Teams`,
icon: FaUsers,
},
{
key: 'members',
path: `/o/${organisation.url}/settings/members`,
label: msg`Members`,
icon: Users2Icon,
},
{
key: 'groups',
path: `/o/${organisation.url}/settings/groups`,
label: msg`Groups`,
icon: GroupIcon,
},
...((isBillingEnabled && organisation.organisationClaim.flags.authenticationPortal) || isDocumensoCloud
? [
{
key: 'sso',
path: `/o/${organisation.url}/settings/sso`,
label: msg`SSO`,
icon: ShieldCheckIcon,
},
]
: []),
...(isBillingEnabled
? [
{
key: 'billing',
path: `/o/${organisation.url}/settings/billing`,
label: msg`Billing`,
icon: CreditCardIcon,
},
]
: []),
],
}
: null;
const teamGroup: SettingsNavGroup | null =
canManageTeam && team
? {
scope: 'team',
items: [
{
key: 'general',
path: `/t/${team.url}/settings/general`,
label: msg`General`,
icon: SettingsIcon,
},
{
key: 'preferences',
path: `/t/${team.url}/settings/document`,
label: msg`Preferences`,
icon: Settings2Icon,
isSubNavParent: true,
},
{
key: 'preferences-document',
path: `/t/${team.url}/settings/document`,
label: msg`General`,
isSubNav: true,
},
{
key: 'preferences-branding',
path: `/t/${team.url}/settings/branding`,
label: msg`Branding`,
isSubNav: true,
},
{
key: 'preferences-email',
path: `/t/${team.url}/settings/email`,
label: msg`Email`,
isSubNav: true,
},
{
key: 'preferences-reminders',
path: `/t/${team.url}/settings/reminders`,
label: msg`Reminders`,
isSubNav: true,
},
{
key: 'preferences-certificates',
path: `/t/${team.url}/settings/certificates`,
label: msg`Certificates`,
isSubNav: true,
},
{
key: 'members',
path: `/t/${team.url}/settings/members`,
label: msg`Members`,
icon: Users2Icon,
},
{
key: 'groups',
path: `/t/${team.url}/settings/groups`,
label: msg`Groups`,
icon: GroupIcon,
},
{
key: 'public-profile',
path: `/t/${team.url}/settings/public-profile`,
label: msg`Public Profile`,
icon: Globe2Icon,
},
{
key: 'tokens',
path: `/t/${team.url}/settings/tokens`,
label: msg`API Tokens`,
icon: BracesIcon,
},
{
key: 'webhooks',
path: `/t/${team.url}/settings/webhooks`,
label: msg`Webhooks`,
icon: WebhookIcon,
},
],
}
: null;
const accountGroup: SettingsNavGroup = {
scope: 'account',
items: [
{
key: 'profile',
path: '/settings/profile',
label: msg`Profile`,
icon: UserIcon,
},
{
key: 'organisations',
path: '/settings/organisations',
label: msg`Organisations`,
icon: Building2Icon,
},
{
key: 'security',
path: '/settings/security',
label: msg`Security`,
icon: LockIcon,
},
...(IS_BILLING_ENABLED() && hasManageableBillingOrgs
? [
{
key: 'billing',
path: '/settings/billing',
label: msg`Billing`,
icon: CreditCardIcon,
},
]
: []),
],
};
return { organisation: orgGroup, team: teamGroup, account: accountGroup };
};
+57
View File
@@ -0,0 +1,57 @@
export type ComputeSwitcherContinuityPathArgs = {
currentPath: string;
/**
* Every settings path navigable in the destination scope — pass the destination's
* `SettingsNavGroup.items` paths from `getSettingsNavGroups`.
*
* Sourcing these from the nav builder (rather than a hardcoded list) means the switcher
* can only ever land on a page that is actually reachable there: permission and
* claim/billing gating are already applied when the group is built.
*/
destinationPaths: string[];
/** Where to land when the current section has no equivalent in the destination. */
fallbackPath: string;
};
/**
* Extract the "section" portion of a settings URL. Returns null if not a scoped
* settings URL (account settings and non-settings pages have no equivalent to carry over).
*
* Examples:
* /o/acme/settings/members → 'members'
* /o/acme/settings → 'general' (bare index redirects to General)
* /t/eng/settings/webhooks/42 → 'webhooks'
* /o/acme/documents → null
* /settings/profile → null
*/
const parseSettingsSection = (path: string): string | null => {
const match = /^\/[ot]\/[^/]+\/settings(?:\/([^/?#]+))?(?:[/?#]|$)/.exec(path);
if (!match) {
return null;
}
return match[1] ?? 'general';
};
/**
* Compute the destination URL when the user switches organisation or team via the
* unified settings sidebar's switcher.
*
* Rule: stay on the same section if the destination has one; else use the fallback.
*/
export const computeSwitcherContinuityPath = ({
currentPath,
destinationPaths,
fallbackPath,
}: ComputeSwitcherContinuityPathArgs): string => {
const currentSection = parseSettingsSection(currentPath);
if (!currentSection) {
return fallbackPath;
}
return destinationPaths.find((path) => parseSettingsSection(path) === currentSection) ?? fallbackPath;
};
+3
View File
@@ -1,6 +1,9 @@
import macrosPlugin from 'vite-plugin-babel-macros';
import { defineConfig } from 'vitest/config';
export default defineConfig({
// Transform lingui macros (e.g. `msg`) used by the code under test.
plugins: [macrosPlugin()],
test: {
include: ['**/*.test.ts'],
},