auto commit

This commit is contained in:
bangdk 2024-11-09 08:58:47 +09:00
parent a4b29b6a21
commit 9ee1e2cceb
22 changed files with 729 additions and 1039 deletions

View File

@ -1,208 +1,64 @@
// src/middleware/auth.middleware.js // src/middleware/auth.middleware.js
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const { promisify } = require("util");
const config = require("../config/config"); const config = require("../config/config");
const { User, Role, UserRole } = require("../models"); const { User, Role } = require("../models");
const { AuthenticationError, AuthorizationError } = require("../utils/errors");
const logger = require("../utils/logger");
const redis = require("../config/redis");
/**
* 토큰 검증 사용자 인증 미들웨어
*/
const authMiddleware = async (req, res, next) => { const authMiddleware = async (req, res, next) => {
try { try {
// 토큰 추출 const authHeader = req.headers.authorization;
const token = extractTokenFromHeader(req); if (!authHeader || !authHeader.startsWith("Bearer ")) {
if (!token) { return res
throw new AuthenticationError("Authentication token is required"); .status(401)
.json({ message: "Authentication token is required" });
} }
// 토큰 검증 const token = authHeader.split(" ")[1];
const decoded = await verifyToken(token); const decoded = jwt.verify(token, config.jwt.secret);
// 세션 확인 const user = await User.findOne({
await validateSession(decoded.id, token); where: { id: decoded.id },
// 사용자 정보 조회 및 검증
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, {
include: [ include: [
{ {
model: Role, model: Role,
attributes: ["permissions"], through: { attributes: [] },
where: { isActive: true }, attributes: ["id", "name", "permissions"],
required: false,
}, },
], ],
attributes: {
exclude: ["password"],
},
}); });
permissions = user.Roles.reduce((acc, role) => { if (!user || !user.isActive) {
const rolePerms = role.permissions || {}; return res.status(401).json({ message: "User not found or inactive" });
Object.keys(rolePerms).forEach((res) => { }
acc[res] = acc[res] || [];
acc[res].push(...rolePerms[res]); // 권한 정보 처리
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시간 캐시 // user 객체에서 Roles 제거하고 permissions 추가
} else { const userData = user.toJSON();
permissions = JSON.parse(permissions); 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) => { module.exports = authMiddleware;
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
};

View File

@ -1,19 +1,18 @@
// src/services/auth.service.js
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const config = require("../config/config"); const config = require("../config/config");
const { User, AuthLog, Company, Branch } = require("../models"); const { User, AuthLog, Company, Branch, Role } = require("../models");
class AuthService { class AuthService {
async login(username, password, ipAddress, userAgent) { async login(username, password, ipAddress, userAgent) {
const user = await User.findOne({ // 먼저 사용자 존재 여부와 비밀번호 확인
const userWithPassword = await User.findOne({
where: { username }, where: { username },
// 필요한 관계 데이터도 함께 로드 attributes: ["id", "password", "isActive"],
include: [{ model: Company }, { model: Branch }],
}); });
if (!user || !user.isActive) { if (!userWithPassword || !userWithPassword.isActive) {
await this._logAuthAttempt( await this._logAuthAttempt(
user?.id, userWithPassword?.id,
"failed_login", "failed_login",
ipAddress, ipAddress,
userAgent userAgent
@ -21,39 +20,97 @@ class AuthService {
throw new Error("Invalid credentials"); throw new Error("Invalid credentials");
} }
const isValidPassword = await user.validatePassword(password); const isValidPassword = await userWithPassword.validatePassword(password);
if (!isValidPassword) { 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"); 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 User.update({ lastLoginAt: new Date() }, { where: { id: user.id } });
await this._logAuthAttempt(user.id, "login", ipAddress, userAgent); await this._logAuthAttempt(user.id, "login", ipAddress, userAgent);
const token = this._generateToken(user); // 권한 정보 가공
// 안전한 사용자 데이터 반환을 위해 password 제외 const permissions = this._processPermissions(user.Roles || []);
const userWithoutPassword = user.toJSON();
delete userWithoutPassword.password; // 사용자 정보에서 Roles 배열을 제거하고 가공된 권한 정보를 추가
const userData = user.toJSON();
delete userData.Roles;
const userInfo = {
...userData,
permissions,
};
const token = this._generateToken(userInfo);
return { return {
token, 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) { async logout(userId, ipAddress, userAgent) {
await this._logAuthAttempt(userId, "logout", ipAddress, userAgent); await this._logAuthAttempt(userId, "logout", ipAddress, userAgent);
return true; return true;
} }
async _logAuthAttempt(userId, action, ipAddress, userAgent) { async _logAuthAttempt(userId, action, ipAddress, userAgent) {
await AuthLog.create({ if (userId) {
userId, // userId가 있을 때만 로그 생성
action, await AuthLog.create({
ipAddress, userId,
userAgent, action,
}); ipAddress,
userAgent,
});
}
} }
_generateToken(user) { _generateToken(user) {
@ -63,6 +120,7 @@ class AuthService {
role: user.role, role: user.role,
companyId: user.companyId, companyId: user.companyId,
branchId: user.branchId, branchId: user.branchId,
permissions: user.permissions,
}, },
config.jwt.secret, config.jwt.secret,
{ expiresIn: config.jwt.expiresIn } { expiresIn: config.jwt.expiresIn }

View File

@ -1,7 +1,7 @@
// src/app/(admin)/layout.tsx // src/app/(admin)/layout.tsx
import AdminGuard from "@/components/auth/AdminGuard"; import AdminGuard from "@/components/auth/AdminGuard";
import { AdminSidebar } from "@/components/admin/AdminSidebar"; import { SideNav } from "@/components/layout/SideNav";
import { GeneralHeader } from "@/components/general/GeneralHeader"; import { TopNav } from "@/components/layout/TopNav";
export default function AdminLayout({ export default function AdminLayout({
children, children,
@ -12,13 +12,13 @@ export default function AdminLayout({
<AdminGuard> <AdminGuard>
<div className="h-screen flex"> <div className="h-screen flex">
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800"> <aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<AdminSidebar /> <SideNav />
</aside> </aside>
{/* 오른쪽 메인 영역 */} {/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
{/* 상단 헤더 */} {/* 상단 헤더 */}
<header className="h-16 bg-white border-b"> <header className="h-16 bg-white border-b">
<GeneralHeader /> <TopNav />
</header> </header>
{/* 메인 컨텐츠 영역 */} {/* 메인 컨텐츠 영역 */}

View File

@ -1,20 +1,20 @@
import React from "react"; import React from "react";
import { GeneralSidebar } from "@/components/general/GeneralSidebar"; import { SideNav } from "@/components/layout/SideNav";
import { GeneralHeader } from "@/components/general/GeneralHeader"; import { TopNav } from "@/components/layout/TopNav";
const GeneralLayout = ({ children }: { children: React.ReactNode }) => { const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
return ( return (
<div className="h-screen flex"> <div className="h-screen flex">
{/* 왼쪽 사이드바 */} {/* 왼쪽 사이드바 */}
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800"> <aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<GeneralSidebar /> <SideNav />
</aside> </aside>
{/* 오른쪽 메인 영역 */} {/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
{/* 상단 헤더 */} {/* 상단 헤더 */}
<header className="h-16 bg-white border-b"> <header className="h-16 bg-white border-b">
<GeneralHeader /> <TopNav />
</header> </header>
{/* 메인 컨텐츠 영역 */} {/* 메인 컨텐츠 영역 */}

View File

@ -1,20 +1,20 @@
import React from "react"; import React from "react";
import { GeneralSidebar } from "@/components/general/GeneralSidebar"; import { SideNav } from "@/components/layout/SideNav";
import { GeneralHeader } from "@/components/general/GeneralHeader"; import { TopNav } from "@/components/layout/TopNav";
const GeneralLayout = ({ children }: { children: React.ReactNode }) => { const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
return ( return (
<div className="h-screen flex"> <div className="h-screen flex">
{/* 왼쪽 사이드바 */} {/* 왼쪽 사이드바 */}
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800"> <aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<GeneralSidebar /> <SideNav />
</aside> </aside>
{/* 오른쪽 메인 영역 */} {/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
{/* 상단 헤더 */} {/* 상단 헤더 */}
<header className="h-16 bg-white border-b"> <header className="h-16 bg-white border-b">
<GeneralHeader /> <TopNav />
</header> </header>
{/* 메인 컨텐츠 영역 */} {/* 메인 컨텐츠 영역 */}
@ -24,4 +24,4 @@ const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
); );
}; };
export default GeneralLayout; export default GeneralLayout;

View File

@ -1,21 +1,21 @@
// src/(equipment)/layout.tsx // src/(equipment)/layout.tsx
import React from "react"; import React from "react";
import { GeneralSidebar } from "@/components/general/GeneralSidebar"; import { SideNav } from "@/components/layout/SideNav";
import { GeneralHeader } from "@/components/general/GeneralHeader"; import { TopNav } from "@/components/layout/TopNav";
const GeneralLayout = ({ children }: { children: React.ReactNode }) => { const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
return ( return (
<div className="h-screen flex"> <div className="h-screen flex">
{/* 왼쪽 사이드바 */} {/* 왼쪽 사이드바 */}
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800"> <aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<GeneralSidebar /> <SideNav />
</aside> </aside>
{/* 오른쪽 메인 영역 */} {/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
{/* 상단 헤더 */} {/* 상단 헤더 */}
<header className="h-16 bg-white border-b"> <header className="h-16 bg-white border-b">
<GeneralHeader /> <TopNav />
</header> </header>
{/* 메인 컨텐츠 영역 */} {/* 메인 컨텐츠 영역 */}

View File

@ -1,21 +1,21 @@
// src/(general)/layout.tsx // src/(general)/layout.tsx
import React from "react"; import React from "react";
import { GeneralSidebar } from "@/components/general/GeneralSidebar"; import { SideNav } from "@/components/layout/SideNav";
import { GeneralHeader } from "@/components/general/GeneralHeader"; import { TopNav } from "@/components/layout/TopNav";
const GeneralLayout = ({ children }: { children: React.ReactNode }) => { const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
return ( return (
<div className="h-screen flex"> <div className="h-screen flex">
{/* 왼쪽 사이드바 */} {/* 왼쪽 사이드바 */}
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800"> <aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<GeneralSidebar /> <SideNav />
</aside> </aside>
{/* 오른쪽 메인 영역 */} {/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
{/* 상단 헤더 */} {/* 상단 헤더 */}
<header className="h-16 bg-white border-b"> <header className="h-16 bg-white border-b">
<GeneralHeader /> <TopNav />
</header> </header>
{/* 메인 컨텐츠 영역 */} {/* 메인 컨텐츠 영역 */}

View File

@ -1,20 +1,21 @@
// src/app/(monitoring)/layout.tsx // src/app/(monitoring)/layout.tsx
import { MonitoringSidebar } from "@/components/monitoring/MonitoringSidebar"; import React from "react";
import { MonitoringHeader } from "@/components/monitoring/MonitoringHeader"; 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 ( return (
<div className="h-screen flex"> <div className="h-screen flex">
{/* 왼쪽 사이드바 */} {/* 왼쪽 사이드바 */}
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800"> <aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<MonitoringSidebar /> <SideNav />
</aside> </aside>
{/* 오른쪽 메인 영역 */} {/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
{/* 상단 헤더 */} {/* 상단 헤더 */}
<header className="h-16 bg-white border-b"> <header className="h-16 bg-white border-b">
<MonitoringHeader /> <TopNav />
</header> </header>
{/* 메인 컨텐츠 영역 */} {/* 메인 컨텐츠 영역 */}
@ -24,4 +25,4 @@ const MonitoringLayout = ({ children }: { children: React.ReactNode }) => {
); );
}; };
export default MonitoringLayout; export default GeneralLayout;

View File

@ -1,21 +1,21 @@
// src/(general)/layout.tsx // src/(general)/layout.tsx
import React from "react"; import React from "react";
import { GeneralSidebar } from "@/components/general/GeneralSidebar"; import { SideNav } from "@/components/layout/SideNav";
import { GeneralHeader } from "@/components/general/GeneralHeader"; import { TopNav } from "@/components/layout/TopNav";
const GeneralLayout = ({ children }: { children: React.ReactNode }) => { const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
return ( return (
<div className="h-screen flex"> <div className="h-screen flex">
{/* 왼쪽 사이드바 */} {/* 왼쪽 사이드바 */}
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800"> <aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<GeneralSidebar /> <SideNav />
</aside> </aside>
{/* 오른쪽 메인 영역 */} {/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
{/* 상단 헤더 */} {/* 상단 헤더 */}
<header className="h-16 bg-white border-b"> <header className="h-16 bg-white border-b">
<GeneralHeader /> <TopNav />
</header> </header>
{/* 메인 컨텐츠 영역 */} {/* 메인 컨텐츠 영역 */}

View File

@ -1,21 +1,21 @@
// src/(general)/layout.tsx // src/(general)/layout.tsx
import React from "react"; import React from "react";
import { GeneralSidebar } from "@/components/general/GeneralSidebar"; import { SideNav } from "@/components/layout/SideNav";
import { GeneralHeader } from "@/components/general/GeneralHeader"; import { TopNav } from "@/components/layout/TopNav";
const GeneralLayout = ({ children }: { children: React.ReactNode }) => { const GeneralLayout = ({ children }: { children: React.ReactNode }) => {
return ( return (
<div className="h-screen flex"> <div className="h-screen flex">
{/* 왼쪽 사이드바 */} {/* 왼쪽 사이드바 */}
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800"> <aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<GeneralSidebar /> <SideNav />
</aside> </aside>
{/* 오른쪽 메인 영역 */} {/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
{/* 상단 헤더 */} {/* 상단 헤더 */}
<header className="h-16 bg-white border-b"> <header className="h-16 bg-white border-b">
<GeneralHeader /> <TopNav />
</header> </header>
{/* 메인 컨텐츠 영역 */} {/* 메인 컨텐츠 영역 */}

View File

@ -1,21 +1,43 @@
// src/components/auth/AdminGuard.tsx // src/components/auth/AdminGuard.tsx
"use client"; "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({ export default function AdminGuard({
children, children,
}: { requiredPermissions = [],
children: React.ReactNode; requireAll = false,
}) { }: AdminGuardProps) {
const { user } = useAuth(); const { hasAllPermissions, hasAnyPermission } = usePermissions();
const isAdmin =
user?.role === "super_admin" || user?.role === "company_admin";
if (!isAdmin) { const hasAccess =
return <div> .</div>; requiredPermissions.length === 0
? true
: requireAll
? hasAllPermissions(requiredPermissions)
: hasAnyPermission(requiredPermissions);
if (!hasAccess) {
return (
<div className="flex items-center justify-center min-h-[200px] text-gray-500">
.
</div>
);
} }
return <>{children}</>; return <>{children}</>;
} }
// 사용 예시:
/*
<AdminGuard requiredPermissions={['users:manage']}>
<UserManagementPanel />
</AdminGuard>
*/

View File

@ -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 (
<header className="h-16 border-b bg-white fixed top-0 right-0 left-64 z-50">
<div className="h-full px-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{/* 검색바 */}
<div className="relative">
<input
type="text"
placeholder="검색..."
className="h-9 w-64 px-4 pl-10 rounded-md bg-gray-50 border border-gray-200
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<Search className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
</div>
</div>
<div className="flex items-center gap-3">
{/* 알림 버튼 */}
<Button
variant="ghost"
size="icon"
className="relative text-gray-600 hover:text-gray-900"
>
<Bell className="h-5 w-5" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
</Button>
{/* 도움말 버튼 */}
<Button
variant="ghost"
size="icon"
className="text-gray-600 hover:text-gray-900"
>
<HelpCircle className="h-5 w-5" />
</Button>
{/* 사용자 프로필 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex items-center gap-2 px-3 hover:bg-gray-100"
>
<div className="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center font-medium">
{user?.name?.[0] || "U"}
</div>
<div className="flex items-center gap-2">
<div className="flex flex-col items-start">
<span className="text-sm font-medium text-gray-700">
{user?.name || "사용자"}
</span>
<span className="text-xs text-gray-500">
{user?.role || "관리자"}
</span>
</div>
<ChevronDown className="h-4 w-4 text-gray-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<User className="mr-2 h-4 w-4" />
<span> </span>
</DropdownMenuItem>
<DropdownMenuItem>
<Settings className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={logout}
className="text-red-600 focus:text-red-600"
>
<LogOut className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
);
}
export default GeneralHeader;

View File

@ -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<MenuItemProps> = ({
item,
isOpen,
onToggle,
pathname,
}) => {
const firstIcon = item.items[0]?.icon;
const IconComponent = firstIcon || Box;
return (
<div className="mb-1">
<button
onClick={onToggle}
className={cn(
"w-full flex items-center px-3 py-2 text-sm font-medium rounded-md",
"transition-colors duration-150",
"hover:bg-gray-100",
isOpen ? "text-blue-600 bg-blue-50" : "text-gray-700"
)}
>
<IconComponent className="h-5 w-5 mr-2" />
<span className="flex-1 text-left">{item.title}</span>
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{isOpen && (
<div className="ml-9 mt-1 space-y-1">
{item.items.map((subItem) => (
<Link
key={subItem.href}
href={subItem.href}
className={cn(
"flex items-center px-3 py-2 text-sm rounded-md",
"transition-colors duration-150",
pathname === subItem.href
? "bg-blue-50 text-blue-600 font-medium"
: "text-gray-600 hover:bg-gray-50"
)}
>
{subItem.icon && (
<subItem.icon className="h-4 w-4 mr-2 flex-shrink-0" />
)}
{subItem.title}
</Link>
))}
</div>
)}
</div>
);
};
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 (
<nav className="w-64 bg-white border-r border-gray-200 h-screen overflow-y-auto">
<div className="sticky top-0 z-10 bg-white p-4 border-b border-gray-200">
<div className="flex items-center space-x-2">
<Gauge className="h-8 w-6 text-blue-600" />
<h1 className="text-xl font-semibold text-gray-900">FEMS</h1>
</div>
</div>
<div className="p-3">
{menuItems.map((item) => (
<MenuItem
key={item.title}
item={item}
isOpen={openSections[item.title]}
onToggle={() => toggleSection(item.title)}
pathname={pathname}
/>
))}
</div>
</nav>
);
}
export default GeneralSidebar;

View File

@ -4,78 +4,307 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils"; 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 = [ // 관리자 메뉴에 필요한 권한 정의
{ const ADMIN_PERMISSIONS = {
title: "대시보드", 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: [ items: [
{ title: "전체 현황", href: "/dashboard/overview", icon: MenuIcon }, {
{ title: "KPI 지표", href: "/dashboard/kpi" }, title: "회사 설정",
{ title: "비용 현황", href: "/dashboard/costs" }, 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: [ const filteredAdminItems = {
{ title: "전력", href: "/monitoring/electricity" }, ...adminMenuItem,
{ title: "가스", href: "/monitoring/gas" }, items: adminMenuItem.items.filter((item) => hasPermission(item.permission)),
{ title: "용수", href: "/monitoring/water" }, };
{ title: "스팀", href: "/monitoring/steam" },
], // 관리자 권한이 있고, 접근 가능한 메뉴가 하나라도 있는 경우에만 관리자 메뉴 추가
}, return isAdmin ||
{ (hasAnyAdminPermission && filteredAdminItems.items.length > 0)
title: "설비 관리", ? [...baseMenuItems, filteredAdminItems]
items: [ : baseMenuItems;
{ title: "설비 목록", href: "/equipment/inventory" }, };
{ title: "상태 모니터링", href: "/equipment/monitoring" },
{ title: "정비 관리", href: "/equipment/maintenance" }, interface MenuItemProps {
], item: {
}, title: string;
{ items: {
title: "시스템 관리", title: string;
items: [ href: string;
{ title: "사용자 관리", href: "/system/users" }, icon: React.ComponentType<{ className?: string }>;
{ title: "알림 관리", href: "/system/alerts" }, permission?: string;
{ title: "설정", href: "/system/settings" }, }[];
], };
}, isOpen: boolean;
]; onToggle: () => void;
pathname: string;
}
const MenuItem: React.FC<MenuItemProps> = ({
item,
isOpen,
onToggle,
pathname,
}) => {
const firstIcon = item.items[0]?.icon;
const IconComponent = firstIcon || Box;
return (
<div className="mb-1">
<button
onClick={onToggle}
className={cn(
"w-full flex items-center px-3 py-2 text-sm font-medium rounded-md",
"transition-colors duration-150",
"hover:bg-gray-100",
isOpen ? "text-blue-600 bg-blue-50" : "text-gray-700"
)}
>
<IconComponent className="h-5 w-5 mr-2" />
<span className="flex-1 text-left">{item.title}</span>
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{isOpen && (
<div className="ml-9 mt-1 space-y-1">
{item.items.map((subItem) => (
<Link
key={subItem.href}
href={subItem.href}
className={cn(
"flex items-center px-3 py-2 text-sm rounded-md",
"transition-colors duration-150",
pathname === subItem.href
? "bg-blue-50 text-blue-600 font-medium"
: "text-gray-600 hover:bg-gray-50"
)}
>
{subItem.icon && (
<subItem.icon className="h-4 w-4 mr-2 flex-shrink-0" />
)}
{subItem.title}
</Link>
))}
</div>
)}
</div>
);
};
export function SideNav() { export function SideNav() {
const pathname = usePathname(); 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 ( return (
<nav className="w-64 bg-slate-900 text-white min-h-screen"> <nav className="w-64 bg-white border-r border-gray-200 h-screen overflow-y-auto">
<div className="p-4"> <div className="sticky top-0 z-10 bg-white p-4 border-b border-gray-200">
<h1 className="text-xl font-bold">FEMS</h1> <div className="flex items-center space-x-2">
<Gauge className="h-8 w-6 text-blue-600" />
<h1 className="text-xl font-semibold text-gray-900">FEMS</h1>
</div>
</div> </div>
<div className="space-y-4">
{menuItems.map((group) => ( <div className="p-3">
<div key={group.title}> {menuItems.map((item) => (
<h2 className="px-4 text-xs font-semibold text-slate-400 uppercase tracking-wider"> <MenuItem
{group.title} key={item.title}
</h2> item={item}
<div className="mt-2 space-y-1"> isOpen={openSections[item.title]}
{group.items.map((item) => ( onToggle={() => toggleSection(item.title)}
<Link pathname={pathname}
key={item.href} />
href={item.href}
className={cn(
"flex items-center px-4 py-2 text-sm font-medium",
pathname === item.href
? "bg-slate-800 text-white"
: "text-slate-400 hover:text-white hover:bg-slate-800"
)}
>
{item.icon && <item.icon className="mr-3 h-4 w-4" />}
{item.title}
</Link>
))}
</div>
</div>
))} ))}
</div> </div>
</nav> </nav>
); );
} }
export default SideNav;

View File

@ -6,24 +6,102 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import {
Bell,
Search,
Settings,
HelpCircle,
LogOut,
User,
ChevronDown,
} from "lucide-react";
export function TopNav() { export function TopNav() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
return ( return (
<header className="h-16 border-b bg-white"> <header className="h-16 border-b bg-white fixed top-0 right-0 left-64 z-50">
<div className="h-full px-4 flex items-center justify-between"> <div className="h-full px-4 flex items-center justify-between">
<div>{/* 추가 기능 버튼들 */}</div> <div className="flex items-center gap-3">
<div className="flex items-center gap-4"> {/* 검색바 */}
<div className="relative">
<input
type="text"
placeholder="검색..."
className="h-9 w-64 px-4 pl-10 rounded-md bg-gray-50 border border-gray-200
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<Search className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
</div>
</div>
<div className="flex items-center gap-3">
{/* 알림 버튼 */}
<Button
variant="ghost"
size="icon"
className="relative text-gray-600 hover:text-gray-900"
>
<Bell className="h-5 w-5" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
</Button>
{/* 도움말 버튼 */}
<Button
variant="ghost"
size="icon"
className="text-gray-600 hover:text-gray-900"
>
<HelpCircle className="h-5 w-5" />
</Button>
{/* 사용자 프로필 드롭다운 */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost">{user?.name}</Button> <Button
variant="ghost"
className="flex items-center gap-2 px-3 hover:bg-gray-100"
>
<div className="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center font-medium">
{user?.name?.[0] || "U"}
</div>
<div className="flex items-center gap-2">
<div className="flex flex-col items-start">
<span className="text-sm font-medium text-gray-700">
{user?.name || "사용자"}
</span>
<span className="text-xs text-gray-500">
{user?.role || "관리자"}
</span>
</div>
<ChevronDown className="h-4 w-4 text-gray-500" />
</div>
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={logout}></DropdownMenuItem> <DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<User className="mr-2 h-4 w-4" />
<span> </span>
</DropdownMenuItem>
<DropdownMenuItem>
<Settings className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={logout}
className="text-red-600 focus:text-red-600"
>
<LogOut className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@ -31,3 +109,5 @@ export function TopNav() {
</header> </header>
); );
} }
export default TopNav;

View File

@ -1,113 +0,0 @@
// src/components/monitoring/MonitoringHeader.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 const MonitoringHeader = () => {
const { user, logout } = useAuth();
return (
<header className="h-16 border-b bg-white fixed top-0 right-0 left-64 z-50">
<div className="h-full px-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{/* 검색바 */}
<div className="relative">
<input
type="text"
placeholder="검색..."
className="h-9 w-64 px-4 pl-10 rounded-md bg-gray-50 border border-gray-200
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<Search className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
</div>
</div>
<div className="flex items-center gap-3">
{/* 알림 버튼 */}
<Button
variant="ghost"
size="icon"
className="relative text-gray-600 hover:text-gray-900"
>
<Bell className="h-5 w-5" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
</Button>
{/* 도움말 버튼 */}
<Button
variant="ghost"
size="icon"
className="text-gray-600 hover:text-gray-900"
>
<HelpCircle className="h-5 w-5" />
</Button>
{/* 사용자 프로필 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex items-center gap-2 px-3 hover:bg-gray-100"
>
<div className="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center font-medium">
{user?.name?.[0] || "U"}
</div>
<div className="flex items-center gap-2">
<div className="flex flex-col items-start">
<span className="text-sm font-medium text-gray-700">
{user?.name || "사용자"}
</span>
<span className="text-xs text-gray-500">
{user?.role || "관리자"}
</span>
</div>
<ChevronDown className="h-4 w-4 text-gray-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<User className="mr-2 h-4 w-4" />
<span> </span>
</DropdownMenuItem>
<DropdownMenuItem>
<Settings className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={logout}
className="text-red-600 focus:text-red-600"
>
<LogOut className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
);
};
export default MonitoringHeader;

View File

@ -1,235 +0,0 @@
// src/components/monitoring/MonitoringSidebar.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";
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<MenuItemProps> = ({
item,
isOpen,
onToggle,
pathname,
}) => {
const firstIcon = item.items[0]?.icon;
const IconComponent = firstIcon || Box;
return (
<div className="mb-1">
<button
onClick={onToggle}
className={cn(
"w-full flex items-center px-3 py-2 text-sm font-medium rounded-md",
"transition-colors duration-150",
"hover:bg-gray-100",
isOpen ? "text-blue-600 bg-blue-50" : "text-gray-700"
)}
>
<IconComponent className="h-5 w-5 mr-2" />
<span className="flex-1 text-left">{item.title}</span>
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{isOpen && (
<div className="ml-9 mt-1 space-y-1">
{item.items.map((subItem) => (
<Link
key={subItem.href}
href={subItem.href}
className={cn(
"flex items-center px-3 py-2 text-sm rounded-md",
"transition-colors duration-150",
pathname === subItem.href
? "bg-blue-50 text-blue-600 font-medium"
: "text-gray-600 hover:bg-gray-50"
)}
>
{subItem.icon && (
<subItem.icon className="h-4 w-4 mr-2 flex-shrink-0" />
)}
{subItem.title}
</Link>
))}
</div>
)}
</div>
);
};
export function MonitoringSidebar() {
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 (
<nav className="w-64 bg-white border-r border-gray-200 h-screen overflow-y-auto">
<div className="sticky top-0 z-10 bg-white p-4 border-b border-gray-200">
<div className="flex items-center space-x-2">
<Gauge className="h-8 w-6 text-blue-600" />
<h1 className="text-xl font-semibold text-gray-900">FEMS</h1>
</div>
</div>
<div className="p-3">
{menuItems.map((item) => (
<MenuItem
key={item.title}
item={item}
isOpen={openSections[item.title]}
onToggle={() => toggleSection(item.title)}
pathname={pathname}
/>
))}
</div>
</nav>
);
}
export default MonitoringSidebar;

View File

@ -0,0 +1,39 @@
// src/hooks/usePermissions.ts
import { useAuth } from "./useAuth";
export function usePermissions() {
const { user } = useAuth();
const permissions = user?.permissions || {};
const hasPermission = (permission: string): boolean => {
return !!permissions[permission];
};
const hasAnyPermission = (requiredPermissions: string[]): boolean => {
return requiredPermissions.some((permission) => hasPermission(permission));
};
const hasAllPermissions = (requiredPermissions: string[]): boolean => {
return requiredPermissions.every((permission) => hasPermission(permission));
};
return {
permissions,
hasPermission,
hasAnyPermission,
hasAllPermissions,
};
}
// 사용 예시:
/*
function MyComponent() {
const { hasPermission } = usePermissions();
if (!hasPermission('departments:view')) {
return <div> .</div>;
}
return <div> ...</div>;
}
*/

View File

@ -1,24 +1,65 @@
// src/lib/jwt.ts // src/lib/jwt.ts
import * as jose from "jose"; import * as jose from "jose";
import { UserRole } from "@/types/auth"; import { UserRole, Permissions } from "@/types/auth";
interface JwtPayload { interface JwtPayload {
id: string; id: string;
role: UserRole; role: UserRole;
companyId: string; companyId: string;
branchId: string; branchId?: string;
permissions: Permissions; // 권한 정보 추가
} }
export function decodeToken(token: string): JwtPayload | null { export function decodeToken(token: string): JwtPayload | null {
try { try {
// jose를 사용한 디코딩
const decoded = jose.decodeJwt(token); const decoded = jose.decodeJwt(token);
const payload = decoded as unknown as JwtPayload; const payload = decoded as unknown as JwtPayload;
if (payload.id && payload.role && payload.companyId && payload.branchId) {
return payload; // 필수 필드 검증
if (
!payload.id ||
!payload.role ||
!payload.companyId ||
!payload.permissions
) {
console.warn("Invalid token payload:", payload);
return null;
} }
return null;
} catch { return {
id: payload.id,
role: payload.role,
companyId: payload.companyId,
branchId: payload.branchId,
permissions: payload.permissions,
};
} catch (error) {
console.error("Token decode error:", error);
return null; return null;
} }
} }
// 권한 체크 유틸리티 함수들 추가
export function hasPermission(
payload: JwtPayload | null,
permission: string
): boolean {
if (!payload?.permissions) return false;
return !!payload.permissions[permission];
}
export function hasAnyPermission(
payload: JwtPayload | null,
permissions: string[]
): boolean {
if (!payload?.permissions) return false;
return permissions.some((permission) => hasPermission(payload, permission));
}
export function hasAllPermissions(
payload: JwtPayload | null,
permissions: string[]
): boolean {
if (!payload?.permissions) return false;
return permissions.every((permission) => hasPermission(payload, permission));
}

View File

@ -1,36 +1,75 @@
// src/middleware.ts // src/middleware.ts
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { decodeToken } from "@/lib/jwt"; import { decodeToken, hasPermission, hasAnyPermission } from "@/lib/jwt";
import type { UserRole } from "@/types/auth";
function getUserRole(token: string | undefined): UserRole | null {
if (!token) return null;
const decodedToken = decodeToken(token);
return decodedToken?.role || null;
}
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const token = request.cookies.get("token")?.value; const token = request.cookies.get("token")?.value;
// Public routes
if (
request.nextUrl.pathname === "/" ||
request.nextUrl.pathname === "/login"
) {
return NextResponse.next();
}
// 비인증 사용자는 로그인 페이지로 // 비인증 사용자는 로그인 페이지로
if (!token && !request.nextUrl.pathname.startsWith("/")) { if (!token) {
return NextResponse.redirect(new URL("/", request.url)); return NextResponse.redirect(new URL("/", request.url));
} }
// 권한별 접근 제어 // 토큰 디코딩
const tokenData = decodeToken(token);
if (!tokenData) {
return NextResponse.redirect(new URL("/", request.url));
}
// 권한 기반 라우팅 보호
if (request.nextUrl.pathname.startsWith("/admin")) { if (request.nextUrl.pathname.startsWith("/admin")) {
const role = getUserRole(token); // admin 페이지 접근을 위해서는 admin:access 권한이나 특정 role이 필요
if ( const hasAdminAccess =
!role || hasPermission(tokenData, "admin:access") ||
!["super_admin", "company_admin", "branch_admin", "user"].includes(role) ["super_admin", "company_admin"].includes(tokenData.role);
) {
if (!hasAdminAccess) {
return NextResponse.redirect(new URL("/dashboard/overview", request.url)); return NextResponse.redirect(new URL("/dashboard/overview", request.url));
} }
} }
return NextResponse.next(); // 사용자 관리 페이지
if (request.nextUrl.pathname.startsWith("/users")) {
const hasUserManageAccess = hasAnyPermission(tokenData, [
"users:manage",
"users:view",
]);
if (!hasUserManageAccess) {
return NextResponse.redirect(new URL("/dashboard/overview", request.url));
}
}
// 부서 관리 페이지
if (request.nextUrl.pathname.startsWith("/departments")) {
const hasDepartmentAccess = hasAnyPermission(tokenData, [
"departments:manage",
"departments:view",
]);
if (!hasDepartmentAccess) {
return NextResponse.redirect(new URL("/dashboard/overview", request.url));
}
}
// 응답 헤더에 사용자 권한 정보 추가 (옵션)
const response = NextResponse.next();
response.headers.set(
"X-User-Permissions",
JSON.stringify(tokenData.permissions)
);
response.headers.set("X-User-Role", tokenData.role);
return response;
} }
export const config = { export const config = {

View File

@ -5,6 +5,10 @@ export type UserRole =
| "branch_admin" | "branch_admin"
| "user"; | "user";
export interface Permissions {
[key: string]: boolean;
}
export interface User { export interface User {
id: string; id: string;
username: string; username: string;
@ -13,4 +17,22 @@ export interface User {
role: UserRole; role: UserRole;
companyId: string; companyId: string;
branchId?: string; branchId?: string;
permissions: Permissions; // 권한 정보 추가
} }
// 권한 타입 상수 정의
export const PERMISSIONS = {
DEPARTMENTS: {
VIEW: "departments:view",
CREATE: "departments:create",
UPDATE: "departments:update",
DELETE: "departments:delete",
},
USERS: {
VIEW: "users:view",
CREATE: "users:create",
UPDATE: "users:update",
DELETE: "users:delete",
},
// 다른 권한들도 추가...
} as const;