fix: signup (#25334)
* Handle Stripe logic in `paymentCallback` * Remove endpoint * Do not send email verification email if premium username * Remove logic from verify-view * Callback send verification email * Add `create` method to `VerificationToken` repository * Create `VerificationTokenService` * Early return if payment failed * Refactor token generation * Add tests * Type fixes * Type fixes
This commit is contained in:
@@ -10,14 +10,11 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
async function sendVerificationLogin(email: string, username: string, t: (key: string) => string) {
|
||||
await signIn("email", {
|
||||
email: email.toLowerCase(),
|
||||
@@ -54,8 +51,9 @@ function useSendFirstVerificationLogin({
|
||||
}
|
||||
|
||||
const querySchema = z.object({
|
||||
stripeCustomerId: z.string().optional(),
|
||||
sessionId: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
paymentStatus: z.string().optional(),
|
||||
t: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -119,20 +117,20 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const routerQuery = useRouterQuery();
|
||||
const { t: tParam, sessionId, stripeCustomerId } = querySchema.parse(routerQuery);
|
||||
const { email, username, paymentStatus } = querySchema.parse(routerQuery);
|
||||
const { t } = useLocale();
|
||||
const [secondsLeft, setSecondsLeft] = useState(30);
|
||||
const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery(
|
||||
{
|
||||
stripeCustomerId,
|
||||
checkoutSessionId: sessionId,
|
||||
},
|
||||
{
|
||||
enabled: !!stripeCustomerId || !!sessionId,
|
||||
staleTime: Infinity,
|
||||
}
|
||||
);
|
||||
useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username });
|
||||
|
||||
// Derive payment failed status from payment status
|
||||
const hasPaymentFailed = paymentStatus !== undefined && paymentStatus !== "paid";
|
||||
const isPremiumUsername = !!paymentStatus; // If paymentStatus exists, it's from premium username flow
|
||||
|
||||
// Only send verification login if we DON'T have the email yet (for resend button)
|
||||
// The email is already sent server-side in paymentCallback
|
||||
useSendFirstVerificationLogin({
|
||||
email: !isPremiumUsername ? email : undefined,
|
||||
username: !isPremiumUsername ? username : undefined,
|
||||
});
|
||||
// @note: check for t=timestamp and apply disabled state and secondsLeft accordingly
|
||||
// to avoid refresh to skip waiting 30 seconds to re-send email
|
||||
useEffect(() => {
|
||||
@@ -159,16 +157,7 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
|
||||
}
|
||||
}, [secondsLeft]);
|
||||
|
||||
if (!data) {
|
||||
// Loading state
|
||||
return <Loader />;
|
||||
}
|
||||
const { valid, hasPaymentFailed, customer } = data;
|
||||
if (!valid) {
|
||||
throw new Error("Invalid session or customer id");
|
||||
}
|
||||
|
||||
if (!stripeCustomerId && !sessionId) {
|
||||
if (!email) {
|
||||
return <div>{t("invalid_link")}</div>;
|
||||
}
|
||||
|
||||
@@ -176,17 +165,23 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
|
||||
<div className="text-default bg-muted bg-opacity-90 backdrop-blur-md backdrop-grayscale backdrop-filter">
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6">
|
||||
<div className="border-subtle bg-default m-10 flex max-w-2xl flex-col items-center rounded-xl border px-8 py-14 text-left">
|
||||
{hasPaymentFailed ? <PaymentFailedIcon /> : sessionId ? <PaymentSuccess /> : <MailOpenIcon />}
|
||||
{hasPaymentFailed ? (
|
||||
<PaymentFailedIcon />
|
||||
) : isPremiumUsername ? (
|
||||
<PaymentSuccess />
|
||||
) : (
|
||||
<MailOpenIcon />
|
||||
)}
|
||||
<h3 className="font-cal text-emphasis my-6 text-2xl font-normal leading-none">
|
||||
{hasPaymentFailed
|
||||
? t("your_payment_failed")
|
||||
: sessionId
|
||||
: isPremiumUsername
|
||||
? t("payment_successful")
|
||||
: t("check_your_inbox")}
|
||||
</h3>
|
||||
{hasPaymentFailed && <p className="my-6">{t("account_created_premium_not_reserved")}</p>}
|
||||
<p className="text-muted dark:text-subtle text-base font-normal">
|
||||
{t("email_sent_with_activation_link", { email: customer?.email })}{" "}
|
||||
{t("email_sent_with_activation_link", { email })}{" "}
|
||||
{hasPaymentFailed && t("activate_account_to_purchase_username")}
|
||||
</p>
|
||||
<div className="mt-7">
|
||||
@@ -212,7 +207,7 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
|
||||
)}
|
||||
disabled={secondsLeft > 0}
|
||||
onClick={async (e) => {
|
||||
if (!customer) {
|
||||
if (!email || !username) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
@@ -221,7 +216,7 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
|
||||
const _searchParams = new URLSearchParams(searchParams?.toString());
|
||||
_searchParams.set("t", `${Date.now()}`);
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
return await sendVerificationLogin(customer.email, customer.username, t);
|
||||
return await sendVerificationLogin(email, username, t);
|
||||
}}>
|
||||
{secondsLeft > 0 ? t("resend_in_seconds", { seconds: secondsLeft }) : t("resend")}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import sendVerificationRequest from "@calcom/features/auth/lib/sendVerificationRequest";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { VerificationTokenService } from "@calcom/lib/server/service/VerificationTokenService";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { getCustomerAndCheckoutSession } from "../../lib/getCustomerAndCheckoutSession";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/getCustomerAndCheckoutSession");
|
||||
vi.mock("@calcom/features/auth/lib/sendVerificationRequest");
|
||||
vi.mock("@calcom/lib/server/service/VerificationTokenService");
|
||||
|
||||
const mockGetCustomerAndCheckoutSession = vi.mocked(getCustomerAndCheckoutSession);
|
||||
const mockSendVerificationRequest = vi.mocked(sendVerificationRequest);
|
||||
const mockVerificationTokenService = vi.mocked(VerificationTokenService);
|
||||
|
||||
// Type the mocked prisma properly
|
||||
const mockPrisma = prisma as unknown as {
|
||||
user: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
describe("paymentCallback", () => {
|
||||
let mockReq: Partial<NextApiRequest>;
|
||||
let mockRes: Partial<NextApiResponse>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockReq = {
|
||||
query: {
|
||||
callbackUrl: "/premium-username-checkout",
|
||||
checkoutSessionId: "cs_test_123",
|
||||
},
|
||||
url: "/api/payment/callback",
|
||||
method: "GET",
|
||||
};
|
||||
|
||||
mockRes = {
|
||||
redirect: vi.fn().mockReturnThis(),
|
||||
end: vi.fn().mockReturnThis(),
|
||||
setHeader: vi.fn().mockReturnThis(),
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
// Default mock implementations
|
||||
mockGetCustomerAndCheckoutSession.mockResolvedValue({
|
||||
stripeCustomer: {
|
||||
id: "cus_123",
|
||||
email: "test@example.com",
|
||||
metadata: {
|
||||
username: "premium-user",
|
||||
},
|
||||
},
|
||||
checkoutSession: {
|
||||
payment_status: "paid",
|
||||
},
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
mockPrisma.user.findFirst.mockResolvedValue({
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
locale: "en",
|
||||
metadata: {},
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
mockPrisma.user.update.mockResolvedValue({
|
||||
id: 1,
|
||||
username: "premium-user",
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
mockVerificationTokenService.create.mockResolvedValue("test-token-123");
|
||||
mockSendVerificationRequest.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("VerificationTokenService integration", () => {
|
||||
it("should call VerificationTokenService.create with correct parameters", async () => {
|
||||
const { default: handler } = await import("../paymentCallback");
|
||||
|
||||
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
|
||||
|
||||
expect(mockVerificationTokenService.create).toHaveBeenCalledWith({
|
||||
identifier: "test@example.com",
|
||||
expires: expect.any(Date),
|
||||
});
|
||||
|
||||
const callArgs = mockVerificationTokenService.create.mock.calls[0][0];
|
||||
const expiresDate = callArgs.expires;
|
||||
const now = Date.now();
|
||||
const oneDayInMs = 86400 * 1000;
|
||||
|
||||
// Verify expires is approximately 1 day from now (within 1 second tolerance)
|
||||
expect(expiresDate.getTime()).toBeGreaterThan(now);
|
||||
expect(expiresDate.getTime()).toBeLessThanOrEqual(now + oneDayInMs + 1000);
|
||||
});
|
||||
|
||||
it("should send verification email with token from service", async () => {
|
||||
const { default: handler } = await import("../paymentCallback");
|
||||
|
||||
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
|
||||
|
||||
expect(mockSendVerificationRequest).toHaveBeenCalledWith({
|
||||
identifier: "test@example.com",
|
||||
url: expect.stringContaining("token=test-token-123"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should create verification token before sending email", async () => {
|
||||
const { default: handler } = await import("../paymentCallback");
|
||||
const callOrder: string[] = [];
|
||||
|
||||
mockVerificationTokenService.create.mockImplementation(async () => {
|
||||
callOrder.push("create-token");
|
||||
return "test-token";
|
||||
});
|
||||
|
||||
mockSendVerificationRequest.mockImplementation(async () => {
|
||||
callOrder.push("send-email");
|
||||
});
|
||||
|
||||
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
|
||||
|
||||
expect(callOrder).toEqual(["create-token", "send-email"]);
|
||||
});
|
||||
|
||||
it("should create verification token only after payment is confirmed", async () => {
|
||||
mockGetCustomerAndCheckoutSession.mockResolvedValue({
|
||||
stripeCustomer: {
|
||||
id: "cus_123",
|
||||
email: "test@example.com",
|
||||
metadata: { username: "premium-user" },
|
||||
},
|
||||
checkoutSession: {
|
||||
payment_status: "unpaid",
|
||||
},
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const { default: handler } = await import("../paymentCallback");
|
||||
|
||||
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
|
||||
|
||||
expect(mockVerificationTokenService.create).not.toHaveBeenCalled();
|
||||
expect(mockSendVerificationRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle user found by stripeCustomerId", async () => {
|
||||
mockPrisma.user.findFirst
|
||||
.mockResolvedValueOnce(null) // First call by email returns null
|
||||
.mockResolvedValueOnce({
|
||||
// Second call by stripeCustomerId succeeds
|
||||
id: 2,
|
||||
email: "different@example.com",
|
||||
locale: "en",
|
||||
metadata: { stripeCustomerId: "cus_123" },
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const { default: handler } = await import("../paymentCallback");
|
||||
|
||||
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
|
||||
|
||||
expect(mockVerificationTokenService.create).toHaveBeenCalledWith({
|
||||
identifier: "different@example.com", // Should use user.email from found user
|
||||
expires: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
it("should update user with premium username before creating token", async () => {
|
||||
const { default: handler } = await import("../paymentCallback");
|
||||
const callOrder: string[] = [];
|
||||
|
||||
mockPrisma.user.update.mockImplementation(async () => {
|
||||
callOrder.push("update-user");
|
||||
return {} as any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
});
|
||||
|
||||
mockVerificationTokenService.create.mockImplementation(async () => {
|
||||
callOrder.push("create-token");
|
||||
return "token";
|
||||
});
|
||||
|
||||
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
|
||||
|
||||
expect(callOrder).toEqual(["update-user", "create-token"]);
|
||||
});
|
||||
|
||||
it("should redirect with correct parameters after successful payment", async () => {
|
||||
const { default: handler } = await import("../paymentCallback");
|
||||
|
||||
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const redirectUrl = (mockRes.redirect as any).mock.calls[0][0] as string;
|
||||
expect(redirectUrl).toContain("email=test%40example.com");
|
||||
expect(redirectUrl).toContain("username=premium-user");
|
||||
expect(redirectUrl).toContain("paymentStatus=paid");
|
||||
});
|
||||
|
||||
it("should not create verification token when stripe customer is not found", async () => {
|
||||
mockGetCustomerAndCheckoutSession.mockResolvedValue({
|
||||
stripeCustomer: null,
|
||||
checkoutSession: { payment_status: "paid" },
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const { default: handler } = await import("../paymentCallback");
|
||||
|
||||
try {
|
||||
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
expect((error as HttpError).statusCode).toBe(404);
|
||||
}
|
||||
|
||||
expect(mockVerificationTokenService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not create verification token when user is not found", async () => {
|
||||
mockPrisma.user.findFirst.mockResolvedValue(null);
|
||||
|
||||
const { default: handler } = await import("../paymentCallback");
|
||||
|
||||
try {
|
||||
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
expect((error as HttpError).statusCode).toBe(404);
|
||||
}
|
||||
|
||||
expect(mockVerificationTokenService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use user email if stripe customer email is missing", async () => {
|
||||
mockGetCustomerAndCheckoutSession.mockResolvedValue({
|
||||
stripeCustomer: {
|
||||
id: "cus_123",
|
||||
email: null,
|
||||
metadata: { username: "premium-user" },
|
||||
},
|
||||
checkoutSession: { payment_status: "paid" },
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const { default: handler } = await import("../paymentCallback");
|
||||
|
||||
await handler(mockReq as NextApiRequest, mockRes as NextApiResponse);
|
||||
|
||||
expect(mockVerificationTokenService.create).toHaveBeenCalledWith({
|
||||
identifier: "test@example.com", // Should use user.email
|
||||
expires: expect.any(Date),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,13 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import z from "zod";
|
||||
|
||||
import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/lib/getCustomerAndCheckoutSession";
|
||||
import sendVerificationRequest from "@calcom/features/auth/lib/sendVerificationRequest";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { VerificationTokenService } from "@calcom/lib/server/service/VerificationTokenService";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@@ -22,43 +25,76 @@ const querySchema = z.object({
|
||||
// It handles premium user payment success/failure
|
||||
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { callbackUrl, checkoutSessionId } = querySchema.parse(req.query);
|
||||
const log = logger.getSubLogger({ prefix: [`[paymentCallback] checkoutSessionId: ${checkoutSessionId}`] });
|
||||
const { stripeCustomer, checkoutSession } = await getCustomerAndCheckoutSession(checkoutSessionId);
|
||||
|
||||
if (!stripeCustomer)
|
||||
if (!stripeCustomer) {
|
||||
log.error("Could not find stripeCustomer");
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: "Stripe customer not found or deleted",
|
||||
message:
|
||||
"Stripe customer not found or deleted. Please contact support@cal.com and mention your premium username",
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
});
|
||||
}
|
||||
|
||||
// first let's try to find user by metadata stripeCustomerId
|
||||
let user = await prisma.user.findFirst({
|
||||
where: {
|
||||
metadata: {
|
||||
path: ["stripeCustomerId"],
|
||||
equals: stripeCustomer.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
let user;
|
||||
|
||||
if (!user && stripeCustomer.email) {
|
||||
// if user not found, let's try to find user by email
|
||||
if (stripeCustomer?.email) {
|
||||
user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: stripeCustomer.email,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!user)
|
||||
throw new HttpError({ statusCode: 404, message: "User not found", url: req.url, method: req.method });
|
||||
// If we cannot find the user via email, query via stripeCustomerId
|
||||
if (!user) {
|
||||
user = await prisma.user.findFirst({
|
||||
where: {
|
||||
metadata: {
|
||||
path: ["stripeCustomerId"],
|
||||
equals: stripeCustomer.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (checkoutSession.payment_status === "paid" && stripeCustomer.metadata.username) {
|
||||
if (!user) {
|
||||
log.error("Could not find user");
|
||||
throw new HttpError({ statusCode: 404, message: "User not found", url: req.url, method: req.method });
|
||||
}
|
||||
|
||||
const username = stripeCustomer.metadata.username;
|
||||
const email = user.email ?? stripeCustomer.email;
|
||||
|
||||
// If payment hasn't been completed, redirect to verify page with failure status
|
||||
if (checkoutSession.payment_status !== "paid") {
|
||||
log.warn("Payment not completed", { paymentStatus: checkoutSession.payment_status });
|
||||
callbackUrl.searchParams.set("email", email || "");
|
||||
callbackUrl.searchParams.set("username", username || "");
|
||||
callbackUrl.searchParams.set("paymentStatus", checkoutSession.payment_status);
|
||||
return res.redirect(callbackUrl.toString()).end();
|
||||
}
|
||||
|
||||
if (stripeCustomer.metadata.username) {
|
||||
try {
|
||||
await prisma.user.update({
|
||||
data: {
|
||||
username: stripeCustomer.metadata.username,
|
||||
username,
|
||||
metadata: {
|
||||
...(user.metadata as Prisma.JsonObject),
|
||||
isPremium: true,
|
||||
@@ -69,7 +105,7 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
log.error(error);
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
url: req.url,
|
||||
@@ -79,7 +115,29 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const expires = new Date(Date.now() + 86400 * 1000); // 1 day
|
||||
|
||||
const token = await VerificationTokenService.create({ identifier: email, expires });
|
||||
|
||||
// Generate the callback URL with token
|
||||
const params = new URLSearchParams({
|
||||
callbackUrl: WEBAPP_URL || "https://app.cal.com",
|
||||
token,
|
||||
email,
|
||||
});
|
||||
const url = `${WEBAPP_URL}/api/auth/callback/email?${params.toString()}`;
|
||||
|
||||
await sendVerificationRequest({
|
||||
identifier: email,
|
||||
url,
|
||||
});
|
||||
|
||||
// Pass email, username, and payment status in the redirect URL
|
||||
callbackUrl.searchParams.set("email", email || "");
|
||||
callbackUrl.searchParams.set("username", username);
|
||||
callbackUrl.searchParams.set("paymentStatus", checkoutSession.payment_status);
|
||||
|
||||
return res.redirect(callbackUrl.toString()).end();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ const transporter = nodemailer.createTransport<TransportOptions>({
|
||||
...(serverConfig.transport as TransportOptions),
|
||||
} as TransportOptions);
|
||||
|
||||
const sendVerificationRequest = async ({ identifier, url }: SendVerificationRequestParams) => {
|
||||
const sendVerificationRequest = async ({
|
||||
identifier,
|
||||
url,
|
||||
}: Pick<SendVerificationRequestParams, "identifier" | "url">) => {
|
||||
const emailsDir = path.resolve(process.cwd(), "..", "..", "packages/emails", "templates");
|
||||
const originalUrl = new URL(url);
|
||||
const webappUrl = new URL(process.env.NEXTAUTH_URL || WEBAPP_URL);
|
||||
|
||||
@@ -213,11 +213,15 @@ const handler: CustomNextApiHandler = async (body, usernameStatus) => {
|
||||
if (process.env.AVATARAPI_USERNAME && process.env.AVATARAPI_PASSWORD) {
|
||||
await prefillAvatar({ email });
|
||||
}
|
||||
sendEmailVerification({
|
||||
email,
|
||||
language: await getLocaleFromRequest(buildLegacyRequest(await headers(), await cookies())),
|
||||
username: username || "",
|
||||
});
|
||||
// Only send verification email for non-premium usernames
|
||||
// Premium usernames will get a magic link after payment in paymentCallback
|
||||
if (!checkoutSessionId) {
|
||||
sendEmailVerification({
|
||||
email,
|
||||
language: await getLocaleFromRequest(buildLegacyRequest(await headers(), await cookies())),
|
||||
username: username || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (checkoutSessionId) {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { VerificationToken } from "@calcom/prisma/client";
|
||||
|
||||
import { VerificationTokenRepository } from "../verificationToken";
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: {
|
||||
verificationToken: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
verificationToken: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: prismaMock,
|
||||
}));
|
||||
|
||||
describe("VerificationTokenRepository", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
test("should create a verification token", async () => {
|
||||
const identifier = "test@example.com";
|
||||
const token = "hashed-token-value";
|
||||
const expires = new Date(Date.now() + 86400 * 1000);
|
||||
|
||||
const mockVerificationToken: VerificationToken = {
|
||||
identifier,
|
||||
token,
|
||||
expires,
|
||||
};
|
||||
|
||||
prismaMock.verificationToken.create.mockResolvedValue(mockVerificationToken);
|
||||
|
||||
const result = await VerificationTokenRepository.create({
|
||||
identifier,
|
||||
token,
|
||||
expires,
|
||||
});
|
||||
|
||||
expect(prismaMock.verificationToken.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
identifier,
|
||||
token,
|
||||
expires,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockVerificationToken);
|
||||
});
|
||||
|
||||
test("should create a verification token with correct data types", async () => {
|
||||
const identifier = "user@example.com";
|
||||
const token = "sha256-hashed-token";
|
||||
const expires = new Date("2025-12-31T23:59:59.000Z");
|
||||
|
||||
const mockVerificationToken: VerificationToken = {
|
||||
identifier,
|
||||
token,
|
||||
expires,
|
||||
};
|
||||
|
||||
prismaMock.verificationToken.create.mockResolvedValue(mockVerificationToken);
|
||||
|
||||
await VerificationTokenRepository.create({
|
||||
identifier,
|
||||
token,
|
||||
expires,
|
||||
});
|
||||
|
||||
expect(prismaMock.verificationToken.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
identifier,
|
||||
token,
|
||||
expires,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTeamInviteTokenExpirationDate", () => {
|
||||
test("should update team invite token expiration date", async () => {
|
||||
const email = "team@example.com";
|
||||
const teamId = 123;
|
||||
const expiresInDays = 7;
|
||||
|
||||
const existingToken: VerificationToken = {
|
||||
identifier: email,
|
||||
token: "existing-token",
|
||||
expires: new Date(),
|
||||
};
|
||||
|
||||
prismaMock.verificationToken.findFirst.mockResolvedValue(existingToken);
|
||||
prismaMock.verificationToken.update.mockResolvedValue({
|
||||
...existingToken,
|
||||
expires: new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
const result = await VerificationTokenRepository.updateTeamInviteTokenExpirationDate({
|
||||
email,
|
||||
teamId,
|
||||
expiresInDays,
|
||||
});
|
||||
|
||||
expect(prismaMock.verificationToken.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
identifier: email,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaMock.verificationToken.update).toHaveBeenCalledWith({
|
||||
where: {
|
||||
identifier: email,
|
||||
teamId,
|
||||
token: existingToken.token,
|
||||
},
|
||||
data: { expires: expect.any(Date) },
|
||||
});
|
||||
|
||||
expect(result.token).toBe(existingToken.token);
|
||||
expect(result.expires).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test("should calculate correct expiration date", async () => {
|
||||
const email = "team@example.com";
|
||||
const teamId = 456;
|
||||
const expiresInDays = 3;
|
||||
|
||||
const existingToken: VerificationToken = {
|
||||
identifier: email,
|
||||
token: "token-123",
|
||||
expires: new Date(),
|
||||
};
|
||||
|
||||
prismaMock.verificationToken.findFirst.mockResolvedValue(existingToken);
|
||||
|
||||
const expectedExpires = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
prismaMock.verificationToken.update.mockImplementation(({ data }) => {
|
||||
expect(data.expires.getTime()).toBeCloseTo(expectedExpires.getTime(), -2);
|
||||
return Promise.resolve({ ...existingToken, expires: data.expires });
|
||||
});
|
||||
|
||||
await VerificationTokenRepository.updateTeamInviteTokenExpirationDate({
|
||||
email,
|
||||
teamId,
|
||||
expiresInDays,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,4 +30,22 @@ export class VerificationTokenRepository {
|
||||
|
||||
return { ...token, expires };
|
||||
}
|
||||
|
||||
static async create({
|
||||
identifier,
|
||||
token,
|
||||
expires,
|
||||
}: {
|
||||
identifier: string;
|
||||
token: string;
|
||||
expires: Date;
|
||||
}) {
|
||||
return prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier,
|
||||
token,
|
||||
expires,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { randomBytes, createHash } from "crypto";
|
||||
|
||||
import { VerificationTokenRepository } from "../repository/verificationToken";
|
||||
|
||||
export class VerificationTokenService {
|
||||
static async create({ identifier, expires }: { identifier: string; expires: Date }) {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const hashedToken = createHash("sha256")
|
||||
.update(`${token}${process.env.NEXTAUTH_SECRET}`)
|
||||
.digest("hex");
|
||||
|
||||
await VerificationTokenRepository.create({ identifier, token: hashedToken, expires });
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { VerificationTokenRepository } from "@calcom/lib/server/repository/verificationToken";
|
||||
|
||||
import { VerificationTokenService } from "../VerificationTokenService";
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/verificationToken", () => ({
|
||||
VerificationTokenRepository: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("VerificationTokenService", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
process.env.NEXTAUTH_SECRET = "test-secret";
|
||||
|
||||
vi.mocked(VerificationTokenRepository.create).mockResolvedValue({
|
||||
identifier: "test@example.com",
|
||||
token: "hashed-token",
|
||||
expires: new Date(),
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
test("should create a verification token and return unhashed token", async () => {
|
||||
const identifier = "test@example.com";
|
||||
const expires = new Date(Date.now() + 86400 * 1000);
|
||||
|
||||
const result = await VerificationTokenService.create({
|
||||
identifier,
|
||||
expires,
|
||||
});
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(VerificationTokenRepository.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should call repository with correct identifier and expires", async () => {
|
||||
const identifier = "user@example.com";
|
||||
const expires = new Date("2025-12-31T23:59:59.000Z");
|
||||
|
||||
await VerificationTokenService.create({
|
||||
identifier,
|
||||
expires,
|
||||
});
|
||||
|
||||
expect(VerificationTokenRepository.create).toHaveBeenCalledWith({
|
||||
identifier,
|
||||
token: expect.any(String),
|
||||
expires,
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(VerificationTokenRepository.create).mock.calls[0][0];
|
||||
expect(callArgs.identifier).toBe(identifier);
|
||||
expect(callArgs.expires).toBe(expires);
|
||||
expect(callArgs.token).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should hash token with NEXTAUTH_SECRET", async () => {
|
||||
const identifier = "test@example.com";
|
||||
const expires = new Date(Date.now() + 86400 * 1000);
|
||||
|
||||
await VerificationTokenService.create({
|
||||
identifier,
|
||||
expires,
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(VerificationTokenRepository.create).mock.calls[0][0];
|
||||
// The hashed token should be a 64-character hex string (SHA-256)
|
||||
expect(callArgs.token).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
test("should generate unique tokens on multiple calls", async () => {
|
||||
const identifier = "test@example.com";
|
||||
const expires = new Date(Date.now() + 86400 * 1000);
|
||||
|
||||
const result1 = await VerificationTokenService.create({
|
||||
identifier,
|
||||
expires,
|
||||
});
|
||||
|
||||
const result2 = await VerificationTokenService.create({
|
||||
identifier,
|
||||
expires,
|
||||
});
|
||||
|
||||
expect(result1).not.toBe(result2);
|
||||
expect(VerificationTokenRepository.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("should handle Date objects for expires", async () => {
|
||||
const identifier = "test@example.com";
|
||||
const expires = new Date(2025, 11, 31, 23, 59, 59);
|
||||
|
||||
await VerificationTokenService.create({
|
||||
identifier,
|
||||
expires,
|
||||
});
|
||||
|
||||
expect(VerificationTokenRepository.create).toHaveBeenCalledWith({
|
||||
identifier,
|
||||
token: expect.any(String),
|
||||
expires: expect.any(Date),
|
||||
});
|
||||
|
||||
const callArgs = vi.mocked(VerificationTokenRepository.create).mock.calls[0][0];
|
||||
expect(callArgs.expires).toBeInstanceOf(Date);
|
||||
expect(callArgs.expires.getTime()).toBe(expires.getTime());
|
||||
});
|
||||
|
||||
test("should generate 32-byte random token in hex format", async () => {
|
||||
const identifier = "test@example.com";
|
||||
const expires = new Date(Date.now() + 86400 * 1000);
|
||||
|
||||
const token = await VerificationTokenService.create({
|
||||
identifier,
|
||||
expires,
|
||||
});
|
||||
|
||||
// 32 bytes = 64 hex characters
|
||||
expect(token).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,8 @@ import { ZUserEmailVerificationRequiredSchema } from "./checkIfUserEmailVerifica
|
||||
import { ZMarkHostAsNoShowInputSchema } from "./markHostAsNoShow.schema";
|
||||
import { event } from "./procedures/event";
|
||||
import { ZSamlTenantProductInputSchema } from "./samlTenantProduct.schema";
|
||||
import { ZStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema";
|
||||
import { ZSubmitRatingInputSchema } from "./submitRating.schema";
|
||||
|
||||
const NAMESPACE = "publicViewer";
|
||||
|
||||
const namespaced = (s: string) => `${NAMESPACE}.${s}`;
|
||||
|
||||
// things that unauthenticated users can query about themselves
|
||||
export const publicViewerRouter = router({
|
||||
countryCode: publicProcedure.query(async (opts) => {
|
||||
@@ -29,10 +24,6 @@ export const publicViewerRouter = router({
|
||||
const { default: handler } = await import("./samlTenantProduct.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
stripeCheckoutSession: publicProcedure.input(ZStripeCheckoutSessionInputSchema).query(async (opts) => {
|
||||
const { default: handler } = await import("./stripeCheckoutSession.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
event,
|
||||
ssoConnections: publicProcedure.query(async () => {
|
||||
const { default: handler } = await import("./ssoConnections.handler");
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { StripeService } from "@calcom/features/ee/payments/server/stripe-service";
|
||||
|
||||
import { ZStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema";
|
||||
import type { TStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema";
|
||||
|
||||
type StripeCheckoutSessionOptions = {
|
||||
input: TStripeCheckoutSessionInputSchema;
|
||||
};
|
||||
|
||||
export const stripeCheckoutSessionHandler = async ({ input }: StripeCheckoutSessionOptions) => {
|
||||
const parsedInput = ZStripeCheckoutSessionInputSchema.parse(input);
|
||||
|
||||
return await StripeService.getCheckoutSession(parsedInput);
|
||||
};
|
||||
|
||||
export default stripeCheckoutSessionHandler;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZStripeCheckoutSessionInputSchema = z
|
||||
.object({
|
||||
stripeCustomerId: z.string().optional(),
|
||||
checkoutSessionId: z.string().optional(),
|
||||
})
|
||||
.superRefine((arg, ctx) => {
|
||||
if (!arg.checkoutSessionId && !arg.stripeCustomerId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Missing checkoutSessionId or stripeCustomerId",
|
||||
});
|
||||
}
|
||||
if (arg.checkoutSessionId && arg.stripeCustomerId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Both checkoutSessionId and stripeCustomerId provided",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type TStripeCheckoutSessionInputSchema = z.infer<typeof ZStripeCheckoutSessionInputSchema>;
|
||||
Reference in New Issue
Block a user