feat: auto-accept team invitations for existing users (#24091)
* feat: auto-accept team invitations for existing users - Change email button text from 'View Invitation' to 'Accept Invite' - Implement auto-accept flow when clicking email CTA - Update TeamService.inviteMemberByToken to support auto-acceptance - Add new autoAcceptInvite tRPC endpoint for handling auto-acceptance - Update invitation link generation to include autoAccept parameter - Handle both team and organization invitation scenarios - Maintain payment/billing flow integration with TeamBilling.updateQuantity - Preserve backward compatibility with existing manual flow - Update all locale files with new 'Accept Invite' button text Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * revert: locale changes except English - Keep only English 'Accept Invite' translation - Revert all other locale files to original 'View Invitation' translations - Maintain core auto-accept invitation functionality Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * simplify: remove autoAccept parameter and make auto-acceptance default - Remove autoAccept parameter from TeamService.inviteMemberByToken - Always auto-accept invitations for existing users clicking email links - Remove autoAccept logic from teams server-page.tsx - Remove autoAccept=true from invitation URLs - Delete autoAcceptInvite handler and schema files - Remove autoAcceptInvite endpoint from tRPC router - Simplify invitation flow to match new user pattern Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * test: update teamService test to expect auto-accepted memberships - Change expectation from accepted: false to accepted: true - Update test description to reflect auto-accept behavior - Fix TypeScript type casting to use Pick<TeamRepository, 'deleteById'> - Aligns with new default auto-acceptance for team invitations Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * update * Update utils.ts * fix type error * delete token * add prisma transaction * update * update param * test: fix mock objects in teamService tests with realistic data - Fix duplicate property assignments in mock user objects - Use proper email format (user@example.com) for email fields - Use proper username format (testuser) for username fields - Fix logic error in acceptInvitationByToken (|| to &&) - Add autoAccept parameter to resendInvitation.handler.ts - All 16 tests passing with proper TypeScript types Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * test: add e2e tests for team invitation auto-accept flow - Add test for existing user auto-accepting team invitation via email link - Add test for error handling when wrong user tries to use invitation link - Verify proper user identity validation and database state changes - Follow existing e2e test patterns with browser context isolation - Fix ESLint warnings: replace conditional with assertion and remove unused browser parameter Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * fix * fix * fix: update team owner creation in e2e tests to include proper names - Fix email subject mismatch in auto-accept invitation tests - Team owners now created with explicit names instead of undefined - Matches pattern used in other working team invitation tests Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * fix: update organization invitation test helper to match new auto-accept link format - Change expectExistingUserToBeInvitedToOrganization to look for 'teams?token' instead of 'settings/team' - Fixes 'Invite link not found' error in organization booking e2e test - Aligns with auto-accept invitation URL changes that use /teams?token= format - Fix eslint disable comment for playwright rule Co-Authored-By: anik@cal.com <adhabal2002@gmail.com> * address coderrabit review * fix failing test * addressed review --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
anik@cal.com <adhabal2002@gmail.com>
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
fea7e66996
commit
27820ce897
@@ -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");
|
||||
|
||||
@@ -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: (
|
||||
<TeamsListing
|
||||
invitationAccepted={invitationAccepted}
|
||||
teams={teams}
|
||||
orgId={orgId ?? null}
|
||||
permissions={{
|
||||
|
||||
@@ -15,7 +15,6 @@ export async function expectInvitationEmailToBeReceived(
|
||||
) {
|
||||
if (!emails) return null;
|
||||
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(2000);
|
||||
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
|
||||
expect(receivedEmails?.total).toBe(1);
|
||||
@@ -36,5 +35,5 @@ export async function expectExistingUserToBeInvitedToOrganization(
|
||||
userEmail: string,
|
||||
subject?: string | null
|
||||
) {
|
||||
return expectInvitationEmailToBeReceived(page, emails, userEmail, subject, "settings/team");
|
||||
return expectInvitationEmailToBeReceived(page, emails, userEmail, subject, "teams?token");
|
||||
}
|
||||
|
||||
@@ -47,8 +47,7 @@ test.describe("Team", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -61,7 +61,7 @@ export const TeamInviteEmail = (
|
||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||
<CallToAction
|
||||
label={props.language(
|
||||
props.isCalcomMember ? (props.isAutoJoin ? "login" : "email_user_cta") : "create_your_account"
|
||||
props.isCalcomMember ? (props.isAutoJoin ? "login" : "accept_invite") : "join_now"
|
||||
)}
|
||||
href={props.joinLink}
|
||||
endIconName="linkIcon"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { UpgradeTip } from "../../../tips";
|
||||
import TeamList from "./TeamList";
|
||||
|
||||
type TeamsListingProps = {
|
||||
invitationAccepted: boolean;
|
||||
orgId: number | null;
|
||||
permissions: {
|
||||
canCreateTeam: boolean;
|
||||
@@ -28,6 +29,7 @@ type TeamsListingProps = {
|
||||
};
|
||||
|
||||
export function TeamsListing({
|
||||
invitationAccepted,
|
||||
orgId,
|
||||
permissions,
|
||||
teams: data,
|
||||
@@ -93,13 +95,18 @@ export function TeamsListing({
|
||||
return;
|
||||
}
|
||||
|
||||
if (teamNameFromInvite) {
|
||||
showToast(t("team_invite_received", { teamName: teamNameFromInvite }), "success");
|
||||
if (errorMsgFromInvite) {
|
||||
showToast(errorMsgFromInvite, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorMsgFromInvite) {
|
||||
showToast(errorMsgFromInvite, "error");
|
||||
if (invitationAccepted) {
|
||||
showToast(t("successfully_joined"), "success");
|
||||
return;
|
||||
}
|
||||
|
||||
if (teamNameFromInvite) {
|
||||
showToast(t("team_invite_received", { teamName: teamNameFromInvite }), "success");
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -3,9 +3,12 @@ import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { TeamBilling } from "@calcom/features/ee/billing/teams";
|
||||
import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries";
|
||||
import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser";
|
||||
import { deleteDomain } from "@calcom/lib/domainManager/organization";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import { WorkflowService } from "@calcom/lib/server/service/workflows";
|
||||
import type { Membership, Team, User, VerificationToken, Profile } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -17,6 +20,8 @@ vi.mock("@calcom/lib/server/repository/team");
|
||||
vi.mock("@calcom/lib/server/service/workflows");
|
||||
vi.mock("@calcom/lib/domainManager/organization");
|
||||
vi.mock("@calcom/features/ee/teams/lib/removeMember");
|
||||
vi.mock("@calcom/lib/createAProfileForAnExistingUser");
|
||||
vi.mock("@calcom/features/ee/teams/lib/queries");
|
||||
|
||||
const mockTeamBilling = {
|
||||
cancel: vi.fn(),
|
||||
@@ -49,8 +54,8 @@ describe("TeamService", () => {
|
||||
// @ts-ignore
|
||||
const mockTeamRepo = {
|
||||
deleteById: vi.fn().mockResolvedValue(mockDeletedTeam),
|
||||
};
|
||||
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);
|
||||
} as Pick<TeamRepository, "deleteById">;
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user