mirror of
https://github.com/documenso/documenso.git
synced 2026-07-21 07:23:37 +10:00
Merge branch 'main' into feature/pdf-placeholder-selection-fields
This commit is contained in:
+284
@@ -0,0 +1,284 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { hashString } from '@documenso/lib/server-only/auth/hash';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
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 { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
/**
|
||||
* Create an API token directly, bypassing the role check in `createApiToken`.
|
||||
*
|
||||
* This simulates a token that was minted while the user had permission, and which
|
||||
* survives a later downgrade to a lower team role (e.g. MEMBER). Such a token must
|
||||
* still respect document visibility at request time.
|
||||
*/
|
||||
const seedApiTokenForUser = async ({
|
||||
userId,
|
||||
teamId,
|
||||
tokenName,
|
||||
}: {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
tokenName: string;
|
||||
}) => {
|
||||
const token = `api_${alphaid(16)}`;
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: {
|
||||
name: tokenName,
|
||||
token: hashString(token),
|
||||
expires: null,
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
return { token };
|
||||
};
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const downloadAuditLogPdf = (request: APIRequestContext, envelopeId: string, authToken?: string) => {
|
||||
return request.get(`${API_BASE_URL}/envelope/${envelopeId}/audit-log/download`, {
|
||||
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
|
||||
});
|
||||
};
|
||||
|
||||
const downloadCertificatePdf = (request: APIRequestContext, envelopeId: string, authToken?: string) => {
|
||||
return request.get(`${API_BASE_URL}/envelope/${envelopeId}/certificate/download`, {
|
||||
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('Envelope certificate / audit log PDF download API V2 - access control', () => {
|
||||
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user: userA, team: teamA } = await seedUser());
|
||||
({ token: tokenA } = await createApiToken({
|
||||
userId: userA.id,
|
||||
teamId: teamA.id,
|
||||
tokenName: 'userA',
|
||||
expiresIn: null,
|
||||
}));
|
||||
|
||||
({ user: userB, team: teamB } = await seedUser());
|
||||
({ token: tokenB } = await createApiToken({
|
||||
userId: userB.id,
|
||||
teamId: teamB.id,
|
||||
tokenName: 'userB',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('should reject audit log download without an API token', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should reject certificate download without an API token', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should reject audit log download from a user in a different team', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id, tokenB);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should reject certificate download from a user in a different team', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id, tokenB);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should reject a disabled user downloading the audit log', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userA.id },
|
||||
data: { disabled: true },
|
||||
});
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id, tokenA);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should reject a disabled user downloading the certificate', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userA.id },
|
||||
data: { disabled: true },
|
||||
});
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id, tokenA);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should return 404 for a non-existent envelope id', async ({ request }) => {
|
||||
const auditLogRes = await downloadAuditLogPdf(request, 'envelope_doesnotexist', tokenA);
|
||||
expect(auditLogRes.status()).toBe(404);
|
||||
|
||||
const certificateRes = await downloadCertificatePdf(request, 'envelope_doesnotexist', tokenA);
|
||||
expect(certificateRes.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope certificate / audit log PDF download API V2 - document visibility', () => {
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
test('should hide an ADMIN-only document from a downgraded member (audit log)', 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,
|
||||
tokenName: 'member-audit-log-token',
|
||||
});
|
||||
|
||||
// ADMIN-visibility document owned by the team owner - a member must not see it.
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id, memberToken);
|
||||
|
||||
// Visibility failure surfaces as not-found, matching the canonical access checks.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should hide an ADMIN-only document from a downgraded member (certificate)', 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,
|
||||
tokenName: 'member-certificate-token',
|
||||
});
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id, memberToken);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should hide a MANAGER_AND_ABOVE document from a downgraded member (audit log)', 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,
|
||||
tokenName: 'member-manager-vis-token',
|
||||
});
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.MANAGER_AND_ABOVE },
|
||||
});
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id, memberToken);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should hide a MANAGER_AND_ABOVE document from a downgraded member (certificate)', 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,
|
||||
tokenName: 'member-manager-vis-cert-token',
|
||||
});
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.MANAGER_AND_ABOVE },
|
||||
});
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id, memberToken);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should hide an ADMIN-only document from a downgraded manager (certificate)', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const { token: managerToken } = await seedApiTokenForUser({
|
||||
userId: manager.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'manager-admin-vis-cert-token',
|
||||
});
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id, managerToken);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow a member to download an EVERYONE-visibility document (audit log)', 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,
|
||||
tokenName: 'member-everyone-vis-token',
|
||||
});
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.EVERYONE },
|
||||
});
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id, memberToken);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
expect(res.headers()['content-type']).toContain('application/pdf');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { OrganisationGroupType, type OrganisationMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
/**
|
||||
* Calls a tRPC mutation directly using the cookies of whoever is currently
|
||||
* signed in on the page context. This deliberately bypasses the UI: the
|
||||
* authorisation checks under test live on the server, and the UI may simply
|
||||
* hide a button rather than reject the request, which would mask a backend gap.
|
||||
*/
|
||||
const trpcMutation = async (page: Page, procedure: string, input: Record<string, unknown>) => {
|
||||
return await page.request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: JSON.stringify({ json: input }),
|
||||
});
|
||||
};
|
||||
|
||||
const getOrganisationMember = async (userId: number, organisationId: string) => {
|
||||
return await prisma.organisationMember.findFirstOrThrow({
|
||||
where: {
|
||||
userId,
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createCustomGroup = async (organisationId: string, organisationRole: OrganisationMemberRole) => {
|
||||
return await prisma.organisationGroup.create({
|
||||
data: {
|
||||
id: generateDatabaseId('org_group'),
|
||||
organisationId,
|
||||
name: `custom-${organisationRole}-${nanoid()}`,
|
||||
type: OrganisationGroupType.CUSTOM,
|
||||
organisationRole,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createPendingInvite = async (organisationId: string, organisationRole: OrganisationMemberRole) => {
|
||||
return await prisma.organisationMemberInvite.create({
|
||||
data: {
|
||||
id: generateDatabaseId('member_invite'),
|
||||
email: `invite-${nanoid()}@test.documenso.com`,
|
||||
token: nanoid(32),
|
||||
organisationId,
|
||||
organisationRole,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: member deletion', () => {
|
||||
test('a manager cannot delete an admin via member.delete', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser, adminUser] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{ name: 'Manager', organisationRole: 'MANAGER' },
|
||||
{ name: 'Admin', organisationRole: 'ADMIN' },
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const adminMember = await getOrganisationMember(adminUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.delete', {
|
||||
organisationId: organisation.id,
|
||||
organisationMemberId: adminMember.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
// The admin must still be a member of the organisation.
|
||||
const stillExists = await prisma.organisationMember.findFirst({
|
||||
where: { id: adminMember.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a manager cannot delete an admin via member.deleteMany', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser, adminUser] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{ name: 'Manager', organisationRole: 'MANAGER' },
|
||||
{ name: 'Admin', organisationRole: 'ADMIN' },
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const adminMember = await getOrganisationMember(adminUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
|
||||
organisationId: organisation.id,
|
||||
organisationMemberIds: [adminMember.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const stillExists = await prisma.organisationMember.findFirst({
|
||||
where: { id: adminMember.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a manager cannot delete the organisation owner', async ({ page }) => {
|
||||
const { user: ownerUser, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const ownerMember = await getOrganisationMember(ownerUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
|
||||
organisationId: organisation.id,
|
||||
organisationMemberIds: [ownerMember.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const stillExists = await prisma.organisationMember.findFirst({
|
||||
where: { id: ownerMember.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('an admin cannot delete the organisation owner', async ({ page }) => {
|
||||
const { user: ownerUser, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [adminUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Admin', organisationRole: 'ADMIN' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const ownerMember = await getOrganisationMember(ownerUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
|
||||
organisationId: organisation.id,
|
||||
organisationMemberIds: [ownerMember.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const stillExists = await prisma.organisationMember.findFirst({
|
||||
where: { id: ownerMember.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a manager can still delete a regular member (positive control)', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser, memberUser] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{ name: 'Manager', organisationRole: 'MANAGER' },
|
||||
{ name: 'Member', organisationRole: 'MEMBER' },
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const member = await getOrganisationMember(memberUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
|
||||
organisationId: organisation.id,
|
||||
organisationMemberIds: [member.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const deleted = await prisma.organisationMember.findFirst({
|
||||
where: { id: member.id },
|
||||
});
|
||||
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: group deletion', () => {
|
||||
test('a manager cannot delete an admin-role group', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const adminGroup = await createCustomGroup(organisation.id, 'ADMIN');
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.group.delete', {
|
||||
organisationId: organisation.id,
|
||||
groupId: adminGroup.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const stillExists = await prisma.organisationGroup.findFirst({
|
||||
where: { id: adminGroup.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a manager can delete a member-role group (positive control)', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const memberGroup = await createCustomGroup(organisation.id, 'MEMBER');
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.group.delete', {
|
||||
organisationId: organisation.id,
|
||||
groupId: memberGroup.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const deleted = await prisma.organisationGroup.findFirst({
|
||||
where: { id: memberGroup.id },
|
||||
});
|
||||
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: invite resend', () => {
|
||||
test('a manager cannot resend an admin-role invite', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const adminInvite = await createPendingInvite(organisation.id, 'ADMIN');
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.invite.resend', {
|
||||
organisationId: organisation.id,
|
||||
invitationId: adminInvite.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('a manager can resend a member-role invite (positive control)', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const memberInvite = await createPendingInvite(organisation.id, 'MEMBER');
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.invite.resend', {
|
||||
organisationId: organisation.id,
|
||||
invitationId: memberInvite.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: leaving an organisation', () => {
|
||||
test('the owner cannot leave without transferring ownership first', async ({ page }) => {
|
||||
const { user: ownerUser, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const ownerMember = await getOrganisationMember(ownerUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: ownerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.leave', {
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const stillExists = await prisma.organisationMember.findFirst({
|
||||
where: { id: ownerMember.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a non-owner member can still leave (positive control)', async ({ page }) => {
|
||||
const { 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);
|
||||
|
||||
await apiSignin({ page, email: memberUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.leave', {
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const deleted = await prisma.organisationMember.findFirst({
|
||||
where: { id: member.id },
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -30,9 +30,10 @@ export const AccessAuth2FAEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -21,8 +21,9 @@ export const AdminUserCreatedTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -24,11 +24,14 @@ export const BulkSendCompleteEmail = ({
|
||||
}: BulkSendCompleteEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const previewText = msg`Bulk send operation complete for template "${templateName}"`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(msg`Bulk send operation complete for template "${templateName}"`)}</Preview>
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -18,8 +18,9 @@ export const ConfirmEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -30,9 +30,9 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -23,9 +23,10 @@ export const DocumentCancelTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -26,9 +26,9 @@ export const DocumentCompletedEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
|
||||
@@ -33,9 +33,9 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
|
||||
@@ -56,9 +56,10 @@ export const DocumentInviteEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
@@ -85,7 +86,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>
|
||||
|
||||
@@ -20,9 +20,9 @@ export const DocumentPendingEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -28,9 +28,9 @@ export const DocumentRecipientSignedEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
|
||||
@@ -28,9 +28,10 @@ export function DocumentRejectedEmail({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -28,9 +28,10 @@ export function DocumentRejectionConfirmedEmail({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -37,9 +37,10 @@ export const DocumentReminderEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -20,9 +20,9 @@ export const DocumentSelfSignedEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
|
||||
@@ -23,9 +23,10 @@ export const DocumentSuperDeleteEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -20,9 +20,10 @@ export const ForgotPasswordTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -30,8 +30,9 @@ export const OrganisationAccountLinkConfirmationTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -34,9 +34,9 @@ export const OrganisationDeleteEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -29,9 +29,9 @@ export const OrganisationInviteEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -31,9 +31,9 @@ export const OrganisationJoinEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -31,9 +31,9 @@ export const OrganisationLeaveEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -29,9 +29,9 @@ export const OrganisationLimitAlertEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -23,9 +23,10 @@ export const RecipientExpiredTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -22,9 +22,10 @@ export const RecipientRemovedFromDocumentTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -22,9 +22,10 @@ export const ResetPasswordTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
|
||||
@@ -29,9 +29,9 @@ export const TeamDeleteEmailTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -30,9 +30,9 @@ export const TeamEmailRemovedTemplate = ({
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -85,6 +85,7 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
|
||||
// Purge all internal organisation groups that have no teams.
|
||||
await tx.organisationGroup.deleteMany({
|
||||
where: {
|
||||
organisationId: team.organisationId,
|
||||
type: OrganisationGroupType.INTERNAL_TEAM,
|
||||
teamGroups: {
|
||||
none: {},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -71,10 +71,17 @@ const isBypassedHost = (url: string): boolean => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Asserts that a webhook URL does not resolve to a private or loopback
|
||||
* address. Throws an AppError with WEBHOOK_INVALID_REQUEST if it does.
|
||||
* Assert that a webhook URL does not point at a private/loopback address,
|
||||
* checking both the literal host and its resolved DNS records. Throws an
|
||||
* AppError with WEBHOOK_INVALID_REQUEST if it does. Hosts listed in
|
||||
* NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks.
|
||||
*
|
||||
* Hosts listed in NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks.
|
||||
* This is best-effort, non-exhaustive SSRF defence, NOT a complete mitigation.
|
||||
* It does not cover DNS rebinding (the resolved address can change between this
|
||||
* check and the actual request), obscure IP encodings, or every IPv6 form, and
|
||||
* it fails open on lookup errors/timeouts (see the catch below). Network-level
|
||||
* SSRF protection (firewall/egress rules, blocking internal services and cloud
|
||||
* metadata endpoints) remains the responsibility of the deployment.
|
||||
*/
|
||||
export const assertNotPrivateUrl = async (
|
||||
url: string,
|
||||
|
||||
@@ -3,10 +3,11 @@ import { z } from 'zod';
|
||||
const ZIpSchema = z.string().ip();
|
||||
|
||||
/**
|
||||
* Check whether a URL points to a known private/loopback address.
|
||||
* Synchronously check whether a URL's host is a known private/loopback address
|
||||
* (localhost, RFC 1918, link-local, loopback, etc.), regardless of protocol.
|
||||
*
|
||||
* Performs a synchronous check against known private hostnames and IP ranges.
|
||||
* Works regardless of the URL protocol.
|
||||
* Best-effort and non-exhaustive: unrecognised or unparseable hosts return
|
||||
* `false` (fail open). See `assertNotPrivateUrl` for the full SSRF caveats.
|
||||
*/
|
||||
export const isPrivateUrl = (url: string): boolean => {
|
||||
try {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -15,18 +15,18 @@ export const downloadDocumentAuditLogsRoute = authenticatedProcedure
|
||||
.output(ZDownloadDocumentAuditLogsResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const { documentId } = input;
|
||||
const { envelopeId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
documentId,
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
const envelope = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: ctx.user.id,
|
||||
@@ -55,10 +55,8 @@ export const downloadDocumentAuditLogsRoute = authenticatedProcedure
|
||||
|
||||
const result = await certificatePdf.save();
|
||||
|
||||
const base64 = Buffer.from(result).toString('base64');
|
||||
|
||||
return {
|
||||
data: base64,
|
||||
data: Buffer.from(result).toString('base64'),
|
||||
envelopeTitle: envelope.title,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDownloadDocumentAuditLogsRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
envelopeId: z.string(),
|
||||
});
|
||||
|
||||
export const ZDownloadDocumentAuditLogsResponseSchema = z.object({
|
||||
|
||||
@@ -17,18 +17,18 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure
|
||||
.output(ZDownloadDocumentCertificateResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const { documentId } = input;
|
||||
const { envelopeId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
documentId,
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: ctx.user.id,
|
||||
@@ -81,10 +81,8 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure
|
||||
|
||||
const result = await certificatePdf.save();
|
||||
|
||||
const base64 = Buffer.from(result).toString('base64');
|
||||
|
||||
return {
|
||||
data: base64,
|
||||
data: Buffer.from(result).toString('base64'),
|
||||
envelopeTitle: envelope.title,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDownloadDocumentCertificateRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
envelopeId: z.string(),
|
||||
});
|
||||
|
||||
export const ZDownloadDocumentCertificateResponseSchema = z.object({
|
||||
|
||||
@@ -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'),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
downloadEnvelopeAuditLogPdfMeta,
|
||||
ZDownloadEnvelopeAuditLogPdfRequestSchema,
|
||||
ZDownloadEnvelopeAuditLogPdfResponseSchema,
|
||||
} from './download-envelope-audit-log-pdf.types';
|
||||
|
||||
export const downloadEnvelopeAuditLogPdfRoute = authenticatedProcedure
|
||||
.meta(downloadEnvelopeAuditLogPdfMeta)
|
||||
.input(ZDownloadEnvelopeAuditLogPdfRequestSchema)
|
||||
.output(ZDownloadEnvelopeAuditLogPdfResponseSchema)
|
||||
.query(({ input, ctx }) => {
|
||||
const { envelopeId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
// This endpoint is purely for V2 API, which is implemented in the Hono remix server.
|
||||
throw new Error('NOT_IMPLEMENTED');
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const downloadEnvelopeAuditLogPdfMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/envelope/{envelopeId}/audit-log/download',
|
||||
summary: 'Download envelope audit log PDF',
|
||||
description: 'Download the audit log for a document as a PDF.',
|
||||
tags: ['Envelope'],
|
||||
responseHeaders: z.object({
|
||||
'Content-Type': z.literal('application/pdf'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const ZDownloadEnvelopeAuditLogPdfRequestSchema = z.object({
|
||||
envelopeId: z.string().describe('The ID of the envelope to download the audit log for.'),
|
||||
});
|
||||
|
||||
export const ZDownloadEnvelopeAuditLogPdfResponseSchema = z.instanceof(Uint8Array);
|
||||
|
||||
export type TDownloadEnvelopeAuditLogPdfRequest = z.infer<typeof ZDownloadEnvelopeAuditLogPdfRequestSchema>;
|
||||
export type TDownloadEnvelopeAuditLogPdfResponse = z.infer<typeof ZDownloadEnvelopeAuditLogPdfResponseSchema>;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
downloadEnvelopeCertificatePdfMeta,
|
||||
ZDownloadEnvelopeCertificatePdfRequestSchema,
|
||||
ZDownloadEnvelopeCertificatePdfResponseSchema,
|
||||
} from './download-envelope-certificate-pdf.types';
|
||||
|
||||
export const downloadEnvelopeCertificatePdfRoute = authenticatedProcedure
|
||||
.meta(downloadEnvelopeCertificatePdfMeta)
|
||||
.input(ZDownloadEnvelopeCertificatePdfRequestSchema)
|
||||
.output(ZDownloadEnvelopeCertificatePdfResponseSchema)
|
||||
.query(({ input, ctx }) => {
|
||||
const { envelopeId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
// This endpoint is purely for V2 API, which is implemented in the Hono remix server.
|
||||
throw new Error('NOT_IMPLEMENTED');
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const downloadEnvelopeCertificatePdfMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/envelope/{envelopeId}/certificate/download',
|
||||
summary: 'Download envelope certificate PDF',
|
||||
description: 'Download the signing certificate for a completed document as a PDF.',
|
||||
tags: ['Envelope'],
|
||||
responseHeaders: z.object({
|
||||
'Content-Type': z.literal('application/pdf'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const ZDownloadEnvelopeCertificatePdfRequestSchema = z.object({
|
||||
envelopeId: z.string().describe('The ID of the envelope to download the certificate for.'),
|
||||
});
|
||||
|
||||
export const ZDownloadEnvelopeCertificatePdfResponseSchema = z.instanceof(Uint8Array);
|
||||
|
||||
export type TDownloadEnvelopeCertificatePdfRequest = z.infer<typeof ZDownloadEnvelopeCertificatePdfRequestSchema>;
|
||||
export type TDownloadEnvelopeCertificatePdfResponse = z.infer<typeof ZDownloadEnvelopeCertificatePdfResponseSchema>;
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@ import { createEnvelopeItemsRoute } from './create-envelope-items';
|
||||
import { deleteEnvelopeRoute } from './delete-envelope';
|
||||
import { deleteEnvelopeItemRoute } from './delete-envelope-item';
|
||||
import { distributeEnvelopeRoute } from './distribute-envelope';
|
||||
import { downloadEnvelopeAuditLogPdfRoute } from './download-envelope-audit-log-pdf';
|
||||
import { downloadEnvelopeCertificatePdfRoute } from './download-envelope-certificate-pdf';
|
||||
import { downloadEnvelopeItemRoute } from './download-envelope-item';
|
||||
import { duplicateEnvelopeRoute } from './duplicate-envelope';
|
||||
import { createEnvelopeFieldsRoute } from './envelope-fields/create-envelope-fields';
|
||||
@@ -85,6 +87,10 @@ export const envelopeRouter = router({
|
||||
find: findEnvelopesRoute,
|
||||
auditLog: {
|
||||
find: findEnvelopeAuditLogsRoute,
|
||||
downloadPdf: downloadEnvelopeAuditLogPdfRoute,
|
||||
},
|
||||
certificate: {
|
||||
downloadPdf: downloadEnvelopeCertificatePdfRoute,
|
||||
},
|
||||
bulk: {
|
||||
move: bulkMoveEnvelopesRoute,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles';
|
||||
import { buildOrganisationWhereQuery, isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationGroupType } from '@prisma/client';
|
||||
|
||||
@@ -59,6 +60,22 @@ export const deleteOrganisationGroupRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
const currentUserOrganisationRole = await getMemberOrganisationRole({
|
||||
organisationId,
|
||||
reference: {
|
||||
type: 'User',
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// A user cannot delete a group whose role is higher than their own
|
||||
// (e.g. a manager deleting an admin-role group).
|
||||
if (!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, group.organisationRole)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You are not allowed to delete this organisation group',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.organisationGroup.delete({
|
||||
where: {
|
||||
id: groupId,
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import {
|
||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
|
||||
} from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import {
|
||||
buildOrganisationWhereQuery,
|
||||
getHighestOrganisationRoleInGroup,
|
||||
isOrganisationRoleWithinUserHierarchy,
|
||||
} from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
|
||||
|
||||
@@ -60,9 +67,12 @@ export const deleteOrganisationMembers = async ({
|
||||
},
|
||||
},
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
include: {
|
||||
organisationGroupMembers: {
|
||||
include: {
|
||||
group: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invites: {
|
||||
@@ -84,6 +94,41 @@ export const deleteOrganisationMembers = async ({
|
||||
|
||||
const membersToDelete = organisation.members.filter((member) => organisationMemberIds.includes(member.id));
|
||||
|
||||
const currentUserMember = organisation.members.find((member) => member.userId === userId);
|
||||
|
||||
if (!currentUserMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
const currentUserOrganisationRole = getHighestOrganisationRoleInGroup(
|
||||
currentUserMember.organisationGroupMembers.map(({ group }) => group),
|
||||
);
|
||||
|
||||
// The roles the current user is allowed to act on (their own role and below).
|
||||
const manageableOrganisationRoles = ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserOrganisationRole];
|
||||
|
||||
for (const member of membersToDelete) {
|
||||
// The organisation owner can never be removed via this route. Ownership must
|
||||
// be transferred first (mirrors the admin and update-member routes).
|
||||
if (member.userId === organisation.ownerUserId) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Cannot remove the organisation owner',
|
||||
});
|
||||
}
|
||||
|
||||
const memberOrganisationRole = getHighestOrganisationRoleInGroup(
|
||||
member.organisationGroupMembers.map(({ group }) => group),
|
||||
);
|
||||
|
||||
// A user cannot remove a member whose role is higher than their own
|
||||
// (e.g. a manager removing an admin).
|
||||
if (!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, memberOrganisationRole)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Cannot remove a member with a higher role',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const inviteCount = organisation.invites.length;
|
||||
const newMemberCount = organisation.members.length + inviteCount - membersToDelete.length;
|
||||
|
||||
@@ -123,6 +168,18 @@ export const deleteOrganisationMembers = async ({
|
||||
in: organisationMemberIds,
|
||||
},
|
||||
organisationId,
|
||||
userId: {
|
||||
not: organisation.ownerUserId,
|
||||
},
|
||||
organisationGroupMembers: {
|
||||
none: {
|
||||
group: {
|
||||
organisationRole: {
|
||||
notIn: manageableOrganisationRoles,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,14 @@ export const leaveOrganisationRoute = authenticatedProcedure
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
// The organisation owner cannot leave their own organisation. Ownership must
|
||||
// be transferred to another member first.
|
||||
if (organisation.ownerUserId === userId) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You cannot leave an organisation you own. Please transfer ownership first.',
|
||||
});
|
||||
}
|
||||
|
||||
const { organisationClaim } = organisation;
|
||||
|
||||
const inviteCount = organisation.invites.length;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { sendOrganisationMemberInviteEmail } from '@documenso/lib/server-only/organisation/create-organisation-member-invites';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles';
|
||||
import { buildOrganisationWhereQuery, isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
@@ -97,6 +98,21 @@ export const resendOrganisationMemberInvitation = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const currentUserOrganisationRole = await getMemberOrganisationRole({
|
||||
organisationId: organisation.id,
|
||||
reference: {
|
||||
type: 'User',
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// A user cannot interact with an invitation that is not within their own hierarchy.
|
||||
if (!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, invitation.organisationRole)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You cannot resend an invite for a member with a higher role',
|
||||
});
|
||||
}
|
||||
|
||||
await sendOrganisationMemberInviteEmail({
|
||||
email: invitation.email,
|
||||
token: invitation.token,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user