mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-02-16 10:42:08 +00:00
Merge pull request #11 from aaronpo97/confirm-user
Implement confirm user functionality
This commit is contained in:
@@ -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;
|
||||
@@ -19,6 +19,7 @@ model User {
|
||||
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[]
|
||||
|
||||
@@ -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