From 9a9d8bcb94d55a5a572db7230f463addae06b802 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Mon, 6 Feb 2023 17:17:11 -0500 Subject: [PATCH] Implement login, add useUser hook --- components/ui/Spinner.tsx | 23 ++++ components/ui/forms/FormError.tsx | 2 - components/ui/forms/FormSelect.tsx | 2 +- config/auth/middleware/getCurrentUser.ts | 9 +- config/auth/session.ts | 8 +- config/auth/types.ts | 7 +- hooks/useUser.ts | 35 ++++++ pages/api/users/login.ts | 9 +- pages/api/users/register.ts | 21 ++++ pages/login/index.tsx | 107 ++++++++++++++++++ pages/protected.tsx | 16 --- pages/user/current.tsx | 30 +++++ requests/sendLoginUserRequest.tsx | 28 +++++ services/user/createNewUser.ts | 14 +-- services/user/findUserByEmail.ts | 13 +++ services/user/findUserById.ts | 23 ++++ services/user/schema/GetUserSchema.ts | 14 +++ services/user/schema/LoginValidationSchema.ts | 18 +++ 18 files changed, 336 insertions(+), 43 deletions(-) create mode 100644 components/ui/Spinner.tsx create mode 100644 hooks/useUser.ts create mode 100644 pages/login/index.tsx delete mode 100644 pages/protected.tsx create mode 100644 pages/user/current.tsx create mode 100644 requests/sendLoginUserRequest.tsx create mode 100644 services/user/findUserByEmail.ts create mode 100644 services/user/findUserById.ts create mode 100644 services/user/schema/GetUserSchema.ts create mode 100644 services/user/schema/LoginValidationSchema.ts diff --git a/components/ui/Spinner.tsx b/components/ui/Spinner.tsx new file mode 100644 index 0000000..6dc37e4 --- /dev/null +++ b/components/ui/Spinner.tsx @@ -0,0 +1,23 @@ +const Spinner = () => ( +
+ + Loading... +
+); + +export default Spinner; diff --git a/components/ui/forms/FormError.tsx b/components/ui/forms/FormError.tsx index 8992c60..09e06c1 100644 --- a/components/ui/forms/FormError.tsx +++ b/components/ui/forms/FormError.tsx @@ -1,6 +1,4 @@ import { FunctionComponent } from 'react'; -// import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -// import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; /** * @example diff --git a/components/ui/forms/FormSelect.tsx b/components/ui/forms/FormSelect.tsx index 62a8179..00962f8 100644 --- a/components/ui/forms/FormSelect.tsx +++ b/components/ui/forms/FormSelect.tsx @@ -2,7 +2,7 @@ import { FunctionComponent } from 'react'; import { UseFormRegisterReturn } from 'react-hook-form'; interface FormSelectProps { - options: ReadonlyArray<{ value: string; text: string }>; + options: readonly { value: string; text: string }[]; id: string; formRegister: UseFormRegisterReturn; error: boolean; diff --git a/config/auth/middleware/getCurrentUser.ts b/config/auth/middleware/getCurrentUser.ts index aef0ef0..b827bac 100644 --- a/config/auth/middleware/getCurrentUser.ts +++ b/config/auth/middleware/getCurrentUser.ts @@ -1,5 +1,7 @@ import { NextApiResponse } from 'next'; import { NextHandler } from 'next-connect'; +import findUserById from '@/services/user/findUserById'; +import ServerError from '@/config/util/ServerError'; import { getLoginSession } from '../session'; import { ExtendedNextApiRequest } from '../types'; @@ -10,7 +12,12 @@ const getCurrentUser = async ( next: NextHandler, ) => { const session = await getLoginSession(req); - const user = { id: session.id, username: session.username }; + const user = await findUserById(session?.id); + + if (!user) { + throw new ServerError('Could not get user.', 401); + } + req.user = user; next(); }; diff --git a/config/auth/session.ts b/config/auth/session.ts index 53dd711..c05bf4b 100644 --- a/config/auth/session.ts +++ b/config/auth/session.ts @@ -1,6 +1,10 @@ import { NextApiResponse } from 'next'; import Iron from '@hapi/iron'; -import { SessionRequest, UserInfoSchema, UserSessionSchema } from '@/config/auth/types'; +import { + SessionRequest, + BasicUserInfoSchema, + UserSessionSchema, +} from '@/config/auth/types'; import { z } from 'zod'; import { MAX_AGE, setTokenCookie, getTokenCookie } from './cookie'; import ServerError from '../util/ServerError'; @@ -9,7 +13,7 @@ const { TOKEN_SECRET } = process.env; export async function setLoginSession( res: NextApiResponse, - session: z.infer, + session: z.infer, ) { if (!TOKEN_SECRET) { throw new ServerError('Authentication is not configured.', 500); diff --git a/config/auth/types.ts b/config/auth/types.ts index 790950f..2c63ab8 100644 --- a/config/auth/types.ts +++ b/config/auth/types.ts @@ -1,13 +1,14 @@ +import GetUserSchema from '@/services/user/schema/GetUserSchema'; import { IncomingMessage } from 'http'; import { NextApiRequest } from 'next'; import { z } from 'zod'; -export const UserInfoSchema = z.object({ +export const BasicUserInfoSchema = z.object({ id: z.string().uuid(), username: z.string(), }); -export const UserSessionSchema = UserInfoSchema.merge( +export const UserSessionSchema = BasicUserInfoSchema.merge( z.object({ createdAt: z.number(), maxAge: z.number(), @@ -15,7 +16,7 @@ export const UserSessionSchema = UserInfoSchema.merge( ); export interface ExtendedNextApiRequest extends NextApiRequest { - user?: z.infer; + user?: z.infer; } export type SessionRequest = IncomingMessage & { diff --git a/hooks/useUser.ts b/hooks/useUser.ts new file mode 100644 index 0000000..96eb9d2 --- /dev/null +++ b/hooks/useUser.ts @@ -0,0 +1,35 @@ +import GetUserSchema from '@/services/user/schema/GetUserSchema'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import useSWR from 'swr'; + +const useUser = () => { + const { + data: user, + error, + isLoading, + } = useSWR('/api/users/current', async (url) => { + 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(parsed.error.message); + } + + const parsedPayload = GetUserSchema.safeParse(parsed.data.payload); + if (!parsedPayload.success) { + throw new Error(parsedPayload.error.message); + } + + return parsedPayload.data; + }); + + return { user, isLoading, error: error as unknown }; +}; + +export default useUser; diff --git a/pages/api/users/login.ts b/pages/api/users/login.ts index 7dd0294..4a60dff 100644 --- a/pages/api/users/login.ts +++ b/pages/api/users/login.ts @@ -7,13 +7,9 @@ import { setLoginSession } from '@/config/auth/session'; import { NextApiResponse } from 'next'; import { z } from 'zod'; import ServerError from '@/config/util/ServerError'; +import LoginValidationSchema from '@/services/user/schema/LoginValidationSchema'; import { ExtendedNextApiRequest } from '../../../config/auth/types'; -const LoginSchema = z.object({ - username: z.string(), - password: z.string(), -}); - export default nextConnect< ExtendedNextApiRequest, NextApiResponse> @@ -21,7 +17,7 @@ export default nextConnect< .use(passport.initialize()) .use(async (req, res, next) => { passport.use(localStrat); - const parsed = LoginSchema.safeParse(req.body); + const parsed = LoginValidationSchema.safeParse(req.body); if (!parsed.success) { throw new ServerError('Username and password are required.', 400); } @@ -41,6 +37,7 @@ export default nextConnect< res.status(200).json({ message: 'Login successful.', + payload: user, statusCode: 200, success: true, }); diff --git a/pages/api/users/register.ts b/pages/api/users/register.ts index d134bec..3088792 100644 --- a/pages/api/users/register.ts +++ b/pages/api/users/register.ts @@ -5,6 +5,8 @@ import nc, { NextHandler } from 'next-connect'; import createNewUser from '@/services/user/createNewUser'; import CreateUserValidationSchema from '@/services/user/schema/CreateUserValidationSchema'; import NextConnectConfig from '@/config/nextConnect/NextConnectConfig'; +import findUserByUsername from '@/services/user/findUserByUsername'; +import findUserByEmail from '@/services/user/findUserByEmail'; interface RegisterUserRequest extends NextApiRequest { body: z.infer; @@ -38,6 +40,25 @@ const validateRequest = }; const registerUser = async (req: RegisterUserRequest, res: NextApiResponse) => { + const [usernameTaken, emailTaken] = await Promise.all([ + findUserByUsername(req.body.username), + findUserByEmail(req.body.email), + ]); + + if (usernameTaken) { + throw new ServerError( + 'Could not register a user with that username as it is already taken.', + 409, + ); + } + + if (emailTaken) { + throw new ServerError( + 'Could not register a user with that email as it is already taken.', + 409, + ); + } + const user = await createNewUser(req.body); res.status(201).json({ message: 'User created successfully.', diff --git a/pages/login/index.tsx b/pages/login/index.tsx new file mode 100644 index 0000000..73117a4 --- /dev/null +++ b/pages/login/index.tsx @@ -0,0 +1,107 @@ +import { NextPage } from 'next'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import FormError from '@/components/ui/forms/FormError'; +import FormInfo from '@/components/ui/forms/FormInfo'; +import FormLabel from '@/components/ui/forms/FormLabel'; +import FormSegment from '@/components/ui/forms/FormSegment'; +import FormTextInput from '@/components/ui/forms/FormTextInput'; +import Layout from '@/components/ui/Layout'; +import LoginValidationSchema from '@/services/user/schema/LoginValidationSchema'; +import sendLoginUserRequest from '@/requests/sendLoginUserRequest'; +import useUser from '@/hooks/useUser'; + +type LoginT = z.infer; +const LoginForm = () => { + const router = useRouter(); + const { register, handleSubmit, formState } = useForm({ + resolver: zodResolver(LoginValidationSchema), + defaultValues: { + username: '', + password: '', + }, + }); + + const { errors } = formState; + + const onSubmit: SubmitHandler = async (data) => { + try { + const response = await sendLoginUserRequest(data); + + router.push(`/users/${response.id}`); + } catch (error) { + console.error(error); + } + }; + + return ( +
+
+ + username + {errors.username?.message} + + + + + + + password + {errors.password?.message} + + + + +
+ +
+ +
+
+ ); +}; + +const LoginPage: NextPage = () => { + const { user } = useUser(); + const router = useRouter(); + + useEffect(() => { + if (!user) { + return; + } + + router.push(`/user/current`); + }, [user]); + + return ( + +
+
+

Login

+
+
+ +
+
+
+ ); +}; + +export default LoginPage; diff --git a/pages/protected.tsx b/pages/protected.tsx deleted file mode 100644 index b010888..0000000 --- a/pages/protected.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import withPageAuthRequired from '@/config/auth/withPageAuthRequired'; -import { GetServerSideProps, NextPage } from 'next'; - -const protectedPage: NextPage<{ - username: string; -}> = ({ username }) => { - return ( -
-

Hello, {username}!

-
- ); -}; - -export const getServerSideProps: GetServerSideProps = withPageAuthRequired(); - -export default protectedPage; diff --git a/pages/user/current.tsx b/pages/user/current.tsx new file mode 100644 index 0000000..3d07503 --- /dev/null +++ b/pages/user/current.tsx @@ -0,0 +1,30 @@ +import Layout from '@/components/ui/Layout'; +import Spinner from '@/components/ui/Spinner'; +import withPageAuthRequired from '@/config/auth/withPageAuthRequired'; +import useUser from '@/hooks/useUser'; +import { GetServerSideProps, NextPage } from 'next'; + +const ProtectedPage: NextPage = () => { + const { user, isLoading, error } = useUser(); + + return ( + +
+

Hello!

+ <> + {isLoading && } + {error &&

Something went wrong.

} + {user && ( +
+

{user.username}

+
+ )} + +
+
+ ); +}; + +export const getServerSideProps: GetServerSideProps = withPageAuthRequired(); + +export default ProtectedPage; diff --git a/requests/sendLoginUserRequest.tsx b/requests/sendLoginUserRequest.tsx new file mode 100644 index 0000000..f664c79 --- /dev/null +++ b/requests/sendLoginUserRequest.tsx @@ -0,0 +1,28 @@ +import { BasicUserInfoSchema } from '@/config/auth/types'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; + +const sendLoginUserRequest = async (data: { username: string; password: string }) => { + const response = await fetch('/api/users/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + const json: unknown = await response.json(); + + const parsed = APIResponseValidationSchema.safeParse(json); + if (!parsed.success) { + throw new Error('API response validation failed'); + } + + const parsedPayload = BasicUserInfoSchema.safeParse(parsed.data.payload); + if (!parsedPayload.success) { + throw new Error('API response payload validation failed'); + } + + return parsedPayload.data; +}; + +export default sendLoginUserRequest; diff --git a/services/user/createNewUser.ts b/services/user/createNewUser.ts index a0e19c5..4b5888b 100644 --- a/services/user/createNewUser.ts +++ b/services/user/createNewUser.ts @@ -1,9 +1,8 @@ -import findUserByUsername from '@/services/user/findUserByUsername'; import { hashPassword } from '@/config/auth/passwordFns'; import DBClient from '@/prisma/DBClient'; import { z } from 'zod'; -import ServerError from '@/config/util/ServerError'; import CreateUserValidationSchema from './schema/CreateUserValidationSchema'; +import GetUserSchema from './schema/GetUserSchema'; const createNewUser = async ({ email, @@ -13,17 +12,8 @@ const createNewUser = async ({ dateOfBirth, username, }: z.infer) => { - const userExists = await findUserByUsername(username); - - if (userExists) { - throw new ServerError( - "Could not register a user with that username as it's already taken.", - 409, - ); - } - const hash = await hashPassword(password); - const user = DBClient.instance.user.create({ + const user: z.infer = await DBClient.instance.user.create({ data: { username, email, diff --git a/services/user/findUserByEmail.ts b/services/user/findUserByEmail.ts new file mode 100644 index 0000000..a3ea319 --- /dev/null +++ b/services/user/findUserByEmail.ts @@ -0,0 +1,13 @@ +import DBClient from '@/prisma/DBClient'; + +const findUserByEmail = async (email: string) => + DBClient.instance.user.findFirst({ + where: { email }, + select: { + id: true, + username: true, + hash: true, + }, + }); + +export default findUserByEmail; diff --git a/services/user/findUserById.ts b/services/user/findUserById.ts new file mode 100644 index 0000000..23265b4 --- /dev/null +++ b/services/user/findUserById.ts @@ -0,0 +1,23 @@ +import DBClient from '@/prisma/DBClient'; +import { z } from 'zod'; +import GetUserSchema from './schema/GetUserSchema'; + +const findUserById = async (id: string) => { + const user: z.infer | null = + await DBClient.instance.user.findUnique({ + where: { id }, + select: { + id: true, + username: true, + email: true, + firstName: true, + lastName: true, + dateOfBirth: true, + createdAt: true, + }, + }); + + return user; +}; + +export default findUserById; diff --git a/services/user/schema/GetUserSchema.ts b/services/user/schema/GetUserSchema.ts new file mode 100644 index 0000000..529f824 --- /dev/null +++ b/services/user/schema/GetUserSchema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +const GetUserSchema = z.object({ + id: z.string().uuid(), + username: z.string(), + createdAt: z.date().or(z.string()), + updatedAt: z.date().or(z.string()).optional(), + email: z.string().email(), + firstName: z.string(), + lastName: z.string(), + dateOfBirth: z.date().or(z.string()), +}); + +export default GetUserSchema; diff --git a/services/user/schema/LoginValidationSchema.ts b/services/user/schema/LoginValidationSchema.ts new file mode 100644 index 0000000..5ef4c4c --- /dev/null +++ b/services/user/schema/LoginValidationSchema.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +const LoginValidationSchema = z.object({ + username: z + .string({ + required_error: 'Username is required.', + invalid_type_error: 'Username must be a string.', + }) + .min(1, { message: 'Username is required.' }), + password: z + .string({ + required_error: 'Password is required.', + invalid_type_error: 'Password must be a string.', + }) + .min(1, { message: 'Password is required.' }), +}); + +export default LoginValidationSchema;