From fd48e755f28ddff223aff198107f07a211d8a588 Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 28 Feb 2026 02:21:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=EB=A9=94=EB=89=B4=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(DB=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=20=EC=86=8C=EC=8A=A4,=20=EC=9D=B4=EB=AA=A8?= =?UTF-8?q?=EC=A7=80=20=ED=94=BC=EC=BB=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메뉴 활성/비활성, 순서, 라벨, 아이콘을 DB(AUTH_SETTING)에서 관리 - GET/PUT /api/menus 엔드포인트 추가 - Zustand menuStore로 메뉴 설정 전역 상태 관리 - TopBar: DB 메뉴 설정 기반 동적 탭 렌더링 (ALL_TABS 하드코딩 제거) - AdminView MenusPanel: API 연동, 이모지 피커(@emoji-mart) 통합 - SETTING_VAL 컬럼 VARCHAR(500) → TEXT 마이그레이션 - dotenv 추가로 .env 파일 자동 로딩 - wing_auth DB 비밀번호 기본값 수정 (JDBC 호환) Co-Authored-By: Claude Opus 4.6 --- backend/package-lock.json | 13 + backend/package.json | 1 + backend/src/db/authDb.ts | 2 +- backend/src/menus/menuRouter.ts | 35 +++ backend/src/server.ts | 15 +- backend/src/settings/settingsService.ts | 82 ++++++ database/auth_init.sql | 5 +- frontend/package-lock.json | 29 ++- frontend/package.json | 3 + frontend/src/App.tsx | 8 + frontend/src/components/layout/TopBar.tsx | 34 +-- frontend/src/components/views/AdminView.tsx | 271 ++++++++++++++------ frontend/src/services/authApi.ts | 19 ++ frontend/src/store/menuStore.ts | 28 ++ 14 files changed, 442 insertions(+), 103 deletions(-) create mode 100644 backend/src/menus/menuRouter.ts create mode 100644 frontend/src/store/menuStore.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 8bb0b5a..761c796 100755 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "better-sqlite3": "^11.9.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "dotenv": "^17.3.1", "express": "^4.21.2", "express-rate-limit": "^8.2.1", "google-auth-library": "^10.6.1", @@ -1128,6 +1129,18 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index e09efd0..36aaee1 100755 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "better-sqlite3": "^11.9.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "dotenv": "^17.3.1", "express": "^4.21.2", "express-rate-limit": "^8.2.1", "google-auth-library": "^10.6.1", diff --git a/backend/src/db/authDb.ts b/backend/src/db/authDb.ts index ac8b352..d82613c 100644 --- a/backend/src/db/authDb.ts +++ b/backend/src/db/authDb.ts @@ -7,7 +7,7 @@ const authPool = new Pool({ port: Number(process.env.AUTH_DB_PORT) || 5432, database: process.env.AUTH_DB_NAME || 'wing_auth', user: process.env.AUTH_DB_USER || 'wing_auth', - password: process.env.AUTH_DB_PASSWORD || 'WingAuth!2026', + password: process.env.AUTH_DB_PASSWORD || 'WingAuth2026', max: 10, idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000, diff --git a/backend/src/menus/menuRouter.ts b/backend/src/menus/menuRouter.ts new file mode 100644 index 0000000..07562e5 --- /dev/null +++ b/backend/src/menus/menuRouter.ts @@ -0,0 +1,35 @@ +import { Router } from 'express' +import { requireAuth, requireRole } from '../auth/authMiddleware.js' +import { getMenuConfig, updateMenuConfig } from '../settings/settingsService.js' + +const router = Router() + +// GET /api/menus — 메뉴 설정 조회 (인증된 모든 사용자) +router.get('/', requireAuth, async (_req, res) => { + try { + const config = await getMenuConfig() + res.json(config) + } catch (err) { + console.error('[menus] 조회 오류:', err) + res.status(500).json({ error: '메뉴 설정 조회 중 오류가 발생했습니다.' }) + } +}) + +// PUT /api/menus — 메뉴 설정 수정 (ADMIN 전용) +router.put('/', requireAuth, requireRole('ADMIN'), async (req, res) => { + try { + const { menus } = req.body + if (!Array.isArray(menus)) { + res.status(400).json({ error: '메뉴 설정 형식이 올바르지 않습니다.' }) + return + } + const updated = await updateMenuConfig(menus) + res.json(updated) + } catch (err) { + const message = err instanceof Error ? err.message : '메뉴 설정 수정 중 오류가 발생했습니다.' + console.error('[menus] 수정 오류:', err) + res.status(400).json({ error: message }) + } +}) + +export default router diff --git a/backend/src/server.ts b/backend/src/server.ts index d8afe60..63af491 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,3 +1,4 @@ +import 'dotenv/config' import express from 'express' import cors from 'cors' import helmet from 'helmet' @@ -11,6 +12,7 @@ import authRouter from './auth/authRouter.js' import userRouter from './users/userRouter.js' import roleRouter from './roles/roleRouter.js' import settingsRouter from './settings/settingsRouter.js' +import menuRouter from './menus/menuRouter.js' import { sanitizeBody, sanitizeQuery, @@ -132,6 +134,7 @@ app.use('/api/auth', authRouter) app.use('/api/users', userRouter) app.use('/api/roles', roleRouter) app.use('/api/settings', settingsRouter) +app.use('/api/menus', menuRouter) // API 라우트 — 업무 app.use('/api/layers', layersRouter) @@ -170,5 +173,15 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres // ============================================================ app.listen(PORT, async () => { console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`) - await testAuthDbConnection() + const connected = await testAuthDbConnection() + if (connected) { + // SETTING_VAL VARCHAR(500) → TEXT 마이그레이션 (메뉴 설정 JSON 확장 대응) + try { + const { authPool } = await import('./db/authDb.js') + await authPool.query(`ALTER TABLE AUTH_SETTING ALTER COLUMN SETTING_VAL TYPE TEXT`) + console.log('[migration] SETTING_VAL → TEXT 변환 완료') + } catch { + // 이미 TEXT이거나 권한 없으면 무시 + } + } }) diff --git a/backend/src/settings/settingsService.ts b/backend/src/settings/settingsService.ts index d320faf..acb601b 100644 --- a/backend/src/settings/settingsService.ts +++ b/backend/src/settings/settingsService.ts @@ -72,6 +72,88 @@ export async function updateOAuthSettings(settings: { } } +// ─── 메뉴 설정 ────────────────────────────────────────────── + +export interface MenuConfigItem { + id: string + label: string + icon: string + enabled: boolean + order: number +} + +const DEFAULT_MENU_CONFIG: MenuConfigItem[] = [ + { id: 'prediction', label: '유출유 확산예측', icon: '🛢️', enabled: true, order: 1 }, + { id: 'hns', label: 'HNS·대기확산', icon: '🧪', enabled: true, order: 2 }, + { id: 'rescue', label: '긴급구난', icon: '🚨', enabled: true, order: 3 }, + { id: 'reports', label: '보고자료', icon: '📊', enabled: true, order: 4 }, + { id: 'aerial', label: '항공탐색', icon: '✈️', enabled: true, order: 5 }, + { id: 'assets', label: '방제자산 관리', icon: '🚢', enabled: true, order: 6 }, + { id: 'scat', label: '해안평가', icon: '🏖️', enabled: true, order: 7 }, + { id: 'board', label: '게시판', icon: '📌', enabled: true, order: 8 }, + { id: 'weather', label: '기상정보', icon: '⛅', enabled: true, order: 9 }, + { id: 'incidents', label: '통합조회', icon: '🔍', enabled: true, order: 10 }, +] + +const VALID_MENU_IDS = DEFAULT_MENU_CONFIG.map(m => m.id) + +export async function getMenuConfig(): Promise { + const val = await getSetting('menu.config') + if (!val) return DEFAULT_MENU_CONFIG + + try { + const parsed = JSON.parse(val) as MenuConfigItem[] + const defaultMap = new Map(DEFAULT_MENU_CONFIG.map(m => [m.id, m])) + + return parsed + .filter(item => VALID_MENU_IDS.includes(item.id)) + .map(item => { + const defaults = defaultMap.get(item.id)! + return { + id: item.id, + label: item.label || defaults.label, + icon: item.icon || defaults.icon, + enabled: item.enabled, + order: item.order, + } + }) + .sort((a, b) => a.order - b.order) + } catch { + return DEFAULT_MENU_CONFIG + } +} + +export async function updateMenuConfig( + config: MenuConfigItem[] +): Promise { + const filtered = config.filter(item => VALID_MENU_IDS.includes(item.id)) + + if (filtered.length !== VALID_MENU_IDS.length) { + throw new Error('모든 메뉴 항목이 포함되어야 합니다.') + } + + const ids = new Set(filtered.map(item => item.id)) + if (ids.size !== filtered.length) { + throw new Error('중복된 메뉴 ID가 있습니다.') + } + + const defaultMap = new Map(DEFAULT_MENU_CONFIG.map(m => [m.id, m])) + + const normalized = filtered.map((item, idx) => { + const defaults = defaultMap.get(item.id)! + return { + id: item.id, + label: item.label || defaults.label, + icon: item.icon || defaults.icon, + enabled: Boolean(item.enabled), + order: idx + 1, + } + }) + + await setSetting('menu.config', JSON.stringify(normalized)) + return normalized +} + export async function getAllSettings(): Promise { const result = await authPool.query( 'SELECT SETTING_KEY, SETTING_VAL, SETTING_DC FROM AUTH_SETTING ORDER BY SETTING_KEY' diff --git a/database/auth_init.sql b/database/auth_init.sql index 0383e08..d78b94a 100644 --- a/database/auth_init.sql +++ b/database/auth_init.sql @@ -179,7 +179,7 @@ COMMENT ON COLUMN AUTH_LOGIN_HIST.SUCCESS_YN IS '성공여부 (Y:성공, N:실 -- ============================================================ CREATE TABLE AUTH_SETTING ( SETTING_KEY VARCHAR(100) NOT NULL, - SETTING_VAL VARCHAR(500) NOT NULL, + SETTING_VAL TEXT NOT NULL, SETTING_DC VARCHAR(200), MDFCN_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT PK_AUTH_SETTING PRIMARY KEY (SETTING_KEY) @@ -281,7 +281,8 @@ SELECT USER_ID, 1 FROM AUTH_USER WHERE USER_ACNT = 'admin'; INSERT INTO AUTH_SETTING (SETTING_KEY, SETTING_VAL, SETTING_DC) VALUES ('registration.auto-approve', 'true', '신규 사용자 자동 승인 여부 (true: 즉시 ACTIVE, false: PENDING 대기)'), ('registration.default-role', 'true', '신규 사용자에게 기본 역할(DFLT_YN=Y) 자동 할당 여부'), -('oauth.auto-approve-domains', 'gcsc.co.kr', 'OAuth 자동 승인 도메인 (쉼표 구분)'); +('oauth.auto-approve-domains', 'gcsc.co.kr', 'OAuth 자동 승인 도메인 (쉼표 구분)'), +('menu.config', '[{"id":"prediction","label":"유출유 확산예측","icon":"🛢️","enabled":true,"order":1},{"id":"hns","label":"HNS·대기확산","icon":"🧪","enabled":true,"order":2},{"id":"rescue","label":"긴급구난","icon":"🚨","enabled":true,"order":3},{"id":"reports","label":"보고자료","icon":"📊","enabled":true,"order":4},{"id":"aerial","label":"항공탐색","icon":"✈️","enabled":true,"order":5},{"id":"assets","label":"방제자산 관리","icon":"🚢","enabled":true,"order":6},{"id":"scat","label":"해안평가","icon":"🏖️","enabled":true,"order":7},{"id":"board","label":"게시판","icon":"📌","enabled":true,"order":8},{"id":"weather","label":"기상정보","icon":"⛅","enabled":true,"order":9},{"id":"incidents","label":"통합조회","icon":"🔍","enabled":true,"order":10}]', '메뉴 표시 여부 및 순서 설정 (JSON 배열)'); -- ============================================================ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e427d4..1f62c2b 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,9 +8,12 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "@react-oauth/google": "^0.13.4", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.5", + "emoji-mart": "^5.6.0", "leaflet": "^1.9.4", "lucide-react": "^0.564.0", "ol": "^10.8.0", @@ -334,6 +337,22 @@ "node": ">=6.9.0" } }, + "node_modules/@emoji-mart/data": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz", + "integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==", + "license": "MIT" + }, + "node_modules/@emoji-mart/react": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz", + "integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==", + "license": "MIT", + "peerDependencies": { + "emoji-mart": "^5.2", + "react": "^16.8 || ^17 || ^18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1585,7 +1604,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2344,7 +2363,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -2421,6 +2440,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-mart": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz", + "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==", + "license": "MIT" + }, "node_modules/engine.io-client": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2091db7..9bfc380 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,9 +10,12 @@ "preview": "vite preview" }, "dependencies": { + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "@react-oauth/google": "^0.13.4", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.5", + "emoji-mart": "^5.6.0", "leaflet": "^1.9.4", "lucide-react": "^0.564.0", "ol": "^10.8.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6fdb0e..da36a5a 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { MainLayout } from './components/layout/MainLayout' import { LoginPage } from './components/auth/LoginPage' import { registerMainTabSwitcher } from './hooks/useSubMenu' import { useAuthStore } from './store/authStore' +import { useMenuStore } from './store/menuStore' import { OilSpillView } from './components/views/OilSpillView' import { ReportsView } from './components/views/ReportsView' import { HNSView } from './components/views/HNSView' @@ -23,11 +24,18 @@ const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '' function App() { const [activeMainTab, setActiveMainTab] = useState('prediction') const { isAuthenticated, isLoading, checkSession } = useAuthStore() + const { loadMenuConfig } = useMenuStore() useEffect(() => { checkSession() }, [checkSession]) + useEffect(() => { + if (isAuthenticated) { + loadMenuConfig() + } + }, [isAuthenticated, loadMenuConfig]) + useEffect(() => { registerMainTabSwitcher(setActiveMainTab) }, []) diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 1ad5a87..c5f4a8d 100755 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -1,25 +1,7 @@ import { useState, useRef, useEffect, useMemo } from 'react' import type { MainTab } from '../../App' import { useAuthStore } from '../../store/authStore' - -interface Tab { - id: MainTab - label: string - icon: string -} - -const ALL_TABS: Tab[] = [ - { id: 'prediction', label: '유출유 확산예측', icon: '🛢️' }, - { id: 'hns', label: 'HNS·대기확산', icon: '🧪' }, - { id: 'rescue', label: '긴급구난', icon: '🚨' }, - { id: 'reports', label: '보고자료', icon: '📊' }, - { id: 'aerial', label: '항공탐색', icon: '✈️' }, - { id: 'assets', label: '방제자산 관리', icon: '🚢' }, - { id: 'scat', label: '해안평가', icon: '🏖️' }, - { id: 'board', label: '게시판', icon: '📌' }, - { id: 'weather', label: '기상정보', icon: '⛅' }, - { id: 'incidents', label: '통합조회', icon: '🔍' }, -] +import { useMenuStore } from '../../store/menuStore' interface TopBarProps { activeTab: MainTab @@ -31,11 +13,15 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) { const [mapToggles, setMapToggles] = useState({ s57: true, s101: false, threeD: false, satellite: false }) const quickMenuRef = useRef(null) const { hasPermission, user, logout } = useAuthStore() + const { menuConfig, isLoaded } = useMenuStore() - const tabs = useMemo( - () => ALL_TABS.filter((tab) => hasPermission(tab.id)), - [hasPermission, user?.permissions] - ) + const tabs = useMemo(() => { + if (!isLoaded || menuConfig.length === 0) return [] + + return menuConfig + .filter((m) => m.enabled && hasPermission(m.id)) + .sort((a, b) => a.order - b.order) + }, [hasPermission, user?.permissions, menuConfig, isLoaded]) useEffect(() => { const handler = (e: MouseEvent) => { @@ -66,7 +52,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) { return (
-
- {menus.map((menu, idx) => ( -
-
- {idx + 1} -
-
- {menu.label} -
-
{menu.id}
+
+ {menus.map((menu, idx) => { + const isEditing = editingId === menu.id + return ( +
+
+ {idx + 1} + {isEditing ? ( + <> +
+ + {emojiPickerId === menu.id && ( +
+ +
+ )} +
+
+ updateMenuField(menu.id, 'label', e.target.value)} + className="w-full h-8 text-[13px] font-semibold font-korean bg-bg-2 border border-border rounded px-2 text-text-1 focus:border-primary-cyan focus:outline-none" + /> +
{menu.id}
+
+ + + ) : ( + <> + {menu.icon} +
+
+ {menu.label} +
+
{menu.id}
+
+ + + )}
-
-
- -
- - +
+ + +
-
- ))} + ) + })}
diff --git a/frontend/src/services/authApi.ts b/frontend/src/services/authApi.ts index c3db860..f61bd0f 100644 --- a/frontend/src/services/authApi.ts +++ b/frontend/src/services/authApi.ts @@ -188,3 +188,22 @@ export async function updateOAuthSettingsApi( const response = await api.put('/settings/oauth', settings) return response.data } + +// 메뉴 설정 API +export interface MenuConfigItem { + id: string + label: string + icon: string + enabled: boolean + order: number +} + +export async function fetchMenuConfig(): Promise { + const response = await api.get('/menus') + return response.data +} + +export async function updateMenuConfigApi(menus: MenuConfigItem[]): Promise { + const response = await api.put('/menus', { menus }) + return response.data +} diff --git a/frontend/src/store/menuStore.ts b/frontend/src/store/menuStore.ts new file mode 100644 index 0000000..4f77539 --- /dev/null +++ b/frontend/src/store/menuStore.ts @@ -0,0 +1,28 @@ +import { create } from 'zustand' +import { fetchMenuConfig } from '../services/authApi' +import type { MenuConfigItem } from '../services/authApi' + +interface MenuState { + menuConfig: MenuConfigItem[] + isLoaded: boolean + loadMenuConfig: () => Promise + setMenuConfig: (config: MenuConfigItem[]) => void +} + +export const useMenuStore = create((set) => ({ + menuConfig: [], + isLoaded: false, + + loadMenuConfig: async () => { + try { + const config = await fetchMenuConfig() + set({ menuConfig: config, isLoaded: true }) + } catch { + set({ isLoaded: true }) + } + }, + + setMenuConfig: (config: MenuConfigItem[]) => { + set({ menuConfig: config }) + }, +}))