feat(WEB): 권한 관리 시스템 구현 및 상세 페이지 권한 통합

- PermissionContext, usePermission 훅, PermissionGuard 컴포넌트 신규 추가
- AccessDenied 접근 거부 페이지 추가
- permissions lib (체커, 매퍼, 타입) 구현
- BadDebtDetail, BoardDetail, LaborDetail, PricingDetail 등 상세 페이지 권한 적용
- ProcessDetail, StepDetail, ItemDetail, PermissionDetail 권한 연동
- RootProvider에 PermissionProvider 통합
- protected layout 권한 체크 추가
- Claude 프로젝트 설정 파일 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-03 10:17:02 +09:00
parent f0987127eb
commit e111f7b362
22 changed files with 1267 additions and 994 deletions

View File

@@ -6,6 +6,7 @@ import { RootProvider } from '@/contexts/RootProvider';
import { ApiErrorProvider } from '@/contexts/ApiErrorContext';
import { FCMProvider } from '@/contexts/FCMProvider';
import { DevFillProvider, DevToolbar } from '@/components/dev';
import { PermissionGate } from '@/contexts/PermissionContext';
/**
* Protected Layout
@@ -42,7 +43,9 @@ export default function ProtectedLayout({
<ApiErrorProvider>
<FCMProvider>
<DevFillProvider>
<AuthenticatedLayout>{children}</AuthenticatedLayout>
<AuthenticatedLayout>
<PermissionGate>{children}</PermissionGate>
</AuthenticatedLayout>
<DevToolbar />
</DevFillProvider>
</FCMProvider>

View File

@@ -43,6 +43,7 @@ import {
} from './types';
import { createBadDebt, updateBadDebt, deleteBadDebt, addBadDebtMemo, deleteBadDebtMemo } from './actions';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
interface BadDebtDetailProps {
mode: 'view' | 'edit' | 'new';
@@ -95,6 +96,7 @@ const getEmptyRecord = (): Omit<BadDebtRecord, 'id' | 'createdAt' | 'updatedAt'>
export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProps) {
const router = useRouter();
const { canUpdate, canDelete } = usePermission();
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
@@ -346,12 +348,16 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
if (isViewMode) {
return (
<>
<Button variant="outline" className="text-red-500 border-red-200 hover:bg-red-50" onClick={handleDelete} disabled={isLoading}>
{isLoading ? '처리중...' : '삭제'}
</Button>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
</Button>
{canDelete && (
<Button variant="outline" className="text-red-500 border-red-200 hover:bg-red-50" onClick={handleDelete} disabled={isLoading}>
{isLoading ? '처리중...' : '삭제'}
</Button>
)}
{canUpdate && (
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
</Button>
)}
</>
);
}
@@ -365,7 +371,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
</Button>
</>
);
}, [isViewMode, isNewMode, isLoading, handleDelete, handleEdit, handleCancel, handleSave, mode]);
}, [isViewMode, isNewMode, isLoading, handleDelete, handleEdit, handleCancel, handleSave, mode, canUpdate, canDelete]);
// 입력 필드 렌더링 헬퍼
const renderField = (

View File

@@ -16,8 +16,8 @@ import {
interface BoardDetailProps {
board: Board;
onEdit: () => void;
onDelete: () => void;
onEdit?: () => void;
onDelete?: () => void;
}
// 날짜/시간 포맷
@@ -100,14 +100,18 @@ export function BoardDetail({ board, onEdit, onDelete }: BoardDetailProps) {
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={onDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
<Button onClick={onEdit}>
<Edit className="w-4 h-4 mr-2" />
</Button>
{onDelete && (
<Button variant="outline" onClick={onDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
)}
{onEdit && (
<Button onClick={onEdit}>
<Edit className="w-4 h-4 mr-2" />
</Button>
)}
</div>
</div>
</div>

View File

@@ -20,6 +20,7 @@ import { ErrorCard } from '@/components/ui/error-card';
import { Button } from '@/components/ui/button';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { usePermission } from '@/hooks/usePermission';
type DetailMode = 'view' | 'edit' | 'create';
@@ -40,6 +41,7 @@ const generateBoardCode = (): string => {
export function BoardDetailClientV2({ boardId, initialMode }: BoardDetailClientV2Props) {
const router = useRouter();
const searchParams = useSearchParams();
const { canUpdate, canDelete } = usePermission();
// URL 쿼리에서 모드 결정
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
@@ -268,8 +270,8 @@ export function BoardDetailClientV2({ boardId, initialMode }: BoardDetailClientV
<>
<BoardDetail
board={boardData}
onEdit={handleEdit}
onDelete={handleDelete}
onEdit={canUpdate ? handleEdit : undefined}
onDelete={canDelete ? handleDelete : undefined}
/>
<DeleteConfirmDialog

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Hammer, ArrowLeft, Trash2, Edit, X, Save, Plus } from 'lucide-react';
import { useMenuStore } from '@/store/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -45,6 +46,7 @@ export default function LaborDetailClient({
}: LaborDetailClientProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const { canUpdate, canDelete } = usePermission();
// 모드 상태
const [mode, setMode] = useState<'view' | 'edit' | 'new'>(
@@ -410,18 +412,22 @@ export default function LaborDetailClient({
<div className="flex items-center gap-2">
{mode === 'view' && (
<>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(true)}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleEditMode}>
<Edit className="h-4 w-4 mr-2" />
</Button>
{canDelete && (
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(true)}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
)}
{canUpdate && (
<Button onClick={handleEditMode}>
<Edit className="h-4 w-4 mr-2" />
</Button>
)}
</>
)}
{mode === 'edit' && (

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { DollarSign, ArrowLeft, Trash2, Edit, X, Save, Plus, List } from 'lucide-react';
import { useMenuStore } from '@/store/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -69,6 +70,7 @@ const initialFormData: FormData = {
export default function PricingDetailClient({ id, mode }: PricingDetailClientProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const { canUpdate, canDelete } = usePermission();
const [pricing, setPricing] = useState<Pricing | null>(null);
const [formData, setFormData] = useState<FormData>(initialFormData);
const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]);
@@ -403,18 +405,22 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
<div className="flex items-center gap-2">
{isViewMode && (
<>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(true)}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleEdit}>
<Edit className="h-4 w-4 mr-2" />
</Button>
{canDelete && (
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(true)}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
)}
{canUpdate && (
<Button onClick={handleEdit}>
<Edit className="h-4 w-4 mr-2" />
</Button>
)}
</>
)}
{isEditMode && (

View File

@@ -0,0 +1,67 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ShieldOff, ArrowLeft, Home } from 'lucide-react';
import { useRouter } from 'next/navigation';
interface AccessDeniedProps {
title?: string;
description?: string;
showBackButton?: boolean;
showHomeButton?: boolean;
}
export function AccessDenied({
title = '접근 권한이 없습니다',
description = '이 페이지에 대한 접근 권한이 없습니다. 관리자에게 문의하세요.',
showBackButton = true,
showHomeButton = true,
}: AccessDeniedProps) {
const router = useRouter();
return (
<div className="min-h-[calc(100vh-200px)] flex items-center justify-center p-4">
<Card className="w-full max-w-lg border border-border/20 bg-card/50 backdrop-blur">
<CardHeader className="text-center pb-4">
<div className="flex justify-center mb-6">
<div className="w-20 h-20 bg-gradient-to-br from-amber-500/20 to-orange-500/10 rounded-2xl flex items-center justify-center">
<ShieldOff className="w-10 h-10 text-amber-500" />
</div>
</div>
<CardTitle className="text-xl md:text-2xl font-bold text-foreground">
{title}
</CardTitle>
</CardHeader>
<CardContent className="text-center space-y-6">
<p className="text-muted-foreground">{description}</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center pt-2">
{showBackButton && (
<Button
variant="outline"
onClick={() => router.back()}
className="rounded-xl"
>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
)}
{showHomeButton && (
<Button
variant="outline"
onClick={() => router.push('/dashboard')}
className="rounded-xl"
>
<Home className="w-4 h-4 mr-2" />
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import { usePermission } from '@/hooks/usePermission';
import type { PermissionAction } from '@/lib/permissions/types';
interface PermissionGuardProps {
action: PermissionAction;
/** 다른 메뉴 권한 체크 시 URL 직접 지정 (생략하면 현재 URL 자동 매칭) */
url?: string;
/** 권한 없을 때 대체 UI (기본: 렌더링 안 함) */
fallback?: React.ReactNode;
children: React.ReactNode;
}
/**
* 버튼/영역 레벨 권한 제어 컴포넌트
*
* @example
* // 현재 페이지 기준 (URL 자동매칭)
* <PermissionGuard action="delete">
* <Button variant="destructive">삭제</Button>
* </PermissionGuard>
*
* // 다른 메뉴 권한 체크
* <PermissionGuard action="approve" url="/approval/inbox">
* <Button>승인</Button>
* </PermissionGuard>
*/
export function PermissionGuard({
action,
url,
fallback = null,
children,
}: PermissionGuardProps) {
const permission = usePermission(url);
const actionMap: Record<PermissionAction, boolean> = {
view: permission.canView,
create: permission.canCreate,
update: permission.canUpdate,
delete: permission.canDelete,
approve: permission.canApprove,
};
if (!actionMap[action]) {
return <>{fallback}</>;
}
return <>{children}</>;
}

View File

@@ -30,6 +30,7 @@ import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calenda
import { downloadFileById } from '@/lib/utils/fileDownload';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { useMenuStore } from '@/store/menuStore';
import { usePermission } from '@/hooks/usePermission';
interface ItemDetailClientProps {
item: ItemMaster;
@@ -96,6 +97,7 @@ function getStorageUrl(path: string | undefined): string | null {
export default function ItemDetailClient({ item }: ItemDetailClientProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const { canUpdate } = usePermission();
return (
<div className="space-y-6 pb-24">
@@ -625,13 +627,15 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<Button
type="button"
onClick={() => router.push(`/production/screen-production/${encodeURIComponent(item.itemCode)}?mode=edit&type=${item.itemType}&id=${item.id}`)}
>
<Edit className="w-4 h-4 mr-2" />
</Button>
{canUpdate && (
<Button
type="button"
onClick={() => router.push(`/production/screen-production/${encodeURIComponent(item.itemCode)}?mode=edit&type=${item.itemType}&id=${item.id}`)}
>
<Edit className="w-4 h-4 mr-2" />
</Button>
)}
</div>
</div>
);

View File

@@ -18,6 +18,7 @@ import { Badge } from '@/components/ui/badge';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { useMenuStore } from '@/store/menuStore';
import { usePermission } from '@/hooks/usePermission';
import { getProcessSteps } from './actions';
import type { Process, ProcessStep } from '@/types/process';
@@ -28,6 +29,7 @@ interface ProcessDetailProps {
export function ProcessDetail({ process }: ProcessDetailProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const { canUpdate } = usePermission();
// 단계 목록 상태
const [steps, setSteps] = useState<ProcessStep[]>([]);
@@ -325,10 +327,12 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleEdit}>
<Edit className="h-4 w-4 mr-2" />
</Button>
{canUpdate && (
<Button onClick={handleEdit}>
<Edit className="h-4 w-4 mr-2" />
</Button>
)}
</div>
</PageLayout>
);

View File

@@ -17,6 +17,7 @@ import { Badge } from '@/components/ui/badge';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { useMenuStore } from '@/store/menuStore';
import { usePermission } from '@/hooks/usePermission';
import type { ProcessStep } from '@/types/process';
interface StepDetailProps {
@@ -27,6 +28,7 @@ interface StepDetailProps {
export function StepDetail({ step, processId }: StepDetailProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const { canUpdate } = usePermission();
const handleEdit = () => {
router.push(
@@ -129,10 +131,12 @@ export function StepDetail({ step, processId }: StepDetailProps) {
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleEdit}>
<Edit className="h-4 w-4 mr-2" />
</Button>
{canUpdate && (
<Button onClick={handleEdit}>
<Edit className="h-4 w-4 mr-2" />
</Button>
)}
</div>
</PageLayout>
);

View File

@@ -50,6 +50,7 @@ import {
resetPermissions,
} from './actions';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermissionContext } from '@/contexts/PermissionContext';
// 플랫 배열을 트리 구조로 변환
interface FlatMenuItem {
@@ -128,6 +129,7 @@ const PERMISSION_LABELS_MAP: Record<PermissionType, string> = {
export function PermissionDetailClient({ permissionId, isNew = false, mode = 'view' }: PermissionDetailClientProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const { reloadPermissions } = usePermissionContext();
// 역할 데이터
const [role, setRole] = useState<Role | null>(null);
@@ -655,6 +657,15 @@ export function PermissionDetailClient({ permissionId, isNew = false, mode = 'vi
</>
)}
</Button>
<Button
onClick={() => {
reloadPermissions();
toast.success('권한 정보가 저장되었습니다.');
}}
>
<Shield className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
onClick={handleDelete}

View File

@@ -0,0 +1,143 @@
'use client';
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { usePathname } from 'next/navigation';
import { getRolePermissionMatrix } from '@/lib/permissions/actions';
import { buildMenuIdToUrlMap, convertMatrixToPermissionMap, findMatchingUrl, mergePermissionMaps } from '@/lib/permissions/utils';
import type { PermissionMap, PermissionAction } from '@/lib/permissions/types';
import { AccessDenied } from '@/components/common/AccessDenied';
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 {
const results = await Promise.all(
roleIds.map(id => getRolePermissionMatrix(id))
);
const maps = results
.filter(r => r.success && r.data?.permissions)
.map(r => convertMatrixToPermissionMap(r.data.permissions, menuIdToUrl));
if (maps.length > 0) {
const merged = mergePermissionMaps(maps);
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 perms = permissionMap[url];
if (!perms) return true;
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 = pathname.replace(/^\/[a-z]{2}(\/|$)/, '/');
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);

View File

@@ -2,6 +2,7 @@
import { ReactNode } from 'react';
import { AuthProvider } from './AuthContext';
import { PermissionProvider } from './PermissionContext';
import { ItemMasterProvider } from './ItemMasterContext';
/**
@@ -9,7 +10,8 @@ import { ItemMasterProvider } from './ItemMasterContext';
*
* 현재 사용 중인 Context:
* 1. AuthContext - 사용자/인증 (2개 상태)
* 2. ItemMasterContext - 품목관리 (13개 상태)
* 2. PermissionContext - 권한 관리 (URL 자동매칭)
* 3. ItemMasterContext - 품목관리 (13개 상태)
*
* 미사용 Context (contexts/_unused/로 이동됨):
* - FacilitiesContext, AccountingContext, HRContext, ShippingContext
@@ -18,9 +20,11 @@ import { ItemMasterProvider } from './ItemMasterContext';
export function RootProvider({ children }: { children: ReactNode }) {
return (
<AuthProvider>
<ItemMasterProvider>
{children}
</ItemMasterProvider>
<PermissionProvider>
<ItemMasterProvider>
{children}
</ItemMasterProvider>
</PermissionProvider>
</AuthProvider>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import { usePathname } from 'next/navigation';
import { usePermissionContext } from '@/contexts/PermissionContext';
import { findMatchingUrl } from '@/lib/permissions/utils';
import type { UsePermissionReturn } from '@/lib/permissions/types';
/**
* URL 자동매칭 권한 훅
*
* 인자 없이 호출하면 현재 URL 기반 자동 매칭.
* 특수 케이스에서 URL 직접 지정 가능.
*
* @example
* // 자동 매칭 (대부분의 경우)
* const { canView, canCreate, canUpdate, canDelete } = usePermission();
*
* // URL 직접 지정 (다른 메뉴 권한 체크 시)
* const { canApprove } = usePermission('/approval/inbox');
*/
export function usePermission(overrideUrl?: string): UsePermissionReturn {
const pathname = usePathname();
const { permissionMap, isLoading } = usePermissionContext();
const targetPath = overrideUrl || pathname;
if (isLoading || !permissionMap) {
return {
canView: true,
canCreate: true,
canUpdate: true,
canDelete: true,
canApprove: true,
isLoading,
matchedUrl: null,
};
}
const matchedUrl = findMatchingUrl(targetPath, permissionMap);
console.log('[usePermission]', targetPath, '→ matched:', matchedUrl, '| perms:', matchedUrl ? permissionMap[matchedUrl] : 'none');
if (!matchedUrl) {
return {
canView: true,
canCreate: true,
canUpdate: true,
canDelete: true,
canApprove: true,
isLoading: false,
matchedUrl: null,
};
}
const perms = permissionMap[matchedUrl] || {};
return {
canView: perms.view ?? true,
canCreate: perms.create ?? true,
canUpdate: perms.update ?? true,
canDelete: perms.delete ?? true,
canApprove: perms.approve ?? true,
isLoading: false,
matchedUrl,
};
}

View File

@@ -0,0 +1,30 @@
'use server';
import { serverFetch } from '@/lib/api/fetch-wrapper';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
/** 역할(Role) 기반 권한 매트릭스 조회 (설정 페이지와 동일 API) */
export async function getRolePermissionMatrix(roleId: number) {
try {
const url = `${API_URL}/api/v1/roles/${roleId}/permissions/matrix`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error) {
console.error('[Permission Action] serverFetch error:', error);
return { success: false, data: null };
}
if (!response?.ok) {
console.error('[Permission Action] HTTP 에러:', response?.status, response?.statusText);
return { success: false, data: null };
}
const json = await response.json();
return json;
} catch (err) {
console.error('[Permission Action] 예외:', err);
return { success: false, data: null };
}
}

View File

@@ -0,0 +1,19 @@
export type PermissionAction = 'view' | 'create' | 'update' | 'delete' | 'approve';
/** flat 변환된 권한 맵 (프론트엔드 사용) */
export interface PermissionMap {
[url: string]: {
[key in PermissionAction]?: boolean;
};
}
/** usePermission 훅 반환 타입 */
export interface UsePermissionReturn {
canView: boolean;
canCreate: boolean;
canUpdate: boolean;
canDelete: boolean;
canApprove: boolean;
isLoading: boolean;
matchedUrl: string | null;
}

View File

@@ -0,0 +1,111 @@
import type { PermissionMap, PermissionAction } from './types';
interface SerializableMenuItem {
id: string;
path: string;
children?: SerializableMenuItem[];
}
/**
* localStorage 메뉴 트리에서 menuId → URL 매핑 생성
*/
export function buildMenuIdToUrlMap(menus: SerializableMenuItem[]): Record<string, string> {
const map: Record<string, string> = {};
function traverse(items: SerializableMenuItem[]) {
for (const item of items) {
if (item.id && item.path) {
map[item.id] = item.path;
}
if (item.children?.length) {
traverse(item.children);
}
}
}
traverse(menus);
return map;
}
/**
* 설정 페이지 API 응답(menuId 기반) → URL 기반 PermissionMap 변환
*
* API 응답: { permissions: { [menuId]: { view: true, create: false, ... } } }
* 결과: { "/boards/free": { view: true, create: false, ... } }
*/
export function convertMatrixToPermissionMap(
permissions: Record<string, Record<string, boolean>>,
menuIdToUrl: Record<string, string>
): PermissionMap {
const map: PermissionMap = {};
const actions: PermissionAction[] = ['view', 'create', 'update', 'delete', 'approve'];
for (const [menuId, perms] of Object.entries(permissions)) {
const url = menuIdToUrl[menuId];
if (!url) continue; // URL 매핑 없는 메뉴 스킵
map[url] = {};
for (const action of actions) {
// API는 허용된 권한만 포함, 누락된 action = 비허용(false)
map[url][action] = perms[action] === true;
}
}
return map;
}
/**
* 다중 역할 PermissionMap 병합 (Union: 하나라도 허용이면 허용)
*/
export function mergePermissionMaps(maps: PermissionMap[]): PermissionMap {
if (maps.length === 0) return {};
if (maps.length === 1) return maps[0];
const merged: PermissionMap = {};
const allUrls = new Set(maps.flatMap(m => Object.keys(m)));
for (const url of allUrls) {
merged[url] = {};
const actions: PermissionAction[] = ['view', 'create', 'update', 'delete', 'approve'];
for (const action of actions) {
const values = maps
.map(m => m[url]?.[action])
.filter((v): v is boolean => v !== undefined);
if (values.length > 0) {
merged[url][action] = values.some(v => v);
}
}
}
return merged;
}
/**
* Longest prefix match: 현재 경로에서 가장 길게 매칭되는 권한 URL 찾기
*/
export function findMatchingUrl(currentPath: string, permissionMap: PermissionMap): string | null {
const pathWithoutLocale = currentPath.replace(/^\/[a-z]{2}(\/|$)/, '/');
if (permissionMap[pathWithoutLocale]) {
return pathWithoutLocale;
}
const segments = pathWithoutLocale.split('/').filter(Boolean);
for (let i = segments.length; i > 0; i--) {
const prefix = '/' + segments.slice(0, i).join('/');
if (permissionMap[prefix]) {
return prefix;
}
}
return null;
}
/**
* CRUD 라우트에서 현재 액션 추론
*/
export function inferActionFromPath(path: string): PermissionAction {
if (path.endsWith('/new') || path.endsWith('/create')) return 'create';
if (path.endsWith('/edit')) return 'update';
return 'view';
}