diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index 462d1938e6..cc80791ef7 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -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"]'); diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 35884d7a5d..1d45c989b0 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -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 }, diff --git a/apps/web/playwright/integrations-stripe.e2e.ts b/apps/web/playwright/integrations-stripe.e2e.ts index c9d86ccf0e..afa55a246c 100644 --- a/apps/web/playwright/integrations-stripe.e2e.ts +++ b/apps/web/playwright/integrations-stripe.e2e.ts @@ -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); diff --git a/apps/web/playwright/organization/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts index 5681e14e42..f51ce3470e 100644 --- a/apps/web/playwright/organization/organization-invitation.e2e.ts +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -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; +} diff --git a/apps/web/playwright/signup.e2e.ts b/apps/web/playwright/signup.e2e.ts index 884fcebcab..23ba84e14f 100644 --- a/apps/web/playwright/signup.e2e.ts +++ b/apps/web/playwright/signup.e2e.ts @@ -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"); diff --git a/apps/web/playwright/team/team-invitation.e2e.ts b/apps/web/playwright/team/team-invitation.e2e.ts index 44f085cad0..31dd240801 100644 --- a/apps/web/playwright/team/team-invitation.e2e.ts +++ b/apps/web/playwright/team/team-invitation.e2e.ts @@ -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"); diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts index a7723dc163..a7504c1433 100644 --- a/apps/web/playwright/teams.e2e.ts +++ b/apps/web/playwright/teams.e2e.ts @@ -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(); diff --git a/apps/web/playwright/unpublished.e2e.ts b/apps/web/playwright/unpublished.e2e.ts index 8ae45b8038..fb486c3859 100644 --- a/apps/web/playwright/unpublished.e2e.ts +++ b/apps/web/playwright/unpublished.e2e.ts @@ -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}`); diff --git a/packages/features/auth/signup/handlers/calcomHandler.ts b/packages/features/auth/signup/handlers/calcomHandler.ts index f09ab8a6b7..47e86e779a 100644 --- a/packages/features/auth/signup/handlers/calcomHandler.ts +++ b/packages/features/auth/signup/handlers/calcomHandler.ts @@ -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); diff --git a/packages/features/auth/signup/handlers/selfHostedHandler.ts b/packages/features/auth/signup/handlers/selfHostedHandler.ts index 174e7a1507..60e1111432 100644 --- a/packages/features/auth/signup/handlers/selfHostedHandler.ts +++ b/packages/features/auth/signup/handlers/selfHostedHandler.ts @@ -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); diff --git a/packages/features/auth/signup/utils/createOrUpdateMemberships.ts b/packages/features/auth/signup/utils/createOrUpdateMemberships.ts new file mode 100644 index 0000000000..593fb50ebd --- /dev/null +++ b/packages/features/auth/signup/utils/createOrUpdateMemberships.ts @@ -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; + team: Pick; + teamMetadata: z.infer; +}) => { + 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 }; + }); +}; diff --git a/packages/features/ee/teams/components/MemberListItem.tsx b/packages/features/ee/teams/components/MemberListItem.tsx index d0d4cb873f..632b4660d3 100644 --- a/packages/features/ee/teams/components/MemberListItem.tsx +++ b/packages/features/ee/teams/components/MemberListItem.tsx @@ -146,11 +146,15 @@ export default function MemberListItem(props: Props) {
-
- {name} - {!props.member.accepted && } +
+ + {name} + + {!props.member.accepted && ( + + )} {isAdmin && props.member.accepted && appList} - {props.member.role && } + {props.member.role && }
{ text: string; color?: PillColor; } export default function TeamPill(props: Props) { + const { color, text, ...rest } = props; return (
- {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}
); } -export function TeamRole(props: { role: MembershipRole }) { +interface TeamRoleProps extends Omit, "text"> { + role: MembershipRole; +} + +export function TeamRole(props: TeamRoleProps) { const { t } = useLocale(); + const { role, ...rest } = props; const keys: Record = { [MembershipRole.OWNER]: "blue", [MembershipRole.ADMIN]: "red", [MembershipRole.MEMBER]: undefined, }; - return ; + return ; } diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index f929cc4998..bd9f6dd411 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -166,10 +166,16 @@ export function UserListTable() {
-
+
{username || "No username"}
-
{email}
+
+ {email} +
); @@ -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 ( { 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 (
{accepted ? null : ( diff --git a/packages/lib/validateUsername.ts b/packages/lib/validateUsername.ts index 745eedad34..56d0a10de9 100644 --- a/packages/lib/validateUsername.ts +++ b/packages/lib/validateUsername.ts @@ -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({ diff --git a/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts b/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts index 1dc192b300..d1e6315fe2 100644 --- a/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/createInvite.handler.ts @@ -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; }