feat: [Phase 1] 모듈 레지스트리 + 라우트 가드
- src/modules/ 모듈 시스템 (types, registry, tenant-config) - useModules() 훅: 테넌트 industry 기반 모듈 활성화 판단 - ModuleGuard: 비허용 모듈 라우트 접근 차단 (클라이언트 사이드) - (protected)/layout.tsx에 ModuleGuard 적용 - industry 미설정 테넌트는 가드 비활성 (하위 호환) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { ApiErrorProvider } from '@/contexts/ApiErrorContext';
|
||||
import { FCMProvider } from '@/contexts/FCMProvider';
|
||||
import { DevFillProvider, DevToolbar } from '@/components/dev';
|
||||
import { PermissionGate } from '@/contexts/PermissionContext';
|
||||
import { ModuleGuard } from '@/components/auth/ModuleGuard';
|
||||
|
||||
/**
|
||||
* Protected Layout
|
||||
@@ -44,7 +45,9 @@ export default function ProtectedLayout({
|
||||
<FCMProvider>
|
||||
<DevFillProvider>
|
||||
<AuthenticatedLayout>
|
||||
<PermissionGate>{children}</PermissionGate>
|
||||
<PermissionGate>
|
||||
<ModuleGuard>{children}</ModuleGuard>
|
||||
</PermissionGate>
|
||||
</AuthenticatedLayout>
|
||||
<DevToolbar />
|
||||
</DevFillProvider>
|
||||
|
||||
60
src/components/auth/ModuleGuard.tsx
Normal file
60
src/components/auth/ModuleGuard.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 모듈 라우트 가드
|
||||
*
|
||||
* 현재 테넌트가 보유하지 않은 모듈의 페이지에 접근 시 차단.
|
||||
* (protected)/layout.tsx에서 PermissionGate 내부에 래핑.
|
||||
*
|
||||
* Phase 1: 클라이언트 사이드 가드 (백엔드 변경 불필요)
|
||||
* Phase 2: middleware.ts로 이동 (서버 사이드 가드)
|
||||
*/
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function ModuleGuard({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { isRouteAllowed, tenantIndustry } = useModules();
|
||||
|
||||
// locale 접두사 제거 (예: /ko/production → /production)
|
||||
const cleanPath = pathname.replace(/^\/[a-z]{2}(?=\/)/, '');
|
||||
|
||||
const allowed = isRouteAllowed(cleanPath);
|
||||
|
||||
useEffect(() => {
|
||||
// industry가 아직 설정되지 않은 테넌트는 가드 비활성 (전부 허용)
|
||||
if (!tenantIndustry) return;
|
||||
|
||||
if (!allowed) {
|
||||
toast.error('접근 권한이 없는 모듈입니다.');
|
||||
}
|
||||
}, [allowed, tenantIndustry]);
|
||||
|
||||
// industry 미설정 시 가드 비활성 (하위 호환)
|
||||
if (!tenantIndustry) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
|
||||
<ShieldAlert className="h-16 w-16 text-muted-foreground mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-2">접근 권한 없음</h1>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
현재 계약에 포함되지 않은 모듈입니다.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => router.push('/dashboard')}>
|
||||
대시보드로 돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
54
src/hooks/useModules.ts
Normal file
54
src/hooks/useModules.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 테넌트 모듈 접근 훅
|
||||
*
|
||||
* 현재 로그인한 테넌트의 활성 모듈 정보와 헬퍼 함수를 제공.
|
||||
*
|
||||
* @example
|
||||
* const { isEnabled, isRouteAllowed } = useModules();
|
||||
* if (isEnabled('production')) { ... }
|
||||
* if (isRouteAllowed('/production/work-orders')) { ... }
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import type { ModuleId } from '@/modules/types';
|
||||
import { resolveEnabledModules, isModuleEnabled } from '@/modules/tenant-config';
|
||||
import { getModuleForRoute, getEnabledDashboardSections } from '@/modules';
|
||||
|
||||
export function useModules() {
|
||||
const tenant = useAuthStore((state) => state.currentUser?.tenant);
|
||||
|
||||
const enabledModules = useMemo<ModuleId[]>(() => {
|
||||
if (!tenant) return [];
|
||||
return resolveEnabledModules({
|
||||
industry: tenant.options?.industry,
|
||||
// Phase 2: explicitModules: tenant.options?.modules
|
||||
});
|
||||
}, [tenant]);
|
||||
|
||||
const isEnabled = useMemo(() => {
|
||||
return (moduleId: ModuleId) => isModuleEnabled(moduleId, enabledModules);
|
||||
}, [enabledModules]);
|
||||
|
||||
const isRouteAllowed = useMemo(() => {
|
||||
return (pathname: string) => {
|
||||
const owningModule = getModuleForRoute(pathname);
|
||||
if (owningModule === 'common') return true;
|
||||
return enabledModules.includes(owningModule);
|
||||
};
|
||||
}, [enabledModules]);
|
||||
|
||||
const dashboardSections = useMemo(() => {
|
||||
return getEnabledDashboardSections(enabledModules);
|
||||
}, [enabledModules]);
|
||||
|
||||
return {
|
||||
enabledModules,
|
||||
isEnabled,
|
||||
isRouteAllowed,
|
||||
dashboardSections,
|
||||
tenantIndustry: tenant?.options?.industry,
|
||||
};
|
||||
}
|
||||
73
src/modules/index.ts
Normal file
73
src/modules/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 모듈 레지스트리
|
||||
*
|
||||
* Phase 1: 정적 매니페스트 (여기서 직접 정의)
|
||||
* Phase 2: 백엔드 API에서 로딩으로 교체
|
||||
*/
|
||||
|
||||
import type { ModuleManifest, ModuleId } from './types';
|
||||
|
||||
const MODULE_REGISTRY: Record<ModuleId, ModuleManifest> = {
|
||||
common: {
|
||||
id: 'common',
|
||||
name: '공통 ERP',
|
||||
description: '모든 테넌트가 사용하는 기본 모듈',
|
||||
routePrefixes: [
|
||||
'/dashboard', '/accounting', '/sales', '/hr', '/approval',
|
||||
'/board', '/boards', '/customer-center', '/settings',
|
||||
'/master-data', '/material', '/outbound', '/reports',
|
||||
'/company-info', '/subscription', '/payment-history',
|
||||
],
|
||||
},
|
||||
production: {
|
||||
id: 'production',
|
||||
name: '생산관리',
|
||||
description: '셔터 MES 생산/작업지시 관리',
|
||||
routePrefixes: ['/production'],
|
||||
dashboardSections: ['dailyProduction', 'unshipped'],
|
||||
},
|
||||
quality: {
|
||||
id: 'quality',
|
||||
name: '품질관리',
|
||||
description: '설비/검사/수리 관리',
|
||||
routePrefixes: ['/quality'],
|
||||
},
|
||||
construction: {
|
||||
id: 'construction',
|
||||
name: '건설관리',
|
||||
description: '건설 프로젝트/계약/기성 관리',
|
||||
routePrefixes: ['/construction'],
|
||||
dashboardSections: ['construction'],
|
||||
},
|
||||
'vehicle-management': {
|
||||
id: 'vehicle-management',
|
||||
name: '차량관리',
|
||||
description: '차량/지게차 관리',
|
||||
routePrefixes: ['/vehicle-management', '/vehicle'],
|
||||
},
|
||||
};
|
||||
|
||||
/** 특정 경로가 어떤 모듈에 속하는지 반환 */
|
||||
export function getModuleForRoute(pathname: string): ModuleId {
|
||||
for (const [moduleId, manifest] of Object.entries(MODULE_REGISTRY)) {
|
||||
if (moduleId === 'common') continue;
|
||||
for (const prefix of manifest.routePrefixes) {
|
||||
if (pathname === prefix || pathname.startsWith(prefix + '/')) {
|
||||
return moduleId as ModuleId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'common';
|
||||
}
|
||||
|
||||
/** 활성 모듈들의 대시보드 섹션 키 목록 반환 */
|
||||
export function getEnabledDashboardSections(enabledModules: ModuleId[]): string[] {
|
||||
const sections: string[] = [];
|
||||
for (const moduleId of enabledModules) {
|
||||
const manifest = MODULE_REGISTRY[moduleId];
|
||||
if (manifest?.dashboardSections) {
|
||||
sections.push(...manifest.dashboardSections);
|
||||
}
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
47
src/modules/tenant-config.ts
Normal file
47
src/modules/tenant-config.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 테넌트 → 모듈 매핑 설정
|
||||
*
|
||||
* Phase 1: industry 기반 하드코딩 매핑
|
||||
* Phase 2: 백엔드 tenant.options.modules에서 직접 수신
|
||||
*/
|
||||
|
||||
import type { ModuleId } from './types';
|
||||
|
||||
/** 업종별 기본 모듈 매핑 */
|
||||
const INDUSTRY_MODULE_MAP: Record<string, ModuleId[]> = {
|
||||
shutter_mes: ['production', 'quality', 'vehicle-management'],
|
||||
construction: ['construction', 'vehicle-management'],
|
||||
};
|
||||
|
||||
/**
|
||||
* 테넌트의 활성 모듈 목록을 결정
|
||||
*
|
||||
* 우선순위:
|
||||
* 1. 백엔드에서 명시적 모듈 목록 제공 시 (Phase 2)
|
||||
* 2. industry 기반 기본값 (Phase 1)
|
||||
* 3. 빈 배열 (공통 ERP만)
|
||||
*/
|
||||
export function resolveEnabledModules(options: {
|
||||
industry?: string;
|
||||
explicitModules?: ModuleId[];
|
||||
}): ModuleId[] {
|
||||
const { industry, explicitModules } = options;
|
||||
|
||||
// Phase 2: 백엔드가 명시적 모듈 목록 제공
|
||||
if (explicitModules && explicitModules.length > 0) {
|
||||
return explicitModules;
|
||||
}
|
||||
|
||||
// Phase 1: industry 기반 기본값
|
||||
if (industry) {
|
||||
return INDUSTRY_MODULE_MAP[industry] ?? [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 특정 모듈이 활성화됐는지 확인 */
|
||||
export function isModuleEnabled(moduleId: ModuleId, enabledModules: ModuleId[]): boolean {
|
||||
if (moduleId === 'common') return true;
|
||||
return enabledModules.includes(moduleId);
|
||||
}
|
||||
26
src/modules/types.ts
Normal file
26
src/modules/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 모듈 시스템 타입 정의
|
||||
*
|
||||
* Phase 1: 프론트 하드코딩 industry 매핑
|
||||
* Phase 2: 백엔드 API에서 모듈 목록 수신
|
||||
* Phase 3: JSON 스키마 기반 동적 페이지 조립
|
||||
*/
|
||||
|
||||
/** 모듈 식별자 */
|
||||
export type ModuleId =
|
||||
| 'common'
|
||||
| 'production'
|
||||
| 'quality'
|
||||
| 'construction'
|
||||
| 'vehicle-management';
|
||||
|
||||
/** 모듈 매니페스트 — 각 모듈의 메타데이터 */
|
||||
export interface ModuleManifest {
|
||||
id: ModuleId;
|
||||
name: string;
|
||||
description: string;
|
||||
/** 이 모듈이 소유하는 라우트 접두사 (예: ['/production']) */
|
||||
routePrefixes: string[];
|
||||
/** 이 모듈이 대시보드에 기여하는 섹션 키 */
|
||||
dashboardSections?: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user