diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx index 66117c09bb..114b4f1489 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx @@ -23,7 +23,8 @@ const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => { const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); const searchParams = await _searchParams; const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token; - const callbackUrl = token ? `/teams?token=${encodeURIComponent(token)}` : null; + const autoAccept = Array.isArray(searchParams?.autoAccept) ? searchParams.autoAccept[0] : searchParams?.autoAccept; + const callbackUrl = token ? `/teams?token=${encodeURIComponent(token)}${autoAccept ? `&autoAccept=${encodeURIComponent(autoAccept)}` : ''}` : null; if (!session) { redirect(callbackUrl ? `/auth/login?callbackUrl=${callbackUrl}` : "/auth/login"); diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx index 2b3eb0dce6..d17fa76c1f 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/server-page.tsx @@ -33,14 +33,21 @@ export const ServerTeamsListing = async ({ session: Session; }) => { const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token; + const autoAccept = Array.isArray(searchParams?.autoAccept) ? searchParams.autoAccept[0] : searchParams?.autoAccept; const userId = session.user.id; + let invitationAccepted = false; let teamNameFromInvite, errorMsgFromInvite = null; if (token) { try { - teamNameFromInvite = await TeamService.inviteMemberByToken(token, userId); + if (autoAccept === "true") { + await TeamService.acceptInvitationByToken(token, userId); + invitationAccepted = true; + } else { + teamNameFromInvite = await TeamService.inviteMemberByToken(token, userId); + } } catch (e) { errorMsgFromInvite = "Error while fetching teams"; if (e instanceof TRPCError) errorMsgFromInvite = e.message; @@ -64,6 +71,7 @@ export const ServerTeamsListing = async ({ return { Main: ( { page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) ).toHaveCount(1); - // eslint-disable-next-line playwright/no-conditional-in-test - if (!inviteLink) return null; + expect(inviteLink).toBeTruthy(); // Follow invite link to new window const context = await browser.newContext(); @@ -103,7 +102,7 @@ test.describe("Team", () => { }); }); - test("Invitation (verified)", async ({ browser, page, users, emails }) => { + test("Invitation (verified)", async ({ page, users, emails }) => { const t = await localize("en"); const teamOwner = await users.create({ name: `team-owner-${Date.now()}` }, { hasTeam: true }); const { team } = await teamOwner.getFirstTeamMembership(); @@ -192,4 +191,128 @@ test.describe("Team", () => { }); expect(parentEventType?.children.find((et) => et.userId === invitedMember.id)).toBeTruthy(); }); + + test("Auto-accept invitation for existing user", async ({ browser, page, users, emails }) => { + const t = await localize("en"); + const teamOwner = await users.create({ name: "Invited User" }, { hasTeam: true }); + const { team } = await teamOwner.getFirstTeamMembership(); + const invitedUser = await users.create({ + email: `invited-user-${Date.now()}@example.com`, + name: "Invited User", + }); + + await teamOwner.apiLogin(); + await page.goto(`/settings/teams/${team.id}/members`); + + let inviteLink: string; + + await test.step("Send invitation to existing user", async () => { + await page.waitForLoadState("domcontentloaded"); + await page.waitForTimeout(500); + await page.getByTestId("new-member-button").click(); + await page.locator('input[name="inviteUser"]').fill(invitedUser.email); + await page.getByText(t("send_invite")).click(); + + inviteLink = await expectInvitationEmailToBeReceived( + page, + emails, + invitedUser.email, + `${teamOwner.name} invited you to join the team ${team.name} on Cal.com`, + "teams?token" + ); + + expect(inviteLink).toContain("autoAccept=true"); + + const membership = await prisma.membership.findFirst({ + where: { + userId: invitedUser.id, + teamId: team.id, + }, + }); + expect(membership?.accepted).toBe(false); + }); + + await test.step("Auto-accept invitation by clicking link", async () => { + const [secondContext, secondPage] = await invitedUser.apiLoginOnNewBrowser(browser); + + await secondPage.goto(inviteLink); + + await expect(secondPage.getByText("Successfully joined")).toBeVisible(); + + const membership = await prisma.membership.findFirst({ + where: { + userId: invitedUser.id, + teamId: team.id, + }, + }); + expect(membership?.accepted).toBe(true); + + await secondPage.close(); + await secondContext.close(); + }); + }); + + test("Error when wrong user tries to use invitation link", async ({ browser, page, users, emails }) => { + const t = await localize("en"); + const teamOwner = await users.create({ name: "Wrong User" }, { hasTeam: true }); + const { team } = await teamOwner.getFirstTeamMembership(); + const invitedUser = await users.create({ + email: `invited-user-${Date.now()}@example.com`, + name: "Invited User", + }); + const wrongUser = await users.create({ + email: `wrong-user-${Date.now()}@example.com`, + name: "Wrong User", + }); + + await teamOwner.apiLogin(); + await page.goto(`/settings/teams/${team.id}/members`); + + let inviteLink: string; + + await test.step("Send invitation to specific user", async () => { + await page.waitForLoadState("domcontentloaded"); + await page.waitForTimeout(500); + await page.getByTestId("new-member-button").click(); + await page.locator('input[name="inviteUser"]').fill(invitedUser.email); + await page.getByText(t("send_invite")).click(); + + inviteLink = await expectInvitationEmailToBeReceived( + page, + emails, + invitedUser.email, + `${teamOwner.name} invited you to join the team ${team.name} on Cal.com`, + "teams?token" + ); + + expect(inviteLink).toContain("autoAccept=true"); + }); + + await test.step("Wrong user tries to use invitation link", async () => { + const [secondContext, secondPage] = await wrongUser.apiLoginOnNewBrowser(browser); + + await secondPage.goto(inviteLink); + + await expect(secondPage.getByText("This invitation is not for your account")).toBeVisible(); + + const membership = await prisma.membership.findFirst({ + where: { + userId: wrongUser.id, + teamId: team.id, + }, + }); + expect(membership).toBeNull(); + + const invitedMembership = await prisma.membership.findFirst({ + where: { + userId: invitedUser.id, + teamId: team.id, + }, + }); + expect(invitedMembership?.accepted).toBe(false); + + await secondPage.close(); + await secondContext.close(); + }); + }); }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index a0a79e847b..9261b86c90 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2152,6 +2152,9 @@ "delete_sso_configuration_confirmation_description": "Are you sure you want to delete the {{connectionType}} configuration? Your team members who use {{connectionType}} login will no longer be able to access Cal.com.", "organizer_timezone": "Organizer timezone", "email_user_cta": "View Invitation", + "accept_invite": "Accept Invite", + "join_now": "Join Now", + "successfully_joined": "Successfully joined", "email_no_user_invite_heading_subteam": "You've been invited to join a team of {{parentTeamName}} organization", "email_no_user_invite_heading_org": "You've been invited to join a {{appName}} organization", "email_no_user_invite_subheading": "{{invitedBy}} has invited you to join their team on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", diff --git a/packages/emails/src/templates/TeamInviteEmail.tsx b/packages/emails/src/templates/TeamInviteEmail.tsx index 4cc62dd272..c74083cfbb 100644 --- a/packages/emails/src/templates/TeamInviteEmail.tsx +++ b/packages/emails/src/templates/TeamInviteEmail.tsx @@ -61,7 +61,7 @@ export const TeamInviteEmail = (
{ // @ts-ignore const mockTeamRepo = { deleteById: vi.fn().mockResolvedValue(mockDeletedTeam), - }; - vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any); + } as Pick; + vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo); const result = await TeamService.delete({ id: 1 }); @@ -69,17 +74,18 @@ describe("TeamService", () => { await expect(TeamService.inviteMemberByToken("invalid-token", 1)).rejects.toThrow(TRPCError); }); - it("should create membership and update billing", async () => { + it("should create provisional membership and update billing", async () => { const mockToken = { teamId: 1, team: { name: "Test Team" }, expiresInDays: null, expires: new Date(Date.now() + 86400000), token: "valid-token", - identifier: "valid-token", + identifier: "test@example.com", id: "1", }; prismaMock.verificationToken.findFirst.mockResolvedValue(mockToken); + prismaMock.membership.create.mockResolvedValue({} as Membership); const result = await TeamService.inviteMemberByToken("valid-token", 1); @@ -97,6 +103,292 @@ describe("TeamService", () => { }); }); + describe("acceptTeamMembership", () => { + it("should accept membership and update event types for regular team", async () => { + const mockMembership = { + team: { id: 1, parentId: null, isOrganization: false }, + }; + + prismaMock.membership.update.mockResolvedValue(mockMembership as Membership & { team: Team }); + vi.mocked(updateNewTeamMemberEventTypes).mockResolvedValue(undefined); + + await TeamService.acceptTeamMembership({ + userId: 1, + teamId: 1, + userEmail: "test@example.com", + username: "testuser", + }); + + expect(prismaMock.membership.update).toHaveBeenCalledWith({ + where: { userId_teamId: { userId: 1, teamId: 1 } }, + data: { accepted: true }, + select: { team: true }, + }); + expect(updateNewTeamMemberEventTypes).toHaveBeenCalledWith(1, 1); + }); + + it("should accept membership and create profile for organization", async () => { + const mockMembership = { + team: { id: 1, parentId: null, isOrganization: true }, + }; + + prismaMock.membership.update.mockResolvedValue(mockMembership as Membership & { team: Team }); + vi.mocked(createAProfileForAnExistingUser).mockResolvedValue({} as Profile); + vi.mocked(updateNewTeamMemberEventTypes).mockResolvedValue(undefined); + + await TeamService.acceptTeamMembership({ + userId: 1, + teamId: 1, + userEmail: "test@example.com", + username: "testuser", + }); + + expect(createAProfileForAnExistingUser).toHaveBeenCalledWith({ + user: { + id: 1, + email: "test@example.com", + currentUsername: "testuser", + }, + organizationId: 1, + }); + }); + + it("should accept membership and handle parent team for subteam", async () => { + const mockMembership = { + team: { id: 1, parentId: 2, isOrganization: false }, + }; + + prismaMock.membership.update + .mockResolvedValueOnce(mockMembership as Membership & { team: Team }) + .mockResolvedValueOnce({} as Membership); + vi.mocked(createAProfileForAnExistingUser).mockResolvedValue({} as Profile); + vi.mocked(updateNewTeamMemberEventTypes).mockResolvedValue(undefined); + + await TeamService.acceptTeamMembership({ + userId: 1, + teamId: 1, + userEmail: "test@example.com", + username: "testuser", + }); + + expect(prismaMock.membership.update).toHaveBeenCalledTimes(2); + expect(prismaMock.membership.update).toHaveBeenNthCalledWith(2, { + where: { userId_teamId: { userId: 1, teamId: 2 } }, + data: { accepted: true }, + }); + expect(createAProfileForAnExistingUser).toHaveBeenCalledWith({ + user: { + id: 1, + email: "test@example.com", + currentUsername: "testuser", + }, + organizationId: 2, + }); + }); + }); + describe("leaveTeamMembership", () => { + it("should delete membership when rejecting invitation", async () => { + const mockMembership = { + team: { id: 1, parentId: null }, + }; + + prismaMock.membership.delete.mockResolvedValue(mockMembership as Membership & { team: Team }); + + await TeamService.leaveTeamMembership({ + userId: 1, + teamId: 1, + }); + + expect(prismaMock.membership.delete).toHaveBeenCalledWith({ + where: { userId_teamId: { userId: 1, teamId: 1 } }, + select: { team: true }, + }); + }); + + it("should delete parent membership when rejecting subteam invitation", async () => { + const mockMembership = { + team: { id: 1, parentId: 2 }, + }; + + prismaMock.membership.delete + .mockResolvedValueOnce(mockMembership as Membership & { team: Team }) + .mockResolvedValueOnce({} as Membership); + + await TeamService.leaveTeamMembership({ + userId: 1, + teamId: 1, + }); + + expect(prismaMock.membership.delete).toHaveBeenCalledTimes(2); + expect(prismaMock.membership.delete).toHaveBeenNthCalledWith(2, { + where: { userId_teamId: { userId: 1, teamId: 2 } }, + }); + }); + }); + + describe("acceptInvitationByToken", () => { + it("should throw error if verification token is not found", async () => { + prismaMock.verificationToken.findFirst.mockResolvedValue(null); + await expect(TeamService.acceptInvitationByToken("invalid-token", 1)).rejects.toThrow(TRPCError); + }); + + it("should throw error if token is not associated with team", async () => { + const mockToken = { + teamId: null, + team: null, + identifier: "test@example.com", + id: "1", + }; + + prismaMock.verificationToken.findFirst.mockResolvedValue( + mockToken as VerificationToken & { team: Team | null } + ); + + await expect(TeamService.acceptInvitationByToken("valid-token", 1)).rejects.toThrow( + new TRPCError({ + code: "NOT_FOUND", + message: "Invite token is not associated with any team", + }) + ); + }); + + it("should throw error if user not found", async () => { + const mockToken = { + teamId: 1, + team: { name: "Test Team" }, + identifier: "test@example.com", + id: "1", + }; + + prismaMock.verificationToken.findFirst.mockResolvedValue( + mockToken as VerificationToken & { team: Team } + ); + prismaMock.user.findUnique.mockResolvedValue(null); + + await expect(TeamService.acceptInvitationByToken("valid-token", 1)).rejects.toThrow( + new TRPCError({ code: "NOT_FOUND", message: "User not found" }) + ); + }); + + it("should throw error if user email doesn't match token identifier", async () => { + const mockToken = { + teamId: 1, + team: { name: "Test Team" }, + identifier: "invited@example.com", + id: "1", + }; + + const mockUser = { + email: "different@example.com", + username: "testuser", + }; + + prismaMock.verificationToken.findFirst.mockResolvedValue( + mockToken as VerificationToken & { team: Team } + ); + prismaMock.user.findUnique.mockResolvedValue(mockUser as User); + + await expect(TeamService.acceptInvitationByToken("valid-token", 1)).rejects.toThrow( + new TRPCError({ + code: "FORBIDDEN", + message: "This invitation is not for your account", + }) + ); + }); + + it("should throw error if user username doesn't match token identifier", async () => { + const mockToken = { + teamId: 1, + team: { name: "Test Team" }, + identifier: "inviteduser", + id: "1", + }; + + const mockUser = { + email: "test@example.com", + username: "differentuser", + }; + + prismaMock.verificationToken.findFirst.mockResolvedValue( + mockToken as VerificationToken & { team: Team } + ); + prismaMock.user.findUnique.mockResolvedValue(mockUser as User); + + await expect(TeamService.acceptInvitationByToken("valid-token", 1)).rejects.toThrow( + new TRPCError({ + code: "FORBIDDEN", + message: "This invitation is not for your account", + }) + ); + }); + + it("should accept invitation when user email matches token identifier", async () => { + const mockToken = { + teamId: 1, + team: { name: "Test Team" }, + identifier: "test@example.com", + id: "1", + }; + + const mockUser = { + email: "test@example.com", + username: "testuser", + }; + + const mockMembership = { + team: { id: 1, parentId: null, isOrganization: false }, + }; + + prismaMock.verificationToken.findFirst.mockResolvedValue( + mockToken as VerificationToken & { team: Team } + ); + prismaMock.user.findUnique.mockResolvedValue(mockUser as User); + prismaMock.membership.update.mockResolvedValue(mockMembership as Membership & { team: Team }); + vi.mocked(updateNewTeamMemberEventTypes).mockResolvedValue(undefined); + + await TeamService.acceptInvitationByToken("valid-token", 1); + + expect(prismaMock.membership.update).toHaveBeenCalledWith({ + where: { userId_teamId: { userId: 1, teamId: 1 } }, + data: { accepted: true }, + select: { team: true }, + }); + }); + + it("should accept invitation when user username matches token identifier", async () => { + const mockToken = { + teamId: 1, + team: { name: "Test Team" }, + identifier: "testuser", + id: "1", + }; + + const mockUser = { + email: "testuser@example.com", + username: "testuser", + }; + + const mockMembership = { + team: { id: 1, parentId: null, isOrganization: false }, + }; + + prismaMock.verificationToken.findFirst.mockResolvedValue( + mockToken as VerificationToken & { team: Team } + ); + prismaMock.user.findUnique.mockResolvedValue(mockUser as User); + prismaMock.membership.update.mockResolvedValue(mockMembership as Membership & { team: Team }); + vi.mocked(updateNewTeamMemberEventTypes).mockResolvedValue(undefined); + + await TeamService.acceptInvitationByToken("valid-token", 1); + + expect(prismaMock.membership.update).toHaveBeenCalledWith({ + where: { userId_teamId: { userId: 1, teamId: 1 } }, + data: { accepted: true }, + select: { team: true }, + }); + }); + }); + describe("publish", () => { it("should call publish on TeamBilling", async () => { await TeamService.publish(1); diff --git a/packages/lib/server/service/teamService.ts b/packages/lib/server/service/teamService.ts index d5a85817c5..3f0a333393 100644 --- a/packages/lib/server/service/teamService.ts +++ b/packages/lib/server/service/teamService.ts @@ -1,6 +1,10 @@ +import { randomBytes } from "crypto"; + import { TeamBilling } from "@calcom/features/ee/billing/teams"; -import { WEBAPP_URL } from "@calcom/lib/constants"; import { deleteWorkfowRemindersOfRemovedMember } from "@calcom/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember"; +import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser"; import { deleteDomain } from "@calcom/lib/domainManager/organization"; import logger from "@calcom/lib/logger"; import { ProfileRepository } from "@calcom/lib/server/repository/profile"; @@ -12,7 +16,6 @@ import type { Membership } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; -import { randomBytes } from "crypto"; const log = logger.getSubLogger({ prefix: ["TeamService"] }); @@ -169,7 +172,8 @@ export class TeamService { token, OR: [{ expiresInDays: null }, { expires: { gte: new Date() } }], }, - include: { + select: { + teamId: true, team: { select: { name: true, @@ -212,6 +216,139 @@ export class TeamService { return verificationToken.team.name; } + static async acceptTeamMembership({ + userId, + teamId, + userEmail, + username, + }: { + userId: number; + teamId: number; + userEmail: string; + username: string | null; + }) { + const teamMembership = await prisma.membership.update({ + where: { + userId_teamId: { userId, teamId }, + }, + data: { + accepted: true, + }, + select: { + team: true, + }, + }); + + const team = teamMembership.team; + + if (team.parentId) { + await prisma.membership.update({ + where: { + userId_teamId: { userId, teamId: team.parentId }, + }, + data: { + accepted: true, + }, + }); + } + + const isASubteam = team.parentId !== null; + const idOfOrganizationInContext = team.isOrganization ? team.id : isASubteam ? team.parentId : null; + const needProfileUpdate = !!idOfOrganizationInContext; + + if (needProfileUpdate) { + await createAProfileForAnExistingUser({ + user: { + id: userId, + email: userEmail, + currentUsername: username, + }, + organizationId: idOfOrganizationInContext, + }); + } + + await updateNewTeamMemberEventTypes(userId, teamId); + } + static async leaveTeamMembership({ + userId, + teamId, + }: { + userId: number; + teamId: number; + }) { + try { + const membership = await prisma.membership.delete({ + where: { + userId_teamId: { userId, teamId }, + }, + select: { + team: true, + }, + }); + + if (membership.team.parentId) { + await prisma.membership.delete({ + where: { + userId_teamId: { userId, teamId: membership.team.parentId }, + }, + }); + } + } catch (e) { + console.log(e); + } + } + + static async acceptInvitationByToken(acceptanceToken: string, userId: number) { + const verificationToken = await prisma.verificationToken.findFirst({ + where: { + token: acceptanceToken, + expires: { gte: new Date() }, + }, + select: { + identifier: true, + teamId: true, + team: { select: { name: true } }, + }, + }); + + if (!verificationToken) { + throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" }); + } + + if (!verificationToken.teamId || !verificationToken.team) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Invite token is not associated with any team", + }); + } + + const currentUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { email: true, username: true }, + }); + + if (!currentUser) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } + + if ( + currentUser.email !== verificationToken.identifier && + currentUser.username !== verificationToken.identifier + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "This invitation is not for your account", + }); + } + + await TeamService.acceptTeamMembership({ + userId, + teamId: verificationToken.teamId, + userEmail: currentUser.email, + username: currentUser.username, + }); + } + static async publish(teamId: number) { const teamBilling = await TeamBilling.findAndInit(teamId); return teamBilling.publish(); diff --git a/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts index 9bcd76b1ac..a8f7678cc2 100644 --- a/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/acceptOrLeave.handler.ts @@ -1,6 +1,4 @@ -import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser"; -import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries"; -import { prisma } from "@calcom/prisma"; +import { TeamService } from "@calcom/lib/server/service/teamService"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import type { TAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema"; @@ -14,69 +12,17 @@ type AcceptOrLeaveOptions = { export const acceptOrLeaveHandler = async ({ ctx, input }: AcceptOrLeaveOptions) => { if (input.accept) { - const teamMembership = await prisma.membership.update({ - where: { - userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, - }, - data: { - accepted: true, - }, - include: { - team: true, - }, + await TeamService.acceptTeamMembership({ + userId: ctx.user.id, + teamId: input.teamId, + userEmail: ctx.user.email, + username: ctx.user.username, }); - - const team = teamMembership.team; - - if (team.parentId) { - await prisma.membership.update({ - where: { - userId_teamId: { userId: ctx.user.id, teamId: team.parentId }, - }, - data: { - accepted: true, - }, - include: { - team: true, - }, - }); - } - - const isASubteam = team.parentId !== null; - const idOfOrganizationInContext = team.isOrganization ? team.id : isASubteam ? team.parentId : null; - const needProfileUpdate = !!idOfOrganizationInContext; - if (needProfileUpdate) { - await createAProfileForAnExistingUser({ - user: { - id: ctx.user.id, - email: ctx.user.email, - currentUsername: ctx.user.username, - }, - organizationId: idOfOrganizationInContext, - }); - } - await updateNewTeamMemberEventTypes(ctx.user.id, input.teamId); } else { - try { - const membership = await prisma.membership.delete({ - where: { - userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, - }, - include: { - team: true, - }, - }); - - if (membership.team.parentId) { - await prisma.membership.delete({ - where: { - userId_teamId: { userId: ctx.user.id, teamId: membership.team.parentId }, - }, - }); - } - } catch (e) { - console.log(e); - } + await TeamService.leaveTeamMembership({ + userId: ctx.user.id, + teamId: input.teamId, + }); } }; diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index 6e55e53114..4ced2955c9 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -470,6 +470,22 @@ export async function createMemberships({ } } +const createVerificationToken = async (identifier: string, teamId: number) => { + const token = randomBytes(32).toString("hex"); + return prisma.verificationToken.create({ + data: { + identifier, + token, + expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week + team: { + connect: { + id: teamId, + }, + }, + }, + }); +}; + export async function sendSignupToOrganizationEmail({ usernameOrEmail, team, @@ -486,26 +502,13 @@ export async function sendSignupToOrganizationEmail({ isOrg: boolean; }) { try { - const token: string = randomBytes(32).toString("hex"); - - await prisma.verificationToken.create({ - data: { - identifier: usernameOrEmail, - token, - expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week - team: { - connect: { - id: teamId, - }, - }, - }, - }); + const verificationToken = await createVerificationToken(usernameOrEmail, teamId); await sendTeamInviteEmail({ language: translation, from: inviterName || `${team.name}'s admin`, to: usernameOrEmail, teamName: team.name, - joinLink: `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`, + joinLink: `${WEBAPP_URL}/signup?token=${verificationToken.token}&callbackUrl=/getting-started`, isCalcomMember: false, isOrg: isOrg, parentTeamName: team?.parent?.name, @@ -712,22 +715,22 @@ export const sendExistingUserTeamInviteEmails = async ({ * This only changes if the user is a CAL user and has not completed onboarding and has no password */ if (!user.completedOnboarding && !user.password?.hash && user.identityProvider === "CAL") { - const token = randomBytes(32).toString("hex"); - await prisma.verificationToken.create({ - data: { + const verificationToken = await createVerificationToken(user.email, teamId); + + inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${verificationToken.token}&callbackUrl=/getting-started`; + inviteTeamOptions.isCalcomMember = false; + } else if (!isAutoJoin) { + let verificationToken = await prisma.verificationToken.findFirst({ + where: { identifier: user.email, - token, - expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week - team: { - connect: { - id: teamId, - }, - }, + teamId: teamId, }, }); - inviteTeamOptions.joinLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`; - inviteTeamOptions.isCalcomMember = false; + if (!verificationToken) { + verificationToken = await createVerificationToken(user.email, teamId); + } + inviteTeamOptions.joinLink = `${WEBAPP_URL}/teams?token=${verificationToken.token}&autoAccept=true`; } return sendTeamInviteEmail({ diff --git a/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts index 12a400d305..001d8946bd 100644 --- a/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts @@ -37,7 +37,7 @@ export const resendInvitationHandler = async ({ ctx, input }: InviteMemberOption } const inviteTeamOptions = { - joinLink: `${WEBAPP_URL}/auth/login?callbackUrl=/settings/teams`, + joinLink: `${WEBAPP_URL}/teams?token=${verificationToken?.token}&autoAccept=true`, isCalcomMember: true, isAutoJoin: false, };