Skip to content

Commit 1702030

Browse files
authored
fix: handle existing users on invite token flow (#26217)
* fix(auth): validate user before signup with invite token Validate if user already exists before creating account when signing up with team or organization invite tokens. Existing users are redirected to login to accept the invitation. - Add user existence check in signup handlers - Return 409 for existing users with redirect to login - Extract signup fetch logic to dedicated module - Add e2e test coverage * fix(auth): address code review feedback - Fix fetchSignup tests to use vi.spyOn for proper mock restoration - Add content-type validation before parsing JSON response - Guard against undefined error in Stripe callback - Use t() for localized error message - Fix race condition in handlers by catching P2002 on create * fix(auth): address additional code review feedback - Add INVALID_SERVER_RESPONSE constant to follow established pattern - Check error.meta.target includes email before returning USER_ALREADY_EXISTS to avoid false positives from other unique constraint violations - Add select: { id: true } to user.create calls since downstream functions only need the user id * test: add unit tests for P2002 handling in signup handlers - Add shared test suite covering all P2002 edge cases - Ensure 409 only for email constraint violations - Fix non-token paths to use atomic create + catch pattern * fix: update error message copy per review feedback * fix(auth): address code review feedback and prevent orphan Stripe customers - Add user existence check before Stripe customer creation (token flow) - Add select clause to user.create for consistency - Fix showToast argument order (pre-existing bug) - Use toHaveURL instead of waitForURL in E2E tests * fix(auth): resolve 500 errors by fixing Prisma error detection across module boundaries The instanceof check for PrismaClientKnownRequestError fails when different Prisma client instances are loaded. Added fallback check by constructor name * fix(auth): validate invitedTo before upsert on team invite signup * test(auth): update P2002 tests for new invite flow P2002 tests now use non-token flow since token flow uses upsert Added tests for invitedTo validation on invite signup * fix(auth): add guards and P2002 handling per review feedback - Guard existingUser check with if (foundToken?.teamId) - Guard username check with if (username) for premium flow - Add `select` clause to findFirst/findUnique queries - Add try-catch on upsert for race condition P2002 errors * fix(auth): narrow P2002 handling to email/username targets
1 parent 8b5d920 commit 1702030

15 files changed

Lines changed: 976 additions & 154 deletions

File tree

apps/web/modules/signup-view.tsx

Lines changed: 76 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { z } from "zod";
1616

1717
import getStripe from "@calcom/app-store/stripepayment/lib/client";
1818
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
19+
import { fetchSignup, isUserAlreadyExistsError, hasCheckoutSession } from "@calcom/features/auth/signup/lib/fetchSignup";
1920
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
2021
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
2122
import ServerTrans from "@calcom/lib/components/ServerTrans";
@@ -222,23 +223,6 @@ export default function Signup({
222223
const loadingSubmitState = isSubmitSuccessful || isSubmitting;
223224
const displayBackButton = token ? false : displayEmailForm;
224225

225-
const handleErrorsAndStripe = async (resp: Response) => {
226-
if (!resp.ok) {
227-
const err = await resp.json();
228-
if (err.checkoutSessionId) {
229-
const stripe = await getStripe();
230-
if (stripe) {
231-
const { error } = await stripe.redirectToCheckout({
232-
sessionId: err.checkoutSessionId,
233-
});
234-
console.warn(error.message);
235-
}
236-
} else {
237-
throw new Error(err.message);
238-
}
239-
}
240-
};
241-
242226
const isPlatformUser = redirectUrl?.includes("platform") && redirectUrl?.includes("new");
243227

244228
const signUp: SubmitHandler<FormValues> = async (_data) => {
@@ -252,77 +236,90 @@ export default function Signup({
252236
username_taken: usernameTaken,
253237
});
254238

255-
await fetch("/api/auth/signup", {
256-
body: JSON.stringify({
257-
...data,
258-
language: i18n.language,
259-
token,
260-
}),
261-
headers: {
262-
"Content-Type": "application/json",
263-
"cf-access-token": cfToken ?? "invalid-token",
264-
},
265-
method: "POST",
266-
})
267-
.then(handleErrorsAndStripe)
268-
.then(async () => {
269-
if (process.env.NEXT_PUBLIC_GTM_ID)
270-
pushGTMEvent("create_account", { email: data.email, user: data.username, lang: data.language });
271-
272-
// telemetry.event(telemetryEventTypes.signup, collectPageParameters());
273-
274-
const gettingStartedPath = onboardingV3Enabled ? "onboarding/getting-started" : "getting-started";
275-
const verifyOrGettingStarted = emailVerificationEnabled ? "auth/verify-email" : gettingStartedPath;
276-
const gettingStartedWithPlatform = "settings/platform/new";
277-
278-
const constructCallBackIfUrlPresent = () => {
279-
if (isOrgInviteByLink) {
280-
return `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`;
281-
}
282-
283-
return addOrUpdateQueryParam(`${WEBAPP_URL}/${searchParams.get("callbackUrl")}`, "from", "signup");
284-
};
239+
try {
240+
const result = await fetchSignup(
241+
{
242+
...data,
243+
language: i18n.language,
244+
token,
245+
},
246+
cfToken
247+
);
248+
249+
if (!result.ok) {
250+
if (isUserAlreadyExistsError(result)) {
251+
showToast(t("account_already_exists_please_login"), "warning");
252+
const callbackUrl = token ? `/teams?token=${token}` : "/event-types";
253+
setTimeout(() => {
254+
router.push(`/auth/login?callbackUrl=${encodeURIComponent(callbackUrl)}`);
255+
}, 3000);
256+
return;
257+
}
285258

286-
const constructCallBackIfUrlNotPresent = () => {
287-
if (isPlatformUser) {
288-
return `${WEBAPP_URL}/${gettingStartedWithPlatform}?from=signup`;
259+
if (hasCheckoutSession(result)) {
260+
const stripe = await getStripe();
261+
if (stripe) {
262+
const { error } = await stripe.redirectToCheckout({
263+
sessionId: result.error.checkoutSessionId,
264+
});
265+
if (error) console.warn(error.message);
289266
}
267+
return;
268+
}
290269

291-
return `${WEBAPP_URL}/${verifyOrGettingStarted}?from=signup`;
292-
};
270+
throw new Error(result.error.message);
271+
}
293272

294-
const constructCallBackUrl = () => {
295-
const callbackUrlSearchParams = searchParams?.get("callbackUrl");
273+
if (process.env.NEXT_PUBLIC_GTM_ID) {
274+
pushGTMEvent("create_account", { email: data.email, user: data.username, lang: data.language });
275+
}
296276

297-
return callbackUrlSearchParams
298-
? constructCallBackIfUrlPresent()
299-
: constructCallBackIfUrlNotPresent();
300-
};
277+
const gettingStartedPath = onboardingV3Enabled ? "onboarding/getting-started" : "getting-started";
278+
const verifyOrGettingStarted = emailVerificationEnabled ? "auth/verify-email" : gettingStartedPath;
279+
const gettingStartedWithPlatform = "settings/platform/new";
301280

302-
const callBackUrl = constructCallBackUrl();
281+
const constructCallBackIfUrlPresent = () => {
282+
if (isOrgInviteByLink) {
283+
return `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`;
284+
}
285+
return addOrUpdateQueryParam(`${WEBAPP_URL}/${searchParams.get("callbackUrl")}`, "from", "signup");
286+
};
303287

304-
await signIn<"credentials">("credentials", {
305-
...data,
306-
callbackUrl: callBackUrl,
307-
});
308-
})
309-
.catch((err) => {
310-
setTurnstileKey((k) => k + 1);
311-
formMethods.setValue("cfToken", undefined);
312-
313-
if (err.message === INVALID_CLOUDFLARE_TOKEN_ERROR) {
314-
return;
288+
const constructCallBackIfUrlNotPresent = () => {
289+
if (isPlatformUser) {
290+
return `${WEBAPP_URL}/${gettingStartedWithPlatform}?from=signup`;
315291
}
292+
return `${WEBAPP_URL}/${verifyOrGettingStarted}?from=signup`;
293+
};
316294

317-
posthog.capture("signup_form_submit_error", {
318-
has_token: !!token,
319-
is_org_invite: isOrgInviteByLink,
320-
org_slug: orgSlug,
321-
is_premium_username: premiumUsername,
322-
error_message: err.message,
323-
});
324-
formMethods.setError("apiError", { message: err.message });
295+
const constructCallBackUrl = () => {
296+
const callbackUrlSearchParams = searchParams?.get("callbackUrl");
297+
return callbackUrlSearchParams ? constructCallBackIfUrlPresent() : constructCallBackIfUrlNotPresent();
298+
};
299+
300+
await signIn<"credentials">("credentials", {
301+
...data,
302+
callbackUrl: constructCallBackUrl(),
325303
});
304+
} catch (err) {
305+
setTurnstileKey((k) => k + 1);
306+
formMethods.setValue("cfToken", undefined);
307+
308+
const errorMessage = err instanceof Error ? err.message : t("unexpected_error_try_again");
309+
310+
if (errorMessage === INVALID_CLOUDFLARE_TOKEN_ERROR) {
311+
return;
312+
}
313+
314+
posthog.capture("signup_form_submit_error", {
315+
has_token: !!token,
316+
is_org_invite: isOrgInviteByLink,
317+
org_slug: orgSlug,
318+
is_premium_username: premiumUsername,
319+
error_message: errorMessage,
320+
});
321+
formMethods.setError("apiError", { message: errorMessage });
322+
}
326323
};
327324

328325
return (
@@ -563,7 +560,7 @@ export default function Signup({
563560
const username = formMethods.getValues("username");
564561
if (!username) {
565562
// should not be reached but needed to bypass type errors
566-
showToast("error", t("username_required"));
563+
showToast(t("username_required"), "error");
567564
return;
568565
}
569566

apps/web/playwright/signup.e2e.ts

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Page } from "@playwright/test";
22
import { expect } from "@playwright/test";
3+
import { hashSync } from "bcryptjs";
34
import { randomBytes } from "node:crypto";
45

56
import { APP_NAME, IS_PREMIUM_USERNAME_ENABLED, IS_MAILHOG_ENABLED } from "@calcom/lib/constants";
@@ -115,6 +116,86 @@ test.describe("Email Signup Flow Test", async () => {
115116
expect(alertMessageInner).toContain(alertMessageInner);
116117
});
117118
});
119+
120+
test("Signup with org invite token for existing user redirects to login without overwriting password", async ({
121+
page,
122+
prisma,
123+
}) => {
124+
const originalPassword = "OriginalPass99!";
125+
const attackerPassword = "AttackerPass99!";
126+
const testEmail = `existing-user-${Date.now()}@example.com`;
127+
128+
// Create existing user without emailVerified to bypass server-side check
129+
const hashedPassword = hashSync(originalPassword, 12);
130+
const existingUser = await prisma.user.create({
131+
data: {
132+
email: testEmail,
133+
username: `existing-user-${Date.now()}`,
134+
password: { create: { hash: hashedPassword } },
135+
emailVerified: null,
136+
},
137+
});
138+
139+
// Create org invite token for the existing user's email
140+
const token = randomBytes(32).toString("hex");
141+
const org = await prisma.team.create({
142+
data: {
143+
name: "Test Org",
144+
slug: `test-org-${Date.now()}`,
145+
isOrganization: true,
146+
},
147+
});
148+
149+
await prisma.verificationToken.create({
150+
data: {
151+
identifier: existingUser.email,
152+
token,
153+
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
154+
teamId: org.id,
155+
},
156+
});
157+
158+
// Clear any existing session before attempting signup
159+
await page.context().clearCookies();
160+
161+
// Try to signup with the invite token using a different password
162+
await page.goto(`/signup?token=${token}`);
163+
await expect(page.getByTestId("signup-submit-button")).toBeVisible();
164+
165+
await page.locator('input[name="password"]').fill(attackerPassword);
166+
167+
// Intercept the signup API request to verify 409 response
168+
const responsePromise = page.waitForResponse(
169+
(response) => response.url().includes("/api/auth/signup") && response.request().method() === "POST"
170+
);
171+
172+
const submitButton = page.getByTestId("signup-submit-button");
173+
await submitButton.click();
174+
175+
// Verify API returns 409 (user already exists)
176+
const response = await responsePromise;
177+
expect(response.status()).toBe(409);
178+
179+
const responseBody = await response.json();
180+
expect(responseBody.message).toBe("user_already_exists");
181+
182+
// Should redirect to login (toast shows and redirects after 3s)
183+
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 8000 });
184+
185+
// Verify original password still works by logging in
186+
await page.locator('input[name="email"]').fill(existingUser.email);
187+
await page.locator('input[name="password"]').fill(originalPassword);
188+
await page.locator('button[type="submit"]').click();
189+
190+
// Should successfully login with original password
191+
await expect(page).toHaveURL(/\/(getting-started|event-types|teams)/, { timeout: 8000 });
192+
193+
// Cleanup
194+
await prisma.verificationToken.deleteMany({ where: { token } });
195+
await prisma.user.delete({ where: { id: existingUser.id } });
196+
await prisma.team.delete({ where: { id: org.id } });
197+
});
198+
118199
test("Premium Username Flow - creates stripe checkout", async ({ page, users, prisma }) => {
119200
// eslint-disable-next-line playwright/no-skipped-test
120201
test.skip(!IS_PREMIUM_USERNAME_ENABLED, "Only run on Cal.com");
@@ -182,7 +263,7 @@ test.describe("Email Signup Flow Test", async () => {
182263
// 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
183264
expect(dbUser?.username).toBe(userToCreate.username);
184265
});
185-
test("Signup fields prefilled with query params", async ({ page, users }) => {
266+
test("Signup fields prefilled with query params", async ({ page, users: _users }) => {
186267
const signupUrlWithParams = "/signup?username=rick-jones&email=rick-jones%40example.com";
187268
await page.goto(signupUrlWithParams);
188269
await preventFlakyTest(page);
@@ -354,7 +435,7 @@ test.describe("Email Signup Flow Test", async () => {
354435
});
355436
});
356437

357-
test("Checkbox for cookie consent does not need to be checked", async ({ page, users }) => {
438+
test("Checkbox for cookie consent does not need to be checked", async ({ page, users: _users }) => {
358439
await page.goto("/signup");
359440
await preventFlakyTest(page);
360441

apps/web/public/static/locales/en/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4185,5 +4185,6 @@
41854185
"audit_logs_owner_not_in_organization": "The booking owner is not a member of your organization.",
41864186
"audit_logs_permission_denied": "You do not have permission to view audit logs for this booking.",
41874187
"audit_logs_permission_check_error": "An error occurred while checking permissions.",
4188+
"account_already_exists_please_login": "An account with this email already exists. Please log in to accept the invitation.",
41884189
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
41894190
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export const SIGNUP_ERROR_CODES = {
2+
USER_ALREADY_EXISTS: "user_already_exists",
3+
INVALID_SERVER_RESPONSE: "invalid_server_response",
4+
} as const;
5+
6+
export type SignupErrorCode = (typeof SIGNUP_ERROR_CODES)[keyof typeof SIGNUP_ERROR_CODES];

0 commit comments

Comments
 (0)