Compare commits

...

4 Commits

Author SHA1 Message Date
Lucas Smith 16e1c69939 fix: tighten permission and validation checks 2026-06-28 22:39:31 +10:00
Lucas Smith 381293af0c fix: invite email placeholder (#3045)
- **fix: interpolate inviterEmail in invite email mailto link**
- **fix: add alt attributes to email template images**
2026-06-28 22:01:20 +10:00
David Nguyen 97835b8dbb feat: add field multiselect (#3031) 2026-06-28 15:08:11 +10:00
David Nguyen 977d07330b fix: auto select field on drop (#3028) 2026-06-28 15:07:33 +10:00
38 changed files with 1135 additions and 206 deletions
@@ -1,3 +1,4 @@
import { toSafeHref } from '@documenso/lib/utils/is-http-url';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
@@ -53,7 +54,7 @@ export const DocumentSigningAttachmentsPopover = ({
{attachments?.data.map((attachment) => (
<a
key={attachment.id}
href={attachment.data}
href={toSafeHref(attachment.data)}
title={attachment.data}
target="_blank"
rel="noopener noreferrer"
@@ -1,5 +1,6 @@
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { isHttpUrl, toSafeHref } from '@documenso/lib/utils/is-http-url';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -24,7 +25,7 @@ export type DocumentAttachmentsPopoverProps = {
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
url: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
@@ -156,7 +157,7 @@ export const DocumentAttachmentsPopover = ({
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{attachment.label}</p>
<a
href={attachment.data}
href={toSafeHref(attachment.data)}
target="_blank"
rel="noopener noreferrer"
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
@@ -1,5 +1,6 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { nanoid } from '@documenso/lib/universal/id';
import { isHttpUrl, toSafeHref } from '@documenso/lib/utils/is-http-url';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
@@ -22,7 +23,7 @@ export type EmbeddedEditorAttachmentPopoverProps = {
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
url: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
@@ -117,7 +118,7 @@ export const EmbeddedEditorAttachmentPopover = ({
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{attachment.label}</p>
<a
href={attachment.data}
href={toSafeHref(attachment.data)}
target="_blank"
rel="noopener noreferrer"
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
@@ -49,6 +49,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
const [isFieldChanging, setIsFieldChanging] = useState(false);
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
/**
* Whether the field was automatically selected on creation (drag-drop or marquee).
*
* We purposefully supress the floating toolbar for newly created fields.
*/
const [isAutoSelectedField, setIsAutoSelectedField] = useState(false);
const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer(
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
pageData,
@@ -237,10 +244,26 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
fieldGroup.off('transformend');
fieldGroup.off('dragend');
// Set up field selection.
fieldGroup.on('click', () => {
// Set up field selection. Shift + click toggles this field in/out of the current
// multi-selection, so fields can be added to a group by clicking them --
// complementing marquee drag-selection. A plain click (no modifier) selects just
// this field.
fieldGroup.on('click', (event) => {
removePendingField();
setSelectedFields([fieldGroup]);
const isMultiSelectModifier = event.evt.shiftKey;
if (isMultiSelectModifier) {
const currentNodes = interactiveTransformer.current?.nodes() ?? [];
const isAlreadySelected = currentNodes.includes(fieldGroup);
setSelectedFields(
isAlreadySelected ? currentNodes.filter((node) => node !== fieldGroup) : [...currentNodes, fieldGroup],
);
} else {
setSelectedFields([fieldGroup]);
}
pageLayer.current?.batchDraw();
});
@@ -445,43 +468,18 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
}
});
// Clicks should select/deselect shapes
// Clicking empty stage area clears the selection. Field clicks -- including
// Shift+click multi-select -- are handled by each field group's own click
// handler in `unsafeRenderFieldOnLayer`.
currentStage.on('click tap', (e) => {
// if we are selecting with rect, do nothing
// If we are selecting with the marquee rectangle, do nothing.
if (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) {
return;
}
// If empty area clicked, remove all selections
// If empty area clicked, remove all selections.
if (e.target === stage.current) {
setSelectedFields([]);
return;
}
// Do nothing if field not clicked, or if field is not editable
if (!e.target.hasName('field-group') || e.target.draggable() === false) {
return;
}
// do we pressed shift or ctrl?
const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey;
const isSelected = transformer.nodes().indexOf(e.target) >= 0;
if (!metaPressed && !isSelected) {
// if no key pressed and the node is not selected
// select just one
setSelectedFields([e.target]);
} else if (metaPressed && isSelected) {
// if we pressed keys and node was selected
// we need to remove it from selection:
const nodes = transformer.nodes().slice(); // use slice to have new copy of array
// remove node from array
nodes.splice(nodes.indexOf(e.target), 1);
setSelectedFields(nodes);
} else if (metaPressed && !isSelected) {
// add the node into selection
const nodes = transformer.nodes().concat([e.target]);
setSelectedFields(nodes);
}
});
@@ -521,13 +519,48 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
setSelectedFields(liveSelectedFieldGroups);
}
// Mirror the editor's single selected field onto the canvas (Konva) selection.
//
// `addField` already marks a newly created field as the selected field, so this
// makes a field placed via the palette (drag-drop) or marquee creation show its
// resize handles immediately -- no second click needed. It also clears the canvas
// selection when the selected field is cleared (e.g. when the author starts
// placing another field), so the floating action toolbar can't intercept the next
// placement click. Runs after the render loop above so the field's group exists.
const selectedFormId = editorFields.selectedField?.formId ?? null;
const isSingleCanvasSelection = selectedKonvaFieldGroups.length === 1;
if (selectedFormId && localPageFields.some((field) => field.formId === selectedFormId)) {
const isAlreadySelected = isSingleCanvasSelection && selectedKonvaFieldGroups[0].id() === selectedFormId;
if (!isAlreadySelected) {
const fieldGroupToSelect = pageLayer.current.findOne(`#${selectedFormId}`);
if (fieldGroupToSelect instanceof Konva.Group) {
setSelectedFields([fieldGroupToSelect], { isAutoSelect: true });
}
}
} else if (selectedFormId === null && isSingleCanvasSelection) {
setSelectedFields([]);
}
// Rerender the transformer
interactiveTransformer.current?.forceUpdate();
pageLayer.current.batchDraw();
}, [localPageFields, selectedKonvaFieldGroups, overlappingFieldFormIds, isFieldChanging]);
}, [
localPageFields,
selectedKonvaFieldGroups,
overlappingFieldFormIds,
isFieldChanging,
editorFields.selectedField?.formId,
]);
const setSelectedFields = (nodes: Konva.Node[], options?: { isAutoSelect?: boolean }) => {
// Any explicit (user-driven) selection shows the action toolbar; only auto-selection
// on field creation suppresses it.
setIsAutoSelectedField(Boolean(options?.isAutoSelect));
const setSelectedFields = (nodes: Konva.Node[]) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const fieldGroups = nodes.filter(
(node) => node.hasName('field-group') && Boolean(node.getStage()) && Boolean(node.getParent()),
@@ -663,25 +696,30 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
return (
<>
{selectedKonvaFieldGroups.length > 0 && interactiveTransformer.current && !isFieldChanging && (
<FieldActionButtons
handleDuplicateSelectedFields={duplicatedSelectedFields}
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
handleDeleteSelectedFields={deletedSelectedFields}
handleChangeRecipient={changeSelectedFieldsRecipients}
handleChangeFieldType={changeSelectedFieldsType}
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
style={{
position: 'absolute',
top: interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px',
left: interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px',
transform: 'translateX(-50%)',
gap: '8px',
pointerEvents: 'auto',
zIndex: 50,
}}
/>
)}
{selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current &&
!isFieldChanging &&
!isAutoSelectedField && (
<FieldActionButtons
handleDuplicateSelectedFields={duplicatedSelectedFields}
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
handleDeleteSelectedFields={deletedSelectedFields}
handleChangeRecipient={changeSelectedFieldsRecipients}
handleChangeFieldType={changeSelectedFieldsType}
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => field.id())}
style={{
position: 'absolute',
top:
interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px',
left:
interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px',
transform: 'translateX(-50%)',
gap: '8px',
pointerEvents: 'auto',
zIndex: 50,
}}
/>
)}
{pendingFieldCreation && (
<div
@@ -0,0 +1,89 @@
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
const requestMetadata = {
auth: null,
requestMetadata: {},
source: 'app' as const,
};
test.describe.configure({ mode: 'parallel' });
const canReadEnvelope = async (envelopeId: string, userId: number, teamId: number) => {
try {
await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
userId,
teamId,
type: null,
}).then(({ envelopeWhereInput }) => prisma.envelope.findFirstOrThrow({ where: envelopeWhereInput }));
return true;
} catch {
return false;
}
};
test('[DOCUMENTS]: a member cannot delete a document with restricted visibility', async () => {
const { user: owner, team } = await seedUser();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const envelope = await seedBlankDocument(owner, team.id, {
createDocumentOptions: {
visibility: DocumentVisibility.ADMIN,
status: DocumentStatus.DRAFT,
},
});
// The member cannot read an ADMIN-visibility document, so they must not be
// able to delete it either.
expect(await canReadEnvelope(envelope.id, member.id, team.id)).toBe(false);
await expect(
deleteDocument({
id: { type: 'envelopeId', id: envelope.id },
userId: member.id,
teamId: team.id,
requestMetadata,
}),
).rejects.toThrow();
const stillExists = await prisma.envelope.findUnique({ where: { id: envelope.id } });
expect(stillExists).not.toBeNull();
});
test('[DOCUMENTS]: a manager cannot cancel a document with restricted visibility', async () => {
const { user: owner, team } = await seedUser();
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
const envelope = await seedBlankDocument(owner, team.id, {
createDocumentOptions: {
visibility: DocumentVisibility.ADMIN,
status: DocumentStatus.PENDING,
},
});
// A manager outranks a member but still cannot read an ADMIN-visibility
// document, so cancellation must be blocked despite the sufficient role.
expect(await canReadEnvelope(envelope.id, manager.id, team.id)).toBe(false);
await expect(
cancelDocument({
id: { type: 'envelopeId', id: envelope.id },
userId: manager.id,
teamId: team.id,
reason: 'test-cancel',
requestMetadata,
}),
).rejects.toThrow();
const after = await prisma.envelope.findUnique({ where: { id: envelope.id } });
expect(after?.status).toBe(DocumentStatus.PENDING);
});
@@ -0,0 +1,70 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { hashString } from '@documenso/lib/server-only/auth/hash';
import { alphaid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { expect, test } from '@playwright/test';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({ mode: 'parallel' });
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
const token = `api_${alphaid(16)}`;
await prisma.apiToken.create({
data: { name: 'attachment-url-test', token: hashString(token), expires: null, userId, teamId },
});
return { token };
};
/**
* Attachment URLs are rendered as link hrefs, so they must be restricted to
* http(s). The API must reject any other scheme.
*/
const NON_HTTP_URLS = [
'javascript:alert(document.cookie)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
'file:///etc/passwd',
];
test('[ATTACHMENTS]: rejects attachment URLs with a non-http(s) protocol', async ({ request }) => {
const { team, owner } = await seedTeam();
const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
const envelope = await seedBlankDocument(owner, team.id);
for (const url of NON_HTTP_URLS) {
const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { envelopeId: envelope.id, data: { label: 'attachment', data: url } },
});
expect(res.ok(), `expected ${url} to be rejected`).toBe(false);
}
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
expect(attachments).toHaveLength(0);
});
test('[ATTACHMENTS]: accepts attachment URLs with an http(s) protocol', async ({ request }) => {
const { team, owner } = await seedTeam();
const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
const envelope = await seedBlankDocument(owner, team.id);
const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { envelopeId: envelope.id, data: { label: 'safe', data: 'https://example.com/file.pdf' } },
});
expect(res.ok()).toBe(true);
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
expect(attachments).toHaveLength(1);
expect(attachments[0].data).toBe('https://example.com/file.pdf');
});
@@ -0,0 +1,121 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { hashString } from '@documenso/lib/server-only/auth/hash';
import { alphaid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { type APIRequestContext, expect, test } from '@playwright/test';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({ mode: 'parallel' });
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
const token = `api_${alphaid(16)}`;
await prisma.apiToken.create({
data: { name: 'attachment-access-test', token: hashString(token), expires: null, userId, teamId },
});
return { token };
};
const canReadEnvelope = async (request: APIRequestContext, token: string, envelopeId: string) => {
const res = await request.get(`${API_BASE_URL}/envelope/${envelopeId}`, {
headers: { Authorization: `Bearer ${token}` },
});
return res.ok();
};
/**
* Attachment create/update/delete/list must enforce document visibility, not
* just team membership. A member whose visibility tier excludes a restricted
* envelope must not be able to read or mutate its attachments.
*/
test('[ATTACHMENTS]: a member cannot create or delete attachments on a restricted document', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
const envelope = await seedBlankDocument(owner, team.id, {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' },
data: { envelopeId: envelope.id, data: { label: 'attachment', data: 'https://example.com' } },
});
expect(createRes.ok()).toBe(false);
// No attachment should have been created.
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
expect(attachments).toHaveLength(0);
});
test('[ATTACHMENTS]: a member cannot update an attachment on a restricted document', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
const envelope = await seedBlankDocument(owner, team.id, {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
// The owner (who can see the document) creates the attachment.
const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' },
data: { envelopeId: envelope.id, data: { label: 'original', data: 'https://example.com/original' } },
});
expect(createRes.ok()).toBe(true);
const attachment = await createRes.json();
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
const updateRes = await request.post(`${API_BASE_URL}/envelope/attachment/update`, {
headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' },
data: { id: attachment.id, data: { label: 'tampered', data: 'https://example.com/tampered' } },
});
expect(updateRes.ok()).toBe(false);
const persisted = await prisma.envelopeAttachment.findUnique({ where: { id: attachment.id } });
expect(persisted?.label).toBe('original');
});
test('[ATTACHMENTS]: a member cannot list attachments on a restricted document', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
const envelope = await seedBlankDocument(owner, team.id, {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' },
data: { envelopeId: envelope.id, data: { label: 'restricted', data: 'https://example.com/restricted' } },
});
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
const findRes = await request.get(`${API_BASE_URL}/envelope/attachment?envelopeId=${envelope.id}`, {
headers: { Authorization: `Bearer ${memberToken}` },
});
expect(findRes.ok()).toBe(false);
const body = findRes.ok() ? await findRes.json() : null;
const attachments = body?.data ?? [];
expect(attachments).toHaveLength(0);
});
@@ -20,7 +20,7 @@ import {
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { getKonvaElementCountForPage } from '../fixtures/konva';
import { getKonvaElementCountForPage, getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
type TFieldFlowResult = {
externalId: string;
@@ -46,6 +46,7 @@ const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: str
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
await surface.root.getByTestId('toast-close').click();
}
};
@@ -98,6 +99,17 @@ const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number
await canvas.click({ position, force: true });
};
/**
* Shift+click a field on the canvas to toggle it in/out of the current multi-selection.
*/
const shiftClickFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => {
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await root.waitForTimeout(300);
// Use force:true to bypass any floating action toolbar buttons that may intercept clicks.
await canvas.click({ position, modifiers: ['Shift'], force: true });
};
const runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
const externalId = `e2e-fields-${nanoid()}`;
@@ -760,9 +772,106 @@ const assertChangeFieldTypePersistedInDatabase = async ({
expect(actualMetaTypes).toEqual(['date', 'date']);
};
// --- Shift+click multi-select flow ---
type TShiftClickFlowResult = {
externalId: string;
};
const SHIFT_CLICK_FIELD_POSITIONS = {
signature: { x: 150, y: 120 },
text: { x: 150, y: 260 },
name: { x: 150, y: 400 },
};
const runShiftClickMultiSelectFlow = async (surface: TEnvelopeEditorSurface): Promise<TShiftClickFlowResult> => {
const externalId = `e2e-shift-click-${nanoid()}`;
const root = surface.root;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
}
await updateExternalId(surface, externalId);
await setupRecipientsForFieldPlacement(surface);
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
// Place three fields, spaced far enough apart that their action toolbars don't
// overlap a neighbouring field's click target.
await placeFieldOnPdf(root, 'Signature', SHIFT_CLICK_FIELD_POSITIONS.signature);
await placeFieldOnPdf(root, 'Text', SHIFT_CLICK_FIELD_POSITIONS.text);
await placeFieldOnPdf(root, 'Name', SHIFT_CLICK_FIELD_POSITIONS.name);
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3);
// A plain click selects exactly one field.
await selectFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
// Shift+click a second field ADDS it to the selection (the new behaviour).
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.text);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
// Shift+click an already-selected field REMOVES it from the selection.
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
// Shift+click it again RE-ADDS it, leaving Signature + Text selected and Name excluded.
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
// Delete the two selected fields via the floating action toolbar. Only the
// un-selected Name field should remain -- proving the multi-selection contained
// exactly the two Shift-clicked fields.
await expect(root.locator('button[title="Remove"]')).toBeVisible();
await root.locator('button[title="Remove"]').click();
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
// Navigate away and back to verify persistence.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
return { externalId };
};
const assertShiftClickMultiSelectPersistedInDatabase = async ({
surface,
externalId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: { fields: true },
});
// Signature + Text were multi-selected via Shift+click and deleted; only Name remains.
expect(envelope.fields).toHaveLength(1);
expect(envelope.fields[0].type).toBe(FieldType.NAME);
};
// --- Test describe blocks ---
test.describe('document editor', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runShiftClickMultiSelectFlow(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runAddAndPersistSignatureTextFields(surface);
@@ -815,6 +924,16 @@ test.describe('document editor', () => {
});
test.describe('template editor', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runShiftClickMultiSelectFlow(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runAddAndPersistSignatureTextFields(surface);
@@ -867,6 +986,21 @@ test.describe('template editor', () => {
});
test.describe('embedded create', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-shift-click',
});
const result = await runShiftClickMultiSelectFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
@@ -944,6 +1078,22 @@ test.describe('embedded create', () => {
});
test.describe('embedded edit', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-shift-click',
});
const result = await runShiftClickMultiSelectFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
+32
View File
@@ -16,3 +16,35 @@ export const getKonvaElementCountForPage = async (page: Page, pageNumber: number
{ pageNumber, elementSelector },
);
};
/**
* Returns how many field groups are currently attached to the page's Konva
* transformer, i.e. the size of the active canvas selection. Used to assert
* multi-select behaviour (marquee drag and Shift+click).
*/
export const getKonvaTransformerNodeCountForPage = async (page: Page, pageNumber: number) => {
await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
return await page.evaluate(
({ pageNumber }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
const stage = konva.stages.find((stage) => stage.attrs.id === `page-${pageNumber}`);
if (!stage) {
return 0;
}
const transformer = stage.find('Transformer')[0];
if (!transformer) {
return 0;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (transformer as Konva.Transformer).nodes().length;
},
{ pageNumber },
);
};
@@ -340,3 +340,67 @@ test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: leaving an organisation', ()
expect(deleted).toBeNull();
});
});
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: group membership scoping', () => {
test('cannot add a member from another organisation to a group', async ({ page }) => {
// Organisation A, where the actor is the owner/admin.
const { user: actor, organisation: organisationA } = await seedUser({
isPersonalOrganisation: false,
});
// A separate organisation B with a member the actor has no authority over.
const { organisation: organisationB } = await seedUser({ isPersonalOrganisation: false });
const [foreignUser] = await seedOrganisationMembers({
members: [{ name: 'Foreign', organisationRole: 'MEMBER' }],
organisationId: organisationB.id,
});
const foreignMember = await getOrganisationMember(foreignUser.id, organisationB.id);
// A custom group the actor legitimately controls in organisation A.
const groupA = await createCustomGroup(organisationA.id, 'MEMBER');
await apiSignin({ page, email: actor.email });
const res = await trpcMutation(page, 'organisation.group.update', {
id: groupA.id,
memberIds: [foreignMember.id],
});
expect(res.ok()).toBeFalsy();
const injectedMembership = await prisma.organisationGroupMember.findFirst({
where: { groupId: groupA.id, organisationMemberId: foreignMember.id },
});
expect(injectedMembership).toBeNull();
});
test('can add a member from the same organisation to a group (positive control)', async ({ page }) => {
const { user: actor, organisation } = await seedUser({ isPersonalOrganisation: false });
const [memberUser] = await seedOrganisationMembers({
members: [{ name: 'Member', organisationRole: 'MEMBER' }],
organisationId: organisation.id,
});
const member = await getOrganisationMember(memberUser.id, organisation.id);
const group = await createCustomGroup(organisation.id, 'MEMBER');
await apiSignin({ page, email: actor.email });
const res = await trpcMutation(page, 'organisation.group.update', {
id: group.id,
memberIds: [member.id],
});
expect(res.ok()).toBeTruthy();
const membership = await prisma.organisationGroupMember.findFirst({
where: { groupId: group.id, organisationMemberId: member.id },
});
expect(membership).not.toBeNull();
});
});
@@ -0,0 +1,72 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { hashString } from '@documenso/lib/server-only/auth/hash';
import { alphaid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedCompletedDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { expect, test } from '@playwright/test';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({ mode: 'parallel' });
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
const token = `api_${alphaid(16)}`;
await prisma.apiToken.create({
data: { name: 'recipient-access-test', token: hashString(token), expires: null, userId, teamId },
});
return { token };
};
/**
* Reading a recipient exposes its signing token (a bearer credential), so the
* recipient read must enforce document visibility — a member who cannot read a
* restricted document must not be able to read its recipients either. This
* mirrors the field read, which is asserted as a control below.
*/
test('[RECIPIENT]: a member cannot read a recipient of a restricted document', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
const recipient = await prisma.recipient.findFirstOrThrow({ where: { envelopeId: document.id } });
const res = await request.get(`${API_BASE_URL}/envelope/recipient/${recipient.id}`, {
headers: { Authorization: `Bearer ${memberToken}` },
});
expect(res.status()).toBe(404);
const body = res.ok() ? await res.json() : null;
expect(body?.token).toBeUndefined();
});
test('[RECIPIENT]: a member cannot read a field of a restricted document', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
const field = await prisma.field.findFirst({ where: { envelopeId: document.id } });
test.skip(!field, 'No field seeded on completed document');
const res = await request.get(`${API_BASE_URL}/envelope/field/${field!.id}`, {
headers: { Authorization: `Bearer ${memberToken}` },
});
expect(res.status()).toBe(404);
});
@@ -0,0 +1,53 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({ mode: 'parallel' });
/**
* Editing the team public profile is a team-management action and must require
* MANAGE_TEAM, consistent with renaming the team or changing its URL.
*/
test('[TEAMS]: a member cannot edit the team public profile', async ({ page }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await apiSignin({ page, email: member.email });
const profileRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, {
headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() },
data: JSON.stringify({
json: {
teamId: team.id,
data: { profileEnabled: true, profileBio: 'edited-by-member' },
},
}),
});
expect(profileRes.status()).not.toBe(200);
const profile = await prisma.teamProfile.findUnique({ where: { teamId: team.id } });
expect(profile?.enabled ?? false).toBe(false);
expect(profile?.bio ?? '').not.toBe('edited-by-member');
// The name/url path of the same route is also management-gated.
const nameRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, {
headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() },
data: JSON.stringify({
json: { teamId: team.id, data: { name: 'renamed-by-member' } },
}),
});
expect(nameRes.status()).not.toBe(200);
const reloaded = await prisma.team.findUnique({ where: { id: team.id } });
expect(reloaded?.name).not.toBe('renamed-by-member');
expect(owner.id).toBeTruthy();
});
@@ -28,7 +28,11 @@ export const TemplateDocumentCompleted = ({
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Img
src={getAssetUrl('/static/completed.png')}
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
alt=""
/>
<Trans>Completed</Trans>
</Text>
</Column>
@@ -47,7 +51,7 @@ export const TemplateDocumentCompleted = ({
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
href={downloadLink}
>
<Img src={getAssetUrl('/static/download.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
<Img src={getAssetUrl('/static/download.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" alt="" />
<Trans>Download</Trans>
</Button>
</Section>
@@ -21,7 +21,7 @@ export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: Template
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/clock.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Img src={getAssetUrl('/static/clock.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" alt="" />
<Trans>Waiting for others</Trans>
</Text>
</Column>
@@ -30,7 +30,11 @@ export const TemplateDocumentRecipientSigned = ({
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Img
src={getAssetUrl('/static/completed.png')}
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
alt=""
/>
<Trans>Completed</Trans>
</Text>
</Column>
@@ -26,7 +26,11 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
<Section>
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Img
src={getAssetUrl('/static/completed.png')}
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
alt=""
/>
<Trans>Completed</Trans>
</Text>
</Column>
@@ -51,7 +55,11 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
href={signUpUrl}
className="mr-4 rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
>
<Img src={getAssetUrl('/static/user-plus.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
<Img
src={getAssetUrl('/static/user-plus.png')}
className="mr-2 mb-0.5 inline h-5 w-5 align-middle"
alt=""
/>
<Trans>Create account</Trans>
</Button>
@@ -59,7 +67,7 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
href="https://documenso.com/pricing"
>
<Img src={getAssetUrl('/static/review.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
<Img src={getAssetUrl('/static/review.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" alt="" />
<Trans>View plans</Trans>
</Button>
</Section>
@@ -11,7 +11,7 @@ export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: Template
return new URL(path, assetBaseUrl).toString();
};
return <Img className={className} src={getAssetUrl(`/static/${staticAsset}`)} />;
return <Img className={className} src={getAssetUrl(`/static/${staticAsset}`)} alt="" />;
};
export default TemplateImage;
+1 -1
View File
@@ -85,7 +85,7 @@ export const DocumentInviteEmailTemplate = ({
<Text className="my-4 font-semibold text-base">
<Trans>
{inviterName}{' '}
<Link className="font-normal text-muted-foreground" href="mailto:{inviterEmail}">
<Link className="font-normal text-muted-foreground" href={`mailto:${inviterEmail}`}>
({inviterEmail})
</Link>
</Trans>
@@ -8,8 +8,9 @@ import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { isMemberManagerOrAbove } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { getMemberRoles } from '../team/get-member-roles';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -22,9 +23,18 @@ export type CancelDocumentOptions = {
};
export const cancelDocument = async ({ id, userId, teamId, reason, requestMetadata }: CancelDocumentOptions) => {
// Note: This is an unsafe request, we validate the ownership/permission later in the function.
const envelope = await prisma.envelope.findUnique({
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
// Resolve the envelope through the visibility-aware helper so the caller must
// have read access (ownership OR team membership with sufficient visibility OR
// team-email). This prevents cancelling a document the caller cannot see.
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
userId,
teamId,
type: EnvelopeType.DOCUMENT,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
documentMeta: true,
@@ -49,16 +59,6 @@ export const cancelDocument = async ({ id, userId, teamId, reason, requestMetada
.then((roles) => roles.teamRole)
.catch(() => null);
const isUserTeamMember = teamRole !== null;
// Callers with no relationship to the document must not be able to determine
// whether it exists, so respond as if it was not found.
if (!isUserOwner && !isUserTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const isPrivilegedTeamMember = teamRole && isMemberManagerOrAbove(teamRole);
// The document is visible to the caller, but cancelling requires elevated permissions.
@@ -13,7 +13,7 @@ import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { getEmailContext } from '../email/get-email-context';
import { getMemberRoles } from '../team/get-member-roles';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type DeleteDocumentOptions = {
@@ -36,7 +36,9 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De
});
}
// Note: This is an unsafe request, we validate the ownership later in the function.
// Note: This is an unsafe request. It is used purely to resolve the recipient
// self-hide path below. The authoritative delete authorization is performed
// via the visibility-aware `getEnvelopeWhereInput` helper.
const envelope = await prisma.envelope.findUnique({
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
include: {
@@ -51,27 +53,36 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De
});
}
const isUserTeamMember = await getMemberRoles({
teamId: envelope.teamId,
reference: {
type: 'User',
id: userId,
},
// Determine whether the user has authorized delete access using the
// visibility-aware helper. This enforces ownership OR (team membership AND
// the document's visibility is permitted for the member's role) OR team-email
// access. A bare team member without sufficient visibility will resolve to
// null here and therefore must not be able to delete the document.
const hasDeleteAccess = await getEnvelopeWhereInput({
id,
userId,
teamId,
type: EnvelopeType.DOCUMENT,
})
.then(() => true)
.then(({ envelopeWhereInput }) =>
prisma.envelope.findFirst({
where: envelopeWhereInput,
select: { id: true },
}),
)
.then((result) => Boolean(result))
.catch(() => false);
const isUserOwner = envelope.userId === userId;
const userRecipient = envelope.recipients.find((recipient) => recipient.email === user.email);
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
if (!hasDeleteAccess && !userRecipient) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Not allowed',
});
}
// Handle hard or soft deleting the actual document if user has permission.
if (isUserOwner || isUserTeamMember) {
if (hasDeleteAccess) {
await handleDocumentOwnerDelete({
envelope,
user,
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type CreateAttachmentOptions = {
envelopeId: string;
@@ -15,11 +15,15 @@ export type CreateAttachmentOptions = {
};
export const createAttachment = async ({ envelopeId, teamId, userId, data }: CreateAttachmentOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
userId,
teamId,
type: null,
});
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
where: envelopeWhereInput,
});
if (!envelope) {
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type DeleteAttachmentOptions = {
id: string;
@@ -14,9 +14,6 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
@@ -29,6 +26,24 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: attachment.envelopeId },
userId,
teamId,
type: null,
});
// Additional validation to check the user has visibility-aware access to the envelope.
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
@@ -1,7 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type FindAttachmentsByEnvelopeIdOptions = {
envelopeId: string;
@@ -14,11 +14,15 @@ export const findAttachmentsByEnvelopeId = async ({
userId,
teamId,
}: FindAttachmentsByEnvelopeIdOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
userId,
teamId,
type: null,
});
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
where: envelopeWhereInput,
});
if (!envelope) {
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type UpdateAttachmentOptions = {
id: string;
@@ -15,9 +15,6 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
@@ -30,6 +27,24 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: attachment.envelopeId },
userId,
teamId,
type: null,
});
// Additional validation to check the user has visibility-aware access to the envelope.
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
@@ -1,44 +0,0 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { hashString } from '../auth/hash';
export const getUserByApiToken = async ({ token }: { token: string }) => {
const hashedToken = hashString(token);
const user = await prisma.user.findFirst({
where: {
apiTokens: {
some: {
token: hashedToken,
},
},
},
include: {
apiTokens: true,
},
});
if (!user) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid token',
statusCode: 401,
});
}
const retrievedToken = user.apiTokens.find((apiToken) => apiToken.token === hashedToken);
// This should be impossible but we need to satisfy TypeScript
if (!retrievedToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid token',
statusCode: 401,
});
}
if (retrievedToken.expires && retrievedToken.expires < new Date()) {
throw new Error('Expired token');
}
return user;
};
@@ -4,6 +4,7 @@ import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type GetRecipientByIdOptions = {
recipientId: number;
@@ -41,6 +42,27 @@ export const getRecipientById = async ({ recipientId, userId, teamId, type }: Ge
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: recipient.envelopeId,
},
type,
userId,
teamId,
});
// Additional validation to check visibility.
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const legacyId = {
documentId: type === EnvelopeType.DOCUMENT ? mapSecondaryIdToDocumentId(recipient.envelope.secondaryId) : null,
templateId: type === EnvelopeType.TEMPLATE ? mapSecondaryIdToTemplateId(recipient.envelope.secondaryId) : null,
@@ -5,6 +5,7 @@ import { match, P } from 'ts-pattern';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { alphaid } from '../../universal/id';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type CreateSharingIdOptions =
| {
@@ -27,6 +28,7 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha
),
select: {
id: true,
teamId: true,
},
});
@@ -46,6 +48,31 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha
.then((recipient) => recipient?.email);
})
.with({ userId: P.number }, async ({ userId }) => {
// Ensure the authenticated user actually has visibility-aware access to the
// envelope before allowing them to create a share link. The share route does
// not carry a teamId, so we derive it from the envelope and reuse the canonical
// visibility check (owner OR team member with sufficient visibility).
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'documentId',
id: documentId,
},
userId,
teamId: envelope.teamId,
type: EnvelopeType.DOCUMENT,
});
const accessibleEnvelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
select: {
id: true,
},
});
if (!accessibleEnvelope) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return await prisma.user
.findFirst({
where: {
@@ -3,7 +3,6 @@ import type { TeamProfile } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
import { updateTeamPublicProfile } from './update-team-public-profile';
export type GetTeamPublicProfileOptions = {
userId: number;
@@ -32,25 +31,24 @@ export const getTeamPublicProfile = async ({
});
}
// Create and return the public profile.
// Lazily initialize a disabled public profile on first access. Membership is
// already verified by the query above, so this system initialization does not
// impose the MANAGE_TEAM gate that updateTeamPublicProfile enforces for writes.
if (!team.profile) {
const { url, profile } = await updateTeamPublicProfile({
userId: userId,
teamId,
data: {
const profile = await prisma.teamProfile.upsert({
where: {
teamId,
},
create: {
teamId,
enabled: false,
},
update: {},
});
if (!profile) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Failed to create public profile',
});
}
return {
profile,
url,
url: team.url,
};
}
@@ -1,3 +1,4 @@
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
@@ -13,7 +14,11 @@ export type UpdatePublicProfileOptions = {
export const updateTeamPublicProfile = async ({ userId, teamId, data }: UpdatePublicProfileOptions) => {
return await prisma.team.update({
where: buildTeamWhereQuery({ teamId, userId }),
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
data: {
profile: {
upsert: {
@@ -18,31 +18,26 @@ export const forgotPassword = async ({ email }: { email: string }) => {
return;
}
// Find a token that was created in the last hour and hasn't expired
// const existingToken = await prisma.passwordResetToken.findFirst({
// where: {
// userId: user.id,
// expiry: {
// gt: new Date(),
// },
// createdAt: {
// gt: new Date(Date.now() - ONE_HOUR),
// },
// },
// });
// if (existingToken) {
// return;
// }
const token = crypto.randomBytes(18).toString('hex');
await prisma.passwordResetToken.create({
data: {
token,
expiry: new Date(Date.now() + ONE_HOUR),
userId: user.id,
},
// Invalidate any prior reset tokens for this user before issuing a new one, so
// only a single token is ever live at a time. We still always issue a fresh
// token (and email) so the user can request a new link if a prior email never
// arrived, while bounding the number of usable tokens to one.
await prisma.$transaction(async (tx) => {
await tx.passwordResetToken.deleteMany({
where: {
userId: user.id,
},
});
await tx.passwordResetToken.create({
data: {
token,
expiry: new Date(Date.now() + ONE_HOUR),
userId: user.id,
},
});
});
await sendForgotPassword({
@@ -1,4 +1,6 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { assertNotPrivateUrl } from '../assert-webhook-url';
@@ -9,14 +11,36 @@ export const subscribeHandler = async (req: Request) => {
const authorization = req.headers.get('authorization');
if (!authorization) {
return new Response('Unauthorized', { status: 401 });
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
}
const { webhookUrl, eventTrigger } = await req.json();
await assertNotPrivateUrl(webhookUrl);
const result = await validateApiToken({ authorization });
const result = await validateApiToken({ authorization }).catch(() => {
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
});
const userId = result.userId ?? result.user.id;
const teamId = result.teamId ?? undefined;
// Re-verify the token holder still has MANAGE_TEAM on the team, mirroring the
// tRPC webhook mutations (create-webhook.ts). Guards against stale-privilege
// use of a token minted while the holder was privileged.
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
});
if (!team) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to manage webhooks for this team',
});
}
const createdWebhook = await prisma.webhook.create({
data: {
@@ -24,15 +48,19 @@ export const subscribeHandler = async (req: Request) => {
eventTriggers: [eventTrigger],
secret: null,
enabled: true,
userId: result.userId ?? result.user.id,
teamId: result.teamId ?? undefined,
userId,
teamId,
},
});
return Response.json(createdWebhook);
} catch (err) {
if (err instanceof AppError) {
return Response.json({ message: err.message }, { status: 400 });
// Map authorization failures to 401, keep other AppErrors as 400 to
// preserve the existing Zapier contract (e.g. invalid webhook URL).
const status = err.code === AppErrorCode.UNAUTHORIZED ? 401 : 400;
return Response.json({ message: err.message }, { status });
}
console.error(err);
@@ -1,3 +1,6 @@
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { validateApiToken } from './validateApiToken';
@@ -7,23 +10,42 @@ export const unsubscribeHandler = async (req: Request) => {
const authorization = req.headers.get('authorization');
if (!authorization) {
return new Response('Unauthorized', { status: 401 });
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
}
const { webhookId } = await req.json();
const result = await validateApiToken({ authorization });
const result = await validateApiToken({ authorization }).catch(() => {
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
});
const userId = result.userId ?? result.user.id;
const teamId = result.teamId ?? undefined;
// Re-verify the token holder still has MANAGE_TEAM on the team, mirroring the
// tRPC delete-webhook-by-id mutation. Guards against stale-privilege use of a
// token minted while the holder was privileged.
const deletedWebhook = await prisma.webhook.delete({
where: {
id: webhookId,
userId: result.userId ?? result.user.id,
teamId: result.teamId ?? undefined,
team: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
},
});
return Response.json(deletedWebhook);
} catch (err) {
if (err instanceof AppError) {
// Map authorization failures to 401, keep other AppErrors as 400 to
// preserve the existing Zapier contract.
const status = err.code === AppErrorCode.UNAUTHORIZED ? 401 : 400;
return Response.json({ message: err.message }, { status });
}
console.error(err);
return Response.json(
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { isHttpUrl, toSafeHref } from './is-http-url';
describe('isHttpUrl', () => {
it('accepts http and https URLs', () => {
expect(isHttpUrl('http://example.com')).toBe(true);
expect(isHttpUrl('https://example.com/path?q=1#hash')).toBe(true);
expect(isHttpUrl('HTTPS://EXAMPLE.COM')).toBe(true);
});
it('rejects non-http(s) schemes', () => {
expect(isHttpUrl('javascript:alert(1)')).toBe(false);
expect(isHttpUrl('JavaScript:alert(1)')).toBe(false);
expect(isHttpUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
expect(isHttpUrl('vbscript:msgbox(1)')).toBe(false);
expect(isHttpUrl('file:///etc/passwd')).toBe(false);
});
it('rejects non-absolute or unparseable values', () => {
expect(isHttpUrl('not a url')).toBe(false);
expect(isHttpUrl('/relative/path')).toBe(false);
expect(isHttpUrl('')).toBe(false);
});
it('does not treat leading whitespace tricks as safe', () => {
// `new URL` trims leading control chars; ensure a smuggled scheme is rejected.
expect(isHttpUrl(' javascript:alert(1)')).toBe(false);
expect(isHttpUrl('java\tscript:alert(1)')).toBe(false);
});
});
describe('toSafeHref', () => {
it('returns the URL when it is http(s)', () => {
expect(toSafeHref('https://example.com')).toBe('https://example.com');
});
it('returns undefined for dangerous or empty values', () => {
expect(toSafeHref('javascript:alert(1)')).toBeUndefined();
expect(toSafeHref('data:text/html,x')).toBeUndefined();
expect(toSafeHref('')).toBeUndefined();
expect(toSafeHref(null)).toBeUndefined();
expect(toSafeHref(undefined)).toBeUndefined();
});
});
+32
View File
@@ -0,0 +1,32 @@
const ALLOWED_PROTOCOLS = ['http', 'https'];
/**
* Returns true only when `value` parses as an absolute URL using the http or
* https protocol.
*
* Zod's `.url()` accepts any parseable URL, including non-web schemes. Use this
* to restrict user-supplied URLs to http(s) before they are stored or rendered
* as a link.
*/
export const isHttpUrl = (value: string) => {
try {
const url = new URL(value);
return ALLOWED_PROTOCOLS.includes(url.protocol.slice(0, -1).toLowerCase());
} catch {
return false;
}
};
/**
* Returns the value to use for a link `href` only when it is an http(s) URL,
* otherwise `undefined`. Use this when rendering user-supplied URLs as anchors,
* including for older rows stored before URL validation was in place.
*/
export const toSafeHref = (value: string | null | undefined): string | undefined => {
if (!value || !isHttpUrl(value)) {
return undefined;
}
return value;
};
@@ -1,3 +1,4 @@
import { isHttpUrl } from '@documenso/lib/utils/is-http-url';
import { z } from 'zod';
import type { TrpcRouteMeta } from '../../trpc';
@@ -16,7 +17,7 @@ export const ZCreateAttachmentRequestSchema = z.object({
envelopeId: z.string(),
data: z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
data: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
}),
});
@@ -1,3 +1,4 @@
import { isHttpUrl } from '@documenso/lib/utils/is-http-url';
import { z } from 'zod';
import { ZSuccessResponseSchema } from '../../schema';
@@ -17,7 +18,7 @@ export const ZUpdateAttachmentRequestSchema = z.object({
id: z.string(),
data: z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
data: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
}),
});
@@ -1,4 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
@@ -41,5 +42,26 @@ export const getEnvelopeRecipientRoute = authenticatedProcedure
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: recipient.envelopeId,
},
type: null,
userId: user.id,
teamId,
});
// Additional validation to check visibility.
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
return recipient;
});
@@ -38,6 +38,15 @@ export const updateOrganisationGroupRoute = authenticatedProcedure
},
include: {
organisationGroupMembers: true,
organisation: {
include: {
members: {
select: {
id: true,
},
},
},
},
},
});
@@ -78,6 +87,15 @@ export const updateOrganisationGroupRoute = authenticatedProcedure
const groupMemberIds = unique(data.memberIds || []);
// Validate that members belong to the same organisation as the group.
groupMemberIds.forEach((memberId) => {
const member = organisationGroup.organisation.members.find(({ id }) => id === memberId);
if (!member) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
});
const membersToDelete = organisationGroup.organisationGroupMembers.filter(
(member) => !groupMemberIds.includes(member.organisationMemberId),
);