From 9e70a985e43e8ec99bd80109fe5a79676b9e19c0 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Wed, 7 Jun 2023 08:27:48 +0100 Subject: [PATCH] feat: email verification (#9081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Verify - inital email commit * Add token type - api redirect - migration * Redirect and valid api callback route * Update email design * Change signup URL to redirect to verify-email * Add feature flag - add a11y text to email - add top banner * Shell shouldnt redirect to onboarding if the user needs to verify account * Move flag check to server * Cleanup * Rate limit * Fix redirects * Remove api signup mess * Double negation for forced bool * Fix props * Update packages/emails/templates/account-verify-email.ts * Enable migration by default * Fix typos * Fix google verify issue * Update packages/features/auth/lib/verifyEmail.ts Co-authored-by: Hariom Balhara * NITS: @harioms addressed * Remove schema changes * Fix NITs+ improvments * Update apps/web/pages/api/auth/verify-email.ts Co-authored-by: Omar López * Update packages/features/ee/common/components/LicenseRequired.tsx Co-authored-by: Omar López * Update apps/web/pages/api/auth/verify-email.ts Co-authored-by: Omar López * Always preloads feature flags * Update verifyEmail.ts * Update schema.prisma * Type fix --------- Co-authored-by: Hariom Balhara Co-authored-by: Omar López --- apps/web/pages/api/auth/signup.ts | 28 ++++---- apps/web/pages/api/auth/verify-email.ts | 48 ++++++++++++++ apps/web/pages/api/email.ts | 11 +++- apps/web/pages/auth/verify-email.tsx | 60 +++++++++++++++++ apps/web/pages/signup.tsx | 9 ++- apps/web/public/static/locales/en/common.json | 10 +++ packages/emails/email-manager.ts | 6 ++ .../src/templates/VerifyAccountEmail.tsx | 60 +++++++++++++++++ packages/emails/src/templates/index.ts | 1 + .../emails/templates/account-verify-email.ts | 50 ++++++++++++++ packages/features/auth/lib/verifyEmail.ts | 66 +++++++++++++++++++ packages/features/flags/config.ts | 1 + packages/features/shell/Shell.tsx | 13 +++- .../users/components/VerifyEmailBanner.tsx | 36 ++++++++++ .../migration.sql | 9 +++ .../trpc/react/hooks/useEmailVerifyCheck.ts | 13 ++++ .../server/middlewares/sessionMiddleware.ts | 1 + .../server/routers/loggedInViewer/_router.tsx | 15 +++++ .../shouldVerifyEmail.handler.ts | 21 ++++++ .../shouldVerifyEmail.schema.ts | 1 + .../server/routers/viewer/auth/_router.tsx | 16 +++++ .../viewer/auth/resendVerifyEmail.handler.ts | 30 +++++++++ .../components/empty-screen/EmptyScreen.tsx | 9 ++- 23 files changed, 493 insertions(+), 21 deletions(-) create mode 100644 apps/web/pages/api/auth/verify-email.ts create mode 100644 apps/web/pages/auth/verify-email.tsx create mode 100644 packages/emails/src/templates/VerifyAccountEmail.tsx create mode 100644 packages/emails/templates/account-verify-email.ts create mode 100644 packages/features/auth/lib/verifyEmail.ts create mode 100644 packages/features/users/components/VerifyEmailBanner.tsx create mode 100644 packages/prisma/migrations/20230523101834_email_verification_feature_flag/migration.sql create mode 100644 packages/trpc/react/hooks/useEmailVerifyCheck.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/shouldVerifyEmail.handler.ts create mode 100644 packages/trpc/server/routers/loggedInViewer/shouldVerifyEmail.schema.ts create mode 100644 packages/trpc/server/routers/viewer/auth/resendVerifyEmail.handler.ts 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 })} +

+ + +
+

+ <>{props.language("verify_email_email_link_text")} +
+ {props.verificationEmailLink} +

+
+
+

+ <> + {props.language("happy_scheduling")}
+ + <>{props.language("the_calcom_team")} + + +

+
+
+ ); +}; 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}