mirror of
https://github.com/documenso/documenso.git
synced 2026-08-23 14:52:23 +10:00
Merge branch 'main' into duncan/signing-reason-env
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const PREFERRED_TEAM_URL_COOKIE = 'preferred-team-url';
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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,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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
+723
-315
File diff suppressed because it is too large
Load Diff
+723
-315
File diff suppressed because it is too large
Load Diff
+723
-315
File diff suppressed because it is too large
Load Diff
+724
-316
File diff suppressed because it is too large
Load Diff
+723
-315
File diff suppressed because it is too large
Load Diff
+723
-315
File diff suppressed because it is too large
Load Diff
+723
-315
File diff suppressed because it is too large
Load Diff
+723
-315
File diff suppressed because it is too large
Load Diff
+738
-330
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+723
-315
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user