diff --git a/apps/web/public/static/delete-team.png b/apps/web/public/static/delete-team.png new file mode 100644 index 000000000..cc6cca3d7 Binary files /dev/null and b/apps/web/public/static/delete-team.png differ diff --git a/apps/web/public/static/delete-user.png b/apps/web/public/static/delete-user.png new file mode 100644 index 000000000..34e15e6b9 Binary files /dev/null and b/apps/web/public/static/delete-user.png differ diff --git a/apps/web/src/app/(dashboard)/settings/teams/decline-team-invitation-button.tsx b/apps/web/src/app/(dashboard)/settings/teams/decline-team-invitation-button.tsx new file mode 100644 index 000000000..a7e8f937f --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/teams/decline-team-invitation-button.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DeclineTeamInvitationButtonProps = { + teamId: number; +}; + +export const DeclineTeamInvitationButton = ({ teamId }: DeclineTeamInvitationButtonProps) => { + const { toast } = useToast(); + + const { + mutateAsync: declineTeamInvitation, + isLoading, + isSuccess, + } = trpc.team.declineTeamInvitation.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Declined team invitation', + duration: 5000, + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + variant: 'destructive', + duration: 10000, + description: 'Unable to decline this team invitation at this time.', + }); + }, + }); + + return ( + + ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx b/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx index b3ff9233b..7d2ec11c9 100644 --- a/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx +++ b/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx @@ -19,6 +19,7 @@ import { } from '@documenso/ui/primitives/dialog'; import { AcceptTeamInvitationButton } from './accept-team-invitation-button'; +import { DeclineTeamInvitationButton } from './decline-team-invitation-button'; export const TeamInvitations = () => { const { data, isInitialLoading } = trpc.team.getTeamInvitations.useQuery(); @@ -68,7 +69,8 @@ export const TeamInvitations = () => { } secondaryText={formatTeamUrl(invitation.team.url)} rightSideComponent={ -
+
+
} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx index e2c0a0d87..15717f326 100644 --- a/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx @@ -1,7 +1,5 @@ 'use client'; -import { useRouter } from 'next/navigation'; - import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react'; import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; @@ -14,6 +12,7 @@ import { } from '@documenso/ui/primitives/dropdown-menu'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { RemoveTeamEmailDialog } from '~/components/(teams)/dialogs/remove-team-email-dialog'; import { UpdateTeamEmailDialog } from '~/components/(teams)/dialogs/update-team-email-dialog'; export type TeamsSettingsPageProps = { @@ -21,8 +20,6 @@ export type TeamsSettingsPageProps = { }; export const TeamEmailDropdown = ({ team }: TeamsSettingsPageProps) => { - const router = useRouter(); - const { toast } = useToast(); const { mutateAsync: resendEmailVerification, isLoading: isResendingEmailVerification } = @@ -44,56 +41,6 @@ export const TeamEmailDropdown = ({ team }: TeamsSettingsPageProps) => { }, }); - const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } = - trpc.team.deleteTeamEmail.useMutation({ - onSuccess: () => { - toast({ - title: 'Success', - description: 'Team email has been removed', - duration: 5000, - }); - }, - onError: () => { - toast({ - title: 'Something went wrong', - variant: 'destructive', - duration: 10000, - description: 'Unable to remove team email at this time. Please try again.', - }); - }, - }); - - const { mutateAsync: deleteTeamEmailVerification, isLoading: isDeletingTeamEmailVerification } = - trpc.team.deleteTeamEmailVerification.useMutation({ - onSuccess: () => { - toast({ - title: 'Success', - description: 'Email verification has been removed', - duration: 5000, - }); - }, - onError: () => { - toast({ - title: 'Something went wrong', - variant: 'destructive', - duration: 10000, - description: 'Unable to remove email verification at this time. Please try again.', - }); - }, - }); - - const onRemove = async () => { - if (team.teamEmail) { - await deleteTeamEmail({ teamId: team.id }); - } - - if (team.emailVerification) { - await deleteTeamEmailVerification({ teamId: team.id }); - } - - router.refresh(); - }; - return ( @@ -130,13 +77,16 @@ export const TeamEmailDropdown = ({ team }: TeamsSettingsPageProps) => { /> )} - onRemove()} - > - - Remove - + e.preventDefault()}> + + Remove + + } + /> ); diff --git a/apps/web/src/app/(unauthenticated)/team/decline/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/decline/[token]/page.tsx new file mode 100644 index 000000000..46d62fc3d --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/team/decline/[token]/page.tsx @@ -0,0 +1,120 @@ +import Link from 'next/link'; + +import { DateTime } from 'luxon'; + +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; +import { declineTeamInvitation } from '@documenso/lib/server-only/team/decline-team-invitation'; +import { getTeamById } from '@documenso/lib/server-only/team/get-team'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberInviteStatus } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; + +type DeclineInvitationPageProps = { + params: { + token: string; + }; +}; + +export default async function DeclineInvitationPage({ + params: { token }, +}: DeclineInvitationPageProps) { + const session = await getServerComponentSession(); + + const teamMemberInvite = await prisma.teamMemberInvite.findUnique({ + where: { + token, + }, + }); + + if (!teamMemberInvite) { + return ( +
+
+

Invalid token

+ +

+ This token is invalid or has expired. No action is needed. +

+ + +
+
+ ); + } + + const team = await getTeamById({ teamId: teamMemberInvite.teamId }); + + const user = await prisma.user.findFirst({ + where: { + email: { + equals: teamMemberInvite.email, + mode: 'insensitive', + }, + }, + }); + + if (user) { + await declineTeamInvitation({ userId: user.id, teamId: team.id }); + } + + if (!user && teamMemberInvite.status !== TeamMemberInviteStatus.DECLINED) { + await prisma.teamMemberInvite.update({ + where: { + id: teamMemberInvite.id, + }, + data: { + status: TeamMemberInviteStatus.DECLINED, + }, + }); + } + + const email = encryptSecondaryData({ + data: teamMemberInvite.email, + expiresAt: DateTime.now().plus({ days: 1 }).toMillis(), + }); + + if (!user) { + return ( +
+

Team invitation

+ +

+ You have been invited by {team.name} to join their team. +

+ +

+ To decline this invitation you must create an account. +

+ + +
+ ); + } + + const isSessionUserTheInvitedUser = user?.id === session.user?.id; + + return ( +
+

Invitation declined

+ +

+ You have declined the invitation from {team.name} to join their team. +

+ + {isSessionUserTheInvitedUser ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx index 99630e57c..83aa0fa5a 100644 --- a/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx +++ b/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx @@ -113,10 +113,11 @@ export const DeleteTeamDialog = ({ trigger, teamId, teamName }: DeleteTeamDialog - Delete team + Are you sure you wish to delete this team? - Are you sure? This is irreversable. + Please note that you will lose access to all documents associated with this team & all + the members will be removed and notified diff --git a/apps/web/src/components/(teams)/dialogs/remove-team-email-dialog.tsx b/apps/web/src/components/(teams)/dialogs/remove-team-email-dialog.tsx new file mode 100644 index 000000000..04fd46d33 --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/remove-team-email-dialog.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import type { Prisma } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { Alert } from '@documenso/ui/primitives/alert'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type RemoveTeamEmailDialogProps = { + trigger?: React.ReactNode; + teamName: string; + team: Prisma.TeamGetPayload<{ + include: { + teamEmail: true; + emailVerification: { + select: { + expiresAt: true; + name: true; + email: true; + }; + }; + }; + }>; +}; + +export const RemoveTeamEmailDialog = ({ trigger, teamName, team }: RemoveTeamEmailDialogProps) => { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + const router = useRouter(); + + const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } = + trpc.team.deleteTeamEmail.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Team email has been removed', + duration: 5000, + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + variant: 'destructive', + duration: 10000, + description: 'Unable to remove team email at this time. Please try again.', + }); + }, + }); + + const { mutateAsync: deleteTeamEmailVerification, isLoading: isDeletingTeamEmailVerification } = + trpc.team.deleteTeamEmailVerification.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'Email verification has been removed', + duration: 5000, + }); + }, + onError: () => { + toast({ + title: 'Something went wrong', + variant: 'destructive', + duration: 10000, + description: 'Unable to remove email verification at this time. Please try again.', + }); + }, + }); + + const onRemove = async () => { + if (team.teamEmail) { + await deleteTeamEmail({ teamId: team.id }); + } + + if (team.emailVerification) { + await deleteTeamEmailVerification({ teamId: team.id }); + } + + router.refresh(); + }; + + return ( + setOpen(value)}> + + {trigger ?? } + + + + + Are you sure? + + + You are about to delete the following team email from{' '} + {teamName}. + + + + + + {team.teamEmail?.name || team.emailVerification?.name} + + } + secondaryText={ + + {team.teamEmail?.email || team.emailVerification?.email} + + } + /> + + +
+ + + + + +
+
+
+ ); +}; diff --git a/packages/app-tests/e2e/document-auth/access-auth.spec.ts b/packages/app-tests/e2e/document-auth/access-auth.spec.ts index b57969b50..8fb4afc04 100644 --- a/packages/app-tests/e2e/document-auth/access-auth.spec.ts +++ b/packages/app-tests/e2e/document-auth/access-auth.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth'; import { prisma } from '@documenso/prisma'; import { seedPendingDocument } from '@documenso/prisma/seed/documents'; -import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; +import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from '../fixtures/authentication'; @@ -31,8 +31,6 @@ test('[DOCUMENT_AUTH]: should grant access when not required', async ({ page }) await page.goto(`/sign/${token}`); await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); } - - await unseedUser(user.id); }); test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page }) => { @@ -90,7 +88,4 @@ test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page await expect(page.getByRole('paragraph')).toContainText(email); } } - - await unseedUser(user.id); - await unseedUser(recipientWithAccount.id); }); diff --git a/packages/app-tests/e2e/document-auth/action-auth.spec.ts b/packages/app-tests/e2e/document-auth/action-auth.spec.ts index 5a29f287c..7a30d585d 100644 --- a/packages/app-tests/e2e/document-auth/action-auth.spec.ts +++ b/packages/app-tests/e2e/document-auth/action-auth.spec.ts @@ -10,7 +10,7 @@ import { seedPendingDocumentNoFields, seedPendingDocumentWithFullFields, } from '@documenso/prisma/seed/documents'; -import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/users'; +import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users'; import { apiSignin, apiSignout } from '../fixtures/authentication'; @@ -60,9 +60,6 @@ test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page } await page.getByRole('button', { name: 'Sign' }).click(); await page.waitForURL(`${signUrl}/complete`); } - - await unseedUser(user.id); - await unseedUser(recipientWithAccount.id); }); test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ page }) => { @@ -119,9 +116,6 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Sign' }).click(); await page.waitForURL(`${signUrl}/complete`); - - await unseedUser(user.id); - await unseedUser(recipientWithAccount.id); }); // Currently document auth for signing/approving/viewing is not required. @@ -154,9 +148,6 @@ test.skip('[DOCUMENT_AUTH]: should deny signing document when required for globa await expect(page.getByRole('paragraph')).toContainText( 'Reauthentication is required to sign the document', ); - - await unseedUser(user.id); - await unseedUser(recipientWithAccount.id); }); test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth', async ({ @@ -196,9 +187,6 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth' await page.getByRole('button', { name: 'Cancel' }).click(); } } - - await unseedUser(user.id); - await unseedUser(recipientWithAccount.id); }); test('[DOCUMENT_AUTH]: should allow field signing when required for recipient auth', async ({ diff --git a/packages/app-tests/e2e/document-flow/settings-step.spec.ts b/packages/app-tests/e2e/document-flow/settings-step.spec.ts index cef428a24..49d8de1f5 100644 --- a/packages/app-tests/e2e/document-flow/settings-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/settings-step.spec.ts @@ -6,8 +6,8 @@ import { seedPendingDocument, } from '@documenso/prisma/seed/documents'; import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; -import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; -import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; +import { seedTeam } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from '../fixtures/authentication'; @@ -53,8 +53,6 @@ test.describe('[EE_ONLY]', () => { await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); - - await unseedUser(user.id); }); test('[DOCUMENT_FLOW] enterprise team member can add action auth settings', async ({ page }) => { @@ -94,8 +92,6 @@ test.describe('[EE_ONLY]', () => { // Advanced settings should be visible. await expect(page.getByLabel('Show advanced settings')).toBeVisible(); - - await unseedTeam(team.url); }); test('[DOCUMENT_FLOW] enterprise team member should not have access to enterprise on personal account', async ({ @@ -130,8 +126,6 @@ test.describe('[EE_ONLY]', () => { // Advanced settings should not be visible. await expect(page.getByLabel('Show advanced settings')).not.toBeVisible(); - - await unseedTeam(team.url); }); }); @@ -166,8 +160,6 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => { await expect(page.getByLabel('Title')).toHaveValue('New Title'); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); - - await unseedUser(user.id); }); test('[DOCUMENT_FLOW]: title should be disabled depending on document status', async ({ page }) => { @@ -188,6 +180,4 @@ test('[DOCUMENT_FLOW]: title should be disabled depending on document status', a // Should be enabled for draft documents. await page.goto(`/documents/${draftDocument.id}/edit`); await expect(page.getByLabel('Title')).toBeEnabled(); - - await unseedUser(user.id); }); diff --git a/packages/app-tests/e2e/document-flow/signers-step.spec.ts b/packages/app-tests/e2e/document-flow/signers-step.spec.ts index a832c69a6..a29066c03 100644 --- a/packages/app-tests/e2e/document-flow/signers-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/signers-step.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; -import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; +import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from '../fixtures/authentication'; @@ -57,8 +57,6 @@ test.describe('[EE_ONLY]', () => { await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); // Todo: Fix stepper component back issue before finishing test. - - await unseedUser(user.id); }); }); @@ -91,6 +89,4 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await page.getByRole('button', { name: 'Go Back' }).click(); await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); - - await unseedUser(user.id); }); diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts index 327e0ab74..c35ad759d 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -9,7 +9,7 @@ import { seedBlankDocument, seedPendingDocumentWithFullFields, } from '@documenso/prisma/seed/documents'; -import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; +import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from '../fixtures/authentication'; @@ -107,8 +107,6 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) => // Assert document was created await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); - - await unseedUser(user.id); }); test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients', async ({ @@ -192,8 +190,6 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie // Assert document was created await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); - - await unseedUser(user.id); }); test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients with different roles', async ({ @@ -291,8 +287,6 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie // Assert document was created await expect(page.getByRole('link', { name: 'Test Title' })).toBeVisible(); - - await unseedUser(user.id); }); test('[DOCUMENT_FLOW]: should not be able to create a document without signatures', async ({ @@ -331,8 +325,6 @@ test('[DOCUMENT_FLOW]: should not be able to create a document without signature await expect( page.getByRole('dialog').getByText('No signature field found').first(), ).toBeVisible(); - - await unseedUser(user.id); }); test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) => { @@ -388,8 +380,6 @@ test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) = .click(); await page.waitForURL(`${signUrl}/complete`); } - - await unseedUser(user.id); }); test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({ @@ -462,8 +452,6 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a // Check if document has been signed const { status: completedStatus } = await getDocumentByToken(token); expect(completedStatus).toBe(DocumentStatus.COMPLETED); - - await unseedUser(user.id); }); test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', async ({ page }) => { @@ -505,6 +493,4 @@ test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', asyn // Check if document has been signed const { status: completedStatus } = await getDocumentByToken(token); expect(completedStatus).toBe(DocumentStatus.COMPLETED); - - await unseedUser(user.id); }); diff --git a/packages/app-tests/e2e/teams/manage-team.spec.ts b/packages/app-tests/e2e/teams/manage-team.spec.ts index 7403ab9c9..3d0fb0ace 100644 --- a/packages/app-tests/e2e/teams/manage-team.spec.ts +++ b/packages/app-tests/e2e/teams/manage-team.spec.ts @@ -1,7 +1,7 @@ import { test } from '@playwright/test'; import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; -import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; +import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from '../fixtures/authentication'; @@ -33,8 +33,6 @@ test('[TEAMS]: create team', async ({ page }) => { // Goto new team settings page. await page.getByRole('row').filter({ hasText: teamId }).getByRole('link').nth(1).click(); - - await unseedTeam(teamId); }); test('[TEAMS]: delete team', async ({ page }) => { @@ -84,6 +82,4 @@ test('[TEAMS]: update team', async ({ page }) => { // Check we have been redirected to the new team URL and the name is updated. await page.waitForURL(`${WEBAPP_BASE_URL}/t/${updatedTeamId}/settings`); - - await unseedTeam(updatedTeamId); }); diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts index 6cea6445d..b40f6d376 100644 --- a/packages/app-tests/e2e/teams/team-documents.spec.ts +++ b/packages/app-tests/e2e/teams/team-documents.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { DocumentStatus } from '@documenso/prisma/client'; import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents'; -import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams'; +import { seedTeamEmail } from '@documenso/prisma/seed/teams'; import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin, apiSignout } from '../fixtures/authentication'; @@ -42,8 +42,6 @@ test('[TEAMS]: check team documents count', async ({ page }) => { await apiSignout({ page }); } - - await unseedTeam(team.url); }); test('[TEAMS]: check team documents count with internal team email', async ({ page }) => { @@ -138,9 +136,6 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa await apiSignout({ page }); } - - await unseedTeamEmail({ teamId: team.id }); - await unseedTeam(team.url); }); test('[TEAMS]: check team documents count with external team email', async ({ page }) => { @@ -225,9 +220,6 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa await checkDocumentTabCount(page, 'Completed', 0); await checkDocumentTabCount(page, 'Draft', 1); await checkDocumentTabCount(page, 'All', 3); - - await unseedTeamEmail({ teamId: team.id }); - await unseedTeam(team.url); }); test('[TEAMS]: resend pending team document', async ({ page }) => { @@ -284,8 +276,6 @@ test('[TEAMS]: delete draft team document', async ({ page }) => { await apiSignout({ page }); } - - await unseedTeam(team.url); }); test('[TEAMS]: delete pending team document', async ({ page }) => { @@ -325,8 +315,6 @@ test('[TEAMS]: delete pending team document', async ({ page }) => { await apiSignout({ page }); } - - await unseedTeam(team.url); }); test('[TEAMS]: delete completed team document', async ({ page }) => { @@ -366,6 +354,4 @@ test('[TEAMS]: delete completed team document', async ({ page }) => { await apiSignout({ page }); } - - await unseedTeam(team.url); }); diff --git a/packages/app-tests/e2e/teams/team-email.spec.ts b/packages/app-tests/e2e/teams/team-email.spec.ts index 6ae820f59..7becb2418 100644 --- a/packages/app-tests/e2e/teams/team-email.spec.ts +++ b/packages/app-tests/e2e/teams/team-email.spec.ts @@ -1,8 +1,8 @@ import { expect, test } from '@playwright/test'; import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; -import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams'; -import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; +import { seedTeam, seedTeamEmailVerification } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from '../fixtures/authentication'; @@ -31,8 +31,6 @@ test('[TEAMS]: send team email request', async ({ page }) => { .filter({ hasText: 'We have sent a confirmation email for verification.' }) .first(), ).toBeVisible(); - - await unseedTeam(team.url); }); test('[TEAMS]: accept team email request', async ({ page }) => { @@ -41,14 +39,12 @@ test('[TEAMS]: accept team email request', async ({ page }) => { }); const teamEmailVerification = await seedTeamEmailVerification({ - email: 'team-email-verification@test.documenso.com', + email: `team-email-verification--${team.url}@test.documenso.com`, teamId: team.id, }); await page.goto(`${WEBAPP_BASE_URL}/team/verify/email/${teamEmailVerification.token}`); await expect(page.getByRole('heading')).toContainText('Team email verified!'); - - await unseedTeam(team.url); }); test('[TEAMS]: delete team email', async ({ page }) => { @@ -66,10 +62,9 @@ test('[TEAMS]: delete team email', async ({ page }) => { await page.locator('section div').filter({ hasText: 'Team email' }).getByRole('button').click(); await page.getByRole('menuitem', { name: 'Remove' }).click(); + await page.getByRole('button', { name: 'Remove' }).click(); await expect(page.getByText('Team email has been removed').first()).toBeVisible(); - - await unseedTeam(team.url); }); test('[TEAMS]: team email owner removes access', async ({ page }) => { @@ -96,7 +91,4 @@ test('[TEAMS]: team email owner removes access', async ({ page }) => { await page.getByRole('button', { name: 'Revoke' }).click(); await expect(page.getByText('You have successfully revoked').first()).toBeVisible(); - - await unseedTeam(team.url); - await unseedUser(teamEmailOwner.id); }); diff --git a/packages/app-tests/e2e/teams/team-members.spec.ts b/packages/app-tests/e2e/teams/team-members.spec.ts index c85717729..0e5f828ed 100644 --- a/packages/app-tests/e2e/teams/team-members.spec.ts +++ b/packages/app-tests/e2e/teams/team-members.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; -import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams'; +import { seedTeam, seedTeamInvite } from '@documenso/prisma/seed/teams'; import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from '../fixtures/authentication'; @@ -35,8 +35,6 @@ test('[TEAMS]: update team member role', async ({ page }) => { await expect( page.getByRole('row').filter({ hasText: teamMemberToUpdate.user.email }), ).toContainText('Manager'); - - await unseedTeam(team.url); }); test('[TEAMS]: accept team invitation without account', async ({ page }) => { @@ -49,8 +47,6 @@ test('[TEAMS]: accept team invitation without account', async ({ page }) => { await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`); await expect(page.getByRole('heading')).toContainText('Team invitation'); - - await unseedTeam(team.url); }); test('[TEAMS]: accept team invitation with account', async ({ page }) => { @@ -64,8 +60,6 @@ test('[TEAMS]: accept team invitation with account', async ({ page }) => { await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`); await expect(page.getByRole('heading')).toContainText('Invitation accepted!'); - - await unseedTeam(team.url); }); test('[TEAMS]: member can leave team', async ({ page }) => { @@ -88,8 +82,6 @@ test('[TEAMS]: member can leave team', async ({ page }) => { await expect(page.getByRole('status').first()).toContainText( 'You have successfully left this team.', ); - - await unseedTeam(team.url); }); test('[TEAMS]: owner cannot leave team', async ({ page }) => { @@ -105,6 +97,4 @@ test('[TEAMS]: owner cannot leave team', async ({ page }) => { }); await expect(page.getByRole('button').getByText('Leave')).toBeDisabled(); - - await unseedTeam(team.url); }); diff --git a/packages/app-tests/e2e/teams/transfer-team.spec.ts b/packages/app-tests/e2e/teams/transfer-team.spec.ts index c8460baf8..d34525160 100644 --- a/packages/app-tests/e2e/teams/transfer-team.spec.ts +++ b/packages/app-tests/e2e/teams/transfer-team.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; -import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams'; +import { seedTeam, seedTeamTransfer } from '@documenso/prisma/seed/teams'; import { apiSignin } from '../fixtures/authentication'; @@ -43,8 +43,6 @@ test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => { await expect(page.getByRole('status').first()).toContainText( 'The team transfer invitation has been successfully deleted.', ); - - await unseedTeam(team.url); }); /** @@ -64,6 +62,4 @@ test.skip('[TEAMS]: accept team transfer', async ({ page }) => { await page.goto(`${WEBAPP_BASE_URL}/team/verify/transfer/${teamTransferRequest.token}`); await expect(page.getByRole('heading')).toContainText('Team ownership transferred!'); - - await unseedTeam(team.url); }); diff --git a/packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts index 517a3f093..4061b0046 100644 --- a/packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts +++ b/packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts @@ -1,9 +1,9 @@ import { expect, test } from '@playwright/test'; import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; -import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; +import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; -import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; +import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from '../fixtures/authentication'; @@ -49,8 +49,6 @@ test.describe('[EE_ONLY]', () => { await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); - - await unseedUser(user.id); }); test('[TEMPLATE_FLOW] enterprise team member can add action auth settings', async ({ page }) => { @@ -90,8 +88,6 @@ test.describe('[EE_ONLY]', () => { // Advanced settings should be visible. await expect(page.getByLabel('Show advanced settings')).toBeVisible(); - - await unseedTeam(team.url); }); test('[TEMPLATE_FLOW] enterprise team member should not have access to enterprise on personal account', async ({ @@ -126,8 +122,6 @@ test.describe('[EE_ONLY]', () => { // Advanced settings should not be visible. await expect(page.getByLabel('Show advanced settings')).not.toBeVisible(); - - await unseedTeam(team.url); }); }); @@ -162,6 +156,4 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => { await expect(page.getByLabel('Title')).toHaveValue('New Title'); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); - - await unseedUser(user.id); }); diff --git a/packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts index 37b58f53b..dd9dfa9a5 100644 --- a/packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts +++ b/packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; -import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; +import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from '../fixtures/authentication'; @@ -73,8 +73,6 @@ test.describe('[EE_ONLY]', () => { // Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced // settings were applied. await expect(page.getByLabel('Show advanced settings')).toBeHidden(); - - await unseedUser(user.id); }); }); @@ -101,6 +99,4 @@ test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => { // Advanced settings should not be visible for non EE users. await expect(page.getByLabel('Show advanced settings')).toBeHidden(); - - await unseedUser(user.id); }); diff --git a/packages/app-tests/e2e/templates/direct-templates.spec.ts b/packages/app-tests/e2e/templates/direct-templates.spec.ts index 518dc28c0..5b822dbcf 100644 --- a/packages/app-tests/e2e/templates/direct-templates.spec.ts +++ b/packages/app-tests/e2e/templates/direct-templates.spec.ts @@ -9,9 +9,9 @@ import { import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; -import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; +import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates'; -import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/users'; +import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from '../fixtures/authentication'; import { checkDocumentTabCount } from '../fixtures/documents'; @@ -67,8 +67,6 @@ test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) => // Expect badge to appear. await expect(page.getByRole('button', { name: 'direct link' })).toHaveCount(2); } - - await unseedTeam(team.url); }); test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => { @@ -115,8 +113,6 @@ test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => { await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); await expect(page.getByText('Template not found')).toBeVisible(); } - - await unseedTeam(team.url); }); test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => { @@ -162,8 +158,6 @@ test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => { await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); await expect(page.getByText('Template not found')).toBeVisible(); } - - await unseedTeam(team.url); }); test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => { @@ -197,8 +191,6 @@ test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByLabel('Email')).toBeDisabled(); - - await unseedUser(user.id); }); test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ page }) => { @@ -248,8 +240,6 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p // Check that the document is in the 'All' tab. await checkDocumentTabCount(page, 'Completed', 1); } - - await unseedTeam(team.url); }); test('[DIRECT_TEMPLATES]: use direct template link with 2 recipients', async ({ page }) => { @@ -333,7 +323,4 @@ test('[DIRECT_TEMPLATES]: use direct template link with 2 recipients', async ({ await checkDocumentTabCount(page, 'All', 2); await checkDocumentTabCount(page, 'Inbox', 2); - - await unseedTeam(team.url); - await unseedUser(secondRecipient.id); }); diff --git a/packages/app-tests/e2e/templates/manage-templates.spec.ts b/packages/app-tests/e2e/templates/manage-templates.spec.ts index 7d75c4f65..135ab96f1 100644 --- a/packages/app-tests/e2e/templates/manage-templates.spec.ts +++ b/packages/app-tests/e2e/templates/manage-templates.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; -import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; +import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedTemplate } from '@documenso/prisma/seed/templates'; import { apiSignin } from '../fixtures/authentication'; @@ -49,8 +49,6 @@ test('[TEMPLATES]: view templates', async ({ page }) => { // Only should only see their personal template. await page.goto(`${WEBAPP_BASE_URL}/templates`); await expect(page.getByRole('main')).toContainText('Showing 1 result'); - - await unseedTeam(team.url); }); test('[TEMPLATES]: delete template', async ({ page }) => { @@ -110,8 +108,6 @@ test('[TEMPLATES]: delete template', async ({ page }) => { await page.reload(); } - - await unseedTeam(team.url); }); test('[TEMPLATES]: duplicate template', async ({ page }) => { @@ -156,8 +152,6 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => { await page.getByRole('button', { name: 'Duplicate' }).click(); await expect(page.getByText('Template duplicated').first()).toBeVisible(); await expect(page.getByRole('main')).toContainText('Showing 2 results'); - - await unseedTeam(team.url); }); test('[TEMPLATES]: use template', async ({ page }) => { @@ -219,6 +213,4 @@ test('[TEMPLATES]: use template', async ({ page }) => { await page.getByRole('main').getByRole('link', { name: 'Documents' }).click(); await page.waitForURL(`/t/${team.url}/documents`); await expect(page.getByRole('main')).toContainText('Showing 1 result'); - - await unseedTeam(team.url); }); diff --git a/packages/app-tests/e2e/user/auth-flow.spec.ts b/packages/app-tests/e2e/user/auth-flow.spec.ts index 94338ec21..e1d311530 100644 --- a/packages/app-tests/e2e/user/auth-flow.spec.ts +++ b/packages/app-tests/e2e/user/auth-flow.spec.ts @@ -4,8 +4,6 @@ import { extractUserVerificationToken, seedTestEmail, seedUser, - unseedUser, - unseedUserByEmail, } from '@documenso/prisma/seed/users'; test.use({ storageState: { cookies: [], origins: [] } }); @@ -48,7 +46,6 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page await page.waitForURL('/documents'); await expect(page).toHaveURL('/documents'); - await unseedUserByEmail(email); }); test('[USER] can sign in using email and password', async ({ page }: { page: Page }) => { @@ -61,6 +58,4 @@ test('[USER] can sign in using email and password', async ({ page }: { page: Pag await page.waitForURL('/documents'); await expect(page).toHaveURL('/documents'); - - await unseedUser(user.id); }); diff --git a/packages/email/static/delete-team.png b/packages/email/static/delete-team.png new file mode 100644 index 000000000..cc6cca3d7 Binary files /dev/null and b/packages/email/static/delete-team.png differ diff --git a/packages/email/static/delete-user.png b/packages/email/static/delete-user.png new file mode 100644 index 000000000..34e15e6b9 Binary files /dev/null and b/packages/email/static/delete-user.png differ diff --git a/packages/email/templates/team-delete.tsx b/packages/email/templates/team-delete.tsx new file mode 100644 index 000000000..240757338 --- /dev/null +++ b/packages/email/templates/team-delete.tsx @@ -0,0 +1,86 @@ +import { formatTeamUrl } from '@documenso/lib/utils/teams'; +import config from '@documenso/tailwind-config'; + +import { Body, Container, Head, Hr, Html, Preview, Section, Tailwind, Text } from '../components'; +import { TemplateFooter } from '../template-components/template-footer'; +import TemplateImage from '../template-components/template-image'; + +export type TeamDeleteEmailProps = { + assetBaseUrl: string; + baseUrl: string; + teamUrl: string; + isOwner: boolean; +}; + +export const TeamDeleteEmailTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + baseUrl = 'https://documenso.com', + teamUrl = 'demo', + isOwner = false, +}: TeamDeleteEmailProps) => { + const previewText = isOwner + ? 'Your team has been deleted' + : 'A team you were a part of has been deleted'; + + const title = isOwner + ? 'Your team has been deleted' + : 'A team you were a part of has been deleted'; + + const description = isOwner + ? 'The following team has been deleted by you' + : 'The following team has been deleted by its owner. You will no longer be able to access this team and its documents'; + + return ( + + + {previewText} + + +
+ + + +
+ +
+ +
+ {title} + + {description} + +
+ {formatTeamUrl(teamUrl, baseUrl)} +
+
+
+ +
+ + + + +
+ +
+ + ); +}; + +export default TeamDeleteEmailTemplate; diff --git a/packages/email/templates/team-invite.tsx b/packages/email/templates/team-invite.tsx index 4602b7382..3c062c4c8 100644 --- a/packages/email/templates/team-invite.tsx +++ b/packages/email/templates/team-invite.tsx @@ -89,6 +89,12 @@ export const TeamInviteEmailTemplate = ({ > Accept + diff --git a/packages/email/templates/team-join.tsx b/packages/email/templates/team-join.tsx new file mode 100644 index 000000000..b3c1efc2f --- /dev/null +++ b/packages/email/templates/team-join.tsx @@ -0,0 +1,84 @@ +import { formatTeamUrl } from '@documenso/lib/utils/teams'; +import config from '@documenso/tailwind-config'; + +import { Body, Container, Head, Hr, Html, Preview, Section, Tailwind, Text } from '../components'; +import { TemplateFooter } from '../template-components/template-footer'; +import TemplateImage from '../template-components/template-image'; + +export type TeamJoinEmailProps = { + assetBaseUrl: string; + baseUrl: string; + memberName: string; + memberEmail: string; + teamName: string; + teamUrl: string; +}; + +export const TeamJoinEmailTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + baseUrl = 'https://documenso.com', + memberName = 'John Doe', + memberEmail = 'johndoe@documenso.com', + teamName = 'Team Name', + teamUrl = 'demo', +}: TeamJoinEmailProps) => { + const previewText = 'A team member has joined a team on Documenso'; + + return ( + + + {previewText} + + +
+ + + +
+ +
+ +
+ + {memberName || memberEmail} joined the team {teamName} on Documenso + + + + {memberEmail} joined the following team + + +
+ {formatTeamUrl(teamUrl, baseUrl)} +
+
+
+ +
+ + + + +
+ +
+ + ); +}; + +export default TeamJoinEmailTemplate; diff --git a/packages/email/templates/team-leave.tsx b/packages/email/templates/team-leave.tsx new file mode 100644 index 000000000..51b9aaab1 --- /dev/null +++ b/packages/email/templates/team-leave.tsx @@ -0,0 +1,84 @@ +import { formatTeamUrl } from '@documenso/lib/utils/teams'; +import config from '@documenso/tailwind-config'; + +import { Body, Container, Head, Hr, Html, Preview, Section, Tailwind, Text } from '../components'; +import { TemplateFooter } from '../template-components/template-footer'; +import TemplateImage from '../template-components/template-image'; + +export type TeamLeaveEmailProps = { + assetBaseUrl: string; + baseUrl: string; + memberName: string; + memberEmail: string; + teamName: string; + teamUrl: string; +}; + +export const TeamLeaveEmailTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + baseUrl = 'https://documenso.com', + memberName = 'John Doe', + memberEmail = 'johndoe@documenso.com', + teamName = 'Team Name', + teamUrl = 'demo', +}: TeamLeaveEmailProps) => { + const previewText = 'A team member has left a team on Documenso'; + + return ( + + + {previewText} + + +
+ + + +
+ +
+ +
+ + {memberName || memberEmail} left the team {teamName} on Documenso + + + + {memberEmail} left the following team + + +
+ {formatTeamUrl(teamUrl, baseUrl)} +
+
+
+ +
+ + + + +
+ +
+ + ); +}; + +export default TeamLeaveEmailTemplate; diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index 03e6a8ca1..9899cc791 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -1,6 +1,9 @@ import { JobClient } from './client/client'; -import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/send-confirmation-email'; -import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/send-signing-email'; +import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email'; +import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email'; +import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email'; +import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email'; +import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email'; /** * The `as const` assertion is load bearing as it provides the correct level of type inference for @@ -9,4 +12,9 @@ import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/send-signing-em export const jobsClient = new JobClient([ SEND_SIGNING_EMAIL_JOB_DEFINITION, SEND_CONFIRMATION_EMAIL_JOB_DEFINITION, + SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION, + SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION, + SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION, ] as const); + +export const jobs = jobsClient; diff --git a/packages/lib/jobs/definitions/send-confirmation-email.ts b/packages/lib/jobs/definitions/emails/send-confirmation-email.ts similarity index 84% rename from packages/lib/jobs/definitions/send-confirmation-email.ts rename to packages/lib/jobs/definitions/emails/send-confirmation-email.ts index 09bf479ef..34513082e 100644 --- a/packages/lib/jobs/definitions/send-confirmation-email.ts +++ b/packages/lib/jobs/definitions/emails/send-confirmation-email.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { sendConfirmationToken } from '../../server-only/user/send-confirmation-token'; -import type { JobDefinition } from '../client/_internal/job'; +import { sendConfirmationToken } from '../../../server-only/user/send-confirmation-token'; +import type { JobDefinition } from '../../client/_internal/job'; const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID = 'send.signup.confirmation.email'; diff --git a/packages/lib/jobs/definitions/send-signing-email.ts b/packages/lib/jobs/definitions/emails/send-signing-email.ts similarity index 89% rename from packages/lib/jobs/definitions/send-signing-email.ts rename to packages/lib/jobs/definitions/emails/send-signing-email.ts index dc2c17d12..0244df34f 100644 --- a/packages/lib/jobs/definitions/send-signing-email.ts +++ b/packages/lib/jobs/definitions/emails/send-signing-email.ts @@ -13,17 +13,17 @@ import { SendStatus, } from '@documenso/prisma/client'; -import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; -import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; +import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email'; import { RECIPIENT_ROLES_DESCRIPTION, RECIPIENT_ROLE_TO_EMAIL_TYPE, -} from '../../constants/recipient-roles'; -import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; -import { ZRequestMetadataSchema } from '../../universal/extract-request-metadata'; -import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; -import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template'; -import { type JobDefinition } from '../client/_internal/job'; +} from '../../../constants/recipient-roles'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs'; +import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata'; +import { createDocumentAuditLogData } from '../../../utils/document-audit-logs'; +import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template'; +import { type JobDefinition } from '../../client/_internal/job'; const SEND_SIGNING_EMAIL_JOB_DEFINITION_ID = 'send.signing.requested.email'; diff --git a/packages/lib/jobs/definitions/emails/send-team-deleted-email.ts b/packages/lib/jobs/definitions/emails/send-team-deleted-email.ts new file mode 100644 index 000000000..b6bd52cda --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-team-deleted-email.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; + +import { sendTeamDeleteEmail } from '../../../server-only/team/delete-team'; +import type { JobDefinition } from '../../client/_internal/job'; + +const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_ID = 'send.team-deleted.email'; + +const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({ + team: z.object({ + name: z.string(), + url: z.string(), + ownerUserId: z.number(), + }), + members: z.array( + z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + }), + ), +}); + +export const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION = { + id: SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_ID, + name: 'Send Team Deleted Email', + version: '1.0.0', + trigger: { + name: SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_ID, + schema: SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const { team, members } = payload; + + for (const member of members) { + await io.runTask(`send-team-deleted-email--${team.url}_${member.id}`, async () => { + await sendTeamDeleteEmail({ + email: member.email, + teamName: team.name, + teamUrl: team.url, + isOwner: member.id === team.ownerUserId, + }); + }); + } + }, +} as const satisfies JobDefinition< + typeof SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_ID, + z.infer +>; diff --git a/packages/lib/jobs/definitions/emails/send-team-member-joined-email.ts b/packages/lib/jobs/definitions/emails/send-team-member-joined-email.ts new file mode 100644 index 000000000..c5afa1cd3 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-team-member-joined-email.ts @@ -0,0 +1,91 @@ +import { z } from 'zod'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import TeamJoinEmailTemplate from '@documenso/email/templates/team-join'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberRole } from '@documenso/prisma/client'; + +import { WEBAPP_BASE_URL } from '../../../constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email'; +import type { JobDefinition } from '../../client/_internal/job'; + +const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID = 'send.team-member-joined.email'; + +const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({ + teamId: z.number(), + memberId: z.number(), +}); + +export const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION = { + id: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID, + name: 'Send Team Member Joined Email', + version: '1.0.0', + trigger: { + name: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID, + schema: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const team = await prisma.team.findFirstOrThrow({ + where: { + id: payload.teamId, + }, + include: { + members: { + where: { + role: { + in: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER], + }, + }, + include: { + user: true, + }, + }, + }, + }); + + const invitedMember = await prisma.teamMember.findFirstOrThrow({ + where: { + id: payload.memberId, + teamId: payload.teamId, + }, + include: { + user: true, + }, + }); + + for (const member of team.members) { + if (member.id === invitedMember.id) { + continue; + } + + await io.runTask( + `send-team-member-joined-email--${invitedMember.id}_${member.id}`, + async () => { + const emailContent = TeamJoinEmailTemplate({ + assetBaseUrl: WEBAPP_BASE_URL, + baseUrl: WEBAPP_BASE_URL, + memberName: invitedMember.user.name || '', + memberEmail: invitedMember.user.email, + teamName: team.name, + teamUrl: team.url, + }); + + await mailer.sendMail({ + to: member.user.email, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: 'A new member has joined your team', + html: render(emailContent), + text: render(emailContent, { plainText: true }), + }); + }, + ); + } + }, +} as const satisfies JobDefinition< + typeof SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID, + z.infer +>; diff --git a/packages/lib/jobs/definitions/emails/send-team-member-left-email.ts b/packages/lib/jobs/definitions/emails/send-team-member-left-email.ts new file mode 100644 index 000000000..55e5ab1e6 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-team-member-left-email.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import TeamJoinEmailTemplate from '@documenso/email/templates/team-join'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberRole } from '@documenso/prisma/client'; + +import { WEBAPP_BASE_URL } from '../../../constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email'; +import type { JobDefinition } from '../../client/_internal/job'; + +const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID = 'send.team-member-left.email'; + +const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA = z.object({ + teamId: z.number(), + memberUserId: z.number(), +}); + +export const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION = { + id: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID, + name: 'Send Team Member Left Email', + version: '1.0.0', + trigger: { + name: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID, + schema: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const team = await prisma.team.findFirstOrThrow({ + where: { + id: payload.teamId, + }, + include: { + members: { + where: { + role: { + in: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER], + }, + }, + include: { + user: true, + }, + }, + }, + }); + + const oldMember = await prisma.user.findFirstOrThrow({ + where: { + id: payload.memberUserId, + }, + }); + + for (const member of team.members) { + await io.runTask(`send-team-member-left-email--${oldMember.id}_${member.id}`, async () => { + const emailContent = TeamJoinEmailTemplate({ + assetBaseUrl: WEBAPP_BASE_URL, + baseUrl: WEBAPP_BASE_URL, + memberName: oldMember.name || '', + memberEmail: oldMember.email, + teamName: team.name, + teamUrl: team.url, + }); + + await mailer.sendMail({ + to: member.user.email, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: `A team member has left ${team.name}`, + html: render(emailContent), + text: render(emailContent, { plainText: true }), + }); + }); + } + }, +} as const satisfies JobDefinition< + typeof SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID, + z.infer +>; diff --git a/packages/lib/server-only/team/accept-team-invitation.ts b/packages/lib/server-only/team/accept-team-invitation.ts index 31fef5967..9318acc48 100644 --- a/packages/lib/server-only/team/accept-team-invitation.ts +++ b/packages/lib/server-only/team/accept-team-invitation.ts @@ -1,7 +1,8 @@ import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { prisma } from '@documenso/prisma'; -import { IS_BILLING_ENABLED } from '../../constants/app'; +import { jobs } from '../../jobs/client'; export type AcceptTeamInvitationOptions = { userId: number; @@ -26,6 +27,11 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat team: { include: { subscription: true, + members: { + include: { + user: true, + }, + }, }, }, }, @@ -33,7 +39,7 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat const { team } = teamMemberInvite; - await tx.teamMember.create({ + const teamMember = await tx.teamMember.create({ data: { teamId: teamMemberInvite.teamId, userId: user.id, @@ -60,6 +66,14 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat quantity: numberOfSeats, }); } + + await jobs.triggerJob({ + name: 'send.team-member-joined.email', + payload: { + teamId: team.id, + memberId: teamMember.id, + }, + }); }, { timeout: 30_000 }, ); diff --git a/packages/lib/server-only/team/decline-team-invitation.ts b/packages/lib/server-only/team/decline-team-invitation.ts new file mode 100644 index 000000000..e3d87dc4e --- /dev/null +++ b/packages/lib/server-only/team/decline-team-invitation.ts @@ -0,0 +1,34 @@ +import { prisma } from '@documenso/prisma'; + +export type DeclineTeamInvitationOptions = { + userId: number; + teamId: number; +}; + +export const declineTeamInvitation = async ({ userId, teamId }: DeclineTeamInvitationOptions) => { + await prisma.$transaction( + async (tx) => { + const user = await tx.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + + const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({ + where: { + teamId, + email: user.email, + }, + }); + + await tx.teamMemberInvite.delete({ + where: { + id: teamMemberInvite.id, + }, + }); + + // TODO: notify the team owner + }, + { timeout: 30_000 }, + ); +}; diff --git a/packages/lib/server-only/team/delete-team.ts b/packages/lib/server-only/team/delete-team.ts index 667d2f448..57c761e72 100644 --- a/packages/lib/server-only/team/delete-team.ts +++ b/packages/lib/server-only/team/delete-team.ts @@ -1,7 +1,16 @@ +import { createElement } from 'react'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +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 { AppError } from '../../errors/app-error'; -import { stripe } from '../stripe'; +import { jobs } from '../../jobs/client'; export type DeleteTeamOptions = { userId: number; @@ -18,6 +27,17 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => { }, include: { subscription: true, + members: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }, }, }); @@ -33,6 +53,22 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => { }); } + await jobs.triggerJob({ + name: 'send.team-deleted.email', + payload: { + team: { + name: team.name, + url: team.url, + ownerUserId: team.ownerUserId, + }, + members: team.members.map((member) => ({ + id: member.user.id, + name: member.user.name || '', + email: member.user.email, + })), + }, + }); + await tx.team.delete({ where: { id: teamId, @@ -43,3 +79,30 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => { { timeout: 30_000 }, ); }; + +type SendTeamDeleteEmailOptions = Omit & { + email: string; + teamName: string; +}; + +export const sendTeamDeleteEmail = async ({ + email, + ...emailTemplateOptions +}: SendTeamDeleteEmailOptions) => { + const template = createElement(TeamDeleteEmailTemplate, { + assetBaseUrl: WEBAPP_BASE_URL, + baseUrl: WEBAPP_BASE_URL, + ...emailTemplateOptions, + }); + + await mailer.sendMail({ + to: email, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: `Team "${emailTemplateOptions.teamName}" has been deleted on Documenso`, + html: render(template), + text: render(template, { plainText: true }), + }); +}; diff --git a/packages/lib/server-only/team/leave-team.ts b/packages/lib/server-only/team/leave-team.ts index 410d0707c..ef038913c 100644 --- a/packages/lib/server-only/team/leave-team.ts +++ b/packages/lib/server-only/team/leave-team.ts @@ -2,6 +2,8 @@ import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { prisma } from '@documenso/prisma'; +import { jobs } from '../../jobs/client'; + export type LeaveTeamOptions = { /** * The ID of the user who is leaving the team. @@ -23,12 +25,21 @@ export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => { ownerUserId: { not: userId, }, + members: { + some: { + userId, + }, + }, }, include: { subscription: true, }, }); + const leavingUser = await tx.user.findUniqueOrThrow({ + where: { id: userId }, + }); + await tx.teamMember.delete({ where: { userId_teamId: { @@ -56,6 +67,14 @@ export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => { quantity: numberOfSeats, }); } + + await jobs.triggerJob({ + name: 'send.team-member-left.email', + payload: { + teamId, + memberUserId: leavingUser.id, + }, + }); }, { timeout: 30_000 }, ); diff --git a/packages/prisma/migrations/20240713043633_decline_team_invite/migration.sql b/packages/prisma/migrations/20240713043633_decline_team_invite/migration.sql new file mode 100644 index 000000000..44ba65cff --- /dev/null +++ b/packages/prisma/migrations/20240713043633_decline_team_invite/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "TeamMemberInviteStatus" ADD VALUE 'DECLINED'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index b48dcb1cb..44cf9e157 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -480,6 +480,7 @@ enum TeamMemberRole { enum TeamMemberInviteStatus { ACCEPTED PENDING + DECLINED } model Team { diff --git a/packages/tailwind-config/index.cjs b/packages/tailwind-config/index.cjs index ae36f7fcf..22faa9899 100644 --- a/packages/tailwind-config/index.cjs +++ b/packages/tailwind-config/index.cjs @@ -7,9 +7,6 @@ module.exports = { content: ['src/**/*.{ts,tsx}'], theme: { extend: { - screens: { - print: { raw: 'print' }, - }, fontFamily: { sans: ['var(--font-sans)', ...fontFamily.sans], signature: ['var(--font-signature)'], @@ -138,6 +135,7 @@ module.exports = { '3xl': '1920px', '4xl': '2560px', '5xl': '3840px', + print: { raw: 'print' }, }, }, }, diff --git a/packages/trpc/server/team-router/router.ts b/packages/trpc/server/team-router/router.ts index 50d9431a7..2b27fa489 100644 --- a/packages/trpc/server/team-router/router.ts +++ b/packages/trpc/server/team-router/router.ts @@ -8,6 +8,7 @@ import { createTeamBillingPortal } from '@documenso/lib/server-only/team/create- import { createTeamPendingCheckoutSession } from '@documenso/lib/server-only/team/create-team-checkout-session'; import { createTeamEmailVerification } from '@documenso/lib/server-only/team/create-team-email-verification'; import { createTeamMemberInvites } from '@documenso/lib/server-only/team/create-team-member-invites'; +import { declineTeamInvitation } from '@documenso/lib/server-only/team/decline-team-invitation'; import { deleteTeam } from '@documenso/lib/server-only/team/delete-team'; import { deleteTeamEmail } from '@documenso/lib/server-only/team/delete-team-email'; import { deleteTeamEmailVerification } from '@documenso/lib/server-only/team/delete-team-email-verification'; @@ -42,6 +43,7 @@ import { ZCreateTeamMemberInvitesMutationSchema, ZCreateTeamMutationSchema, ZCreateTeamPendingCheckoutMutationSchema, + ZDeclineTeamInvitationMutationSchema, ZDeleteTeamEmailMutationSchema, ZDeleteTeamEmailVerificationMutationSchema, ZDeleteTeamMemberInvitationsMutationSchema, @@ -82,6 +84,21 @@ export const teamRouter = router({ } }), + declineTeamInvitation: authenticatedProcedure + .input(ZDeclineTeamInvitationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await declineTeamInvitation({ + teamId: input.teamId, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + createBillingPortal: authenticatedProcedure .input(ZCreateTeamBillingPortalMutationSchema) .mutation(async ({ input, ctx }) => { diff --git a/packages/trpc/server/team-router/schema.ts b/packages/trpc/server/team-router/schema.ts index 9c835ac33..a27805cdd 100644 --- a/packages/trpc/server/team-router/schema.ts +++ b/packages/trpc/server/team-router/schema.ts @@ -53,6 +53,10 @@ export const ZAcceptTeamInvitationMutationSchema = z.object({ teamId: z.number(), }); +export const ZDeclineTeamInvitationMutationSchema = z.object({ + teamId: z.number(), +}); + export const ZCreateTeamBillingPortalMutationSchema = z.object({ teamId: z.number(), });