feat(mng): 권한 관리 기능 구현

- Permission 모델 및 PermissionService 생성 (Spatie Permission 확장)
- HTMX 기반 권한 CRUD API 구현
- Blade 기반 권한 관리 화면 (index, create, edit)
- 권한명 포맷팅 로직 추가 (menu:id.type 파싱)
- 사이드바 메뉴 추가
- 멀티테넌트 지원 (tenant_id nullable)
- 할당된 역할/부서 표시 기능
This commit is contained in:
2025-11-25 11:05:57 +09:00
parent ee167a112e
commit dc91b89b44
13 changed files with 1097 additions and 1 deletions

View File

@@ -53,6 +53,17 @@ class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-
</a>
</li>
<!-- 권한 관리 -->
<li>
<a href="{{ route('permissions.index') }}"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100 {{ request()->routeIs('permissions.*') ? 'bg-primary text-white hover:bg-primary' : '' }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span class="font-medium">권한 관리</span>
</a>
</li>
<!-- 부서 관리 -->
<li>
<a href="{{ route('departments.index') }}"

View File

@@ -0,0 +1,109 @@
@extends('layouts.app')
@section('title', '권한 생성')
@section('content')
<div class="max-w-3xl mx-auto">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">🛡️ 권한 생성</h1>
<a href="{{ route('permissions.index') }}" class="text-gray-600 hover:text-gray-800">
목록으로
</a>
</div>
<!-- -->
<div class="bg-white rounded-lg shadow-sm p-6">
<form id="permissionForm" action="/api/admin/permissions" method="POST">
@csrf
<!-- 권한 이름 -->
<div class="mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-2">
권한 이름 <span class="text-red-500">*</span>
</label>
<input type="text"
name="name"
required
placeholder="예: users.view, products.create, orders.delete"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="mt-1 text-sm text-gray-500">영문 소문자, (.), 하이픈(-), 언더스코어(_) 사용</p>
</div>
<!-- Guard 이름 -->
<div class="mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-2">
Guard 이름 <span class="text-red-500">*</span>
</label>
<select name="guard_name"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="web" selected>web</option>
<option value="api">api</option>
</select>
<p class="mt-1 text-sm text-gray-500">기본값: web</p>
</div>
<!-- 테넌트 -->
<div class="mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-2">
테넌트
</label>
<select name="tenant_id"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체 (마스터 권한)</option>
@foreach($tenants as $tenant)
<option value="{{ $tenant->id }}" {{ session('selected_tenant_id') == $tenant->id ? 'selected' : '' }}>
{{ $tenant->company_name }}
</option>
@endforeach
</select>
<p class="mt-1 text-sm text-gray-500">선택하지 않으면 전체 테넌트에서 사용 가능한 마스터 권한</p>
</div>
<!-- 버튼 -->
<div class="flex gap-3">
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition">
생성
</button>
<a href="{{ route('permissions.index') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transition">
취소
</a>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
document.getElementById('permissionForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
fetch('/api/admin/permissions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
window.location.href = '{{ route("permissions.index") }}';
} else {
alert(data.message || '권한 생성에 실패했습니다.');
}
})
.catch(error => {
alert('권한 생성 중 오류가 발생했습니다.');
});
});
</script>
@endpush

View File

@@ -0,0 +1,157 @@
@extends('layouts.app')
@section('title', '권한 수정')
@section('content')
<div class="max-w-3xl mx-auto">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">🛡️ 권한 수정</h1>
<a href="{{ route('permissions.index') }}" class="text-gray-600 hover:text-gray-800">
목록으로
</a>
</div>
<!-- -->
<div id="loadingState" class="bg-white rounded-lg shadow-sm p-6">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
<div id="formContainer" class="bg-white rounded-lg shadow-sm p-6" style="display: none;">
<form id="permissionForm" method="POST">
@csrf
@method('PUT')
<!-- 권한 이름 -->
<div class="mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-2">
권한 이름 <span class="text-red-500">*</span>
</label>
<input type="text"
name="name"
id="name"
required
placeholder="예: users.view, products.create"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="mt-1 text-sm text-gray-500">영문 소문자, (.), 하이픈(-), 언더스코어(_) 사용</p>
</div>
<!-- Guard 이름 -->
<div class="mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-2">
Guard 이름 <span class="text-red-500">*</span>
</label>
<select name="guard_name"
id="guard_name"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="web">web</option>
<option value="api">api</option>
</select>
</div>
<!-- 테넌트 -->
<div class="mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-2">
테넌트
</label>
<select name="tenant_id"
id="tenant_id"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체 (마스터 권한)</option>
@foreach($tenants as $tenant)
<option value="{{ $tenant->id }}">{{ $tenant->company_name }}</option>
@endforeach
</select>
</div>
<!-- 할당된 역할 (읽기 전용) -->
<div class="mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-2">
할당된 역할
</label>
<p id="rolesCount" class="text-gray-900 text-lg font-medium">-</p>
</div>
<!-- 버튼 -->
<div class="flex gap-3">
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition">
수정
</button>
<a href="{{ route('permissions.index') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transition">
취소
</a>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
const permissionId = {{ $id }};
// 권한 정보 로드
fetch(`/api/admin/permissions/${permissionId}`, {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
const permission = data.data;
// 폼 필드 채우기
document.getElementById('name').value = permission.name;
document.getElementById('guard_name').value = permission.guard_name;
document.getElementById('tenant_id').value = permission.tenant_id || '';
document.getElementById('rolesCount').textContent = permission.roles?.length || 0;
// 로딩 숨기고 폼 표시
document.getElementById('loadingState').style.display = 'none';
document.getElementById('formContainer').style.display = 'block';
} else {
alert('권한 정보를 불러올 수 없습니다.');
window.location.href = '{{ route("permissions.index") }}';
}
})
.catch(error => {
alert('권한 정보 로드 중 오류가 발생했습니다.');
window.location.href = '{{ route("permissions.index") }}';
});
// 폼 제출
document.getElementById('permissionForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
fetch(`/api/admin/permissions/${permissionId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
window.location.href = '{{ route("permissions.index") }}';
} else {
alert(data.message || '권한 수정에 실패했습니다.');
}
})
.catch(error => {
alert('권한 수정 중 오류가 발생했습니다.');
});
});
</script>
@endpush

View File

@@ -0,0 +1,92 @@
@extends('layouts.app')
@section('title', '권한 관리')
@section('content')
<!-- Tenant Selector -->
@include('partials.tenant-selector')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mt-6 mb-6">
<h1 class="text-2xl font-bold text-gray-800">🛡️ 권한 관리</h1>
<a href="{{ route('permissions.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
+ 권한
</a>
</div>
<!-- 필터 영역 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<form id="filterForm" class="flex gap-4">
<!-- 검색 -->
<div class="flex-1">
<input type="text"
name="search"
placeholder="권한 이름으로 검색..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<!-- Guard 필터 -->
<div class="w-48">
<select name="guard_name" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체 Guard</option>
<option value="web">web</option>
<option value="api">api</option>
</select>
</div>
<!-- 검색 버튼 -->
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
검색
</button>
</form>
</div>
<!-- 테이블 영역 (HTMX로 로드) -->
<div id="permission-table"
hx-get="/api/admin/permissions"
hx-trigger="load, filterSubmit from:body"
hx-include="#filterForm"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="bg-white rounded-lg shadow-sm overflow-hidden">
<!-- 로딩 스피너 -->
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
@endsection
@push('scripts')
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>
// 폼 제출 시 HTMX 이벤트 트리거
document.getElementById('filterForm').addEventListener('submit', function(e) {
e.preventDefault();
htmx.trigger('#permission-table', 'filterSubmit');
});
// 권한 삭제 확인
function confirmDelete(id, name) {
if (confirm(`"${name}" 권한을 삭제하시겠습니까?\n\n이 권한이 할당된 역할이 있는 경우 삭제할 수 없습니다.`)) {
fetch(`/api/admin/permissions/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
htmx.trigger('#permission-table', 'filterSubmit');
alert(data.message);
} else {
alert(data.message);
}
})
.catch(error => {
alert('권한 삭제 중 오류가 발생했습니다.');
});
}
}
</script>
@endpush

View File

@@ -0,0 +1,195 @@
@php
use App\Models\Commons\Menu;
/**
* 권한명을 파싱하여 메뉴 태그와 권한 타입을 시각적으로 표시
*/
function formatPermissionName(string $permissionName): string
{
// menu:{menu_id}.{permission_type} 패턴 파싱
if (preg_match('/^menu:(\d+)\.(\w+)$/', $permissionName, $matches)) {
$menuId = (int) $matches[1];
$permissionType = $matches[2];
// 메뉴 정보 조회
$menu = Menu::find($menuId);
$menuName = $menu ? $menu->name : '알 수 없는 메뉴';
// 권한 타입별 설정
$permissionConfig = getPermissionConfig($permissionType);
// HTML 생성
$html = '<div class="flex items-center gap-2">';
// 메뉴 태그 (회색 배지)
$html .= sprintf(
'<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-700">메뉴 #%d</span>',
$menuId
);
// 메뉴명
$html .= sprintf(
'<span class="text-sm text-gray-700">%s</span>',
htmlspecialchars($menuName)
);
// 권한 타입 배지
$html .= sprintf(
'<span class="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full %s" title="%s">%s</span>',
$permissionConfig['class'],
$permissionConfig['label'],
$permissionConfig['badge']
);
$html .= '</div>';
return $html;
}
// 패턴이 일치하지 않으면 원본 반환
return '<span class="text-sm text-gray-900">' . htmlspecialchars($permissionName) . '</span>';
}
/**
* 권한 타입별 설정 반환
*/
function getPermissionConfig(string $type): array
{
$configs = [
'view' => [
'badge' => 'V',
'label' => '조회',
'class' => 'bg-blue-100 text-blue-800',
],
'create' => [
'badge' => 'C',
'label' => '생성',
'class' => 'bg-green-100 text-green-800',
],
'update' => [
'badge' => 'U',
'label' => '수정',
'class' => 'bg-orange-100 text-orange-800',
],
'delete' => [
'badge' => 'D',
'label' => '삭제',
'class' => 'bg-red-100 text-red-800',
],
'approve' => [
'badge' => 'A',
'label' => '승인',
'class' => 'bg-purple-100 text-purple-800',
],
'export' => [
'badge' => 'E',
'label' => '내보내기',
'class' => 'bg-sky-100 text-sky-600',
],
'manage' => [
'badge' => 'M',
'label' => '관리',
'class' => 'bg-gray-100 text-gray-800',
],
];
return $configs[$type] ?? [
'badge' => strtoupper(substr($type, 0, 1)),
'label' => $type,
'class' => 'bg-gray-100 text-gray-800',
];
}
@endphp
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">권한명</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">테넌트</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">가드</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">할당된 역할</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">할당된 부서</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">생성일</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">수정일</th>
<th class="px-6 py-3 text-right text-sm font-semibold text-gray-700 uppercase tracking-wider">액션</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($permissions as $permission)
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ $permission->id }}
</td>
<td class="px-6 py-4">
{!! formatPermissionName($permission->name) !!}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $permission->tenant?->company_name ?? '전역' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full {{ $permission->guard_name === 'web' ? 'bg-blue-100 text-blue-800' : 'bg-blue-100 text-blue-800' }}">
{{ $permission->guard_name }}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-900">
@if($permission->roles->isNotEmpty())
<div class="flex flex-wrap gap-1">
@foreach($permission->roles as $role)
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">
{{ $role->name }}
</span>
@endforeach
</div>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-6 py-4 text-sm text-gray-900">
@if($permission->departments->isNotEmpty())
<div class="flex flex-wrap gap-1">
@foreach($permission->departments as $department)
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800">
{{ $department->name }}
</span>
@endforeach
</div>
@else
<span class="text-gray-400">-</span>
@endif
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $permission->created_at?->format('Y-m-d H:i') ?? '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ $permission->updated_at?->format('Y-m-d H:i') ?? '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ route('permissions.edit', $permission->id) }}"
class="text-blue-600 hover:text-blue-900 mr-3">
수정
</a>
<button onclick="confirmDelete({{ $permission->id }}, '{{ $permission->name }}')"
class="text-red-600 hover:text-red-900">
삭제
</button>
</td>
</tr>
@empty
<tr>
<td colspan="9" class="px-6 py-12 text-center text-gray-500">
등록된 권한이 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- 페이지네이션 -->
@include('partials.pagination', [
'paginator' => $permissions,
'target' => '#permission-table',
'includeForm' => '#filterForm'
])