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

@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StorePermissionRequest;
use App\Http\Requests\UpdatePermissionRequest;
use App\Services\PermissionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PermissionController extends Controller
{
public function __construct(
private PermissionService $permissionService
) {}
/**
* 권한 목록 조회
*/
public function index(Request $request)
{
$permissions = $this->permissionService->getPermissions(
$request->all(),
$request->input('per_page', 20)
);
// HTMX 요청 시 부분 HTML 반환
if ($request->header('HX-Request')) {
return view('permissions.partials.table', compact('permissions'));
}
// 일반 요청 시 JSON 반환
return response()->json([
'success' => true,
'data' => $permissions->items(),
'meta' => [
'current_page' => $permissions->currentPage(),
'total' => $permissions->total(),
'per_page' => $permissions->perPage(),
'last_page' => $permissions->lastPage(),
],
]);
}
/**
* 권한 생성
*/
public function store(StorePermissionRequest $request): JsonResponse
{
try {
$permission = $this->permissionService->createPermission($request->validated());
return response()->json([
'success' => true,
'message' => '권한이 생성되었습니다.',
'data' => $permission,
], 201);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 권한 상세 조회
*/
public function show(int $id): JsonResponse
{
$permission = $this->permissionService->getPermissionById($id);
if (! $permission) {
return response()->json([
'success' => false,
'message' => '권한을 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'data' => $permission,
]);
}
/**
* 권한 수정
*/
public function update(UpdatePermissionRequest $request, int $id): JsonResponse
{
try {
$permission = $this->permissionService->updatePermission($id, $request->validated());
return response()->json([
'success' => true,
'message' => '권한이 수정되었습니다.',
'data' => $permission,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
/**
* 권한 삭제
*/
public function destroy(int $id): JsonResponse
{
try {
$this->permissionService->deletePermission($id);
return response()->json([
'success' => true,
'message' => '권한이 삭제되었습니다.',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tenant;
class PermissionController extends Controller
{
/**
* 권한 목록 화면
*/
public function index()
{
return view('permissions.index');
}
/**
* 권한 생성 화면
*/
public function create()
{
$tenants = Tenant::orderBy('company_name')->get();
return view('permissions.create', compact('tenants'));
}
/**
* 권한 수정 화면
*/
public function edit($id)
{
$tenants = Tenant::orderBy('company_name')->get();
return view('permissions.edit', compact('id', 'tenants'));
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Http\Requests;
use App\Services\PermissionService;
use Illuminate\Foundation\Http\FormRequest;
class StorePermissionRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'max:255',
function ($attribute, $value, $fail) {
$guardName = $this->input('guard_name', 'web');
$tenantId = $this->input('tenant_id');
$service = app(PermissionService::class);
if ($service->isNameExists($value, $guardName, null, $tenantId)) {
$fail('이 권한 이름은 이미 사용 중입니다.');
}
},
],
'guard_name' => 'required|string|max:255',
'tenant_id' => 'nullable|exists:tenants,id',
];
}
/**
* Get custom attributes for validator errors.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'name' => '권한 이름',
'guard_name' => '가드 이름',
'tenant_id' => '테넌트',
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'name.required' => '권한 이름을 입력해주세요.',
'name.max' => '권한 이름은 255자를 초과할 수 없습니다.',
'guard_name.required' => '가드 이름을 입력해주세요.',
'tenant_id.exists' => '존재하지 않는 테넌트입니다.',
];
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Http\Requests;
use App\Services\PermissionService;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePermissionRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$permissionId = $this->route('id');
return [
'name' => [
'required',
'string',
'max:255',
function ($attribute, $value, $fail) use ($permissionId) {
$guardName = $this->input('guard_name', 'web');
$tenantId = $this->input('tenant_id');
$service = app(PermissionService::class);
if ($service->isNameExists($value, $guardName, $permissionId, $tenantId)) {
$fail('이 권한 이름은 이미 사용 중입니다.');
}
},
],
'guard_name' => 'required|string|max:255',
'tenant_id' => 'nullable|exists:tenants,id',
];
}
/**
* Get custom attributes for validator errors.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'name' => '권한 이름',
'guard_name' => '가드 이름',
'tenant_id' => '테넌트',
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'name.required' => '권한 이름을 입력해주세요.',
'name.max' => '권한 이름은 255자를 초과할 수 없습니다.',
'guard_name.required' => '가드 이름을 입력해주세요.',
'tenant_id.exists' => '존재하지 않는 테넌트입니다.',
];
}
}

61
app/Models/Permission.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use App\Models\Tenants\Tenant;
use Spatie\Permission\Models\Permission as SpatiePermission;
class Permission extends SpatiePermission
{
protected $fillable = [
'tenant_id',
'name',
'guard_name',
'created_by',
'updated_by',
];
/**
* 테넌트 관계
*/
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
/**
* 이 권한이 할당된 역할 목록 (현재 테넌트만)
*/
public function roles(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
$query = parent::roles();
$currentTenantId = session('selected_tenant_id');
if ($currentTenantId && $currentTenantId !== 'all') {
$query->where('roles.tenant_id', $currentTenantId);
}
return $query;
}
/**
* 이 권한이 직접 할당된 부서 목록 (현재 테넌트만)
*/
public function departments(): \Illuminate\Database\Eloquent\Relations\MorphToMany
{
$query = $this->morphedByMany(
\App\Models\Tenants\Department::class,
'model',
config('permission.table_names.model_has_permissions'),
'permission_id',
'model_id'
);
$currentTenantId = session('selected_tenant_id');
if ($currentTenantId && $currentTenantId !== 'all') {
$query->where('departments.tenant_id', $currentTenantId);
}
return $query;
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace App\Services;
use App\Models\Permission;
use Illuminate\Pagination\LengthAwarePaginator;
class PermissionService
{
/**
* 권한 목록 조회
*/
public function getPermissions(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
$query = Permission::query()->with('tenant', 'roles', 'departments');
// 검색
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
});
}
// 테넌트 필터
$selectedTenantId = session('selected_tenant_id');
if ($selectedTenantId && $selectedTenantId !== 'all') {
$query->where('tenant_id', $selectedTenantId);
} elseif (isset($filters['tenant_id'])) {
$query->where('tenant_id', $filters['tenant_id']);
}
// guard_name 필터
if (! empty($filters['guard_name'])) {
$query->where('guard_name', $filters['guard_name']);
}
return $query->orderBy('id', 'desc')->paginate($perPage);
}
/**
* 특정 권한 조회
*/
public function getPermissionById(int $id): ?Permission
{
return Permission::with('tenant', 'roles')->find($id);
}
/**
* 권한 생성
*/
public function createPermission(array $data): Permission
{
$data['created_by'] = auth()->id();
$data['guard_name'] = $data['guard_name'] ?? 'web';
return Permission::create($data);
}
/**
* 권한 수정
*/
public function updatePermission(int $id, array $data): Permission
{
$permission = Permission::findOrFail($id);
$data['updated_by'] = auth()->id();
$permission->update($data);
return $permission->fresh();
}
/**
* 권한 삭제
*/
public function deletePermission(int $id): bool
{
$permission = Permission::findOrFail($id);
// 할당된 역할이 있는지 확인
if ($permission->roles()->count() > 0) {
throw new \Exception('이 권한이 할당된 역할이 있어 삭제할 수 없습니다.');
}
return $permission->delete();
}
/**
* 권한 이름 중복 체크
*/
public function isNameExists(string $name, string $guardName, ?int $excludeId = null, ?int $tenantId = null): bool
{
$query = Permission::where('name', $name)
->where('guard_name', $guardName);
if ($tenantId !== null) {
$query->where('tenant_id', $tenantId);
}
if ($excludeId) {
$query->where('id', '!=', $excludeId);
}
return $query->exists();
}
/**
* 활성 권한 목록 (드롭다운용)
*/
public function getActivePermissions(?int $tenantId = null): \Illuminate\Database\Eloquent\Collection
{
$query = Permission::query();
if ($tenantId) {
$query->where('tenant_id', $tenantId);
}
return $query->orderBy('name')->get();
}
/**
* 권한 통계
*/
public function getPermissionStats(): array
{
$selectedTenantId = session('selected_tenant_id');
$query = Permission::query();
if ($selectedTenantId && $selectedTenantId !== 'all') {
$query->where('tenant_id', $selectedTenantId);
}
return [
'total' => $query->count(),
'by_guard' => $query->select('guard_name', \DB::raw('count(*) as count'))
->groupBy('guard_name')
->pluck('count', 'guard_name')
->toArray(),
];
}
}

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'
])

View File

@@ -2,6 +2,7 @@
use App\Http\Controllers\Api\Admin\DepartmentController;
use App\Http\Controllers\Api\Admin\MenuController;
use App\Http\Controllers\Api\Admin\PermissionController;
use App\Http\Controllers\Api\Admin\RoleController;
use App\Http\Controllers\Api\Admin\TenantController;
use App\Http\Controllers\Api\Admin\UserController;
@@ -84,4 +85,13 @@
Route::post('/{id}/toggle-active', [MenuController::class, 'toggleActive'])->name('toggleActive');
Route::post('/{id}/toggle-hidden', [MenuController::class, 'toggleHidden'])->name('toggleHidden');
});
});
// 권한 관리 API
Route::prefix('permissions')->name('permissions.')->group(function () {
Route::get('/', [PermissionController::class, 'index'])->name('index');
Route::post('/', [PermissionController::class, 'store'])->name('store');
Route::get('/{id}', [PermissionController::class, 'show'])->name('show');
Route::put('/{id}', [PermissionController::class, 'update'])->name('update');
Route::delete('/{id}', [PermissionController::class, 'destroy'])->name('destroy');
});
});

View File

@@ -3,6 +3,7 @@
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\DepartmentController;
use App\Http\Controllers\MenuController;
use App\Http\Controllers\PermissionController;
use App\Http\Controllers\RoleController;
use App\Http\Controllers\TenantController;
use App\Http\Controllers\UserController;
@@ -66,6 +67,13 @@
Route::get('/{id}/edit', [MenuController::class, 'edit'])->name('edit');
});
// 권한 관리 (Blade 화면만)
Route::prefix('permissions')->name('permissions.')->group(function () {
Route::get('/', [PermissionController::class, 'index'])->name('index');
Route::get('/create', [PermissionController::class, 'create'])->name('create');
Route::get('/{id}/edit', [PermissionController::class, 'edit'])->name('edit');
});
// 대시보드
Route::get('/dashboard', function () {
return view('dashboard.index');