Skip to content

Commit

Permalink
[Closes #96] User can verify their account using email token (#100)
Browse files Browse the repository at this point in the history
* user can verify their account using email

* token field is UUID, cleared when verified

* adjust verify test

* fix verify find

* error catch

---------

Co-authored-by: Francis Li <mail@francisli.com>
  • Loading branch information
javtran and francisli authored Aug 26, 2024
1 parent 51df2a9 commit 91103b1
Show file tree
Hide file tree
Showing 8 changed files with 159 additions and 6 deletions.
6 changes: 4 additions & 2 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { AdminUsers } from './pages/admin/users/AdminUsers';

import Context from './Context';
import AdminPendingUsers from './pages/admin/pending-users/AdminPendingUsers';
import Verify from './pages/verify/verify';

const RedirectProps = {
isLoading: PropTypes.bool.isRequired,
Expand Down Expand Up @@ -103,10 +104,11 @@ function App() {
<Route
element={<Redirect isLoading={isLoading} isLoggedIn={isLoggedIn} />}
>
<Route path="/" element={<Index />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/register/:inviteId" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route path="/" element={<Index />} />
<Route path="verify/:emailVerificationToken" element={<Verify />} />
</Route>
</Routes>
</>
Expand Down
57 changes: 57 additions & 0 deletions client/src/pages/verify/verify.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import { LoadingOverlay } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import { notifications } from '@mantine/notifications';
/**
* Email Verification
*/
function Verify() {
const { emailVerificationToken } = useParams();
const navigate = useNavigate();

const { isFetching } = useQuery({
queryFn: () =>
fetch('/api/v1/users/verify', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
emailVerificationToken: emailVerificationToken,
}),
})
.then((response) => {
if (!response.ok) {
return Promise.reject(response);
}
})
.then(() => {
notifications.show({
color: 'green',
title: 'Your email address was successfully verified.',
autoClose: 5000,
});
navigate('/login');
})
.catch(() => {
notifications.show({
color: 'red',
title: `Invalid Email Verification Token`,
autoClose: 5000,
});
navigate('/');
}),
});
return (
<div>
<LoadingOverlay
visible={isFetching}
zIndex={1000}
overlayProps={{ radius: 'sm', blur: 2 }}
/>
</div>
);
}

export default Verify;
3 changes: 1 addition & 2 deletions server/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,7 @@ class User extends Base {
}

generateEmailVerificationToken() {
const buffer = crypto.randomBytes(3);
this.emailVerificationToken = buffer.toString('hex').toUpperCase();
this.emailVerificationToken = crypto.randomUUID();
}

async sendVerificationEmail() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ CREATE TABLE "User" (
"middleName" TEXT,
"lastName" TEXT NOT NULL,
"email" CITEXT NOT NULL,
"emailVerificationToken" TEXT,
"emailVerificationToken" UUID,
"emailVerifiedAt" TIMESTAMP(3),
"role" "Role" NOT NULL,
"hashedPassword" TEXT NOT NULL,
Expand Down Expand Up @@ -167,6 +167,9 @@ CREATE TABLE "_HospitalToPhysician" (
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

-- CreateIndex
CREATE UNIQUE INDEX "User_emailVerificationToken_key" ON "User"("emailVerificationToken");

-- CreateIndex
CREATE UNIQUE INDEX "User_licenseNumber_key" ON "User"("licenseNumber");

Expand Down
2 changes: 1 addition & 1 deletion server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ model User {
middleName String?
lastName String
email String @unique @db.Citext
emailVerificationToken String?
emailVerificationToken String? @db.Uuid @unique
emailVerifiedAt DateTime?
role Role
hashedPassword String
Expand Down
53 changes: 53 additions & 0 deletions server/routes/api/v1/users/verify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { StatusCodes } from 'http-status-codes';
import User from '../../../../models/user.js';

export default async function (fastify, _opts) {
fastify.patch(
'/verify',
{
schema: {
body: {
type: 'object',
required: ['emailVerificationToken'],
properties: {
emailVerificationToken: { type: 'string' },
},
},
response: {
[StatusCodes.OK]: {
type: 'null',
},
[StatusCodes.NOT_FOUND]: {
type: 'null',
},
},
},
},
async (request, reply) => {
const { emailVerificationToken } = request.body;
let data;
try {
data = await fastify.prisma.user.findUnique({
where: { emailVerificationToken },
});
} catch (error) {
return reply.notFound();
}
if (!data) {
return reply.notFound();
}
const user = new User(data);

if (!user.isEmailVerified) {
data = await fastify.prisma.user.update({
where: { id: user.id },
data: {
emailVerifiedAt: new Date(),
emailVerificationToken: null,
},
});
}
reply.code(StatusCodes.OK);
},
);
}
1 change: 1 addition & 0 deletions server/test/fixtures/db/User.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ items:
email: unverified.email@test.com
role: FIRST_RESPONDER
hashedPassword: $2b$10$ICaCk3VVZUCtO9HySahquuQusQhEnRpXHdzxaceUUJPk0DTwN2e/W # test
emailVerificationToken: be63f7ca-64c5-4eea-a1c0-4c81e7161fa4
user3:
id: f4a4be16-e1a5-49dd-9f21-11b1650057f5
firstName: Unapproved
Expand Down
38 changes: 38 additions & 0 deletions server/test/routes/api/v1/users.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -695,4 +695,42 @@ describe('/api/v1/users', () => {
assert.deepStrictEqual(user.disabledAt, null);
});
});
describe('PATCH /verify', () => {
it('should allow user to verify account through email verification', async (t) => {
const app = await build(t);
await t.loadFixtures();

const reply = await app.inject().patch('/api/v1/users/verify').payload({
emailVerificationToken: 'be63f7ca-64c5-4eea-a1c0-4c81e7161fa4',
});

assert.deepStrictEqual(reply.statusCode, StatusCodes.OK);

const user = await t.prisma.user.findUnique({
where: { id: 'dab5dff3-360d-4dbb-98dd-1990dfb5c4c5' },
});
assert.ok(user);
assert.deepStrictEqual(user.emailVerificationToken, null);

const date = new Date(user.emailVerifiedAt);
const today = new Date();

assert.deepStrictEqual(
date.toISOString().split('T')[0],
today.toISOString().split('T')[0],
);
});
});

it('should return 404 if no token exist', async (t) => {
const app = await build(t);
await t.loadFixtures();

const reply = await app.inject().patch('/api/v1/users/verify').payload({
emailVerificationToken: 'NOEXIST',
});

assert.deepStrictEqual(reply.statusCode, StatusCodes.NOT_FOUND);
assert.deepStrictEqual(reply.statusMessage, 'Not Found');
});
});

0 comments on commit 91103b1

Please sign in to comment.