Merge branch 'development'
This commit is contained in:
commit
48d079254e
|
|
@ -5,6 +5,7 @@
|
||||||
"project": "./tsconfig.json"
|
"project": "./tsconfig.json"
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"@typescript-eslint/no-floating-promises": "warn"
|
"@typescript-eslint/no-floating-promises": "warn",
|
||||||
|
"@typescript-eslint/no-unused-vars": "warn"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
import signIn from "@/modules/auth/actions/signIn";
|
import signIn from "@/modules/auth/actions/signInAction";
|
||||||
import {
|
import {
|
||||||
Paper,
|
Paper,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
|
|
@ -11,9 +11,7 @@ import {
|
||||||
Button,
|
Button,
|
||||||
Alert,
|
Alert,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { redirect } from "next/navigation";
|
import React from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { useFormState } from "react-dom";
|
import { useFormState } from "react-dom";
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import logout from "@/modules/auth/actions/logout";
|
import logoutAction from "@/modules/auth/actions/logoutAction";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -9,9 +9,9 @@ import { useEffect } from "react";
|
||||||
*/
|
*/
|
||||||
export default function LogoutPage() {
|
export default function LogoutPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const logoutAction = async () => await logout();
|
(async () => await logoutAction())()
|
||||||
|
.then(() => {})
|
||||||
logoutAction();
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <div></div>;
|
return <div></div>;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import createUser from "@/modules/auth/actions/createUser";
|
import createUserAction from "@/modules/auth/actions/createUserAction";
|
||||||
|
import createUser from "@/modules/auth/actions/createUserAction";
|
||||||
|
import { CreateUserSchema } from "@/modules/auth/formSchemas/CreateUserFormSchema";
|
||||||
|
import DashboardError from "@/modules/dashboard/errors/DashboardError";
|
||||||
|
import withServerAction from "@/modules/dashboard/utils/withServerAction";
|
||||||
import {
|
import {
|
||||||
Paper,
|
Paper,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
|
|
@ -12,65 +16,53 @@ import {
|
||||||
Button,
|
Button,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
|
import { showNotification } from "@mantine/notifications";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
export interface RegisterFormSchema {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
passwordConfirmation: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
const form = useForm<RegisterFormSchema>({
|
const form = useForm<CreateUserSchema>({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
plainPassword: "",
|
||||||
passwordConfirmation: "",
|
plainPasswordConfirmation: "",
|
||||||
name: "",
|
name: "",
|
||||||
},
|
},
|
||||||
validate: {
|
validate: {
|
||||||
email: (value: string) =>
|
email: (value: string) =>
|
||||||
/^\S+@\S+$/.test(value) ? null : "Invalid email",
|
/^\S+@\S+$/.test(value) ? null : "Invalid email",
|
||||||
password: (value: string) =>
|
plainPassword: (value: string) =>
|
||||||
value.length >= 6
|
value.length >= 6
|
||||||
? null
|
? null
|
||||||
: "Password should be at least 6 characters",
|
: "Password should be at least 6 characters",
|
||||||
passwordConfirmation: (value: string, values: RegisterFormSchema) =>
|
plainPasswordConfirmation: (value: string, values: CreateUserSchema) =>
|
||||||
value === values.password ? null : "Passwords should match",
|
value === values.plainPassword ? null : "Passwords should match",
|
||||||
name: (value: string) =>
|
name: (value: string) =>
|
||||||
value.length > 0 ? null : "Name is required",
|
value.length > 0 ? null : "Name is required",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (values: RegisterFormSchema) => {
|
const handleSubmit = async (values: CreateUserSchema) => {
|
||||||
const formData = new FormData();
|
withServerAction(createUserAction, form.values)
|
||||||
Object.entries(values).forEach(([key, value]) => {
|
.then((response) => {
|
||||||
formData.append(key, value);
|
showNotification({message: "Register Success", color: "green"})
|
||||||
});
|
})
|
||||||
|
.catch((e) => {
|
||||||
const response = await createUser(formData);
|
if (e instanceof DashboardError) {
|
||||||
|
if (e.errorCode === "INVALID_FORM_DATA") {
|
||||||
if (!response.success) {
|
form.setErrors(e.formErrors ?? {});
|
||||||
setErrorMessage(response.error.message);
|
} else {
|
||||||
|
setErrorMessage(`ERROR: ${e.message} (${e.errorCode})`);
|
||||||
if (response.error.errors) {
|
}
|
||||||
const errors = Object.entries(response.error.errors).reduce(
|
} else if (e instanceof Error) {
|
||||||
(prev, [k, v]) => {
|
setErrorMessage(`ERROR: ${e.message}`);
|
||||||
prev[k] = v[0];
|
} else {
|
||||||
return prev;
|
setErrorMessage(
|
||||||
},
|
`Unkown error is occured. Please contact administrator`
|
||||||
{} as { [k: string]: string }
|
);
|
||||||
);
|
}
|
||||||
|
})
|
||||||
form.setErrors(errors);
|
|
||||||
console.log(form.errors);
|
|
||||||
} else {
|
|
||||||
form.clearErrors();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -102,14 +94,14 @@ export default function RegisterPage() {
|
||||||
placeholder="Your password"
|
placeholder="Your password"
|
||||||
name="password"
|
name="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("plainPassword")}
|
||||||
/>
|
/>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Repeat Password"
|
label="Repeat Password"
|
||||||
placeholder="Repeat yout password"
|
placeholder="Repeat yout password"
|
||||||
name="passwordConfirmation"
|
name="passwordConfirmation"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
{...form.getInputProps("passwordConfirmation")}
|
{...form.getInputProps("plainPasswordConfirmation")}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import Image from "next/image";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import logo from "@/assets/logos/logo.png";
|
import logo from "@/assets/logos/logo.png";
|
||||||
import DashboardLayout from "@/modules/dashboard/components/DashboardLayout";
|
import DashboardLayout from "@/modules/dashboard/components/DashboardLayout";
|
||||||
import getUser from "@/modules/auth/actions/getUser";
|
import getUser from "@/modules/auth/actions/getMyDetailAction";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Notifications } from "@mantine/notifications";
|
import { Notifications } from "@mantine/notifications";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
"use server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import prisma from "@/core/db";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { hashPassword } from "../utils/hashPassword";
|
|
||||||
import { createJwtToken } from "../utils/createJwtToken";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for the schema of a new user.
|
|
||||||
*/
|
|
||||||
interface CreateUserSchema {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validation schema for creating a user.
|
|
||||||
*/
|
|
||||||
const createUserSchema = z
|
|
||||||
.object({
|
|
||||||
name: z.string(),
|
|
||||||
email: z.string().email(),
|
|
||||||
password: z.string().min(6),
|
|
||||||
passwordConfirmation: z.string().optional(),
|
|
||||||
})
|
|
||||||
.refine((data) => data.password === data.passwordConfirmation, {
|
|
||||||
message: "Password confirmation must match the password",
|
|
||||||
path: ["passwordConfirmation"],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new user in the system.
|
|
||||||
*
|
|
||||||
* @param formData - The form data containing user details.
|
|
||||||
* @returns An object indicating the result of the operation.
|
|
||||||
*/
|
|
||||||
export default async function createUser(formData: FormData) {
|
|
||||||
//TODO: Add Throttling
|
|
||||||
//TODO: Add validation check if the user is already logged in
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsedData = {
|
|
||||||
email: formData.get("email")?.toString() ?? "",
|
|
||||||
name: formData.get("name")?.toString() ?? "",
|
|
||||||
password: formData.get("password")?.toString() ?? "",
|
|
||||||
passwordConfirmation: formData
|
|
||||||
.get("passwordConfirmation")
|
|
||||||
?.toString(),
|
|
||||||
};
|
|
||||||
const validatedFields = createUserSchema.safeParse(parsedData);
|
|
||||||
|
|
||||||
if (!validatedFields.success) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
message: "",
|
|
||||||
errors: validatedFields.error.flatten().fieldErrors,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
|
||||||
where: { email: validatedFields.data.email },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingUser) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
message: "",
|
|
||||||
errors: {
|
|
||||||
email: ["Email already exists"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
name: validatedFields.data.name,
|
|
||||||
email: validatedFields.data.email,
|
|
||||||
passwordHash: await hashPassword(validatedFields.data.password),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const token = createJwtToken({ id: user.id });
|
|
||||||
cookies().set("token", token);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
// Handle unexpected errors
|
|
||||||
console.error(e);
|
|
||||||
//@ts-ignore
|
|
||||||
console.log(e.message);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
message:
|
|
||||||
"An unexpected error occurred on the server. Please try again or contact the administrator.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect("/dashboard");
|
|
||||||
}
|
|
||||||
30
src/modules/auth/actions/createUserAction.ts
Normal file
30
src/modules/auth/actions/createUserAction.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
"use server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import prisma from "@/core/db";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { hashPassword } from "../utils/hashPassword";
|
||||||
|
import { createJwtToken } from "../utils/createJwtToken";
|
||||||
|
import createUser from "../services/createUser";
|
||||||
|
import ServerResponseAction from "@/modules/dashboard/types/ServerResponseAction";
|
||||||
|
import handleCatch from "@/modules/dashboard/utils/handleCatch";
|
||||||
|
import { CreateUserSchema } from "../formSchemas/CreateUserFormSchema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new user in the system.
|
||||||
|
*
|
||||||
|
* @param formData - The form data containing user details.
|
||||||
|
* @returns An object indicating the result of the operation.
|
||||||
|
*/
|
||||||
|
export default async function createUserAction(formData: CreateUserSchema): Promise<ServerResponseAction> {
|
||||||
|
//TODO: Add Throttling
|
||||||
|
//TODO: Add validation check if the user is already logged in
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createUser(formData);
|
||||||
|
cookies().set("token", result.token);
|
||||||
|
redirect("/dashboard");
|
||||||
|
} catch (e) {
|
||||||
|
return handleCatch(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/modules/auth/actions/getMyDetailAction.ts
Normal file
47
src/modules/auth/actions/getMyDetailAction.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import getMyDetail from "../services/getMyDetail";
|
||||||
|
import AuthError from "../error/AuthError";
|
||||||
|
import BaseError from "@/core/error/BaseError";
|
||||||
|
import ServerResponseAction from "@/modules/dashboard/types/ServerResponseAction";
|
||||||
|
import handleCatch from "@/modules/dashboard/utils/handleCatch";
|
||||||
|
import "server-only";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import getUserFromToken from "../utils/getUserFromToken";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously retrieves the authenticated user's details from a server-side context in a Next.js application.
|
||||||
|
* This function uses a JWT token obtained from cookies to authenticate the user and fetch their details.
|
||||||
|
* If the authentication fails due to an invalid JWT token, or if any other error occurs, the function handles these errors gracefully.
|
||||||
|
*
|
||||||
|
* @returns A promise that resolves to a `ServerResponseAction` object. This object includes a `success` flag indicating the operation's outcome, the user's details in the `data` field if successful, or an error object in the `error` field if an error occurs.
|
||||||
|
* @throws an unhandled error if an unexpected error occurs during the function execution.
|
||||||
|
*/
|
||||||
|
export default async function getMyDetailAction(): Promise<ServerResponseAction<Awaited<ReturnType<typeof getMyDetail>>>> {
|
||||||
|
try {
|
||||||
|
const token = cookies().get("token");
|
||||||
|
|
||||||
|
// Return null if token is not present
|
||||||
|
if (!token) throw new AuthError({errorCode: "INVALID_JWT_TOKEN"});
|
||||||
|
|
||||||
|
// Attempt to fetch and return the user's details.
|
||||||
|
const userDetails = await getMyDetail(token.value);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: userDetails,
|
||||||
|
};
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// Check if the error is an instance of AuthError and handle it.
|
||||||
|
if (e instanceof AuthError && e.errorCode === "INVALID_JWT_TOKEN") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: new BaseError({
|
||||||
|
errorCode: e.errorCode,
|
||||||
|
message: "You are not authenticated",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Handle other types of errors.
|
||||||
|
return handleCatch(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import "server-only";
|
|
||||||
import getUserFromToken from "../utils/getUserFromToken";
|
|
||||||
import AuthError from "../error/AuthError";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the user details based on the JWT token from cookies.
|
|
||||||
* This function is designed to be used in a server-side context within a Next.js application.
|
|
||||||
* It attempts to parse the user's token, fetch the user's details, and format the response.
|
|
||||||
* If the token is invalid or the user cannot be found, it gracefully handles these cases.
|
|
||||||
*
|
|
||||||
* @returns A promise that resolves to the user's details object or null if the user cannot be authenticated or an error occurs.
|
|
||||||
* @throws an error if an unexpected error occurs during execution.
|
|
||||||
*/
|
|
||||||
export default async function getUser() {
|
|
||||||
try {
|
|
||||||
const token = cookies().get("token");
|
|
||||||
|
|
||||||
if (!token) return null;
|
|
||||||
|
|
||||||
const user = await getUserFromToken(token.value);
|
|
||||||
|
|
||||||
if (!user) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: user.name ?? "",
|
|
||||||
email: user.email ?? "",
|
|
||||||
photoUrl: user.photoProfile ?? null,
|
|
||||||
};
|
|
||||||
} catch (e: unknown) {
|
|
||||||
// Handle specific authentication errors gracefully
|
|
||||||
if (e instanceof AuthError && e.errorCode === "INVALID_JWT_TOKEN") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,25 @@
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import getUser from "./getUser";
|
import getMyDetail from "../services/getMyDetail";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
export default async function guestOnly() {
|
/**
|
||||||
const user = await getUser();
|
* Enforces a guest-only access policy by redirecting authenticated users to the dashboard.
|
||||||
|
* This function asynchronously checks if the user is authenticated by attempting to retrieve user details.
|
||||||
|
* If the user is authenticated, they are redirected to the dashboard page.
|
||||||
|
*
|
||||||
|
* @returns A promise that resolves when the operation completes. The function itself does not return a value.
|
||||||
|
*/
|
||||||
|
export default async function guestOnly(): Promise<void> {
|
||||||
|
const token = cookies().get("token");
|
||||||
|
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const user = await getMyDetail(token.value);
|
||||||
|
|
||||||
|
// If an authenticated user is detected, redirect them to the dashboard.
|
||||||
if (user) {
|
if (user) {
|
||||||
redirect("dashboard");
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
"use server";
|
|
||||||
import prisma from "@/core/db";
|
|
||||||
import { User } from "@prisma/client";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import AuthError from "../error/AuthError";
|
|
||||||
import comparePassword from "../utils/comparePassword";
|
|
||||||
import { createJwtToken } from "../utils/createJwtToken";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the sign-in process for a user.
|
|
||||||
*
|
|
||||||
* This function validates a user's credentials (email and password), checks against the database,
|
|
||||||
* and on successful validation, redirects the user to the dashboard and sets a cookie with a JWT token.
|
|
||||||
* If the validation fails at any stage, it throws a custom AuthError.
|
|
||||||
*
|
|
||||||
* @param prevState - The previous state of the application, not currently used.
|
|
||||||
* @param rawFormData - The raw form data containing the user's email and password.
|
|
||||||
* @returns A promise that resolves to a redirect to the dashboard on successful authentication,
|
|
||||||
* or an object containing error details on failure.
|
|
||||||
* @throws Specific authentication error based on the failure stage.
|
|
||||||
*/
|
|
||||||
export default async function signIn(prevState: any, rawFormData: FormData) {
|
|
||||||
//TODO: Add Throttling
|
|
||||||
//TODO: Add validation check if the user is already logged in
|
|
||||||
try {
|
|
||||||
const formData = {
|
|
||||||
email: rawFormData.get("email") as string,
|
|
||||||
password: rawFormData.get("password") as string,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Retrieve user from the database by email
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { email: formData.email },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Throw if user not found
|
|
||||||
if (!user)
|
|
||||||
throw new AuthError({
|
|
||||||
errorCode: "EMAIL_NOT_FOUND",
|
|
||||||
message: "Email or Password does not match",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Throw if user has no password hash
|
|
||||||
// TODO: Add check if the user uses another provider
|
|
||||||
if (!user.passwordHash)
|
|
||||||
throw new AuthError({ errorCode: "EMPTY_USER_HASH" });
|
|
||||||
|
|
||||||
// Compare the provided password with the user's stored password hash
|
|
||||||
const isMatch = await comparePassword(
|
|
||||||
formData.password,
|
|
||||||
user.passwordHash
|
|
||||||
);
|
|
||||||
if (!isMatch)
|
|
||||||
throw new AuthError({
|
|
||||||
errorCode: "INVALID_CREDENTIALS",
|
|
||||||
message: "Email or Password does not match",
|
|
||||||
});
|
|
||||||
|
|
||||||
//Set cookie
|
|
||||||
//TODO: Auth: Add expiry
|
|
||||||
const token = createJwtToken({ id: user.id });
|
|
||||||
|
|
||||||
cookies().set("token", token);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
// Custom error handling for authentication errors
|
|
||||||
if (e instanceof AuthError) {
|
|
||||||
// Specific error handling for known authentication errors
|
|
||||||
switch (e.errorCode) {
|
|
||||||
case "EMAIL_NOT_FOUND":
|
|
||||||
case "INVALID_CREDENTIALS":
|
|
||||||
return {
|
|
||||||
errors: {
|
|
||||||
message:
|
|
||||||
"Email/Password combination is incorrect. Please try again.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
// Handle other types of authentication errors
|
|
||||||
return {
|
|
||||||
errors: {
|
|
||||||
message: e.message,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic error handling for unexpected server errors
|
|
||||||
return {
|
|
||||||
errors: {
|
|
||||||
message:
|
|
||||||
"An unexpected error occurred on the server. Please try again or contact the administrator.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect("/dashboard");
|
|
||||||
}
|
|
||||||
70
src/modules/auth/actions/signInAction.ts
Normal file
70
src/modules/auth/actions/signInAction.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"use server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import AuthError from "../error/AuthError";
|
||||||
|
import signIn from "../services/signIn";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously handles the sign-in process for a user by validating their credentials against the database.
|
||||||
|
* Upon successful validation, the user is redirected to the dashboard, and a JWT token is set as a cookie.
|
||||||
|
* If validation fails, a custom `AuthError` is thrown, and detailed error information is provided to the caller.
|
||||||
|
*
|
||||||
|
* Note: Future enhancements should include throttling to prevent brute force attacks and a check to prevent
|
||||||
|
* sign-in attempts if the user is already logged in.
|
||||||
|
*
|
||||||
|
* @param prevState - The previous state of the application. Currently not utilized but may be used for future enhancements.
|
||||||
|
* @param rawFormData - The raw form data obtained from the sign-in form, containing the user's email and password.
|
||||||
|
* @returns A promise that, upon successful authentication, resolves to a redirection to the dashboard. If authentication fails,
|
||||||
|
* it resolves to an object containing error details.
|
||||||
|
* @throws {AuthError} - Throws a custom `AuthError` with specific error codes for different stages of the authentication failure.
|
||||||
|
*/
|
||||||
|
export default async function signInAction(prevState: any, rawFormData: FormData) {
|
||||||
|
//TODO: Add Throttling
|
||||||
|
//TODO: Add validation check if the user is already logged in
|
||||||
|
try {
|
||||||
|
// Extract email and password from the raw form data.
|
||||||
|
const formData = {
|
||||||
|
email: rawFormData.get("email") as string,
|
||||||
|
password: rawFormData.get("password") as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attempt to sign in with the provided credentials.
|
||||||
|
const result = await signIn(formData)
|
||||||
|
|
||||||
|
// Set the JWT token in cookies upon successful sign-in.
|
||||||
|
cookies().set("token", result.token);
|
||||||
|
|
||||||
|
// Redirect to the dashboard after successful sign-in.
|
||||||
|
redirect("/dashboard");
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// Custom error handling for authentication errors
|
||||||
|
if (e instanceof AuthError) {
|
||||||
|
// Specific error handling for known authentication errors
|
||||||
|
switch (e.errorCode) {
|
||||||
|
case "EMAIL_NOT_FOUND":
|
||||||
|
case "INVALID_CREDENTIALS":
|
||||||
|
return {
|
||||||
|
errors: {
|
||||||
|
message:
|
||||||
|
"Email/Password combination is incorrect. Please try again.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
// Handle other types of authentication errors
|
||||||
|
return {
|
||||||
|
errors: {
|
||||||
|
message: e.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic error handling for unexpected server errors
|
||||||
|
return {
|
||||||
|
errors: {
|
||||||
|
message:
|
||||||
|
"An unexpected error occurred on the server. Please try again or contact the administrator.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,68 +1,90 @@
|
||||||
|
// Directive to enforce client-side operation in a Next.js application.
|
||||||
"use client";
|
"use client";
|
||||||
import React, {
|
|
||||||
ReactElement,
|
|
||||||
ReactNode,
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import getUser from "../actions/getUser";
|
|
||||||
|
|
||||||
|
// Importing React functionalities and required components.
|
||||||
|
import React, { ReactElement, ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import getMyDetailAction from "../actions/getMyDetailAction";
|
||||||
|
import withServerAction from "@/modules/dashboard/utils/withServerAction";
|
||||||
|
|
||||||
|
// Defining the structure for user data within the authentication context.
|
||||||
interface UserData {
|
interface UserData {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
photoUrl: string | null;
|
photoUrl: string | null;
|
||||||
// Add additional user fields as needed
|
// Additional user fields can be added here.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// State structure for the authentication context.
|
||||||
interface AuthContextState {
|
interface AuthContextState {
|
||||||
user: UserData | null;
|
user: UserData | null;
|
||||||
fetchUserData: () => void;
|
fetchUserData: () => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Props type definition for the AuthContextProvider component.
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Creating the authentication context with an undefined initial value.
|
||||||
const AuthContext = createContext<AuthContextState | undefined>(undefined);
|
const AuthContext = createContext<AuthContextState | undefined>(undefined);
|
||||||
|
|
||||||
export const AuthContextProvider = ({ children }: Props) => {
|
/**
|
||||||
const [user, setUser] = useState<UserData | null>(null);
|
* Provides an authentication context to wrap around components that require authentication data.
|
||||||
|
* This component initializes user data state, fetches user data upon component mount, and provides
|
||||||
|
* a logout function to clear the user data.
|
||||||
|
*
|
||||||
|
* @param {Props} props - Component props containing children to be rendered within the provider.
|
||||||
|
* @returns {ReactElement} A provider component wrapping children with access to authentication context.
|
||||||
|
*/
|
||||||
|
export const AuthContextProvider = ({ children }: Props): ReactElement => {
|
||||||
|
const [user, setUser] = useState<UserData | null>(null);
|
||||||
|
|
||||||
const fetchUserData = useCallback(() => {
|
// Function to fetch user data and update state accordingly.
|
||||||
const getUserData = async () => {
|
const fetchUserData = useCallback(() => {
|
||||||
const user = await getUser();
|
withServerAction(getMyDetailAction)
|
||||||
setUser(user);
|
.then((response) => {
|
||||||
};
|
setUser(response.data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Error while retrieving user data',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
console.error("Error while retrieving user data", error);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
getUserData()
|
// Fetch user data on component mount.
|
||||||
.then(() => {})
|
useEffect(() => {
|
||||||
.catch(() => {});
|
fetchUserData();
|
||||||
}, []);
|
}, [fetchUserData]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Function to clear user data, effectively logging the user out.
|
||||||
fetchUserData();
|
const logout = () => {
|
||||||
}, [fetchUserData]);
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
const logout = () => {
|
// Providing authentication state and functions to the context consumers.
|
||||||
setUser(null);
|
return (
|
||||||
};
|
<AuthContext.Provider value={{ user, fetchUserData, logout }}>
|
||||||
|
{children}
|
||||||
return (
|
</AuthContext.Provider>
|
||||||
<AuthContext.Provider value={{ user, fetchUserData, logout }}>
|
);
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAuth = () => {
|
/**
|
||||||
const context = useContext(AuthContext);
|
* Custom hook to consume the authentication context. This hook ensures the context is used within a provider.
|
||||||
if (!context) {
|
*
|
||||||
throw new Error("useAuth must be used within an AuthContextProvider");
|
* @returns {AuthContextState} The authentication context state including user data and auth functions.
|
||||||
}
|
* @throws {Error} Throws an error if the hook is used outside of an AuthContextProvider.
|
||||||
return context;
|
*/
|
||||||
|
export const useAuth = (): AuthContextState => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuth must be used within an AuthContextProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
23
src/modules/auth/formSchemas/CreateUserFormSchema.ts
Normal file
23
src/modules/auth/formSchemas/CreateUserFormSchema.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import {z} from "zod"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for the schema of a new user.
|
||||||
|
*/
|
||||||
|
export interface CreateUserSchema {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
plainPassword: string;
|
||||||
|
plainPasswordConfirmation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createUserSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(6),
|
||||||
|
passwordConfirmation: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.passwordConfirmation, {
|
||||||
|
message: "Password confirmation must match the password",
|
||||||
|
path: ["passwordConfirmation"],
|
||||||
|
});
|
||||||
63
src/modules/auth/services/createUser.ts
Normal file
63
src/modules/auth/services/createUser.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import DashboardError from "@/modules/dashboard/errors/DashboardError";
|
||||||
|
import {
|
||||||
|
CreateUserSchema,
|
||||||
|
createUserSchema,
|
||||||
|
} from "../formSchemas/CreateUserFormSchema";
|
||||||
|
import mapObjectToFirstValue from "@/utils/mapObjectToFirstValue";
|
||||||
|
import db from "@/core/db";
|
||||||
|
import AuthError from "../error/AuthError";
|
||||||
|
import hashPassword from "../utils/hashPassword";
|
||||||
|
import { createJwtToken } from "../utils/createJwtToken";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new user in the database after validating the input data.
|
||||||
|
* It throws errors if the input data is invalid or if the user already exists.
|
||||||
|
* On successful creation, it returns a token for the created user.
|
||||||
|
*
|
||||||
|
* @param userData - The user data to create a new user. Must conform to CreateUserSchema.
|
||||||
|
* @returns An object containing the JWT token for the newly created user.
|
||||||
|
* @throws If the input validation fails.
|
||||||
|
* @throws If the user already exists in the database.
|
||||||
|
*/
|
||||||
|
export default async function createUser(userData: CreateUserSchema) {
|
||||||
|
const validatedFields = createUserSchema.safeParse(userData);
|
||||||
|
|
||||||
|
//Validate form input
|
||||||
|
if (!validatedFields.success) {
|
||||||
|
throw new DashboardError({
|
||||||
|
errorCode: "INVALID_FORM_DATA",
|
||||||
|
formErrors: mapObjectToFirstValue(
|
||||||
|
validatedFields.error.flatten().fieldErrors
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//Check email exists
|
||||||
|
if (
|
||||||
|
await db.user.findFirst({
|
||||||
|
where: { email: validatedFields.data.email },
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
throw new AuthError({
|
||||||
|
errorCode: "USER_ALREADY_EXISTS",
|
||||||
|
message: "This email already exists",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//Create user
|
||||||
|
const user = await db.user.create({
|
||||||
|
data: {
|
||||||
|
name: validatedFields.data.name,
|
||||||
|
email: validatedFields.data.email,
|
||||||
|
passwordHash: await hashPassword(validatedFields.data.password),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
//Set token
|
||||||
|
const token = createJwtToken({ id: user.id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
}
|
||||||
30
src/modules/auth/services/getMyDetail.ts
Normal file
30
src/modules/auth/services/getMyDetail.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import getUserFromToken from "../utils/getUserFromToken";
|
||||||
|
import AuthError from "../error/AuthError";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the details of the currently authenticated user based on the JWT token.
|
||||||
|
* If the token is not present or the user cannot be found, it returns null.
|
||||||
|
* Otherwise, it returns the user's name, email, and photo URL.
|
||||||
|
*
|
||||||
|
* @returns An object containing the user's name, email, and photo URL, or null if the user cannot be authenticated.
|
||||||
|
*/
|
||||||
|
export default async function getMyDetail(token?: string) {
|
||||||
|
if (!token)
|
||||||
|
throw new AuthError({
|
||||||
|
errorCode: "INVALID_JWT_TOKEN",
|
||||||
|
message: "You are not authenticated",
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token);
|
||||||
|
|
||||||
|
// Return null if user is not found
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
// Return user details
|
||||||
|
return {
|
||||||
|
name: user.name ?? "",
|
||||||
|
email: user.email ?? "",
|
||||||
|
photoUrl: user.photoProfile ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
54
src/modules/auth/services/signIn.ts
Normal file
54
src/modules/auth/services/signIn.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import "server-only";
|
||||||
|
import SignInFormData from "../types/SignInFormData";
|
||||||
|
import db from "@/core/db";
|
||||||
|
import AuthError from "../error/AuthError";
|
||||||
|
import comparePassword from "../utils/comparePassword";
|
||||||
|
import { createJwtToken } from "../utils/createJwtToken";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates a user with email and password credentials.
|
||||||
|
*
|
||||||
|
* This function looks up the user in the database by email. If the user exists and the password matches
|
||||||
|
* the hashed password in the database, a JWT token is created and returned. If any step of this process fails,
|
||||||
|
* an `AuthError` with a specific error code and message is thrown.
|
||||||
|
*
|
||||||
|
* @param rawCredential - Contains the email and password provided by the user.
|
||||||
|
* @returns An object containing a JWT token if authentication is successful.
|
||||||
|
* @throws {AuthError} - Throws an `AuthError` with an appropriate error code and message for various failure scenarios.
|
||||||
|
*/
|
||||||
|
export default async function signIn(rawCredential: SignInFormData) {
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { email: rawCredential.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
throw new AuthError({
|
||||||
|
errorCode: "EMAIL_NOT_FOUND",
|
||||||
|
message: "Email or Password does not match",
|
||||||
|
});
|
||||||
|
|
||||||
|
//TODO: Add handle for empty password hash
|
||||||
|
// Ensure there is a password hash to compare against.
|
||||||
|
if (!user.passwordHash)
|
||||||
|
throw new AuthError({
|
||||||
|
errorCode: "EMPTY_USER_HASH",
|
||||||
|
message: "Something wrong. Please contact your administrator",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compare the provided password with the stored hash.
|
||||||
|
const isMatch = await comparePassword(
|
||||||
|
rawCredential.password,
|
||||||
|
user.passwordHash
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a JWT token upon successful authentication.
|
||||||
|
if (!isMatch)
|
||||||
|
throw new AuthError({
|
||||||
|
errorCode: "INVALID_CREDENTIALS",
|
||||||
|
message: "Email or Password does not match",
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = createJwtToken({ id: user.id });
|
||||||
|
|
||||||
|
return { token };
|
||||||
|
}
|
||||||
21
src/modules/auth/types/SignInFormData.d.ts
vendored
Normal file
21
src/modules/auth/types/SignInFormData.d.ts
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
/**
|
||||||
|
* Defines the structure for sign-in form data.
|
||||||
|
*
|
||||||
|
* This interface is utilized to type-check the data received from the sign-in form, ensuring that it contains
|
||||||
|
* both an `email` and a `password` field of type `string`. The `email` field represents the user's email address,
|
||||||
|
* and the `password` field represents the user's password. Both fields are required for the sign-in process.
|
||||||
|
*/
|
||||||
|
export default interface SignInFormData {
|
||||||
|
/**
|
||||||
|
* The user's email address.
|
||||||
|
* Must be a valid email format.
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's password.
|
||||||
|
* There are no specific constraints defined here for the password's format or strength, but it is expected
|
||||||
|
* to comply with the application's password policy.
|
||||||
|
*/
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
"use client";
|
"use client";
|
||||||
import getUser from "@/modules/auth/actions/getUser";
|
|
||||||
import CrudPermissions from "@/modules/dashboard/types/CrudPermissions";
|
import CrudPermissions from "@/modules/dashboard/types/CrudPermissions";
|
||||||
import { Table, Text, Flex, Button, Center } from "@mantine/core";
|
import { Table, Text, Flex, Button, Center } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user