feat: add global settings for teams (#1391)

## Description

This PR introduces global settings for teams. At the moment, it allows
team admins to configure the following:
* The default visibility of the documents uploaded to the team account
* Whether to include the document owner (sender) details when sending
emails to the recipients.

### Include Sender Details

If the Sender Details setting is enabled, the emails sent by the team
will include the sender's name:

> "Example User" on behalf of "Example Team" has invited you to sign
"document.pdf"

Otherwise, the email will say:

> "Example Team" has invited you to sign "document.pdf"

### Default Document Visibility

This new option allows users to set the default visibility for the
documents uploaded to the team account. It can have the following
values:
* Everyone
* Manager and above
* Admins only

If the default document visibility isn't set, the document will be set
to the role of the user who created the document:
* If a user with the "User" role creates a document, the document's
visibility is set to "Everyone".
* Manager role -> "Manager and above"
* Admin role -> "Admins only"

Otherwise, if there is a default document visibility value, it uses that
value.

#### Gotcha

To avoid issues, the `document owner` and the `recipient` can access the
document irrespective of their role. For example:
* If a team member with the role "Member" uploads a document and the
default document visibility is "Admins", only the document owner and
admins can access the document.
  * Similar to the other scenarios.

* If an admin uploads a document and the default document visibility is
"Admins", the recipient can access the document.

* The admins have access to all the documents.
* Managers have access to documents with the visibility set to
"Everyone" and "Manager and above"
* Members have access only to the documents with the visibility set to
"Everyone".

## Testing Performed

Tested it locally.
This commit is contained in:
Catalin Pit
2024-11-08 13:50:49 +02:00
committed by GitHub
parent f6bcf921d5
commit 23a0537648
99 changed files with 4372 additions and 1037 deletions

View File

@ -11,10 +11,12 @@ import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export type CreateTeamEmailVerificationOptions = {
userId: number;
@ -48,6 +50,7 @@ export const createTeamEmailVerification = async ({
include: {
teamEmail: true,
emailVerification: true,
teamGlobalSettings: true,
},
});
@ -80,7 +83,7 @@ export const createTeamEmailVerification = async ({
},
});
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
await sendTeamEmailVerificationEmail(data.email, token, team);
},
{ timeout: 30_000 },
);
@ -112,25 +115,36 @@ export const createTeamEmailVerification = async ({
export const sendTeamEmailVerificationEmail = async (
email: string,
token: string,
teamName: string,
teamUrl: string,
team: Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
},
) => {
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const template = createElement(ConfirmTeamEmailTemplate, {
assetBaseUrl,
baseUrl: WEBAPP_BASE_URL,
teamName,
teamUrl,
teamName: team.name,
teamUrl: team.url,
token,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const lang = team.teamGlobalSettings?.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, {
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance();
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: email,
@ -139,7 +153,7 @@ export const sendTeamEmailVerificationEmail = async (
address: FROM_ADDRESS,
},
subject: i18n._(
msg`A request to use your email has been initiated by ${teamName} on Documenso`,
msg`A request to use your email has been initiated by ${team.name} on Documenso`,
),
html,
text,

View File

@ -4,7 +4,6 @@ import { msg } from '@lingui/macro';
import { nanoid } from 'nanoid';
import { mailer } from '@documenso/email/mailer';
import type { TeamInviteEmailProps } from '@documenso/email/templates/team-invite';
import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
@ -12,11 +11,13 @@ import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
import type { TCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export type CreateTeamMemberInvitesOptions = {
userId: number;
@ -59,6 +60,7 @@ export const createTeamMemberInvites = async ({
},
},
invites: true,
teamGlobalSettings: true,
},
});
@ -112,8 +114,7 @@ export const createTeamMemberInvites = async ({
sendTeamMemberInviteEmail({
email,
token,
teamName: team.name,
teamUrl: team.url,
team,
senderName: userName,
}),
),
@ -134,8 +135,13 @@ export const createTeamMemberInvites = async ({
}
};
type SendTeamMemberInviteEmailOptions = Omit<TeamInviteEmailProps, 'baseUrl' | 'assetBaseUrl'> & {
type SendTeamMemberInviteEmailOptions = {
email: string;
senderName: string;
token: string;
team: Team & {
teamGlobalSettings?: TeamGlobalSettings | null;
};
};
/**
@ -143,20 +149,33 @@ type SendTeamMemberInviteEmailOptions = Omit<TeamInviteEmailProps, 'baseUrl' | '
*/
export const sendTeamMemberInviteEmail = async ({
email,
...emailTemplateOptions
senderName,
token,
team,
}: SendTeamMemberInviteEmailOptions) => {
const template = createElement(TeamInviteEmailTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
...emailTemplateOptions,
senderName,
token,
teamName: team.name,
teamUrl: team.url,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
renderEmailWithI18N(template, { lang: team.teamGlobalSettings?.documentLanguage, branding }),
renderEmailWithI18N(template, {
lang: team.teamGlobalSettings?.documentLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance();
const i18n = await getI18nInstance(team.teamGlobalSettings?.documentLanguage);
await mailer.sendMail({
to: email,
@ -164,9 +183,7 @@ export const sendTeamMemberInviteEmail = async ({
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(
msg`You have been invited to join ${emailTemplateOptions.teamName} on Documenso`,
),
subject: i18n._(msg`You have been invited to join ${team.name} on Documenso`),
html,
text,
});

View File

@ -11,6 +11,7 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export type DeleteTeamEmailOptions = {
userId: number;
@ -54,6 +55,7 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
email: true,
},
},
teamGlobalSettings: true,
},
});
@ -77,12 +79,18 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
teamUrl: team.url,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const lang = team.teamGlobalSettings?.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
]);
const i18n = await getI18nInstance();
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: {

View File

@ -1,16 +1,20 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import type { TeamDeleteEmailProps } from '@documenso/email/templates/team-delete';
import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { jobs } from '../../jobs/client';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
export type DeleteTeamOptions = {
userId: number;
@ -38,6 +42,7 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
},
},
},
teamGlobalSettings: true,
},
});
@ -60,6 +65,7 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
name: team.name,
url: team.url,
ownerUserId: team.ownerUserId,
teamGlobalSettings: team.teamGlobalSettings,
},
members: team.members.map((member) => ({
id: member.user.id,
@ -80,33 +86,42 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
);
};
type SendTeamDeleteEmailOptions = Omit<TeamDeleteEmailProps, 'baseUrl' | 'assetBaseUrl'> & {
type SendTeamDeleteEmailOptions = {
email: string;
teamName: string;
team: Pick<Team, 'url' | 'name'> & {
teamGlobalSettings?: TeamGlobalSettings | null;
};
isOwner: boolean;
};
export const sendTeamDeleteEmail = async ({
email,
...emailTemplateOptions
}: SendTeamDeleteEmailOptions) => {
export const sendTeamDeleteEmail = async ({ email, isOwner, team }: SendTeamDeleteEmailOptions) => {
const template = createElement(TeamDeleteEmailTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
...emailTemplateOptions,
teamUrl: team.url,
isOwner,
});
const branding = team.teamGlobalSettings
? teamGlobalSettingsToBranding(team.teamGlobalSettings)
: undefined;
const lang = team.teamGlobalSettings?.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `Team "${emailTemplateOptions.teamName}" has been deleted on Documenso`,
subject: i18n._(msg`Team "${team.name}" has been deleted on Documenso`),
html,
text,
});

View File

@ -30,6 +30,7 @@ export const getTeamById = async ({ userId, teamId }: GetTeamByIdOptions) => {
where: whereFilter,
include: {
teamEmail: true,
teamGlobalSettings: true,
members: {
where: {
userId,
@ -89,6 +90,7 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
},
},
subscription: true,
teamGlobalSettings: true,
members: {
where: {
userId,

View File

@ -33,6 +33,7 @@ export const resendTeamEmailVerification = async ({
},
include: {
emailVerification: true,
teamGlobalSettings: true,
},
});
@ -61,7 +62,7 @@ export const resendTeamEmailVerification = async ({
},
});
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
await sendTeamEmailVerificationEmail(emailVerification.email, token, team);
},
{ timeout: 30_000 },
);

View File

@ -49,6 +49,9 @@ export const resendTeamMemberInvitation = async ({
},
},
},
include: {
teamGlobalSettings: true,
},
});
if (!team) {
@ -69,9 +72,8 @@ export const resendTeamMemberInvitation = async ({
await sendTeamMemberInviteEmail({
email: teamMemberInvite.email,
token: teamMemberInvite.token,
teamName: team.name,
teamUrl: team.url,
senderName: userName,
team,
});
},
{ timeout: 30_000 },

View File

@ -0,0 +1,52 @@
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
export type UpdateTeamBrandingSettingsOptions = {
userId: number;
teamId: number;
settings: {
brandingEnabled: boolean;
brandingLogo: string;
brandingUrl: string;
brandingCompanyDetails: string;
};
};
export const updateTeamBrandingSettings = async ({
userId,
teamId,
settings,
}: UpdateTeamBrandingSettingsOptions) => {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = settings;
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
},
});
if (!member || member.role !== TeamMemberRole.ADMIN) {
throw new Error('You do not have permission to update this team.');
}
return await prisma.teamGlobalSettings.upsert({
where: {
teamId,
},
create: {
teamId,
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
},
update: {
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
},
});
};

View File

@ -0,0 +1,52 @@
import { prisma } from '@documenso/prisma';
import type { DocumentVisibility } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client';
import type { SupportedLanguageCodes } from '../../constants/i18n';
export type UpdateTeamDocumentSettingsOptions = {
userId: number;
teamId: number;
settings: {
documentVisibility: DocumentVisibility;
documentLanguage: SupportedLanguageCodes;
includeSenderDetails: boolean;
};
};
export const updateTeamDocumentSettings = async ({
userId,
teamId,
settings,
}: UpdateTeamDocumentSettingsOptions) => {
const { documentVisibility, documentLanguage, includeSenderDetails } = settings;
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
},
});
if (!member || member.role !== TeamMemberRole.ADMIN) {
throw new Error('You do not have permission to update this team.');
}
return await prisma.teamGlobalSettings.upsert({
where: {
teamId,
},
create: {
teamId,
documentVisibility,
documentLanguage,
includeSenderDetails,
},
update: {
documentVisibility,
documentLanguage,
includeSenderDetails,
},
});
};

View File

@ -4,6 +4,7 @@ import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client';
import type { DocumentVisibility } from '@documenso/prisma/client';
export type UpdateTeamOptions = {
userId: number;
@ -11,6 +12,8 @@ export type UpdateTeamOptions = {
data: {
name?: string;
url?: string;
documentVisibility?: DocumentVisibility;
includeSenderDetails?: boolean;
};
};
@ -42,6 +45,18 @@ export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions) =>
data: {
url: data.url,
name: data.name,
teamGlobalSettings: {
upsert: {
create: {
documentVisibility: data.documentVisibility,
includeSenderDetails: data.includeSenderDetails,
},
update: {
documentVisibility: data.documentVisibility,
includeSenderDetails: data.includeSenderDetails,
},
},
},
},
});