feat(mng): 권한 관리 기능 구현
- Permission 모델 및 PermissionService 생성 (Spatie Permission 확장) - HTMX 기반 권한 CRUD API 구현 - Blade 기반 권한 관리 화면 (index, create, edit) - 권한명 포맷팅 로직 추가 (menu:id.type 파싱) - 사이드바 메뉴 추가 - 멀티테넌트 지원 (tenant_id nullable) - 할당된 역할/부서 표시 기능
This commit is contained in:
127
app/Http/Controllers/Api/Admin/PermissionController.php
Normal file
127
app/Http/Controllers/Api/Admin/PermissionController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/Http/Controllers/PermissionController.php
Normal file
36
app/Http/Controllers/PermissionController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
73
app/Http/Requests/StorePermissionRequest.php
Normal file
73
app/Http/Requests/StorePermissionRequest.php
Normal 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' => '존재하지 않는 테넌트입니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
75
app/Http/Requests/UpdatePermissionRequest.php
Normal file
75
app/Http/Requests/UpdatePermissionRequest.php
Normal 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
61
app/Models/Permission.php
Normal 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;
|
||||
}
|
||||
}
|
||||
142
app/Services/PermissionService.php
Normal file
142
app/Services/PermissionService.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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') }}"
|
||||
|
||||
109
resources/views/permissions/create.blade.php
Normal file
109
resources/views/permissions/create.blade.php
Normal 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
|
||||
157
resources/views/permissions/edit.blade.php
Normal file
157
resources/views/permissions/edit.blade.php
Normal 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
|
||||
92
resources/views/permissions/index.blade.php
Normal file
92
resources/views/permissions/index.blade.php
Normal 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
|
||||
195
resources/views/permissions/partials/table.blade.php
Normal file
195
resources/views/permissions/partials/table.blade.php
Normal 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'
|
||||
])
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user