자재관리: - 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장 - 재고현황 컴포넌트 리팩토링 출고관리: - 출하관리 생성/수정/목록/상세 개선 - 차량배차관리 상세/수정/목록 기능 보강 생산관리: - 작업지시서 WIP 생산 모달 신규 추가 - 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가 - 작업자화면 기능 대폭 확장 (카드/목록 개선) - 검사성적서 모달 개선 품질관리: - 실적보고서 관리 페이지 신규 추가 - 검사관리 문서/타입/목데이터 개선 단가관리: - 단가배포 페이지 및 컴포넌트 신규 추가 - 단가표 관리 페이지 및 컴포넌트 신규 추가 공통: - 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard) - 메뉴 폴링 훅 개선, 레이아웃 수정 - 모바일 줌/패닝 CSS 수정 - locale 유틸 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
162 lines
5.2 KiB
TypeScript
162 lines
5.2 KiB
TypeScript
'use client';
|
|
|
|
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
|
import { usePathname } from 'next/navigation';
|
|
import { getRolePermissionMatrix, getPermissionMenuUrlMap } from '@/lib/permissions/actions';
|
|
import { buildMenuIdToUrlMap, convertMatrixToPermissionMap, findMatchingUrl, mergePermissionMaps } from '@/lib/permissions/utils';
|
|
import { ALL_DENIED_PERMS } from '@/lib/permissions/types';
|
|
import type { PermissionMap, PermissionAction } from '@/lib/permissions/types';
|
|
import { AccessDenied } from '@/components/common/AccessDenied';
|
|
import { stripLocalePrefix } from '@/lib/utils/locale';
|
|
|
|
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 {
|
|
// 사이드바 메뉴에 없는 권한 메뉴의 URL 매핑 보완
|
|
// (기준정보 관리, 공정관리 등 사이드바 미등록 메뉴 대응)
|
|
const [permMenuUrlMap, ...results] = await Promise.all([
|
|
getPermissionMenuUrlMap(),
|
|
...roleIds.map(id => getRolePermissionMatrix(id)),
|
|
]);
|
|
|
|
// 권한 메뉴 URL을 베이스로, 사이드바 메뉴 URL로 덮어쓰기 (사이드바 우선)
|
|
const mergedMenuIdToUrl = { ...permMenuUrlMap, ...menuIdToUrl };
|
|
|
|
const maps = results
|
|
.filter(r => r.success && r.data?.permissions)
|
|
.map(r => convertMatrixToPermissionMap(r.data.permissions, mergedMenuIdToUrl));
|
|
|
|
if (maps.length > 0) {
|
|
const merged = mergePermissionMaps(maps);
|
|
|
|
// 권한 메뉴에 등록되어 있지만 매트릭스 응답에 없는 메뉴 처리
|
|
// (모든 권한 OFF → API가 해당 menuId를 생략 → "all denied"로 보완)
|
|
for (const [, url] of Object.entries(permMenuUrlMap)) {
|
|
if (url && !merged[url]) {
|
|
merged[url] = { ...ALL_DENIED_PERMS };
|
|
}
|
|
}
|
|
|
|
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 matchedUrl = findMatchingUrl(url, permissionMap);
|
|
if (!matchedUrl) return true;
|
|
const perms = permissionMap[matchedUrl];
|
|
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 = stripLocalePrefix(pathname);
|
|
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);
|