Revamp auth system

This commit is contained in:
sianida26 2024-03-29 02:28:13 +07:00
parent 667e06b198
commit b9214dfe88
16 changed files with 146 additions and 172 deletions

View File

@ -1,25 +1,36 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import React from "react"; import React from "react";
import DashboardLayout from "@/modules/dashboard/components/DashboardLayout"; import DashboardLayout from "@/modules/dashboard/components/DashboardLayout";
import getUser from "@/modules/auth/actions/getMyDetailAction";
import { redirect } from "next/navigation";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import getCurrentUser from "@/modules/auth/services/getCurrentUser";
import { AuthContextProvider } from "@/modules/auth/contexts/AuthContext";
import getSidebarMenus from "@/modules/dashboard/services/getSidebarMenus";
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
} }
export default async function Layout(props: Props) { export default async function Layout(props: Props) {
const user = await getUser(); const user = (await getCurrentUser());
if (!user) { // if (!user) {
redirect("/login"); // redirect("/dashboard/login");
} // }
const userData = user ? {
id: user.id,
name: user.name ?? "",
email: user.email ?? "",
photoProfile: user.photoProfile,
sidebarMenus: await getSidebarMenus()
} : null;
return ( return (
<MantineProvider> <MantineProvider>
<Notifications /> <Notifications />
<DashboardLayout>{props.children}</DashboardLayout> <AuthContextProvider userData={userData}>
<DashboardLayout isLoggedIn={!!user}>{props.children}</DashboardLayout>
</AuthContextProvider>
</MantineProvider> </MantineProvider>
); );
} }

View File

@ -6,7 +6,6 @@ import "@mantine/core/styles.css";
import '@mantine/notifications/styles.css'; import '@mantine/notifications/styles.css';
import { ColorSchemeScript } from "@mantine/core"; import { ColorSchemeScript } from "@mantine/core";
import { AuthContextProvider } from "@/modules/auth/contexts/AuthContext";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
@ -26,7 +25,7 @@ export default function RootLayout({
<ColorSchemeScript /> <ColorSchemeScript />
</head> </head>
<body className={inter.className}> <body className={inter.className}>
<AuthContextProvider>{children}</AuthContextProvider> {children}
</body> </body>
</html> </html>
); );

View File

@ -12,6 +12,7 @@ import { cookies } from "next/headers";
* This function uses a JWT token obtained from cookies to authenticate the user and fetch their details. * 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. * If the authentication fails due to an invalid JWT token, or if any other error occurs, the function handles these errors gracefully.
* *
* @deprecated
* @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. * @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. * @throws an unhandled error if an unexpected error occurs during the function execution.
*/ */

View File

@ -1,8 +1,7 @@
"use server"; "use server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import getMyDetail from "../services/getMyDetail"; import getCurrentUser from "../services/getCurrentUser";
import { cookies } from "next/headers";
/** /**
* Enforces a guest-only access policy by redirecting authenticated users to the dashboard. * Enforces a guest-only access policy by redirecting authenticated users to the dashboard.
@ -12,11 +11,7 @@ import { cookies } from "next/headers";
* @returns A promise that resolves when the operation completes. The function itself does not return a value. * @returns A promise that resolves when the operation completes. The function itself does not return a value.
*/ */
export default async function guestOnly(): Promise<void> { export default async function guestOnly(): Promise<void> {
const token = cookies().get("token"); const user = await getCurrentUser();
if (!token) return;
const user = await getMyDetail(token.value);
// If an authenticated user is detected, redirect them to the dashboard. // If an authenticated user is detected, redirect them to the dashboard.
if (user) { if (user) {

View File

@ -1,5 +1,6 @@
"use server"; "use server";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import "server-only"; import "server-only";
@ -12,5 +13,6 @@ import "server-only";
*/ */
export default async function logout() { export default async function logout() {
cookies().delete("token"); cookies().delete("token");
revalidatePath("/dashboard/login");
redirect("/dashboard/login"); redirect("/dashboard/login");
} }

View File

@ -2,30 +2,29 @@
"use client"; "use client";
// Importing React functionalities and required components. // Importing React functionalities and required components.
import React, { ReactElement, ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react"; import React, { ReactElement, ReactNode, createContext, useContext } from "react";
import { notifications } from "@mantine/notifications"; import SidebarMenu from "@/modules/dashboard/types/SidebarMenu";
import getMyDetailAction from "../actions/getMyDetailAction";
import withServerAction from "@/modules/dashboard/utils/withServerAction";
import ClientError from "@/core/error/ClientError";
// Defining the structure for user data within the authentication context. // Defining the structure for user data within the authentication context.
interface UserData { interface UserData {
name: string; id: string,
email: string; name: string,
photoUrl: string | null; email: string,
// Additional user fields can be added here. photoProfile: string | null,
sidebarMenus: SidebarMenu[]
} }
// State structure for the authentication context. // 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. // Props type definition for the AuthContextProvider component.
interface Props { interface Props {
children: ReactNode; children: ReactNode;
userData: UserData | null;
} }
// Creating the authentication context with an undefined initial value. // Creating the authentication context with an undefined initial value.
@ -39,41 +38,11 @@ const AuthContext = createContext<AuthContextState | undefined>(undefined);
* @param {Props} props - Component props containing children to be rendered within the provider. * @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. * @returns {ReactElement} A provider component wrapping children with access to authentication context.
*/ */
export const AuthContextProvider = ({ children }: Props): ReactElement => { export const AuthContextProvider = ({ children, userData }: Props): ReactElement => {
const [user, setUser] = useState<UserData | null>(null);
// Function to fetch user data and update state accordingly.
const fetchUserData = useCallback(() => {
withServerAction(getMyDetailAction)
.then((response) => {
setUser(response.data);
})
.catch((error) => {
if (error instanceof ClientError){
if (error.errorCode === "UNAUTHENTICATED") return;
}
notifications.show({
title: 'Error',
message: 'Error while retrieving user data',
color: 'red',
});
console.error("Error while retrieving user data", error);
});
}, []);
// Fetch user data on component mount.
useEffect(() => {
fetchUserData();
}, [fetchUserData]);
// Function to clear user data, effectively logging the user out.
const logout = () => {
setUser(null);
};
// Providing authentication state and functions to the context consumers. // Providing authentication state and functions to the context consumers.
return ( return (
<AuthContext.Provider value={{ user, fetchUserData, logout }}> <AuthContext.Provider value={{ user: userData }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@ -0,0 +1,13 @@
import { cookies } from "next/headers";
import "server-only";
import getUserFromToken from "../utils/getUserFromToken";
export default async function getCurrentUser() {
const token = cookies().get("token")?.value;
if (!token) return null;
const userData = await getUserFromToken(token);
return userData;
}

View File

@ -6,6 +6,9 @@ import AuthError from "../error/AuthError";
* If the token is not present or the user cannot be found, it returns null. * 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. * Otherwise, it returns the user's name, email, and photo URL.
* *
* Deprecated. use getCurrentUser() instead (see getCurrentUser.ts)
*
* @deprecated
* @returns An object containing the user's name, email, and photo URL, or null if the user cannot be authenticated. * @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) { export default async function getMyDetail(token?: string) {

View File

@ -1,6 +1,7 @@
import { cache } from "react"; import { cache } from "react";
import decodeJwtToken from "./decodeJwtToken"; import decodeJwtToken from "./decodeJwtToken";
import prisma from "@/core/db"; import prisma from "@/core/db";
import "server-only";
/** /**
* Retrieves user data from the database based on the provided JWT token. * Retrieves user data from the database based on the provided JWT token.
@ -12,7 +13,7 @@ import prisma from "@/core/db";
* @returns The user's data if the user exists, or null if no user is found. * @returns The user's data if the user exists, or null if no user is found.
* Throws an error if the token is invalid or the database query fails. * Throws an error if the token is invalid or the database query fails.
*/ */
const getUserFromToken = async (token: string) => { const getUserFromToken = cache(async (token: string) => {
// Decode the JWT token to extract the user ID // Decode the JWT token to extract the user ID
const decodedToken = decodeJwtToken(token) as { id: string; iat: number }; const decodedToken = decodeJwtToken(token) as { id: string; iat: number };
@ -32,6 +33,6 @@ const getUserFromToken = async (token: string) => {
}); });
return user; return user;
}; });
export default getUserFromToken; export default getUserFromToken;

View File

@ -1,67 +0,0 @@
"use server";
import SidebarMenu from "../types/SidebarMenu";
import "server-only";
import ServerResponseAction from "../types/ServerResponseAction";
import handleCatch from "../utils/handleCatch";
import getUserRoles from "@/modules/auth/utils/getUserRoles";
import getUserPermissions from "@/modules/auth/utils/getUserPermissions";
import sidebarMenus from "../data/sidebarMenus";
export default async function getSidebarMenus(): Promise<
ServerResponseAction<SidebarMenu[]>
> {
try {
const filteredMenus: SidebarMenu[] = [];
const roles = await getUserRoles();
const permissions = await getUserPermissions();
for (let menu of sidebarMenus) {
//if has children
if (menu.children) {
const currentMenuChildren: SidebarMenu["children"] = [];
for (let menuChild of menu.children) {
if (
menuChild.allowedPermissions?.some((perm) =>
permissions?.includes(perm)
) ||
menuChild.allowedRoles?.some((role) =>
roles?.includes(role)
) ||
menuChild.allowedPermissions?.includes("*") ||
menuChild.allowedRoles?.includes("*")
|| roles.includes("super-admin")
)
currentMenuChildren.push(menuChild);
}
if (currentMenuChildren.length > 0) {
filteredMenus.push({
...menu,
children: currentMenuChildren,
});
}
}
//if does not have any children
else {
if (
menu.allowedPermissions?.some((perm) =>
permissions?.includes(perm)
) ||
menu.allowedRoles?.some((role) => roles?.includes(role)) ||
menu.allowedPermissions?.includes("*") ||
menu.allowedRoles?.includes("*")
) {
filteredMenus.push(menu);
}
}
}
return {
success: true,
data: filteredMenus,
};
} catch (e) {
return handleCatch(e);
}
}

View File

@ -0,0 +1,22 @@
"use server";
import "server-only";
import SidebarMenu from "../types/SidebarMenu";
import ServerResponseAction from "../types/ServerResponseAction";
import handleCatch from "../utils/handleCatch";
import getSidebarMenus from "../services/getSidebarMenus";
export default async function getSidebarMenusAction(): Promise<
ServerResponseAction<SidebarMenu[]>
> {
try {
const filteredMenus = await getSidebarMenus();
return {
success: true,
data: filteredMenus,
};
} catch (e) {
return handleCatch(e);
}
}

View File

@ -64,7 +64,7 @@ export default function AppHeader(props: Props) {
> >
<Group gap={7}> <Group gap={7}>
<Avatar <Avatar
src={user?.photoUrl} src={user?.photoProfile}
alt={user?.name} alt={user?.name}
radius="xl" radius="xl"
size={20} size={20}

View File

@ -1,10 +1,8 @@
import React, { useEffect, useState } from "react"; import React from "react";
import { AppShell, ScrollArea, Skeleton, Stack } from "@mantine/core"; import { AppShell, ScrollArea } from "@mantine/core";
import MenuItem from "./SidebarMenuItem"; import MenuItem from "./SidebarMenuItem";
import getSidebarMenus from "../actions/getSidebarMenus"; import { useAuth } from "@/modules/auth/contexts/AuthContext";
import withServerAction from "../utils/withServerAction";
import SidebarMenu from "../types/SidebarMenu";
/** /**
* `AppNavbar` is a React functional component that renders the application's navigation bar. * `AppNavbar` is a React functional component that renders the application's navigation bar.
@ -13,35 +11,16 @@ import SidebarMenu from "../types/SidebarMenu";
* @returns A React element representing the application's navigation bar. * @returns A React element representing the application's navigation bar.
*/ */
export default function AppNavbar() { export default function AppNavbar() {
const [isFetching, setFetching] = useState(true);
const [sidebarMenus, setSidebarMenus] = useState<SidebarMenu[]>([]);
// Mapping all menu items to MenuItem components const {user} = useAuth();
// const menus = getSidebarMenus().map((menu, i) => <MenuItem menu={menu} key={i} />);
useEffect(() => {
setFetching(true);
withServerAction(getSidebarMenus)
.then((response) => {
setSidebarMenus(response.data);
})
.catch((e) => {
console.error(e);
})
.finally(() => {
setFetching(false);
});
}, []);
return ( return (
<AppShell.Navbar p="md"> <AppShell.Navbar p="md">
<ScrollArea style={{ flex: "1" }}> <ScrollArea style={{ flex: "1" }}>
{ {
isFetching ? <Stack gap="md"> user?.sidebarMenus.map((menu, i) => (
{[...new Array(10)].map((_,i) => <Skeleton key={i} visible={true} height={40} width={"100%"} />)}
</Stack> :
sidebarMenus.map((menu, i) => (
<MenuItem menu={menu} key={i} /> <MenuItem menu={menu} key={i} />
))} )) ?? null}
</ScrollArea> </ScrollArea>
</AppShell.Navbar> </AppShell.Navbar>
); );

View File

@ -1,17 +1,15 @@
/* eslint-disable react-hooks/exhaustive-deps */
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React from "react";
import { AppShell } from "@mantine/core"; import { AppShell } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import AppHeader from "./AppHeader"; import AppHeader from "./AppHeader";
import AppNavbar from "./AppNavbar"; import AppNavbar from "./AppNavbar";
import { useAuth } from "@/modules/auth/contexts/AuthContext";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import dashboardConfig from "../dashboard.config";
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
isLoggedIn: boolean
} }
/** /**
@ -24,22 +22,14 @@ interface Props {
*/ */
export default function DashboardLayout(props: Props) { export default function DashboardLayout(props: Props) {
const pathname = usePathname(); const pathname = usePathname();
console.log(pathname)
// State and toggle function for handling the disclosure of the navigation bar // State and toggle function for handling the disclosure of the navigation bar
const [openNavbar, { toggle }] = useDisclosure(false); const [openNavbar, { toggle }] = useDisclosure(false);
const {fetchUserData} = useAuth(); const withAppShell = props.isLoggedIn;
const [withAppShell, setWithAppShell] = useState(false)
useEffect(() => {
fetchUserData()
}, [])
useEffect(() => {
setWithAppShell(!dashboardConfig.routesWithoutAppShell.some(v => `${dashboardConfig.baseRoute}${v}` === pathname))
}, [pathname])
return withAppShell ? ( return withAppShell ? (
<AppShell <AppShell

View File

@ -0,0 +1,55 @@
import "server-only";
import SidebarMenu from "../types/SidebarMenu";
import getUserRoles from "@/modules/auth/utils/getUserRoles";
import getUserPermissions from "@/modules/auth/utils/getUserPermissions";
import sidebarMenus from "../data/sidebarMenus";
export default async function getSidebarMenus() {
const filteredMenus: SidebarMenu[] = [];
const roles = await getUserRoles();
const permissions = await getUserPermissions();
for (let menu of sidebarMenus) {
//if has children
if (menu.children) {
const currentMenuChildren: SidebarMenu["children"] = [];
for (let menuChild of menu.children) {
if (
menuChild.allowedPermissions?.some((perm) =>
permissions?.includes(perm)
) ||
menuChild.allowedRoles?.some((role) =>
roles?.includes(role)
) ||
menuChild.allowedPermissions?.includes("*") ||
menuChild.allowedRoles?.includes("*") ||
roles.includes("super-admin")
)
currentMenuChildren.push(menuChild);
}
if (currentMenuChildren.length > 0) {
filteredMenus.push({
...menu,
children: currentMenuChildren,
});
}
}
//if does not have any children
else {
if (
menu.allowedPermissions?.some((perm) =>
permissions?.includes(perm)
) ||
menu.allowedRoles?.some((role) => roles?.includes(role)) ||
menu.allowedPermissions?.includes("*") ||
menu.allowedRoles?.includes("*")
) {
filteredMenus.push(menu);
}
}
}
return filteredMenus;
}

View File

@ -11,6 +11,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noErrorTruncation": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [