조민수 조민수 05-06
admin menu load + login process api
@e24a741ade3695d3c171df3e33d0ea47bd2d7558
 
src/admin/hook/useMenuList.ts (added)
+++ src/admin/hook/useMenuList.ts
@@ -0,0 +1,84 @@
+import { useEffect, useState } from 'react';
+import { adminAuthJson } from '../../api/adminAuth';
+import type { MenuItem } from '../component/menu/MenuList';
+
+type BackendMenuItem = {
+  menuNo?: number | string;
+  menuNm?: string;
+  url?: string;
+  upperMenuId?: number | string;
+};
+
+type MenuLeftResponse = {
+  head?: BackendMenuItem[];
+  menu?: BackendMenuItem[];
+};
+
+const fallbackHeadMenuList: MenuItem[] = [
+  { name: '테스트 메뉴1', no: '1', url: '#', upperNo: '0' },
+  { name: '테스트 메뉴2', no: '2', url: '#', upperNo: '0' },
+  { name: '테스트 메뉴3', no: '3', url: '#', upperNo: '0' },
+];
+
+const fallbackMenuList: MenuItem[] = [
+  { name: '테스트 1', no: '6', url: '#', upperNo: '1' },
+  { name: '테스트 2', no: '7', url: '#', upperNo: '1' },
+  { name: '테스트 3', no: '8', url: '#', upperNo: '2' },
+];
+
+function toMenuItem(item: BackendMenuItem): MenuItem {
+  return {
+    no: String(item.menuNo ?? ''),
+    name: item.menuNm ?? '',
+    url: item.url ?? '#',
+    upperNo: String(item.upperMenuId ?? '0'),
+  };
+}
+
+export const useMenuList = () => {
+  const [headMenuList, setHeadMenuList] = useState<MenuItem[]>(fallbackHeadMenuList);
+  const [menuList, setMenuList] = useState<MenuItem[]>(fallbackMenuList);
+  const [isLoading, setIsLoading] = useState(true);
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+
+  useEffect(() => {
+    let mounted = true;
+
+    adminAuthJson<MenuLeftResponse>('/sym/mms/menuLeft.do')
+      .then((data) => {
+        if (!mounted) {
+          return;
+        }
+
+        const nextHeadMenuList = (data.head ?? []).map(toMenuItem).filter((item) => item.no && item.name);
+        const nextMenuList = (data.menu ?? []).map(toMenuItem).filter((item) => item.no && item.name);
+
+        setHeadMenuList(nextHeadMenuList.length > 0 ? nextHeadMenuList : fallbackHeadMenuList);
+        setMenuList(nextMenuList.length > 0 ? nextMenuList : fallbackMenuList);
+        setErrorMessage(null);
+      })
+      .catch((error: unknown) => {
+        if (!mounted) {
+          return;
+        }
+
+        setErrorMessage(error instanceof Error ? error.message : '메뉴 조회에 실패했습니다.');
+      })
+      .finally(() => {
+        if (mounted) {
+          setIsLoading(false);
+        }
+      });
+
+    return () => {
+      mounted = false;
+    };
+  }, []);
+
+  return {
+    headMenuList,
+    menuList,
+    isLoading,
+    errorMessage,
+  };
+};
src/admin/layout/AdminSideBar.tsx
--- src/admin/layout/AdminSideBar.tsx
+++ src/admin/layout/AdminSideBar.tsx
@@ -1,31 +1,19 @@
-import {MenuList} from "../component/menu/MenuList.tsx";
-
-const menuList = [
-    {name: "1", no: '6', url: '테스트', upperNo: '1'},
-    {name: "2", no: '7', url: '테스트', upperNo: '1'},
-    {name: "3", no: '8', url: '테스트', upperNo: '2'},
-    {name: "4", no: '9', url: '테스트', upperNo: '2'},
-    {name: "5", no: '10', url: '테스트', upperNo: '2'},
-
-]
-
-const headMenuList = [
-    {name: "테스트 메뉴1", no: '1', url: '테스트', upperNo: '0'},
-    {name: "테스트 메뉴2", no: '2', url: '테스트', upperNo: '0'},
-    {name: "테스트 메뉴3", no: '3', url: '테스트', upperNo: '0'},
-    {name: "테스트 메뉴4", no: '4', url: '테스트', upperNo: '0'},
-    {name: "테스트 메뉴5", no: '5', url: '테스트', upperNo: '0'},
-]
+import { MenuList } from '../component/menu/MenuList.tsx';
+import { useMenuList } from '../hook/useMenuList.ts';
 
 export const AdminSideBar = () => {
-    return (
-        <div className={"menu_wrap"}>
-            <h1 className={"logo"}>
-                <a href="/">DashBoard</a>
-            </h1>
-            <nav className={"menu"}>
-                <MenuList headMenuList={headMenuList} menuList={menuList}/>
-            </nav>
-        </div>
-    );
-}
(No newline at end of file)
+  const { headMenuList, menuList, isLoading, errorMessage } = useMenuList();
+
+  return (
+    <div className="menu_wrap">
+      <h1 className="logo">
+        <a href="/">DashBoard</a>
+      </h1>
+      <nav className="menu">
+        {isLoading && <p style={{ padding: '0 20px', color: '#fff' }}>메뉴 로딩중...</p>}
+        {errorMessage && <p style={{ padding: '0 20px', color: '#ffd1d1' }}>{errorMessage}</p>}
+        <MenuList headMenuList={headMenuList} menuList={menuList} />
+      </nav>
+    </div>
+  );
+};
 
src/api/adminAuth.ts (added)
+++ src/api/adminAuth.ts
@@ -0,0 +1,123 @@
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '';
+const ADMIN_LOGIN_URL = '/uat/uia/actionSecurityLogin.do';
+const ADMIN_ID = 'admin';
+const ADMIN_PASSWORD = '1';
+
+type ApiEnvelope<T> = {
+  success?: boolean;
+  message?: string;
+  data?: T;
+};
+
+let loginPromise: Promise<void> | null = null;
+
+function toApiUrl(path: string) {
+  if (/^https?:\/\//i.test(path)) {
+    return path;
+  }
+
+  return `${API_BASE_URL}${path}`;
+}
+
+function createHeaders(headers?: HeadersInit) {
+  return new Headers(headers);
+}
+
+async function adminLogin() {
+  if (!loginPromise) {
+    loginPromise = fetch(toApiUrl(ADMIN_LOGIN_URL), {
+      method: 'POST',
+      credentials: 'include',
+      headers: {
+        'Content-Type': 'application/x-www-form-urlencoded',
+        'X-Requested-With': 'XMLHttpRequest',
+      },
+      body: new URLSearchParams({
+        id: ADMIN_ID,
+        password: ADMIN_PASSWORD,
+      }),
+    })
+      .then(async (response) => {
+        if (!response.ok) {
+          throw new Error(`Admin login failed: ${response.status}`);
+        }
+
+        const contentType = response.headers.get('content-type') ?? '';
+        if (contentType.includes('application/json')) {
+          const result = (await response.json()) as ApiEnvelope<unknown>;
+          if (result.success === false) {
+            throw new Error(result.message ?? 'Admin login failed');
+          }
+        }
+      })
+      .finally(() => {
+        loginPromise = null;
+      });
+  }
+
+  return loginPromise;
+}
+
+async function needsAdminLogin(response: Response) {
+  if (response.type === 'opaqueredirect' || response.status === 0) {
+    return true;
+  }
+
+  if (response.status >= 300 && response.status < 400) {
+    return true;
+  }
+
+  if (response.status === 401 || response.status === 403) {
+    return true;
+  }
+
+  const contentType = response.headers.get('content-type') ?? '';
+
+  if (contentType.includes('text/html')) {
+    const html = await response.clone().text();
+    return html.includes('/uat/uia/actionMain.do') || html.includes('actionSecurityLogin.do');
+  }
+
+  if (contentType.includes('application/json')) {
+    try {
+      const result = (await response.clone().json()) as ApiEnvelope<unknown>;
+      const message = result.message ?? '';
+      return result.success === false && /login|로그인|/.test(message);
+    } catch {
+      return false;
+    }
+  }
+
+  return false;
+}
+
+export async function adminAuthFetch(path: string, init: RequestInit = {}, retry = true): Promise<Response> {
+  const response = await fetch(toApiUrl(path), {
+    ...init,
+    credentials: 'include',
+    headers: createHeaders(init.headers),
+    redirect: 'manual',
+  });
+
+  if (retry && (await needsAdminLogin(response))) {
+    await adminLogin();
+    return adminAuthFetch(path, init, false);
+  }
+
+  return response;
+}
+
+export async function adminAuthJson<T>(path: string, init?: RequestInit): Promise<T> {
+  const response = await adminAuthFetch(path, init);
+
+  if (!response.ok) {
+    throw new Error(`API request failed: ${response.status}`);
+  }
+
+  const result = (await response.json()) as ApiEnvelope<T>;
+  if (result.success === false) {
+    throw new Error(result.message ?? 'API request failed');
+  }
+
+  return result.data as T;
+}
vite.config.ts
--- vite.config.ts
+++ vite.config.ts
@@ -1,10 +1,25 @@
-import { defineConfig } from 'vite'
+import { defineConfig, loadEnv } from 'vite'
 import react from '@vitejs/plugin-react'
 
-export default defineConfig({
-  plugins: [react()],
-  server: {
-    port: 5173,
-    host: '0.0.0.0'
-  },
+export default defineConfig(({ mode }) => {
+  const env = loadEnv(mode, process.cwd(), '')
+  const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:9999'
+
+  return {
+    plugins: [react()],
+    server: {
+      port: 5173,
+      host: '0.0.0.0',
+      proxy: {
+        '/uat': { target: apiProxyTarget, changeOrigin: true },
+        '/cmm': { target: apiProxyTarget, changeOrigin: true },
+        '/sym': { target: apiProxyTarget, changeOrigin: true },
+        '/cop': { target: apiProxyTarget, changeOrigin: true },
+        '/uss': { target: apiProxyTarget, changeOrigin: true },
+        '/sec': { target: apiProxyTarget, changeOrigin: true },
+        '/sts': { target: apiProxyTarget, changeOrigin: true },
+        '/react': { target: apiProxyTarget, changeOrigin: true },
+      },
+    },
+  }
 })
Add a comment
List