Merge pull request #43 from aaronpo97/password-and-security

Account security updates
This commit is contained in:
Aaron Po
2023-05-29 16:01:02 -04:00
committed by GitHub
44 changed files with 553 additions and 107 deletions

View File

@@ -4,6 +4,7 @@ const nextConfig = {
images: { images: {
domains: ['picsum.photos', 'res.cloudinary.com'], domains: ['picsum.photos', 'res.cloudinary.com'],
}, },
swcMinify: true,
}; };
const withBundleAnalyzer = require('@next/bundle-analyzer')({ const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true', enabled: process.env.ANALYZE === 'true',

87
package-lock.json generated
View File

@@ -73,6 +73,7 @@
"eslint-config-next": "^13.3.4", "eslint-config-next": "^13.3.4",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-react": "^7.32.2", "eslint-plugin-react": "^7.32.2",
"onchange": "^7.1.0",
"postcss": "^8.4.23", "postcss": "^8.4.23",
"prettier": "^2.8.8", "prettier": "^2.8.8",
"prettier-plugin-jsdoc": "^0.4.2", "prettier-plugin-jsdoc": "^0.4.2",
@@ -202,6 +203,18 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@blakeembrey/deque": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@blakeembrey/deque/-/deque-1.0.5.tgz",
"integrity": "sha512-6xnwtvp9DY1EINIKdTfvfeAtCYw4OqBZJhtiqkT3ivjnEfa25VQ3TsKvaFfKm8MyGIEfE95qLe+bNEt3nB0Ylg==",
"dev": true
},
"node_modules/@blakeembrey/template": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@blakeembrey/template/-/template-1.1.0.tgz",
"integrity": "sha512-iZf+UWfL+DogJVpd/xMQyP6X6McYd6ArdYoPMiv/zlOTzeXXfQbYxBNJJBF6tThvsjLMbA8tLjkCdm9RWMFCCw==",
"dev": true
},
"node_modules/@cspotcode/source-map-support": { "node_modules/@cspotcode/source-map-support": {
"version": "0.8.1", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -8288,6 +8301,30 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/onchange": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/onchange/-/onchange-7.1.0.tgz",
"integrity": "sha512-ZJcqsPiWUAUpvmnJri5TPBooqJOPmC0ttN65juhN15Q8xA+Nbg3BaxBHXQ45EistKKlKElb0edmbPWnKSBkvMg==",
"dev": true,
"dependencies": {
"@blakeembrey/deque": "^1.0.5",
"@blakeembrey/template": "^1.0.0",
"arg": "^4.1.3",
"chokidar": "^3.3.1",
"cross-spawn": "^7.0.1",
"ignore": "^5.1.4",
"tree-kill": "^1.2.2"
},
"bin": {
"onchange": "dist/bin.js"
}
},
"node_modules/onchange/node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true
},
"node_modules/onetime": { "node_modules/onetime": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
@@ -10741,6 +10778,15 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tree-node-cli": { "node_modules/tree-node-cli": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/tree-node-cli/-/tree-node-cli-1.6.0.tgz", "resolved": "https://registry.npmjs.org/tree-node-cli/-/tree-node-cli-1.6.0.tgz",
@@ -11669,6 +11715,18 @@
"regenerator-runtime": "^0.13.11" "regenerator-runtime": "^0.13.11"
} }
}, },
"@blakeembrey/deque": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@blakeembrey/deque/-/deque-1.0.5.tgz",
"integrity": "sha512-6xnwtvp9DY1EINIKdTfvfeAtCYw4OqBZJhtiqkT3ivjnEfa25VQ3TsKvaFfKm8MyGIEfE95qLe+bNEt3nB0Ylg==",
"dev": true
},
"@blakeembrey/template": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@blakeembrey/template/-/template-1.1.0.tgz",
"integrity": "sha512-iZf+UWfL+DogJVpd/xMQyP6X6McYd6ArdYoPMiv/zlOTzeXXfQbYxBNJJBF6tThvsjLMbA8tLjkCdm9RWMFCCw==",
"dev": true
},
"@cspotcode/source-map-support": { "@cspotcode/source-map-support": {
"version": "0.8.1", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -17455,6 +17513,29 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"onchange": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/onchange/-/onchange-7.1.0.tgz",
"integrity": "sha512-ZJcqsPiWUAUpvmnJri5TPBooqJOPmC0ttN65juhN15Q8xA+Nbg3BaxBHXQ45EistKKlKElb0edmbPWnKSBkvMg==",
"dev": true,
"requires": {
"@blakeembrey/deque": "^1.0.5",
"@blakeembrey/template": "^1.0.0",
"arg": "^4.1.3",
"chokidar": "^3.3.1",
"cross-spawn": "^7.0.1",
"ignore": "^5.1.4",
"tree-kill": "^1.2.2"
},
"dependencies": {
"arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true
}
}
},
"onetime": { "onetime": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
@@ -19181,6 +19262,12 @@
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
"integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==" "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="
}, },
"tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true
},
"tree-node-cli": { "tree-node-cli": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/tree-node-cli/-/tree-node-cli-1.6.0.tgz", "resolved": "https://registry.npmjs.org/tree-node-cli/-/tree-node-cli-1.6.0.tgz",

View File

@@ -8,6 +8,7 @@
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"format": "npx prettier . --write", "format": "npx prettier . --write",
"format-watch": "npx onchange \"**/*\" -- prettier --write --ignore-unknown {{changed}}",
"seed": "npx --max-old-space-size=4096 ts-node ./src/prisma/seed/index.ts" "seed": "npx --max-old-space-size=4096 ts-node ./src/prisma/seed/index.ts"
}, },
"dependencies": { "dependencies": {
@@ -56,7 +57,6 @@
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^7.6.0", "@faker-js/faker": "^7.6.0",
"@types/cookie": "^0.5.1", "@types/cookie": "^0.5.1",
"@types/ejs": "^3.1.2",
"@types/jsonwebtoken": "^9.0.2", "@types/jsonwebtoken": "^9.0.2",
"@types/lodash": "^4.14.194", "@types/lodash": "^4.14.194",
"@types/mapbox__mapbox-sdk": "^0.13.4", "@types/mapbox__mapbox-sdk": "^0.13.4",
@@ -80,6 +80,7 @@
"prettier": "^2.8.8", "prettier": "^2.8.8",
"prettier-plugin-jsdoc": "^0.4.2", "prettier-plugin-jsdoc": "^0.4.2",
"prettier-plugin-tailwindcss": "^0.2.8", "prettier-plugin-tailwindcss": "^0.2.8",
"onchange": "^7.1.0",
"prisma": "^4.13.0", "prisma": "^4.13.0",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"tailwindcss-animate": "^1.0.5", "tailwindcss-animate": "^1.0.5",

View File

@@ -0,0 +1,77 @@
import { Switch } from '@headlessui/react';
import { FunctionComponent, useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { UpdatePasswordSchema } from '@/services/User/schema/CreateUserValidationSchemas';
import sendUpdatePasswordRequest from '@/requests/User/sendUpdatePasswordRequest';
import FormError from '../ui/forms/FormError';
import FormInfo from '../ui/forms/FormInfo';
import FormLabel from '../ui/forms/FormLabel';
import FormTextInput from '../ui/forms/FormTextInput';
const Security: FunctionComponent = () => {
const [editToggled, setEditToggled] = useState(false);
const { register, handleSubmit, formState, reset } = useForm<
z.infer<typeof UpdatePasswordSchema>
>({
resolver: zodResolver(UpdatePasswordSchema),
});
const onSubmit: SubmitHandler<z.infer<typeof UpdatePasswordSchema>> = async (data) => {
await sendUpdatePasswordRequest(data);
reset();
};
return (
<div className="mt-8 w-full space-y-4">
<div className="flex w-full items-center justify-between space-x-5">
<div className="">
<h1 className="text-lg font-bold">Change Your Password</h1>
<p>Update your password to maintain the safety of your account.</p>
</div>
<div>
<Switch
className="toggle"
id="edit-toggle"
checked={editToggled}
onClick={() => setEditToggled((val) => !val)}
/>
</div>
</div>
{editToggled && (
<form className="form-control" noValidate onSubmit={handleSubmit(onSubmit)}>
<FormInfo>
<FormLabel htmlFor="password">New Password</FormLabel>
<FormError>{formState.errors.password?.message}</FormError>
</FormInfo>
<FormTextInput
type="password"
disabled={!editToggled || formState.isSubmitting}
error={!!formState.errors.password}
id="password"
formValidationSchema={register('password')}
/>
<FormInfo>
<FormLabel htmlFor="password">Confirm Password</FormLabel>
<FormError>{formState.errors.confirmPassword?.message}</FormError>
</FormInfo>
<FormTextInput
type="password"
disabled={!editToggled || formState.isSubmitting}
error={!!formState.errors.confirmPassword}
id="password"
formValidationSchema={register('confirmPassword')}
/>
<button className="btn-primary btn mt-5" disabled={!editToggled} type="submit">
Update
</button>
</form>
)}
</div>
);
};
export default Security;

View File

@@ -67,8 +67,8 @@ const BeerPostCommentsSection: FC<BeerPostCommentsSectionProps> = ({ beerPost })
{ {
/** /**
* If the comments are loading, show a loading component. Otherwise, show * If the comments are loading, show a loading component. Otherwise, show the
* the comments. * comments.
*/ */
isLoading ? ( isLoading ? (
<div className="card bg-base-300 pb-6"> <div className="card bg-base-300 pb-6">

View File

@@ -19,8 +19,8 @@ const BeerRecommendationsSection: FC<{
const { ref: penultimateBeerPostRef } = useInView({ const { ref: penultimateBeerPostRef } = useInView({
/** /**
* When the last beer post comes into view, call setSize from * When the last beer post comes into view, call setSize from useBeerPostsByBrewery to
* useBeerPostsByBrewery to load more beer posts. * load more beer posts.
*/ */
onChange: (visible) => { onChange: (visible) => {
if (!visible || isAtEnd) return; if (!visible || isAtEnd) return;
@@ -46,9 +46,8 @@ const BeerRecommendationsSection: FC<{
const isPenultimateBeerPost = index === beerPosts.length - 2; const isPenultimateBeerPost = index === beerPosts.length - 2;
/** /**
* Attach a ref to the second last beer post in the list. * Attach a ref to the second last beer post in the list. When it comes
* When it comes into view, the component will call * into view, the component will call setSize to load more beer posts.
* setSize to load more beer posts.
*/ */
return ( return (
@@ -86,8 +85,8 @@ const BeerRecommendationsSection: FC<{
{ {
/** /**
* If there are more beer posts to load, show a loading component * If there are more beer posts to load, show a loading component with a
* with a skeleton loader and a loading spinner. * skeleton loader and a loading spinner.
*/ */
!!isLoadingMore && !isAtEnd && ( !!isLoadingMore && !isAtEnd && (
<BeerRecommendationLoadingComponent length={PAGE_SIZE} /> <BeerRecommendationLoadingComponent length={PAGE_SIZE} />

View File

@@ -22,8 +22,8 @@ const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) =
}); });
const { ref: penultimateBeerPostRef } = useInView({ const { ref: penultimateBeerPostRef } = useInView({
/** /**
* When the last beer post comes into view, call setSize from * When the last beer post comes into view, call setSize from useBeerPostsByBrewery to
* useBeerPostsByBrewery to load more beer posts. * load more beer posts.
*/ */
onChange: (visible) => { onChange: (visible) => {
if (!visible || isAtEnd) return; if (!visible || isAtEnd) return;
@@ -60,9 +60,8 @@ const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) =
const isPenultimateBeerPost = index === beerPosts.length - 2; const isPenultimateBeerPost = index === beerPosts.length - 2;
/** /**
* Attach a ref to the second last beer post in the list. * Attach a ref to the second last beer post in the list. When it comes
* When it comes into view, the component will call * into view, the component will call setSize to load more beer posts.
* setSize to load more beer posts.
*/ */
return ( return (
@@ -91,8 +90,8 @@ const BreweryBeersSection: FC<BreweryCommentsSectionProps> = ({ breweryPost }) =
{ {
/** /**
* If there are more beer posts to load, show a loading component * If there are more beer posts to load, show a loading component with a
* with a skeleton loader and a loading spinner. * skeleton loader and a loading spinner.
*/ */
!!isLoadingMore && !isAtEnd && ( !!isLoadingMore && !isAtEnd && (
<BeerRecommendationLoadingComponent length={PAGE_SIZE} /> <BeerRecommendationLoadingComponent length={PAGE_SIZE} />

View File

@@ -38,7 +38,7 @@ const LoginForm = () => {
await mutate!(); await mutate!();
toast.remove(loadingToast); toast.remove(loadingToast);
toast.success('Logged in!'); toast.success('Logged in!');
await router.push(`/user/current`); await router.push(`/users/current`);
} catch (error) { } catch (error) {
toast.remove(loadingToast); toast.remove(loadingToast);
createErrorToast(error); createErrorToast(error);

View File

@@ -68,9 +68,8 @@ const CommentsComponent: FC<CommentsComponentProps> = ({
const isLastComment = index === comments.length - 1; const isLastComment = index === comments.length - 1;
/** /**
* Attach a ref to the last comment in the list. When it comes * Attach a ref to the last comment in the list. When it comes into view, the
* into view, the component will call setSize to load more * component will call setSize to load more comments.
* comments.
*/ */
return ( return (
<div <div
@@ -89,17 +88,16 @@ const CommentsComponent: FC<CommentsComponentProps> = ({
{ {
/** /**
* If there are more comments to load, show a loading component * If there are more comments to load, show a loading component with a
* with a skeleton loader and a loading spinner. * skeleton loader and a loading spinner.
*/ */
!!isLoadingMore && <LoadingComponent length={pageSize} /> !!isLoadingMore && <LoadingComponent length={pageSize} />
} }
{ {
/** /**
* If the user has scrolled to the end of the comments, show a * If the user has scrolled to the end of the comments, show a button that
* button that will scroll them back to the top of the comments * will scroll them back to the top of the comments section.
* section.
*/ */
!!isAtEnd && ( !!isAtEnd && (
<div className="flex h-20 items-center justify-center text-center"> <div className="flex h-20 items-center justify-center text-center">

View File

@@ -4,7 +4,7 @@ import { NextApiRequest } from 'next';
import { z } from 'zod'; import { z } from 'zod';
export const BasicUserInfoSchema = z.object({ export const BasicUserInfoSchema = z.object({
id: z.string().uuid(), id: z.string().cuid(),
username: z.string(), username: z.string(),
}); });

View File

@@ -2,15 +2,14 @@ import { BasicUserInfoSchema } from '@/config/auth/types';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { z } from 'zod'; import { z } from 'zod';
import { CONFIRMATION_TOKEN_SECRET } from '../env'; import { CONFIRMATION_TOKEN_SECRET } from '../env';
import ServerError from '../util/ServerError';
type User = z.infer<typeof BasicUserInfoSchema>; export const generateConfirmationToken = (user: z.infer<typeof BasicUserInfoSchema>) => {
return jwt.sign(user, CONFIRMATION_TOKEN_SECRET, { expiresIn: '3m' });
export const generateConfirmationToken = (user: User) => {
const token = jwt.sign(user, CONFIRMATION_TOKEN_SECRET, { expiresIn: '30m' });
return token;
}; };
export const verifyConfirmationToken = (token: string) => { export const verifyConfirmationToken = async (token: string) => {
try {
const decoded = jwt.verify(token, CONFIRMATION_TOKEN_SECRET); const decoded = jwt.verify(token, CONFIRMATION_TOKEN_SECRET);
const parsed = BasicUserInfoSchema.safeParse(decoded); const parsed = BasicUserInfoSchema.safeParse(decoded);
@@ -20,4 +19,14 @@ export const verifyConfirmationToken = (token: string) => {
} }
return parsed.data; return parsed.data;
} catch (error) {
if (error instanceof Error && error.message === 'jwt expired') {
throw new ServerError(
'Your confirmation token is expired. Please generate a new one.',
401,
);
}
throw new ServerError('Something went wrong', 500);
}
}; };

View File

@@ -18,9 +18,8 @@ import { useState, useEffect } from 'react';
*/ */
const useMediaQuery = (query: `(${string})`) => { const useMediaQuery = (query: `(${string})`) => {
/** /**
* Initialize the matches state variable to false. This is updated whenever the * Initialize the matches state variable to false. This is updated whenever the viewport
* viewport size changes (i.e. when the component is mounted and when the window is * size changes (i.e. when the component is mounted and when the window is resized)
* resized)
*/ */
const [matches, setMatches] = useState(false); const [matches, setMatches] = useState(false);
@@ -35,8 +34,8 @@ const useMediaQuery = (query: `(${string})`) => {
} }
/** /**
* Add a resize event listener to the window object, and update the `matches` * Add a resize event listener to the window object, and update the `matches` state
* state variable whenever the viewport size changes. * variable whenever the viewport size changes.
*/ */
const listener = () => setMatches(media.matches); const listener = () => setMatches(media.matches);
window.addEventListener('resize', listener); window.addEventListener('resize', listener);

View File

@@ -6,6 +6,7 @@ import Head from 'next/head';
import AccountInfo from '@/components/Account/AccountInfo'; import AccountInfo from '@/components/Account/AccountInfo';
import { useContext } from 'react'; import { useContext } from 'react';
import UserContext from '@/contexts/UserContext'; import UserContext from '@/contexts/UserContext';
import Security from '@/components/Account/Security';
const AccountPage: NextPage = () => { const AccountPage: NextPage = () => {
const { user } = useContext(UserContext); const { user } = useContext(UserContext);
@@ -50,7 +51,10 @@ const AccountPage: NextPage = () => {
<Tab.Panel> <Tab.Panel>
<AccountInfo /> <AccountInfo />
</Tab.Panel> </Tab.Panel>
<Tab.Panel>Content 3</Tab.Panel> <Tab.Panel>
<Security />
</Tab.Panel>
<Tab.Panel>Your posts!</Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</div> </div>

View File

@@ -85,14 +85,14 @@ const router = createRouter<
router router
.delete( .delete(
validateRequest({ querySchema: z.object({ id: z.string().uuid() }) }), validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }),
getCurrentUser, getCurrentUser,
checkIfCommentOwner, checkIfCommentOwner,
deleteComment, deleteComment,
) )
.put( .put(
validateRequest({ validateRequest({
querySchema: z.object({ id: z.string().uuid() }), querySchema: z.object({ id: z.string().cuid() }),
bodySchema: CreateCommentValidationSchema, bodySchema: CreateCommentValidationSchema,
}), }),
getCurrentUser, getCurrentUser,

View File

@@ -79,7 +79,7 @@ const router = createRouter<
router.post( router.post(
validateRequest({ validateRequest({
bodySchema: CreateCommentValidationSchema, bodySchema: CreateCommentValidationSchema,
querySchema: z.object({ id: z.string().uuid() }), querySchema: z.object({ id: z.string().cuid() }),
}), }),
getCurrentUser, getCurrentUser,
createComment, createComment,
@@ -88,7 +88,7 @@ router.post(
router.get( router.get(
validateRequest({ validateRequest({
querySchema: z.object({ querySchema: z.object({
id: z.string().uuid(), id: z.string().cuid(),
page_size: z.coerce.number().int().positive(), page_size: z.coerce.number().int().positive(),
page_num: z.coerce.number().int().positive(), page_num: z.coerce.number().int().positive(),
}), }),

View File

@@ -70,12 +70,12 @@ const router = createRouter<
router.post( router.post(
getCurrentUser, getCurrentUser,
validateRequest({ querySchema: z.object({ id: z.string().uuid() }) }), validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }),
sendLikeRequest, sendLikeRequest,
); );
router.get( router.get(
validateRequest({ querySchema: z.object({ id: z.string().uuid() }) }), validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }),
getLikeCount, getLikeCount,
); );

View File

@@ -32,7 +32,7 @@ const router = createRouter<
router.get( router.get(
getCurrentUser, getCurrentUser,
validateRequest({ querySchema: z.object({ id: z.string().uuid() }) }), validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }),
checkIfLiked, checkIfLiked,
); );

View File

@@ -50,7 +50,7 @@ const getBeerRecommendationsRequest = async (
router.get( router.get(
validateRequest({ validateRequest({
querySchema: z.object({ querySchema: z.object({
id: z.string().uuid(), id: z.string().cuid(),
page_num: z.string().regex(/^[0-9]+$/), page_num: z.string().regex(/^[0-9]+$/),
page_size: z.string().regex(/^[0-9]+$/), page_size: z.string().regex(/^[0-9]+$/),
}), }),

View File

@@ -89,7 +89,7 @@ const router = createRouter<
router.post( router.post(
validateRequest({ validateRequest({
bodySchema: CreateCommentValidationSchema, bodySchema: CreateCommentValidationSchema,
querySchema: z.object({ id: z.string().uuid() }), querySchema: z.object({ id: z.string().cuid() }),
}), }),
getCurrentUser, getCurrentUser,
createComment, createComment,
@@ -98,7 +98,7 @@ router.post(
router.get( router.get(
validateRequest({ validateRequest({
querySchema: z.object({ querySchema: z.object({
id: z.string().uuid(), id: z.string().cuid(),
page_size: z.coerce.number().int().positive(), page_size: z.coerce.number().int().positive(),
page_num: z.coerce.number().int().positive(), page_num: z.coerce.number().int().positive(),
}), }),

View File

@@ -83,12 +83,12 @@ const router = createRouter<
router.post( router.post(
getCurrentUser, getCurrentUser,
validateRequest({ querySchema: z.object({ id: z.string().uuid() }) }), validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }),
sendLikeRequest, sendLikeRequest,
); );
router.get( router.get(
validateRequest({ querySchema: z.object({ id: z.string().uuid() }) }), validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }),
getLikeCount, getLikeCount,
); );

View File

@@ -39,7 +39,7 @@ router.get(
getCurrentUser, getCurrentUser,
validateRequest({ validateRequest({
querySchema: z.object({ querySchema: z.object({
id: z.string().uuid(), id: z.string().cuid(),
}), }),
}), }),
checkIfLiked, checkIfLiked,

View File

@@ -87,14 +87,14 @@ const router = createRouter<
router router
.delete( .delete(
validateRequest({ querySchema: z.object({ id: z.string().uuid() }) }), validateRequest({ querySchema: z.object({ id: z.string().cuid() }) }),
getCurrentUser, getCurrentUser,
checkIfCommentOwner, checkIfCommentOwner,
deleteComment, deleteComment,
) )
.put( .put(
validateRequest({ validateRequest({
querySchema: z.object({ id: z.string().uuid() }), querySchema: z.object({ id: z.string().cuid() }),
bodySchema: CreateCommentValidationSchema, bodySchema: CreateCommentValidationSchema,
}), }),
getCurrentUser, getCurrentUser,

View File

@@ -97,7 +97,7 @@ router.put(
getCurrentUser, getCurrentUser,
validateRequest({ validateRequest({
bodySchema: EditUserSchema, bodySchema: EditUserSchema,
querySchema: z.object({ id: z.string().uuid() }), querySchema: z.object({ id: z.string().cuid() }),
}), }),
checkIfUserCanEditUser, checkIfUserCanEditUser,
editUser, editUser,

View File

@@ -21,16 +21,16 @@ const confirmUser = async (req: ConfirmUserRequest, res: NextApiResponse) => {
const { token } = req.query; const { token } = req.query;
const user = req.user!; const user = req.user!;
const { id } = verifyConfirmationToken(token); const { id } = await verifyConfirmationToken(token);
if (user.accountIsVerified) {
throw new ServerError('Your account is already verified.', 400);
}
if (user.id !== id) { if (user.id !== id) {
throw new ServerError('Could not confirm user.', 401); throw new ServerError('Could not confirm user.', 401);
} }
if (user.accountIsVerified) {
throw new ServerError('User is already verified.', 400);
}
await updateUserToBeConfirmedById(id); await updateUserToBeConfirmedById(id);
res.status(200).json({ res.status(200).json({

View File

@@ -0,0 +1,60 @@
import { hashPassword } from '@/config/auth/passwordFns';
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import validateRequest from '@/config/nextConnect/middleware/validateRequest';
import DBClient from '@/prisma/DBClient';
import { UpdatePasswordSchema } from '@/services/User/schema/CreateUserValidationSchemas';
import GetUserSchema from '@/services/User/schema/GetUserSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import { createRouter } from 'next-connect';
import { z } from 'zod';
interface UpdatePasswordRequest extends UserExtendedNextApiRequest {
body: z.infer<typeof UpdatePasswordSchema>;
}
const updatePassword = async (
req: UpdatePasswordRequest,
res: NextApiResponse<z.infer<typeof APIResponseValidationSchema>>,
) => {
const { password } = req.body;
const hash = await hashPassword(password);
const user = req.user!;
const updatedUser: z.infer<typeof GetUserSchema> = await DBClient.instance.user.update({
data: { hash },
where: { id: user.id },
select: {
id: true,
username: true,
createdAt: true,
updatedAt: true,
email: true,
firstName: true,
lastName: true,
dateOfBirth: true,
accountIsVerified: true,
},
});
res.json({
message: 'Updated user password.',
statusCode: 200,
success: true,
payload: updatedUser,
});
};
const router = createRouter<
UpdatePasswordRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.put(
validateRequest({ bodySchema: UpdatePasswordSchema }),
getCurrentUser,
updatePassword,
);
const handler = router.handler(NextConnectOptions);
export default handler;

View File

@@ -0,0 +1,32 @@
import NextConnectOptions from '@/config/nextConnect/NextConnectOptions';
import { UserExtendedNextApiRequest } from '@/config/auth/types';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { NextApiResponse } from 'next';
import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser';
import { createRouter } from 'next-connect';
import { z } from 'zod';
import sendConfirmationEmail from '@/services/User/sendConfirmationEmail';
const resendConfirmation = async (
req: UserExtendedNextApiRequest,
res: NextApiResponse,
) => {
const user = req.user!;
await sendConfirmationEmail(user);
res.status(200).json({
message: `Resent the confirmation email for ${user.username}.`,
statusCode: 200,
success: true,
});
};
const router = createRouter<
UserExtendedNextApiRequest,
NextApiResponse<z.infer<typeof APIResponseValidationSchema>>
>();
router.post(getCurrentUser, resendConfirmation);
const handler = router.handler(NextConnectOptions);
export default handler;

5
src/pages/users/[id].tsx Normal file
View File

@@ -0,0 +1,5 @@
import { FC } from 'react';
const UserInfoPage: FC = () => null;
export default UserInfoPage;

125
src/pages/users/confirm.tsx Normal file
View File

@@ -0,0 +1,125 @@
import UserContext from '@/contexts/UserContext';
import createErrorToast from '@/util/createErrorToast';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { FC, useContext, useState } from 'react';
import { toast } from 'react-hot-toast';
import useSWR from 'swr';
const useSendConfirmUserRequest = () => {
const router = useRouter();
const token = router.query.token as string | undefined;
const { data, error } = useSWR(`/api/users/confirm?token=${token}`, async (url) => {
if (!token) {
throw new Error('Token must be provided.');
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(response.statusText);
}
const json = await response.json();
const parsed = APIResponseValidationSchema.safeParse(json);
if (!parsed.success) {
throw new Error('API response validation failed.');
}
return parsed.data;
});
return { data, error: error as unknown };
};
const ConfirmUserPage: FC = () => {
const router = useRouter();
const { error, data } = useSendConfirmUserRequest();
const { user } = useContext(UserContext);
const needsToLogin =
error instanceof Error && error.message === 'Unauthorized' && !user;
const tokenExpired = error instanceof Error && error.message === 'Unauthorized' && user;
const [confirmationResent, setConfirmationResent] = useState(false);
if (user?.accountIsVerified) {
router.push('/users/current');
return null;
}
if (data) {
router.push('/users/current');
return null;
}
if (needsToLogin) {
return (
<>
<Head>
<title>Confirm User | The Biergarten App</title>
</Head>
<div className="flex h-full flex-col items-center justify-center">
<p className="text-center text-xl font-bold">
Please login to confirm your account.
</p>
</div>
</>
);
}
if (tokenExpired) {
const onClick = async () => {
const loadingToast = toast.loading('Resending your confirmation email.');
try {
const response = await fetch('/api/users/resend-confirmation', {
method: 'POST',
});
if (!response.ok) {
throw new Error('Something went wrong.');
}
toast.remove(loadingToast);
toast.success('Sent a new confirmation email.');
setConfirmationResent(true);
} catch (err) {
createErrorToast(err);
}
};
return (
<>
<Head>
<title>Confirm User | The Biergarten App</title>
</Head>
<div className="flex h-full flex-col items-center justify-center space-y-4">
{!confirmationResent ? (
<>
<p className="text-center text-2xl font-bold">
Your confirmation token is expired.
</p>
<button
className="btn-outline btn-sm btn normal-case"
onClick={onClick}
type="button"
>
Click here to request a new token.
</button>
</>
) : (
<>
<p className="text-center text-2xl font-bold">
Resent your confirmation link.
</p>
<p className="font-xl text-center">Please check your email.</p>
</>
)}
</div>
</>
);
}
return null;
};
export default ConfirmUserPage;

View File

@@ -5,6 +5,7 @@ import UserContext from '@/contexts/UserContext';
import { GetServerSideProps, NextPage } from 'next'; import { GetServerSideProps, NextPage } from 'next';
import { useContext } from 'react'; import { useContext } from 'react';
import useMediaQuery from '@/hooks/utilities/useMediaQuery'; import useMediaQuery from '@/hooks/utilities/useMediaQuery';
import Head from 'next/head';
const ProtectedPage: NextPage = () => { const ProtectedPage: NextPage = () => {
const { user, isLoading } = useContext(UserContext); const { user, isLoading } = useContext(UserContext);
@@ -17,6 +18,10 @@ const ProtectedPage: NextPage = () => {
const isDesktop = useMediaQuery('(min-width: 768px)'); const isDesktop = useMediaQuery('(min-width: 768px)');
return ( return (
<>
<Head>
<title>Hello, {user?.firstName}! | The Biergarten App</title>
</Head>
<div className="flex h-full flex-col items-center justify-center space-y-3 bg-primary text-center"> <div className="flex h-full flex-col items-center justify-center space-y-3 bg-primary text-center">
{isLoading && <Spinner size={isDesktop ? 'xl' : 'md'} />} {isLoading && <Spinner size={isDesktop ? 'xl' : 'md'} />}
{user && !isLoading && ( {user && !isLoading && (
@@ -33,6 +38,7 @@ const ProtectedPage: NextPage = () => {
</> </>
)} )}
</div> </div>
</>
); );
}; };

View File

@@ -13,7 +13,7 @@ datasource db {
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(cuid())
username String @unique username String @unique
firstName String firstName String
lastName String lastName String
@@ -36,7 +36,7 @@ model User {
} }
model BeerPost { model BeerPost {
id String @id @default(uuid()) id String @id @default(cuid())
name String name String
ibu Float ibu Float
abv Float abv Float
@@ -55,7 +55,7 @@ model BeerPost {
} }
model BeerPostLike { model BeerPostLike {
id String @id @default(uuid()) id String @id @default(cuid())
beerPost BeerPost @relation(fields: [beerPostId], references: [id], onDelete: Cascade) beerPost BeerPost @relation(fields: [beerPostId], references: [id], onDelete: Cascade)
beerPostId String beerPostId String
likedBy User @relation(fields: [likedById], references: [id], onDelete: Cascade) likedBy User @relation(fields: [likedById], references: [id], onDelete: Cascade)
@@ -65,7 +65,7 @@ model BeerPostLike {
} }
model BreweryPostLike { model BreweryPostLike {
id String @id @default(uuid()) id String @id @default(cuid())
breweryPost BreweryPost @relation(fields: [breweryPostId], references: [id], onDelete: Cascade) breweryPost BreweryPost @relation(fields: [breweryPostId], references: [id], onDelete: Cascade)
breweryPostId String breweryPostId String
likedBy User @relation(fields: [likedById], references: [id], onDelete: Cascade) likedBy User @relation(fields: [likedById], references: [id], onDelete: Cascade)
@@ -75,7 +75,7 @@ model BreweryPostLike {
} }
model BeerComment { model BeerComment {
id String @id @default(uuid()) id String @id @default(cuid())
rating Int rating Int
beerPost BeerPost @relation(fields: [beerPostId], references: [id], onDelete: Cascade) beerPost BeerPost @relation(fields: [beerPostId], references: [id], onDelete: Cascade)
beerPostId String beerPostId String
@@ -87,7 +87,7 @@ model BeerComment {
} }
model BeerType { model BeerType {
id String @id @default(uuid()) id String @id @default(cuid())
name String name String
createdAt DateTime @default(now()) @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime? @updatedAt @db.Timestamptz(3) updatedAt DateTime? @updatedAt @db.Timestamptz(3)
@@ -97,7 +97,7 @@ model BeerType {
} }
model Location { model Location {
id String @id @default(uuid()) id String @id @default(cuid())
city String city String
stateOrProvince String? stateOrProvince String?
country String? country String?
@@ -109,7 +109,7 @@ model Location {
} }
model BreweryPost { model BreweryPost {
id String @id @default(uuid()) id String @id @default(cuid())
name String name String
location Location @relation(fields: [locationId], references: [id]) location Location @relation(fields: [locationId], references: [id])
locationId String @unique locationId String @unique
@@ -126,7 +126,7 @@ model BreweryPost {
} }
model BreweryComment { model BreweryComment {
id String @id @default(uuid()) id String @id @default(cuid())
rating Int rating Int
breweryPost BreweryPost @relation(fields: [breweryPostId], references: [id], onDelete: Cascade) breweryPost BreweryPost @relation(fields: [breweryPostId], references: [id], onDelete: Cascade)
breweryPostId String breweryPostId String
@@ -138,7 +138,7 @@ model BreweryComment {
} }
model BeerImage { model BeerImage {
id String @id @default(uuid()) id String @id @default(cuid())
beerPost BeerPost @relation(fields: [beerPostId], references: [id], onDelete: Cascade) beerPost BeerPost @relation(fields: [beerPostId], references: [id], onDelete: Cascade)
beerPostId String beerPostId String
path String path String
@@ -151,7 +151,7 @@ model BeerImage {
} }
model BreweryImage { model BreweryImage {
id String @id @default(uuid()) id String @id @default(cuid())
breweryPost BreweryPost @relation(fields: [breweryPostId], references: [id], onDelete: Cascade) breweryPost BreweryPost @relation(fields: [breweryPostId], references: [id], onDelete: Cascade)
breweryPostId String breweryPostId String
path String path String

View File

@@ -5,7 +5,7 @@ import APIResponseValidationSchema from '@/validation/APIResponseValidationSchem
import { z } from 'zod'; import { z } from 'zod';
const BeerCommentValidationSchemaWithId = CreateCommentValidationSchema.extend({ const BeerCommentValidationSchemaWithId = CreateCommentValidationSchema.extend({
beerPostId: z.string().uuid(), beerPostId: z.string().cuid(),
}); });
const sendCreateBeerCommentRequest = async ({ const sendCreateBeerCommentRequest = async ({

View File

@@ -0,0 +1,33 @@
import { UpdatePasswordSchema } from '@/services/User/schema/CreateUserValidationSchemas';
import GetUserSchema from '@/services/User/schema/GetUserSchema';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { z } from 'zod';
const sendUpdatePasswordRequest = async (data: z.infer<typeof UpdatePasswordSchema>) => {
const response = await fetch('/api/users/edit-password', {
body: JSON.stringify(data),
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(response.statusText);
}
const json = await response.json();
const parsed = APIResponseValidationSchema.safeParse(json);
if (!parsed.success) {
throw new Error('API response validation failed.');
}
const parsedPayload = GetUserSchema.safeParse(parsed.data.payload);
if (!parsedPayload.success) {
throw new Error('API payload validation failed.');
}
return parsedPayload.data;
};
export default sendUpdatePasswordRequest;

View File

@@ -3,8 +3,8 @@ import { z } from 'zod';
import CreateCommentValidationSchema from '../types/CommentSchema/CreateCommentValidationSchema'; import CreateCommentValidationSchema from '../types/CommentSchema/CreateCommentValidationSchema';
const CreateNewBeerCommentServiceSchema = CreateCommentValidationSchema.extend({ const CreateNewBeerCommentServiceSchema = CreateCommentValidationSchema.extend({
userId: z.string().uuid(), userId: z.string().cuid(),
beerPostId: z.string().uuid(), beerPostId: z.string().cuid(),
}); });
const createNewBeerComment = async ({ const createNewBeerComment = async ({

View File

@@ -4,7 +4,7 @@ import beerPostQueryResult from './schema/BeerPostQueryResult';
import CreateBeerPostValidationSchema from './schema/CreateBeerPostValidationSchema'; import CreateBeerPostValidationSchema from './schema/CreateBeerPostValidationSchema';
const CreateBeerPostWithUserSchema = CreateBeerPostValidationSchema.extend({ const CreateBeerPostWithUserSchema = CreateBeerPostValidationSchema.extend({
userId: z.string().uuid(), userId: z.string().cuid(),
}); });
const createNewBeerPost = async ({ const createNewBeerPost = async ({

View File

@@ -31,13 +31,13 @@ const CreateBeerPostValidationSchema = z.object({
required_error: 'Type id is required.', required_error: 'Type id is required.',
invalid_type_error: 'Type id must be a string.', invalid_type_error: 'Type id must be a string.',
}) })
.uuid({ message: 'Invalid type id.' }), .cuid({ message: 'Invalid type id.' }),
breweryId: z breweryId: z
.string({ .string({
required_error: 'Brewery id is required.', required_error: 'Brewery id is required.',
invalid_type_error: 'Brewery id must be a string.', invalid_type_error: 'Brewery id must be a string.',
}) })
.uuid({ message: 'Invalid brewery id.' }), .cuid({ message: 'Invalid brewery id.' }),
}); });
export default CreateBeerPostValidationSchema; export default CreateBeerPostValidationSchema;

View File

@@ -4,6 +4,6 @@ import CreateBeerPostValidationSchema from './CreateBeerPostValidationSchema';
const EditBeerPostValidationSchema = CreateBeerPostValidationSchema.omit({ const EditBeerPostValidationSchema = CreateBeerPostValidationSchema.omit({
breweryId: true, breweryId: true,
typeId: true, typeId: true,
}).extend({ id: z.string().uuid() }); }).extend({ id: z.string().cuid() });
export default EditBeerPostValidationSchema; export default EditBeerPostValidationSchema;

View File

@@ -3,8 +3,8 @@ import { z } from 'zod';
import CreateCommentValidationSchema from '../types/CommentSchema/CreateCommentValidationSchema'; import CreateCommentValidationSchema from '../types/CommentSchema/CreateCommentValidationSchema';
const CreateNewBreweryCommentServiceSchema = CreateCommentValidationSchema.extend({ const CreateNewBreweryCommentServiceSchema = CreateCommentValidationSchema.extend({
userId: z.string().uuid(), userId: z.string().cuid(),
breweryPostId: z.string().uuid(), breweryPostId: z.string().cuid(),
}); });
const createNewBreweryComment = async ({ const createNewBreweryComment = async ({

View File

@@ -31,6 +31,7 @@ const createNewUser = async ({
dateOfBirth: true, dateOfBirth: true,
createdAt: true, createdAt: true,
accountIsVerified: true, accountIsVerified: true,
updatedAt: true,
}, },
}); });

View File

@@ -15,6 +15,7 @@ const findUserById = async (id: string) => {
dateOfBirth: true, dateOfBirth: true,
createdAt: true, createdAt: true,
accountIsVerified: true, accountIsVerified: true,
updatedAt: true,
}, },
}); });

View File

@@ -69,3 +69,11 @@ export const CreateUserValidationSchemaWithUsernameAndEmailCheck =
message: 'Passwords do not match.', message: 'Passwords do not match.',
path: ['confirmPassword'], path: ['confirmPassword'],
}); });
export const UpdatePasswordSchema = BaseCreateUserSchema.pick({
password: true,
confirmPassword: true,
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
});

View File

@@ -1,10 +1,10 @@
import { z } from 'zod'; import { z } from 'zod';
const GetUserSchema = z.object({ const GetUserSchema = z.object({
id: z.string().uuid(), id: z.string().cuid(),
username: z.string(), username: z.string(),
createdAt: z.coerce.date(), createdAt: z.coerce.date(),
updatedAt: z.coerce.date().optional(), updatedAt: z.coerce.date().nullable(),
email: z.string().email(), email: z.string().email(),
firstName: z.string(), firstName: z.string(),
lastName: z.string(), lastName: z.string(),

View File

@@ -14,7 +14,7 @@ const sendConfirmationEmail = async ({ id, username, email }: UserSchema) => {
const subject = 'Confirm your email'; const subject = 'Confirm your email';
const name = username; const name = username;
const url = `${BASE_URL}/api/users/confirm?token=${confirmationToken}`; const url = `${BASE_URL}/users/confirm?token=${confirmationToken}`;
const address = email; const address = email;
const html = render(Welcome({ name, url, subject })!); const html = render(Welcome({ name, url, subject })!);

View File

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

View File

@@ -1,12 +1,12 @@
import { z } from 'zod'; import { z } from 'zod';
const CommentQueryResult = z.object({ const CommentQueryResult = z.object({
id: z.string().uuid(), id: z.string().cuid(),
content: z.string().min(1).max(500), content: z.string().min(1).max(500),
rating: z.number().int().min(1).max(5), rating: z.number().int().min(1).max(5),
createdAt: z.coerce.date(), createdAt: z.coerce.date(),
postedBy: z.object({ postedBy: z.object({
id: z.string().uuid(), id: z.string().cuid(),
username: z.string().min(1).max(50), username: z.string().min(1).max(50),
}), }),
}); });