From 9ee1e2ccebdbc712aad01bf4bf4ecf8f99a0db9f Mon Sep 17 00:00:00 2001 From: bangdk Date: Sat, 9 Nov 2024 08:58:47 +0900 Subject: [PATCH] auto commit --- fems-api/src/middleware/auth.middleware.js | 230 +++--------- fems-api/src/services/auth.service.js | 98 ++++- fems-app/src/app/(admin)/layout.tsx | 8 +- fems-app/src/app/(alarm)/layout.tsx | 8 +- fems-app/src/app/(analysis)/layout.tsx | 10 +- fems-app/src/app/(equipment)/layout.tsx | 8 +- fems-app/src/app/(general)/layout.tsx | 8 +- fems-app/src/app/(monitoring)/layout.tsx | 13 +- fems-app/src/app/(planning)/layout.tsx | 8 +- fems-app/src/app/(support)/layout.tsx | 8 +- fems-app/src/components/auth/AdminGuard.tsx | 40 +- .../src/components/general/GeneralHeader.tsx | 113 ------ .../src/components/general/GeneralSidebar.tsx | 236 ------------ .../{admin => layout}/AdminSidebar.tsx | 0 fems-app/src/components/layout/SideNav.tsx | 351 +++++++++++++++--- fems-app/src/components/layout/TopNav.tsx | 92 ++++- .../monitoring/MonitoringHeader.tsx | 113 ------ .../monitoring/MonitoringSidebar.tsx | 235 ------------ fems-app/src/hooks/usePermissions.ts | 39 ++ fems-app/src/lib/jwt.ts | 55 ++- fems-app/src/middleware.tsx | 73 +++- fems-app/src/types/auth.ts | 22 ++ 22 files changed, 729 insertions(+), 1039 deletions(-) delete mode 100644 fems-app/src/components/general/GeneralHeader.tsx delete mode 100644 fems-app/src/components/general/GeneralSidebar.tsx rename fems-app/src/components/{admin => layout}/AdminSidebar.tsx (100%) delete mode 100644 fems-app/src/components/monitoring/MonitoringHeader.tsx delete mode 100644 fems-app/src/components/monitoring/MonitoringSidebar.tsx create mode 100644 fems-app/src/hooks/usePermissions.ts diff --git a/fems-api/src/middleware/auth.middleware.js b/fems-api/src/middleware/auth.middleware.js index d0b439d..8413370 100644 --- a/fems-api/src/middleware/auth.middleware.js +++ b/fems-api/src/middleware/auth.middleware.js @@ -1,208 +1,64 @@ // src/middleware/auth.middleware.js const jwt = require("jsonwebtoken"); -const { promisify } = require("util"); const config = require("../config/config"); -const { User, Role, UserRole } = require("../models"); -const { AuthenticationError, AuthorizationError } = require("../utils/errors"); -const logger = require("../utils/logger"); -const redis = require("../config/redis"); +const { User, Role } = require("../models"); -/** - * 토큰 검증 및 사용자 인증 미들웨어 - */ const authMiddleware = async (req, res, next) => { try { - // 토큰 추출 - const token = extractTokenFromHeader(req); - if (!token) { - throw new AuthenticationError("Authentication token is required"); + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return res + .status(401) + .json({ message: "Authentication token is required" }); } - // 토큰 검증 - const decoded = await verifyToken(token); + const token = authHeader.split(" ")[1]; + const decoded = jwt.verify(token, config.jwt.secret); - // 세션 확인 - await validateSession(decoded.id, token); - - // 사용자 정보 조회 및 검증 - const user = await getUserWithRoles(decoded.id); - validateUser(user); - - // 마지막 활동 시간 업데이트 - await updateLastActivity(user.id); - - // 요청 객체에 사용자 정보 추가 - req.user = user; - next(); - } catch (error) { - handleAuthError(error, res); - } -}; - -/** - * 특정 권한 필요한 엔드포인트용 미들웨어 - */ -const requireRole = (...roles) => { - return (req, res, next) => { - if (!req.user) { - return res.status(401).json({ message: "Authentication required" }); - } - - const hasRequiredRole = req.user.Roles.some( - (role) => roles.includes(role.name) && role.isActive - ); - - if (!hasRequiredRole) { - return res.status(403).json({ - message: `Required role: ${roles.join(" or ")}`, - }); - } - - next(); - }; -}; - -/** - * 세부 권한 체크 미들웨어 - */ -const checkPermission = (resource, action) => { - return async (req, res, next) => { - try { - if (!req.user) { - throw new AuthenticationError("Authentication required"); - } - - const hasPermission = await validatePermission( - req.user.id, - resource, - action - ); - if (!hasPermission) { - throw new AuthorizationError( - `Permission denied: ${resource}:${action}` - ); - } - - next(); - } catch (error) { - handleAuthError(error, res); - } - }; -}; - -// 유틸리티 함수들 -const extractTokenFromHeader = (req) => { - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return null; - } - return authHeader.split(" ")[1]; -}; - -const verifyToken = async (token) => { - try { - // 토큰 블랙리스트 체크 - const isBlacklisted = await redis.get(`blacklist:${token}`); - if (isBlacklisted) { - throw new AuthenticationError("Token has been revoked"); - } - - return await promisify(jwt.verify)(token, config.jwt.secret); - } catch (error) { - if (error.name === "TokenExpiredError") { - throw new AuthenticationError("Token has expired"); - } - throw new AuthenticationError("Invalid token"); - } -}; - -const validateSession = async (userId, token) => { - const currentSession = await redis.get(`session:${userId}`); - if (currentSession && currentSession !== token) { - throw new AuthenticationError("Session has been invalidated"); - } -}; - -const getUserWithRoles = async (userId) => { - return await User.findByPk(userId, { - attributes: { exclude: ["password"] }, - include: [ - { - model: Role, - through: UserRole, - attributes: ["id", "name", "permissions", "isActive"], - where: { isActive: true }, - }, - ], - }); -}; - -const validateUser = (user) => { - if (!user) { - throw new AuthenticationError("User not found"); - } - if (!user.isActive) { - throw new AuthenticationError("User account is inactive"); - } - if (user.Roles.length === 0) { - throw new AuthorizationError("User has no active roles"); - } -}; - -const validatePermission = async (userId, resource, action) => { - // Redis에서 캐시된 권한 확인 - const cacheKey = `permissions:${userId}`; - let permissions = await redis.get(cacheKey); - - if (!permissions) { - // DB에서 권한 조회 및 캐싱 - const user = await User.findByPk(userId, { + const user = await User.findOne({ + where: { id: decoded.id }, include: [ { model: Role, - attributes: ["permissions"], - where: { isActive: true }, + through: { attributes: [] }, + attributes: ["id", "name", "permissions"], + required: false, }, ], + attributes: { + exclude: ["password"], + }, }); - permissions = user.Roles.reduce((acc, role) => { - const rolePerms = role.permissions || {}; - Object.keys(rolePerms).forEach((res) => { - acc[res] = acc[res] || []; - acc[res].push(...rolePerms[res]); + if (!user || !user.isActive) { + return res.status(401).json({ message: "User not found or inactive" }); + } + + // 권한 정보 처리 + const permissions = { + "basic:view": true, // 기본 권한 + }; + + if (user.Roles) { + user.Roles.forEach((role) => { + if (role.permissions) { + Object.entries(role.permissions).forEach(([key, value]) => { + permissions[key] = permissions[key] || value; + }); + } }); - return acc; - }, {}); + } - await redis.set(cacheKey, JSON.stringify(permissions), "EX", 3600); // 1시간 캐시 - } else { - permissions = JSON.parse(permissions); + // user 객체에서 Roles 제거하고 permissions 추가 + const userData = user.toJSON(); + delete userData.Roles; + userData.permissions = permissions; + + req.user = userData; + next(); + } catch (error) { + return res.status(401).json({ message: "Invalid token" }); } - - return permissions[resource]?.includes(action) || false; }; -const updateLastActivity = async (userId) => { - await redis.set(`lastActivity:${userId}`, Date.now()); -}; - -const handleAuthError = (error, res) => { - logger.error("Authentication error", { error }); - - if (error instanceof AuthenticationError) { - return res.status(401).json({ message: error.message }); - } - if (error instanceof AuthorizationError) { - return res.status(403).json({ message: error.message }); - } - - return res.status(500).json({ message: "Internal server error" }); -}; - -module.exports = { - authMiddleware, - requireRole, - checkPermission, - validatePermission, // Export for testing -}; +module.exports = authMiddleware; diff --git a/fems-api/src/services/auth.service.js b/fems-api/src/services/auth.service.js index 7af0c63..f8a779b 100644 --- a/fems-api/src/services/auth.service.js +++ b/fems-api/src/services/auth.service.js @@ -1,19 +1,18 @@ -// src/services/auth.service.js const jwt = require("jsonwebtoken"); const config = require("../config/config"); -const { User, AuthLog, Company, Branch } = require("../models"); +const { User, AuthLog, Company, Branch, Role } = require("../models"); class AuthService { async login(username, password, ipAddress, userAgent) { - const user = await User.findOne({ + // 먼저 사용자 존재 여부와 비밀번호 확인 + const userWithPassword = await User.findOne({ where: { username }, - // 필요한 관계 데이터도 함께 로드 - include: [{ model: Company }, { model: Branch }], + attributes: ["id", "password", "isActive"], }); - if (!user || !user.isActive) { + if (!userWithPassword || !userWithPassword.isActive) { await this._logAuthAttempt( - user?.id, + userWithPassword?.id, "failed_login", ipAddress, userAgent @@ -21,39 +20,97 @@ class AuthService { throw new Error("Invalid credentials"); } - const isValidPassword = await user.validatePassword(password); + const isValidPassword = await userWithPassword.validatePassword(password); if (!isValidPassword) { - await this._logAuthAttempt(user.id, "failed_login", ipAddress, userAgent); + await this._logAuthAttempt( + userWithPassword.id, + "failed_login", + ipAddress, + userAgent + ); throw new Error("Invalid credentials"); } + // 비밀번호 확인 후 전체 사용자 정보 조회 + const user = await User.findOne({ + where: { id: userWithPassword.id }, + include: [ + { + model: Company, + attributes: ["id", "name", "businessNumber"], + }, + { + model: Branch, + attributes: ["id", "name"], + }, + { + model: Role, + through: { attributes: [] }, + attributes: ["id", "name", "permissions"], + required: false, // Role이 없어도 조회 가능하도록 설정 + }, + ], + attributes: { + exclude: ["password"], + }, + }); + await User.update({ lastLoginAt: new Date() }, { where: { id: user.id } }); await this._logAuthAttempt(user.id, "login", ipAddress, userAgent); - const token = this._generateToken(user); - // 안전한 사용자 데이터 반환을 위해 password 제외 - const userWithoutPassword = user.toJSON(); - delete userWithoutPassword.password; + // 권한 정보 가공 + const permissions = this._processPermissions(user.Roles || []); + + // 사용자 정보에서 Roles 배열을 제거하고 가공된 권한 정보를 추가 + const userData = user.toJSON(); + delete userData.Roles; + + const userInfo = { + ...userData, + permissions, + }; + + const token = this._generateToken(userInfo); return { token, - user: userWithoutPassword, + user: userInfo, }; } + _processPermissions(roles) { + // 기본 권한 설정 (모든 사용자가 가져야 할 기본 권한) + const permissions = { + "basic:view": true, // 기본 조회 권한 + }; + + roles.forEach((role) => { + if (role?.permissions) { + Object.entries(role.permissions).forEach(([key, value]) => { + permissions[key] = permissions[key] || value; + }); + } + }); + + return permissions; + } + async logout(userId, ipAddress, userAgent) { await this._logAuthAttempt(userId, "logout", ipAddress, userAgent); return true; } async _logAuthAttempt(userId, action, ipAddress, userAgent) { - await AuthLog.create({ - userId, - action, - ipAddress, - userAgent, - }); + if (userId) { + // userId가 있을 때만 로그 생성 + await AuthLog.create({ + userId, + action, + ipAddress, + userAgent, + }); + } } _generateToken(user) { @@ -63,6 +120,7 @@ class AuthService { role: user.role, companyId: user.companyId, branchId: user.branchId, + permissions: user.permissions, }, config.jwt.secret, { expiresIn: config.jwt.expiresIn } diff --git a/fems-app/src/app/(admin)/layout.tsx b/fems-app/src/app/(admin)/layout.tsx index ac8f17c..51746a8 100644 --- a/fems-app/src/app/(admin)/layout.tsx +++ b/fems-app/src/app/(admin)/layout.tsx @@ -1,7 +1,7 @@ // src/app/(admin)/layout.tsx import AdminGuard from "@/components/auth/AdminGuard"; -import { AdminSidebar } from "@/components/admin/AdminSidebar"; -import { GeneralHeader } from "@/components/general/GeneralHeader"; +import { SideNav } from "@/components/layout/SideNav"; +import { TopNav } from "@/components/layout/TopNav"; export default function AdminLayout({ children, @@ -12,13 +12,13 @@ export default function AdminLayout({
{/* 오른쪽 메인 영역 */}
{/* 상단 헤더 */}
- +
{/* 메인 컨텐츠 영역 */} diff --git a/fems-app/src/app/(alarm)/layout.tsx b/fems-app/src/app/(alarm)/layout.tsx index 8b4bf26..086c4bf 100644 --- a/fems-app/src/app/(alarm)/layout.tsx +++ b/fems-app/src/app/(alarm)/layout.tsx @@ -1,20 +1,20 @@ import React from "react"; -import { GeneralSidebar } from "@/components/general/GeneralSidebar"; -import { GeneralHeader } from "@/components/general/GeneralHeader"; +import { SideNav } from "@/components/layout/SideNav"; +import { TopNav } from "@/components/layout/TopNav"; const GeneralLayout = ({ children }: { children: React.ReactNode }) => { return (
{/* 왼쪽 사이드바 */} {/* 오른쪽 메인 영역 */}
{/* 상단 헤더 */}
- +
{/* 메인 컨텐츠 영역 */} diff --git a/fems-app/src/app/(analysis)/layout.tsx b/fems-app/src/app/(analysis)/layout.tsx index 8b4bf26..a48eeec 100644 --- a/fems-app/src/app/(analysis)/layout.tsx +++ b/fems-app/src/app/(analysis)/layout.tsx @@ -1,20 +1,20 @@ import React from "react"; -import { GeneralSidebar } from "@/components/general/GeneralSidebar"; -import { GeneralHeader } from "@/components/general/GeneralHeader"; +import { SideNav } from "@/components/layout/SideNav"; +import { TopNav } from "@/components/layout/TopNav"; const GeneralLayout = ({ children }: { children: React.ReactNode }) => { return (
{/* 왼쪽 사이드바 */} {/* 오른쪽 메인 영역 */}
{/* 상단 헤더 */}
- +
{/* 메인 컨텐츠 영역 */} @@ -24,4 +24,4 @@ const GeneralLayout = ({ children }: { children: React.ReactNode }) => { ); }; -export default GeneralLayout; +export default GeneralLayout; \ No newline at end of file diff --git a/fems-app/src/app/(equipment)/layout.tsx b/fems-app/src/app/(equipment)/layout.tsx index e2455a4..d51f89f 100644 --- a/fems-app/src/app/(equipment)/layout.tsx +++ b/fems-app/src/app/(equipment)/layout.tsx @@ -1,21 +1,21 @@ // src/(equipment)/layout.tsx import React from "react"; -import { GeneralSidebar } from "@/components/general/GeneralSidebar"; -import { GeneralHeader } from "@/components/general/GeneralHeader"; +import { SideNav } from "@/components/layout/SideNav"; +import { TopNav } from "@/components/layout/TopNav"; const GeneralLayout = ({ children }: { children: React.ReactNode }) => { return (
{/* 왼쪽 사이드바 */} {/* 오른쪽 메인 영역 */}
{/* 상단 헤더 */}
- +
{/* 메인 컨텐츠 영역 */} diff --git a/fems-app/src/app/(general)/layout.tsx b/fems-app/src/app/(general)/layout.tsx index de09b11..c8313ce 100644 --- a/fems-app/src/app/(general)/layout.tsx +++ b/fems-app/src/app/(general)/layout.tsx @@ -1,21 +1,21 @@ // src/(general)/layout.tsx import React from "react"; -import { GeneralSidebar } from "@/components/general/GeneralSidebar"; -import { GeneralHeader } from "@/components/general/GeneralHeader"; +import { SideNav } from "@/components/layout/SideNav"; +import { TopNav } from "@/components/layout/TopNav"; const GeneralLayout = ({ children }: { children: React.ReactNode }) => { return (
{/* 왼쪽 사이드바 */} {/* 오른쪽 메인 영역 */}
{/* 상단 헤더 */}
- +
{/* 메인 컨텐츠 영역 */} diff --git a/fems-app/src/app/(monitoring)/layout.tsx b/fems-app/src/app/(monitoring)/layout.tsx index cf37798..170cc70 100644 --- a/fems-app/src/app/(monitoring)/layout.tsx +++ b/fems-app/src/app/(monitoring)/layout.tsx @@ -1,20 +1,21 @@ // src/app/(monitoring)/layout.tsx -import { MonitoringSidebar } from "@/components/monitoring/MonitoringSidebar"; -import { MonitoringHeader } from "@/components/monitoring/MonitoringHeader"; +import React from "react"; +import { SideNav } from "@/components/layout/SideNav"; +import { TopNav } from "@/components/layout/TopNav"; -const MonitoringLayout = ({ children }: { children: React.ReactNode }) => { +const GeneralLayout = ({ children }: { children: React.ReactNode }) => { return (
{/* 왼쪽 사이드바 */} {/* 오른쪽 메인 영역 */}
{/* 상단 헤더 */}
- +
{/* 메인 컨텐츠 영역 */} @@ -24,4 +25,4 @@ const MonitoringLayout = ({ children }: { children: React.ReactNode }) => { ); }; -export default MonitoringLayout; +export default GeneralLayout; diff --git a/fems-app/src/app/(planning)/layout.tsx b/fems-app/src/app/(planning)/layout.tsx index de09b11..c8313ce 100644 --- a/fems-app/src/app/(planning)/layout.tsx +++ b/fems-app/src/app/(planning)/layout.tsx @@ -1,21 +1,21 @@ // src/(general)/layout.tsx import React from "react"; -import { GeneralSidebar } from "@/components/general/GeneralSidebar"; -import { GeneralHeader } from "@/components/general/GeneralHeader"; +import { SideNav } from "@/components/layout/SideNav"; +import { TopNav } from "@/components/layout/TopNav"; const GeneralLayout = ({ children }: { children: React.ReactNode }) => { return (
{/* 왼쪽 사이드바 */} {/* 오른쪽 메인 영역 */}
{/* 상단 헤더 */}
- +
{/* 메인 컨텐츠 영역 */} diff --git a/fems-app/src/app/(support)/layout.tsx b/fems-app/src/app/(support)/layout.tsx index de09b11..c8313ce 100644 --- a/fems-app/src/app/(support)/layout.tsx +++ b/fems-app/src/app/(support)/layout.tsx @@ -1,21 +1,21 @@ // src/(general)/layout.tsx import React from "react"; -import { GeneralSidebar } from "@/components/general/GeneralSidebar"; -import { GeneralHeader } from "@/components/general/GeneralHeader"; +import { SideNav } from "@/components/layout/SideNav"; +import { TopNav } from "@/components/layout/TopNav"; const GeneralLayout = ({ children }: { children: React.ReactNode }) => { return (
{/* 왼쪽 사이드바 */} {/* 오른쪽 메인 영역 */}
{/* 상단 헤더 */}
- +
{/* 메인 컨텐츠 영역 */} diff --git a/fems-app/src/components/auth/AdminGuard.tsx b/fems-app/src/components/auth/AdminGuard.tsx index 09a897e..7c8e655 100644 --- a/fems-app/src/components/auth/AdminGuard.tsx +++ b/fems-app/src/components/auth/AdminGuard.tsx @@ -1,21 +1,43 @@ // src/components/auth/AdminGuard.tsx "use client"; -import { useAuth } from "@/hooks/useAuth"; +import { usePermissions } from "@/hooks/usePermissions"; +import { ReactNode } from "react"; + +interface AdminGuardProps { + children: ReactNode; + requiredPermissions?: string[]; // 필요한 권한 목록 + requireAll?: boolean; // true면 모든 권한 필요, false면 하나라도 있으면 됨 +} export default function AdminGuard({ children, -}: { - children: React.ReactNode; -}) { - const { user } = useAuth(); - const isAdmin = - user?.role === "super_admin" || user?.role === "company_admin"; + requiredPermissions = [], + requireAll = false, +}: AdminGuardProps) { + const { hasAllPermissions, hasAnyPermission } = usePermissions(); - if (!isAdmin) { - return
접근 권한이 없습니다.
; + const hasAccess = + requiredPermissions.length === 0 + ? true + : requireAll + ? hasAllPermissions(requiredPermissions) + : hasAnyPermission(requiredPermissions); + + if (!hasAccess) { + return ( +
+ 접근 권한이 없습니다. +
+ ); } return <>{children}; } +// 사용 예시: +/* + + + +*/ diff --git a/fems-app/src/components/general/GeneralHeader.tsx b/fems-app/src/components/general/GeneralHeader.tsx deleted file mode 100644 index b22a58c..0000000 --- a/fems-app/src/components/general/GeneralHeader.tsx +++ /dev/null @@ -1,113 +0,0 @@ -// src/components/general/GeneralHeader.tsx -"use client"; - -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { useAuth } from "@/hooks/useAuth"; -import { - Bell, - Search, - Settings, - HelpCircle, - LogOut, - User, - ChevronDown, -} from "lucide-react"; - -export function GeneralHeader() { - const { user, logout } = useAuth(); - - return ( -
-
-
- {/* 검색바 */} -
- - -
-
- -
- {/* 알림 버튼 */} - - - {/* 도움말 버튼 */} - - - {/* 사용자 프로필 드롭다운 */} - - - - - - 내 계정 - - - - 프로필 설정 - - - - 환경설정 - - - - - 로그아웃 - - - -
-
-
- ); -} - -export default GeneralHeader; diff --git a/fems-app/src/components/general/GeneralSidebar.tsx b/fems-app/src/components/general/GeneralSidebar.tsx deleted file mode 100644 index 09631c9..0000000 --- a/fems-app/src/components/general/GeneralSidebar.tsx +++ /dev/null @@ -1,236 +0,0 @@ -// src/components/layout/GenericSidebar.tsx -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { cn } from "@/lib/utils"; -import { - LayoutDashboard, - Gauge, - TrendingUp, - DollarSign, - Zap, - Droplet, - Wind, - Flame, - Box, - Activity, - Wrench, - LineChart, - BarChart, - FileText, - Bell, - History, - Settings as SettingsIcon, - Target, - Brain, - Sliders, - Building2, - Users, - HelpCircle, - MessageSquare, - Newspaper, - ChevronDown, - ChevronRight, - Puzzle, -} from "lucide-react"; -import { useState } from "react"; -import { title } from "process"; - -const menuItems = [ - { - title: "대시보드", - items: [ - { - title: "전체 현황", - href: "/dashboard/overview", - icon: LayoutDashboard, - }, - { title: "KPI 지표", href: "/dashboard/kpi", icon: Gauge }, - { title: "비용 현황", href: "/dashboard/costs", icon: DollarSign }, - ], - }, - { - title: "에너지 모니터링", - items: [ - { title: "전력", href: "/electricity", icon: Zap }, - { title: "가스", href: "/gas", icon: Flame }, - { title: "용수", href: "/water", icon: Droplet }, - { title: "스팀", href: "/steam", icon: Wind }, - ], - }, - { - title: "설비 관리", - items: [ - { title: "설비 목록", href: "/inventory", icon: Box }, - { title: "상태 모니터링", href: "/monitoring", icon: Activity }, - { title: "정비 관리", href: "/maintenance", icon: Wrench }, - { title: "부품 관리", href: "/parts", icon: Puzzle }, - { title: "작업자 관리", href: "/personnel", icon: Users }, - ], - }, - { - title: "분석/리포트", - items: [ - { title: "에너지 분석", href: "/energy", icon: LineChart }, - { title: "원단위 분석", href: "/efficiency", icon: BarChart }, - { title: "보고서", href: "/reports", icon: FileText }, - ], - }, - { - title: "알람/이벤트", - items: [ - { title: "실시간 알람", href: "/realtime", icon: Bell }, - { title: "이력 관리", href: "/history", icon: History }, - { title: "알람 설정", href: "/settings", icon: SettingsIcon }, - ], - }, - { - title: "에너지 계획", - items: [ - { title: "절감 목표", href: "/targets", icon: Target }, - { title: "수요 예측", href: "/forecast", icon: TrendingUp }, - { title: "최적화", href: "/optimization", icon: Brain }, - ], - }, - { - title: "관리", - items: [ - { title: "회사 설정", href: "/company/profile", icon: Building2 }, - { - title: "지점/공장 관리", - href: "/company/branches", - icon: Building2, - }, - { title: "결재 관리", href: "/company/billing", icon: DollarSign }, - { title: "사용자 권한 관리", href: "/users/roles", icon: Users }, - { title: "부서 관리", href: "/users/departments", icon: Users }, - { title: "계정 관리", href: "/users/accounts", icon: Users }, - { title: "시스템 설정", href: "/system", icon: Sliders }, - ], - }, - { - title: "지원/커뮤니티", - items: [ - { title: "도움말", href: "/faq", icon: HelpCircle }, - { title: "게시판", href: "/community/forum", icon: MessageSquare }, - { title: "뉴스", href: "/community/news", icon: Newspaper }, - ], - }, -]; - -interface MenuItemProps { - item: { - title: string; - items: { - title: string; - href: string; - icon: React.ComponentType<{ className?: string }>; - }[]; - }; - isOpen: boolean; - onToggle: () => void; - pathname: string; -} - -const MenuItem: React.FC = ({ - item, - isOpen, - onToggle, - pathname, -}) => { - const firstIcon = item.items[0]?.icon; - const IconComponent = firstIcon || Box; - - return ( -
- - - {isOpen && ( -
- {item.items.map((subItem) => ( - - {subItem.icon && ( - - )} - {subItem.title} - - ))} -
- )} -
- ); -}; - -export function GeneralSidebar() { - const pathname = usePathname(); - const [openSections, setOpenSections] = useState<{ [key: string]: boolean }>( - () => { - // 현재 경로에 해당하는 섹션을 자동으로 열어둠 - return menuItems.reduce((acc: { [key: string]: boolean }, item) => { - if (item.items.some((subItem) => subItem.href === pathname)) { - acc[item.title] = true; - } - return acc; - }, {}); - } - ); - - const toggleSection = (title: string) => { - setOpenSections((prev) => ({ - ...prev, - [title]: !prev[title], - })); - }; - - return ( - - ); -} - -export default GeneralSidebar; diff --git a/fems-app/src/components/admin/AdminSidebar.tsx b/fems-app/src/components/layout/AdminSidebar.tsx similarity index 100% rename from fems-app/src/components/admin/AdminSidebar.tsx rename to fems-app/src/components/layout/AdminSidebar.tsx diff --git a/fems-app/src/components/layout/SideNav.tsx b/fems-app/src/components/layout/SideNav.tsx index 778f60f..78b7b63 100644 --- a/fems-app/src/components/layout/SideNav.tsx +++ b/fems-app/src/components/layout/SideNav.tsx @@ -4,78 +4,307 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; -import { MenuIcon } from "lucide-react"; +import { usePermissions } from "@/hooks/usePermissions"; +import { useAuth } from "@/hooks/useAuth"; +import { + LayoutDashboard, + Gauge, + TrendingUp, + DollarSign, + Zap, + Droplet, + Wind, + Flame, + Box, + Activity, + Wrench, + LineChart, + BarChart, + FileText, + Bell, + History, + Settings as SettingsIcon, + Target, + Brain, + Sliders, + Building2, + Users, + HelpCircle, + MessageSquare, + Newspaper, + ChevronDown, + ChevronRight, + Puzzle, +} from "lucide-react"; +import { useState } from "react"; -const menuItems = [ - { - title: "대시보드", +// 관리자 메뉴에 필요한 권한 정의 +const ADMIN_PERMISSIONS = { + COMPANY: "company:manage", + BRANCH: "branches:manage", + BILLING: "billing:manage", + USERS: "users:manage", + DEPARTMENTS: "departments:manage", + ACCOUNTS: "accounts:manage", + SYSTEM: "system:manage", +}; + +const getMenuItems = ( + hasPermission: (permission: string) => boolean, + role: string +) => { + // 기본 메뉴 아이템 (관리자 메뉴 제외) + const baseMenuItems = [ + { + title: "대시보드", + items: [ + { + title: "전체 현황", + href: "/dashboard/overview", + icon: LayoutDashboard, + }, + { title: "KPI 지표", href: "/dashboard/kpi", icon: Gauge }, + { title: "비용 현황", href: "/dashboard/costs", icon: DollarSign }, + ], + }, + { + title: "에너지 모니터링", + items: [ + { title: "전력", href: "/electricity", icon: Zap }, + { title: "가스", href: "/gas", icon: Flame }, + { title: "용수", href: "/water", icon: Droplet }, + { title: "스팀", href: "/steam", icon: Wind }, + ], + }, + { + title: "설비 관리", + items: [ + { title: "설비 목록", href: "/inventory", icon: Box }, + { title: "상태 모니터링", href: "/monitoring", icon: Activity }, + { title: "정비 관리", href: "/maintenance", icon: Wrench }, + { title: "부품 관리", href: "/parts", icon: Puzzle }, + { title: "작업자 관리", href: "/personnel", icon: Users }, + ], + }, + { + title: "분석/리포트", + items: [ + { title: "에너지 분석", href: "/energy", icon: LineChart }, + { title: "원단위 분석", href: "/efficiency", icon: BarChart }, + { title: "보고서", href: "/reports", icon: FileText }, + ], + }, + { + title: "알람/이벤트", + items: [ + { title: "실시간 알람", href: "/realtime", icon: Bell }, + { title: "이력 관리", href: "/history", icon: History }, + { title: "알람 설정", href: "/settings", icon: SettingsIcon }, + ], + }, + { + title: "에너지 계획", + items: [ + { title: "절감 목표", href: "/targets", icon: Target }, + { title: "수요 예측", href: "/forecast", icon: TrendingUp }, + { title: "최적화", href: "/optimization", icon: Brain }, + ], + }, + { + title: "지원/커뮤니티", + items: [ + { title: "도움말", href: "/faq", icon: HelpCircle }, + { title: "게시판", href: "/community/forum", icon: MessageSquare }, + { title: "뉴스", href: "/community/news", icon: Newspaper }, + ], + }, + ]; + + // 관리자 권한 체크 + const isAdmin = ["super_admin", "company_admin"].includes(role); + const hasAnyAdminPermission = Object.values(ADMIN_PERMISSIONS).some( + (permission) => hasPermission(permission) + ); + + // 관리자 메뉴 아이템 + const adminMenuItem = { + title: "관리", items: [ - { title: "전체 현황", href: "/dashboard/overview", icon: MenuIcon }, - { title: "KPI 지표", href: "/dashboard/kpi" }, - { title: "비용 현황", href: "/dashboard/costs" }, + { + title: "회사 설정", + href: "/company/profile", + icon: Building2, + permission: ADMIN_PERMISSIONS.COMPANY, + }, + { + title: "지점/공장 관리", + href: "/company/branches", + icon: Building2, + permission: ADMIN_PERMISSIONS.BRANCH, + }, + { + title: "결재 관리", + href: "/company/billing", + icon: DollarSign, + permission: ADMIN_PERMISSIONS.BILLING, + }, + { + title: "사용자 권한 관리", + href: "/users/roles", + icon: Users, + permission: ADMIN_PERMISSIONS.USERS, + }, + { + title: "부서 관리", + href: "/users/departments", + icon: Users, + permission: ADMIN_PERMISSIONS.DEPARTMENTS, + }, + { + title: "계정 관리", + href: "/users/accounts", + icon: Users, + permission: ADMIN_PERMISSIONS.ACCOUNTS, + }, + { + title: "시스템 설정", + href: "/system", + icon: Sliders, + permission: ADMIN_PERMISSIONS.SYSTEM, + }, ], - }, - { - title: "에너지 모니터링", - items: [ - { title: "전력", href: "/monitoring/electricity" }, - { title: "가스", href: "/monitoring/gas" }, - { title: "용수", href: "/monitoring/water" }, - { title: "스팀", href: "/monitoring/steam" }, - ], - }, - { - title: "설비 관리", - items: [ - { title: "설비 목록", href: "/equipment/inventory" }, - { title: "상태 모니터링", href: "/equipment/monitoring" }, - { title: "정비 관리", href: "/equipment/maintenance" }, - ], - }, - { - title: "시스템 관리", - items: [ - { title: "사용자 관리", href: "/system/users" }, - { title: "알림 관리", href: "/system/alerts" }, - { title: "설정", href: "/system/settings" }, - ], - }, -]; + }; + + // 관리자 권한이 있는 메뉴 아이템만 필터링 + const filteredAdminItems = { + ...adminMenuItem, + items: adminMenuItem.items.filter((item) => hasPermission(item.permission)), + }; + + // 관리자 권한이 있고, 접근 가능한 메뉴가 하나라도 있는 경우에만 관리자 메뉴 추가 + return isAdmin || + (hasAnyAdminPermission && filteredAdminItems.items.length > 0) + ? [...baseMenuItems, filteredAdminItems] + : baseMenuItems; +}; + +interface MenuItemProps { + item: { + title: string; + items: { + title: string; + href: string; + icon: React.ComponentType<{ className?: string }>; + permission?: string; + }[]; + }; + isOpen: boolean; + onToggle: () => void; + pathname: string; +} + +const MenuItem: React.FC = ({ + item, + isOpen, + onToggle, + pathname, +}) => { + const firstIcon = item.items[0]?.icon; + const IconComponent = firstIcon || Box; + + return ( +
+ + + {isOpen && ( +
+ {item.items.map((subItem) => ( + + {subItem.icon && ( + + )} + {subItem.title} + + ))} +
+ )} +
+ ); +}; export function SideNav() { const pathname = usePathname(); + const { user } = useAuth(); + const { hasPermission } = usePermissions(); + + const menuItems = getMenuItems(hasPermission, user?.role || ""); + + const [openSections, setOpenSections] = useState<{ [key: string]: boolean }>( + () => { + return menuItems.reduce((acc: { [key: string]: boolean }, item) => { + if (item.items.some((subItem) => subItem.href === pathname)) { + acc[item.title] = true; + } + return acc; + }, {}); + } + ); + + const toggleSection = (title: string) => { + setOpenSections((prev) => ({ + ...prev, + [title]: !prev[title], + })); + }; return ( -