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

@@ -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}</>;
}