Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gabe/Email Verification #113

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
24 changes: 22 additions & 2 deletions components/user/ProgressStepper/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import Button from '@material-ui/core/Button';
import { useRouter } from 'next/router';
import { ApplicationStatus } from '@prisma/client';
import styles from './ProgressStepper.module.css';
import { useEffect, useState } from 'react';
import useSession from 'utils/useSession';

type ProgressStepperProps = {
applicationStatus?: ApplicationStatus;
Expand All @@ -14,6 +16,8 @@ const ProgressStepper: React.FC<ProgressStepperProps> = ({
orgId,
}) => {
const router = useRouter();
const [emailVerified, setVerified] = useState(false);
const [session, sessionLoading] = useSession();
let status: number;
switch (applicationStatus) {
case 'approved': {
Expand All @@ -38,10 +42,25 @@ const ProgressStepper: React.FC<ProgressStepperProps> = ({
}
}


const goToRegistration = (feedback?: boolean) => (): void => {
router.push(`/registration${feedback ? '?feedback=true' : ''}`);
};


useEffect(() : void => {
const checkVerification = async() => {
const res = await fetch(`/api/users/${session?.user.id}`, {
method: "GET"
});
if (res.ok) {
const user = await res.json();
setVerified(Boolean(user.emailVerified));
}
}
checkVerification();
}, [session]);

const appAction = (): JSX.Element | null => {
if (applicationStatus === 'draft') {
return (
Expand Down Expand Up @@ -128,13 +147,14 @@ const ProgressStepper: React.FC<ProgressStepperProps> = ({
return (
<>
<div className={`${styles.wording} ${styles.verifyMessage}`}>
Verify email address to begin application.
{emailVerified? '' : 'Verify email address to begin application.'}
</div>
<div className={styles.button}>
<Button
variant="contained"
color="primary"
onClick={goToRegistration()}
disabled={!emailVerified}
>
Begin Application
</Button>
Expand Down Expand Up @@ -180,4 +200,4 @@ const ProgressStepper: React.FC<ProgressStepperProps> = ({
);
};

export default ProgressStepper;
export default ProgressStepper;
43 changes: 43 additions & 0 deletions migrations/20210428050612-create-email-verification-table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Base from 'db-migrate-base';
import { promisify } from 'util';

/**
* Describe what your `up` migration does.
*/
export async function up(
db: Base,
callback: Base.CallbackFunction
): Promise<void> {
db.runSql(
`CREATE TABLE email_verifications
(
id uuid DEFAULT uuid_generate_v4(),
user_id INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
valid BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES users(id)
);`,
callback
);
}

/**
* Describe what your `down` migration does.
*/
export async function down(
db: Base,
callback: Base.CallbackFunction
): Promise<void> {
const dropTable = promisify<string>(db.dropTable.bind(db));
try {
await dropTable('email_verifications');
} catch (err) {
callback(err, null);
}
}

// eslint-disable-next-line no-underscore-dangle
export const _meta = {
version: 1,
};
84 changes: 84 additions & 0 deletions pages/api/emailVerification/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { NextApiRequest, NextApiResponse } from 'next';
import Joi from 'joi';
import { EmailVerification } from '@prisma/client';
import EmailNotifier from 'utils/notify';
import CreateError, { MethodNotAllowed } from 'utils/error';
import { NotificationType } from 'utils/notify/types';
import prisma from 'utils/prisma';

export type EmailVerificationDTO = {
email: string;
}

const generateEmailVerification = async (
user: EmailVerificationDTO
) : Promise<EmailVerification | null> => {
const userRecord = await prisma.user.findUnique({
where: { email: user.email}
});

if (!userRecord) {
throw new Error(`No account found with email: ${user.email}`);
}

const verificationRequest = await prisma.emailVerification.create({
data: {
users: { connect: { email: user.email}},
}
});

// Invalidate old verification requests
await prisma.emailVerification.updateMany({
data: {valid: false},
where: {
createdAt: { lt: verificationRequest.createdAt},
valid: true
}
});

if (!verificationRequest) {
return null;
}

// Send Email!
await EmailNotifier.sendNotification(NotificationType.VerificationLink, {
recipient: user.email,
verificationCode: verificationRequest.id,
});

return verificationRequest;
}

const handler = async(
req: NextApiRequest,
res: NextApiResponse
) : Promise<void> => {
try {
if (req.method !== 'POST') {
return MethodNotAllowed(req.method, res);
}

const expectedBody = Joi.object({
email: Joi.string().email().required(),
});

const { value, error } = expectedBody.validate(req.body);

if (error) {
return CreateError(400, error.message, res);
}

const body = value as EmailVerificationDTO;
const verificationData = await generateEmailVerification(body);

if (verificationData) {
res.status(200).json(verificationData);
} else {
return CreateError(400, 'Failed to generate email verification data', res);
}
} catch (err) {
return CreateError(500, err.message, res);
}
};

export default handler;
83 changes: 83 additions & 0 deletions pages/api/emailVerification/verify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { NextApiRequest, NextApiResponse } from 'next';
import Joi from 'joi';
import CreateError, { MethodNotAllowed } from 'utils/error';
import prisma from 'utils/prisma';
import sanitizeUser from 'utils/sanitizeUser';
import { SanitizedUser } from 'interfaces/user';

export type VerificationRequestDTO = {
verificationCode: string;
}

const validateCode = async (
request: VerificationRequestDTO
) : Promise<SanitizedUser> => {
if (Joi.string().uuid({ version: 'uuidv4'}).validate(request.verificationCode).error) {
throw new Error('Invalid Email Verification Code');
}

const verificationRecord = await prisma.emailVerification.findUnique({
where: { id: request.verificationCode},
});

if (!verificationRecord) {
throw new Error('Invalid Email Verification Code');
}

if (!verificationRecord.valid) {
throw new Error('Email Verification Link has expired or been used');
}

// update record
const updatedVerificationRecord = await prisma.emailVerification.update({
data: { valid: false },
where: { id: verificationRecord.id}
});

if (!verificationRecord) {
throw new Error('Verification Update Failed');
}

// update user
const updatedUserRecord = await prisma.user.update({
data: { emailVerified: new Date()},
where: { id: updatedVerificationRecord.userId}
});

if (!updatedUserRecord) {
throw new Error('Could not verify user\'s email');
}

return sanitizeUser(updatedUserRecord);
};

const handler = async(
req: NextApiRequest,
res: NextApiResponse,
) : Promise<void> => {
try {
if (req.method !== 'PATCH') {
return MethodNotAllowed(req.method, res);
}

const expectedBody = Joi.object({
verificationCode: Joi.string().required()
});

const { value, error } = expectedBody.validate(req.body);
if (error) {
return CreateError(400, error.message, res);
}

const body = value as VerificationRequestDTO;
const updatedUser = await validateCode(body);
if (updatedUser) {
return res.status(200).json(updatedUser);
}
return CreateError(404, 'Failed to verify email', res);
} catch (err) {
return CreateError(500, err.message, res);
}
};

export default handler;
47 changes: 47 additions & 0 deletions pages/signin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,48 @@ import { useRouter } from 'next/router';
import signInRedirect from 'utils/signInRedirect';
import { useSnackbar } from 'notistack';
import styles from '../styles/Auth.module.css';
import { useEffect, useState } from 'react';
import Toast from 'components/Toast';

type FormValues = {
email: string;
password: string;
};


// UserSignIn page. Will need additional email verification to be able to create organizations.
const UserSignIn: React.FC = () => {
// Get URL params for error callbacks.
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const [session, sessionLoading] = useSession();
const [toastMessage, setToast] = useState('');
const [toastType, setToastType] = useState<'success' | 'error'>('success');
const [errorBanner, setErrorBanner] = useState('');


useEffect(() => {
const validateCode = async() => {
if (router.query.verificationCode) {
const res = await fetch('/api/emailVerification/verify', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
verificationCode: router.query.verificationCode
}),
});
const resJson = await res.json();
if (resJson.error) {
setToastType('error');
setToast(resJson.error.message);
} else {
setToastType('success');
setToast('Email successfully verified! Please sign in to use your account');
}
}
}
validateCode();
}, [router.query]);

const handleSubmit = async (
values: FormValues,
Expand Down Expand Up @@ -106,10 +136,27 @@ const UserSignIn: React.FC = () => {
onSubmit: handleSubmit,
});

const renderToast = () : JSX.Element => {
return(
<Toast
snackbarProps={{
anchorOrigin: { vertical: 'top', horizontal: 'center' },
}}
type={toastType}
showDismissButton
>
<div>
{toastMessage}
</div>
</Toast>);
}

if (!sessionLoading && session) signInRedirect(router, session);

if (!sessionLoading && !session)
return (
<Layout title="Sign In">
{toastMessage.length > 0 ? renderToast() : null}
<div className={styles.root}>
<div className={styles.content}>
<div className={styles.titles}>
Expand Down
16 changes: 16 additions & 0 deletions pages/signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ const UserSignUp: React.FC = () => {
throw error;
}

// Send a verification email
const verificationRes = await fetch('/api/emailVerification', {
method: 'POST',
headers: {
'Content-Type' : 'application/json',
},
body: JSON.stringify({
email: values.email
})
});

if (!verificationRes.ok) {
const { error } = await verificationRes.json();
throw error;
}

// Sign in user
await signIn('credentials', {
email: values.email,
Expand Down
Loading