fix: Disallow changing username and email in case of Organization email invite (#12735)
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
parent
943c7a4c6c
commit
99e71365ab
|
@ -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 */}
|
||||
<UsernameField
|
||||
label={t("username")}
|
||||
username={watch("username")}
|
||||
premium={premiumUsername}
|
||||
usernameTaken={usernameTaken}
|
||||
setUsernameTaken={(value) => setUsernameTaken(value)}
|
||||
data-testid="signup-usernamefield"
|
||||
setPremium={(value) => setPremiumUsername(value)}
|
||||
addOnLeading={
|
||||
orgSlug
|
||||
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
|
||||
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
|
||||
}
|
||||
/>
|
||||
{!isOrgInviteByLink ? (
|
||||
<UsernameField
|
||||
label={t("username")}
|
||||
username={watch("username") || ""}
|
||||
premium={premiumUsername}
|
||||
usernameTaken={usernameTaken}
|
||||
disabled={!!orgSlug}
|
||||
setUsernameTaken={(value) => setUsernameTaken(value)}
|
||||
data-testid="signup-usernamefield"
|
||||
setPremium={(value) => setPremiumUsername(value)}
|
||||
addOnLeading={
|
||||
orgSlug
|
||||
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
|
||||
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{/* Email */}
|
||||
<TextField
|
||||
{...register("email")}
|
||||
label={t("email")}
|
||||
type="email"
|
||||
disabled={prepopulateFormValues?.email}
|
||||
data-testid="signup-emailfield"
|
||||
/>
|
||||
|
||||
|
@ -322,7 +318,7 @@ export default function Signup({
|
|||
: t("create_account")}
|
||||
</Button>
|
||||
</Form>
|
||||
{/* Continue with Social Logins */}
|
||||
{/* Continue with Social Logins - Only for non-invite links */}
|
||||
{token || (!isGoogleLoginEnabled && !isSAMLLoginEnabled) ? null : (
|
||||
<div className="mt-6">
|
||||
<div className="relative flex items-center">
|
||||
|
@ -334,7 +330,7 @@ export default function Signup({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Social Logins */}
|
||||
{/* Social Logins - Only for non-invite links*/}
|
||||
{!token && (
|
||||
<div className="mt-6 flex flex-col gap-2 md:flex-row">
|
||||
{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({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue
Block a user