Merge pull request #11 from aaronpo97/confirm-user

Implement confirm user functionality
This commit is contained in:
Aaron Po
2023-03-15 22:02:20 -04:00
committed by GitHub
21 changed files with 5352 additions and 145 deletions

View File

@@ -59,7 +59,7 @@ const BeerInfoHeader: FC<{ beerPost: BeerPostQueryResult; initialLikeCount: numb
<div className="tooltip tooltip-left" data-tip={`Edit '${beerPost.name}'`}>
<Link
href={`/beers/${beerPost.id}/edit`}
className="btn btn-outline btn-sm"
className="btn-outline btn-sm btn"
>
<FaRegEdit className="text-xl" />
</Link>

View File

@@ -68,7 +68,7 @@ const Navbar = () => {
</ul>
</div>
<div className="flex-none lg:hidden">
<div className="dropdown-end dropdown">
<div className="dropdown dropdown-end">
<label tabIndex={0} className="btn btn-ghost btn-circle">
<span className="w-10 rounded-full">
<svg

View File

@@ -14,7 +14,7 @@ const Button: FunctionComponent<FormButtonProps> = ({
// eslint-disable-next-line react/button-has-type
<button
type={type}
className={`btn-primary btn w-full rounded-xl ${isSubmitting ? 'loading' : ''}`}
className={`btn btn-primary w-full rounded-xl ${isSubmitting ? 'loading' : ''}`}
>
{children}
</button>

View File

@@ -9,24 +9,24 @@ import { z } from 'zod';
import { MAX_AGE, setTokenCookie, getTokenCookie } from './cookie';
import ServerError from '../util/ServerError';
const { TOKEN_SECRET } = process.env;
const { SESSION_SECRET } = process.env;
export async function setLoginSession(
res: NextApiResponse,
session: z.infer<typeof BasicUserInfoSchema>,
) {
if (!TOKEN_SECRET) {
if (!SESSION_SECRET) {
throw new ServerError('Authentication is not configured.', 500);
}
const createdAt = Date.now();
const obj = { ...session, createdAt, maxAge: MAX_AGE };
const token = await Iron.seal(obj, TOKEN_SECRET, Iron.defaults);
const token = await Iron.seal(obj, SESSION_SECRET, Iron.defaults);
setTokenCookie(res, token);
}
export async function getLoginSession(req: SessionRequest) {
if (!TOKEN_SECRET) {
if (!SESSION_SECRET) {
throw new ServerError('Authentication is not configured.', 500);
}
@@ -35,7 +35,7 @@ export async function getLoginSession(req: SessionRequest) {
throw new ServerError('You are not logged in.', 401);
}
const session = await Iron.unseal(token, TOKEN_SECRET, Iron.defaults);
const session = await Iron.unseal(token, SESSION_SECRET, Iron.defaults);
const parsed = UserSessionSchema.safeParse(session);

28
config/jwt/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import { BasicUserInfoSchema } from '@/config/auth/types';
import jwt from 'jsonwebtoken';
import { z } from 'zod';
const { CONFIRMATION_TOKEN_SECRET } = process.env;
if (!CONFIRMATION_TOKEN_SECRET) {
throw new Error('CONFIRMATION_TOKEN_SECRET is not defined');
}
type User = z.infer<typeof BasicUserInfoSchema>;
export const generateConfirmationToken = (user: User) => {
const token = jwt.sign(user, CONFIRMATION_TOKEN_SECRET, { expiresIn: '30m' });
return token;
};
export const verifyConfirmationToken = (token: string) => {
const decoded = jwt.verify(token, CONFIRMATION_TOKEN_SECRET);
const parsed = BasicUserInfoSchema.safeParse(decoded);
if (!parsed.success) {
throw new Error('Invalid token');
}
return parsed.data;
};

View File

@@ -15,7 +15,7 @@ const getCurrentUser = async (
const user = await findUserById(session?.id);
if (!user) {
throw new ServerError('Could not get user.', 401);
throw new ServerError('User is not logged in.', 401);
}
req.user = user;

View File

@@ -0,0 +1,11 @@
import SparkPost from 'sparkpost';
const { SPARKPOST_API_KEY } = process.env;
if (!SPARKPOST_API_KEY) {
throw new Error('SPARKPOST_API_KEY is not defined');
}
const client = new SparkPost(SPARKPOST_API_KEY);
export default client;

View File

@@ -0,0 +1,25 @@
import client from './client';
interface EmailParams {
address: string;
text: string;
html: string;
subject: string;
}
const { SPARKPOST_SENDER_ADDRESS } = process.env;
if (!SPARKPOST_SENDER_ADDRESS) {
throw new Error('SPARKPOST_SENDER_ADDRESS env variable is not set.');
}
const sendEmail = async ({ address, text, html, subject }: EmailParams) => {
const from = SPARKPOST_SENDER_ADDRESS;
await client.transmissions.send({
content: { from, html, subject, text },
recipients: [{ address }],
});
};
export default sendEmail;

46
emails/Welcome.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { Container, Heading, Text, Button, Section } from '@react-email/components';
import { Tailwind } from '@react-email/tailwind';
import { FC } from 'react';
interface WelcomeEmail {
subject?: string;
name?: string;
url?: string;
}
const Welcome: FC<WelcomeEmail> = ({ name, url }) => (
<Tailwind>
<Container className="flex h-full w-full flex-col items-center justify-center">
<Section>
<Heading className="text-2xl font-bold">Welcome to The Biergarten App!</Heading>
<Text>
Hi {name}, welcome to The Biergarten App! We are excited to have you as a member
of our community.
</Text>
<Text>
The Biergarten App is a social network for beer lovers. Here you can share your
favorite beers with the community, and discover new ones. You can also create
your own beer list, and share it with your friends.
</Text>
<Text>
To get started, please verify your email address by clicking the button below.
Once you do so, you will be able to create your profile and start sharing your
favorite beers with the community.
</Text>
<Button href={url}>Verify Email</Button>
<Text className="italic">
Please note that this email was automatically generated, and we kindly ask you
not to reply to it.
</Text>
</Section>
</Container>
</Tailwind>
);
export default Welcome;

5176
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,41 +9,49 @@
"lint": "next lint",
"format": "npx prettier . --write",
"prestart": "npm run build",
"prismaDev": "dotenv -e .env.local prisma migrate dev",
"seed": "npx ts-node ./prisma/seed/index.ts"
},
"dependencies": {
"@hapi/iron": "^7.0.1",
"@hookform/resolvers": "^2.9.10",
"@prisma/client": "^4.10.1",
"@react-email/components": "^0.0.2",
"@react-email/render": "0.0.6",
"@react-email/tailwind": "0.0.6",
"argon2": "^0.30.3",
"cloudinary": "^1.34.0",
"cookie": "0.5.0",
"date-fns": "^2.29.3",
"multer": "^2.0.0-rc.4",
"jsonwebtoken": "^9.0.0",
"multer-storage-cloudinary": "^4.0.0",
"next": "^13.2.1",
"multer": "^2.0.0-rc.4",
"next-connect": "^1.0.0-next.3",
"passport": "^0.6.0",
"next": "^13.2.1",
"passport-local": "^1.0.0",
"pino": "^8.11.0",
"passport": "^0.6.0",
"pino-pretty": "^9.3.0",
"react": "18.2.0",
"pino": "^8.11.0",
"react-daisyui": "^3.0.3",
"react-dom": "18.2.0",
"react-email": "^1.7.15",
"react-hook-form": "^7.43.2",
"react-icons": "^4.7.1",
"react": "18.2.0",
"sparkpost": "^2.1.4",
"swr": "^2.0.3",
"zod": "^3.20.6"
},
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@types/cookie": "^0.5.1",
"@types/ejs": "^3.1.2",
"@types/jsonwebtoken": "^9.0.1",
"@types/multer": "^1.4.7",
"@types/node": "^18.14.1",
"@types/passport-local": "^1.0.35",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/sparkpost": "^2.1.5",
"autoprefixer": "^10.4.13",
"daisyui": "^2.51.0",
"dotenv-cli": "^7.0.0",

View File

@@ -0,0 +1,56 @@
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import { verifyConfirmationToken } from '@/config/jwt';
import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import ServerError from '@/config/util/ServerError';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { createRouter } from 'next-connect';
import { z } from 'zod';
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import updateUserToBeConfirmedById from '@/services/User/updateUserToBeConfirmedById';
const ConfirmUserValidationSchema = z.object({ token: z.string() });
interface ConfirmUserRequest extends UserExtendedNextApiRequest {
query: z.infer<typeof ConfirmUserValidationSchema>;
}
const confirmUser = async (req: ConfirmUserRequest, res: NextApiResponse) => {
const { token } = req.query;
const user = req.user!;
const { id } = verifyConfirmationToken(token);
if (user.id !== id) {
throw new ServerError('Could not confirm user.', 401);
}
if (user.isAccountVerified) {
throw new ServerError('User is already verified.', 400);
}
await updateUserToBeConfirmedById(id);
res.status(200).json({
message: 'User confirmed successfully.',
statusCode: 200,
success: true,
});
};
const router = createRouter<
ConfirmUserRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.get(
getCurrentUser,
validateRequest({ querySchema: ConfirmUserValidationSchema }),
confirmUser,
);
const handler = router.handler(NextConnectOptions);
export default handler;

View File

@@ -11,10 +11,18 @@ import findUserByEmail from '@/services/User/findUserByEmail';
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import sendConfirmationEmail from '@/services/User/sendConfirmationEmail';
interface RegisterUserRequest extends NextApiRequest {
body: z.infer<typeof CreateUserValidationSchema>;
}
const { BASE_URL } = process.env;
if (!BASE_URL) {
throw new ServerError('BASE_URL env variable is not set.', 500);
}
const registerUser = async (req: RegisterUserRequest, res: NextApiResponse) => {
const [usernameTaken, emailTaken] = await Promise.all([
findUserByUsername(req.body.username),
@@ -41,11 +49,14 @@ const registerUser = async (req: RegisterUserRequest, res: NextApiResponse) => {
id: user.id,
username: user.username,
});
res.status(201).json({
message: 'User created successfully.',
payload: user,
statusCode: 201,
await sendConfirmationEmail(user);
res.status(200).json({
success: true,
statusCode: 200,
message: 'User registered successfully.',
payload: user,
});
};

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isAccountVerified" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -11,23 +11,24 @@ datasource db {
}
model User {
id String @id @default(uuid())
username String @unique
firstName String
lastName String
hash String
email String @unique
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime? @updatedAt @db.Timestamptz(3)
dateOfBirth DateTime
beerPosts BeerPost[]
beerTypes BeerType[]
breweryPosts BreweryPost[]
beerComments BeerComment[]
breweryComments BreweryComment[]
BeerPostLikes BeerPostLike[]
BeerImage BeerImage[]
BreweryImage BreweryImage[]
id String @id @default(uuid())
username String @unique
firstName String
lastName String
hash String
email String @unique
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime? @updatedAt @db.Timestamptz(3)
isAccountVerified Boolean @default(false)
dateOfBirth DateTime
beerPosts BeerPost[]
beerTypes BeerType[]
breweryPosts BreweryPost[]
beerComments BeerComment[]
breweryComments BreweryComment[]
BeerPostLikes BeerPostLike[]
BeerImage BeerImage[]
BreweryImage BreweryImage[]
}
model BeerPost {

View File

@@ -30,6 +30,7 @@ const createNewUser = async ({
lastName: true,
dateOfBirth: true,
createdAt: true,
isAccountVerified: true,
},
});

View File

@@ -14,6 +14,7 @@ const findUserById = async (id: string) => {
lastName: true,
dateOfBirth: true,
createdAt: true,
isAccountVerified: true,
},
});

View File

@@ -9,6 +9,7 @@ const GetUserSchema = z.object({
firstName: z.string(),
lastName: z.string(),
dateOfBirth: z.coerce.date(),
isAccountVerified: z.boolean(),
});
export default GetUserSchema;

View File

@@ -0,0 +1,32 @@
import { generateConfirmationToken } from '@/config/jwt';
import sendEmail from '@/config/sparkpost/sendEmail';
import ServerError from '@/config/util/ServerError';
import Welcome from '@/emails/Welcome';
import { render } from '@react-email/render';
import { z } from 'zod';
import GetUserSchema from './schema/GetUserSchema';
const { BASE_URL } = process.env;
if (!BASE_URL) {
throw new ServerError('BASE_URL env variable is not set.', 500);
}
type UserSchema = z.infer<typeof GetUserSchema>;
const sendConfirmationEmail = async ({ id, username, email }: UserSchema) => {
const confirmationToken = generateConfirmationToken({ id, username });
const subject = 'Confirm your email';
const name = username;
const url = `${BASE_URL}/api/users/confirm?token=${confirmationToken}`;
const address = email;
const html = render(Welcome({ name, url, subject })!);
const text = render(Welcome({ name, url, subject })!, { plainText: true });
await sendEmail({ address, subject, text, html });
};
export default sendConfirmationEmail;

View File

@@ -0,0 +1,24 @@
import GetUserSchema from '@/services/User/schema/GetUserSchema';
import DBClient from '@/prisma/DBClient';
import { z } from 'zod';
const updateUserToBeConfirmedById = async (id: string) => {
const user: z.infer<typeof GetUserSchema> = await DBClient.instance.user.update({
where: { id },
data: { isAccountVerified: true, updatedAt: new Date() },
select: {
id: true,
username: true,
email: true,
isAccountVerified: true,
createdAt: true,
firstName: true,
lastName: true,
dateOfBirth: true,
},
});
return user;
};
export default updateUserToBeConfirmedById;