mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 10:42:08 +00:00
Implement confirm user functionality
This commit adds the necessary functionality to confirm a user's account. It includes the addition of a new column in the user table to track whether an account is confirmed or not, and the implementation of JWT for confirmation tokens. This commit integrates the SparkPost API as well as React Email to send dynamic emails for whatever purpose. Upon user registration, a confirmation email will be sent to the user.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -40,4 +40,4 @@ next-env.d.ts
|
||||
*.http
|
||||
|
||||
# uploaded images
|
||||
public/uploads
|
||||
public/uploads
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
28
config/jwt/index.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
11
config/sparkpost/client.ts
Normal file
11
config/sparkpost/client.ts
Normal 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;
|
||||
25
config/sparkpost/sendEmail.ts
Normal file
25
config/sparkpost/sendEmail.ts
Normal 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
46
emails/Welcome.tsx
Normal 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
5176
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -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",
|
||||
|
||||
56
pages/api/users/confirm.ts
Normal file
56
pages/api/users/confirm.ts
Normal 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;
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
2
prisma/migrations/20230304185749_/migration.sql
Normal file
2
prisma/migrations/20230304185749_/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "isAccountVerified" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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 {
|
||||
|
||||
@@ -30,6 +30,7 @@ const createNewUser = async ({
|
||||
lastName: true,
|
||||
dateOfBirth: true,
|
||||
createdAt: true,
|
||||
isAccountVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const findUserById = async (id: string) => {
|
||||
lastName: true,
|
||||
dateOfBirth: true,
|
||||
createdAt: true,
|
||||
isAccountVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const GetUserSchema = z.object({
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
dateOfBirth: z.coerce.date(),
|
||||
isAccountVerified: z.boolean(),
|
||||
});
|
||||
|
||||
export default GetUserSchema;
|
||||
|
||||
32
services/User/sendConfirmationEmail.ts
Normal file
32
services/User/sendConfirmationEmail.ts
Normal 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;
|
||||
24
services/User/updateUserToBeConfirmedById.ts
Normal file
24
services/User/updateUserToBeConfirmedById.ts
Normal 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;
|
||||
Reference in New Issue
Block a user