feat: 쿠키 통한 로그인 권한 체크 구현

This commit is contained in:
swcho
2025-10-02 11:44:43 +09:00
parent 5e3f5e6aa8
commit fb0178b2a8
44 changed files with 1239 additions and 211 deletions
+12 -47
View File
@@ -1,49 +1,14 @@
"use client";
import "./globals.css";
import i18next from "i18next";
import { I18nextProvider } from "react-i18next";
import koBase from "../public/locales/ko/message.json";
import { StoreProvider } from "./store";
import { LoginApi } from "@/src/entities/auth";
import { useEffect } from "react";
import type { Metadata } from "next";
import { App } from "../src/app";
const i18n = i18next.createInstance();
i18n.init({
lng: "ko",
fallbackLng: "ko",
resources: {
ko: {
base: koBase,
},
},
});
export const metadata: Metadata = {
title: "디지털 하수관로",
description: "디지털 하수관로 시스템입니다",
keywords: ["하수관로"],
openGraph: {
title: "디지털 하수관로",
description: "디지털 하수관로 시스템입니다"
}
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
useEffect(() => {
// 렌더 중이 아니라 클라이언트에서만 실행 + 에러 캐치
void LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" })
.finally(()=>{
// getCommonUpCd()
} )
.catch((e) => {
console.error("Auto login failed:", e);
});
}, []);
return (
<html lang="ko">
<body>
<I18nextProvider i18n={i18n}>
<StoreProvider>
{children}
</StoreProvider>
</I18nextProvider>
</body>
</html>
);
}
export default App;
+24 -159
View File
@@ -1,166 +1,31 @@
"use client";
import { Button } from "@common/components/ui";
import { LoginApi } from "../src/entities/auth";
import { useEffect } from "react";
import { getCookie } from "../src/shared/lib";
import { AUTH_USER_ID } from "../src/shared/config";
import { useRouter } from "next/navigation";
export default function Home() {
const router = useRouter();
return (
<>
<div className="flex flex-col gap-8 p-6">
{/* 시설 등록 */}
<div>
<h2 className="text-xl font-semibold mb-4">시설 등록</h2>
<div className="grid grid-cols-12 gap-4">
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/ruseFcltMng/fcltReg/pbnstReg')}}>
하수재이용 시설 등록
</Button>
</div>
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/ruseFcltMng/fcltReg/rainReg')}}>
빗물재이용 시설 등록
</Button>
</div>
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/ruseFcltMng/fcltReg/rswtReg')}}>
중수도시설 등록
</Button>
</div>
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/ruseFcltMng/fcltReg/htdflReg')}}>
온배수시설 등록
</Button>
</div>
</div>
</div>
const userId = "CLEANWATER";
const password = "gktneh12!@";
const checkToken = getCookie(AUTH_USER_ID);
{/* 지도점검 결과 */}
<div>
<h2 className="text-xl font-semibold mb-4">지도점검 결과</h2>
<div className="grid grid-cols-12 gap-4">
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/ruseFcltMng/prfmncReg/pbnstPrfmncReg')}}>
하수재이용실적 등록
</Button>
</div>
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/ruseFcltMng/prfmncSttn/pbnstPrfmncSttn')}}>
하수재이용실적 현황
</Button>
</div>
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/ruseFcltMng/prfmncReg/chkgdnReg')}}>
지도점검 결과 등록
</Button>
</div>
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/ruseFcltMng/prfmncSttn/chkgdnSttn')}}>
지도점검 결과 현황
</Button>
</div>
</div>
</div>
{/* 시설 현황 */}
<div>
<h2 className="text-xl font-semibold mb-4">시설 현황</h2>
<div className="grid grid-cols-12 gap-4">
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/ruseFcltMng/fcltSttn/pbnstSttn')}}>
하수재이용시설현황
</Button>
</div>
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/ruseFcltMng/fcltSttn/rainSttn')}}>
빗물재이용시설현황
</Button>
</div>
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/ruseFcltMng/fcltSttn/rswtSttn')}}>
중수도이용시설현황
</Button>
</div>
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/ruseFcltMng/fcltSttn/htdflSttn')}}>
온배수현황
</Button>
</div>
</div>
</div>
{/* 수요처 관리 */}
<div>
<h2 className="text-xl font-semibold mb-4">수요처 관리</h2>
<div className="grid grid-cols-12 gap-4">
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/matMng/coMng/coSttn')}}>
수요처 관리
</Button>
</div>
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/matMng/coMng/ldrmntCoSttn')}}>
잠재수요처 관리
</Button>
</div>
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/matMng/coMng/drgtSttn')}}>
우리동네가뭄
</Button>
</div>
</div>
</div>
{/* 수요처 매칭 */}
<div>
<h2 className="text-xl font-semibold mb-4">수요처 매칭</h2>
<div className="grid grid-cols-12 gap-4">
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/matMng/coMat/coMatRvw')}}>
수요처 매칭 검토
</Button>
</div>
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/matMng/coMat/ruseRvwHstry')}}>
재이용검토이력
</Button>
</div>
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/matMng/coMat/ruseCaseMng')}}>
물재이용사례 관리
</Button>
</div>
</div>
</div>
{/* 통계*/}
<div>
<h2 className="text-xl font-semibold mb-4">통계 매칭</h2>
<div className="grid grid-cols-12 gap-4">
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/stats/rainStat')}}>
빗물재이용시설통계
</Button>
</div>
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/stats/rswtStat')}}>
중수도시설통계
</Button>
</div>
</div>
</div>
{/* 시설 현황(지도) */}
<div>
<h2 className="text-xl font-semibold mb-4">시설 현황(지도)</h2>
<div className="grid grid-cols-12 gap-4">
<div className="col-span-2">
<Button className="w-full" onClick={() => {router.push('/map/mainMap')}}>
시설 현황(지도)
</Button>
</div>
</div>
</div>
</div>
</>
);
useEffect(() => {
if (!checkToken) {
async function doLogin() {
try {
await LoginApi.login({ userId, password });
window.location.href = "/map/mainMap";
} catch (err) {
console.error("로그인 실패", err);
}
}
doLogin();
} else {
router.push("/map/mainMap");
}
}, []);
}
-4
View File
@@ -29,10 +29,6 @@ export function middleware(req: NextRequest) {
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname === "/") {
return NextResponse.redirect(new URL("/map/mainMap", req.url));
}
if (pathname.startsWith("/cms-service")) {
const requestHeaders = new Headers(req.headers);
const Authorization = req.cookies.get(CLAIM_NAME)?.value;
+20
View File
@@ -0,0 +1,20 @@
import "./styles/base.css";
import "./styles/components.css";
import "@fontsource-variable/noto-sans-kr";
import { AppProvider } from "./providers";
import { cookies } from "next/headers";
const App = async ({ children }: Readonly<{ children: React.ReactNode }>) => {
const theme = (await cookies()).get("site-theme-key");
return (
<html lang="ko" data-theme={theme?.value}>
<body>
<AppProvider>{children}</AppProvider>
</body>
</html>
);
};
export { App };
@@ -0,0 +1,68 @@
"use client";
import { DndProvider } from "react-dnd";
import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
import { AuthWrapper } from "../../entities/auth";
import { MenuWrapper } from "../../entities/menu";
import { PromptProvider, StoreProvider } from "../../shared/providers";
import { AlertDialog } from "../../shared/ui/alert-dialog";
import { PromptDialog } from "../../shared/ui/prompt-dialog";
import { ConfirmDialog } from "../../shared/ui/confirm-dialog";
// import "./globals.css";
import i18next from "i18next";
import { I18nextProvider } from "react-i18next";
import koBase from "@/public/locales/ko/message.json";
import { StoreProvider as WrStoreProvider } from "@/app/store";
import { LoginApi } from "@/src/entities/auth";
import { useEffect } from "react";
const i18n = i18next.createInstance();
i18n.init({
lng: "ko",
fallbackLng: "ko",
resources: {
ko: {
base: koBase,
},
},
});
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<StoreProvider>
<PromptProvider>
<ConfirmProvider>
<AuthWrapper>
<MenuWrapper>
<DndProvider
backend={MultiBackend}
options={getBackendOptions()}
>
<I18nextProvider i18n={i18n}>
<WrStoreProvider>
{children}
</WrStoreProvider>
</I18nextProvider>
</DndProvider>
</MenuWrapper>
<AlertDialog />
<PromptDialog />
<ConfirmDialog />
</AuthWrapper>
</ConfirmProvider>
</PromptProvider>
</StoreProvider>
<div className="text-2xl">
<ReactQueryDevtools initialIsOpen={false} />
</div>
</QueryClientProvider>
);
};
export { AppProvider };
+1
View File
@@ -0,0 +1 @@
export { AppProvider } from "./app-provider";
+12
View File
@@ -0,0 +1,12 @@
@import "@common/assets/styles/base.css";
@import "@common/assets/styles/diveicon.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
font-family: "Noto Sans KR", sans-serif;
font-size: 10px;
}
+29
View File
@@ -0,0 +1,29 @@
@tailwind components;
@layer components {
.card {
@apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666];
}
.card-border {
@apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur;
}
.card-header {
@apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4;
}
.card-footer {
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4;
}
[data-theme="black"] {
.card-border {
@apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
}
.card-header {
@apply border-b-[#3f4042];
}
}
input[type="file"] {
@apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer;
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
export { LoginApi } from "./api/login-service";
//export { AuthWrapper } from "./lib/auth-wrapper";
export { AuthWrapper } from "./lib/auth-wrapper";
@@ -0,0 +1,56 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { client } from "../../../shared/api";
import type { IUserInfo } from "../../../shared/store";
import { useUserStore } from "../../../shared/lib";
import { ENDPOINT, AUTH_USER_ID } from "../../../shared/config";
import { getCookie } from "../../../shared/lib";
import { LoginApi } from "../api/login-service";
const LOGIN_PAGE_URL = process.env.NEXT_PUBLIC_DEV_CM_LOGIN_PAGE_URL || "http://localhost:3000/auth/login"; // 로그인 페이지 URL
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
}
const response = await client.get<IUserInfo>(
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
);
if (!userInfo) setUserInfo(response.data);
}
useEffect(() => {
(async () => {
try {
await authCheck();
} catch (error) {
console.error(error);
try {
await LoginApi.silentRefresh();
await authCheck();
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
setUserInfo(null);
router.push("/"); // 개발시 홈으로 돌아가기
// window.location.href = LOGIN_PAGE_URL; // 세션 만료 시 CM 로그인 페이지로 리다이렉트
// router.push는 같은 Next.js 앱 내에서만 작동하므로 다른 앱으로 이동할 때는 window.location.href 사용
}
}
})();
}, []);
return <>{children}</>;
};
export { AuthWrapper };
@@ -0,0 +1 @@
export { MenuAuthorityApi } from "../api/menu-authority-service";
@@ -0,0 +1 @@
export { AuthorityButton } from "../ui/authority-button";
@@ -0,0 +1,40 @@
import { client } from "@/src/shared/api";
import { ENDPOINT } from "@/src/shared/config";
import type {
MenuAuthorityList,
MenuAuthorityParams,
MenuAuthorityPayload,
MenuAuthorityService,
PageMenuAuthority,
} from "../model/menu-authority-interface";
export class MenuAuthorityApiService implements MenuAuthorityService {
public async getMenuAuthorityList(
params: MenuAuthorityParams,
): Promise<MenuAuthorityList> {
const response = await client.get<MenuAuthorityList>(
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`,
{ params },
);
return response.data;
}
public async getPageMenuAuthority(
menuCd: number,
): Promise<PageMenuAuthority[]> {
const response = await client.get<PageMenuAuthority[]>(
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}/pageinfo/${menuCd}`,
);
return response.data;
}
public async saveMenuAuthority(data: MenuAuthorityPayload[]): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`,
data,
);
return response.data;
}
}
export const MenuAuthorityApi = new MenuAuthorityApiService();
@@ -0,0 +1,2 @@
export { AuthorityButton } from "./ui/authority-button";
export { ManagementAuthorityDialog } from "./ui/management-authority-dialog";
@@ -0,0 +1,48 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export interface MenuAuthorityParams extends PageableParams {
groupId?: string;
menuCd?: number;
useYn?: string;
}
export interface PageMenuAuthority {
mngAuthrtYn: string;
inqAuthrtYn: string;
regAuthrtYn: string;
mdfcnAuthrtYn: string;
delAuthrtYn: string;
}
export const menuAuthoritySchema = z.object({
groupId: z.string(),
groupNm: z.string(),
menuCd: z.number(),
mngAuthrtYn: z.string(),
inqAuthrtYn: z.string(),
regAuthrtYn: z.string(),
mdfcnAuthrtYn: z.string(),
delAuthrtYn: z.string(),
useYn: z.string(),
mode: z.string(),
});
export const menuAuthorityPayloadSchema = menuAuthoritySchema.omit({
groupNm: true,
});
export const menuAuthorityFormSchema = z.object({
menuAuthority: z.array(menuAuthorityPayloadSchema),
});
export type MenuAuthority = z.infer<typeof menuAuthoritySchema>;
export type MenuAuthorityList = PageableList<MenuAuthority>;
export type MenuAuthorityPayload = z.infer<typeof menuAuthorityPayloadSchema>;
export type MenuAuthorityFormData = z.infer<typeof menuAuthorityFormSchema>;
export interface MenuAuthorityService {
getMenuAuthorityList(params: MenuAuthorityParams): Promise<MenuAuthorityList>;
getPageMenuAuthority(menuCd: number): Promise<PageMenuAuthority[]>;
saveMenuAuthority(data: MenuAuthorityPayload[]): Promise<void>;
}
@@ -0,0 +1,117 @@
import type { ColumnDef } from "@tanstack/react-table";
import type { UseFormReturn } from "react-hook-form";
import { Checkbox } from "@common/components";
import type {
MenuAuthority,
MenuAuthorityFormData,
MenuAuthorityPayload,
} from "./menu-authority-interface";
export const createColumns = (
form: UseFormReturn<MenuAuthorityFormData>,
): ColumnDef<MenuAuthority>[] => {
const getCurrentRow = (i: number) => form.watch("menuAuthority")[i];
const handleCheckedChange =
(i: number, key: keyof MenuAuthorityPayload) => (checked: boolean) => {
if (getCurrentRow(i)?.mode === "R") {
form.setValue(`menuAuthority.${i}.mode`, "U");
}
form.setValue(`menuAuthority.${i}.${key}`, checked ? "Y" : "N");
};
return [
{
accessorKey: "groupNm",
header: "그룹명",
},
{
accessorKey: "mngAuthrtYn",
header: "관리권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.mngAuthrtYn === "Y"}
onCheckedChange={(checked) => {
if (checked) {
handleCheckedChange(
row.index,
"regAuthrtYn",
)(checked as boolean);
handleCheckedChange(
row.index,
"mdfcnAuthrtYn",
)(checked as boolean);
handleCheckedChange(
row.index,
"delAuthrtYn",
)(checked as boolean);
handleCheckedChange(
row.index,
"inqAuthrtYn",
)(checked as boolean);
}
handleCheckedChange(row.index, "mngAuthrtYn")(checked as boolean);
}}
/>
);
},
},
{
accessorKey: "regAuthrtYn",
header: "등록권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.regAuthrtYn === "Y"}
onCheckedChange={(checked) => {
if (checked) {
handleCheckedChange(
row.index,
"inqAuthrtYn",
)(checked as boolean);
}
handleCheckedChange(row.index, "regAuthrtYn")(checked as boolean);
}}
/>
);
},
},
{
accessorKey: "mdfcnAuthrtYn",
header: "수정권한 ",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.mdfcnAuthrtYn === "Y"}
onCheckedChange={handleCheckedChange(row.index, "mdfcnAuthrtYn")}
/>
);
},
},
{
accessorKey: "delAuthrtYn",
header: "삭제권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.delAuthrtYn === "Y"}
onCheckedChange={handleCheckedChange(row.index, "delAuthrtYn")}
/>
);
},
},
{
accessorKey: "inqAuthrtYn",
header: "조회권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.inqAuthrtYn === "Y"}
onCheckedChange={handleCheckedChange(row.index, "inqAuthrtYn")}
/>
);
},
},
];
};
@@ -0,0 +1,26 @@
import { useMenuStore } from "@/shared/lib";
import { Button, cn, type ButtonProps } from "@common/components";
import type { PageMenuAuthority } from "../model/menu-authority-interface";
type AuthorityButtonProps = {
authority: keyof PageMenuAuthority;
} & ButtonProps;
function AuthorityButton({
authority,
className,
children,
...props
}: AuthorityButtonProps) {
const pageMenuAuthority = useMenuStore((state) => state.pageMenuAuthority);
const isAuthority = pageMenuAuthority?.[authority] === "Y";
return (
<Button className={cn(className, !isAuthority && "hidden")} {...props}>
{children}
</Button>
);
}
export { AuthorityButton };
@@ -0,0 +1,103 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Button,
DataTable,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import {
menuAuthorityFormSchema,
type MenuAuthorityFormData,
type MenuAuthorityPayload,
} from "../model/menu-authority-interface";
import { createColumns } from "../model/menu-authority-table-columns";
import { MenuAuthorityApi } from "../api/menu-authority-service";
type ManagementAuthorityDialogProps = {
menuCd?: number;
} & React.ComponentProps<typeof Dialog>;
function ManagementAuthorityDialog({
menuCd,
...props
}: ManagementAuthorityDialogProps) {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const { data: menuAuthorityList } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_AUTHRTS, menuCd],
queryFn: () => MenuAuthorityApi.getMenuAuthorityList({ menuCd }),
enabled: !!menuCd,
});
const { mutateAsync: saveMenuAuthority } = useMutationWrapper({
mutationFn: (data: MenuAuthorityPayload[]) =>
MenuAuthorityApi.saveMenuAuthority(data),
onSuccess: () => {
alert("저장되었습니다.");
},
});
const form = useForm<MenuAuthorityFormData>({
resolver: zodResolver(menuAuthorityFormSchema),
values: {
menuAuthority: menuAuthorityList?.content ?? [],
},
});
const table = useReactTable({
data: menuAuthorityList?.content ?? [],
columns: createColumns(form),
getCoreRowModel: getCoreRowModel(),
});
const handleSave = async () => {
if (await confirm("저장하시겠습니까?")) {
const data = form.getValues("menuAuthority");
const filteredData = data.filter((item) => item.mode !== "R");
await saveMenuAuthority(filteredData);
}
};
return (
<Dialog {...props}>
<DialogTrigger asChild>
<Button className="gap-3" disabled={!menuCd}>
<i className="diveicon di-personal-information" />
권한관리
</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>그룹별 메뉴 권한</DialogTitle>
</DialogHeader>
<div className="max-h-[40rem] overflow-auto">
<DataTable table={table} />
</div>
<DialogFooter className="!justify-center">
<Button size="lg" onClick={handleSave}>
저장
</Button>
<DialogClose asChild>
<Button size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { ManagementAuthorityDialog };
@@ -0,0 +1,14 @@
import { client } from "@/src/shared/api";
import { ENDPOINT } from "@/src/shared/config";
import type { MenuItem, MenuService } from "../model/menu-interface";
export class MenuApiService implements MenuService {
async getMenuRoles(siteId: string): Promise<MenuItem[]> {
const response = await client.get<MenuItem[]>(
`${ENDPOINT.CMS_SERVICE.MENU_ROLES}/${siteId}`,
);
return response.data;
}
}
export const MenuApi = new MenuApiService();
+2
View File
@@ -0,0 +1,2 @@
export { MenuWrapper } from "./lib/menu-wrapper";
export type { MenuItem } from "./model/menu-interface";
@@ -0,0 +1,59 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { MenuAuthorityApi } from "@/src/entities/menu-authority/@x/menu";
import { useAlertStore, useMenuStore } from "@/src/shared/lib";
import { getCookie } from "@/src/shared/lib";;
import { SITE_ID } from "@/src/shared/config";
import { MenuApi } from "../api/menu-service";
import { getCurrentMenu } from "./utils";
const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const pathname = usePathname();
const alert = useAlertStore((state) => state.setMessage);
const { pageMenuAuthority, setCurrentMenuInfo, setPageMenuAuthority } =
useMenuStore((state) => state);
const getCurrentMenuInfo = async () => {
const menuRoles = await MenuApi.getMenuRoles(SITE_ID);
if (!menuRoles) return;
return getCurrentMenu(menuRoles, pathname ?? "");
};
const getPageMenuAuthority = async () => {
const menuCd = getCookie("menu-code-key");
if (!menuCd) return;
const response = await MenuAuthorityApi.getPageMenuAuthority(
parseInt(menuCd),
);
return response;
};
const checkInquiryAuthority = () => {
return pageMenuAuthority?.inqAuthrtYn === "Y";
};
useEffect(() => {
(async () => {
const currentMenu = await getCurrentMenuInfo();
if (currentMenu) setCurrentMenuInfo(currentMenu);
const response = await getPageMenuAuthority();
if (response?.length) setPageMenuAuthority(response[0]!);
})();
}, []);
useEffect(() => {
if (!pageMenuAuthority) return;
const inquiryAuthority = checkInquiryAuthority();
if (!inquiryAuthority)
alert("페이지에 대한 조회 권한이 없습니다.", () => router.push("/"));
}, [pageMenuAuthority]);
return <>{children}</>;
};
export { MenuWrapper };
+20
View File
@@ -0,0 +1,20 @@
import type { MenuItem } from "../model/menu-interface";
export const getCurrentMenu = (
menus: MenuItem[],
pathname: string,
): MenuItem | null => {
for (const menu of menus) {
if (
menu.urlPath !== "" &&
(menu.urlPath === pathname || pathname.startsWith(menu.urlPath))
)
return menu;
if (menu.children) {
const children = getCurrentMenu(menu.children, pathname);
if (children) return children;
}
}
return null;
};
@@ -0,0 +1,23 @@
export interface MenuItem {
menuCd: number;
menuKornNm: string;
menuEngNm: string;
parentId: number | null;
sortSeq: number;
iconNm?: string;
children: MenuItem[] | null;
levelNo: number;
urlPath: string;
menuLnkgTypeCd: string;
menuLnkgSn: number;
menuPath: string;
qrcd: string;
useYn: string;
otptYn: string;
newWndwYn: string;
requiredUrlPath: boolean;
}
export interface MenuService {
getMenuRoles(siteId: string): Promise<MenuItem[]>;
}
+11
View File
@@ -0,0 +1,11 @@
import { Client } from "@common/network";
import { BASE_URL } from "../config/api";
import { SITE_ID } from "../config/env";
import { setCookie } from "../lib";
setCookie("site-id-key", SITE_ID);
export const client = Client.getInstance({
baseURL: BASE_URL,
siteId: SITE_ID,
});
+1
View File
@@ -0,0 +1 @@
export { client } from "./client";
+6
View File
@@ -0,0 +1,6 @@
export { useMutationWrapper } from "./use-mutation-wrapper";
export { useAlertStore } from "./use-alert-store";
export { useMenuStore } from "./use-menu-store";
export { useUserStore } from "./use-user-store";
export { usePrompt } from "./use-prompt";
export { getCookie, setCookie, removeCookie } from "./cookie";
+14
View File
@@ -0,0 +1,14 @@
import { useStore } from "zustand";
import { useContext } from "react";
import type { AlertStore } from "../store/alert-store";
import { StoreContext } from "../providers/store-provider";
export const useAlertStore = <T>(selector: (store: AlertStore) => T): T => {
const storeContext = useContext(StoreContext);
if (!storeContext) {
throw new Error("useAlertStore must be used within StoreProvider");
}
return useStore(storeContext.alertStore, selector);
};
+14
View File
@@ -0,0 +1,14 @@
import { useStore } from "zustand";
import { useContext } from "react";
import type { MenuStore } from "../store/menu-store";
import { StoreContext } from "../providers/store-provider";
export const useMenuStore = <T>(selector: (store: MenuStore) => T): T => {
const storeContext = useContext(StoreContext);
if (!storeContext) {
throw new Error("useMenuStore must be used within StoreProvider");
}
return useStore(storeContext.menuStore, selector);
};
@@ -0,0 +1,31 @@
import { AxiosError } from "axios";
import { useMutation, type UseMutationOptions } from "@tanstack/react-query";
import { useAlertStore } from "./use-alert-store";
export const useMutationWrapper = <
TData = unknown,
TError = unknown,
TVariables = void,
TContext = unknown,
>(
mutationOptions: UseMutationOptions<TData, TError, TVariables, TContext>,
) => {
const alert = useAlertStore((state) => state.setMessage);
return useMutation({
...mutationOptions,
onError: (error) => {
let errorMessage = "";
if (error instanceof AxiosError) {
if (error.response?.status)
errorMessage += `status: ${error.response.status}\n`;
if (error.response?.data?.message)
errorMessage += `${error.response.data.message}\n`;
if (error.response?.data?.errors[0]?.message)
errorMessage += `${error.response.data.errors[0].message}\n`;
}
alert(`[Error] ${errorMessage}`);
},
});
};
+26
View File
@@ -0,0 +1,26 @@
import { useContext } from "react";
import { PromptContext } from "../providers/prompt-provider";
export const usePrompt = () => {
const { message, resolve, setMessage, setResolve } =
useContext(PromptContext);
const prompt = (message: string) => {
setMessage(message);
return new Promise<{ resolved: boolean; value?: string }>((resolve) => {
setResolve(() => resolve);
});
};
const onSubmit = (value: string) => {
resolve?.({ resolved: true, value });
setMessage("");
};
const onCancel = () => {
resolve?.({ resolved: false });
setMessage("");
};
return { message, isOpen: !!message, prompt, onSubmit, onCancel };
};
+14
View File
@@ -0,0 +1,14 @@
import { useStore } from "zustand";
import { useContext } from "react";
import type { UserStore } from "../store/user-store";
import { StoreContext } from "../providers/store-provider";
export const useUserStore = <T>(selector: (store: UserStore) => T): T => {
const storeContext = useContext(StoreContext);
if (!storeContext) {
throw new Error("useUserStore must be used within StoreProvider");
}
return useStore(storeContext.userStore, selector);
};
+2
View File
@@ -0,0 +1,2 @@
export { StoreProvider } from "./store-provider";
export { PromptProvider } from "./prompt-provider";
@@ -0,0 +1,37 @@
import { createContext, useState } from "react";
interface PromptContext {
message?: string;
resolve?: ({
resolved,
value,
}: {
resolved: boolean;
value?: string;
}) => void;
setMessage: (message: string) => void;
setResolve: (resolve: () => void) => void;
}
export const PromptContext = createContext<PromptContext>({
message: "",
resolve() {},
setMessage() {},
setResolve() {},
});
export const PromptProvider = ({ children }: { children: React.ReactNode }) => {
const [message, setMessage] = useState<string>();
const [resolve, setResolve] =
useState<
({ resolved, value }: { resolved: boolean; value?: string }) => void
>();
return (
<PromptContext.Provider
value={{ message, resolve, setMessage, setResolve }}
>
{children}
</PromptContext.Provider>
);
};
@@ -0,0 +1,38 @@
"use client";
import { createContext, useRef } from "react";
import {
createAlertStore,
createAttachmentStore,
createMenuStore,
createUserStore,
} from "../store";
type StoreContextType = {
alertStore: ReturnType<typeof createAlertStore>;
attachmentStore: ReturnType<typeof createAttachmentStore>;
menuStore: ReturnType<typeof createMenuStore>;
userStore: ReturnType<typeof createUserStore>;
};
export const StoreContext = createContext<StoreContextType | undefined>(
undefined,
);
export const StoreProvider = ({ children }: { children: React.ReactNode }) => {
const storeRef = useRef<StoreContextType | null>(null);
if (storeRef.current === null) {
storeRef.current = {
alertStore: createAlertStore(),
attachmentStore: createAttachmentStore(),
menuStore: createMenuStore(),
userStore: createUserStore(),
};
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
);
};
+25
View File
@@ -0,0 +1,25 @@
import { createStore } from "zustand/vanilla";
export type AlertState = {
message: string;
callback?: () => void;
};
export type AlertActions = {
setMessage: (message: string, callback?: () => void) => void;
};
export type AlertStore = AlertState & AlertActions;
export const defaultInitState: AlertState = {
message: "",
callback: undefined,
};
export const createAlertStore = (initState: AlertState = defaultInitState) => {
return createStore<AlertStore>()((set) => ({
...initState,
setMessage: (message: string, callback?: () => void) =>
set(() => ({ message, callback })),
}));
};
@@ -0,0 +1,25 @@
import { createStore } from "zustand/vanilla";
export type AttachmentState = {
attachmentFileCode: string;
};
export type AttachmentActions = {
setAttachmentFileCode: (attachmentFileCode: string) => void;
};
export type AttachmentStore = AttachmentState & AttachmentActions;
export const defaultInitState: AttachmentState = {
attachmentFileCode: "",
};
export const createAttachmentStore = (
initState: AttachmentState = defaultInitState,
) => {
return createStore<AttachmentStore>()((set) => ({
...initState,
setAttachmentFileCode: (attachmentFileCode: string) =>
set({ attachmentFileCode }),
}));
};
+4
View File
@@ -0,0 +1,4 @@
export { createAlertStore } from "./alert-store";
export { createAttachmentStore } from "./attachment-store";
export { createMenuStore, type BbsType } from "./menu-store";
export { createUserStore, type IUserInfo } from "./user-store";
+93
View File
@@ -0,0 +1,93 @@
import { createStore } from "zustand/vanilla";
export const BBS_TYPE = {
BBSNOTICE: "BBSNOTICE",
BBSNORMAL: "BBSNORMAL",
BBSFAQ: "BBSFAQ",
BBSIMAGE: "BBSIMAGE",
BBSMOVIE: "BBSMOVIE",
} as const;
export type BbsType = (typeof BBS_TYPE)[keyof typeof BBS_TYPE];
export interface IBbs {
bbsId: number;
bbsTypeCd: BbsType;
bbsTypeNm: string;
bbsNm: string;
bbsExpln: string;
rfrncUseYn: string;
atflUseYn: string;
atflMaxCnt: number;
atflMaxFileSz: number;
ansUseYn: string;
ansUseAuthrt: string;
cmntUseYn: string;
dgstfnEvlUseYn: string;
smsUseYn: string;
upndFixUseYn: string;
pstgPrdUseYn: string;
popupUseYn: string;
delYn: string;
useYn: string;
mdfcnDt: string;
mdfrNm: string;
}
interface IMenu {
menuCd: number;
menuKornNm: string;
menuEngNm: string;
parentId: number | null;
sortSeq: number;
iconNm?: string;
children: IMenu[] | null;
levelNo: number;
urlPath: string;
menuLnkgTypeCd: string;
menuLnkgSn: number;
menuPath: string;
qrcd: string;
useYn: string;
otptYn: string;
newWndwYn: string;
requiredUrlPath: boolean;
}
interface IAuthority {
mngAuthrtYn: string;
inqAuthrtYn: string;
regAuthrtYn: string;
mdfcnAuthrtYn: string;
delAuthrtYn: string;
}
export type MenuState = {
bbsInfo: IBbs | null;
currentMenuInfo: IMenu | null;
pageMenuAuthority: IAuthority | null;
};
export type MenuActions = {
setBbsInfo: (bbs: IBbs) => void;
setCurrentMenuInfo: (menu: IMenu) => void;
setPageMenuAuthority: (authority: IAuthority) => void;
};
export type MenuStore = MenuState & MenuActions;
export const defaultInitState: MenuState = {
currentMenuInfo: null,
bbsInfo: null,
pageMenuAuthority: null,
};
export const createMenuStore = (initState: MenuState = defaultInitState) => {
return createStore<MenuStore>()((set) => ({
...initState,
setBbsInfo: (bbs: IBbs) => set({ bbsInfo: bbs }),
setCurrentMenuInfo: (menu: IMenu) => set({ currentMenuInfo: menu }),
setPageMenuAuthority: (authority: IAuthority) =>
set({ pageMenuAuthority: authority }),
}));
};
+57
View File
@@ -0,0 +1,57 @@
import { createStore } from "zustand/vanilla";
export interface IUserInfo {
regDt: string;
mdfcnDt: string;
rgtr: string;
mdfr: string;
adminRoleYn: string;
userUnqId: string;
userId: string;
userName: string;
email: string;
encryptedPassword: string;
refreshToken: string;
userStateCode: string;
lastLoginDate: string;
loginFailCount: number;
pswdHnt: string;
pswdAns: string;
mphn: string;
telno: string;
zip: string;
addr: string;
daddr: string;
laeSeCd: string;
ogdpSeCd: string;
aprvYn: string;
autzrId: string;
expryYmd: string;
lastPswdChgDt: string;
googleId: string;
kakaoId: string;
naverId: string;
isSocialUser: boolean;
hasPassword: boolean;
}
export type UserState = {
userInfo: IUserInfo | null;
};
export type UserActions = {
setUserInfo: (userInfo: IUserInfo | null) => void;
};
export type UserStore = UserState & UserActions;
export const defaultInitState: UserState = {
userInfo: null,
};
export const createUserStore = (initState: UserState = defaultInitState) => {
return createStore<UserStore>()((set) => ({
...initState,
setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })),
}));
};
+34
View File
@@ -0,0 +1,34 @@
import {
Dialog,
DialogContent,
DialogFooter,
DialogClose,
Button,
} from "@common/components";
import { useAlertStore } from "../lib/use-alert-store";
function AlertDialog() {
const { message, callback, setMessage } = useAlertStore((state) => state);
const handleOpenChange = () => {
setMessage("");
callback?.();
};
return (
<Dialog open={!!message} onOpenChange={handleOpenChange}>
<DialogContent aria-describedby={undefined}>
<div className="py-10">
<p className="whitespace-pre-line text-center text-[1.4rem] text-foreground">
{message}
</p>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button>확인</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { AlertDialog };
+48
View File
@@ -0,0 +1,48 @@
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Dialog,
DialogContent,
DialogFooter,
DialogClose,
Button,
type ButtonProps,
} from "@common/components";
type ConfirmDialogProps = {
isOpen?: boolean;
confirmButtonProps?: ButtonProps & { label?: string };
cancelButtonProps?: ButtonProps & { label?: string };
};
function ConfirmDialog({
isOpen,
confirmButtonProps,
cancelButtonProps,
}: ConfirmDialogProps) {
const { message, isOpen: isGlobalOpen, onConfirm, onCancel } = useConfirm();
return (
<Dialog open={isOpen ?? isGlobalOpen} onOpenChange={onCancel}>
<DialogContent aria-describedby={undefined}>
<div className="py-10">
<p className="whitespace-pre-line text-center text-[1.4rem] text-foreground">
{message}
</p>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button onClick={onConfirm} {...confirmButtonProps}>
{confirmButtonProps?.label ?? "확인"}
</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={onCancel} color="white" {...cancelButtonProps}>
{cancelButtonProps?.label ?? "취소"}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { ConfirmDialog };
+13
View File
@@ -0,0 +1,13 @@
import { useMenuStore } from "../lib/use-menu-store";
function MapLayout({ children }: { children: React.ReactNode }) {
const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
return (
<div className="h-auto min-h-full bg-container pb-2">
{children}
</div>
);
}
export { MapLayout };
+22
View File
@@ -0,0 +1,22 @@
import { PageTitle } from "@common/components";
import { useMenuStore } from "../lib/use-menu-store";
function PageLayout({ children }: { children: React.ReactNode }) {
const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
const menuPaths = currentMenuInfo?.menuPath
?.split(">")
.map((path) => path.trim());
return (
<div className="h-auto min-h-full bg-container pb-2">
<PageTitle
title={currentMenuInfo?.menuKornNm ?? ""}
menuPaths={menuPaths ?? []}
/>
{children}
</div>
);
}
export { PageLayout };
+45
View File
@@ -0,0 +1,45 @@
import { useState } from "react";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogClose,
Input,
} from "@common/components";
import { usePrompt } from "@/src/shared/lib/use-prompt";
function PromptDialog() {
const { message, isOpen, onSubmit, onCancel } = usePrompt();
const [value, setValue] = useState("");
return (
<Dialog open={isOpen} onOpenChange={onCancel}>
<DialogContent aria-describedby={undefined}>
<div className="flex flex-col gap-6 pb-4 pt-8">
<p className="whitespace-pre-line text-center text-[1.4rem] text-foreground">
{message}
</p>
<Input
type="text"
value={value}
className="!text-[1.3rem]"
onChange={(e) => setValue(e.target.value)}
/>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button onClick={() => onSubmit(value)}>확인</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={onCancel} color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { PromptDialog };