diff --git a/src/components/adminPage/mail/mailVerifyEmail.tsx b/src/components/adminPage/mail/mailVerifyEmail.tsx new file mode 100644 index 00000000..15f00cad --- /dev/null +++ b/src/components/adminPage/mail/mailVerifyEmail.tsx @@ -0,0 +1,204 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useState, useEffect } from "react"; +import { api } from "~/utils/api"; +import { toast } from "react-hot-toast"; +import cn from "classnames"; +import { useTranslations } from "next-intl"; +import { + useTrpcApiErrorHandler, + useTrpcApiSuccessHandler, +} from "~/hooks/useTrpcApiHandler"; + +type InviteUserTemplate = { + subject: string; + body: string; +}; + +const VerifyEmailTemplate = () => { + const t = useTranslations("admin"); + + const handleApiError = useTrpcApiErrorHandler(); + const handleApiSuccess = useTrpcApiSuccessHandler(); + + const [changes, setChanges] = useState({ + subject: false, + body: false, + }); + + const [emailTemplate, setEmailTemplate] = useState({ + subject: "", + body: "", + }); + // get default mail template + const { + data: mailTemplates, + refetch: refetchMailTemplates, + isLoading: loadingTemplates, + } = api.admin.getMailTemplates.useQuery({ + template: "verifyEmailTemplate", + }); + + const changeTemplateHandler = ( + e: React.ChangeEvent, + ) => { + const modifiedValue = e.target.value.replace(/\n/g, "
"); + setEmailTemplate({ + ...emailTemplate, + [e.target.name]: modifiedValue, + }); + }; + + const { mutate: sendTestMail, isLoading: sendingMailLoading } = + api.admin.sendTestMail.useMutation({ + onError: handleApiError, + onSuccess: handleApiSuccess({ + toastMessage: t("mail.templates.successToastMailSent"), + }), + }); + + const { mutate: setMailTemplates } = api.admin.setMailTemplates.useMutation(); + + const { mutate: getDefaultMailTemplate, data: defaultTemplates } = + api.admin.getDefaultMailTemplate.useMutation(); + + useEffect(() => { + if (!defaultTemplates) return; + + setEmailTemplate(defaultTemplates); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultTemplates]); + + useEffect(() => { + const verifyEmailTemplate = mailTemplates as InviteUserTemplate; + setEmailTemplate(verifyEmailTemplate); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mailTemplates]); + + useEffect(() => { + const keysToCompare = ["subject", "body"]; // Add more keys as needed + + const inviteUserTemplate = mailTemplates as InviteUserTemplate; + if (!inviteUserTemplate || !emailTemplate) return; + + const newChanges = keysToCompare.reduce( + (acc, key) => { + const val1 = inviteUserTemplate?.[key] as string; + const val2 = emailTemplate[key] as string; + + // Here we just compare strings directly, you could add more complex comparison logic if needed + acc[key] = val1 !== val2; + + return acc; + }, + { subject: false, body: false }, + ); + + setChanges(newChanges); + }, [mailTemplates, emailTemplate]); + + const submitTemplateHandler = () => { + if (!emailTemplate.subject || !emailTemplate.body) { + return toast.error(t("mail.templates.errorFields")); + } + + setMailTemplates( + { + template: JSON.stringify(emailTemplate), + type: "verifyEmailTemplate", + }, + { + onSuccess: () => { + toast.success(t("mail.templates.successToastTemplateSaved")); + void refetchMailTemplates(); + }, + }, + ); + }; + + if (loadingTemplates) { + return ( +
+

+ +

+
+ ); + } + return ( +
+
+

+ {t("mail.templates.availableTags")} + + toName + verifyLink + +

+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
+
+
+ ); +}; + +export default VerifyEmailTemplate; diff --git a/src/components/elements/inputField.tsx b/src/components/elements/inputField.tsx index 55097d7d..d61e5fc2 100644 --- a/src/components/elements/inputField.tsx +++ b/src/components/elements/inputField.tsx @@ -44,10 +44,12 @@ interface FormProps { badge?: { text: string; color: string; + onClick?: () => void; }; headerBadge?: { text: string; color: string; + onClick?: () => void; }; } @@ -125,7 +127,7 @@ const InputField = ({ const handleChange = ( e: | ChangeEvent - | { target: { name: string; value: string[] } }, + | { target: { name: string; value: string | string[] } }, ) => { const { name, value } = "target" in e ? e.target : e; setFormValues((prevValues) => ({ @@ -156,32 +158,42 @@ const InputField = ({ ); + const renderBadge = (badgeProps: FormProps["badge"] | FormProps["headerBadge"]) => { + if (!badgeProps) return null; + + return ( + { + e.stopPropagation(); // Prevent event from bubbling up + if (badgeProps.onClick) { + badgeProps.onClick(); + } + }} + > + {badgeProps.text} + + ); + }; return ( <> {!showInputs ? (
-
-
+
+
{label} - - {headerBadge && ( - - {headerBadge.text} - - )} + {renderBadge(headerBadge)}
{description ? (

{description}

) : null}
-
- {placeholder ?? fields[0].placeholder} - {badge && ( - - {badge.text} - - )} +
+ {placeholder ?? fields[0].placeholder} + {renderBadge(badge)}
@@ -263,9 +275,9 @@ const InputField = ({ formFieldName={field.name} options={field.selectOptions as string[]} value={(formValues[field.name] as string[]) || []} - onChange={(e) => + onChange={(selectedValues: string[]) => handleChange({ - target: { name: field.name, type: "select", value: e }, + target: { name: field.name, value: selectedValues }, }) } prompt={field.placeholder} diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 4e41b8e4..c676e072 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -191,6 +191,13 @@ "mfaRecoveryResetTitle": "2FA Recovery", "mfaRecoveryResetMessage": "Please enter your credentials and Recovery Code", "backToLogin": "Back to Login" + }, + "emailVerification": { + "emailVerified": "Email Verified", + "errorMessage": "An error occurred while verifying your email.", + "successMessage": "Your email has been successfully verified.", + "redirectMessage": "You will be redirected automatically in {seconds} seconds.", + "goNowButton": "Go Now" } }, "networks": { @@ -448,6 +455,7 @@ "password": "Password", "useSSL": "Use SSL", "inviteUserTemplate": "Invite user template", + "emailVerificationTemplate": "Email verification template", "forgotPasswordTemplate": "Forgot Password template", "notificationTemplate": "Notification template", "organizationInviteTemplate": "Organization Invite template", diff --git a/src/locales/es/common.json b/src/locales/es/common.json index e40f0622..bbce0b74 100644 --- a/src/locales/es/common.json +++ b/src/locales/es/common.json @@ -191,6 +191,13 @@ "mfaRecoveryResetTitle": "Recuperación de 2FA", "mfaRecoveryResetMessage": "Por favor, introduce tus credenciales y el código de recuperación", "backToLogin": "Volver al inicio de sesión" + }, + "emailVerification": { + "emailVerified": "Correo electrónico verificado", + "errorMessage": "Ocurrió un error al verificar tu correo electrónico.", + "successMessage": "Tu correo electrónico ha sido verificado exitosamente.", + "redirectMessage": "Serás redirigido automáticamente en {seconds} segundos.", + "goNowButton": "Ir ahora" } }, "networks": { @@ -448,6 +455,7 @@ "password": "Contraseña", "useSSL": "Usar SSL", "inviteUserTemplate": "Plantilla de invitación de usuario", + "emailVerificationTemplate": "Plantilla de verificación de correo electrónico", "forgotPasswordTemplate": "Plantilla de contraseña olvidada", "notificationTemplate": "Plantilla de notificación", "organizationInviteTemplate": "Plantilla de invitación de organización", diff --git a/src/locales/fr/common.json b/src/locales/fr/common.json index 66383e38..a22cea82 100644 --- a/src/locales/fr/common.json +++ b/src/locales/fr/common.json @@ -191,6 +191,13 @@ "mfaRecoveryResetTitle": "Récupération 2FA", "mfaRecoveryResetMessage": "Veuillez entrer vos identifiants et le code de récupération", "backToLogin": "Retour à la connexion" + }, + "emailVerification": { + "emailVerified": "E-mail vérifié", + "errorMessage": "Une erreur s'est produite lors de la vérification de votre e-mail.", + "successMessage": "Votre e-mail a été vérifié avec succès.", + "redirectMessage": "Vous serez redirigé automatiquement dans {seconds} secondes.", + "goNowButton": "Aller maintenant" } }, "networks": { @@ -448,6 +455,7 @@ "password": "Mot de passe", "useSSL": "Utiliser SSL", "inviteUserTemplate": "Modèle d'invitation d'utilisateur", + "emailVerificationTemplate": "Modèle de vérification d'e-mail", "forgotPasswordTemplate": "Modèle de mot de passe oublié", "notificationTemplate": "Modèle de notification", "organizationInviteTemplate": "Modèle d'invitation d'organisation", diff --git a/src/locales/no/common.json b/src/locales/no/common.json index 3a51a983..498f9c85 100644 --- a/src/locales/no/common.json +++ b/src/locales/no/common.json @@ -191,6 +191,13 @@ "mfaRecoveryResetTitle": "Gjenoppretting av 2FA", "mfaRecoveryResetMessage": "Vennligst skriv inn dine legitimasjonsbeskrivelser og gjenopprettingskode", "backToLogin": "Tilbake til innlogging" + }, + "emailVerification": { + "emailVerified": "E-post bekreftet", + "errorMessage": "Det oppstod en feil under verifisering av e-posten din.", + "successMessage": "E-posten din har blitt bekreftet.", + "redirectMessage": "Du vil bli videresendt automatisk om {seconds} sekunder.", + "goNowButton": "Gå nå" } }, "networks": { @@ -448,6 +455,7 @@ "password": "Passord", "useSSL": "Bruk SSL", "inviteUserTemplate": "Inviter bruker mal", + "emailVerificationTemplate": "E-post verifikasjons mal", "forgotPasswordTemplate": "Glemt passord mal", "notificationTemplate": "Varslings mal", "organizationInviteTemplate": "Organisasjons invitasjons mal", diff --git a/src/locales/pl/common.json b/src/locales/pl/common.json index c5ba494a..82a113e2 100644 --- a/src/locales/pl/common.json +++ b/src/locales/pl/common.json @@ -191,6 +191,13 @@ "mfaRecoveryResetTitle": "Odzyskiwanie 2FA", "mfaRecoveryResetMessage": "Wprowadź swoje dane uwierzytelniające i kod odzyskiwania", "backToLogin": "Powrót do strony logowania" + }, + "emailVerification": { + "emailVerified": "E-mail zweryfikowany", + "errorMessage": "Wystąpił błąd podczas weryfikacji twojego e-maila.", + "successMessage": "Twój e-mail został pomyślnie zweryfikowany.", + "redirectMessage": "Zostaniesz automatycznie przekierowany za {seconds} sekund.", + "goNowButton": "Przejdź teraz" } }, "networks": { @@ -448,6 +455,7 @@ "password": "Hasło", "useSSL": "Użyj SSL", "inviteUserTemplate": "Szablon - Zaproszenie użytkownika", + "emailVerificationTemplate": "Szablon - Weryfikacja e-mail", "forgotPasswordTemplate": "Szablon - Zapomniane hasło", "notificationTemplate": "Szablon - Powiadomienie", "organizationInviteTemplate": "Szablon - Zaproszenie do organizacji", @@ -923,4 +931,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/locales/zh-tw/common.json b/src/locales/zh-tw/common.json index 6c393e4a..8551bf3f 100644 --- a/src/locales/zh-tw/common.json +++ b/src/locales/zh-tw/common.json @@ -191,6 +191,13 @@ "mfaRecoveryResetTitle": "2FA恢復", "mfaRecoveryResetMessage": "請輸入您的憑證和恢復代碼", "backToLogin": "返回登錄" + }, + "emailVerification": { + "emailVerified": "電子郵件已驗證", + "errorMessage": "驗證電子郵件時發生錯誤。", + "successMessage": "您的電子郵件已成功驗證。", + "redirectMessage": "您將在 {seconds} 秒後自動重定向。", + "goNowButton": "立即前往" } }, "networks": { @@ -448,6 +455,7 @@ "password": "密碼", "useSSL": "使用SSL", "inviteUserTemplate": "邀請使用者模板", + "emailVerificationTemplate": "電子郵件驗證模板", "forgotPasswordTemplate": "忘記密碼模板", "notificationTemplate": "通知模板", "organizationInviteTemplate": "組織邀請模板", diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json index 453a15c3..f62ebe3d 100644 --- a/src/locales/zh/common.json +++ b/src/locales/zh/common.json @@ -191,6 +191,13 @@ "mfaRecoveryResetTitle": "2FA恢复", "mfaRecoveryResetMessage": "请输入您的凭据和恢复代码", "backToLogin": "返回登录" + }, + "emailVerification": { + "emailVerified": "邮箱已验证", + "errorMessage": "验证邮箱时发生错误。", + "successMessage": "您的邮箱已成功验证。", + "redirectMessage": "您将在 {seconds} 秒后自动重定向。", + "goNowButton": "立即前往" } }, "networks": { @@ -448,6 +455,7 @@ "password": "密码", "useSSL": "使用SSL", "inviteUserTemplate": "邀请用户模板", + "emailVerificationTemplate": "电子邮件验证模板", "forgotPasswordTemplate": "忘记密码模板", "notificationTemplate": "通知模板", "organizationInviteTemplate": "组织邀请模板", diff --git a/src/pages/admin/mail/index.tsx b/src/pages/admin/mail/index.tsx index 92b2cd7e..20ae87e8 100644 --- a/src/pages/admin/mail/index.tsx +++ b/src/pages/admin/mail/index.tsx @@ -15,6 +15,7 @@ import { import MenuSectionDividerWrapper from "~/components/shared/menuSectionDividerWrapper"; import NewDeviceNotificationTemplate from "~/components/adminPage/mail/mailNewDeviceNotificationTemplate"; import DeviceIpChangeNotificationTemplate from "~/components/adminPage/mail/mailDeviceIpChangeNotificationTemplate"; +import VerifyEmailTemplate from "~/components/adminPage/mail/mailVerifyEmail"; const Mail = () => { const t = useTranslations("admin"); @@ -166,6 +167,13 @@ const Mail = () => {
+
+ +
{t("mail.emailVerificationTemplate")}
+
+ +
+
{t("mail.forgotPasswordTemplate")}
diff --git a/src/pages/auth/verifyEmail/index.tsx b/src/pages/auth/verifyEmail/index.tsx new file mode 100644 index 00000000..bcc03807 --- /dev/null +++ b/src/pages/auth/verifyEmail/index.tsx @@ -0,0 +1,129 @@ +import { useRouter } from "next/router"; +import { api } from "~/utils/api"; +import { Session } from "next-auth"; +import { toast } from "react-hot-toast"; +import Head from "next/head"; +import { globalSiteTitle } from "~/utils/global"; +import { ErrorCode } from "~/utils/errorCode"; +import { useTranslations } from "next-intl"; +import { GetServerSideProps, GetServerSidePropsContext } from "next"; +import { getSession } from "next-auth/react"; +import { useState, useEffect } from "react"; + +const title = `${globalSiteTitle} - VerifyEmail`; + +const VerifyEmail = () => { + const t = useTranslations(); + const router = useRouter(); + const { token } = router.query; + const [redirectCountdown, setRedirectCountdown] = useState(5); + + const { data: tokenData, isLoading: validateTokenLoading } = + api.auth.validateEmailVerificationToken.useQuery( + { + token: token as string, + }, + { + enabled: !!token, + onSuccess: (response) => { + if (response?.error) { + switch (response.error) { + case ErrorCode.InvalidToken: + void router.push("/auth/login"); + break; + case ErrorCode.TooManyRequests: + toast.error("Too many requests, please try again later"); + break; + default: + toast.error(response.error); + } + } + }, + onError: (error) => { + toast.error(error.message); + }, + }, + ); + + useEffect(() => { + if (!validateTokenLoading && tokenData && !tokenData.error) { + const timer = setInterval(() => { + setRedirectCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer); + void router.push("/user-settings/?tab=account"); + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + } + }, [validateTokenLoading, tokenData, router]); + + if (validateTokenLoading || !tokenData || tokenData?.error) { + return null; + } + + return ( +
+ + {title} + + + +
+
+
+

+ {tokenData.error ? "Error" : t("authPages.emailVerification.emailVerified")} +

+

+ {tokenData.error + ? t("authPages.emailVerification.errorMessage") + : t("authPages.emailVerification.successMessage")} +

+ {!tokenData.error && ( +

+ {t.rich("authPages.emailVerification.redirectMessage", { + seconds: redirectCountdown, + })} +

+ )} +
+ {!tokenData.error && ( +
+ +
+ )} +
+ Copyright © {new Date().getFullYear()} Kodea Solutions +
+
+
+
+ ); +}; + +interface Props { + auth?: Session["user"]; +} + +export const getServerSideProps: GetServerSideProps = async ( + context: GetServerSidePropsContext, +) => { + const session = await getSession(context); + return { + props: { + auth: session?.user || null, + messages: (await import(`~/locales/${context.locale}/common.json`)).default, + }, + }; +}; + +export default VerifyEmail; diff --git a/src/pages/user-settings/account/index.tsx b/src/pages/user-settings/account/index.tsx index 775600bf..9e322486 100644 --- a/src/pages/user-settings/account/index.tsx +++ b/src/pages/user-settings/account/index.tsx @@ -17,6 +17,10 @@ import DisableTwoFactSetupModal from "~/components/auth/totpDisable"; import MultifactorNotEnabled from "~/components/auth/multifactorNotEnabledAlert"; import MenuSectionDividerWrapper from "~/components/shared/menuSectionDividerWrapper"; import ListUserDevices from "~/components/auth/userDevices"; +import { + useTrpcApiErrorHandler, + useTrpcApiSuccessHandler, +} from "~/hooks/useTrpcApiHandler"; const defaultLocale = "en"; @@ -25,17 +29,28 @@ const Account = () => { const { asPath, locale, locales, push } = useRouter(); const t = useTranslations(); - const { data: me, refetch: refetchMe } = api.auth.me.useQuery(); + const handleApiError = useTrpcApiErrorHandler(); + const handleApiSuccess = useTrpcApiSuccessHandler(); + + const { data: me, refetch: refetchMe, isLoading: meLoading } = api.auth.me.useQuery(); const { data: session, update: sessionUpdate } = useSession(); - const { mutate: userUpdate, error: userError } = api.auth.update.useMutation(); + + const { mutate: userUpdate, error: userError } = api.auth.update.useMutation({ + onError: handleApiError, + onSuccess: handleApiSuccess({ actions: [] }), + }); const { mutate: updateZtApi } = api.auth.setZtApi.useMutation({ - onError: (error) => { - toast.error(error.message); - }, + onError: handleApiError, }); + const { mutate: sendVerificationEmail, isLoading: sendMailLoading } = + api.auth.sendVerificationEmail.useMutation({ + onError: handleApiError, + onSuccess: handleApiSuccess({ actions: [] }), + }); + const ChangeLanguage = async (locale: string) => { if (locale === "default") { localStorage.removeItem("ztnet-language"); @@ -61,7 +76,6 @@ const Account = () => { if (userError) { toast.error(userError.message); } - return (
{ isLoading={!session?.user} rootFormClassName="space-y-3 w-6/6 sm:w-3/6" size="sm" - // badge={ - // session?.user?.emailVerified - // ? { - // text: t("userSettings.account.accountSettings.verifiedBadge"), - // color: "success", - // } - // : { - // text: t("userSettings.account.accountSettings.notVerifiedBadge"), - // color: "warning", - // } - // } + badge={ + meLoading || sendMailLoading + ? { + text: "loading", + color: "ghost", + } + : me?.emailVerified + ? { + text: t("userSettings.account.accountSettings.verifiedBadge"), + color: "success", + } + : { + text: "Not verified, click to resend", + color: "warning", + onClick: sendVerificationEmail, + } + } fields={[ { name: "email", @@ -107,7 +127,10 @@ const Account = () => { value: session?.user?.email, }, ]} - submitHandler={async (params) => await sessionUpdate({ update: { ...params } })} + submitHandler={async (params) => { + await sessionUpdate({ update: { ...params } }); + return refetchMe(); + }} />
diff --git a/src/server/api/routers/adminRoute.ts b/src/server/api/routers/adminRoute.ts index bee0574a..5b5705ca 100644 --- a/src/server/api/routers/adminRoute.ts +++ b/src/server/api/routers/adminRoute.ts @@ -1,16 +1,7 @@ import { createTRPCRouter, adminRoleProtectedRoute } from "~/server/api/trpc"; import { z } from "zod"; import * as ztController from "~/utils/ztApi"; -import { - deviceIpChangeNotificationTemplate, - forgotPasswordTemplate, - inviteOrganizationTemplate, - inviteUserTemplate, - mailTemplateMap, - newDeviceNotificationTemplate, - notificationTemplate, - sendMailWithTemplate, -} from "~/utils/mail"; +import { mailTemplateMap, sendMailWithTemplate } from "~/utils/mail"; import { GlobalOptions, Role } from "@prisma/client"; import { throwError } from "~/server/helpers/errorHandler"; import { type ZTControllerNodeStatus } from "~/types/ztController"; @@ -381,16 +372,8 @@ export const adminRouter = createTRPCRouter({ id: 1, }, }); - const templateMap = { - inviteUserTemplate, - forgotPasswordTemplate, - notificationTemplate, - inviteOrganizationTemplate, - newDeviceNotificationTemplate, - deviceIpChangeNotificationTemplate, - }; - return JSON.parse(templates?.[input.template]) ?? templateMap[input.template](); + return JSON.parse(templates?.[input.template]) ?? mailTemplateMap[input.template](); }), setMail: adminRoleProtectedRoute @@ -446,16 +429,7 @@ export const adminRouter = createTRPCRouter({ }), ) .mutation(({ input }) => { - const templateMap = { - inviteUserTemplate, - forgotPasswordTemplate, - notificationTemplate, - inviteOrganizationTemplate, - newDeviceNotificationTemplate, - deviceIpChangeNotificationTemplate, - }; - - return templateMap[input.template](); + return mailTemplateMap[input.template](); }), sendTestMail: adminRoleProtectedRoute .input( @@ -494,6 +468,9 @@ export const adminRouter = createTRPCRouter({ expirationTime: "24 hours", actionRequired: "Please verify your email address", customMessage: "This is a custom message for testing purposes", + + // verifyEmailTemplate specific tags + verifyLink: "https://ztnet.network/verify-email", }; await sendMailWithTemplate(mailTemplateMap[type], { diff --git a/src/server/api/routers/authRouter.ts b/src/server/api/routers/authRouter.ts index 7ed196bd..63b3b541 100644 --- a/src/server/api/routers/authRouter.ts +++ b/src/server/api/routers/authRouter.ts @@ -13,11 +13,13 @@ import { forgotPasswordTemplate, notificationTemplate, sendMailWithTemplate, + verifyEmailTemplate, } from "~/utils/mail"; import * as ztController from "~/utils/ztApi"; import { API_TOKEN_SECRET, PASSWORD_RESET_SECRET, + VERIFY_EMAIL_SECRET, encrypt, generateInstanceSecret, } from "~/utils/encryption"; @@ -620,6 +622,104 @@ export const authRouter = createTRPCRouter({ throwError("token is not valid, please try again!"); } }), + sendVerificationEmail: protectedProcedure.mutation(async ({ ctx }) => { + // add cooldown to prevent spam + try { + await limiter.check(ctx.res, SHORT_REQUEST_LIMIT, "SEND_EMAIL_VERIFICATION"); + } catch { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Rate limit exceeded", + }); + } + const user = await ctx.prisma.user.findFirst({ + where: { + id: ctx.session.user.id, + }, + }); + + if (!user) return { message: "Internal Error" }; + if (user.emailVerified) return { message: "Email is already verified!" }; + + const secret = generateInstanceSecret(VERIFY_EMAIL_SECRET); + const validationToken = jwt.sign( + { + id: user.id, + email: user.email, + }, + secret, + { + expiresIn: "15m", + }, + ); + + const verifyLink = `${process.env.NEXTAUTH_URL}/auth/verifyEmail?token=${validationToken}`; + // Send email + try { + await sendMailWithTemplate(verifyEmailTemplate, { + to: user.email, + userId: user.id, + templateData: { + toName: user.name, + verifyLink: verifyLink, + }, + }); + } catch (error) { + console.error("Failed to send verification email:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: error.message, + }); + } + + return { message: "Verification link has been sent." }; + }), + validateEmailVerificationToken: publicProcedure + .input( + z.object({ + token: z.string({ required_error: "Token is required!" }), + }), + ) + .query(async ({ ctx, input }) => { + // add rate limit + try { + await limiter.check(ctx.res, SHORT_REQUEST_LIMIT, "EMAIL_VERIFICATION_LINK"); + } catch { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Rate limit exceeded", + }); + } + + const { token } = input; + if (!token) return { error: ErrorCode.InvalidToken }; + try { + const secret = generateInstanceSecret(VERIFY_EMAIL_SECRET); + const decoded = jwt.verify(token, secret) as { id: string; email: string }; + + const user = await ctx.prisma.user.findFirst({ + where: { + id: decoded.id, + email: decoded.email, + }, + }); + + if (!user || user.emailVerified) return { error: ErrorCode.InvalidToken }; + + // set emailVerified to true + await ctx.prisma.user.update({ + where: { + id: user.id, + }, + data: { + emailVerified: new Date().toISOString(), + }, + }); + return { message: "Email verified successfully!" }; + } catch (_error) { + return { error: ErrorCode.InvalidToken }; + } + }), /** * Update the specified NetworkMemberNotation instance. * diff --git a/src/server/callbacks/jwt.ts b/src/server/callbacks/jwt.ts index 54e8f030..85b3d6fe 100644 --- a/src/server/callbacks/jwt.ts +++ b/src/server/callbacks/jwt.ts @@ -8,18 +8,15 @@ export function jwtCallback( return async function jwt({ token, user, trigger, account, session }) { if (trigger === "update") { if (session.update) { + const updateObject: Record = {}; + const user = await prisma.user.findFirst({ where: { id: token.id, }, select: { - id: true, - name: true, email: true, - role: true, emailVerified: true, - lastLogin: true, - lastseen: true, }, }); @@ -36,6 +33,7 @@ export function jwtCallback( // throw new Error("Name must be at least one character long."); // } token.name = session.update.name; + updateObject.name = session.update.name; } // verify that email is valid @@ -43,6 +41,9 @@ export function jwtCallback( // eslint-disable-next-line @typescript-eslint/no-unsafe-call token.email = session.update.email; + updateObject.email = session.update.email; + updateObject.emailVerified = + user?.email === session.update.email ? user.emailVerified : null; } // update user with new values @@ -50,10 +51,7 @@ export function jwtCallback( where: { id: token.id as string, }, - data: { - email: session.update.email || user.email, - name: session.update.name || user.name, - }, + data: updateObject, }); } return token; diff --git a/src/utils/encryption.ts b/src/utils/encryption.ts index 5118afbc..4cabfcb5 100644 --- a/src/utils/encryption.ts +++ b/src/utils/encryption.ts @@ -9,6 +9,7 @@ export const API_TOKEN_SECRET = "_ztnet_api_token"; export const ORG_API_TOKEN_SECRET = "_ztnet_organization_api_token"; export const ORG_INVITE_TOKEN_SECRET = "_ztnet_org_invite"; export const PASSWORD_RESET_SECRET = "_ztnet_passwd_reset"; +export const VERIFY_EMAIL_SECRET = "_ztnet_email_verify"; export const TOTP_MFA_TOKEN_SECRET = "_ztnet_mfa_totp_token"; // Generate instance specific auth secret using salt diff --git a/src/utils/mail.ts b/src/utils/mail.ts index 248a3d7e..d8bd18e5 100644 --- a/src/utils/mail.ts +++ b/src/utils/mail.ts @@ -48,6 +48,19 @@ export const forgotPasswordTemplate = () => { }; }; +export const verifyEmailTemplate = () => { + return { + body: + "Hi <%= toName %>,

" + + "Welcome to ZTNET!

" + + "Please verify your email address by clicking the link below:
<%= verifyLink %>

" + + "Please note, this link is valid for 15 minutes. If it expires, you will need to request a new one.
" + + "If you did not create an account, please ignore this message.

" + + "Sincerely,
--
ZTNET", + subject: "Verify Your Email Address", + }; +}; + export const notificationTemplate = () => { return { body: @@ -99,6 +112,7 @@ export const newDeviceNotificationTemplate = () => { export const mailTemplateMap = { inviteUserTemplate, forgotPasswordTemplate, + verifyEmailTemplate, notificationTemplate, inviteOrganizationTemplate, newDeviceNotificationTemplate,