Merge branch 'main' into feat/expiry-links

This commit is contained in:
Lucas Smith
2025-10-06 16:39:34 +11:00
committed by GitHub
183 changed files with 10954 additions and 1513 deletions

View File

@ -0,0 +1,124 @@
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZPromoteMemberToOwnerRequestSchema,
ZPromoteMemberToOwnerResponseSchema,
} from './promote-member-to-owner.types';
export const promoteMemberToOwnerRoute = adminProcedure
.input(ZPromoteMemberToOwnerRequestSchema)
.output(ZPromoteMemberToOwnerResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, userId } = input;
ctx.logger.info({
input: {
organisationId,
userId,
},
});
// First, verify the organisation exists and get member details with groups
const organisation = await prisma.organisation.findUnique({
where: {
id: organisationId,
},
include: {
groups: {
where: {
type: OrganisationGroupType.INTERNAL_ORGANISATION,
},
},
members: {
where: {
userId,
},
include: {
organisationGroupMembers: {
include: {
group: true,
},
},
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
// Verify the user is a member of the organisation
const [member] = organisation.members;
if (!member) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User is not a member of this organisation',
});
}
// Verify the user is not already the owner
if (organisation.ownerUserId === userId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'User is already the owner of this organisation',
});
}
// Get current organisation role
const currentOrganisationRole = getHighestOrganisationRoleInGroup(
member.organisationGroupMembers.flatMap((member) => member.group),
);
// Find the current and target organisation groups
const currentMemberGroup = organisation.groups.find(
(group) => group.organisationRole === currentOrganisationRole,
);
const adminGroup = organisation.groups.find(
(group) => group.organisationRole === OrganisationMemberRole.ADMIN,
);
if (!currentMemberGroup) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Current member group not found',
});
}
if (!adminGroup) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Admin group not found',
});
}
// Update the organisation owner and member role in a transaction
await prisma.$transaction(async (tx) => {
// Update the organisation to set the new owner
await tx.organisation.update({
where: {
id: organisationId,
},
data: {
ownerUserId: userId,
},
});
// Only update role if the user is not already an admin then add them to the admin group
if (currentOrganisationRole !== OrganisationMemberRole.ADMIN) {
await tx.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
organisationMemberId: member.id,
groupId: adminGroup.id,
},
});
}
});
});

View File

@ -0,0 +1,11 @@
import { z } from 'zod';
export const ZPromoteMemberToOwnerRequestSchema = z.object({
organisationId: z.string().min(1),
userId: z.number().min(1),
});
export const ZPromoteMemberToOwnerResponseSchema = z.void();
export type TPromoteMemberToOwnerRequest = z.infer<typeof ZPromoteMemberToOwnerRequestSchema>;
export type TPromoteMemberToOwnerResponse = z.infer<typeof ZPromoteMemberToOwnerResponseSchema>;

View File

@ -12,6 +12,7 @@ import { findDocumentsRoute } from './find-documents';
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
import { getAdminOrganisationRoute } from './get-admin-organisation';
import { getUserRoute } from './get-user';
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
import { resealDocumentRoute } from './reseal-document';
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import { updateAdminOrganisationRoute } from './update-admin-organisation';
@ -27,6 +28,9 @@ export const adminRouter = router({
create: createAdminOrganisationRoute,
update: updateAdminOrganisationRoute,
},
organisationMember: {
promoteToOwner: promoteMemberToOwnerRoute,
},
claims: {
find: findSubscriptionClaimsRoute,
create: createSubscriptionClaimRoute,

View File

@ -0,0 +1,94 @@
import { TRPCError } from '@trpc/server';
import { DateTime } from 'luxon';
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from '@documenso/lib/server-only/2fa/email/constants';
import { send2FATokenEmail } from '@documenso/lib/server-only/2fa/email/send-2fa-token-email';
import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { procedure } from '../trpc';
import {
ZAccessAuthRequest2FAEmailRequestSchema,
ZAccessAuthRequest2FAEmailResponseSchema,
} from './access-auth-request-2fa-email.types';
export const accessAuthRequest2FAEmailRoute = procedure
.input(ZAccessAuthRequest2FAEmailRequestSchema)
.output(ZAccessAuthRequest2FAEmailResponseSchema)
.mutation(async ({ input, ctx }) => {
try {
const { token } = input;
const user = ctx.user;
// Get document and recipient by token
const document = await prisma.document.findFirst({
where: {
recipients: {
some: {
token,
},
},
},
include: {
recipients: {
where: {
token,
},
},
},
});
if (!document) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Document not found',
});
}
const [recipient] = document.recipients;
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
if (!derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '2FA is not required for this document',
});
}
// if (user && recipient.email !== user.email) {
// throw new TRPCError({
// code: 'UNAUTHORIZED',
// message: 'User does not match recipient',
// });
// }
const expiresAt = DateTime.now().plus({ minutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES });
await send2FATokenEmail({
token,
documentId: document.id,
});
return {
success: true,
expiresAt: expiresAt.toJSDate(),
};
} catch (error) {
console.error('Error sending access auth 2FA email:', error);
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to send 2FA email',
});
}
});

View File

@ -0,0 +1,17 @@
import { z } from 'zod';
export const ZAccessAuthRequest2FAEmailRequestSchema = z.object({
token: z.string().min(1),
});
export const ZAccessAuthRequest2FAEmailResponseSchema = z.object({
success: z.boolean(),
expiresAt: z.date(),
});
export type TAccessAuthRequest2FAEmailRequest = z.infer<
typeof ZAccessAuthRequest2FAEmailRequestSchema
>;
export type TAccessAuthRequest2FAEmailResponse = z.infer<
typeof ZAccessAuthRequest2FAEmailResponseSchema
>;

View File

@ -82,14 +82,7 @@ export const ZCreateDocumentTemporaryRequestSchema = z.object({
.optional(),
}),
)
.refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{ message: 'Recipients must have unique emails' },
)
.optional(),
meta: z
.object({

View File

@ -1,4 +1,5 @@
import { router } from '../trpc';
import { accessAuthRequest2FAEmailRoute } from './access-auth-request-2fa-email';
import { createDocumentRoute } from './create-document';
import { createDocumentTemporaryRoute } from './create-document-temporary';
import { deleteDocumentRoute } from './delete-document';
@ -38,6 +39,10 @@ export const documentRouter = router({
getDocumentByToken: getDocumentByTokenRoute,
findDocumentsInternal: findDocumentsInternalRoute,
accessAuth: router({
request2FAEmail: accessAuthRequest2FAEmailRoute,
}),
auditLog: {
find: findDocumentAuditLogsRoute,
download: downloadDocumentAuditLogsRoute,

View File

@ -28,6 +28,7 @@ export const ZDocumentTitleSchema = z
export const ZDocumentExternalIdSchema = z
.string()
.trim()
.max(255)
.describe('The external ID of the document.');
export const ZDocumentVisibilitySchema = z
@ -65,10 +66,12 @@ export const ZDocumentMetaLanguageSchema = z
export const ZDocumentMetaSubjectSchema = z
.string()
.max(254)
.describe('The subject of the email that will be sent to the recipients.');
export const ZDocumentMetaMessageSchema = z
.string()
.max(5000)
.describe('The message of the email that will be sent to the recipients.');
export const ZDocumentMetaDistributionMethodSchema = z

View File

@ -47,14 +47,7 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({
.optional(),
}),
)
.refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{ message: 'Recipients must have unique emails' },
)
.optional(),
meta: z
.object({

View File

@ -30,36 +30,27 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
documentId: z.number(),
title: ZDocumentTitleSchema,
externalId: ZDocumentExternalIdSchema.optional(),
recipients: z
.array(
z.object({
id: z.number().optional(),
email: z.string().toLowerCase().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
fields: ZFieldAndMetaSchema.and(
z.object({
id: z.number().optional(),
pageNumber: ZFieldPageNumberSchema,
pageX: ZFieldPageXSchema,
pageY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
}),
)
.array()
.optional(),
}),
)
.refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{ message: 'Recipients must have unique emails' },
),
recipients: z.array(
z.object({
id: z.number().optional(),
email: z.string().toLowerCase().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
fields: ZFieldAndMetaSchema.and(
z.object({
id: z.number().optional(),
pageNumber: ZFieldPageNumberSchema,
pageX: ZFieldPageXSchema,
pageY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
}),
)
.array()
.optional(),
}),
),
meta: z
.object({
subject: ZDocumentMetaSubjectSchema.optional(),

View File

@ -2,7 +2,7 @@ import { z } from 'zod';
import { ZEmailDomainSchema } from '@documenso/lib/types/email-domain';
const domainRegex =
export const domainRegex =
/^(?!https?:\/\/)(?!www\.)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
export const ZDomainSchema = z

View File

@ -0,0 +1,25 @@
import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@documenso/lib/constants/organisations';
import { prisma } from '@documenso/prisma';
import { procedure } from '../trpc';
import {
ZDeclineLinkOrganisationAccountRequestSchema,
ZDeclineLinkOrganisationAccountResponseSchema,
} from './decline-link-organisation-account.types';
/**
* Unauthenicated procedure, do not copy paste.
*/
export const declineLinkOrganisationAccountRoute = procedure
.input(ZDeclineLinkOrganisationAccountRequestSchema)
.output(ZDeclineLinkOrganisationAccountResponseSchema)
.mutation(async ({ input }) => {
const { token } = input;
await prisma.verificationToken.delete({
where: {
token,
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
},
});
});

View File

@ -0,0 +1,11 @@
import { z } from 'zod';
export const ZDeclineLinkOrganisationAccountRequestSchema = z.object({
token: z.string(),
});
export const ZDeclineLinkOrganisationAccountResponseSchema = z.void();
export type TDeclineLinkOrganisationAccountRequest = z.infer<
typeof ZDeclineLinkOrganisationAccountRequestSchema
>;

View File

@ -0,0 +1,84 @@
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 { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZGetOrganisationAuthenticationPortalRequestSchema,
ZGetOrganisationAuthenticationPortalResponseSchema,
} from './get-organisation-authentication-portal.types';
export const getOrganisationAuthenticationPortalRoute = authenticatedProcedure
.input(ZGetOrganisationAuthenticationPortalRequestSchema)
.output(ZGetOrganisationAuthenticationPortalResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId } = input;
ctx.logger.info({
input: {
organisationId,
},
});
return await getOrganisationAuthenticationPortal({
userId: ctx.user.id,
organisationId,
});
});
type GetOrganisationAuthenticationPortalOptions = {
userId: number;
organisationId: string;
};
export const getOrganisationAuthenticationPortal = async ({
userId,
organisationId,
}: GetOrganisationAuthenticationPortalOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
organisationClaim: true,
organisationAuthenticationPortal: {
select: {
defaultOrganisationRole: true,
enabled: true,
clientId: true,
wellKnownUrl: true,
autoProvisionUsers: true,
allowedDomains: true,
clientSecret: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
if (!organisation.organisationClaim.flags.authenticationPortal) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Authentication portal not found',
});
}
const portal = organisation.organisationAuthenticationPortal;
return {
defaultOrganisationRole: portal.defaultOrganisationRole,
enabled: portal.enabled,
clientId: portal.clientId,
wellKnownUrl: portal.wellKnownUrl,
autoProvisionUsers: portal.autoProvisionUsers,
allowedDomains: portal.allowedDomains,
clientSecretProvided: Boolean(portal.clientSecret),
};
};

View File

@ -0,0 +1,28 @@
import { z } from 'zod';
import { OrganisationAuthenticationPortalSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationAuthenticationPortalSchema';
export const ZGetOrganisationAuthenticationPortalRequestSchema = z.object({
organisationId: z.string(),
});
export const ZGetOrganisationAuthenticationPortalResponseSchema =
OrganisationAuthenticationPortalSchema.pick({
defaultOrganisationRole: true,
enabled: true,
clientId: true,
wellKnownUrl: true,
autoProvisionUsers: true,
allowedDomains: true,
}).extend({
/**
* Whether we have the client secret in the database.
*
* Do not expose the actual client secret.
*/
clientSecretProvided: z.boolean(),
});
export type TGetOrganisationAuthenticationPortalResponse = z.infer<
typeof ZGetOrganisationAuthenticationPortalResponseSchema
>;

View File

@ -0,0 +1,22 @@
import { linkOrganisationAccount } from '@documenso/ee/server-only/lib/link-organisation-account';
import { procedure } from '../trpc';
import {
ZLinkOrganisationAccountRequestSchema,
ZLinkOrganisationAccountResponseSchema,
} from './link-organisation-account.types';
/**
* Unauthenicated procedure, do not copy paste.
*/
export const linkOrganisationAccountRoute = procedure
.input(ZLinkOrganisationAccountRequestSchema)
.output(ZLinkOrganisationAccountResponseSchema)
.mutation(async ({ input, ctx }) => {
const { token } = input;
await linkOrganisationAccount({
token,
requestMeta: ctx.metadata.requestMetadata,
});
});

View File

@ -0,0 +1,9 @@
import { z } from 'zod';
export const ZLinkOrganisationAccountRequestSchema = z.object({
token: z.string(),
});
export const ZLinkOrganisationAccountResponseSchema = z.void();
export type TLinkOrganisationAccountRequest = z.infer<typeof ZLinkOrganisationAccountRequestSchema>;

View File

@ -2,15 +2,19 @@ import { router } from '../trpc';
import { createOrganisationEmailRoute } from './create-organisation-email';
import { createOrganisationEmailDomainRoute } from './create-organisation-email-domain';
import { createSubscriptionRoute } from './create-subscription';
import { declineLinkOrganisationAccountRoute } from './decline-link-organisation-account';
import { deleteOrganisationEmailRoute } from './delete-organisation-email';
import { deleteOrganisationEmailDomainRoute } from './delete-organisation-email-domain';
import { findOrganisationEmailDomainsRoute } from './find-organisation-email-domain';
import { findOrganisationEmailsRoute } from './find-organisation-emails';
import { getInvoicesRoute } from './get-invoices';
import { getOrganisationAuthenticationPortalRoute } from './get-organisation-authentication-portal';
import { getOrganisationEmailDomainRoute } from './get-organisation-email-domain';
import { getPlansRoute } from './get-plans';
import { getSubscriptionRoute } from './get-subscription';
import { linkOrganisationAccountRoute } from './link-organisation-account';
import { manageSubscriptionRoute } from './manage-subscription';
import { updateOrganisationAuthenticationPortalRoute } from './update-organisation-authentication-portal';
import { updateOrganisationEmailRoute } from './update-organisation-email';
import { verifyOrganisationEmailDomainRoute } from './verify-organisation-email-domain';
@ -29,6 +33,12 @@ export const enterpriseRouter = router({
delete: deleteOrganisationEmailDomainRoute,
verify: verifyOrganisationEmailDomainRoute,
},
authenticationPortal: {
get: getOrganisationAuthenticationPortalRoute,
update: updateOrganisationAuthenticationPortalRoute,
linkAccount: linkOrganisationAccountRoute,
declineLinkAccount: declineLinkOrganisationAccountRoute,
},
},
billing: {
plans: {

View File

@ -0,0 +1,109 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateOrganisationAuthenticationPortalRequestSchema,
ZUpdateOrganisationAuthenticationPortalResponseSchema,
} from './update-organisation-authentication-portal.types';
export const updateOrganisationAuthenticationPortalRoute = authenticatedProcedure
.input(ZUpdateOrganisationAuthenticationPortalRequestSchema)
.output(ZUpdateOrganisationAuthenticationPortalResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, data } = input;
const { user } = ctx;
ctx.logger.info({
input: {
organisationId,
},
});
if (!IS_BILLING_ENABLED()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Billing is not enabled',
});
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
organisationAuthenticationPortal: true,
organisationClaim: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
if (!organisation.organisationClaim.flags.authenticationPortal) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Authentication portal is not allowed for this organisation',
});
}
const {
defaultOrganisationRole,
enabled,
clientId,
clientSecret,
wellKnownUrl,
autoProvisionUsers,
allowedDomains,
} = data;
if (
enabled &&
(!wellKnownUrl ||
!clientId ||
(!clientSecret && !organisation.organisationAuthenticationPortal.clientSecret))
) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message:
'Client ID, client secret, and well known URL are required when authentication portal is enabled',
});
}
// Allow empty string to be passed in to remove the client secret from the database.
let encryptedClientSecret: string | undefined = clientSecret;
// Encrypt the secret if it is provided.
if (clientSecret) {
const encryptionKey = DOCUMENSO_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
}
encryptedClientSecret = symmetricEncrypt({
key: encryptionKey,
data: clientSecret,
});
}
await prisma.organisationAuthenticationPortal.update({
where: {
id: organisation.organisationAuthenticationPortal.id,
},
data: {
defaultOrganisationRole,
enabled,
clientId,
clientSecret: encryptedClientSecret,
wellKnownUrl,
autoProvisionUsers,
allowedDomains,
},
});
});

View File

@ -0,0 +1,24 @@
import { z } from 'zod';
import OrganisationMemberRoleSchema from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
import { domainRegex } from './create-organisation-email-domain.types';
export const ZUpdateOrganisationAuthenticationPortalRequestSchema = z.object({
organisationId: z.string(),
data: z.object({
defaultOrganisationRole: OrganisationMemberRoleSchema,
enabled: z.boolean(),
clientId: z.string(),
clientSecret: z.string().optional(),
wellKnownUrl: z.union([z.string().url(), z.literal('')]),
autoProvisionUsers: z.boolean(),
allowedDomains: z.array(z.string().regex(domainRegex)),
}),
});
export const ZUpdateOrganisationAuthenticationPortalResponseSchema = z.void();
export type TUpdateOrganisationAuthenticationPortalRequest = z.infer<
typeof ZUpdateOrganisationAuthenticationPortalRequestSchema
>;

View File

@ -274,6 +274,7 @@ export const fieldRouter = router({
fields: fields.map((field) => ({
id: field.nativeId,
signerEmail: field.signerEmail,
recipientId: field.recipientId,
type: field.type,
pageNumber: field.pageNumber,
pageX: field.pageX,
@ -513,6 +514,7 @@ export const fieldRouter = router({
fields: fields.map((field) => ({
id: field.nativeId,
signerEmail: field.signerEmail,
recipientId: field.recipientId,
type: field.type,
pageNumber: field.pageNumber,
pageX: field.pageX,

View File

@ -114,6 +114,7 @@ export const ZSetDocumentFieldsRequestSchema = z.object({
nativeId: z.number().optional(),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
recipientId: z.number().min(1),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
@ -136,6 +137,7 @@ export const ZSetFieldsForTemplateRequestSchema = z.object({
nativeId: z.number().optional(),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
recipientId: z.number().min(1),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),

View File

@ -1,4 +1,7 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import {
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
ORGANISATION_USER_ACCOUNT_TYPE,
} from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
@ -37,9 +40,18 @@ export const deleteOrganisationRoute = authenticatedProcedure
});
}
await prisma.organisation.delete({
where: {
id: organisation.id,
},
await prisma.$transaction(async (tx) => {
await tx.account.deleteMany({
where: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: organisation.id,
},
});
await tx.organisation.delete({
where: {
id: organisation.id,
},
});
});
});

View File

@ -1,3 +1,5 @@
import { OrganisationType } from '@prisma/client';
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';
@ -104,6 +106,19 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
});
}
const isPersonalOrganisation = organisation.type === OrganisationType.PERSONAL;
const currentIncludeSenderDetails =
organisation.organisationGlobalSettings.includeSenderDetails;
const isChangingIncludeSenderDetails =
includeSenderDetails !== undefined && includeSenderDetails !== currentIncludeSenderDetails;
if (isPersonalOrganisation && isChangingIncludeSenderDetails) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Personal organisations cannot update the sender details',
});
}
await prisma.organisation.update({
where: {
id: organisationId,

View File

@ -0,0 +1,34 @@
import { getRecipientSuggestions } from '@documenso/lib/server-only/recipient/get-recipient-suggestions';
import { authenticatedProcedure } from '../trpc';
import {
ZGetRecipientSuggestionsRequestSchema,
ZGetRecipientSuggestionsResponseSchema,
} from './find-recipient-suggestions.types';
/**
* @private
*/
export const findRecipientSuggestionsRoute = authenticatedProcedure
.input(ZGetRecipientSuggestionsRequestSchema)
.output(ZGetRecipientSuggestionsResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { query } = input;
ctx.logger.info({
input: {
query,
},
});
const suggestions = await getRecipientSuggestions({
userId: user.id,
teamId,
query,
});
return {
results: suggestions,
};
});

View File

@ -0,0 +1,22 @@
import { z } from 'zod';
export const ZGetRecipientSuggestionsRequestSchema = z.object({
query: z.string().default(''),
});
export const ZGetRecipientSuggestionsResponseSchema = z.object({
results: z.array(
z.object({
name: z.string().nullable(),
email: z.string().email(),
}),
),
});
export type TGetRecipientSuggestionsRequestSchema = z.infer<
typeof ZGetRecipientSuggestionsRequestSchema
>;
export type TGetRecipientSuggestionsResponseSchema = z.infer<
typeof ZGetRecipientSuggestionsResponseSchema
>;

View File

@ -12,6 +12,7 @@ import { updateTemplateRecipients } from '@documenso/lib/server-only/recipient/u
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
import { authenticatedProcedure, procedure, router } from '../trpc';
import { findRecipientSuggestionsRoute } from './find-recipient-suggestions';
import {
ZCompleteDocumentWithTokenMutationSchema,
ZCreateDocumentRecipientRequestSchema,
@ -42,6 +43,10 @@ import {
} from './schema';
export const recipientRouter = router({
suggestions: {
find: findRecipientSuggestionsRoute,
},
/**
* @public
*/
@ -520,7 +525,7 @@ export const recipientRouter = router({
completeDocumentWithToken: procedure
.input(ZCompleteDocumentWithTokenMutationSchema)
.mutation(async ({ input, ctx }) => {
const { token, documentId, authOptions, nextSigner } = input;
const { token, documentId, authOptions, accessAuthOptions, nextSigner } = input;
ctx.logger.info({
input: {
@ -532,6 +537,7 @@ export const recipientRouter = router({
token,
documentId,
authOptions,
accessAuthOptions,
nextSigner,
userId: ctx.user?.id,
requestMetadata: ctx.metadata.requestMetadata,

View File

@ -3,6 +3,7 @@ import { z } from 'zod';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import {
ZRecipientAccessAuthSchema,
ZRecipientAccessAuthTypesSchema,
ZRecipientActionAuthSchema,
ZRecipientActionAuthTypesSchema,
@ -23,8 +24,8 @@ export const ZGetRecipientResponseSchema = ZRecipientSchema;
* pass along required details.
*/
export const ZCreateRecipientSchema = z.object({
email: z.string().toLowerCase().email().min(1),
name: z.string(),
email: z.string().toLowerCase().email().min(1).max(254),
name: z.string().max(255),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]),
@ -33,8 +34,8 @@ export const ZCreateRecipientSchema = z.object({
export const ZUpdateRecipientSchema = z.object({
id: z.number().describe('The ID of the recipient to update.'),
email: z.string().toLowerCase().email().min(1).optional(),
name: z.string().optional(),
email: z.string().toLowerCase().email().min(1).max(254).optional(),
name: z.string().max(255).optional(),
role: z.nativeEnum(RecipientRole).optional(),
signingOrder: z.number().optional(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]),
@ -50,16 +51,7 @@ export const ZCreateDocumentRecipientResponseSchema = ZRecipientLiteSchema;
export const ZCreateDocumentRecipientsRequestSchema = z.object({
documentId: z.number(),
recipients: z.array(ZCreateRecipientSchema).refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email.toLowerCase());
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
recipients: z.array(ZCreateRecipientSchema),
});
export const ZCreateDocumentRecipientsResponseSchema = z.object({
@ -75,18 +67,7 @@ export const ZUpdateDocumentRecipientResponseSchema = ZRecipientSchema;
export const ZUpdateDocumentRecipientsRequestSchema = z.object({
documentId: z.number(),
recipients: z.array(ZUpdateRecipientSchema).refine(
(recipients) => {
const emails = recipients
.filter((recipient) => recipient.email !== undefined)
.map((recipient) => recipient.email?.toLowerCase());
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
recipients: z.array(ZUpdateRecipientSchema),
});
export const ZUpdateDocumentRecipientsResponseSchema = z.object({
@ -97,29 +78,19 @@ export const ZDeleteDocumentRecipientRequestSchema = z.object({
recipientId: z.number(),
});
export const ZSetDocumentRecipientsRequestSchema = z
.object({
documentId: z.number(),
recipients: z.array(
z.object({
nativeId: z.number().optional(),
email: z.string().toLowerCase().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
})
.refine(
(schema) => {
const emails = schema.recipients.map((recipient) => recipient.email.toLowerCase());
return new Set(emails).size === emails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
);
export const ZSetDocumentRecipientsRequestSchema = z.object({
documentId: z.number(),
recipients: z.array(
z.object({
nativeId: z.number().optional(),
email: z.string().toLowerCase().email().min(1).max(254),
name: z.string().max(255),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
});
export const ZSetDocumentRecipientsResponseSchema = z.object({
recipients: ZRecipientLiteSchema.array(),
@ -134,16 +105,7 @@ export const ZCreateTemplateRecipientResponseSchema = ZRecipientLiteSchema;
export const ZCreateTemplateRecipientsRequestSchema = z.object({
templateId: z.number(),
recipients: z.array(ZCreateRecipientSchema).refine(
(recipients) => {
const emails = recipients.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
recipients: z.array(ZCreateRecipientSchema),
});
export const ZCreateTemplateRecipientsResponseSchema = z.object({
@ -159,18 +121,7 @@ export const ZUpdateTemplateRecipientResponseSchema = ZRecipientSchema;
export const ZUpdateTemplateRecipientsRequestSchema = z.object({
templateId: z.number(),
recipients: z.array(ZUpdateRecipientSchema).refine(
(recipients) => {
const emails = recipients
.filter((recipient) => recipient.email !== undefined)
.map((recipient) => recipient.email);
return new Set(emails).size === emails.length;
},
{
message: 'Recipients must have unique emails',
},
),
recipients: z.array(ZUpdateRecipientSchema),
});
export const ZUpdateTemplateRecipientsResponseSchema = z.object({
@ -181,43 +132,30 @@ export const ZDeleteTemplateRecipientRequestSchema = z.object({
recipientId: z.number(),
});
export const ZSetTemplateRecipientsRequestSchema = z
.object({
templateId: z.number(),
recipients: z.array(
z.object({
nativeId: z.number().optional(),
email: z
.string()
.toLowerCase()
.refine(
(email) => {
return (
isTemplateRecipientEmailPlaceholder(email) ||
z.string().email().safeParse(email).success
);
},
{ message: 'Please enter a valid email address' },
),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
})
.refine(
(schema) => {
// Filter out placeholder emails and only check uniqueness for actual emails
const nonPlaceholderEmails = schema.recipients
.map((recipient) => recipient.email)
.filter((email) => !isTemplateRecipientEmailPlaceholder(email));
return new Set(nonPlaceholderEmails).size === nonPlaceholderEmails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Recipients must have unique emails', path: ['recipients__root'] },
);
export const ZSetTemplateRecipientsRequestSchema = z.object({
templateId: z.number(),
recipients: z.array(
z.object({
nativeId: z.number().optional(),
email: z
.string()
.toLowerCase()
.refine(
(email) => {
return (
isTemplateRecipientEmailPlaceholder(email) ||
z.string().email().safeParse(email).success
);
},
{ message: 'Please enter a valid email address' },
),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
});
export const ZSetTemplateRecipientsResponseSchema = z.object({
recipients: ZRecipientLiteSchema.array(),
@ -227,10 +165,11 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
token: z.string(),
documentId: z.number(),
authOptions: ZRecipientActionAuthSchema.optional(),
accessAuthOptions: ZRecipientAccessAuthSchema.optional(),
nextSigner: z
.object({
email: z.string().email(),
name: z.string().min(1),
email: z.string().email().max(254),
name: z.string().min(1).max(255),
})
.optional(),
});

View File

@ -1,7 +1,10 @@
import { Prisma } from '@prisma/client';
import { OrganisationType } from '@prisma/client';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
@ -97,6 +100,35 @@ export const updateTeamSettingsRoute = authenticatedProcedure
}
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId: team.organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
select: {
type: true,
organisationGlobalSettings: {
select: {
includeSenderDetails: true,
},
},
},
});
const isPersonalOrganisation = organisation?.type === OrganisationType.PERSONAL;
const currentIncludeSenderDetails =
organisation?.organisationGlobalSettings.includeSenderDetails;
const isChangingIncludeSenderDetails =
includeSenderDetails !== undefined && includeSenderDetails !== currentIncludeSenderDetails;
if (isPersonalOrganisation && isChangingIncludeSenderDetails) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Personal teams cannot update the sender details',
});
}
await prisma.team.update({
where: {
id: teamId,

View File

@ -83,8 +83,8 @@ export const ZCreateTemplateMutationSchema = z.object({
});
export const ZCreateDocumentFromDirectTemplateRequestSchema = z.object({
directRecipientName: z.string().optional(),
directRecipientEmail: z.string().email(),
directRecipientName: z.string().max(255).optional(),
directRecipientEmail: z.string().email().max(254),
directTemplateToken: z.string().min(1),
directTemplateExternalId: z.string().optional(),
signedFieldValues: z.array(ZSignFieldWithTokenMutationSchema),
@ -97,16 +97,11 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
.array(
z.object({
id: z.number().describe('The ID of the recipient in the template.'),
email: z.string().email(),
name: z.string().optional(),
email: z.string().email().max(254),
name: z.string().max(255).optional(),
}),
)
.describe('The information of the recipients to create the document with.')
.refine((recipients) => {
const emails = recipients.map((signer) => signer.email);
return new Set(emails).size === emails.length;
}, 'Recipients must have unique emails'),
.describe('The information of the recipients to create the document with.'),
distributeDocument: z
.boolean()
.describe('Whether to create the document as pending and distribute it to recipients.')