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
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;

View File

@ -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 }

View File

@ -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({
<AdminGuard>
<div className="h-screen flex">
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<AdminSidebar />
<SideNav />
</aside>
{/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col">
{/* 상단 헤더 */}
<header className="h-16 bg-white border-b">
<GeneralHeader />
<TopNav />
</header>
{/* 메인 컨텐츠 영역 */}

View File

@ -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 (
<div className="h-screen flex">
{/* 왼쪽 사이드바 */}
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<GeneralSidebar />
<SideNav />
</aside>
{/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col">
{/* 상단 헤더 */}
<header className="h-16 bg-white border-b">
<GeneralHeader />
<TopNav />
</header>
{/* 메인 컨텐츠 영역 */}

View File

@ -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 (
<div className="h-screen flex">
{/* 왼쪽 사이드바 */}
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<GeneralSidebar />
<SideNav />
</aside>
{/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col">
{/* 상단 헤더 */}
<header className="h-16 bg-white border-b">
<GeneralHeader />
<TopNav />
</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
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 (
<div className="h-screen flex">
{/* 왼쪽 사이드바 */}
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<GeneralSidebar />
<SideNav />
</aside>
{/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col">
{/* 상단 헤더 */}
<header className="h-16 bg-white border-b">
<GeneralHeader />
<TopNav />
</header>
{/* 메인 컨텐츠 영역 */}

View File

@ -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 (
<div className="h-screen flex">
{/* 왼쪽 사이드바 */}
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<GeneralSidebar />
<SideNav />
</aside>
{/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col">
{/* 상단 헤더 */}
<header className="h-16 bg-white border-b">
<GeneralHeader />
<TopNav />
</header>
{/* 메인 컨텐츠 영역 */}

View File

@ -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 (
<div className="h-screen flex">
{/* 왼쪽 사이드바 */}
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<MonitoringSidebar />
<SideNav />
</aside>
{/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col">
{/* 상단 헤더 */}
<header className="h-16 bg-white border-b">
<MonitoringHeader />
<TopNav />
</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
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 (
<div className="h-screen flex">
{/* 왼쪽 사이드바 */}
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<GeneralSidebar />
<SideNav />
</aside>
{/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col">
{/* 상단 헤더 */}
<header className="h-16 bg-white border-b">
<GeneralHeader />
<TopNav />
</header>
{/* 메인 컨텐츠 영역 */}

View File

@ -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 (
<div className="h-screen flex">
{/* 왼쪽 사이드바 */}
<aside className="w-64 h-screen flex-shrink-0 bg-gray-800">
<GeneralSidebar />
<SideNav />
</aside>
{/* 오른쪽 메인 영역 */}
<div className="flex-1 flex flex-col">
{/* 상단 헤더 */}
<header className="h-16 bg-white border-b">
<GeneralHeader />
<TopNav />
</header>
{/* 메인 컨텐츠 영역 */}

View File

@ -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 <div> .</div>;
const hasAccess =
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}</>;
}
// 사용 예시:
/*
<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 { 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<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() {
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 (
<nav className="w-64 bg-slate-900 text-white min-h-screen">
<div className="p-4">
<h1 className="text-xl font-bold">FEMS</h1>
<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="space-y-4">
{menuItems.map((group) => (
<div key={group.title}>
<h2 className="px-4 text-xs font-semibold text-slate-400 uppercase tracking-wider">
{group.title}
</h2>
<div className="mt-2 space-y-1">
{group.items.map((item) => (
<Link
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 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 SideNav;

View File

@ -6,24 +6,102 @@ 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 TopNav() {
const { user, logout } = useAuth();
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>{/* 추가 기능 버튼들 */}</div>
<div className="flex items-center gap-4">
<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">{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>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={logout}></DropdownMenuItem>
<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>
@ -31,3 +109,5 @@ export function TopNav() {
</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
import * as jose from "jose";
import { UserRole } from "@/types/auth";
import { UserRole, Permissions } from "@/types/auth";
interface JwtPayload {
id: string;
role: UserRole;
companyId: string;
branchId: string;
branchId?: string;
permissions: Permissions; // 권한 정보 추가
}
export function decodeToken(token: string): JwtPayload | null {
try {
// jose를 사용한 디코딩
const decoded = jose.decodeJwt(token);
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;
}
}
// 권한 체크 유틸리티 함수들 추가
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
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { decodeToken } 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;
}
import { decodeToken, hasPermission, hasAnyPermission } from "@/lib/jwt";
export function middleware(request: NextRequest) {
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));
}
// 권한별 접근 제어
// 토큰 디코딩
const tokenData = decodeToken(token);
if (!tokenData) {
return NextResponse.redirect(new URL("/", request.url));
}
// 권한 기반 라우팅 보호
if (request.nextUrl.pathname.startsWith("/admin")) {
const role = getUserRole(token);
if (
!role ||
!["super_admin", "company_admin", "branch_admin", "user"].includes(role)
) {
// admin 페이지 접근을 위해서는 admin:access 권한이나 특정 role이 필요
const hasAdminAccess =
hasPermission(tokenData, "admin:access") ||
["super_admin", "company_admin"].includes(tokenData.role);
if (!hasAdminAccess) {
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 = {

View File

@ -5,6 +5,10 @@ export type UserRole =
| "branch_admin"
| "user";
export interface Permissions {
[key: string]: boolean;
}
export interface User {
id: string;
username: string;
@ -13,4 +17,22 @@ export interface User {
role: UserRole;
companyId: 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;