mirror of
https://github.com/documenso/documenso.git
synced 2026-08-26 00:02:43 +10:00
Merge branch 'main' into fix/signature-uploads
This commit is contained in:
@@ -437,3 +437,98 @@ test('[ADMIN][GLOBAL_SEARCH]: over-length query skips the admin search without e
|
||||
|
||||
expect(adminSearchRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: capped recipients group links to the admin documents page', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const recipientPrefix = `viewall-recipient-${nanoid()}`;
|
||||
|
||||
// Seed 5 documents, each with one recipient email sharing the prefix, to
|
||||
// hit the 5 result cap on the recipients group.
|
||||
const documents = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
documents.push(
|
||||
await seedPendingDocument(sender, team.id, [`${recipientPrefix}-${i}@test.documenso.com`], {
|
||||
createDocumentOptions: { title: `recipient-viewall-${nanoid()}` },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(recipientPrefix);
|
||||
|
||||
await expect(page.getByText('Global Recipients', { exact: true })).toBeVisible();
|
||||
|
||||
// Only the recipients group matches the prefix, so this is its link.
|
||||
const viewAllOption = page.getByRole('option').filter({ hasText: 'View all results' }).first();
|
||||
|
||||
await expect(viewAllOption.getByRole('link')).toHaveAttribute(
|
||||
'href',
|
||||
`/admin/documents?term=${encodeURIComponent(`recipient:${recipientPrefix}`)}`,
|
||||
);
|
||||
|
||||
await viewAllOption.click();
|
||||
|
||||
await page.waitForURL((url) => url.pathname === '/admin/documents');
|
||||
|
||||
// The term input is prefilled with the recipient query and the matching
|
||||
// documents are listed.
|
||||
await expect(page.getByPlaceholder(/Search by document title/)).toHaveValue(`recipient:${recipientPrefix}`);
|
||||
|
||||
for (const document of documents) {
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: view all results updates the documents page when already on it', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const recipientPrefix = `viewall-live-${nanoid()}`;
|
||||
|
||||
// Seed 5 documents with recipients matching the prefix to hit the cap, and
|
||||
// one control document whose recipient does not match: with a stale
|
||||
// (unfiltered) query the control would show, with the filter it must not.
|
||||
const documents = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
documents.push(
|
||||
await seedPendingDocument(sender, team.id, [`${recipientPrefix}-${i}@test.documenso.com`], {
|
||||
createDocumentOptions: { title: `recipient-live-${nanoid()}` },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const controlDocument = await seedPendingDocument(sender, team.id, [`control-${nanoid()}@test.documenso.com`], {
|
||||
createDocumentOptions: { title: `recipient-live-control-${nanoid()}` },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// Start ON the admin documents page: the buggy state initializer has
|
||||
// already run with an empty term.
|
||||
await page.goto('/admin/documents');
|
||||
|
||||
await expect(page.getByPlaceholder(/Search by document title/)).toBeVisible();
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(recipientPrefix);
|
||||
|
||||
await expect(page.getByText('Global Recipients', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('option').filter({ hasText: 'View all results' }).first().click();
|
||||
|
||||
await page.waitForURL((url) => url.searchParams.get('term') === `recipient:${recipientPrefix}`);
|
||||
|
||||
// The same-route navigation must update both the input and the results.
|
||||
await expect(page.getByPlaceholder(/Search by document title/)).toHaveValue(`recipient:${recipientPrefix}`);
|
||||
|
||||
await expect(page.getByRole('link', { name: documents[0].title })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: controlDocument.title })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { apiSignin } from '../../../fixtures/authentication';
|
||||
|
||||
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
type AdminFindDocumentsResult = {
|
||||
data: Array<{ envelopeId: string; title: string }>;
|
||||
count: number;
|
||||
};
|
||||
|
||||
const callAdminFindDocuments = async (page: Page, query: string) => {
|
||||
const inputParam = encodeURIComponent(JSON.stringify({ json: { query, page: 1, perPage: 20 } }));
|
||||
const url = `${WEBAPP_BASE_URL}/api/trpc/admin.document.find?input=${inputParam}`;
|
||||
|
||||
const res = await page.context().request.get(url);
|
||||
|
||||
return {
|
||||
res,
|
||||
result: res.ok()
|
||||
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
((await res.json()).result.data.json as AdminFindDocumentsResult)
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Access control ──────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: non-admin user is rejected with 401', async ({ page }) => {
|
||||
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
|
||||
|
||||
await apiSignin({ page, email: nonAdminUser.email });
|
||||
|
||||
const { res } = await callAdminFindDocuments(page, 'recipient:anything');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
// ─── recipient: prefix ───────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix matches by recipient email', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const recipientEmail = `recipient-find-${nanoid()}@test.documenso.com`;
|
||||
|
||||
const matchingDocument = await seedPendingDocument(sender, team.id, [recipientEmail], {
|
||||
createDocumentOptions: { title: `recipient-find-match-${nanoid()}` },
|
||||
});
|
||||
|
||||
const otherDocument = await seedPendingDocument(sender, team.id, [`other-${nanoid()}@test.documenso.com`], {
|
||||
createDocumentOptions: { title: `recipient-find-other-${nanoid()}` },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { res, result } = await callAdminFindDocuments(page, `recipient:${recipientEmail}`);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.count).toBe(1);
|
||||
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
|
||||
expect(result?.data.map((document) => document.envelopeId)).not.toContain(otherDocument.id);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix matches by recipient name case-insensitively', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const recipientName = `recipient-name-${nanoid()}`;
|
||||
const { user: recipientUser } = await seedUser({ name: recipientName });
|
||||
|
||||
const matchingDocument = await seedPendingDocument(sender, team.id, [recipientUser], {
|
||||
createDocumentOptions: { title: `recipient-name-match-${nanoid()}` },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// Query the uppercased name: matching must be case-insensitive.
|
||||
const { res, result } = await callAdminFindDocuments(page, `recipient:${recipientName.toUpperCase()}`);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.count).toBe(1);
|
||||
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with empty value returns no results', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const emptyValueSearch = await callAdminFindDocuments(page, 'recipient:');
|
||||
|
||||
expect(emptyValueSearch.res.ok()).toBeTruthy();
|
||||
expect(emptyValueSearch.result?.data).toEqual([]);
|
||||
expect(emptyValueSearch.result?.count).toBe(0);
|
||||
|
||||
// Whitespace-only values are treated the same as empty.
|
||||
const whitespaceValueSearch = await callAdminFindDocuments(page, 'recipient: ');
|
||||
|
||||
expect(whitespaceValueSearch.res.ok()).toBeTruthy();
|
||||
expect(whitespaceValueSearch.result?.data).toEqual([]);
|
||||
expect(whitespaceValueSearch.result?.count).toBe(0);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with no matches returns no results', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { res, result } = await callAdminFindDocuments(page, 'recipient:zzzz-no-such-recipient-9x7q');
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.data).toEqual([]);
|
||||
expect(result?.count).toBe(0);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with numeric value matches by exact recipient ID', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const matchingDocument = await seedPendingDocument(sender, team.id, [`recipient-id-${nanoid()}@test.documenso.com`], {
|
||||
createDocumentOptions: { title: `recipient-id-match-${nanoid()}` },
|
||||
});
|
||||
|
||||
const recipient = matchingDocument.recipients[0];
|
||||
|
||||
// A decoy whose recipient email contains the ID as text: an exact ID lookup
|
||||
// must exclude it, while an accidental "ID or contains" match would not.
|
||||
const decoyDocument = await seedPendingDocument(
|
||||
sender,
|
||||
team.id,
|
||||
[`decoy-${recipient.id}-${nanoid()}@test.documenso.com`],
|
||||
{
|
||||
createDocumentOptions: { title: `recipient-id-decoy-${nanoid()}` },
|
||||
},
|
||||
);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { res, result } = await callAdminFindDocuments(page, `recipient:${recipient.id}`);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.count).toBe(1);
|
||||
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
|
||||
expect(result?.data.map((document) => document.envelopeId)).not.toContain(decoyDocument.id);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with nonexistent recipient ID returns no results', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// Int4 max - 1: a valid ID-shaped number that no autoincrement recipient
|
||||
// sequence will plausibly reach, and that no other test seeds as text.
|
||||
const { res, result } = await callAdminFindDocuments(page, 'recipient:2147483646');
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.data).toEqual([]);
|
||||
expect(result?.count).toBe(0);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with oversized number falls back to text search', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
// 99999999999999 exceeds Int4, so it cannot be an ID lookup: it must be
|
||||
// treated as text (and must not 500).
|
||||
const oversizedNumber = '99999999999999';
|
||||
|
||||
const matchingDocument = await seedPendingDocument(
|
||||
sender,
|
||||
team.id,
|
||||
[`${oversizedNumber}-${nanoid()}@test.documenso.com`],
|
||||
{
|
||||
createDocumentOptions: { title: `recipient-oversized-${nanoid()}` },
|
||||
},
|
||||
);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { res, result } = await callAdminFindDocuments(page, `recipient:${oversizedNumber}`);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: user and team prefixes with oversized numbers return no results', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// 99999999999999 exceeds Int4, so it can never be a valid user or team ID.
|
||||
// The ID schema must reject it so the query returns empty instead of
|
||||
// overflowing Postgres and erroring.
|
||||
const userSearch = await callAdminFindDocuments(page, 'user:99999999999999');
|
||||
|
||||
expect(userSearch.res.ok()).toBeTruthy();
|
||||
expect(userSearch.result?.data).toEqual([]);
|
||||
expect(userSearch.result?.count).toBe(0);
|
||||
|
||||
const teamSearch = await callAdminFindDocuments(page, 'team:99999999999999');
|
||||
|
||||
expect(teamSearch.res.ok()).toBeTruthy();
|
||||
expect(teamSearch.result?.data).toEqual([]);
|
||||
expect(teamSearch.result?.count).toBe(0);
|
||||
});
|
||||
@@ -19,7 +19,7 @@
|
||||
"arctic": "^3.7.0",
|
||||
"hono": "^4.12.14",
|
||||
"luxon": "^3.7.2",
|
||||
"react": "^18",
|
||||
"react": "^19.2.7",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
export { Body } from '@react-email/body';
|
||||
export { Button } from '@react-email/button';
|
||||
export { Column } from '@react-email/column';
|
||||
export { Container } from '@react-email/container';
|
||||
export { Font } from '@react-email/font';
|
||||
export { Head } from '@react-email/head';
|
||||
export { Heading } from '@react-email/heading';
|
||||
export { Hr } from '@react-email/hr';
|
||||
export { Html } from '@react-email/html';
|
||||
export { Img } from '@react-email/img';
|
||||
export { Link } from '@react-email/link';
|
||||
export { Preview } from '@react-email/preview';
|
||||
export { render } from '@react-email/render';
|
||||
export { Row } from '@react-email/row';
|
||||
export { Section } from '@react-email/section';
|
||||
export { Tailwind } from '@react-email/tailwind';
|
||||
export { Text } from '@react-email/text';
|
||||
export {
|
||||
Body,
|
||||
Button,
|
||||
Column,
|
||||
Container,
|
||||
Font,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
render,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from 'react-email';
|
||||
|
||||
@@ -19,27 +19,9 @@
|
||||
"dependencies": {
|
||||
"@documenso/nodemailer-resend": "5.0.0",
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@react-email/body": "0.2.0",
|
||||
"@react-email/button": "0.2.0",
|
||||
"@react-email/code-block": "0.2.0",
|
||||
"@react-email/code-inline": "0.0.5",
|
||||
"@react-email/column": "0.0.13",
|
||||
"@react-email/container": "0.0.15",
|
||||
"@react-email/font": "0.0.9",
|
||||
"@react-email/head": "0.0.12",
|
||||
"@react-email/heading": "0.0.15",
|
||||
"@react-email/hr": "0.0.11",
|
||||
"@react-email/html": "0.0.11",
|
||||
"@react-email/img": "0.0.11",
|
||||
"@react-email/link": "0.0.12",
|
||||
"@react-email/preview": "0.0.13",
|
||||
"@react-email/render": "2.0.0",
|
||||
"@react-email/row": "0.0.12",
|
||||
"@react-email/section": "0.0.16",
|
||||
"@react-email/tailwind": "^2.0.1",
|
||||
"@react-email/text": "0.1.5",
|
||||
"@react-email/render": "2.1.0",
|
||||
"nodemailer": "^9.0.0",
|
||||
"react-email": "^5.0.6",
|
||||
"react-email": "^6.9.0",
|
||||
"resend": "^6.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -43,6 +43,20 @@ export enum AppErrorCode {
|
||||
*/
|
||||
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
|
||||
@@ -99,6 +113,8 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
||||
[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 },
|
||||
@@ -307,6 +323,8 @@ export class AppError extends Error {
|
||||
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,
|
||||
|
||||
@@ -93,7 +93,7 @@ export const run = async ({ payload }: { payload: TSendDocumentPendingEmailJobDe
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Waiting for others to complete signing.`),
|
||||
subject: i18n._(msg`Waiting for others to complete signing`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
@@ -64,10 +65,9 @@
|
||||
"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"
|
||||
|
||||
@@ -10,7 +10,13 @@ export interface AdminFindDocumentsOptions {
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
const ZPositiveIntegerSchema = z.coerce.number().int().positive();
|
||||
const MAX_POSTGRES_INT = 2147483647;
|
||||
|
||||
/**
|
||||
* IDs are Postgres int4 columns: values above the range can never be valid
|
||||
* IDs and would make Prisma throw on overflow, so the schema rejects them.
|
||||
*/
|
||||
const ZPositiveIntegerSchema = z.coerce.number().int().positive().max(MAX_POSTGRES_INT);
|
||||
|
||||
const emptyResponse = {
|
||||
data: [],
|
||||
@@ -58,7 +64,41 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
|
||||
}
|
||||
}
|
||||
|
||||
if (query && query?.startsWith('envelope_')) {
|
||||
if (query?.startsWith('recipient:')) {
|
||||
const recipientQuery = query.slice('recipient:'.length).trim();
|
||||
|
||||
if (recipientQuery.length === 0) {
|
||||
return emptyResponse;
|
||||
}
|
||||
|
||||
// Bare numeric values are exact recipient ID lookups, consistent with the
|
||||
// user: and team: prefixes. Oversized numbers cannot be IDs and fall back
|
||||
// to the text search, mirroring the admin global search.
|
||||
const parsedRecipientId = ZPositiveIntegerSchema.safeParse(recipientQuery);
|
||||
|
||||
if (/^\d+$/.test(recipientQuery) && parsedRecipientId.success) {
|
||||
termFilters = {
|
||||
recipients: {
|
||||
some: {
|
||||
id: parsedRecipientId.data,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
termFilters = {
|
||||
recipients: {
|
||||
some: {
|
||||
OR: [
|
||||
{ email: { contains: recipientQuery, mode: 'insensitive' } },
|
||||
{ name: { contains: recipientQuery, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (query?.startsWith('envelope_')) {
|
||||
termFilters = {
|
||||
id: {
|
||||
equals: query,
|
||||
@@ -66,7 +106,7 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
|
||||
};
|
||||
}
|
||||
|
||||
if (query && query?.startsWith('document_')) {
|
||||
if (query?.startsWith('document_')) {
|
||||
termFilters = {
|
||||
secondaryId: {
|
||||
equals: query,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,10 +78,23 @@ export const completeDocumentWithToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -98,7 +111,19 @@ export const completeDocumentWithToken = async ({
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new Error(`Document ${envelope.id} must be 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);
|
||||
@@ -116,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +307,10 @@ 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) => {
|
||||
|
||||
@@ -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,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,9 +1,12 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { hashString } from '../auth/hash';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
|
||||
const LAST_USED_AT_UPDATE_INTERVAL = 60_000; // 1 minute
|
||||
|
||||
type GetApiTokenByTokenOptions = {
|
||||
token: string;
|
||||
|
||||
@@ -96,6 +99,29 @@ export const getApiTokenByToken = async ({ token, bypassRateLimit = false }: Get
|
||||
});
|
||||
}
|
||||
|
||||
// Only update the lastUsedAt after X amount of time has passed to reduce
|
||||
// the number of writes to the database
|
||||
if (!apiToken.lastUsedAt || apiToken.lastUsedAt.getTime() + LAST_USED_AT_UPDATE_INTERVAL < Date.now()) {
|
||||
void prisma.apiToken
|
||||
.updateMany({
|
||||
where: {
|
||||
id: apiToken.id,
|
||||
// Optimistic guard: skip if another request beat us
|
||||
lastUsedAt: apiToken.lastUsedAt,
|
||||
},
|
||||
data: {
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn({
|
||||
msg: 'Failed to update API token lastUsedAt',
|
||||
apiTokenId: apiToken.id,
|
||||
err,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...apiToken,
|
||||
user,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const getApiTokens = async ({ userId, teamId }: GetApiTokensOptions) => {
|
||||
name: true,
|
||||
createdAt: true,
|
||||
expires: true,
|
||||
lastUsedAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
|
||||
+147
-147
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ let SkiaImage: any;
|
||||
|
||||
void (async () => {
|
||||
if (typeof window === 'undefined') {
|
||||
const mod = await import('skia-canvas');
|
||||
const mod = await import('@documenso/skia-canvas');
|
||||
SkiaImage = mod.Image;
|
||||
}
|
||||
})();
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ApiToken" ADD COLUMN "lastUsedAt" TIMESTAMP(3);
|
||||
@@ -220,16 +220,17 @@ enum ApiTokenAlgorithm {
|
||||
}
|
||||
|
||||
model ApiToken {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
token String @unique
|
||||
algorithm ApiTokenAlgorithm @default(SHA512)
|
||||
expires DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
userId Int?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
teamId Int
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
token String @unique
|
||||
algorithm ApiTokenAlgorithm @default(SHA512)
|
||||
expires DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
lastUsedAt DateTime?
|
||||
userId Int?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
teamId Int
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
"@documenso/prisma": "*",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tanstack/react-query": "5.90.10",
|
||||
"@trpc/client": "11.8.1",
|
||||
"@trpc/react-query": "11.8.1",
|
||||
"@trpc/server": "11.8.1",
|
||||
"@trpc/client": "11.17.0",
|
||||
"@trpc/react-query": "11.17.0",
|
||||
"@trpc/server": "11.17.0",
|
||||
"@ts-rest/core": "^3.52.1",
|
||||
"formidable": "^3.5.4",
|
||||
"luxon": "^3.7.2",
|
||||
|
||||
@@ -9,6 +9,7 @@ export const ZGetApiTokensResponseSchema = z.array(
|
||||
name: true,
|
||||
createdAt: true,
|
||||
expires: true,
|
||||
lastUsedAt: true,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -8,13 +8,14 @@ export const cancelEnvelopeMeta: TrpcRouteMeta = {
|
||||
method: 'POST',
|
||||
path: '/envelope/cancel',
|
||||
summary: 'Cancel envelope',
|
||||
description: 'Cancel a pending envelope',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZCancelEnvelopeRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
reason: z.string().optional(),
|
||||
envelopeId: z.string().describe('The ID of the envelope to cancel.'),
|
||||
reason: z.string().describe('The reason for cancelling the envelope.').optional(),
|
||||
});
|
||||
|
||||
export const ZCancelEnvelopeResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import type { Logger } from 'pino';
|
||||
import { match, P } from 'ts-pattern';
|
||||
|
||||
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
@@ -148,19 +149,11 @@ export const createEnvelopeRouteCaller = async ({
|
||||
accessAuth: recipient.accessAuth,
|
||||
actionAuth: recipient.actionAuth,
|
||||
fields: recipient.fields?.map((field) => {
|
||||
let documentDataId: string | undefined;
|
||||
|
||||
if (typeof field.identifier === 'string') {
|
||||
documentDataId = envelopeItems.find((item) => item.title === field.identifier)?.documentDataId;
|
||||
}
|
||||
|
||||
if (typeof field.identifier === 'number') {
|
||||
documentDataId = envelopeItems.at(field.identifier)?.documentDataId;
|
||||
}
|
||||
|
||||
if (field.identifier === undefined) {
|
||||
documentDataId = envelopeItems.at(0)?.documentDataId;
|
||||
}
|
||||
const documentDataId = match(field.identifier)
|
||||
.with(P.string, (title) => envelopeItems.find((item) => item.title === title)?.documentDataId)
|
||||
.with(P.number, (index) => envelopeItems.at(index)?.documentDataId)
|
||||
.with(undefined, () => envelopeItems.at(0)?.documentDataId)
|
||||
.exhaustive();
|
||||
|
||||
if (!documentDataId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
|
||||
@@ -8,12 +8,13 @@ export const deleteEnvelopeMeta: TrpcRouteMeta = {
|
||||
method: 'POST',
|
||||
path: '/envelope/delete',
|
||||
summary: 'Delete envelope',
|
||||
description: 'Delete an envelope',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZDeleteEnvelopeRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
envelopeId: z.string().describe('The ID of the envelope to delete.'),
|
||||
});
|
||||
|
||||
export const ZDeleteEnvelopeResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
@@ -39,8 +39,8 @@ export const signEnvelopeFieldRoute = procedure
|
||||
const field = await prisma.field.findFirst({
|
||||
where: {
|
||||
id: fieldId,
|
||||
recipient: {
|
||||
...(recipient.role === RecipientRole.ASSISTANT
|
||||
recipient:
|
||||
recipient.role === RecipientRole.ASSISTANT
|
||||
? {
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
@@ -52,8 +52,7 @@ export const signEnvelopeFieldRoute = procedure
|
||||
}
|
||||
: {
|
||||
id: recipient.id,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
envelope: {
|
||||
|
||||
@@ -12,24 +12,32 @@ export const updateEnvelopeMeta: TrpcRouteMeta = {
|
||||
method: 'POST',
|
||||
path: '/envelope/update',
|
||||
summary: 'Update envelope',
|
||||
description: 'Update envelope properties and settings',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZUpdateEnvelopeRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
envelopeId: z.string().describe('The ID of the envelope to update.'),
|
||||
data: z
|
||||
.object({
|
||||
title: ZDocumentTitleSchema.optional(),
|
||||
externalId: ZDocumentExternalIdSchema.nullish(),
|
||||
visibility: ZDocumentVisibilitySchema.optional(),
|
||||
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
|
||||
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
|
||||
folderId: z.string().nullish(),
|
||||
templateType: z.nativeEnum(TemplateType).optional(),
|
||||
globalAccessAuth: z
|
||||
.array(ZDocumentAccessAuthTypesSchema)
|
||||
.describe('The authentication methods required to access the envelope.')
|
||||
.optional(),
|
||||
globalActionAuth: z
|
||||
.array(ZDocumentActionAuthTypesSchema)
|
||||
.describe('The authentication methods required to sign the envelope.')
|
||||
.optional(),
|
||||
folderId: z.string().describe('The ID of the folder containing the envelope.').nullish(),
|
||||
templateType: z.nativeEnum(TemplateType).describe('The template type.').optional(),
|
||||
})
|
||||
.describe('The envelope properties to update.')
|
||||
.optional(),
|
||||
meta: ZDocumentMetaUpdateSchema.optional(),
|
||||
meta: ZDocumentMetaUpdateSchema.describe('The email and signing settings to update.').optional(),
|
||||
});
|
||||
|
||||
export const ZUpdateEnvelopeResponseSchema = ZEnvelopeLiteSchema;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { match, P } from 'ts-pattern';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { useEnvelopeMeta, ZUseEnvelopeRequestSchema, ZUseEnvelopeResponseSchema } from './use-envelope.types';
|
||||
@@ -87,20 +88,11 @@ export const useEnvelopeRoute = authenticatedProcedure
|
||||
|
||||
// Map custom document data using identifiers
|
||||
const customDocumentDataMapped = customDocumentData?.map((mapping) => {
|
||||
let documentDataId: string | undefined;
|
||||
|
||||
// Find the uploaded file by identifier
|
||||
if (typeof mapping.identifier === 'string') {
|
||||
documentDataId = uploadedFiles.find((file) => file.name === mapping.identifier)?.documentDataId;
|
||||
}
|
||||
|
||||
if (typeof mapping.identifier === 'number') {
|
||||
documentDataId = uploadedFiles.at(mapping.identifier)?.documentDataId;
|
||||
}
|
||||
|
||||
if (mapping.identifier === undefined) {
|
||||
documentDataId = uploadedFiles.at(0)?.documentDataId;
|
||||
}
|
||||
const documentDataId = match(mapping.identifier)
|
||||
.with(P.string, (name) => uploadedFiles.find((file) => file.name === name)?.documentDataId)
|
||||
.with(P.number, (index) => uploadedFiles.at(index)?.documentDataId)
|
||||
.exhaustive();
|
||||
|
||||
if (!documentDataId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
|
||||
@@ -603,7 +603,7 @@ export const recipientRouter = router({
|
||||
// can't complete via this route — they go through the CSC sync sign
|
||||
// flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL
|
||||
// for the credential-scope OAuth round-trip.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT),
|
||||
recipients: { some: { token } },
|
||||
@@ -611,6 +611,16 @@ export const recipientRouter = router({
|
||||
select: { signatureLevel: true, internalVersion: true },
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
if (isTspEnvelope(envelope)) {
|
||||
return await prepareCscRecipientSigning({
|
||||
recipientToken: token,
|
||||
|
||||
@@ -2,16 +2,27 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { ClockIcon, EyeOffIcon, LockIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { FieldType, SigningStatus } from '@prisma/client';
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
CheckSquareIcon,
|
||||
ChevronDownIcon,
|
||||
ContactIcon,
|
||||
DiscIcon,
|
||||
EyeOffIcon,
|
||||
HashIcon,
|
||||
LockIcon,
|
||||
MailIcon,
|
||||
TypeIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
import { type ElementType, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { isTemplateRecipientEmailPlaceholder } from '../../../lib/constants/template';
|
||||
import { extractInitials } from '../../../lib/utils/recipient-formatter';
|
||||
import { SignatureIcon } from '../../icons/signature';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Avatar, AvatarFallback } from '../../primitives/avatar';
|
||||
import { Badge } from '../../primitives/badge';
|
||||
import { FRIENDLY_FIELD_TYPE } from '../../primitives/document-flow/types';
|
||||
import { PopoverHover } from '../../primitives/popover';
|
||||
|
||||
@@ -27,16 +38,18 @@ interface EnvelopeRecipientFieldTooltipProps {
|
||||
showRecipientColors?: boolean;
|
||||
}
|
||||
|
||||
const getRecipientDisplayText = (recipient: { name: string; email: string }) => {
|
||||
if (recipient.name && !isTemplateRecipientEmailPlaceholder(recipient.email)) {
|
||||
return `${recipient.name} (${recipient.email})`;
|
||||
}
|
||||
|
||||
if (recipient.name && isTemplateRecipientEmailPlaceholder(recipient.email)) {
|
||||
return recipient.name;
|
||||
}
|
||||
|
||||
return recipient.email;
|
||||
const FIELD_TYPE_ICONS: Record<FieldType, ElementType> = {
|
||||
[FieldType.SIGNATURE]: SignatureIcon,
|
||||
[FieldType.FREE_SIGNATURE]: SignatureIcon,
|
||||
[FieldType.INITIALS]: ContactIcon,
|
||||
[FieldType.TEXT]: TypeIcon,
|
||||
[FieldType.DATE]: CalendarDaysIcon,
|
||||
[FieldType.EMAIL]: MailIcon,
|
||||
[FieldType.NAME]: UserIcon,
|
||||
[FieldType.NUMBER]: HashIcon,
|
||||
[FieldType.RADIO]: DiscIcon,
|
||||
[FieldType.CHECKBOX]: CheckSquareIcon,
|
||||
[FieldType.DROPDOWN]: ChevronDownIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -50,6 +63,8 @@ export function EnvelopeRecipientFieldTooltip({
|
||||
}: EnvelopeRecipientFieldTooltipProps) {
|
||||
const { t } = useLingui();
|
||||
|
||||
const FieldIcon = FIELD_TYPE_ICONS[field.type];
|
||||
|
||||
const [hideField, setHideField] = useState<boolean>(!showRecipientTooltip);
|
||||
|
||||
const [coords, setCoords] = useState({
|
||||
@@ -138,54 +153,64 @@ export function EnvelopeRecipientFieldTooltip({
|
||||
</Avatar>
|
||||
}
|
||||
contentProps={{
|
||||
className: 'relative flex mb-4 w-fit flex-col p-4 text-sm',
|
||||
className: 'flex w-64 flex-col overflow-hidden p-0 text-sm',
|
||||
sideOffset: 20,
|
||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||
}}
|
||||
>
|
||||
{showFieldStatus && (
|
||||
<Badge
|
||||
className="mx-auto mb-1 py-0.5"
|
||||
variant={
|
||||
field?.fieldMeta?.readOnly
|
||||
? 'neutral'
|
||||
: field.recipient.signingStatus === SigningStatus.SIGNED
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{field?.fieldMeta?.readOnly ? (
|
||||
<>
|
||||
<LockIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Read Only</Trans>
|
||||
</>
|
||||
) : field.recipient.signingStatus === SigningStatus.SIGNED ? (
|
||||
<>
|
||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Signed</Trans>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ClockIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Pending</Trans>
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<FieldIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<p className="text-center font-semibold">
|
||||
<span>
|
||||
<p className="min-w-0 flex-1 truncate font-medium">
|
||||
<Trans>{t(FRIENDLY_FIELD_TYPE[field.type])} field</Trans>
|
||||
</span>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-center text-muted-foreground text-xs">{getRecipientDisplayText(field.recipient)}</p>
|
||||
{showFieldStatus && (
|
||||
<div className="flex shrink-0 items-center gap-1.5 text-xs">
|
||||
{field?.fieldMeta?.readOnly ? (
|
||||
<>
|
||||
<LockIcon className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
<Trans>Read Only</Trans>
|
||||
</span>
|
||||
</>
|
||||
) : field.recipient.signingStatus === SigningStatus.SIGNED ? (
|
||||
<>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
<Trans>Signed</Trans>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
<Trans>Pending</Trans>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="absolute top-0 right-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
|
||||
onClick={() => setHideField(true)}
|
||||
title="Hide field"
|
||||
>
|
||||
<EyeOffIcon className="h-3 w-3" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3 border-border/50 border-t bg-muted/50 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-xs">{field.recipient.name || field.recipient.email}</p>
|
||||
|
||||
{!isTemplateRecipientEmailPlaceholder(field.recipient.email) && field.recipient.name && (
|
||||
<p className="truncate text-muted-foreground text-xs">{field.recipient.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="-m-1 shrink-0 rounded-sm p-1 text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
onClick={() => setHideField(true)}
|
||||
title={t`Hide field`}
|
||||
>
|
||||
<EyeOffIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</PopoverHover>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ export const SigningCard3D = ({ className, name, signature, signingCelebrationIm
|
||||
|
||||
const [trackMouse, setTrackMouse] = useState(false);
|
||||
|
||||
const timeoutRef = useRef<number | undefined>();
|
||||
const timeoutRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const cardX = useMotionValue(0);
|
||||
const cardY = useMotionValue(0);
|
||||
|
||||
@@ -18,59 +18,32 @@
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/tsconfig": "*",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/react": "18.3.27",
|
||||
"@types/react-dom": "^18",
|
||||
"react": "^18",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"react": "^19.2.7",
|
||||
"typescript": "5.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@hello-pangea/dnd": "^16.6.0",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hookform/resolvers": "^3",
|
||||
"@lingui/macro": "^5.6.0",
|
||||
"@lingui/react": "^5.6.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@scure/base": "^1.2.6",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^1.2.1",
|
||||
"cmdk": "^0.2.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"colord": "^2.9.3",
|
||||
"framer-motion": "^12.43.0",
|
||||
"lucide-react": "^0.554.0",
|
||||
"luxon": "^3.7.2",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"perfect-freehand": "^1.2.2",
|
||||
"react": "^18",
|
||||
"react": "^19.2.7",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-rnd": "^10.5.2",
|
||||
"remeda": "^2.32.0",
|
||||
|
||||
@@ -145,7 +145,9 @@ const CommandItem = React.forwardRef<
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
// cmdk 1.x always renders data-disabled="true|false", so the variant must
|
||||
// check the value (bare data-[disabled] matches attribute presence).
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -103,15 +103,10 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
|
||||
) {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2 py-0.5">
|
||||
<RadioGroup className="gap-y-1">
|
||||
<RadioGroup value={field.customText ?? ''} className="gap-y-1">
|
||||
{field.fieldMeta.values.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<RadioGroupItem
|
||||
className="pointer-events-none h-3 w-3"
|
||||
value={item.value}
|
||||
id={`option-${index}`}
|
||||
checked={item.value === field.customText}
|
||||
/>
|
||||
<RadioGroupItem className="pointer-events-none h-3 w-3" value={item.value} id={`option-${index}`} />
|
||||
{item.value && (
|
||||
<Label htmlFor={`option-${index}`} className="ml-1.5 font-normal text-foreground text-xs">
|
||||
{item.value}
|
||||
|
||||
@@ -142,6 +142,7 @@ export function MultiSelectCombobox<T = OptionValue>({
|
||||
{showClearButton && !loading && (
|
||||
<div className="absolute top-0 right-8 bottom-0 flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full bg-muted-foreground/20"
|
||||
onClick={() => onChange([])}
|
||||
>
|
||||
|
||||
@@ -431,6 +431,7 @@ const MultiSelect = ({
|
||||
>
|
||||
{option.label}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -inset-y-px -end-px flex size-7 items-center justify-center rounded-e-md border border-transparent p-0 text-muted-foreground/80 outline-none transition-[color,box-shadow] hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RecipientRole } from '@prisma/client';
|
||||
import { BadgeCheck, Copy, Eye, PencilLine, User } from 'lucide-react';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
export const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
|
||||
SIGNER: <PencilLine className="h-4 w-4" />,
|
||||
|
||||
Reference in New Issue
Block a user