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:
유병철
2026-03-18 14:54:42 +09:00
parent 68b1112034
commit 0a65609e5a
6 changed files with 264 additions and 1 deletions

View File

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

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

View 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
View 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[];
}