diff --git a/apps/web/pages/api/auth/signup.ts b/apps/web/pages/api/auth/signup.ts
index b06f39af8c..b0c5c13c77 100644
--- a/apps/web/pages/api/auth/signup.ts
+++ b/apps/web/pages/api/auth/signup.ts
@@ -1,11 +1,20 @@
import type { NextApiRequest, NextApiResponse } from "next";
+import { z } from "zod";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
+import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import slugify from "@calcom/lib/slugify";
import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
+const signupSchema = z.object({
+ username: z.string(),
+ email: z.string().email(),
+ password: z.string().min(7),
+ language: z.string().optional(),
+});
+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return;
@@ -17,7 +26,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
const data = req.body;
- const { email, password } = data;
+ const { email, password, language } = signupSchema.parse(data);
+
const username = slugify(data.username);
const userEmail = email.toLowerCase();
@@ -26,16 +36,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
- if (!userEmail || !userEmail.includes("@")) {
- res.status(422).json({ message: "Invalid email" });
- return;
- }
-
- if (!password || password.trim().length < 7) {
- res.status(422).json({ message: "Invalid input - password should be at least 7 characters long." });
- return;
- }
-
// There is an existingUser if the username matches
// OR if the email matches AND either the email is verified
// or both username and password are set
@@ -106,5 +106,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
+ await sendEmailVerification({
+ email: userEmail,
+ username,
+ language,
+ });
+
res.status(201).json({ message: "Created user" });
}
diff --git a/apps/web/pages/api/auth/verify-email.ts b/apps/web/pages/api/auth/verify-email.ts
new file mode 100644
index 0000000000..3890cdd6a7
--- /dev/null
+++ b/apps/web/pages/api/auth/verify-email.ts
@@ -0,0 +1,48 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import { z } from "zod";
+
+import dayjs from "@calcom/dayjs";
+import { WEBAPP_URL } from "@calcom/lib/constants";
+import { prisma } from "@calcom/prisma";
+
+const verifySchema = z.object({
+ token: z.string(),
+});
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const { token } = verifySchema.parse(req.query);
+
+ const foundToken = await prisma.verificationToken.findFirst({
+ where: {
+ token,
+ },
+ });
+
+ if (!foundToken) {
+ return res.status(401).json({ message: "No token found" });
+ }
+
+ if (dayjs(foundToken?.expires).isBefore(dayjs())) {
+ return res.status(401).json({ message: "Token expired" });
+ }
+
+ const user = await prisma.user.update({
+ where: {
+ email: foundToken?.identifier,
+ },
+ data: {
+ emailVerified: new Date(),
+ },
+ });
+
+ // Delete token from DB after it has been used
+ await prisma.verificationToken.delete({
+ where: {
+ id: foundToken?.id,
+ },
+ });
+
+ const hasCompletedOnboarding = user.completedOnboarding;
+
+ res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`);
+}
diff --git a/apps/web/pages/api/email.ts b/apps/web/pages/api/email.ts
index 6e99a6e77e..4f9a51d64b 100644
--- a/apps/web/pages/api/email.ts
+++ b/apps/web/pages/api/email.ts
@@ -60,9 +60,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
res.setHeader("Content-Type", "text/html");
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
res.write(
- renderEmail("OrganizerRequestEmail", {
- calEvent: evt,
- attendee: evt.organizer,
+ renderEmail("VerifyAccountEmail", {
+ language: t,
+ user: {
+ name: "Pro Example",
+ email: "pro@example.com",
+ },
+ verificationEmailLink:
+ "http://localhost:3000/api/auth/verify-email?token=b91af0eee5a9a24a8d83a3d3d6a58c1606496e94ced589441649273c66100f5b",
})
);
res.end();
diff --git a/apps/web/pages/auth/verify-email.tsx b/apps/web/pages/auth/verify-email.tsx
new file mode 100644
index 0000000000..b18a05de5e
--- /dev/null
+++ b/apps/web/pages/auth/verify-email.tsx
@@ -0,0 +1,60 @@
+import { MailOpenIcon } from "lucide-react";
+import { useSession } from "next-auth/react";
+import { useRouter } from "next/router";
+import { useEffect } from "react";
+
+import { APP_NAME } from "@calcom/lib/constants";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { trpc } from "@calcom/trpc";
+import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck";
+import { Button, EmptyScreen, showToast } from "@calcom/ui";
+
+import PageWrapper from "@components/PageWrapper";
+
+function VerifyEmailPage() {
+ const { data } = useEmailVerifyCheck();
+ const { data: session } = useSession();
+ const router = useRouter();
+ const { t } = useLocale();
+ const mutation = trpc.viewer.auth.resendVerifyEmail.useMutation();
+
+ useEffect(() => {
+ if (data?.isVerified) {
+ router.replace("/getting-started");
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [data?.isVerified]);
+
+ return (
+
+
+
+ {
+ showToast("Send email", "success");
+ mutation.mutate();
+ }}>
+ Resend Email
+
+ }
+ />
+
+
+
+ );
+}
+
+export default VerifyEmailPage;
+
+VerifyEmailPage.PageWrapper = PageWrapper;
diff --git a/apps/web/pages/signup.tsx b/apps/web/pages/signup.tsx
index 8b73bf3cad..88d540ae48 100644
--- a/apps/web/pages/signup.tsx
+++ b/apps/web/pages/signup.tsx
@@ -9,6 +9,7 @@ import { z } from "zod";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
+import { useFlagMap } from "@calcom/features/flags/context/provider";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -29,10 +30,10 @@ type FormValues = {
};
export default function Signup({ prepopulateFormValues, token }: inferSSRProps) {
- const { t } = useLocale();
+ const { t, i18n } = useLocale();
const router = useRouter();
+ const flags = useFlagMap();
const telemetry = useTelemetry();
-
const methods = useForm({
defaultValues: prepopulateFormValues,
});
@@ -52,6 +53,7 @@ export default function Signup({ prepopulateFormValues, token }: inferSSRProps {
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
+ const verifyOrGettingStarted = flags["email-verification"] ? "auth/verify-email" : "getting-started";
await signIn<"credentials">("credentials", {
...data,
callbackUrl: router.query.callbackUrl
? `${WEBAPP_URL}/${router.query.callbackUrl}`
- : `${WEBAPP_URL}/getting-started`,
+ : `${WEBAPP_URL}/${verifyOrGettingStarted}`,
});
})
.catch((err) => {
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 621e0f6df7..4b720d507f 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -11,6 +11,16 @@
"calcom_explained_new_user": "Finish setting up your {{appName}} account! You’re just a few steps away from solving all your scheduling problems.",
"have_any_questions": "Have questions? We're here to help.",
"reset_password_subject": "{{appName}}: Reset password instructions",
+ "verify_email_subject": "{{appName}}: Verify your account",
+ "check_your_email": "Check your email",
+ "verify_email_page_body": "We've sent an email to {{email}}. It is important to verify your email address to guarantee the best email and calendar deliverability from {{appName}}.",
+ "verify_email_banner_body": "Please verify your email adress to guarantee the best email and calendar deliverability from {{appName}}.",
+ "verify_email_banner_button": "Send email",
+ "verify_email_email_header": "Verify your email address",
+ "verify_email_email_button": "Verify email",
+ "verify_email_email_body": "Please verify your email address by clicking the button below.",
+ "verify_email_email_link_text": "Here's the link incase you don't like clicking buttons:",
+ "email_sent": "Email sent successfully",
"event_declined_subject": "Declined: {{title}} at {{date}}",
"event_cancelled_subject": "Cancelled: {{title}} at {{date}}",
"event_request_declined": "Your event request has been declined",
diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts
index 860f89daf3..15bb511ef2 100644
--- a/packages/emails/email-manager.ts
+++ b/packages/emails/email-manager.ts
@@ -6,6 +6,8 @@ import { getEventName } from "@calcom/core/event";
import type BaseEmail from "@calcom/emails/templates/_base-email";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
+import type { EmailVerifyLink } from "./templates/account-verify-email";
+import AccountVerifyEmail from "./templates/account-verify-email";
import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment-email";
import AttendeeCancelledEmail from "./templates/attendee-cancelled-email";
import AttendeeCancelledSeatEmail from "./templates/attendee-cancelled-seat-email";
@@ -240,6 +242,10 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
await sendEmail(() => new TeamInviteEmail(teamInviteEvent));
};
+export const sendEmailVerificationLink = async (verificationInput: EmailVerifyLink) => {
+ await sendEmail(() => new AccountVerifyEmail(verificationInput));
+};
+
export const sendRequestRescheduleEmail = async (
calEvent: CalendarEvent,
metadata: { rescheduleLink: string }
diff --git a/packages/emails/src/templates/VerifyAccountEmail.tsx b/packages/emails/src/templates/VerifyAccountEmail.tsx
new file mode 100644
index 0000000000..462f2ca469
--- /dev/null
+++ b/packages/emails/src/templates/VerifyAccountEmail.tsx
@@ -0,0 +1,60 @@
+import type { TFunction } from "next-i18next";
+
+import { APP_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
+
+import { BaseEmailHtml, CallToAction } from "../components";
+
+export type EmailVerifyLink = {
+ language: TFunction;
+ user: {
+ name?: string | null;
+ email: string;
+ };
+ verificationEmailLink: string;
+};
+
+export const VerifyAccountEmail = (
+ props: EmailVerifyLink & Partial>
+) => {
+ return (
+
+
+ <>{props.language("verify_email_email_header")}>
+
+
+ <>{props.language("hi_user_name", { name: props.user.name })}!>
+
+
+ <>{props.language("verify_email_email_body", { appName: APP_NAME })}>
+
+
+
+
+
+
+ );
+};
diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts
index badde58cc6..e6411eb17b 100644
--- a/packages/emails/src/templates/index.ts
+++ b/packages/emails/src/templates/index.ts
@@ -23,5 +23,6 @@ export { TeamInviteEmail } from "./TeamInviteEmail";
export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail";
export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail";
export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail";
+export { VerifyAccountEmail } from "./VerifyAccountEmail";
export * from "@calcom/app-store/routing-forms/emails/components";
export { AttendeeDailyVideoDownloadRecordingEmail } from "./AttendeeDailyVideoDownloadRecordingEmail";
diff --git a/packages/emails/templates/account-verify-email.ts b/packages/emails/templates/account-verify-email.ts
new file mode 100644
index 0000000000..bcb39e36a7
--- /dev/null
+++ b/packages/emails/templates/account-verify-email.ts
@@ -0,0 +1,50 @@
+import type { TFunction } from "next-i18next";
+
+import { APP_NAME } from "@calcom/lib/constants";
+
+import { renderEmail } from "../";
+import BaseEmail from "./_base-email";
+
+export type EmailVerifyLink = {
+ language: TFunction;
+ user: {
+ name?: string | null;
+ email: string;
+ };
+ verificationEmailLink: string;
+};
+
+
+export default class AccountVerifyEmail extends BaseEmail {
+ verifyAccountInput: EmailVerifyLink;
+
+ constructor(passwordEvent: EmailVerifyLink) {
+ super();
+ this.name = "SEND_ACCOUNT_VERIFY_EMAIL";
+ this.verifyAccountInput = passwordEvent;
+ }
+
+ protected getNodeMailerPayload(): Record {
+ return {
+ to: `${this.verifyAccountInput.user.name} <${this.verifyAccountInput.user.email}>`,
+ from: `${APP_NAME} <${this.getMailerOptions().from}>`,
+ subject: this.verifyAccountInput.language("verify_email_subject", {
+ appName: APP_NAME,
+ }),
+ html: renderEmail("VerifyAccountEmail", this.verifyAccountInput),
+ text: this.getTextBody(),
+ };
+ }
+
+ protected getTextBody(): string {
+ return `
+${this.verifyAccountInput.language("verify_email_subject", { appName: APP_NAME })}
+${this.verifyAccountInput.language("verify_email_email_header")}
+${this.verifyAccountInput.language("hi_user_name", { name: this.verifyAccountInput.user.name })},
+${this.verifyAccountInput.language("verify_email_email_body", { appName: APP_NAME })}
+${this.verifyAccountInput.language("verify_email_email_link_text")}
+${this.verifyAccountInput.verificationEmailLink}
+${this.verifyAccountInput.language("happy_scheduling")} ${this.verifyAccountInput.language("the_calcom_team")}
+`.replace(/(<([^>]+)>)/gi, "");
+ }
+}
diff --git a/packages/features/auth/lib/verifyEmail.ts b/packages/features/auth/lib/verifyEmail.ts
new file mode 100644
index 0000000000..a1c9b01ba9
--- /dev/null
+++ b/packages/features/auth/lib/verifyEmail.ts
@@ -0,0 +1,66 @@
+import { randomBytes } from "crypto";
+
+import { sendEmailVerificationLink } from "@calcom/emails/email-manager";
+import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
+import { WEBAPP_URL } from "@calcom/lib/constants";
+import logger from "@calcom/lib/logger";
+import rateLimit from "@calcom/lib/rateLimit";
+import { getTranslation } from "@calcom/lib/server/i18n";
+import { prisma } from "@calcom/prisma";
+import { TRPCError } from "@calcom/trpc/server";
+
+const log = logger.getChildLogger({ prefix: [`[[Auth] `] });
+
+const limiter = rateLimit({
+ intervalInMs: 60 * 1000, // 1 minute
+});
+
+interface VerifyEmailType {
+ username?: string;
+ email: string;
+ language?: string;
+}
+
+export const sendEmailVerification = async ({ email, language, username }: VerifyEmailType) => {
+ const token = randomBytes(32).toString("hex");
+ const translation = await getTranslation(language ?? "en", "common");
+ const flags = await getFeatureFlagMap(prisma);
+
+ if (!flags["email-verification"]) {
+ log.warn("Email verification is disabled - Skipping");
+ return { ok: true, skipped: true };
+ }
+
+ await prisma.verificationToken.create({
+ data: {
+ identifier: email,
+ token,
+ expires: new Date(Date.now() + 24 * 3600 * 1000), // +1 day
+ },
+ });
+
+ const params = new URLSearchParams({
+ token,
+ });
+
+ const { isRateLimited } = limiter.check(10, email); // 10 requests per minute
+
+ if (isRateLimited) {
+ throw new TRPCError({
+ code: "TOO_MANY_REQUESTS",
+ message: "An unexpected error occurred, please try again later.",
+ cause: "Too many requests",
+ });
+ }
+
+ await sendEmailVerificationLink({
+ language: translation,
+ verificationEmailLink: `${WEBAPP_URL}/api/auth/verify-email?${params.toString()}`,
+ user: {
+ email,
+ name: username,
+ },
+ });
+
+ return { ok: true, skipped: false };
+};
diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts
index da75d8a6f5..589de2e2b5 100644
--- a/packages/features/flags/config.ts
+++ b/packages/features/flags/config.ts
@@ -9,6 +9,7 @@ export type AppFlags = {
webhooks: boolean;
workflows: boolean;
"managed-event-types": boolean;
+ "email-verification": boolean;
"booker-layouts": boolean;
"google-workspace-directory": boolean;
"disable-signup": boolean;
diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx
index 28a92475c2..95cd733d54 100644
--- a/packages/features/shell/Shell.tsx
+++ b/packages/features/shell/Shell.tsx
@@ -18,6 +18,7 @@ import { useFlagMap } from "@calcom/features/flags/context/provider";
import { KBarContent, KBarRoot, KBarTrigger } from "@calcom/features/kbar/Kbar";
import TimezoneChangeDialog from "@calcom/features/settings/TimezoneChangeDialog";
import AdminPasswordBanner from "@calcom/features/users/components/AdminPasswordBanner";
+import VerifyEmailBanner from "@calcom/features/users/components/VerifyEmailBanner";
import classNames from "@calcom/lib/classNames";
import { APP_NAME, DESKTOP_APP_LINK, JOIN_SLACK, ROADMAP, WEBAPP_URL } from "@calcom/lib/constants";
import getBrandColours from "@calcom/lib/getBrandColours";
@@ -26,6 +27,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { isKeyInObject } from "@calcom/lib/isKeyInObject";
import { trpc } from "@calcom/trpc/react";
import useAvatarQuery from "@calcom/trpc/react/hooks/useAvatarQuery";
+import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { SVGComponent } from "@calcom/types/SVGComponent";
import {
@@ -123,17 +125,23 @@ function useRedirectToOnboardingIfNeeded() {
const router = useRouter();
const query = useMeQuery();
const user = query.data;
+ const flags = useFlagMap();
+
+ const { data: email } = useEmailVerifyCheck();
+
+ const needsEmailVerification = !email?.isVerified && flags["email-verification"];
const isRedirectingToOnboarding = user && shouldShowOnboarding(user);
useEffect(() => {
- if (isRedirectingToOnboarding) {
+ if (isRedirectingToOnboarding && !needsEmailVerification) {
router.replace({
pathname: "/getting-started",
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isRedirectingToOnboarding]);
+ }, [isRedirectingToOnboarding, needsEmailVerification]);
+
return {
isRedirectingToOnboarding,
};
@@ -182,6 +190,7 @@ const Layout = (props: LayoutProps) => {
+
{props.SidebarContainer ||
}
diff --git a/packages/features/users/components/VerifyEmailBanner.tsx b/packages/features/users/components/VerifyEmailBanner.tsx
new file mode 100644
index 0000000000..d8e6ffcfd5
--- /dev/null
+++ b/packages/features/users/components/VerifyEmailBanner.tsx
@@ -0,0 +1,36 @@
+import { APP_NAME } from "@calcom/lib/constants";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { trpc } from "@calcom/trpc";
+import useEmailVerifyCheck from "@calcom/trpc/react/hooks/useEmailVerifyCheck";
+import { Button, TopBanner, showToast } from "@calcom/ui";
+
+import { useFlagMap } from "../../flags/context/provider";
+
+function VerifyEmailBanner() {
+ const flags = useFlagMap();
+ const { t } = useLocale();
+ const { data } = useEmailVerifyCheck();
+ const mutation = trpc.viewer.auth.resendVerifyEmail.useMutation();
+
+ if (data?.isVerified || !flags["email-verification"]) return null;
+
+ return (
+ <>
+
{
+ mutation.mutate();
+ showToast(t("email_sent"), "success");
+ }}>
+ {t("verify_email_banner_button")}
+
+ }
+ />
+ >
+ );
+}
+
+export default VerifyEmailBanner;
diff --git a/packages/prisma/migrations/20230523101834_email_verification_feature_flag/migration.sql b/packages/prisma/migrations/20230523101834_email_verification_feature_flag/migration.sql
new file mode 100644
index 0000000000..33d16774c6
--- /dev/null
+++ b/packages/prisma/migrations/20230523101834_email_verification_feature_flag/migration.sql
@@ -0,0 +1,9 @@
+INSERT INTO
+ "Feature" (slug, enabled, description, "type")
+VALUES
+ (
+ 'email-verification',
+ true,
+ 'Enable email verification for new users',
+ 'OPERATIONAL'
+ ) ON CONFLICT (slug) DO NOTHING;
diff --git a/packages/trpc/react/hooks/useEmailVerifyCheck.ts b/packages/trpc/react/hooks/useEmailVerifyCheck.ts
new file mode 100644
index 0000000000..1ce89d5ebc
--- /dev/null
+++ b/packages/trpc/react/hooks/useEmailVerifyCheck.ts
@@ -0,0 +1,13 @@
+import { trpc } from "../trpc";
+
+export function useEmailVerifyCheck() {
+ const emailCheck = trpc.viewer.shouldVerifyEmail.useQuery(undefined, {
+ retry(failureCount) {
+ return failureCount > 3;
+ },
+ });
+
+ return emailCheck;
+}
+
+export default useEmailVerifyCheck;
diff --git a/packages/trpc/server/middlewares/sessionMiddleware.ts b/packages/trpc/server/middlewares/sessionMiddleware.ts
index 663c2900fd..922ff29bde 100644
--- a/packages/trpc/server/middlewares/sessionMiddleware.ts
+++ b/packages/trpc/server/middlewares/sessionMiddleware.ts
@@ -25,6 +25,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe {
+ if (!UNSTABLE_HANDLER_CACHE.shouldVerifyEmail) {
+ UNSTABLE_HANDLER_CACHE.shouldVerifyEmail = (
+ await import("./shouldVerifyEmail.handler")
+ ).shouldVerifyEmailHandler;
+ }
+
+ // Unreachable code but required for type safety
+ if (!UNSTABLE_HANDLER_CACHE.shouldVerifyEmail) {
+ throw new Error("Failed to load handler");
+ }
+
+ return UNSTABLE_HANDLER_CACHE.shouldVerifyEmail({ ctx });
+ }),
});
diff --git a/packages/trpc/server/routers/loggedInViewer/shouldVerifyEmail.handler.ts b/packages/trpc/server/routers/loggedInViewer/shouldVerifyEmail.handler.ts
new file mode 100644
index 0000000000..4943b367c9
--- /dev/null
+++ b/packages/trpc/server/routers/loggedInViewer/shouldVerifyEmail.handler.ts
@@ -0,0 +1,21 @@
+import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
+
+type ShouldVerifyEmailType = {
+ ctx: {
+ user: NonNullable;
+ };
+};
+
+export const shouldVerifyEmailHandler = async ({ ctx }: ShouldVerifyEmailType) => {
+ const { user } = ctx;
+ const isVerified = !!user.emailVerified;
+ const isCalProvider = user.identityProvider === "CAL"; // We dont need to verify on OAUTH providers as they are already verified by the provider
+
+ const obj = {
+ id: user.id,
+ email: user.email,
+ isVerified: isVerified || !isCalProvider,
+ };
+
+ return obj;
+};
diff --git a/packages/trpc/server/routers/loggedInViewer/shouldVerifyEmail.schema.ts b/packages/trpc/server/routers/loggedInViewer/shouldVerifyEmail.schema.ts
new file mode 100644
index 0000000000..cb0ff5c3b5
--- /dev/null
+++ b/packages/trpc/server/routers/loggedInViewer/shouldVerifyEmail.schema.ts
@@ -0,0 +1 @@
+export {};
diff --git a/packages/trpc/server/routers/viewer/auth/_router.tsx b/packages/trpc/server/routers/viewer/auth/_router.tsx
index c8b4c2d999..2636e77d14 100644
--- a/packages/trpc/server/routers/viewer/auth/_router.tsx
+++ b/packages/trpc/server/routers/viewer/auth/_router.tsx
@@ -6,6 +6,7 @@ import { ZVerifyPasswordInputSchema } from "./verifyPassword.schema";
type AuthRouterHandlerCache = {
changePassword?: typeof import("./changePassword.handler").changePasswordHandler;
verifyPassword?: typeof import("./verifyPassword.handler").verifyPasswordHandler;
+ resendVerifyEmail?: typeof import("./resendVerifyEmail.handler").resendVerifyEmail;
};
const UNSTABLE_HANDLER_CACHE: AuthRouterHandlerCache = {};
@@ -46,4 +47,19 @@ export const authRouter = router({
input,
});
}),
+ resendVerifyEmail: authedProcedure.mutation(async ({ ctx }) => {
+ if (!UNSTABLE_HANDLER_CACHE.resendVerifyEmail) {
+ UNSTABLE_HANDLER_CACHE.resendVerifyEmail = await import("./resendVerifyEmail.handler").then(
+ (mod) => mod.resendVerifyEmail
+ );
+ }
+
+ if (!UNSTABLE_HANDLER_CACHE.resendVerifyEmail) {
+ throw new Error("Failed to load handler");
+ }
+
+ return UNSTABLE_HANDLER_CACHE.resendVerifyEmail({
+ ctx,
+ });
+ }),
});
diff --git a/packages/trpc/server/routers/viewer/auth/resendVerifyEmail.handler.ts b/packages/trpc/server/routers/viewer/auth/resendVerifyEmail.handler.ts
new file mode 100644
index 0000000000..b5a4a77c4a
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/auth/resendVerifyEmail.handler.ts
@@ -0,0 +1,30 @@
+import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
+import logger from "@calcom/lib/logger";
+
+import type { TrpcSessionUser } from "../../../trpc";
+
+type ResendEmailOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+};
+
+const log = logger.getChildLogger({ prefix: [`[[Auth] `] });
+
+export const resendVerifyEmail = async ({ ctx }: ResendEmailOptions) => {
+ if (ctx.user.emailVerified) {
+ log.info(`User ${ctx.user.id} already verified email`);
+ return {
+ ok: true,
+ skipped: true,
+ };
+ }
+
+ const email = await sendEmailVerification({
+ email: ctx.user.email,
+ username: ctx.user?.username ?? undefined,
+ language: ctx.user.locale,
+ });
+
+ return email;
+};
diff --git a/packages/ui/components/empty-screen/EmptyScreen.tsx b/packages/ui/components/empty-screen/EmptyScreen.tsx
index a17be261ab..d3fcc778f6 100644
--- a/packages/ui/components/empty-screen/EmptyScreen.tsx
+++ b/packages/ui/components/empty-screen/EmptyScreen.tsx
@@ -16,6 +16,8 @@ export function EmptyScreen({
buttonOnClick,
buttonRaw,
border = true,
+ dashedBorder = true,
+ className,
}: {
Icon?: SVGComponent | IconType;
avatar?: React.ReactElement;
@@ -25,14 +27,17 @@ export function EmptyScreen({
buttonOnClick?: (event: React.MouseEvent) => void;
buttonRaw?: ReactNode; // Used incase you want to provide your own button.
border?: boolean;
-}) {
+ dashedBorder?: boolean;
+} & React.HTMLAttributes) {
return (
<>
{!avatar ? null : (
{avatar}