From 7e1613a5b53a2add7ab4237bfd4d2857e1ce4f2b Mon Sep 17 00:00:00 2001 From: ArshiLamba Date: Mon, 12 Aug 2024 14:24:13 +0800 Subject: [PATCH 1/6] feat: created verification email and token --- server/src/utils/service/email/email.ts | 30 ++++++- .../email/templates/verification-code.ts | 78 +++++++++++++++++++ .../email/templates/verification-code.tsx | 5 -- server/src/v1/user.ts | 30 ++++++- 4 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 server/src/utils/service/email/templates/verification-code.ts delete mode 100644 server/src/utils/service/email/templates/verification-code.tsx diff --git a/server/src/utils/service/email/email.ts b/server/src/utils/service/email/email.ts index a91f3070..0dbe9a93 100644 --- a/server/src/utils/service/email/email.ts +++ b/server/src/utils/service/email/email.ts @@ -9,6 +9,9 @@ import { Resend } from 'resend'; import generateActivationEmail, { ActivationEmailProps, } from './templates/account-activation'; +import generateVerificationEmail, { + VerificationEmailProps, +} from './templates/verification-code'; import { NestedOmit } from '@src/utils/types'; const resend = new Resend(process.env.RESEND_API_KEY); @@ -20,7 +23,8 @@ type EmailWithSenderLike = `${string} <${EmailLike}>`; interface IEmailOptions { type: string; } -export type EmailOptions = IEmailOptions & ActivationEmailProps; +export type EmailOptions = IEmailOptions & + (ActivationEmailProps | VerificationEmailProps); interface IEmailDetails { from: EmailWithSenderLike; @@ -41,6 +45,10 @@ export const sendEmail = ( generated = generateActivationEmail(details.options); break; + case 'verification': + generated = generateVerificationEmail(details.options); + break; + default: throw new Error('Invalid email type'); } @@ -56,7 +64,9 @@ export const sendEmail = ( export const sendActivationEmail = ( details: NestedOmit< - Omit, + Omit & { + options: ActivationEmailProps; + }, 'options.type' >, ): ReturnType => @@ -70,3 +80,19 @@ export const sendActivationEmail = ( type: 'activation', }, }); + +export const sendVerificationEmail = ( + details: Omit & { + options: VerificationEmailProps; + }, +): ReturnType => + sendEmail({ + from: 'GreenBites SG ', + to: details.to, + subject: 'Your Verification Code', + text: `Your verification code is: ${details.options.verificationLink}`, + options: { + ...details.options, + type: 'verification', + }, + }); diff --git a/server/src/utils/service/email/templates/verification-code.ts b/server/src/utils/service/email/templates/verification-code.ts new file mode 100644 index 00000000..51c410e2 --- /dev/null +++ b/server/src/utils/service/email/templates/verification-code.ts @@ -0,0 +1,78 @@ +/** + * SPDX-FileCopyrightText: 2024 Ng Jun Xiang + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +export interface VerificationEmailProps { + type: 'verification'; + name: string; + verificationLink: string; // Change from verificationCode to verificationLink +} + +const generateVerificationEmail = ({ + name, + verificationLink, +}: VerificationEmailProps): string => { + return ` + + + + + + + + +
+
+

Hello, ${name}!

+
+
+

To verify your username, please click the button below:

+ Verify Email +

If you did not request this verification, please ignore this email.

+
+ +
+ + + `; +}; + +export default generateVerificationEmail; diff --git a/server/src/utils/service/email/templates/verification-code.tsx b/server/src/utils/service/email/templates/verification-code.tsx deleted file mode 100644 index 726b8949..00000000 --- a/server/src/utils/service/email/templates/verification-code.tsx +++ /dev/null @@ -1,5 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Ng Jun Xiang - * - * SPDX-License-Identifier: GPL-3.0-only - */ diff --git a/server/src/v1/user.ts b/server/src/v1/user.ts index f8ca7750..02b6055e 100644 --- a/server/src/v1/user.ts +++ b/server/src/v1/user.ts @@ -21,7 +21,9 @@ import type { import { db } from '../db'; import { eq, and } from 'drizzle-orm'; -import { usersTable, passkeysTable } from '../db/schemas'; +import { usersTable, passkeysTable, tokens } from '../db/schemas'; +import { sendVerificationEmail } from '../utils/service/email/email'; +import { getFullPath } from '../utils/app'; export const getUser: IAuthedRouteHandler = async (req, res) => { return res.status(200).json({ @@ -73,7 +75,33 @@ export const updateUser: IAuthedRouteHandler = async (req, res) => { newUserData.activated = false; // TODO: Create verification token (follow auth register) + const createdToken = await db + .insert(tokens) + .values({ + userId: req.user.id, + tokenType: 'verification', + }) + .returning({ token: tokens.token }); // TODO: Send verification email + const sendEmail = await sendVerificationEmail({ + to: validated.data.email, + options: { + type: 'verification', + name: validated.data.username || '', // Comma added here + verificationLink: getFullPath(`/verify/${createdToken[0].token}`), + }, + }).catch((err) => + console.error( + `ERR Failed to send verification email for user [${req.user.id}]: ${err}`, // Adjusted to use req.user.id instead of createdUser[0].id + ), + ); + + if (!sendEmail) { + return res.status(Http4XX.UNPROCESSABLE_ENTITY).json({ + status: Http4XX.UNPROCESSABLE_ENTITY, + errors: [{ message: 'Email could not be reached' }], + } satisfies UpdateUserFailAPI); + } } await db From 35682037a469abd920e182bc4407cef989d3ae2f Mon Sep 17 00:00:00 2001 From: ArshiLamba Date: Mon, 12 Aug 2024 16:32:30 +0800 Subject: [PATCH 2/6] fix: fix pr issues --- client/src/pages/auth/route-map.tsx | 13 ++ client/src/pages/auth/verification.tsx | 192 ++++++++++++++++++ server/src/utils/service/email/email.ts | 2 +- .../email/templates/verification-code.ts | 2 +- server/src/v1/user.ts | 3 +- 5 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 client/src/pages/auth/verification.tsx diff --git a/client/src/pages/auth/route-map.tsx b/client/src/pages/auth/route-map.tsx index 5f25c09f..31445546 100644 --- a/client/src/pages/auth/route-map.tsx +++ b/client/src/pages/auth/route-map.tsx @@ -17,6 +17,7 @@ import RegisterPage from './legacy-register'; import ActivatePage from './activate'; import { PasskeyLoginPage } from './passkey'; import RedirectToAuth from './auth-redirect'; +import VerifyPage from './verification'; const authRouteMap: RouteMap = { '/register': { @@ -86,5 +87,17 @@ const authRouteMap: RouteMap = { component: ActivatePage, accessLevel: 'authenticated', }, + '/verify': { + title: 'Verify Account', + description: 'Verify your Account', + component: VerifyPage, + accessLevel: 'authenticated', + }, + '/verify/:token': { + title: 'Verify Account', + description: 'Verify your Account', + component: VerifyPage, + accessLevel: 'authenticated', + }, } as const; export default authRouteMap; diff --git a/client/src/pages/auth/verification.tsx b/client/src/pages/auth/verification.tsx new file mode 100644 index 00000000..8934407e --- /dev/null +++ b/client/src/pages/auth/verification.tsx @@ -0,0 +1,192 @@ +/** + * SPDX-FileCopyrightText: 2024 Ng Jun Xiang + * + * SPDX-License-Identifier: GPL-3.0-only + */ + +import * as React from 'react'; +import AuthLayout from './auth-layout'; +import type { PageComponent } from '@pages/route-map'; +import { + Navigate, + useLocation, + useNavigate, + useSearchParams, +} from 'react-router-dom'; + +import * as z from 'zod'; +import httpClient from '@utils/http'; +import { auth } from '@lib/api-types'; +import { AuthContext } from '@service/auth'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + activateFormSchema, + recreateTokenSchema, +} from '@lib/api-types/schemas/auth'; + +import { cn } from '@utils/tailwind'; +import { + SymbolIcon, + CheckCircledIcon, + CrossCircledIcon, +} from '@radix-ui/react-icons'; +import { AxiosError, isAxiosError } from 'axios'; +import { Button } from '@components/ui/button'; +import { useToast } from '@components/ui/use-toast'; + +// Page +const VerifyWithTokenPage: PageComponent = (props): React.JSX.Element => { + const { toast } = useToast(); + const { isActivated, isAdmin } = React.useContext(AuthContext)!; + + const [params] = useSearchParams(); + const location = useLocation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const tokenOrActivate = location.pathname + .split('/') + .at(location.pathname.endsWith('/') ? -2 : -1)!; + + // Handle verify + const { isFetching, isSuccess, isError } = useQuery( + { + queryKey: ['verify'], + queryFn: () => + httpClient + .post>({ + uri: '/auth/activate', + payload: { + token: tokenOrActivate, + }, + withCredentials: 'access', + }) + .then(() => { + toast({ + title: 'Account activated', + description: 'Your account has been activated.', + }); + navigate( + params.get('callbackURI') ?? (isAdmin ? '/admin' : '/home'), + ); + }) + .catch((err: AxiosError) => console.log(err)), + retry: 5, + retryDelay: (failureCount) => 2 ** failureCount + 10, + enabled: tokenOrActivate !== 'verify', + }, + queryClient, + ); + + // Handle resend token + const { mutate, isPending } = useMutation( + { + mutationKey: ['resend-verification'], + mutationFn: () => + httpClient.post< + auth.RecreateTokenSuccAPI, + z.infer + >({ + uri: '/auth/recreate-token', + withCredentials: 'access', + payload: { + token_type: 'verification', + }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['verify'] }); + toast({ + title: 'Verification token sent', + description: 'Please check your email for the verification link.', + }); + }, + onError: (e: AxiosError | Error) => { + console.log(e); + toast({ + title: 'Failed to resend token', + description: isAxiosError(e) + ? (e.response?.data as auth.RecreateTokenFailAPI).errors[0].message + : e.message, + variant: 'destructive', + }); + }, + }, + queryClient, + ); + + // Redirect if already activated + if (isActivated || isSuccess) { + return ( + + ); + } + + return ( + + Please check your email for the verification link. +
+ メールを確認してください + + } + > +
+ {/* Header */} +

Activate your account

+ + {/* Sub header */} +

+ Please check your email for the activation link. +

+ + {/* Statuses */} +
+ {isFetching && ( +
+ + Verifying your account... Please wait. +
+ )} + + {isSuccess && ( +
+ + Your account has been verified. Please login. +
+ )} + + {isError && ( +
+ + Failed to verify your account. Please try again later. +
+ )} +
+ + {/* Resend token */} + + +
+ Dummy to force main content up a bit +
+
+
+ ); +}; +export default VerifyWithTokenPage; diff --git a/server/src/utils/service/email/email.ts b/server/src/utils/service/email/email.ts index 0dbe9a93..46b9f33e 100644 --- a/server/src/utils/service/email/email.ts +++ b/server/src/utils/service/email/email.ts @@ -89,7 +89,7 @@ export const sendVerificationEmail = ( sendEmail({ from: 'GreenBites SG ', to: details.to, - subject: 'Your Verification Code', + subject: 'Verify Your Account', text: `Your verification code is: ${details.options.verificationLink}`, options: { ...details.options, diff --git a/server/src/utils/service/email/templates/verification-code.ts b/server/src/utils/service/email/templates/verification-code.ts index 51c410e2..5de1eb43 100644 --- a/server/src/utils/service/email/templates/verification-code.ts +++ b/server/src/utils/service/email/templates/verification-code.ts @@ -7,7 +7,7 @@ export interface VerificationEmailProps { type: 'verification'; name: string; - verificationLink: string; // Change from verificationCode to verificationLink + verificationLink: string; } const generateVerificationEmail = ({ diff --git a/server/src/v1/user.ts b/server/src/v1/user.ts index 02b6055e..768e0fb4 100644 --- a/server/src/v1/user.ts +++ b/server/src/v1/user.ts @@ -74,7 +74,6 @@ export const updateUser: IAuthedRouteHandler = async (req, res) => { if (validated.data.email) { newUserData.activated = false; - // TODO: Create verification token (follow auth register) const createdToken = await db .insert(tokens) .values({ @@ -82,7 +81,7 @@ export const updateUser: IAuthedRouteHandler = async (req, res) => { tokenType: 'verification', }) .returning({ token: tokens.token }); - // TODO: Send verification email + const sendEmail = await sendVerificationEmail({ to: validated.data.email, options: { From 6df96109920c8b5a99291082c4afed54a8457371 Mon Sep 17 00:00:00 2001 From: AlexNg Date: Mon, 12 Aug 2024 17:32:53 +0800 Subject: [PATCH 3/6] feat: Convert IEmailDetails to generic Signed-off-by: AlexNg --- server/src/utils/service/email/email.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/server/src/utils/service/email/email.ts b/server/src/utils/service/email/email.ts index 46b9f33e..fb651900 100644 --- a/server/src/utils/service/email/email.ts +++ b/server/src/utils/service/email/email.ts @@ -23,15 +23,12 @@ type EmailWithSenderLike = `${string} <${EmailLike}>`; interface IEmailOptions { type: string; } -export type EmailOptions = IEmailOptions & - (ActivationEmailProps | VerificationEmailProps); - -interface IEmailDetails { +interface IEmailDetails { from: EmailWithSenderLike; to: string; subject?: string; text?: string; - options: EmailOptions; + options: T; } // Functions @@ -42,11 +39,15 @@ export const sendEmail = ( switch (details.options.type) { case 'activation': - generated = generateActivationEmail(details.options); + generated = generateActivationEmail( + details.options as ActivationEmailProps, + ); break; case 'verification': - generated = generateVerificationEmail(details.options); + generated = generateVerificationEmail( + details.options as VerificationEmailProps, + ); break; default: @@ -64,7 +65,10 @@ export const sendEmail = ( export const sendActivationEmail = ( details: NestedOmit< - Omit & { + Omit< + IEmailDetails, + 'from' | 'subject' | 'text' + > & { options: ActivationEmailProps; }, 'options.type' @@ -82,7 +86,10 @@ export const sendActivationEmail = ( }); export const sendVerificationEmail = ( - details: Omit & { + details: Omit< + IEmailDetails, + 'from' | 'subject' | 'text' + > & { options: VerificationEmailProps; }, ): ReturnType => From 6caf5ab369cd94bb704c1a0140e7c04d4cbb52e8 Mon Sep 17 00:00:00 2001 From: ArshiLamba Date: Mon, 12 Aug 2024 17:48:56 +0800 Subject: [PATCH 4/6] fix: remove unnecessary comments from user.ts --- server/src/v1/user.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/server/src/v1/user.ts b/server/src/v1/user.ts index 768e0fb4..4e8bdd4d 100644 --- a/server/src/v1/user.ts +++ b/server/src/v1/user.ts @@ -41,7 +41,6 @@ export const getUser: IAuthedRouteHandler = async (req, res) => { }; export const updateUser: IAuthedRouteHandler = async (req, res) => { - // Validate request body const validated = schemas.user.userUpdateSchema.safeParse(req.body); if (!validated.success) { const errorStack: errors.CustomErrorContext[] = []; @@ -86,12 +85,12 @@ export const updateUser: IAuthedRouteHandler = async (req, res) => { to: validated.data.email, options: { type: 'verification', - name: validated.data.username || '', // Comma added here + name: validated.data.username || '', verificationLink: getFullPath(`/verify/${createdToken[0].token}`), }, }).catch((err) => console.error( - `ERR Failed to send verification email for user [${req.user.id}]: ${err}`, // Adjusted to use req.user.id instead of createdUser[0].id + `ERR Failed to send verification email for user [${req.user.id}]: ${err}`, ), ); From 64c887d3afa655c816d2a3b46a82d7d8bab7a42d Mon Sep 17 00:00:00 2001 From: ArshiLamba Date: Mon, 12 Aug 2024 17:58:47 +0800 Subject: [PATCH 5/6] fix: fix format errors --- server/src/v1/user.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/v1/user.ts b/server/src/v1/user.ts index 4e8bdd4d..744b2c3b 100644 --- a/server/src/v1/user.ts +++ b/server/src/v1/user.ts @@ -59,7 +59,7 @@ export const updateUser: IAuthedRouteHandler = async (req, res) => { errors: errorStack, } satisfies UpdateUserFailAPI); } - +// Sending verification email if (!validated.data.email && !validated.data.username) { return res.status(Http4XX.BAD_REQUEST).json({ status: Http4XX.BAD_REQUEST, From b0dbec84f24f1be29956328d56bf412c2ebfcf1b Mon Sep 17 00:00:00 2001 From: ArshiLamba Date: Mon, 12 Aug 2024 18:00:48 +0800 Subject: [PATCH 6/6] fix: fix linting errors --- server/src/v1/user.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/v1/user.ts b/server/src/v1/user.ts index 744b2c3b..28330412 100644 --- a/server/src/v1/user.ts +++ b/server/src/v1/user.ts @@ -59,7 +59,7 @@ export const updateUser: IAuthedRouteHandler = async (req, res) => { errors: errorStack, } satisfies UpdateUserFailAPI); } -// Sending verification email + // Sending verification email if (!validated.data.email && !validated.data.username) { return res.status(Http4XX.BAD_REQUEST).json({ status: Http4XX.BAD_REQUEST,