fix: A user joining from invite link of a team doesn't automatically become member of the org (#12774)

* fix: Add org membership when invite link for a team in an org is generated

* Run tests sequentially till we fix emails fixture
This commit is contained in:
Hariom Balhara 2023-12-18 17:48:35 +05:30 committed by GitHub
parent 7d2500a32f
commit a28e8ff39b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 549 additions and 240 deletions

View File

@ -365,7 +365,7 @@ test.describe("Booking round robin event", () => {
teammates: teamMatesObj,
}
);
const team = await testUser.getFirstTeam();
const team = await testUser.getFirstTeamMembership();
await page.goto(`/team/${team.team.slug}`);
});
@ -373,7 +373,7 @@ test.describe("Booking round robin event", () => {
const [testUser] = users.get();
testUser.apiLogin();
const team = await testUser.getFirstTeam();
const team = await testUser.getFirstTeamMembership();
// Click first event type (round robin)
await page.click('[data-testid="event-type-link"]');

View File

@ -458,7 +458,7 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
logout: async () => {
await page.goto("/auth/logout");
},
getFirstTeam: async () => {
getFirstTeamMembership: async () => {
const memberships = await prisma.membership.findMany({
where: { userId: user.id },
include: { team: true },

View File

@ -84,7 +84,7 @@ test.describe("Stripe integration", () => {
schedulingType: SchedulingType.COLLECTIVE,
});
await owner.apiLogin();
const { team } = await owner.getFirstTeam();
const { team } = await owner.getFirstTeamMembership();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
const teamEvent = await owner.getFirstTeamEvent(team.id);

View File

@ -1,3 +1,4 @@
import type { Browser, Page } from "@playwright/test";
import { expect } from "@playwright/test";
import prisma from "@calcom/prisma";
@ -13,169 +14,435 @@ test.afterEach(async ({ users, emails }) => {
emails?.deleteAll();
});
test.describe("Organization", () => {
test("Invitation (non verified)", async ({ browser, page, users, emails }) => {
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true });
const { team: org } = await orgOwner.getOrgMembership();
await orgOwner.apiLogin();
await page.goto("/settings/organizations/members");
await page.waitForLoadState("networkidle");
await test.step("To the organization by email (external user)", async () => {
const invitedUserEmail = `rick-${Date.now()}@domain.com`;
// '-domain' because the email doesn't match orgAutoAcceptEmail
const usernameDerivedFromEmail = `${invitedUserEmail.split("@")[0]}-domain`;
await page.locator('button:text("Add")').click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator('button:text("Send invite")').click();
await page.waitForLoadState("networkidle");
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
"signup?token"
);
// Check newly invited member exists and is pending
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(1);
assertInviteLink(inviteLink);
// Follow invite link in new window
const context = await browser.newContext();
const signupPage = await context.newPage();
signupPage.goto(inviteLink);
await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toBeDisabled();
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toBeDisabled();
await signupPage.waitForLoadState("networkidle");
// Check required fields
await signupPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await signupPage.locator("button[type=submit]").click();
await signupPage.waitForURL("/getting-started?from=signup");
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
await context.close();
await signupPage.close();
// Check newly invited member is not pending anymore
await page.bringToFront();
test.describe.serial("Organization", () => {
test.describe("Email not matching orgAutoAcceptEmail", () => {
test("Org Invitation", async ({ browser, page, users, emails }) => {
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true });
const { team: org } = await orgOwner.getOrgMembership();
await orgOwner.apiLogin();
await page.goto("/settings/organizations/members");
page.locator(`[data-testid="login-form"]`);
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(0);
await page.waitForLoadState("networkidle");
await test.step("By email", async () => {
const invitedUserEmail = `rick-${Date.now()}@domain.com`;
// '-domain' because the email doesn't match orgAutoAcceptEmail
const usernameDerivedFromEmail = `${invitedUserEmail.split("@")[0]}-domain`;
await inviteAnEmail(page, invitedUserEmail);
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
"signup?token"
);
await expectUserToBeAMemberOfOrganization({
page,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: false,
email: invitedUserEmail,
});
assertInviteLink(inviteLink);
await signupFromEmailInviteLink(browser, inviteLink);
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
await expectUserToBeAMemberOfOrganization({
page,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: invitedUserEmail,
});
});
await test.step("By invite link", async () => {
const inviteLink = await copyInviteLink(page);
const email = `rick-${Date.now()}@domain.com`;
// '-domain' because the email doesn't match orgAutoAcceptEmail
const usernameDerivedFromEmail = `${email.split("@")[0]}-domain`;
await signupFromInviteLink({ browser, inviteLink, email });
const dbUser = await prisma.user.findUnique({ where: { email } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
await expectUserToBeAMemberOfOrganization({
page,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email,
});
});
});
await test.step("To the organization by invite link", async () => {
// Get the invite link
await page.locator('button:text("Add")').click();
await page.locator(`[data-testid="copy-invite-link-button"]`).click();
test("Team invitation", async ({ browser, page, users, emails }) => {
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, hasSubteam: true });
await orgOwner.apiLogin();
const { team } = await orgOwner.getFirstTeamMembership();
const { team: org } = await orgOwner.getOrgMembership();
const inviteLink = await getInviteLink(page);
// Follow invite link in new window
const context = await browser.newContext();
const inviteLinkPage = await context.newPage();
await inviteLinkPage.goto(inviteLink);
await inviteLinkPage.waitForLoadState("networkidle");
await test.step("By email", async () => {
await page.goto(`/settings/teams/${team.id}/members`);
await page.waitForLoadState("networkidle");
const invitedUserEmail = `rick-${Date.now()}@domain.com`;
// '-domain' because the email doesn't match orgAutoAcceptEmail
const usernameDerivedFromEmail = `${invitedUserEmail.split("@")[0]}-domain`;
await inviteAnEmail(page, invitedUserEmail);
await expectUserToBeAMemberOfTeam({
page,
teamId: team.id,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: false,
email: invitedUserEmail,
});
// Check required fields
const button = inviteLinkPage.locator("button[type=submit][disabled]");
await expect(button).toBeVisible(); // email + 3 password hints
await expectUserToBeAMemberOfOrganization({
page,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: false,
email: invitedUserEmail,
});
// Happy path
const email = `rick-${Date.now()}@domain.com`;
// '-domain' because the email doesn't match orgAutoAcceptEmail
const usernameDerivedFromEmail = `${email.split("@")[0]}-domain`;
await inviteLinkPage.locator("input[name=email]").fill(email);
await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await inviteLinkPage.locator("button[type=submit]").click();
await inviteLinkPage.waitForURL("/getting-started");
const dbUser = await prisma.user.findUnique({ where: { email } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
await page.waitForLoadState("networkidle");
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${team.name}'s admin invited you to join the team ${org.name} on Cal.com`,
"signup?token"
);
assertInviteLink(inviteLink);
await signupFromEmailInviteLink(browser, inviteLink);
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
await expectUserToBeAMemberOfTeam({
page,
teamId: team.id,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: invitedUserEmail,
});
await expectUserToBeAMemberOfOrganization({
page,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: invitedUserEmail,
});
});
await test.step("By invite link", async () => {
await page.goto(`/settings/teams/${team.id}/members`);
const inviteLink = await copyInviteLink(page);
const email = `rick-${Date.now()}@domain.com`;
// '-domain' because the email doesn't match orgAutoAcceptEmail
const usernameDerivedFromEmail = `${email.split("@")[0]}-domain`;
await signupFromInviteLink({ browser, inviteLink, email });
const dbUser = await prisma.user.findUnique({ where: { email } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
await expectUserToBeAMemberOfTeam({
teamId: team.id,
page,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: email,
});
await expectUserToBeAMemberOfOrganization({
page,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: email,
});
});
});
});
test("Invitation (verified)", async ({ browser, page, users, emails }) => {
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, isOrgVerified: true });
const { team: org } = await orgOwner.getOrgMembership();
await orgOwner.apiLogin();
await page.goto("/settings/organizations/members");
await page.waitForLoadState("networkidle");
await test.step("To the organization by email (internal user)", async () => {
const invitedUserEmail = `rick-${Date.now()}@example.com`;
const usernameDerivedFromEmail = invitedUserEmail.split("@")[0];
await page.locator('button:text("Add")').click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator('button:text("Send invite")').click();
test.describe("Email matching orgAutoAcceptEmail and a Verified Organization", () => {
test("Org Invitation", async ({ browser, page, users, emails }) => {
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, isOrgVerified: true });
const { team: org } = await orgOwner.getOrgMembership();
await orgOwner.apiLogin();
await page.goto("/settings/organizations/members");
await page.waitForLoadState("networkidle");
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
"signup?token"
);
assertInviteLink(inviteLink);
await test.step("By email", async () => {
const invitedUserEmail = `rick-${Date.now()}@example.com`;
const usernameDerivedFromEmail = invitedUserEmail.split("@")[0];
await inviteAnEmail(page, invitedUserEmail);
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
"signup?token"
);
// Check newly invited member exists and is not pending
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(0);
await expectUserToBeAMemberOfOrganization({
page,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: invitedUserEmail,
});
// Follow invite link in new window
const context = await browser.newContext();
const signupPage = await context.newPage();
signupPage.goto(inviteLink);
await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toBeDisabled();
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toBeDisabled();
await signupPage.waitForLoadState("networkidle");
assertInviteLink(inviteLink);
await signupFromEmailInviteLink(browser, inviteLink);
// Check required fields
await signupPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await signupPage.locator("button[type=submit]").click();
await signupPage.waitForURL("/getting-started?from=signup");
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
await context.close();
await signupPage.close();
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
await expectUserToBeAMemberOfOrganization({
page,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: invitedUserEmail,
});
});
await test.step("By invite link", async () => {
const inviteLink = await copyInviteLink(page);
const email = `rick-${Date.now()}@example.com`;
const usernameDerivedFromEmail = email.split("@")[0];
await signupFromInviteLink({ browser, inviteLink, email });
const dbUser = await prisma.user.findUnique({ where: { email } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
await expectUserToBeAMemberOfOrganization({
page,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email,
});
});
});
await test.step("To the organization by invite link", async () => {
// Get the invite link
await page.locator('button:text("Add")').click();
await page.locator(`[data-testid="copy-invite-link-button"]`).click();
test("Team Invitation", async ({ browser, page, users, emails }) => {
const orgOwner = await users.create(undefined, {
hasTeam: true,
isOrg: true,
hasSubteam: true,
isOrgVerified: true,
});
const { team: org } = await orgOwner.getOrgMembership();
const { team } = await orgOwner.getFirstTeamMembership();
const inviteLink = await getInviteLink(page);
// Follow invite link in new window
const context = await browser.newContext();
const inviteLinkPage = await context.newPage();
await inviteLinkPage.goto(inviteLink);
await inviteLinkPage.waitForLoadState("networkidle");
await orgOwner.apiLogin();
// Check required fields
const button = inviteLinkPage.locator("button[type=submit][disabled]");
await expect(button).toBeVisible(); // email + 3 password hints
await test.step("By email", async () => {
await page.goto(`/settings/teams/${team.id}/members`);
await page.waitForLoadState("networkidle");
const invitedUserEmail = `rick-${Date.now()}@example.com`;
const usernameDerivedFromEmail = invitedUserEmail.split("@")[0];
await inviteAnEmail(page, invitedUserEmail);
await expectUserToBeAMemberOfTeam({
page,
teamId: team.id,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: invitedUserEmail,
});
// Happy path
const email = `rick-${Date.now()}@example.com`;
// '-domain' because the email doesn't match orgAutoAcceptEmail
const usernameDerivedFromEmail = `${email.split("@")[0]}`;
await inviteLinkPage.locator("input[name=email]").fill(email);
await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await inviteLinkPage.locator("button[type=submit]").click();
await inviteLinkPage.waitForURL("/getting-started");
const dbUser = await prisma.user.findUnique({ where: { email } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
await expectUserToBeAMemberOfOrganization({
page,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: invitedUserEmail,
});
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${team.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
"signup?token"
);
assertInviteLink(inviteLink);
await signupFromEmailInviteLink(browser, inviteLink);
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
await expectUserToBeAMemberOfTeam({
page,
teamId: team.id,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: invitedUserEmail,
});
await expectUserToBeAMemberOfOrganization({
page,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: invitedUserEmail,
});
});
await test.step("By invite link", async () => {
await page.goto(`/settings/teams/${team.id}/members`);
const inviteLink = await copyInviteLink(page);
const email = `rick-${Date.now()}@example.com`;
// '-domain' because the email doesn't match orgAutoAcceptEmail
const usernameDerivedFromEmail = `${email.split("@")[0]}`;
await signupFromInviteLink({ browser, inviteLink, email });
const dbUser = await prisma.user.findUnique({ where: { email } });
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
await expectUserToBeAMemberOfTeam({
teamId: team.id,
page,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: email,
});
await expectUserToBeAMemberOfOrganization({
page,
username: usernameDerivedFromEmail,
role: "member",
isMemberShipAccepted: true,
email: email,
});
});
});
});
});
async function signupFromInviteLink({
browser,
inviteLink,
email,
}: {
browser: Browser;
inviteLink: string;
email: string;
}) {
const context = await browser.newContext();
const inviteLinkPage = await context.newPage();
await inviteLinkPage.goto(inviteLink);
await inviteLinkPage.waitForLoadState("networkidle");
// Check required fields
const button = inviteLinkPage.locator("button[type=submit][disabled]");
await expect(button).toBeVisible(); // email + 3 password hints
await inviteLinkPage.locator("input[name=email]").fill(email);
await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await inviteLinkPage.locator("button[type=submit]").click();
await inviteLinkPage.waitForURL("/getting-started");
return { email };
}
async function signupFromEmailInviteLink(browser: Browser, inviteLink: string) {
// Follow invite link in new window
const context = await browser.newContext();
const signupPage = await context.newPage();
signupPage.goto(inviteLink);
await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toBeDisabled();
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toBeDisabled();
await signupPage.waitForLoadState("networkidle");
// Check required fields
await signupPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await signupPage.locator("button[type=submit]").click();
await signupPage.waitForURL("/getting-started?from=signup");
await context.close();
await signupPage.close();
}
async function inviteAnEmail(page: Page, invitedUserEmail: string) {
await page.locator('button:text("Add")').click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator('button:text("Send invite")').click();
await page.waitForLoadState("networkidle");
}
async function expectUserToBeAMemberOfOrganization({
page,
username,
email,
role,
isMemberShipAccepted,
}: {
page: Page;
username: string;
role: string;
isMemberShipAccepted: boolean;
email: string;
}) {
// Check newly invited member is not pending anymore
await page.goto("/settings/organizations/members");
expect(await page.locator(`[data-testid="member-${username}-username"]`).textContent()).toBe(username);
expect(await page.locator(`[data-testid="member-${username}-email"]`).textContent()).toBe(email);
expect((await page.locator(`[data-testid="member-${username}-role"]`).textContent())?.toLowerCase()).toBe(
role.toLowerCase()
);
if (isMemberShipAccepted) {
await expect(page.locator(`[data-testid2="member-${username}-pending"]`)).toBeHidden();
} else {
await expect(page.locator(`[data-testid2="member-${username}-pending"]`)).toBeVisible();
}
}
async function expectUserToBeAMemberOfTeam({
page,
teamId,
email,
role,
username,
isMemberShipAccepted,
}: {
page: Page;
username: string;
role: string;
teamId: number;
isMemberShipAccepted: boolean;
email: string;
}) {
// Check newly invited member is not pending anymore
await page.goto(`/settings/teams/${teamId}/members`);
expect(
(
await page.locator(`[data-testid="member-${username}"] [data-testid=member-role]`).textContent()
)?.toLowerCase()
).toBe(role.toLowerCase());
if (isMemberShipAccepted) {
await expect(page.locator(`[data-testid="email-${email.replace("@", "")}-pending"]`)).toBeHidden();
} else {
await expect(page.locator(`[data-testid="email-${email.replace("@", "")}-pending"]`)).toBeVisible();
}
}
function assertInviteLink(inviteLink: string | null | undefined): asserts inviteLink is string {
if (!inviteLink) throw new Error("Invite link not found");
}
async function copyInviteLink(page: Page) {
await page.locator('button:text("Add")').click();
await page.locator(`[data-testid="copy-invite-link-button"]`).click();
const inviteLink = await getInviteLink(page);
return inviteLink;
}

View File

@ -242,7 +242,7 @@ test.describe("Signup Flow Test", async () => {
const t = await localize("en");
const teamOwner = await users.create(undefined, { hasTeam: true });
const { team } = await teamOwner.getFirstTeam();
const { team } = await teamOwner.getFirstTeamMembership();
await teamOwner.apiLogin();
await page.goto(`/settings/teams/${team.id}/members`);
await page.waitForLoadState("networkidle");

View File

@ -17,7 +17,7 @@ test.describe("Team", () => {
test("Invitation (non verified)", async ({ browser, page, users, emails }) => {
const t = await localize("en");
const teamOwner = await users.create(undefined, { hasTeam: true });
const { team } = await teamOwner.getFirstTeam();
const { team } = await teamOwner.getFirstTeamMembership();
await teamOwner.apiLogin();
await page.goto(`/settings/teams/${team.id}/members`);
await page.waitForLoadState("networkidle");
@ -98,7 +98,7 @@ test.describe("Team", () => {
test("Invitation (verified)", async ({ browser, page, users, emails }) => {
const t = await localize("en");
const teamOwner = await users.create({ name: `team-owner-${Date.now()}` }, { hasTeam: true });
const { team } = await teamOwner.getFirstTeam();
const { team } = await teamOwner.getFirstTeamMembership();
await teamOwner.apiLogin();
await page.goto(`/settings/teams/${team.id}/members`);
await page.waitForLoadState("networkidle");

View File

@ -22,7 +22,7 @@ test.describe("Teams - NonOrg", () => {
test("Team Onboarding Invite Members", async ({ page, users }) => {
const user = await users.create(undefined, { hasTeam: true });
const { team } = await user.getFirstTeam();
const { team } = await user.getFirstTeamMembership();
const inviteeEmail = `${user.username}+invitee@example.com`;
await user.apiLogin();
@ -80,7 +80,7 @@ test.describe("Teams - NonOrg", () => {
schedulingType: SchedulingType.COLLECTIVE,
}
);
const { team } = await owner.getFirstTeam();
const { team } = await owner.getFirstTeamMembership();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
@ -118,7 +118,7 @@ test.describe("Teams - NonOrg", () => {
}
);
const { team } = await owner.getFirstTeam();
const { team } = await owner.getFirstTeamMembership();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
@ -235,7 +235,7 @@ test.describe("Teams - NonOrg", () => {
);
await owner.apiLogin();
const { team } = await owner.getFirstTeam();
const { team } = await owner.getFirstTeamMembership();
// Mark team as private
await page.goto(`/settings/teams/${team.id}/members`);
@ -348,7 +348,7 @@ test.describe("Teams - Org", () => {
schedulingType: SchedulingType.COLLECTIVE,
}
);
const { team } = await owner.getFirstTeam();
const { team } = await owner.getFirstTeamMembership();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
@ -397,7 +397,7 @@ test.describe("Teams - Org", () => {
}
);
const { team } = await owner.getFirstTeam();
const { team } = await owner.getFirstTeamMembership();
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
@ -448,7 +448,7 @@ test.describe("Teams - Org", () => {
schedulingType: SchedulingType.COLLECTIVE,
}
);
const { team } = await owner.getFirstTeam();
const { team } = await owner.getFirstTeamMembership();
const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
const teamSlugUpperCase = team.slug?.toUpperCase();

View File

@ -18,7 +18,7 @@ test.afterAll(async ({ users }) => {
test.describe("Unpublished", () => {
test("Regular team profile", async ({ page, users }) => {
const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true });
const { team } = await owner.getFirstTeam();
const { team } = await owner.getFirstTeamMembership();
const { requestedSlug } = team.metadata as { requestedSlug: string };
await page.goto(`/team/${requestedSlug}`);
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
@ -33,7 +33,7 @@ test.describe("Unpublished", () => {
isUnpublished: true,
schedulingType: SchedulingType.COLLECTIVE,
});
const { team } = await owner.getFirstTeam();
const { team } = await owner.getFirstTeamMembership();
const { requestedSlug } = team.metadata as { requestedSlug: string };
const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
await page.goto(`/team/${requestedSlug}/${teamEventSlug}`);

View File

@ -4,6 +4,7 @@ import stripe from "@calcom/app-store/stripepayment/lib/server";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getLocaleFromRequest } from "@calcom/lib/getLocaleFromRequest";
import { HttpError } from "@calcom/lib/http-error";
@ -12,7 +13,7 @@ import { createWebUser as syncServicesCreateWebUser } from "@calcom/lib/sync/Syn
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
import { prisma } from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import { IdentityProvider } from "@calcom/prisma/enums";
import { signupSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { joinAnyChildTeamOnOrgInvite } from "../utils/organization";
@ -147,32 +148,10 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
});
// Wrapping in a transaction as if one fails we want to rollback the whole thing to preventa any data inconsistencies
const membership = await prisma.$transaction(async (tx) => {
if (teamMetadata?.isOrganization) {
await tx.user.update({
where: {
id: user.id,
},
data: {
organizationId: team.id,
},
});
}
const membership = await tx.membership.upsert({
where: {
userId_teamId: { userId: user.id, teamId: team.id },
},
update: {
accepted: true,
},
create: {
userId: user.id,
teamId: team.id,
role: MembershipRole.MEMBER,
accepted: true,
},
});
return membership;
const { membership } = await createOrUpdateMemberships({
teamMetadata,
user,
team,
});
closeComUpsertTeamUser(team, user, membership.role);

View File

@ -3,13 +3,14 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { checkPremiumUsername } from "@calcom/ee/common/lib/checkPremiumUsername";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import slugify from "@calcom/lib/slugify";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername";
import prisma from "@calcom/prisma";
import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import { IdentityProvider } from "@calcom/prisma/enums";
import { signupSchema } from "@calcom/prisma/zod-utils";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@ -86,32 +87,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
const membership = await prisma.$transaction(async (tx) => {
if (teamMetadata?.isOrganization) {
await tx.user.update({
where: {
id: user.id,
},
data: {
organizationId: team.id,
},
});
}
const membership = await tx.membership.upsert({
where: {
userId_teamId: { userId: user.id, teamId: team.id },
},
update: {
accepted: true,
},
create: {
userId: user.id,
teamId: team.id,
role: MembershipRole.MEMBER,
accepted: true,
},
});
return membership;
const { membership } = await createOrUpdateMemberships({
teamMetadata,
user,
team,
});
closeComUpsertTeamUser(team, user, membership.role);

View File

@ -0,0 +1,61 @@
import type z from "zod";
import { prisma } from "@calcom/prisma";
import type { Team, User } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import type { teamMetadataSchema } from "@calcom/prisma/zod-utils";
export const createOrUpdateMemberships = async ({
teamMetadata,
user,
team,
}: {
user: Pick<User, "id">;
team: Pick<Team, "id" | "parentId">;
teamMetadata: z.infer<typeof teamMetadataSchema>;
}) => {
return await prisma.$transaction(async (tx) => {
if (teamMetadata?.isOrganization) {
await tx.user.update({
where: {
id: user.id,
},
data: {
organizationId: team.id,
},
});
}
const membership = await tx.membership.upsert({
where: {
userId_teamId: { userId: user.id, teamId: team.id },
},
update: {
accepted: true,
},
create: {
userId: user.id,
teamId: team.id,
role: MembershipRole.MEMBER,
accepted: true,
},
});
const orgMembership = null;
if (team.parentId) {
await tx.membership.upsert({
where: {
userId_teamId: { userId: user.id, teamId: team.parentId },
},
update: {
accepted: true,
},
create: {
userId: user.id,
teamId: team.parentId,
role: MembershipRole.MEMBER,
accepted: true,
},
});
}
return { membership, orgMembership };
});
};

View File

@ -146,11 +146,15 @@ export default function MemberListItem(props: Props) {
<div className="flex">
<UserAvatar size="sm" user={props.member} className="h-10 w-10 rounded-full" />
<div className="ms-3 inline-block">
<div className="mb-1 flex">
<span className="text-default mr-2 text-sm font-bold leading-4">{name}</span>
{!props.member.accepted && <TeamPill color="orange" text={t("pending")} />}
<div className="mb-1 flex" data-testid={`member-${props.member.username}`}>
<span data-testid="member-name" className="text-default mr-2 text-sm font-bold leading-4">
{name}
</span>
{!props.member.accepted && (
<TeamPill data-testid="member-pending" color="orange" text={t("pending")} />
)}
{isAdmin && props.member.accepted && appList}
{props.member.role && <TeamRole role={props.member.role} />}
{props.member.role && <TeamRole data-testid="member-role" role={props.member.role} />}
</div>
<div className="text-default flex items-center">
<span

View File

@ -5,31 +5,38 @@ import { MembershipRole } from "@calcom/prisma/enums";
type PillColor = "blue" | "green" | "red" | "orange";
interface Props {
interface Props extends React.HTMLAttributes<HTMLDivElement> {
text: string;
color?: PillColor;
}
export default function TeamPill(props: Props) {
const { color, text, ...rest } = props;
return (
<div
className={classNames("text-medium self-center rounded-md px-1 py-0.5 text-xs ltr:mr-1 rtl:ml-1", {
" bg-subtle text-emphasis": !props.color,
" bg-info text-info": props.color === "blue",
" bg-error text-error ": props.color === "red",
" bg-attention text-attention": props.color === "orange",
})}>
{props.text}
" bg-subtle text-emphasis": !color,
" bg-info text-info": color === "blue",
" bg-error text-error ": color === "red",
" bg-attention text-attention": color === "orange",
})}
{...rest}>
{text}
</div>
);
}
export function TeamRole(props: { role: MembershipRole }) {
interface TeamRoleProps extends Omit<React.ComponentProps<typeof TeamPill>, "text"> {
role: MembershipRole;
}
export function TeamRole(props: TeamRoleProps) {
const { t } = useLocale();
const { role, ...rest } = props;
const keys: Record<MembershipRole, PillColor | undefined> = {
[MembershipRole.OWNER]: "blue",
[MembershipRole.ADMIN]: "red",
[MembershipRole.MEMBER]: undefined,
};
return <TeamPill text={t(props.role.toLowerCase())} color={keys[props.role]} />;
return <TeamPill text={t(role.toLowerCase())} color={keys[role]} {...rest} />;
}

View File

@ -166,10 +166,16 @@ export function UserListTable() {
<div className="flex items-center gap-2">
<Avatar size="sm" alt={username || email} imageSrc={`${domain}/${username}/avatar.png`} />
<div className="">
<div className="text-emphasis text-sm font-medium leading-none">
<div
data-testid={`member-${username}-username`}
className="text-emphasis text-sm font-medium leading-none">
{username || "No username"}
</div>
<div className="text-subtle mt-1 text-sm leading-none">{email}</div>
<div
data-testid={`member-${username}-email`}
className="text-subtle mt-1 text-sm leading-none">
{email}
</div>
</div>
</div>
);
@ -185,9 +191,10 @@ export function UserListTable() {
accessorFn: (data) => data.role,
header: "Role",
cell: ({ row, table }) => {
const { role } = row.original;
const { role, username } = row.original;
return (
<Badge
data-testid={`member-${username}-role`}
variant={role === "MEMBER" ? "gray" : "blue"}
onClick={() => {
table.getColumn("role")?.setFilterValue([role]);
@ -204,12 +211,13 @@ export function UserListTable() {
id: "teams",
header: "Teams",
cell: ({ row }) => {
const { teams, accepted, email } = row.original;
const { teams, accepted, email, username } = row.original;
// TODO: Implement click to filter
return (
<div className="flex h-full flex-wrap items-center gap-2">
{accepted ? null : (
<Badge
data-testid2={`member-${username}-pending`}
variant="red"
className="text-xs"
data-testid={`email-${email.replace("@", "")}-pending`}>

View File

@ -95,13 +95,22 @@ export const validateAndGetCorrectedUsernameInTeam = async (
select: {
metadata: true,
parentId: true,
parent: {
select: {
metadata: true,
},
},
},
});
console.log("validateAndGetCorrectedUsernameInTeam", {
teamId,
team,
});
const teamData = { ...team, metadata: teamMetadataSchema.parse(team?.metadata) };
if (teamData.metadata?.isOrganization || teamData.parentId) {
const orgMetadata = teamData.metadata;
const organization = teamData.metadata?.isOrganization ? teamData : teamData.parent;
if (organization) {
const orgMetadata = teamMetadataSchema.parse(organization.metadata);
// Organization context -> org-context username check
const orgId = teamData.parentId || teamId;
return validateAndGetCorrectedUsernameAndEmail({

View File

@ -7,7 +7,6 @@ import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { getMembersHandler } from "../organizations/getMembers.handler";
import type { TCreateInviteInputSchema } from "./createInvite.schema";
type CreateInviteOptions = {
@ -23,11 +22,7 @@ export const createInviteHandler = async ({ ctx, input }: CreateInviteOptions) =
if (!membership || !membership?.team) throw new TRPCError({ code: "UNAUTHORIZED" });
const teamMetadata = teamMetadataSchema.parse(membership.team.metadata);
const isOrg = !!(membership.team?.parentId === null && teamMetadata?.isOrganization);
const orgMembers = await getMembersHandler({
ctx,
input: { teamIdToExclude: teamId, distinctUser: true },
});
const isOrganizationOrATeamInOrganization = !!(membership.team?.parentId || teamMetadata?.isOrganization);
if (input.token) {
const existingToken = await prisma.verificationToken.findFirst({
@ -36,7 +31,7 @@ export const createInviteHandler = async ({ ctx, input }: CreateInviteOptions) =
if (!existingToken) throw new TRPCError({ code: "NOT_FOUND" });
return {
token: existingToken.token,
inviteLink: await getInviteLink(existingToken.token, isOrg, orgMembers?.length),
inviteLink: await getInviteLink(existingToken.token, isOrganizationOrATeamInOrganization),
};
}
@ -50,13 +45,13 @@ export const createInviteHandler = async ({ ctx, input }: CreateInviteOptions) =
},
});
return { token, inviteLink: await getInviteLink(token, isOrg, orgMembers?.length) };
return { token, inviteLink: await getInviteLink(token, isOrganizationOrATeamInOrganization) };
};
async function getInviteLink(token = "", isOrg = false, orgMembers = 0) {
async function getInviteLink(token = "", isOrgContext = false) {
const teamInviteLink = `${WEBAPP_URL}/teams?token=${token}`;
const orgInviteLink = `${WEBAPP_URL}/signup?token=${token}&callbackUrl=/getting-started`;
if (isOrg || orgMembers > 0) return orgInviteLink;
if (isOrgContext) return orgInviteLink;
return teamInviteLink;
}