chore: merge main, resolve biome formatting conflicts

Merge origin/main into feat/external-2fa-codes. Resolve formatting
conflicts caused by biome rollout; preserve both feature streams:
PR's external 2FA token + signing-session 2FA proof additions plus
main's RateLimit/RecipientExpired/signingReminders/date-auto-insert.

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
This commit is contained in:
ephraimduncan
2026-05-12 11:46:11 +00:00
parent 9194884fbe
commit 138d663c25
1959 changed files with 93488 additions and 47038 deletions
@@ -1,23 +1,13 @@
import {
ALLOWED_TEAM_GROUP_TYPES,
TEAM_MEMBER_ROLE_PERMISSIONS_MAP,
} from '@documenso/lib/constants/teams';
import { ALLOWED_TEAM_GROUP_TYPES, TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getMemberRoles } from '@documenso/lib/server-only/team/get-member-roles';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { buildTeamWhereQuery, isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import {
OrganisationGroupType,
OrganisationMemberRole,
TeamMemberRole,
} from '@documenso/prisma/generated/types';
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@documenso/prisma/generated/types';
import { authenticatedProcedure } from '../trpc';
import {
ZCreateTeamGroupsRequestSchema,
ZCreateTeamGroupsResponseSchema,
} from './create-team-groups.types';
import { ZCreateTeamGroupsRequestSchema, ZCreateTeamGroupsResponseSchema } from './create-team-groups.types';
export const createTeamGroupsRoute = authenticatedProcedure
// .meta(createTeamGroupsMeta)
@@ -65,10 +55,10 @@ export const createTeamGroupsRoute = authenticatedProcedure
},
});
// Hard validation — these failures indicate programming or authorisation
// errors and should reject the whole request.
const isValid = groups.every((group) => {
const organisationGroup = team.organisation.groups.find(
({ id }) => id === group.organisationGroupId,
);
const organisationGroup = team.organisation.groups.find(({ id }) => id === group.organisationGroupId);
// Only allow specific organisation groups to be used as a reference for team groups.
if (!organisationGroup?.type || !ALLOWED_TEAM_GROUP_TYPES.includes(organisationGroup.type)) {
@@ -84,11 +74,6 @@ export const createTeamGroupsRoute = authenticatedProcedure
return false;
}
// Check that the group is not already added to the team.
if (organisationGroup.teamGroups.some((teamGroup) => teamGroup.teamId === teamId)) {
return false;
}
// Check that the user has permission to add the group to the team.
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, group.teamRole)) {
return false;
@@ -103,8 +88,21 @@ export const createTeamGroupsRoute = authenticatedProcedure
});
}
// Silently drop groups already attached to the team. Makes the create
// idempotent for the common race where a group was added between the
// picker fetch and the submit.
const filteredGroups = groups.filter((group) => {
const organisationGroup = team.organisation.groups.find(({ id }) => id === group.organisationGroupId);
return !organisationGroup?.teamGroups.some((teamGroup) => teamGroup.teamId === teamId);
});
if (filteredGroups.length === 0) {
return;
}
await prisma.teamGroup.createMany({
data: groups.map((group) => ({
data: filteredGroups.map((group) => ({
id: generateDatabaseId('team_group'),
teamId,
organisationGroupId: group.organisationGroupId,
@@ -1,18 +1,14 @@
import { OrganisationGroupType, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getMemberRoles } from '@documenso/lib/server-only/team/get-member-roles';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { buildTeamWhereQuery, isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { OrganisationGroupType, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { authenticatedProcedure } from '../trpc';
import {
ZCreateTeamMembersRequestSchema,
ZCreateTeamMembersResponseSchema,
} from './create-team-members.types';
import { ZCreateTeamMembersRequestSchema, ZCreateTeamMembersResponseSchema } from './create-team-members.types';
export const createTeamMembersRoute = authenticatedProcedure
.input(ZCreateTeamMembersRequestSchema)
@@ -44,11 +40,7 @@ type CreateTeamMembersOptions = {
}[];
};
export const createTeamMembers = async ({
userId,
teamId,
membersToCreate,
}: CreateTeamMembersOptions) => {
export const createTeamMembers = async ({ userId, teamId, membersToCreate }: CreateTeamMembersOptions) => {
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
@@ -136,25 +128,58 @@ export const createTeamMembers = async ({
},
});
if (
!membersToCreate.every((member) =>
isTeamRoleWithinUserHierarchy(currentUserTeamRole, member.teamRole),
)
) {
if (!membersToCreate.every((member) => isTeamRoleWithinUserHierarchy(currentUserTeamRole, member.teamRole))) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Cannot add a member with a role higher than your own',
});
}
const teamRoleGroupId = (role: TeamMemberRole) =>
match(role)
.with(TeamMemberRole.MEMBER, () => teamMemberGroup.organisationGroupId)
.with(TeamMemberRole.MANAGER, () => teamManagerGroup.organisationGroupId)
.with(TeamMemberRole.ADMIN, () => teamAdminGroup.organisationGroupId)
.exhaustive();
// Silently drop additions that would duplicate an existing membership in
// the same internal-team role group (the only case that would hit the
// (organisationMemberId, groupId) unique constraint). Members who are
// implicitly part of the team via an INTERNAL_ORGANISATION group are NOT
// dropped, so this still allows assigning an explicit team role on top
// of the inherited org-level membership.
const existingTeamGroupMemberships = await prisma.organisationGroupMember.findMany({
where: {
organisationMemberId: {
in: membersToCreate.map((member) => member.organisationMemberId),
},
groupId: {
in: [
teamMemberGroup.organisationGroupId,
teamManagerGroup.organisationGroupId,
teamAdminGroup.organisationGroupId,
],
},
},
select: { organisationMemberId: true, groupId: true },
});
const existingPairs = new Set(
existingTeamGroupMemberships.map(({ organisationMemberId, groupId }) => `${organisationMemberId}:${groupId}`),
);
const filteredMembersToCreate = membersToCreate.filter(
(member) => !existingPairs.has(`${member.organisationMemberId}:${teamRoleGroupId(member.teamRole)}`),
);
if (filteredMembersToCreate.length === 0) {
return;
}
await prisma.organisationGroupMember.createMany({
data: membersToCreate.map((member) => ({
data: filteredMembersToCreate.map((member) => ({
id: generateDatabaseId('group_member'),
organisationMemberId: member.organisationMemberId,
groupId: match(member.teamRole)
.with(TeamMemberRole.MEMBER, () => teamMemberGroup.organisationGroupId)
.with(TeamMemberRole.MANAGER, () => teamManagerGroup.organisationGroupId)
.with(TeamMemberRole.ADMIN, () => teamAdminGroup.organisationGroupId)
.exhaustive(),
groupId: teamRoleGroupId(member.teamRole),
})),
});
};
@@ -1,6 +1,5 @@
import { z } from 'zod';
import { TeamMemberRole } from '@documenso/prisma/generated/types';
import { z } from 'zod';
export const ZCreateTeamMembersRequestSchema = z.object({
teamId: z.number(),
@@ -1,7 +1,5 @@
import { z } from 'zod';
import { ZTeamUrlSchema } from './schema';
import { ZTeamNameSchema } from './schema';
import { ZTeamNameSchema, ZTeamUrlSchema } from './schema';
// export const createTeamMeta: TrpcOpenApiMeta = {
// openapi: {
@@ -6,10 +6,7 @@ import { prisma } from '@documenso/prisma';
import { OrganisationGroupType, OrganisationMemberRole } from '@documenso/prisma/generated/types';
import { authenticatedProcedure } from '../trpc';
import {
ZDeleteTeamGroupRequestSchema,
ZDeleteTeamGroupResponseSchema,
} from './delete-team-group.types';
import { ZDeleteTeamGroupRequestSchema, ZDeleteTeamGroupResponseSchema } from './delete-team-group.types';
export const deleteTeamGroupRoute = authenticatedProcedure
// .meta(deleteTeamGroupMeta)
@@ -1,16 +1,12 @@
import { OrganisationGroupType } from '@prisma/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getMemberRoles } from '@documenso/lib/server-only/team/get-member-roles';
import { buildTeamWhereQuery, isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { OrganisationGroupType } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
import {
ZDeleteTeamMemberRequestSchema,
ZDeleteTeamMemberResponseSchema,
} from './delete-team-member.types';
import { ZDeleteTeamMemberRequestSchema, ZDeleteTeamMemberResponseSchema } from './delete-team-member.types';
export const deleteTeamMemberRoute = authenticatedProcedure
// .meta(deleteTeamMemberMeta)
@@ -34,6 +30,11 @@ export const deleteTeamMemberRoute = authenticatedProcedure
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
include: {
organisation: {
select: {
ownerUserId: true,
},
},
teamGroups: {
where: {
organisationGroup: {
@@ -106,12 +107,38 @@ export const deleteTeamMemberRoute = authenticatedProcedure
});
}
await prisma.organisationGroupMember.delete({
where: {
organisationMemberId_groupId: {
organisationMemberId: memberId,
groupId: teamGroupToRemoveMemberFrom.organisationGroupId,
const removedMember = teamGroupToRemoveMemberFrom.organisationGroup.organisationGroupMembers.find(
(ogm) => ogm.organisationMember.id === memberId,
);
if (!removedMember) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Member not found in this team',
});
}
await prisma.$transaction(async (tx) => {
// Removing a user from a single team drops their INTERNAL_TEAM
// OrganisationGroupMember link, but Envelope rows they authored in this
// team still point at their userId. Reassign to the org owner so those
// envelopes remain reachable after the member loses team access.
await tx.envelope.updateMany({
where: {
userId: removedMember.organisationMember.userId,
teamId,
},
},
data: {
userId: team.organisation.ownerUserId,
},
});
await tx.organisationGroupMember.delete({
where: {
organisationMemberId_groupId: {
organisationMemberId: memberId,
groupId: teamGroupToRemoveMemberFrom.organisationGroupId,
},
},
});
});
});
@@ -1,10 +1,9 @@
import { TeamMemberRole } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { orphanEnvelopes } from '@documenso/lib/server-only/envelope/orphan-envelopes';
import { transferTeamEnvelopes } from '@documenso/lib/server-only/envelope/transfer-team-envelopes';
import { deleteTeam } from '@documenso/lib/server-only/team/delete-team';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { TeamMemberRole } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
import { ZDeleteTeamRequestSchema, ZDeleteTeamResponseSchema } from './delete-team.types';
@@ -1,16 +1,12 @@
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import type { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { unique } from 'remeda';
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZFindTeamGroupsRequestSchema,
ZFindTeamGroupsResponseSchema,
} from './find-team-groups.types';
import { ZFindTeamGroupsRequestSchema, ZFindTeamGroupsResponseSchema } from './find-team-groups.types';
export const findTeamGroupsRoute = authenticatedProcedure
// .meta(getTeamGroupsMeta)
@@ -1,8 +1,7 @@
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { TeamGroupSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamGroupSchema';
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
// export const getTeamGroupsMeta: TrpcOpenApiMeta = {
// openapi: {
@@ -1,10 +1,7 @@
import { findTeamMembers } from '@documenso/lib/server-only/team/find-team-members';
import { authenticatedProcedure } from '../trpc';
import {
ZFindTeamMembersRequestSchema,
ZFindTeamMembersResponseSchema,
} from './find-team-members.types';
import { ZFindTeamMembersRequestSchema, ZFindTeamMembersResponseSchema } from './find-team-members.types';
export const findTeamMembersRoute = authenticatedProcedure
.input(ZFindTeamMembersRequestSchema)
@@ -1,8 +1,7 @@
import { z } from 'zod';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { OrganisationMemberRole, TeamMemberRole } from '@documenso/prisma/generated/types';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import { z } from 'zod';
export const ZFindTeamMembersRequestSchema = ZFindSearchParamsSchema.extend({
teamId: z.number(),
@@ -1,8 +1,7 @@
import { TeamMemberRole } from '@prisma/client';
import { z } from 'zod';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { TeamMemberRole } from '@prisma/client';
import { z } from 'zod';
// export const getTeamsMeta: TrpcOpenApiMeta = {
// openapi: {
@@ -1,10 +1,7 @@
import { getTeamMembers } from '@documenso/lib/server-only/team/get-team-members';
import { authenticatedProcedure } from '../trpc';
import {
ZGetTeamMembersRequestSchema,
ZGetTeamMembersResponseSchema,
} from './get-team-members.types';
import { ZGetTeamMembersRequestSchema, ZGetTeamMembersResponseSchema } from './get-team-members.types';
export const getTeamMembersRoute = authenticatedProcedure
// .meta(getTeamMembersMeta)
@@ -1,7 +1,6 @@
import { z } from 'zod';
import { OrganisationMemberRole, TeamMemberRole } from '@documenso/prisma/generated/types';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import { z } from 'zod';
// export const getTeamMembersMeta: TrpcOpenApiMeta = {
// openapi: {
@@ -1,9 +1,8 @@
import { z } from 'zod';
import { TeamMemberRole } from '@documenso/prisma/generated/types';
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
import TeamGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/TeamGlobalSettingsSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { z } from 'zod';
// export const getTeamMeta: TrpcOpenApiMeta = {
// openapi: {
+24 -28
View File
@@ -58,37 +58,33 @@ export const teamRouter = router({
get: authenticatedProcedure.query(async ({ ctx }) => {
return await getTeamEmailByEmail({ email: ctx.user.email });
}),
update: authenticatedProcedure
.input(ZUpdateTeamEmailMutationSchema)
.mutation(async ({ input, ctx }) => {
ctx.logger.info({
input: {
teamId: input.teamId,
},
});
update: authenticatedProcedure.input(ZUpdateTeamEmailMutationSchema).mutation(async ({ input, ctx }) => {
ctx.logger.info({
input: {
teamId: input.teamId,
},
});
return await updateTeamEmail({
userId: ctx.user.id,
...input,
});
}),
delete: authenticatedProcedure
.input(ZDeleteTeamEmailMutationSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = input;
return await updateTeamEmail({
userId: ctx.user.id,
...input,
});
}),
delete: authenticatedProcedure.input(ZDeleteTeamEmailMutationSchema).mutation(async ({ input, ctx }) => {
const { teamId } = input;
ctx.logger.info({
input: {
teamId,
},
});
return await deleteTeamEmail({
userId: ctx.user.id,
userEmail: ctx.user.email,
ctx.logger.info({
input: {
teamId,
});
}),
},
});
return await deleteTeamEmail({
userId: ctx.user.id,
userEmail: ctx.user.email,
teamId,
});
}),
verification: {
send: authenticatedProcedure
.input(ZCreateTeamEmailVerificationMutationSchema)
+13 -16
View File
@@ -1,8 +1,9 @@
import { URL_PATTERN, ZNameSchema } from '@documenso/lib/constants/auth';
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
import { zEmail } from '@documenso/lib/utils/zod';
import { TeamMemberRole } from '@prisma/client';
import { z } from 'zod';
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
/**
* Restrict team URLs schema.
*
@@ -26,10 +27,7 @@ export const ZTeamUrlSchema = z
.toLowerCase()
.regex(/^[a-z0-9].*[^_-]$/, 'Team URL cannot start or end with dashes or underscores.')
.regex(/^(?!.*[-_]{2})/, 'Team URL cannot contain consecutive dashes or underscores.')
.regex(
/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/,
'Team URL can only contain letters, numbers, dashes and underscores.',
)
.regex(/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/, 'Team URL can only contain letters, numbers, dashes and underscores.')
.refine((value) => !PROTECTED_TEAM_URLS.includes(value), {
message: 'This URL is already in use.',
});
@@ -38,12 +36,15 @@ export const ZTeamNameSchema = z
.string()
.trim()
.min(3, { message: 'Team name must be at least 3 characters long.' })
.max(30, { message: 'Team name must not exceed 30 characters.' });
.max(30, { message: 'Team name must not exceed 30 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Team name cannot contain URLs.',
});
export const ZCreateTeamEmailVerificationMutationSchema = z.object({
teamId: z.number(),
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().trim().email().toLowerCase().min(1, 'Please enter a valid email.'),
name: ZNameSchema,
email: zEmail().trim().toLowerCase().min(1, 'Please enter a valid email.'),
});
export const ZDeleteTeamEmailMutationSchema = z.object({
@@ -61,7 +62,7 @@ export const ZGetTeamMembersQuerySchema = z.object({
export const ZUpdateTeamEmailMutationSchema = z.object({
teamId: z.number(),
data: z.object({
name: z.string().trim().min(1),
name: ZNameSchema,
}),
});
@@ -77,13 +78,9 @@ export const ZResendTeamEmailVerificationMutationSchema = z.object({
teamId: z.number(),
});
export type TCreateTeamEmailVerificationMutationSchema = z.infer<
typeof ZCreateTeamEmailVerificationMutationSchema
>;
export type TCreateTeamEmailVerificationMutationSchema = z.infer<typeof ZCreateTeamEmailVerificationMutationSchema>;
export type TDeleteTeamEmailMutationSchema = z.infer<typeof ZDeleteTeamEmailMutationSchema>;
export type TGetTeamMembersQuerySchema = z.infer<typeof ZGetTeamMembersQuerySchema>;
export type TUpdateTeamEmailMutationSchema = z.infer<typeof ZUpdateTeamEmailMutationSchema>;
export type TResendTeamEmailVerificationMutationSchema = z.infer<
typeof ZResendTeamEmailVerificationMutationSchema
>;
export type TResendTeamEmailVerificationMutationSchema = z.infer<typeof ZResendTeamEmailVerificationMutationSchema>;
@@ -6,10 +6,7 @@ import { prisma } from '@documenso/prisma';
import { OrganisationGroupType } from '@documenso/prisma/generated/types';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateTeamGroupRequestSchema,
ZUpdateTeamGroupResponseSchema,
} from './update-team-group.types';
import { ZUpdateTeamGroupRequestSchema, ZUpdateTeamGroupResponseSchema } from './update-team-group.types';
export const updateTeamGroupRoute = authenticatedProcedure
// .meta(updateTeamGroupMeta)
@@ -1,5 +1,3 @@
import { match } from 'ts-pattern';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getMemberRoles } from '@documenso/lib/server-only/team/get-member-roles';
@@ -7,12 +5,10 @@ import { generateDatabaseId } from '@documenso/lib/universal/id';
import { buildTeamWhereQuery, isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { OrganisationGroupType, TeamMemberRole } from '@documenso/prisma/generated/types';
import { match } from 'ts-pattern';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateTeamMemberRequestSchema,
ZUpdateTeamMemberResponseSchema,
} from './update-team-member.types';
import { ZUpdateTeamMemberRequestSchema, ZUpdateTeamMemberResponseSchema } from './update-team-member.types';
export const updateTeamMemberRoute = authenticatedProcedure
// .meta(updateTeamMemberMeta)
@@ -78,9 +74,7 @@ export const updateTeamMemberRoute = authenticatedProcedure
(group) =>
group.organisationGroup.type === OrganisationGroupType.INTERNAL_TEAM &&
group.teamId === teamId &&
group.organisationGroup.organisationGroupMembers.some(
(member) => member.organisationMemberId === memberId,
),
group.organisationGroup.organisationGroupMembers.some((member) => member.organisationMemberId === memberId),
);
const teamMemberGroup = team.teamGroups.find(
@@ -1,18 +1,15 @@
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 { normalizeBrandingColors } from '@documenso/lib/utils/normalize-branding-colors';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { type SanitizeBrandingCssWarning, sanitizeBrandingCss } from '@documenso/lib/utils/sanitize-branding-css';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { OrganisationType, Prisma } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateTeamSettingsRequestSchema,
ZUpdateTeamSettingsResponseSchema,
} from './update-team-settings.types';
import { ZUpdateTeamSettingsRequestSchema, ZUpdateTeamSettingsResponseSchema } from './update-team-settings.types';
export const updateTeamSettingsRoute = authenticatedProcedure
.input(ZUpdateTeamSettingsRequestSchema)
@@ -40,12 +37,16 @@ export const updateTeamSettingsRoute = authenticatedProcedure
uploadSignatureEnabled,
drawSignatureEnabled,
delegateDocumentOwnership,
envelopeExpirationPeriod,
reminderSettings,
// Branding related settings.
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
brandingCss,
// Email related settings.
emailId,
@@ -66,11 +67,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
}
// Signatures will only be inherited if all are NULL.
if (
typedSignatureEnabled === false &&
uploadSignatureEnabled === false &&
drawSignatureEnabled === false
) {
if (typedSignatureEnabled === false && uploadSignatureEnabled === false && drawSignatureEnabled === false) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'At least one signature type must be enabled',
});
@@ -123,8 +120,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
});
const isPersonalOrganisation = organisation?.type === OrganisationType.PERSONAL;
const currentIncludeSenderDetails =
organisation?.organisationGlobalSettings.includeSenderDetails;
const currentIncludeSenderDetails = organisation?.organisationGlobalSettings.includeSenderDetails;
const isChangingIncludeSenderDetails =
includeSenderDetails !== undefined && includeSenderDetails !== currentIncludeSenderDetails;
@@ -135,6 +131,27 @@ export const updateTeamSettingsRoute = authenticatedProcedure
});
}
// Sanitize custom branding CSS at write time. `null` means inherit-from-org
// for teams, so only run the sanitiser when an explicit string is provided.
// An empty string after sanitisation is collapsed to `null` so the team
// row inherits rather than persisting an empty override.
let cssWarnings: SanitizeBrandingCssWarning[] | undefined;
let sanitizedBrandingCss: string | null | undefined;
if (brandingCss === null) {
sanitizedBrandingCss = null;
} else if (typeof brandingCss === 'string') {
const result = sanitizeBrandingCss(brandingCss);
sanitizedBrandingCss = result.css.trim() === '' ? null : result.css;
cssWarnings = result.warnings;
}
// Strip empty-string colour values; collapse to `null` when the payload
// contains no overrides. For teams this matters because brandingEnabled
// = null inherits from the org — leaving `{}` here would persist a real
// override of nothing once a team toggles brandingEnabled = true.
const normalizedBrandingColors = normalizeBrandingColors(brandingColors);
await prisma.team.update({
where: {
id: teamId,
@@ -154,19 +171,22 @@ export const updateTeamSettingsRoute = authenticatedProcedure
uploadSignatureEnabled,
drawSignatureEnabled,
delegateDocumentOwnership,
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
// Branding related settings.
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
brandingCss: sanitizedBrandingCss,
// Email related settings.
emailId,
emailReplyTo,
// emailReplyToName,
emailDocumentSettings:
emailDocumentSettings === null ? Prisma.DbNull : emailDocumentSettings,
emailDocumentSettings: emailDocumentSettings === null ? Prisma.DbNull : emailDocumentSettings,
defaultRecipients: defaultRecipients === null ? Prisma.DbNull : defaultRecipients,
// AI features settings.
@@ -175,4 +195,8 @@ export const updateTeamSettingsRoute = authenticatedProcedure
},
},
});
return {
cssWarnings: cssWarnings && cssWarnings.length > 0 ? cssWarnings : undefined,
};
});
@@ -1,13 +1,15 @@
import { z } from 'zod';
import { BRANDING_CSS_MAX_LENGTH } from '@documenso/lib/constants/branding';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { ZDocumentMetaDateFormatSchema, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { ZSanitizeBrandingCssWarningSchema } from '@documenso/lib/utils/sanitize-branding-css';
import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod';
/**
* Null = Inherit from organisation.
@@ -28,16 +30,20 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
uploadSignatureEnabled: z.boolean().nullish(),
drawSignatureEnabled: z.boolean().nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
reminderSettings: ZEnvelopeReminderSettings.nullish(),
// Branding related settings.
brandingEnabled: z.boolean().nullish(),
brandingLogo: z.string().nullish(),
brandingUrl: z.string().nullish(),
brandingCompanyDetails: z.string().nullish(),
brandingColors: ZCssVarsSchema.nullish(),
brandingCss: z.string().max(BRANDING_CSS_MAX_LENGTH).nullish(),
// Email related settings.
emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(),
emailReplyTo: zEmail().nullish(),
// emailReplyToName: z.string().nullish(),
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullish(),
@@ -48,4 +54,6 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
}),
});
export const ZUpdateTeamSettingsResponseSchema = z.void();
export const ZUpdateTeamSettingsResponseSchema = z.object({
cssWarnings: z.array(ZSanitizeBrandingCssWarningSchema).optional(),
});