diff --git a/apps/web/pages/signup.tsx b/apps/web/pages/signup.tsx index f00c4b8a63..47c5422f2a 100644 --- a/apps/web/pages/signup.tsx +++ b/apps/web/pages/signup.tsx @@ -7,10 +7,12 @@ import { useRouter } from "next/navigation"; import { useState, useEffect } from "react"; import type { SubmitHandler } from "react-hook-form"; import { useForm, useFormContext } from "react-hook-form"; +import { Toaster } from "react-hot-toast"; import { z } from "zod"; import getStripe from "@calcom/app-store/stripepayment/lib/client"; import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils"; +import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail"; import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername"; import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml"; @@ -27,7 +29,7 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calco import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { signupSchema as apiSignupSchema } from "@calcom/prisma/zod-utils"; import type { inferSSRProps } from "@calcom/types/inferSSRProps"; -import { Button, HeadSeo, PasswordField, TextField, Form, Alert } from "@calcom/ui"; +import { Button, HeadSeo, PasswordField, TextField, Form, Alert, showToast } from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; @@ -66,16 +68,6 @@ const FEATURES = [ }, ]; -const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => { - const [emailUser, emailDomain = ""] = email.split("@"); - const username = - emailDomain === autoAcceptEmailDomain - ? slugify(emailUser) - : slugify(`${emailUser}-${emailDomain.split(".")[0]}`); - - return username; -}; - function UsernameField({ username, setPremium, @@ -276,25 +268,29 @@ export default function Signup({ await signUp(updatedValues); }}> {/* Username */} - setUsernameTaken(value)} - data-testid="signup-usernamefield" - setPremium={(value) => setPremiumUsername(value)} - addOnLeading={ - orgSlug - ? `${getOrgFullOrigin(orgSlug, { protocol: true })}/` - : `${process.env.NEXT_PUBLIC_WEBSITE_URL}/` - } - /> + {!isOrgInviteByLink ? ( + setUsernameTaken(value)} + data-testid="signup-usernamefield" + setPremium={(value) => setPremiumUsername(value)} + addOnLeading={ + orgSlug + ? `${getOrgFullOrigin(orgSlug, { protocol: true })}/` + : `${process.env.NEXT_PUBLIC_WEBSITE_URL}/` + } + /> + ) : null} {/* Email */} @@ -322,7 +318,7 @@ export default function Signup({ : t("create_account")} - {/* Continue with Social Logins */} + {/* Continue with Social Logins - Only for non-invite links */} {token || (!isGoogleLoginEnabled && !isSAMLLoginEnabled) ? null : (
@@ -334,7 +330,7 @@ export default function Signup({
)} - {/* Social Logins */} + {/* Social Logins - Only for non-invite links*/} {!token && (
{isGoogleLoginEnabled ? ( @@ -366,7 +362,7 @@ export default function Signup({ if (username) { // If username is present we save it in query params to check for premium const searchQueryParams = new URLSearchParams(); - searchQueryParams.set("username", formMethods.getValues("username")); + searchQueryParams.set("username", username); localStorage.setItem("username", username); router.push(`${GOOGLE_AUTH_URL}?${searchQueryParams.toString()}`); return; @@ -402,10 +398,14 @@ export default function Signup({ return; } const username = formMethods.getValues("username"); + if (!username) { + showToast("error", t("username_required")); + return; + } localStorage.setItem("username", username); const sp = new URLSearchParams(); // @NOTE: don't remove username query param as it's required right now for stripe payment page - sp.set("username", formMethods.getValues("username")); + sp.set("username", username); sp.set("email", formMethods.getValues("email")); router.push( `${process.env.NEXT_PUBLIC_WEBAPP_URL}/auth/sso/saml` + `?${sp.toString()}` @@ -491,6 +491,7 @@ export default function Signup({
+ ); } diff --git a/apps/web/playwright/organization/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts index 2166b9dcad..5681e14e42 100644 --- a/apps/web/playwright/organization/organization-invitation.e2e.ts +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -1,5 +1,7 @@ import { expect } from "@playwright/test"; +import prisma from "@calcom/prisma"; + import { test } from "../lib/fixtures"; import { getInviteLink } from "../lib/testUtils"; import { expectInvitationEmailToBeReceived } from "./expects"; @@ -20,7 +22,9 @@ test.describe("Organization", () => { await page.waitForLoadState("networkidle"); await test.step("To the organization by email (external user)", async () => { - const invitedUserEmail = `rick@domain-${Date.now()}.com`; + 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(); @@ -38,21 +42,24 @@ test.describe("Organization", () => { page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) ).toHaveCount(1); - // eslint-disable-next-line playwright/no-conditional-in-test - if (!inviteLink) return null; + assertInviteLink(inviteLink); // Follow invite link in new window const context = await browser.newContext(); - const newPage = await context.newPage(); - newPage.goto(inviteLink); - await newPage.waitForLoadState("networkidle"); + 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 newPage.locator("input[name=password]").fill(`P4ssw0rd!`); - await newPage.locator("button[type=submit]").click(); - await newPage.waitForURL("/getting-started?from=signup"); + 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 newPage.close(); + await signupPage.close(); // Check newly invited member is not pending anymore await page.bringToFront(); @@ -80,10 +87,15 @@ test.describe("Organization", () => { await expect(button).toBeVisible(); // email + 3 password hints // Happy path - await inviteLinkPage.locator("input[name=email]").fill(`rick@domain-${Date.now()}.com`); + 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); }); }); @@ -96,21 +108,74 @@ test.describe("Organization", () => { 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(); await page.waitForLoadState("networkidle"); - await expectInvitationEmailToBeReceived( + const inviteLink = await expectInvitationEmailToBeReceived( page, emails, invitedUserEmail, - `${org.name}'s admin invited you to join the organization ${org.name} on Cal.com` + `${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`, + "signup?token" ); + assertInviteLink(inviteLink); + // Check newly invited member exists and is not pending await expect( page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`) ).toHaveCount(0); + + // 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(); + }); + + 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(); + + 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"); + + // Check required fields + const button = inviteLinkPage.locator("button[type=submit][disabled]"); + await expect(button).toBeVisible(); // email + 3 password hints + + // 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); }); }); }); + +function assertInviteLink(inviteLink: string | null | undefined): asserts inviteLink is string { + if (!inviteLink) throw new Error("Invite link not found"); +} diff --git a/apps/web/playwright/signup.e2e.ts b/apps/web/playwright/signup.e2e.ts index 65da61ebe2..884fcebcab 100644 --- a/apps/web/playwright/signup.e2e.ts +++ b/apps/web/playwright/signup.e2e.ts @@ -2,6 +2,7 @@ import { expect } from "@playwright/test"; import { randomBytes } from "crypto"; import { APP_NAME, IS_PREMIUM_USERNAME_ENABLED, IS_MAILHOG_ENABLED } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; import { test } from "./lib/fixtures"; import { getEmailsReceivedByUser, localize } from "./lib/testUtils"; @@ -103,6 +104,8 @@ test.describe("Signup Flow Test", async () => { const userToCreate = users.buildForSignup({ username: "rick-jones", password: "Password99!", + // Email intentonally kept as different from username + email: `rickjones${Math.random()}-${Date.now()}@example.com`, }); await page.goto("/signup"); @@ -120,6 +123,9 @@ test.describe("Signup Flow Test", async () => { // Check that the URL matches the expected URL expect(page.url()).toContain("/auth/verify-email"); + const dbUser = await prisma.user.findUnique({ where: { email: userToCreate.email } }); + // Verify that the username is the same as the one provided and isn't accidentally changed to email derived username - That happens only for organization member signup + expect(dbUser?.username).toBe(userToCreate.username); }); test("Signup fields prefilled with query params", async ({ page, users }) => { const signupUrlWithParams = "/signup?username=rick-jones&email=rick-jones%40example.com"; diff --git a/packages/features/auth/signup/handlers/calcomHandler.ts b/packages/features/auth/signup/handlers/calcomHandler.ts index ffa7baee73..f09ab8a6b7 100644 --- a/packages/features/auth/signup/handlers/calcomHandler.ts +++ b/packages/features/auth/signup/handlers/calcomHandler.ts @@ -10,13 +10,17 @@ import { HttpError } from "@calcom/lib/http-error"; import { usernameHandler, type RequestWithUsernameStatus } from "@calcom/lib/server/username"; import { createWebUser as syncServicesCreateWebUser } from "@calcom/lib/sync/SyncServiceManager"; import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; -import { validateUsername } from "@calcom/lib/validateUsername"; +import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername"; import { prisma } from "@calcom/prisma"; import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums"; import { signupSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { joinAnyChildTeamOnOrgInvite } from "../utils/organization"; -import { findTokenByToken, throwIfTokenExpired, validateUsernameForTeam } from "../utils/token"; +import { + findTokenByToken, + throwIfTokenExpired, + validateAndGetCorrectedUsernameForTeam, +} from "../utils/token"; async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { const { @@ -52,15 +56,33 @@ async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) { if (token) { foundToken = await findTokenByToken({ token }); throwIfTokenExpired(foundToken?.expires); - await validateUsernameForTeam({ username, email, teamId: foundToken?.teamId ?? null }); + username = await validateAndGetCorrectedUsernameForTeam({ + username, + email, + teamId: foundToken?.teamId ?? null, + isSignup: true, + }); } else { - const usernameAndEmailValidation = await validateUsername(username, email); + const usernameAndEmailValidation = await validateAndGetCorrectedUsernameAndEmail({ + username, + email, + isSignup: true, + }); if (!usernameAndEmailValidation.isValid) { throw new HttpError({ statusCode: 409, message: "Username or email is already taken", }); } + + if (!usernameAndEmailValidation.username) { + throw new HttpError({ + statusCode: 422, + message: "Invalid username", + }); + } + + username = usernameAndEmailValidation.username; } // Create the customer in Stripe diff --git a/packages/features/auth/signup/handlers/selfHostedHandler.ts b/packages/features/auth/signup/handlers/selfHostedHandler.ts index 4b54669385..174e7a1507 100644 --- a/packages/features/auth/signup/handlers/selfHostedHandler.ts +++ b/packages/features/auth/signup/handlers/selfHostedHandler.ts @@ -4,16 +4,21 @@ 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 { 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 { validateUsername } from "@calcom/lib/validateUsername"; +import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/lib/validateUsername"; import prisma from "@calcom/prisma"; import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums"; import { signupSchema } from "@calcom/prisma/zod-utils"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { joinAnyChildTeamOnOrgInvite } from "../utils/organization"; -import { findTokenByToken, throwIfTokenExpired, validateUsernameForTeam } from "../utils/token"; +import { + findTokenByToken, + throwIfTokenExpired, + validateAndGetCorrectedUsernameForTeam, +} from "../utils/token"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const data = req.body; @@ -28,15 +33,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } let foundToken: { id: number; teamId: number | null; expires: Date } | null = null; + let correctedUsername = username; if (token) { foundToken = await findTokenByToken({ token }); throwIfTokenExpired(foundToken?.expires); - await validateUsernameForTeam({ username, email: userEmail, teamId: foundToken?.teamId }); + correctedUsername = await validateAndGetCorrectedUsernameForTeam({ + username, + email: userEmail, + teamId: foundToken?.teamId, + isSignup: true, + }); } else { - const userValidation = await validateUsername(username, userEmail); + const userValidation = await validateAndGetCorrectedUsernameAndEmail({ + username, + email: userEmail, + isSignup: true, + }); if (!userValidation.isValid) { + logger.error("User validation failed", { userValidation }); return res.status(409).json({ message: "Username or email is already taken" }); } + if (!userValidation.username) { + return res.status(422).json({ message: "Invalid username" }); + } + correctedUsername = userValidation.username; } const hashedPassword = await hashPassword(password); @@ -53,13 +73,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const user = await prisma.user.upsert({ where: { email: userEmail }, update: { - username, + username: correctedUsername, password: hashedPassword, emailVerified: new Date(Date.now()), identityProvider: IdentityProvider.CAL, }, create: { - username, + username: correctedUsername, email: userEmail, password: hashedPassword, identityProvider: IdentityProvider.CAL, @@ -113,7 +133,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); } else { if (IS_PREMIUM_USERNAME_ENABLED) { - const checkUsername = await checkPremiumUsername(username); + const checkUsername = await checkPremiumUsername(correctedUsername); if (checkUsername.premium) { res.status(422).json({ message: "Sign up from https://cal.com/signup to claim your premium username", @@ -124,13 +144,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) await prisma.user.upsert({ where: { email: userEmail }, update: { - username, + username: correctedUsername, password: hashedPassword, emailVerified: new Date(Date.now()), identityProvider: IdentityProvider.CAL, }, create: { - username, + username: correctedUsername, email: userEmail, password: hashedPassword, identityProvider: IdentityProvider.CAL, @@ -138,7 +158,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); await sendEmailVerification({ email: userEmail, - username, + username: correctedUsername, language, }); } diff --git a/packages/features/auth/signup/utils/getOrgUsernameFromEmail.ts b/packages/features/auth/signup/utils/getOrgUsernameFromEmail.ts new file mode 100644 index 0000000000..ee48f78747 --- /dev/null +++ b/packages/features/auth/signup/utils/getOrgUsernameFromEmail.ts @@ -0,0 +1,11 @@ +import slugify from "@calcom/lib/slugify"; + +export const getOrgUsernameFromEmail = (email: string, autoAcceptEmailDomain: string) => { + const [emailUser, emailDomain = ""] = email.split("@"); + const username = + emailDomain === autoAcceptEmailDomain + ? slugify(emailUser) + : slugify(`${emailUser}-${emailDomain.split(".")[0]}`); + + return username; +}; diff --git a/packages/features/auth/signup/utils/token.ts b/packages/features/auth/signup/utils/token.ts index da1b08967d..41e9e26128 100644 --- a/packages/features/auth/signup/utils/token.ts +++ b/packages/features/auth/signup/utils/token.ts @@ -1,6 +1,6 @@ import dayjs from "@calcom/dayjs"; import { HttpError } from "@calcom/lib/http-error"; -import { validateUsernameInTeam } from "@calcom/lib/validateUsername"; +import { validateAndGetCorrectedUsernameInTeam } from "@calcom/lib/validateUsername"; import { prisma } from "@calcom/prisma"; export async function findTokenByToken({ token }: { token: string }) { @@ -35,21 +35,31 @@ export function throwIfTokenExpired(expires?: Date) { } } -export async function validateUsernameForTeam({ +export async function validateAndGetCorrectedUsernameForTeam({ username, email, teamId, + isSignup, }: { username: string; email: string; teamId: number | null; + isSignup: boolean; }) { - if (!teamId) return; - const teamUserValidation = await validateUsernameInTeam(username, email, teamId); + if (!teamId) return username; + + const teamUserValidation = await validateAndGetCorrectedUsernameInTeam(username, email, teamId, isSignup); if (!teamUserValidation.isValid) { throw new HttpError({ statusCode: 409, message: "Username or email is already taken", }); } + if (!teamUserValidation.username) { + throw new HttpError({ + statusCode: 422, + message: "Invalid username", + }); + } + return teamUserValidation.username; } diff --git a/packages/lib/validateUsername.ts b/packages/lib/validateUsername.ts index 74687725d6..745eedad34 100644 --- a/packages/lib/validateUsername.ts +++ b/packages/lib/validateUsername.ts @@ -1,7 +1,45 @@ +import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail"; import prisma from "@calcom/prisma"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; -export const validateUsername = async (username: string, email: string, organizationId?: number) => { +export const getUsernameForOrgMember = async ({ + email, + orgAutoAcceptEmail, + isSignup, + username, +}: { + username?: string; + email: string; + orgAutoAcceptEmail?: string; + isSignup: boolean; +}) => { + if (isSignup) { + // We ensure that the username is always derived from the email during signup. + return getOrgUsernameFromEmail(email, orgAutoAcceptEmail || ""); + } + if (!username) { + throw new Error("Username is required"); + } + // Right now it's not possible to change username in an org by the user but when that's allowed we would simply accept the provided username + return username; +}; + +export const validateAndGetCorrectedUsernameAndEmail = async ({ + username, + email, + organizationId, + orgAutoAcceptEmail, + isSignup, +}: { + username: string; + email: string; + organizationId?: number; + orgAutoAcceptEmail?: string; + isSignup: boolean; +}) => { + if (username.includes("+")) { + return { isValid: false, username: undefined, email }; + } // There is an existingUser if, within an org context or not, the username matches // OR if the email matches AND either the email is verified // or both username and password are set @@ -31,10 +69,24 @@ export const validateUsername = async (username: string, email: string, organiza email: true, }, }); - return { isValid: !existingUser, email: existingUser?.email }; + let validatedUsername = username; + if (organizationId) { + validatedUsername = await getUsernameForOrgMember({ + email, + orgAutoAcceptEmail, + isSignup, + }); + } + + return { isValid: !existingUser, username: validatedUsername, email: existingUser?.email }; }; -export const validateUsernameInTeam = async (username: string, email: string, teamId: number) => { +export const validateAndGetCorrectedUsernameInTeam = async ( + username: string, + email: string, + teamId: number, + isSignup: boolean +) => { try { const team = await prisma.team.findFirst({ where: { @@ -49,15 +101,22 @@ export const validateUsernameInTeam = async (username: string, email: string, te const teamData = { ...team, metadata: teamMetadataSchema.parse(team?.metadata) }; if (teamData.metadata?.isOrganization || teamData.parentId) { + const orgMetadata = teamData.metadata; // Organization context -> org-context username check const orgId = teamData.parentId || teamId; - return validateUsername(username, email, orgId); + return validateAndGetCorrectedUsernameAndEmail({ + username, + email, + organizationId: orgId, + orgAutoAcceptEmail: orgMetadata?.orgAutoAcceptEmail || "", + isSignup, + }); } else { // Regular team context -> regular username check - return validateUsername(username, email); + return validateAndGetCorrectedUsernameAndEmail({ username, email, isSignup }); } } catch (error) { console.error(error); - return { isValid: false, email: undefined }; + return { isValid: false, username: undefined, email: undefined }; } }; diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index b5a2296a6f..7741375f0f 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -610,9 +610,9 @@ export const emailSchemaRefinement = (value: string) => { }; export const signupSchema = z.object({ - username: z.string().refine((value) => !value.includes("+"), { - message: "String should not contain a plus symbol (+).", - }), + // Username is marked optional here because it's requirement depends on if it's the Organization invite or a team invite which isn't easily done in zod + // It's better handled beyond zod in `validateAndGetCorrectedUsernameAndEmail` + username: z.string().optional(), email: z.string().email(), password: z.string().superRefine((data, ctx) => { const isStrict = false;