diff --git a/apps/web/lib/signup/getServerSideProps.tsx b/apps/web/lib/signup/getServerSideProps.tsx index 2596a24eb4..9d8c98604f 100644 --- a/apps/web/lib/signup/getServerSideProps.tsx +++ b/apps/web/lib/signup/getServerSideProps.tsx @@ -125,37 +125,6 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { } as const; } - const existingUser = await prisma.user.findFirst({ - where: { - AND: [ - { - email: verificationToken?.identifier, - }, - { - emailVerified: { - not: null, - }, - }, - ], - }, - }); - - if (existingUser) { - return { - redirect: { - permanent: false, - destination: `/auth/login?callbackUrl=${WEBAPP_URL}/${ctx.query.callbackUrl}`, - }, - }; - } - - const guessUsernameFromEmail = (email: string) => { - const [username] = email.split("@"); - return username; - }; - - let username = guessUsernameFromEmail(verificationToken.identifier); - if (!verificationToken?.team) { return { redirect: { @@ -165,6 +134,43 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { } as const; } + const isValidEmail = checkValidEmail(verificationToken.identifier); + if (isValidEmail) { + const existingUser = await prisma.user.findFirst({ + where: { + AND: [ + { + email: verificationToken.identifier, + }, + { + emailVerified: { + not: null, + }, + }, + ], + }, + select: { + id: true, + }, + }); + + if (existingUser) { + return { + redirect: { + permanent: false, + destination: `/auth/login?callbackUrl=${WEBAPP_URL}/${ctx.query.callbackUrl}`, + }, + }; + } + } + + const guessUsernameFromEmail = (email: string) => { + const [username] = email.split("@"); + return username; + }; + + let username = isValidEmail ? guessUsernameFromEmail(verificationToken.identifier) : ""; + const tokenTeam = { ...verificationToken.team, metadata: teamMetadataSchema.parse(verificationToken.team.metadata ?? null), @@ -180,14 +186,13 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { : null; // Org context shouldn't check if a username is premium - if (!IS_SELF_HOSTED && !isOrganizationOrATeamInOrganization) { + if (!IS_SELF_HOSTED && !isOrganizationOrATeamInOrganization && username) { // Im not sure we actually hit this because of next redirects signup to website repo - but just in case this is pretty cool :) const { available, suggestion } = await checkPremiumUsername(username); username = available ? username : suggestion || username; } - const isValidEmail = checkValidEmail(verificationToken.identifier); const isOrgInviteByLink = isOrganizationOrATeamInOrganization && !isValidEmail; const parentOrgSettings = tokenTeam?.parent?.organizationSettings ?? null; @@ -195,7 +200,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { props: { ...props, token, - prepopulateFormValues: !isOrgInviteByLink + prepopulateFormValues: !isOrgInviteByLink && isValidEmail ? { email: verificationToken.identifier, username: isOrganizationOrATeamInOrganization diff --git a/apps/web/modules/signup-view.tsx b/apps/web/modules/signup-view.tsx index 64cfd9eacf..346756727a 100644 --- a/apps/web/modules/signup-view.tsx +++ b/apps/web/modules/signup-view.tsx @@ -459,7 +459,7 @@ export default function Signup({ form={formMethods} handleSubmit={async (values) => { let updatedValues = values; - if (!formMethods.getValues().username && isOrgInviteByLink && orgAutoAcceptEmail) { + if (!formMethods.getValues().username && isOrgInviteByLink) { updatedValues = { ...values, username: getOrgUsernameFromEmail(values.email, orgAutoAcceptEmail), diff --git a/apps/web/playwright/signup.e2e.ts b/apps/web/playwright/signup.e2e.ts index 6634906675..e91d3fc58b 100644 --- a/apps/web/playwright/signup.e2e.ts +++ b/apps/web/playwright/signup.e2e.ts @@ -1,4 +1,4 @@ -import type { Page } from "@playwright/test"; +import type { Browser, Page } from "@playwright/test"; import { expect } from "@playwright/test"; import { hashSync } from "bcryptjs"; import { randomBytes } from "node:crypto"; @@ -8,7 +8,7 @@ import prisma from "@calcom/prisma"; import { test } from "./lib/fixtures"; import { localize } from "./lib/localize"; -import { getEmailsReceivedByUser } from "./lib/testUtils"; +import { getEmailsReceivedByUser, getInviteLink } from "./lib/testUtils"; import { expectInvitationEmailToBeReceived } from "./team/expects"; test.describe.configure({ mode: "parallel" }); @@ -421,4 +421,220 @@ test.describe("Email Signup Flow Test", async () => { await checkbox.uncheck(); await expect(submitButton).toBeEnabled(); }); + + test("Signup with org invite link creates user and joins organization", async ({ page, users, browser }) => { + const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true }); + const { team: org } = await orgOwner.getOrgMembership(); + await orgOwner.apiLogin(); + await page.goto(`/settings/organizations/${org.slug}/members`); + + await page.getByTestId("new-organization-member-button").click(); + const inviteLink = await getInviteLink(page); + + const email = users.trackEmail({ username: "rick", domain: "domain.com" }); + const usernameDerivedFromEmail = `${email.split("@")[0]}-domain`; + + await signupFromInviteLink({ browser, inviteLink, email }); + + await expectUserToBeAMemberOfOrganization({ + page, + orgSlug: org.slug, + username: usernameDerivedFromEmail, + role: "member", + isMemberShipAccepted: true, + email, + }); + }); + + test("Signup with sub-team invite link creates user and joins both team and org", async ({ + page, + users, + browser, + }) => { + const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, hasSubteam: true }); + await orgOwner.apiLogin(); + const { team: subTeam } = await orgOwner.getFirstTeamMembership(); + const { team: org } = await orgOwner.getOrgMembership(); + + await page.goto(`/settings/teams/${subTeam.id}/members`); + await page.waitForLoadState("domcontentloaded"); + await page.waitForTimeout(500); + await page.getByTestId("new-member-button").click(); + const inviteLink = await getInviteLink(page); + + const email = users.trackEmail({ username: "rick", domain: "domain.com" }); + const usernameDerivedFromEmail = `${email.split("@")[0]}-domain`; + + await signupFromInviteLink({ browser, inviteLink, email }); + + await expectUserToBeAMemberOfTeam({ + page, + teamId: subTeam.id, + username: usernameDerivedFromEmail, + email, + role: "member", + isMemberShipAccepted: true, + }); + + await expectUserToBeAMemberOfOrganization({ + page, + orgSlug: org.slug, + username: usernameDerivedFromEmail, + role: "member", + isMemberShipAccepted: true, + email, + }); + }); + + test("Signup with email-based token still works (regression test)", async ({ + page, + prisma, + users, + }) => { + const token = randomBytes(32).toString("hex"); + const userToCreate = users.buildForSignup({ + username: "email-token-user", + }); + + const emailToken = await prisma.verificationToken.create({ + data: { + identifier: userToCreate.email, + token, + expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + team: { + create: { + name: "Email Token Team", + slug: `email-token-team-${Date.now()}`, + }, + }, + }, + }); + + await page.goto(`/signup?token=${token}`); + await preventFlakyTest(page); + await expect(page.getByTestId("signup-submit-button")).toBeVisible(); + + const emailField = page.locator('input[name="email"]'); + const usernameField = page.locator('input[name="username"]'); + + expect(await emailField.inputValue()).toBe(userToCreate.email); + const expectedUsername = userToCreate.email.split("@")[0]; + expect(await usernameField.inputValue()).toBe(expectedUsername); + + await page.locator('input[name="password"]').fill("Password99!"); + await page.getByTestId("signup-submit-button").click(); + + await expect(page).toHaveURL(/\/getting-started|\/auth\/verify-email/); + + const createdUser = await prisma.user.findUnique({ + where: { email: userToCreate.email }, + select: { + id: true, + teams: { + select: { + teamId: true, + accepted: true, + }, + }, + }, + }); + + expect(createdUser).toBeTruthy(); + const membership = createdUser?.teams.find( + (m) => m.teamId === emailToken.teamId + ); + expect(membership).toBeTruthy(); + expect(membership?.accepted).toBe(true); + + await prisma.user.delete({ where: { id: createdUser!.id } }); + await prisma.team.delete({ where: { id: emailToken.teamId! } }); + }); }); + +async function expectUserToBeAMemberOfOrganization({ + page, + orgSlug, + username, + email, + role, + isMemberShipAccepted, +}: { + page: Page; + orgSlug: string | null; + username: string; + role: string; + isMemberShipAccepted: boolean; + email: string; +}) { + await page.goto(`/settings/organizations/${orgSlug}/members`); + await expect(page.locator(`[data-testid="member-${username}-username"]`)).toHaveText(username); + await expect(page.locator(`[data-testid="member-${username}-email"]`)).toHaveText(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: _role, + username, + isMemberShipAccepted, +}: { + page: Page; + username: string; + role: string; + teamId: number; + isMemberShipAccepted: boolean; + email: string; +}) { + await page.goto(`/settings/teams/${teamId}/members`); + await page.waitForLoadState("domcontentloaded"); + await page.waitForTimeout(1000); + expect( + ( + await page + .locator( + `[data-testid="member-${username}"] [data-testid="${ + isMemberShipAccepted ? "member-email" : `email-${email.replace("@", "")}-pending` + }"]` + ) + .textContent() + )?.toLowerCase() + ).toBe(email.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(); + } +} + +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 expect(inviteLinkPage.locator("text=Create your account")).toBeVisible(); + + const button = inviteLinkPage.locator("button[type=submit][disabled]"); + await expect(button).toBeVisible(); + + 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"); + await context.close(); +}