diff --git a/src/app/[locale]/(protected)/layout.tsx b/src/app/[locale]/(protected)/layout.tsx index ed11b71a..56beea9b 100644 --- a/src/app/[locale]/(protected)/layout.tsx +++ b/src/app/[locale]/(protected)/layout.tsx @@ -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({ - {children} + + {children} + diff --git a/src/components/auth/ModuleGuard.tsx b/src/components/auth/ModuleGuard.tsx new file mode 100644 index 00000000..079c59a9 --- /dev/null +++ b/src/components/auth/ModuleGuard.tsx @@ -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 ( +
+ +

접근 권한 없음

+

+ 현재 계약에 포함되지 않은 모듈입니다. +

+ +
+ ); + } + + return <>{children}; +} diff --git a/src/hooks/useModules.ts b/src/hooks/useModules.ts new file mode 100644 index 00000000..8ba99688 --- /dev/null +++ b/src/hooks/useModules.ts @@ -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(() => { + 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, + }; +} diff --git a/src/modules/index.ts b/src/modules/index.ts new file mode 100644 index 00000000..7b364396 --- /dev/null +++ b/src/modules/index.ts @@ -0,0 +1,73 @@ +/** + * 모듈 레지스트리 + * + * Phase 1: 정적 매니페스트 (여기서 직접 정의) + * Phase 2: 백엔드 API에서 로딩으로 교체 + */ + +import type { ModuleManifest, ModuleId } from './types'; + +const MODULE_REGISTRY: Record = { + 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; +} diff --git a/src/modules/tenant-config.ts b/src/modules/tenant-config.ts new file mode 100644 index 00000000..f15f3ba9 --- /dev/null +++ b/src/modules/tenant-config.ts @@ -0,0 +1,47 @@ +/** + * 테넌트 → 모듈 매핑 설정 + * + * Phase 1: industry 기반 하드코딩 매핑 + * Phase 2: 백엔드 tenant.options.modules에서 직접 수신 + */ + +import type { ModuleId } from './types'; + +/** 업종별 기본 모듈 매핑 */ +const INDUSTRY_MODULE_MAP: Record = { + 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); +} diff --git a/src/modules/types.ts b/src/modules/types.ts new file mode 100644 index 00000000..b2b2e4f7 --- /dev/null +++ b/src/modules/types.ts @@ -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[]; +}