feat(WEB): 권한 관리 시스템 구현 및 상세 페이지 권한 통합

- PermissionContext, usePermission 훅, PermissionGuard 컴포넌트 신규 추가
- AccessDenied 접근 거부 페이지 추가
- permissions lib (체커, 매퍼, 타입) 구현
- BadDebtDetail, BoardDetail, LaborDetail, PricingDetail 등 상세 페이지 권한 적용
- ProcessDetail, StepDetail, ItemDetail, PermissionDetail 권한 연동
- RootProvider에 PermissionProvider 통합
- protected layout 권한 체크 추가
- Claude 프로젝트 설정 파일 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-03 10:17:02 +09:00
parent f0987127eb
commit e111f7b362
22 changed files with 1267 additions and 994 deletions

View File

@@ -0,0 +1,143 @@
'use client';
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { usePathname } from 'next/navigation';
import { getRolePermissionMatrix } from '@/lib/permissions/actions';
import { buildMenuIdToUrlMap, convertMatrixToPermissionMap, findMatchingUrl, mergePermissionMaps } from '@/lib/permissions/utils';
import type { PermissionMap, PermissionAction } from '@/lib/permissions/types';
import { AccessDenied } from '@/components/common/AccessDenied';
interface PermissionContextType {
permissionMap: PermissionMap | null;
isLoading: boolean;
/** URL 지정 권한 체크 (특수 케이스용) */
can: (url: string, action: PermissionAction) => boolean;
/** 권한 데이터 다시 로드 (설정 변경 후 호출) */
reloadPermissions: () => void;
}
const PermissionContext = createContext<PermissionContextType>({
permissionMap: null,
isLoading: true,
can: () => true,
reloadPermissions: () => {},
});
export function PermissionProvider({ children }: { children: React.ReactNode }) {
const [permissionMap, setPermissionMap] = useState<PermissionMap | null>(null);
const [isLoading, setIsLoading] = useState(true);
const loadPermissions = useCallback(async () => {
const userData = getUserData();
if (!userData || userData.roleIds.length === 0) {
setIsLoading(false);
return;
}
const { roleIds, menuIdToUrl } = userData;
setIsLoading(true);
try {
const results = await Promise.all(
roleIds.map(id => getRolePermissionMatrix(id))
);
const maps = results
.filter(r => r.success && r.data?.permissions)
.map(r => convertMatrixToPermissionMap(r.data.permissions, menuIdToUrl));
if (maps.length > 0) {
const merged = mergePermissionMaps(maps);
setPermissionMap(merged);
} else {
setPermissionMap(null);
}
} catch (error) {
console.error('[Permission] 권한 로드 실패:', error);
setPermissionMap(null);
}
setIsLoading(false);
}, []);
// 마운트 시 1회 로드
useEffect(() => {
loadPermissions();
}, [loadPermissions]);
const can = useCallback((url: string, action: PermissionAction): boolean => {
if (!permissionMap) return true;
const perms = permissionMap[url];
if (!perms) return true;
return perms[action] ?? true;
}, [permissionMap]);
return (
<PermissionContext.Provider value={{ permissionMap, isLoading, can, reloadPermissions: loadPermissions }}>
{children}
</PermissionContext.Provider>
);
}
/**
* 자기 잠금(self-lockout) 방지: 권한 설정 페이지는 항상 접근 허용
*/
const BYPASS_PATHS = ['/settings/permissions'];
function isGateBypassed(pathname: string): boolean {
const pathWithoutLocale = pathname.replace(/^\/[a-z]{2}(\/|$)/, '/');
return BYPASS_PATHS.some(bp => pathWithoutLocale.startsWith(bp));
}
/**
* PermissionGate: 레이아웃에 배치하여 모든 페이지 자동 보호
*/
export function PermissionGate({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { permissionMap, isLoading } = useContext(PermissionContext);
if (isLoading) return null;
if (!permissionMap) {
return <>{children}</>;
}
if (isGateBypassed(pathname)) return <>{children}</>;
const matchedUrl = findMatchingUrl(pathname, permissionMap);
if (!matchedUrl) {
return <>{children}</>;
}
const perms = permissionMap[matchedUrl];
const canView = perms?.view ?? true;
if (!canView) {
return <AccessDenied />;
}
return <>{children}</>;
}
/** localStorage 'user' 키에서 역할 ID 배열 + menuId→URL 매핑 추출 */
function getUserData(): { roleIds: number[]; menuIdToUrl: Record<string, string> } | null {
if (typeof window === 'undefined') return null;
try {
const raw = localStorage.getItem('user');
if (!raw) return null;
const parsed = JSON.parse(raw);
const roleIds = Array.isArray(parsed.roles)
? parsed.roles.map((r: { id: number }) => r.id).filter(Boolean)
: [];
const menuIdToUrl = Array.isArray(parsed.menu)
? buildMenuIdToUrlMap(parsed.menu)
: {};
return { roleIds, menuIdToUrl };
} catch {
return null;
}
}
export const usePermissionContext = () => useContext(PermissionContext);

View File

@@ -2,6 +2,7 @@
import { ReactNode } from 'react';
import { AuthProvider } from './AuthContext';
import { PermissionProvider } from './PermissionContext';
import { ItemMasterProvider } from './ItemMasterContext';
/**
@@ -9,7 +10,8 @@ import { ItemMasterProvider } from './ItemMasterContext';
*
* 현재 사용 중인 Context:
* 1. AuthContext - 사용자/인증 (2개 상태)
* 2. ItemMasterContext - 품목관리 (13개 상태)
* 2. PermissionContext - 권한 관리 (URL 자동매칭)
* 3. ItemMasterContext - 품목관리 (13개 상태)
*
* 미사용 Context (contexts/_unused/로 이동됨):
* - FacilitiesContext, AccountingContext, HRContext, ShippingContext
@@ -18,9 +20,11 @@ import { ItemMasterProvider } from './ItemMasterContext';
export function RootProvider({ children }: { children: ReactNode }) {
return (
<AuthProvider>
<ItemMasterProvider>
{children}
</ItemMasterProvider>
<PermissionProvider>
<ItemMasterProvider>
{children}
</ItemMasterProvider>
</PermissionProvider>
</AuthProvider>
);
}