auto commit
This commit is contained in:
parent
a4b29b6a21
commit
9ee1e2cceb
@ -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;
|
||||
|
@ -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 }
|
||||
|
@ -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>
|
||||
|
||||
{/* 메인 컨텐츠 영역 */}
|
||||
|
@ -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>
|
||||
|
||||
{/* 메인 컨텐츠 영역 */}
|
||||
|
@ -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;
|
@ -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>
|
||||
|
||||
{/* 메인 컨텐츠 영역 */}
|
||||
|
@ -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>
|
||||
|
||||
{/* 메인 컨텐츠 영역 */}
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
||||
{/* 메인 컨텐츠 영역 */}
|
||||
|
@ -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>
|
||||
|
||||
{/* 메인 컨텐츠 영역 */}
|
||||
|
@ -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>
|
||||
*/
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
@ -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;
|
39
fems-app/src/hooks/usePermissions.ts
Normal file
39
fems-app/src/hooks/usePermissions.ts
Normal 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>;
|
||||
}
|
||||
*/
|
@ -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));
|
||||
}
|
||||
|
@ -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 = {
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user