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 = () => (
+
+);
+
+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 (
+
+ );
+};
+
+const LoginPage: NextPage = () => {
+ const { user } = useUser();
+ const router = useRouter();
+
+ useEffect(() => {
+ if (!user) {
+ return;
+ }
+
+ router.push(`/user/current`);
+ }, [user]);
+
+ return (
+
+
+
+ );
+};
+
+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 && (
+
+ )}
+ >
+
+
+ );
+};
+
+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;