feat(mng): 권한 분석 페이지 구현

- 메뉴별 권한 분석 기능 (접근 가능/불가 사용자 목록)
- 사용자 역추적 기능 (역할/부서/개인별 권한 추적)
- CSV 내보내기 기능
- 트리 구조 시각화 (└─ 연결선, 폴더/문서 아이콘)
- 중복 메뉴 표시 문제 해결 (테넌트별 메뉴만 표시)
This commit is contained in:
2025-11-26 21:42:51 +09:00
parent 7546771ee5
commit 1fc530bca2
10 changed files with 1407 additions and 2 deletions

View File

@@ -0,0 +1,142 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Services\PermissionAnalyzeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class PermissionAnalyzeController extends Controller
{
public function __construct(
private PermissionAnalyzeService $service
) {}
/**
* 메뉴 트리 조회 (HTMX용)
*/
public function menuTree(Request $request): JsonResponse|string
{
$tenantId = session('selected_tenant_id');
$search = $request->input('search');
$menuTree = $this->service->getMenuTree($tenantId, $search);
if ($request->header('HX-Request')) {
$html = view('permission-analyze.partials.menu-tree', [
'menuTree' => $menuTree,
])->render();
return response()->json(['html' => $html]);
}
return response()->json([
'success' => true,
'data' => $menuTree,
]);
}
/**
* 특정 메뉴의 권한 분석
*/
public function analyzeMenu(Request $request): JsonResponse|string
{
$menuId = $request->input('menu_id');
$permissionType = $request->input('permission_type', 'view');
$tenantId = session('selected_tenant_id');
if (! $menuId) {
return response()->json([
'success' => false,
'message' => '메뉴를 선택해주세요.',
], 400);
}
$analysis = $this->service->analyzeMenuPermission($menuId, $permissionType, $tenantId);
if ($request->header('HX-Request')) {
$html = view('permission-analyze.partials.analysis-result', [
'analysis' => $analysis,
'permissionType' => $permissionType,
])->render();
return response()->json(['html' => $html]);
}
return response()->json([
'success' => true,
'data' => $analysis,
]);
}
/**
* 사용자 역추적
*/
public function traceUsers(Request $request): JsonResponse|string
{
$menuId = $request->input('menu_id');
$permissionType = $request->input('permission_type', 'view');
$tenantId = session('selected_tenant_id');
if (! $menuId) {
return response()->json([
'success' => false,
'message' => '메뉴를 선택해주세요.',
], 400);
}
$trace = $this->service->traceUsersWithPermission($menuId, $permissionType, $tenantId);
if ($request->header('HX-Request')) {
$html = view('permission-analyze.partials.trace-result', [
'trace' => $trace,
'permissionType' => $permissionType,
])->render();
return response()->json(['html' => $html]);
}
return response()->json([
'success' => true,
'data' => $trace,
]);
}
/**
* CSV 내보내기
*/
public function exportCsv(Request $request): Response
{
$menuId = $request->input('menu_id');
$permissionType = $request->input('permission_type', 'view');
$tenantId = session('selected_tenant_id');
if (! $menuId) {
return response('메뉴를 선택해주세요.', 400);
}
$csv = $this->service->exportToCsv($menuId, $permissionType, $tenantId);
$filename = "permission_analysis_{$menuId}_{$permissionType}_".date('Ymd_His').'.csv';
return response($csv)
->header('Content-Type', 'text/csv; charset=UTF-8')
->header('Content-Disposition', "attachment; filename=\"{$filename}\"")
->header('Content-Transfer-Encoding', 'binary');
}
/**
* 권한 재계산 (캐시 무효화)
*/
public function recalculate(Request $request): JsonResponse
{
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
return response()->json([
'success' => true,
'message' => '권한이 재계산되었습니다.',
]);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use App\Services\PermissionAnalyzeService;
class PermissionAnalyzeController extends Controller
{
public function __construct(
private PermissionAnalyzeService $service
) {}
/**
* 권한 분석 페이지
*/
public function index()
{
$tenantId = session('selected_tenant_id');
$menuTree = $this->service->getMenuTree($tenantId);
$permissionTypes = $this->service->getPermissionTypes();
return view('permission-analyze.index', [
'menuTree' => $menuTree,
'permissionTypes' => $permissionTypes,
]);
}
}

View File

@@ -0,0 +1,525 @@
<?php
namespace App\Services;
use App\Models\Commons\Menu;
use App\Models\Tenants\Department;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class PermissionAnalyzeService
{
/**
* 권한 유형 목록
*/
private array $permissionTypes = ['view', 'create', 'update', 'delete', 'approve', 'export', 'manage'];
/**
* 메뉴 트리 조회 (권한 분석용)
*
* @param int|null $tenantId 테넌트 ID
* @param string|null $search 검색어
* @param bool $onlyAccessible 접근 가능한 메뉴만 필터링
* @return Collection 메뉴 트리
*/
public function getMenuTree(?int $tenantId = null, ?string $search = null, bool $onlyAccessible = false): Collection
{
$query = Menu::with('parent')
->where('is_active', 1);
if ($tenantId) {
// 특정 테넌트: 해당 테넌트 메뉴만 (각 테넌트별로 메뉴가 복제되어 있음)
$query->where('tenant_id', $tenantId);
} else {
// 전체 보기: 공통 메뉴만
$query->whereNull('tenant_id');
}
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('url', 'like', "%{$search}%");
});
}
$allMenus = $query->orderBy('sort_order', 'asc')
->orderBy('id', 'asc')
->get();
return $this->flattenMenuTree($allMenus);
}
/**
* 트리 구조를 플랫한 배열로 변환 (depth 정보 포함)
*/
private function flattenMenuTree(Collection $menus, ?int $parentId = null, int $depth = 0): Collection
{
$result = collect();
$filteredMenus = $menus->where('parent_id', $parentId)->sortBy('sort_order');
foreach ($filteredMenus as $menu) {
$menu->depth = $depth;
$menu->has_children = $menus->where('parent_id', $menu->id)->count() > 0;
$result->push($menu);
$children = $this->flattenMenuTree($menus, $menu->id, $depth + 1);
$result = $result->merge($children);
}
return $result;
}
/**
* 특정 메뉴의 권한 분석 (접근 가능/불가능 사용자 목록)
*
* @param int $menuId 메뉴 ID
* @param string $permissionType 권한 유형 (view, create, update, delete, approve)
* @param int|null $tenantId 테넌트 ID
* @param string $guardName Guard 이름
* @return array 분석 결과
*/
public function analyzeMenuPermission(int $menuId, string $permissionType = 'view', ?int $tenantId = null, string $guardName = 'api'): array
{
$menu = Menu::find($menuId);
if (! $menu) {
return ['error' => '메뉴를 찾을 수 없습니다.'];
}
$permissionName = "menu:{$menuId}.{$permissionType}";
// 테넌트별 사용자 목록 조회
$users = $this->getUsersByTenant($tenantId);
$accessAllowed = [];
$accessDenied = [];
foreach ($users as $user) {
$analysis = $this->analyzeUserPermission($user->id, $menuId, $permissionType, $tenantId, $guardName);
$userInfo = [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'departments' => $this->getUserDepartments($user->id, $tenantId),
'roles' => $this->getUserRoles($user->id, $tenantId),
'personal_override' => $analysis['personal'],
'source' => $analysis['source'],
'effective' => $analysis['effective'],
];
if ($analysis['effective'] === 'allow') {
$accessAllowed[] = $userInfo;
} else {
$accessDenied[] = $userInfo;
}
}
return [
'menu' => [
'id' => $menu->id,
'name' => $menu->name,
'url' => $menu->url,
],
'permission_type' => $permissionType,
'permission_rule' => 'ALLOW = 부서 OR 역할 OR (개인 ALLOW) → 개인 DENY 최우선',
'access_allowed' => $accessAllowed,
'access_denied' => $accessDenied,
'summary' => [
'total_users' => count($users),
'allowed_count' => count($accessAllowed),
'denied_count' => count($accessDenied),
],
];
}
/**
* 특정 사용자의 특정 메뉴 권한 분석
*
* @param int $userId 사용자 ID
* @param int $menuId 메뉴 ID
* @param string $permissionType 권한 유형
* @param int|null $tenantId 테넌트 ID
* @param string $guardName Guard 이름
* @return array 분석 결과
*/
public function analyzeUserPermission(int $userId, int $menuId, string $permissionType = 'view', ?int $tenantId = null, string $guardName = 'api'): array
{
$permissionName = "menu:{$menuId}.{$permissionType}";
$now = now();
// 1. 역할 권한 확인
$hasRolePermission = $this->checkRolePermission($userId, $permissionName, $guardName);
// 2. 부서 권한 확인
$hasDeptPermission = $this->checkDepartmentPermission($userId, $permissionName, $tenantId, $guardName);
// 3. 개인 오버라이드 확인
$personalOverride = $this->checkPersonalOverride($userId, $permissionName, $tenantId, $guardName);
// 최종 권한 계산 (AccessService와 동일한 우선순위)
// 1) 개인 DENY → 거부
// 2) 역할 권한 → 허용
// 3) 부서 ALLOW → 허용
// 4) 개인 ALLOW → 허용
// 5) 기본 → 없음 (거부)
$effective = null;
$source = null;
if ($personalOverride === 'deny') {
$effective = 'deny';
$source = 'personal';
} elseif ($hasRolePermission) {
$effective = 'allow';
$source = 'role';
} elseif ($hasDeptPermission) {
$effective = 'allow';
$source = 'department';
} elseif ($personalOverride === 'allow') {
$effective = 'allow';
$source = 'personal';
} else {
$effective = 'deny';
$source = null;
}
return [
'effective' => $effective,
'source' => $source,
'role' => $hasRolePermission,
'department' => $hasDeptPermission,
'personal' => $personalOverride,
];
}
/**
* 역할 권한 확인
*/
private function checkRolePermission(int $userId, string $permissionName, string $guardName): bool
{
return DB::table('model_has_roles as mhr')
->join('role_has_permissions as rhp', 'rhp.role_id', '=', 'mhr.role_id')
->join('permissions as p', 'p.id', '=', 'rhp.permission_id')
->where('mhr.model_type', User::class)
->where('mhr.model_id', $userId)
->where('p.guard_name', $guardName)
->where('p.name', $permissionName)
->exists();
}
/**
* 부서 권한 확인
*/
private function checkDepartmentPermission(int $userId, string $permissionName, ?int $tenantId, string $guardName): bool
{
$now = now();
$query = DB::table('department_user as du')
->join('permission_overrides as po', function ($j) use ($now) {
$j->on('po.model_id', '=', 'du.department_id')
->where('po.model_type', Department::class)
->whereNull('po.deleted_at')
->where('po.effect', 1)
->where(function ($w) use ($now) {
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
})
->where(function ($w) use ($now) {
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
});
})
->join('permissions as p', 'p.id', '=', 'po.permission_id')
->whereNull('du.deleted_at')
->where('du.user_id', $userId)
->where('p.guard_name', $guardName)
->where('p.name', $permissionName);
if ($tenantId) {
$query->where('du.tenant_id', $tenantId)
->where('po.tenant_id', $tenantId);
}
return $query->exists();
}
/**
* 개인 오버라이드 확인
*/
private function checkPersonalOverride(int $userId, string $permissionName, ?int $tenantId, string $guardName): ?string
{
$now = now();
$query = DB::table('permission_overrides as po')
->join('permissions as p', 'p.id', '=', 'po.permission_id')
->select('po.effect')
->where('po.model_type', User::class)
->where('po.model_id', $userId)
->where('p.guard_name', $guardName)
->where('p.name', $permissionName)
->whereNull('po.deleted_at')
->where(function ($w) use ($now) {
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
})
->where(function ($w) use ($now) {
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
});
if ($tenantId) {
$query->where('po.tenant_id', $tenantId);
}
$override = $query->first();
if (! $override) {
return null;
}
return $override->effect == 1 ? 'allow' : 'deny';
}
/**
* 테넌트별 사용자 목록 조회
*/
private function getUsersByTenant(?int $tenantId): Collection
{
$query = User::where('is_active', true);
if ($tenantId) {
$query->whereHas('tenants', function ($q) use ($tenantId) {
$q->where('tenants.id', $tenantId)
->where('user_tenants.is_active', true);
});
}
return $query->orderBy('name')->get();
}
/**
* 사용자의 부서 목록 조회
*/
private function getUserDepartments(int $userId, ?int $tenantId): array
{
$query = DB::table('department_user as du')
->join('departments as d', 'd.id', '=', 'du.department_id')
->select('d.id', 'd.name', 'd.code', 'du.is_primary')
->where('du.user_id', $userId)
->whereNull('du.deleted_at')
->whereNull('d.deleted_at');
if ($tenantId) {
$query->where('du.tenant_id', $tenantId);
}
return $query->get()->map(function ($dept) {
return [
'id' => $dept->id,
'name' => $dept->name,
'code' => $dept->code,
'is_primary' => $dept->is_primary,
];
})->toArray();
}
/**
* 사용자의 역할 목록 조회
*/
private function getUserRoles(int $userId, ?int $tenantId): array
{
$query = DB::table('user_roles as ur')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->select('r.id', 'r.name', 'r.display_name')
->where('ur.user_id', $userId)
->whereNull('ur.deleted_at');
if ($tenantId) {
$query->where('ur.tenant_id', $tenantId);
}
return $query->get()->map(function ($role) {
return [
'id' => $role->id,
'name' => $role->name,
'display_name' => $role->display_name,
];
})->toArray();
}
/**
* 사용자 역추적 - 특정 권한을 가진 모든 사용자 검색
*
* @param int $menuId 메뉴 ID
* @param string $permissionType 권한 유형
* @param int|null $tenantId 테넌트 ID
* @param string $guardName Guard 이름
* @return array 역추적 결과
*/
public function traceUsersWithPermission(int $menuId, string $permissionType = 'view', ?int $tenantId = null, string $guardName = 'api'): array
{
$permissionName = "menu:{$menuId}.{$permissionType}";
// 역할로 권한이 있는 사용자
$usersFromRole = DB::table('model_has_roles as mhr')
->join('role_has_permissions as rhp', 'rhp.role_id', '=', 'mhr.role_id')
->join('permissions as p', 'p.id', '=', 'rhp.permission_id')
->join('users as u', 'u.id', '=', 'mhr.model_id')
->join('roles as r', 'r.id', '=', 'mhr.role_id')
->select('u.id as user_id', 'u.name as user_name', 'u.email', 'r.id as role_id', 'r.name as role_name', 'r.display_name as role_display_name')
->where('mhr.model_type', User::class)
->where('p.guard_name', $guardName)
->where('p.name', $permissionName)
->where('u.is_active', true)
->get();
// 부서로 권한이 있는 사용자
$now = now();
$usersFromDepartment = DB::table('department_user as du')
->join('permission_overrides as po', function ($j) use ($now) {
$j->on('po.model_id', '=', 'du.department_id')
->where('po.model_type', Department::class)
->whereNull('po.deleted_at')
->where('po.effect', 1)
->where(function ($w) use ($now) {
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
})
->where(function ($w) use ($now) {
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
});
})
->join('permissions as p', 'p.id', '=', 'po.permission_id')
->join('users as u', 'u.id', '=', 'du.user_id')
->join('departments as d', 'd.id', '=', 'du.department_id')
->select('u.id as user_id', 'u.name as user_name', 'u.email', 'd.id as department_id', 'd.name as department_name', 'd.code as department_code')
->whereNull('du.deleted_at')
->where('p.guard_name', $guardName)
->where('p.name', $permissionName)
->where('u.is_active', true);
if ($tenantId) {
$usersFromDepartment->where('du.tenant_id', $tenantId)
->where('po.tenant_id', $tenantId);
}
$usersFromDepartment = $usersFromDepartment->get();
// 개인 ALLOW 오버라이드가 있는 사용자
$usersFromPersonal = DB::table('permission_overrides as po')
->join('permissions as p', 'p.id', '=', 'po.permission_id')
->join('users as u', 'u.id', '=', 'po.model_id')
->select('u.id as user_id', 'u.name as user_name', 'u.email', 'po.effect')
->where('po.model_type', User::class)
->where('p.guard_name', $guardName)
->where('p.name', $permissionName)
->where('po.effect', 1)
->whereNull('po.deleted_at')
->where('u.is_active', true)
->where(function ($w) use ($now) {
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
})
->where(function ($w) use ($now) {
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
});
if ($tenantId) {
$usersFromPersonal->where('po.tenant_id', $tenantId);
}
$usersFromPersonal = $usersFromPersonal->get();
// 개인 DENY 오버라이드가 있는 사용자
$usersWithDeny = DB::table('permission_overrides as po')
->join('permissions as p', 'p.id', '=', 'po.permission_id')
->join('users as u', 'u.id', '=', 'po.model_id')
->select('u.id as user_id', 'u.name as user_name', 'u.email')
->where('po.model_type', User::class)
->where('p.guard_name', $guardName)
->where('p.name', $permissionName)
->where('po.effect', 0)
->whereNull('po.deleted_at')
->where('u.is_active', true)
->where(function ($w) use ($now) {
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
})
->where(function ($w) use ($now) {
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
});
if ($tenantId) {
$usersWithDeny->where('po.tenant_id', $tenantId);
}
$usersWithDeny = $usersWithDeny->get();
return [
'by_role' => $usersFromRole->map(function ($item) {
return [
'user_id' => $item->user_id,
'user_name' => $item->user_name,
'email' => $item->email,
'role' => [
'id' => $item->role_id,
'name' => $item->role_name,
'display_name' => $item->role_display_name,
],
];
})->toArray(),
'by_department' => $usersFromDepartment->map(function ($item) {
return [
'user_id' => $item->user_id,
'user_name' => $item->user_name,
'email' => $item->email,
'department' => [
'id' => $item->department_id,
'name' => $item->department_name,
'code' => $item->department_code,
],
];
})->toArray(),
'by_personal' => $usersFromPersonal->map(function ($item) {
return [
'user_id' => $item->user_id,
'user_name' => $item->user_name,
'email' => $item->email,
];
})->toArray(),
'denied_users' => $usersWithDeny->map(function ($item) {
return [
'user_id' => $item->user_id,
'user_name' => $item->user_name,
'email' => $item->email,
];
})->toArray(),
];
}
/**
* 권한 유형 목록 반환
*/
public function getPermissionTypes(): array
{
return $this->permissionTypes;
}
/**
* CSV 내보내기용 데이터 생성
*/
public function exportToCsv(int $menuId, string $permissionType = 'view', ?int $tenantId = null): string
{
$analysis = $this->analyzeMenuPermission($menuId, $permissionType, $tenantId);
$csv = "사용자,이메일,부서,역할,개인모드,최종,근거\n";
foreach ($analysis['access_allowed'] as $user) {
$departments = collect($user['departments'])->pluck('name')->join(', ');
$roles = collect($user['roles'])->pluck('display_name')->join(', ');
$csv .= "\"{$user['name']}\",\"{$user['email']}\",\"{$departments}\",\"{$roles}\",\"{$user['personal_override']}\",\"허용\",\"{$user['source']}\"\n";
}
foreach ($analysis['access_denied'] as $user) {
$departments = collect($user['departments'])->pluck('name')->join(', ');
$roles = collect($user['roles'])->pluck('display_name')->join(', ');
$csv .= "\"{$user['name']}\",\"{$user['email']}\",\"{$departments}\",\"{$roles}\",\"{$user['personal_override']}\",\"거부\",\"{$user['source']}\"\n";
}
return $csv;
}
}

View File

@@ -132,8 +132,8 @@ class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:
</a>
</li>
<li>
<a href="#"
class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100"
<a href="{{ route('permission-analyze.index') }}"
class="flex items-center gap-2 pr-3 py-2 rounded-lg text-sm text-gray-700 hover:bg-gray-100 {{ request()->routeIs('permission-analyze.*') ? 'bg-primary text-white hover:bg-primary' : '' }}"
style="padding-left: 2rem;">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />

View File

@@ -0,0 +1,285 @@
@extends('layouts.app')
@section('title', '권한 분석')
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">권한 분석</h1>
<div class="flex items-center gap-2">
<button
type="button"
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
hx-post="/api/admin/permission-analyze/recalculate"
hx-swap="none"
onclick="alert('권한이 재계산되었습니다.')"
>
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
권한 재계산
</span>
</button>
<button
type="button"
id="exportCsvBtn"
class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 disabled:bg-gray-400 disabled:cursor-not-allowed"
onclick="exportCsv()"
disabled
>
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
CSV 내보내기
</span>
</button>
</div>
</div>
<!-- 필터 영역 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<div class="flex items-center gap-4">
<!-- 권한 유형 선택 -->
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-700">권한 유형:</label>
<select
id="permissionType"
name="permission_type"
class="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
onchange="onPermissionTypeChange()"
>
@foreach($permissionTypes as $type)
<option value="{{ $type }}">{{ ucfirst($type) }}</option>
@endforeach
</select>
</div>
<!-- 권한 규칙 표시 -->
<div class="flex-1 text-right">
<span class="text-xs text-gray-500 bg-gray-100 px-3 py-1 rounded-full">
권한 규칙: ALLOW = 부서 OR 역할 OR (개인 ALLOW) 개인 DENY 최우선
</span>
</div>
</div>
</div>
<!-- 2 레이아웃 (고정 높이, 좌우 분할) -->
<div class="flex gap-6" style="height: calc(100vh - 220px);">
<!-- 좌측: 메뉴 트리 (고정 너비) -->
<div class="w-80 flex-shrink-0">
<div class="bg-white rounded-lg shadow-sm h-full flex flex-col">
<div class="px-4 py-3 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-800 mb-3">메뉴 트리</h2>
<!-- 검색 -->
<input
type="text"
id="menuSearch"
placeholder="메뉴 검색..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
onkeyup="searchMenu(this.value)"
>
</div>
<div id="menu-tree" class="p-2 flex-1 overflow-y-auto">
@include('permission-analyze.partials.menu-tree', ['menuTree' => $menuTree])
</div>
</div>
</div>
<!-- 우측: 분석 결과 (나머지 공간) -->
<div class="flex-1 min-w-0">
<div class="bg-white rounded-lg shadow-sm h-full flex flex-col">
<div class="px-4 py-3 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-800" id="selectedMenuTitle">선택된 메뉴</h2>
</div>
<!-- 메뉴 -->
<div class="border-b border-gray-200 flex-shrink-0">
<nav class="flex -mb-px" id="analysisTabs">
<button
type="button"
class="tab-button px-4 py-3 text-sm font-medium border-b-2 border-blue-500 text-blue-600"
data-tab="access-allowed"
onclick="switchTab('access-allowed')"
>
접근 가능
</button>
<button
type="button"
class="tab-button px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
data-tab="access-denied"
onclick="switchTab('access-denied')"
>
접근 불가
</button>
<button
type="button"
class="tab-button px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
data-tab="trace"
onclick="switchTab('trace')"
>
사용자 역추적
</button>
</nav>
</div>
<!-- 콘텐츠 -->
<div id="analysis-content" class="p-4 flex-1 overflow-y-auto">
<div class="text-center py-12 text-gray-500">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p>좌측에서 분석할 메뉴를 선택해주세요.</p>
</div>
</div>
</div>
</div>
</div>
<input type="hidden" id="selectedMenuId" value="">
@endsection
@push('scripts')
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>
let currentTab = 'access-allowed';
// 메뉴 선택
function selectMenu(menuId, menuName) {
// 모든 메뉴 항목의 선택 상태 제거
document.querySelectorAll('.menu-item').forEach(item => {
item.classList.remove('bg-blue-100', 'border-blue-500');
item.classList.add('hover:bg-gray-50');
});
// 선택된 메뉴 항목 활성화
const selectedItem = document.querySelector(`[data-menu-id="${menuId}"]`);
if (selectedItem) {
selectedItem.classList.add('bg-blue-100', 'border-blue-500');
selectedItem.classList.remove('hover:bg-gray-50');
}
// 상태 저장
document.getElementById('selectedMenuId').value = menuId;
document.getElementById('selectedMenuTitle').textContent = menuName;
// CSV 버튼 활성화
document.getElementById('exportCsvBtn').disabled = false;
// 분석 결과 로드
loadAnalysis();
}
// 분석 결과 로드
function loadAnalysis() {
const menuId = document.getElementById('selectedMenuId').value;
const permissionType = document.getElementById('permissionType').value;
if (!menuId) return;
if (currentTab === 'trace') {
loadTrace();
} else {
fetch(`/api/admin/permission-analyze/analyze?menu_id=${menuId}&permission_type=${permissionType}`, {
headers: {
'HX-Request': 'true',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.html) {
document.getElementById('analysis-content').innerHTML = data.html;
}
});
}
}
// 역추적 결과 로드
function loadTrace() {
const menuId = document.getElementById('selectedMenuId').value;
const permissionType = document.getElementById('permissionType').value;
if (!menuId) return;
fetch(`/api/admin/permission-analyze/trace?menu_id=${menuId}&permission_type=${permissionType}`, {
headers: {
'HX-Request': 'true',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.html) {
document.getElementById('analysis-content').innerHTML = data.html;
}
});
}
// 탭 전환
function switchTab(tab) {
currentTab = tab;
// 탭 버튼 스타일 변경
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('border-blue-500', 'text-blue-600');
btn.classList.add('border-transparent', 'text-gray-500');
});
const activeBtn = document.querySelector(`[data-tab="${tab}"]`);
if (activeBtn) {
activeBtn.classList.remove('border-transparent', 'text-gray-500');
activeBtn.classList.add('border-blue-500', 'text-blue-600');
}
// 분석 결과 로드
loadAnalysis();
}
// 권한 유형 변경
function onPermissionTypeChange() {
loadAnalysis();
}
// 메뉴 검색
let searchTimeout;
function searchMenu(query) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const items = document.querySelectorAll('.menu-item');
items.forEach(item => {
const name = item.getAttribute('data-menu-name').toLowerCase();
if (query === '' || name.includes(query.toLowerCase())) {
item.style.display = 'block';
} else {
item.style.display = 'none';
}
});
}, 300);
}
// CSV 내보내기
function exportCsv() {
const menuId = document.getElementById('selectedMenuId').value;
const permissionType = document.getElementById('permissionType').value;
if (!menuId) {
alert('메뉴를 선택해주세요.');
return;
}
window.location.href = `/api/admin/permission-analyze/export-csv?menu_id=${menuId}&permission_type=${permissionType}`;
}
// HTMX 응답 처리
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'menu-tree') {
const response = JSON.parse(event.detail.xhr.response);
if (response.html) {
event.detail.target.innerHTML = response.html;
}
}
});
</script>
@endpush

View File

@@ -0,0 +1,207 @@
@php
$activeTab = request('tab', 'access-allowed');
@endphp
@if(isset($analysis['error']))
<div class="text-center py-12 text-red-500">
<svg class="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p>{{ $analysis['error'] }}</p>
</div>
@else
<!-- 요약 정보 -->
<div class="mb-4 p-3 bg-gray-50 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="text-sm text-gray-600">
전체 사용자: <span class="font-bold text-gray-900">{{ $analysis['summary']['total_users'] ?? 0 }}</span>
</span>
<span class="text-sm text-gray-600">
접근 가능: <span class="font-bold text-green-600">{{ $analysis['summary']['allowed_count'] ?? 0 }}</span>
</span>
<span class="text-sm text-gray-600">
접근 불가: <span class="font-bold text-red-600">{{ $analysis['summary']['denied_count'] ?? 0 }}</span>
</span>
</div>
<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded">
{{ strtoupper($permissionType) }} 권한
</span>
</div>
</div>
<!-- 접근 가능 콘텐츠 -->
<div id="tab-access-allowed" class="{{ $activeTab !== 'access-allowed' ? 'hidden' : '' }}">
@if(count($analysis['access_allowed'] ?? []) > 0)
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left font-medium text-gray-700">사용자</th>
<th class="px-4 py-2 text-left font-medium text-gray-700">부서</th>
<th class="px-4 py-2 text-left font-medium text-gray-700">역할</th>
<th class="px-4 py-2 text-center font-medium text-gray-700">개인 설정</th>
<th class="px-4 py-2 text-center font-medium text-gray-700">근거</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($analysis['access_allowed'] as $user)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<div class="font-medium text-gray-900">{{ $user['name'] }}</div>
<div class="text-xs text-gray-500">{{ $user['email'] }}</div>
</td>
<td class="px-4 py-3">
@if(count($user['departments']) > 0)
@foreach($user['departments'] as $dept)
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-800 rounded mr-1 mb-1">
{{ $dept['name'] }}
@if($dept['is_primary'])
<span class="ml-1 text-blue-600">*</span>
@endif
</span>
@endforeach
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-4 py-3">
@if(count($user['roles']) > 0)
@foreach($user['roles'] as $role)
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-purple-100 text-purple-800 rounded mr-1 mb-1">
{{ $role['display_name'] ?? $role['name'] }}
</span>
@endforeach
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-4 py-3 text-center">
@if($user['personal_override'] === 'allow')
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 rounded">
ALLOW
</span>
@elseif($user['personal_override'] === 'deny')
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-red-100 text-red-800 rounded">
DENY
</span>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-4 py-3 text-center">
@if($user['source'] === 'role')
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-purple-100 text-purple-800 rounded">
역할
</span>
@elseif($user['source'] === 'department')
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded">
부서
</span>
@elseif($user['source'] === 'personal')
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 rounded">
개인
</span>
@else
<span class="text-gray-400">-</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-8 text-gray-500">
<p>접근 가능한 사용자가 없습니다.</p>
</div>
@endif
</div>
<!-- 접근 불가 콘텐츠 -->
<div id="tab-access-denied" class="{{ $activeTab !== 'access-denied' ? 'hidden' : '' }}">
@if(count($analysis['access_denied'] ?? []) > 0)
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left font-medium text-gray-700">사용자</th>
<th class="px-4 py-2 text-left font-medium text-gray-700">부서</th>
<th class="px-4 py-2 text-left font-medium text-gray-700">역할</th>
<th class="px-4 py-2 text-center font-medium text-gray-700">개인 설정</th>
<th class="px-4 py-2 text-center font-medium text-gray-700">거부 사유</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($analysis['access_denied'] as $user)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<div class="font-medium text-gray-900">{{ $user['name'] }}</div>
<div class="text-xs text-gray-500">{{ $user['email'] }}</div>
</td>
<td class="px-4 py-3">
@if(count($user['departments']) > 0)
@foreach($user['departments'] as $dept)
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-800 rounded mr-1 mb-1">
{{ $dept['name'] }}
</span>
@endforeach
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-4 py-3">
@if(count($user['roles']) > 0)
@foreach($user['roles'] as $role)
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-purple-100 text-purple-800 rounded mr-1 mb-1">
{{ $role['display_name'] ?? $role['name'] }}
</span>
@endforeach
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-4 py-3 text-center">
@if($user['personal_override'] === 'deny')
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-red-100 text-red-800 rounded">
DENY
</span>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-4 py-3 text-center">
@if($user['source'] === 'personal')
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-red-100 text-red-800 rounded">
개인 DENY
</span>
@else
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-800 rounded">
권한 없음
</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-8 text-gray-500">
<p>접근 불가 사용자가 없습니다.</p>
</div>
@endif
</div>
@endif
<script>
// 현재 탭에 따라 콘텐츠 표시
document.querySelectorAll('[id^="tab-"]').forEach(tab => {
tab.classList.add('hidden');
});
const currentTabContent = document.getElementById('tab-' + (typeof currentTab !== 'undefined' ? currentTab : 'access-allowed'));
if (currentTabContent) {
currentTabContent.classList.remove('hidden');
}
</script>

View File

@@ -0,0 +1,44 @@
@forelse($menuTree as $menu)
<div
class="menu-item flex items-center gap-2 py-2 rounded-lg border border-transparent cursor-pointer transition-colors hover:bg-gray-50 {{ $menu->depth > 0 ? 'ml-4 border-l-2 border-gray-200' : '' }}"
data-menu-id="{{ $menu->id }}"
data-menu-name="{{ $menu->name }}"
onclick="selectMenu({{ $menu->id }}, '{{ addslashes($menu->name) }}')"
style="padding-left: {{ ($menu->depth * 1.5) + 0.75 }}rem;"
>
{{-- 트리 구조 표시 --}}
@if($menu->depth > 0)
<span class="text-gray-300 text-xs font-mono flex-shrink-0">└─</span>
@endif
{{-- 폴더/아이템 아이콘 --}}
@if($menu->has_children)
<svg class="w-4 h-4 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
@else
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
@endif
{{-- 메뉴 정보 --}}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm {{ $menu->depth === 0 ? 'font-semibold text-gray-900' : 'font-medium text-gray-700' }} truncate">
{{ $menu->name }}
</span>
@if($menu->url)
<span class="text-xs text-gray-400 truncate hidden sm:inline">{{ $menu->url }}</span>
@endif
</div>
</div>
{{-- 메뉴 ID --}}
<span class="text-xs text-gray-400 font-mono flex-shrink-0">ID:{{ $menu->id }}</span>
</div>
@empty
<div class="text-center py-8 text-gray-500">
<p>메뉴가 없습니다.</p>
</div>
@endforelse

View File

@@ -0,0 +1,163 @@
<!-- 역추적 결과 -->
<div class="space-y-6">
<!-- 역할로 권한 부여된 사용자 -->
<div>
<h3 class="text-sm font-semibold text-gray-800 mb-3 flex items-center gap-2">
<span class="inline-flex items-center justify-center w-6 h-6 bg-purple-100 text-purple-800 rounded-full text-xs font-bold">
{{ count($trace['by_role'] ?? []) }}
</span>
역할로 권한 부여된 사용자
</h3>
@if(count($trace['by_role'] ?? []) > 0)
<div class="overflow-x-auto">
<table class="w-full text-sm border border-gray-200 rounded-lg">
<thead class="bg-purple-50">
<tr>
<th class="px-4 py-2 text-left font-medium text-gray-700">사용자</th>
<th class="px-4 py-2 text-left font-medium text-gray-700">역할</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($trace['by_role'] as $item)
<tr class="hover:bg-gray-50">
<td class="px-4 py-2">
<div class="font-medium text-gray-900">{{ $item['user_name'] }}</div>
<div class="text-xs text-gray-500">{{ $item['email'] }}</div>
</td>
<td class="px-4 py-2">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-purple-100 text-purple-800 rounded">
{{ $item['role']['display_name'] ?? $item['role']['name'] }}
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-4 text-gray-500 bg-gray-50 rounded-lg">
<p class="text-sm">역할로 권한 부여된 사용자가 없습니다.</p>
</div>
@endif
</div>
<!-- 부서로 권한 부여된 사용자 -->
<div>
<h3 class="text-sm font-semibold text-gray-800 mb-3 flex items-center gap-2">
<span class="inline-flex items-center justify-center w-6 h-6 bg-blue-100 text-blue-800 rounded-full text-xs font-bold">
{{ count($trace['by_department'] ?? []) }}
</span>
부서로 권한 부여된 사용자
</h3>
@if(count($trace['by_department'] ?? []) > 0)
<div class="overflow-x-auto">
<table class="w-full text-sm border border-gray-200 rounded-lg">
<thead class="bg-blue-50">
<tr>
<th class="px-4 py-2 text-left font-medium text-gray-700">사용자</th>
<th class="px-4 py-2 text-left font-medium text-gray-700">부서</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($trace['by_department'] as $item)
<tr class="hover:bg-gray-50">
<td class="px-4 py-2">
<div class="font-medium text-gray-900">{{ $item['user_name'] }}</div>
<div class="text-xs text-gray-500">{{ $item['email'] }}</div>
</td>
<td class="px-4 py-2">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded">
{{ $item['department']['name'] }}
@if($item['department']['code'])
<span class="ml-1 text-blue-600">({{ $item['department']['code'] }})</span>
@endif
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-4 text-gray-500 bg-gray-50 rounded-lg">
<p class="text-sm">부서로 권한 부여된 사용자가 없습니다.</p>
</div>
@endif
</div>
<!-- 개인 ALLOW로 권한 부여된 사용자 -->
<div>
<h3 class="text-sm font-semibold text-gray-800 mb-3 flex items-center gap-2">
<span class="inline-flex items-center justify-center w-6 h-6 bg-green-100 text-green-800 rounded-full text-xs font-bold">
{{ count($trace['by_personal'] ?? []) }}
</span>
개인 ALLOW로 권한 부여된 사용자
</h3>
@if(count($trace['by_personal'] ?? []) > 0)
<div class="overflow-x-auto">
<table class="w-full text-sm border border-gray-200 rounded-lg">
<thead class="bg-green-50">
<tr>
<th class="px-4 py-2 text-left font-medium text-gray-700">사용자</th>
<th class="px-4 py-2 text-left font-medium text-gray-700">이메일</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($trace['by_personal'] as $item)
<tr class="hover:bg-gray-50">
<td class="px-4 py-2">
<div class="font-medium text-gray-900">{{ $item['user_name'] }}</div>
</td>
<td class="px-4 py-2">
<div class="text-gray-500">{{ $item['email'] }}</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-4 text-gray-500 bg-gray-50 rounded-lg">
<p class="text-sm">개인 ALLOW로 권한 부여된 사용자가 없습니다.</p>
</div>
@endif
</div>
<!-- 개인 DENY로 거부된 사용자 -->
<div>
<h3 class="text-sm font-semibold text-gray-800 mb-3 flex items-center gap-2">
<span class="inline-flex items-center justify-center w-6 h-6 bg-red-100 text-red-800 rounded-full text-xs font-bold">
{{ count($trace['denied_users'] ?? []) }}
</span>
개인 DENY로 거부된 사용자
</h3>
@if(count($trace['denied_users'] ?? []) > 0)
<div class="overflow-x-auto">
<table class="w-full text-sm border border-gray-200 rounded-lg">
<thead class="bg-red-50">
<tr>
<th class="px-4 py-2 text-left font-medium text-gray-700">사용자</th>
<th class="px-4 py-2 text-left font-medium text-gray-700">이메일</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($trace['denied_users'] as $item)
<tr class="hover:bg-gray-50">
<td class="px-4 py-2">
<div class="font-medium text-gray-900">{{ $item['user_name'] }}</div>
</td>
<td class="px-4 py-2">
<div class="text-gray-500">{{ $item['email'] }}</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-4 text-gray-500 bg-gray-50 rounded-lg">
<p class="text-sm">개인 DENY로 거부된 사용자가 없습니다.</p>
</div>
@endif
</div>
</div>

View File

@@ -124,4 +124,13 @@
Route::post('/deny-all', [\App\Http\Controllers\Api\Admin\UserPermissionController::class, 'denyAll'])->name('denyAll');
Route::post('/reset', [\App\Http\Controllers\Api\Admin\UserPermissionController::class, 'reset'])->name('reset');
});
// 권한 분석 API
Route::prefix('permission-analyze')->name('permission-analyze.')->group(function () {
Route::get('/menu-tree', [\App\Http\Controllers\Api\Admin\PermissionAnalyzeController::class, 'menuTree'])->name('menuTree');
Route::get('/analyze', [\App\Http\Controllers\Api\Admin\PermissionAnalyzeController::class, 'analyzeMenu'])->name('analyze');
Route::get('/trace', [\App\Http\Controllers\Api\Admin\PermissionAnalyzeController::class, 'traceUsers'])->name('trace');
Route::get('/export-csv', [\App\Http\Controllers\Api\Admin\PermissionAnalyzeController::class, 'exportCsv'])->name('exportCsv');
Route::post('/recalculate', [\App\Http\Controllers\Api\Admin\PermissionAnalyzeController::class, 'recalculate'])->name('recalculate');
});
});

View File

@@ -84,6 +84,9 @@
// 개인 권한 관리 (Blade 화면만)
Route::get('/user-permissions', [\App\Http\Controllers\UserPermissionController::class, 'index'])->name('user-permissions.index');
// 권한 분석 (Blade 화면만)
Route::get('/permission-analyze', [\App\Http\Controllers\PermissionAnalyzeController::class, 'index'])->name('permission-analyze.index');
// 대시보드
Route::get('/dashboard', function () {
return view('dashboard.index');