Skip to content
This repository has been archived by the owner on Sep 29, 2024. It is now read-only.

feat: created verification email and token #269

Merged
merged 7 commits into from
Aug 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions client/src/pages/auth/route-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down Expand Up @@ -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;
192 changes: 192 additions & 0 deletions client/src/pages/auth/verification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* SPDX-FileCopyrightText: 2024 Ng Jun Xiang <[email protected]>
*
* 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<auth.ActivateSuccAPI, z.infer<typeof activateFormSchema>>({
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<typeof recreateTokenSchema>
>({
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 (
<Navigate
to={params.get('callbackURI') ?? (isAdmin ? '/admin' : '/home')}
/>
);
}

return (
<AuthLayout
{...props}
title="Verify your account"
subTitle={
<>
Please check your email for the verification link.
<br />
メールを確認してください
</>
}
>
<div className="my-auto text-center">
{/* Header */}
<h1 className="mb-4 text-3xl font-bold">Activate your account</h1>

{/* Sub header */}
<h3 className="mb-32 text-lg">
Please check your email for the activation link.
</h3>

{/* Statuses */}
<div
className={cn('transition-all', {
hidden: tokenOrActivate === 'verify',
})}
>
{isFetching && (
<div className="flex flex-row items-center justify-center gap-2">
<SymbolIcon className="mr-2 size-6 animate-spin" />
Verifying your account... Please wait.
</div>
)}

{isSuccess && (
<div className="flex flex-row items-center justify-center gap-2">
<CheckCircledIcon className="mr-2 size-6 animate-none text-green-500" />
Your account has been verified. Please login.
</div>
)}

{isError && (
<div className="flex flex-row items-center justify-center gap-2">
<CrossCircledIcon className="mr-2 size-6 text-red-500" />
Failed to verify your account. Please try again later.
</div>
)}
</div>

{/* Resend token */}
<Button
type="button"
onClick={() => mutate()}
disabled={isFetching || isPending}
className="mx-auto w-fit"
>
{isPending ? 'Resending...' : 'Resend activation token'}
</Button>

<div className="h-16 w-4">
<span className="hidden">Dummy to force main content up a bit</span>
</div>
</div>
</AuthLayout>
);
};
export default VerifyWithTokenPage;
45 changes: 39 additions & 6 deletions server/src/utils/service/email/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -20,14 +23,12 @@ type EmailWithSenderLike = `${string} <${EmailLike}>`;
interface IEmailOptions {
type: string;
}
export type EmailOptions = IEmailOptions & ActivationEmailProps;

interface IEmailDetails {
interface IEmailDetails<T extends IEmailOptions = IEmailOptions> {
from: EmailWithSenderLike;
to: string;
subject?: string;
text?: string;
options: EmailOptions;
options: T;
}

// Functions
Expand All @@ -38,7 +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 as VerificationEmailProps,
);
break;

default:
Expand All @@ -56,7 +65,12 @@ export const sendEmail = (

export const sendActivationEmail = (
caffeine-addictt marked this conversation as resolved.
Show resolved Hide resolved
details: NestedOmit<
Omit<IEmailDetails, 'from' | 'subject' | 'text'>,
Omit<
IEmailDetails<IEmailOptions & ActivationEmailProps>,
'from' | 'subject' | 'text'
> & {
options: ActivationEmailProps;
},
'options.type'
>,
): ReturnType<typeof sendEmail> =>
Expand All @@ -70,3 +84,22 @@ export const sendActivationEmail = (
type: 'activation',
},
});

export const sendVerificationEmail = (
details: Omit<
IEmailDetails<IEmailOptions & VerificationEmailProps>,
'from' | 'subject' | 'text'
> & {
options: VerificationEmailProps;
},
): ReturnType<typeof sendEmail> =>
sendEmail({
from: 'GreenBites SG <[email protected]>',
to: details.to,
subject: 'Verify Your Account',
text: `Your verification code is: ${details.options.verificationLink}`,
options: {
...details.options,
type: 'verification',
},
});
78 changes: 78 additions & 0 deletions server/src/utils/service/email/templates/verification-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* SPDX-FileCopyrightText: 2024 Ng Jun Xiang <[email protected]>
*
* SPDX-License-Identifier: GPL-3.0-only
*/

export interface VerificationEmailProps {
type: 'verification';
name: string;
verificationLink: string;
}

const generateVerificationEmail = ({
name,
verificationLink,
}: VerificationEmailProps): string => {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: Arial, sans-serif;
background-color: #f6f6f6;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.header {
text-align: center;
padding-bottom: 20px;
}
.button {
background-color: #007bff;
color: white;
padding: 10px 20px;
text-decoration: none;
border-radius: 4px;
display: inline-block;
margin-top: 20px;
}
.footer {
text-align: center;
color: #888888;
font-size: 12px;
padding-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Hello, ${name}!</h2>
</div>
<div>
<p>To verify your username, please click the button below:</p>
<a href="${verificationLink}" class="button">Verify Email</a>
<p>If you did not request this verification, please ignore this email.</p>
</div>
<div class="footer">
<p>&copy; ${new Date().getFullYear()} Green Bites SG. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
};

export default generateVerificationEmail;

This file was deleted.

Loading