feat: add app and database modules
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\AccessControl;
|
||||
|
||||
use App\Services\System\ActivityFormatter;
|
||||
use App\Support\DataTable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class ActionLogController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
// Middleware handled in web.php
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (DataTable::isDataTableRequest($request)) {
|
||||
return $this->dataTable($request);
|
||||
}
|
||||
|
||||
return view('pages.access_control.action-logs');
|
||||
}
|
||||
|
||||
protected function dataTable(Request $request)
|
||||
{
|
||||
try {
|
||||
$query = Activity::query()->with('causer');
|
||||
|
||||
// Fast count without eager loading or ordering
|
||||
$recordsTotal = Activity::count();
|
||||
|
||||
$globalSearch = DataTable::globalSearch($request);
|
||||
|
||||
if ($event = $request->input('event')) {
|
||||
if ($event === 'auth') {
|
||||
$query->whereIn('description', ['login', 'logout', 'login_attempt', 'password_changed', 'failed login', 'password reset']);
|
||||
} elseif ($event === 'data') {
|
||||
$query->whereIn('description', ['created', 'updated', 'deleted', 'restored', 'force deleted', 'permanent_deleted']);
|
||||
} elseif ($event === 'system') {
|
||||
$query->whereIn('log_name', ['system', 'maintenance', 'backup']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($user = DataTable::columnSearch($request, 0)) {
|
||||
$query->whereHas('causer', function ($causerQuery) use ($user) {
|
||||
$causerQuery->where(function ($q) use ($user) {
|
||||
$q->where('name', 'like', "%{$user}%")
|
||||
->orWhere('email', 'like', "%{$user}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if ($action = DataTable::columnSearch($request, 1)) {
|
||||
$query->where('description', 'like', "%{$action}%");
|
||||
}
|
||||
|
||||
if ($details = DataTable::columnSearch($request, 2)) {
|
||||
$query->where('properties', 'like', "%{$details}%");
|
||||
}
|
||||
|
||||
if ($module = DataTable::columnSearch($request, 3)) {
|
||||
$query->where('log_name', 'like', "%{$module}%");
|
||||
}
|
||||
|
||||
if ($executedAt = DataTable::columnSearch($request, 4)) {
|
||||
$query->whereDate('created_at', $executedAt);
|
||||
}
|
||||
|
||||
if ($ip = DataTable::columnSearch($request, 5)) {
|
||||
$query->where('properties->ip', 'like', "%{$ip}%");
|
||||
}
|
||||
|
||||
if ($agent = DataTable::columnSearch($request, 6)) {
|
||||
$query->where('properties->agent', 'like', "%{$agent}%");
|
||||
}
|
||||
|
||||
if ($properties = DataTable::columnSearch($request, 7)) {
|
||||
$query->where('properties', 'like', "%{$properties}%");
|
||||
}
|
||||
|
||||
if ($globalSearch) {
|
||||
$query->where(function ($searchQuery) use ($globalSearch) {
|
||||
$searchQuery
|
||||
->where('description', 'like', "%{$globalSearch}%")
|
||||
->orWhere('log_name', 'like', "%{$globalSearch}%")
|
||||
->orWhere('properties', 'like', "%{$globalSearch}%")
|
||||
->orWhereHas('causer', function ($causerQuery) use ($globalSearch) {
|
||||
$causerQuery->where('email', 'like', "%{$globalSearch}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[$orderIndex, $orderDirection] = DataTable::order($request, 4, 'desc');
|
||||
|
||||
$sortColumn = match ($orderIndex) {
|
||||
0 => 'causer_type',
|
||||
1 => 'description',
|
||||
3 => 'log_name',
|
||||
4 => 'created_at',
|
||||
default => 'created_at',
|
||||
};
|
||||
|
||||
// Remove old global ordering and apply datatable specific ordering
|
||||
$query->orderBy($sortColumn, $orderDirection);
|
||||
|
||||
// Perform filtered count WITHOUT eager loading or ordering
|
||||
$countQuery = clone $query;
|
||||
$countQuery->setEagerLoads([]);
|
||||
$countQuery->orders = null;
|
||||
$recordsFiltered = $countQuery->count();
|
||||
|
||||
$logs = $query
|
||||
->skip(DataTable::start($request))
|
||||
->take(DataTable::length($request))
|
||||
->get();
|
||||
|
||||
$rows = $logs->map(function (Activity $log) {
|
||||
$properties = is_array($log->properties) ? $log->properties : $log->properties?->toArray();
|
||||
|
||||
$eventLabel = ucfirst($log->description);
|
||||
$eventBadge = ActivityFormatter::getEventBadgeClass($log->description);
|
||||
$eventIcon = ActivityFormatter::getEventIcon($log->description);
|
||||
$modelName = ActivityFormatter::getFriendlyModelName($log->subject_type);
|
||||
$changes = ActivityFormatter::formatChanges($properties ?? []);
|
||||
|
||||
// User Column (Removed icon)
|
||||
$userHtml = '<div>
|
||||
<div class="fw-bold small">'.e($log->causer?->name ?? 'System').'</div>
|
||||
<div class="text-secondary extra-small">'.e($log->causer?->email ?? 'no-email').'</div>
|
||||
</div>';
|
||||
|
||||
// Event Column
|
||||
$eventHtml = '<span class="badge rounded-pill '.$eventBadge.' px-3 py-2 small">
|
||||
<i class="bi '.$eventIcon.' me-1"></i>'.$eventLabel.'
|
||||
</span>';
|
||||
|
||||
// Information Column (Preview of changes)
|
||||
$infoHtml = '<div class="small text-truncate" style="max-width: 250px;">';
|
||||
if (! empty($changes)) {
|
||||
$first = $changes[0];
|
||||
$infoHtml .= '<strong>'.e($first['field']).':</strong> '.e($first['new']);
|
||||
if (count($changes) > 1) {
|
||||
$infoHtml .= ' <span class="badge text-bg-light border text-dark ms-1">+'.(count($changes) - 1).'</span>';
|
||||
}
|
||||
} else {
|
||||
$infoHtml .= e(data_get($properties, 'details', '-'));
|
||||
}
|
||||
$infoHtml .= '</div>';
|
||||
|
||||
// Logistics Column
|
||||
$ip = data_get($properties, 'ip', '-');
|
||||
$agent = data_get($properties, 'agent', '-');
|
||||
|
||||
// Prepare JSON for modal
|
||||
$modalData = [
|
||||
'causer' => [
|
||||
'name' => $log->causer?->name ?? 'System',
|
||||
'email' => $log->causer?->email ?? '-',
|
||||
],
|
||||
'event' => [
|
||||
'label' => $eventLabel,
|
||||
'badge' => $eventBadge,
|
||||
'icon' => $eventIcon,
|
||||
'description' => $log->description,
|
||||
],
|
||||
'subject' => [
|
||||
'type' => $modelName,
|
||||
'id' => $log->subject_id,
|
||||
'module' => $log->log_name,
|
||||
],
|
||||
'changes' => $changes,
|
||||
'meta' => [
|
||||
'ip' => $ip,
|
||||
'agent' => $agent,
|
||||
'time' => format_datetime($log->created_at),
|
||||
],
|
||||
'raw' => $log->toArray(),
|
||||
];
|
||||
|
||||
return [
|
||||
$userHtml,
|
||||
$eventHtml,
|
||||
$infoHtml,
|
||||
'<span class="badge text-bg-theme-1-subtle text-theme-1">'.e($modelName).'</span>',
|
||||
e(format_datetime($log->created_at)),
|
||||
'<code class="extra-small">'.e($ip).'</code>',
|
||||
'<span class="text-secondary extra-small text-truncate d-block" style="max-width:150px;" title="'.e($agent).'">'.e($agent).'</span>',
|
||||
'<pre class="mb-0 extra-small text-secondary" style="max-height: 50px; overflow: hidden;">'.e(json_encode($properties)).'</pre>',
|
||||
'<div class="text-end">
|
||||
<button class="btn btn-square btn-outline-theme btn-sm rounded-circle btn-detail-log"
|
||||
data-activity=\''.e(json_encode($modalData, JSON_HEX_APOS | JSON_HEX_QUOT)).'\'>
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>',
|
||||
];
|
||||
})->all();
|
||||
|
||||
return DataTable::response($request, $recordsTotal, $recordsFiltered, $rows);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('DataTable Error [ActionLog]: '.$e->getMessage());
|
||||
|
||||
return DataTable::response($request, 0, 0, []);
|
||||
}
|
||||
}
|
||||
|
||||
public function clear()
|
||||
{
|
||||
try {
|
||||
DB::table('activity_log')->truncate();
|
||||
|
||||
return response()->json(['success' => true, 'message' => __('Action logs cleared successfully.')]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['success' => false, 'message' => __('Failed to clear logs.')], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function export(Request $request)
|
||||
{
|
||||
$query = Activity::query()->with('causer')->latest();
|
||||
|
||||
// Apply same filters as dataTable
|
||||
if ($event = $request->input('event')) {
|
||||
if ($event === 'auth') {
|
||||
$query->whereIn('description', ['login', 'logout', 'login_attempt', 'password_changed', 'failed login', 'password reset']);
|
||||
} elseif ($event === 'data') {
|
||||
$query->whereIn('description', ['created', 'updated', 'deleted', 'restored', 'force deleted', 'permanent_deleted']);
|
||||
} elseif ($event === 'system') {
|
||||
$query->whereIn('log_name', ['system', 'maintenance', 'backup']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('description', 'like', "%{$search}%")
|
||||
->orWhere('log_name', 'like', "%{$search}%")
|
||||
->orWhere('properties', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$filename = 'action-logs-'.now()->format('Y-m-d-His').'.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($query) {
|
||||
$file = fopen('php://output', 'w');
|
||||
fputcsv($file, ['User', 'Action', 'Module', 'Executed At', 'IP Address', 'User Agent', 'Properties']);
|
||||
|
||||
$query->chunk(200, function ($logs) use ($file) {
|
||||
foreach ($logs as $log) {
|
||||
fputcsv($file, [
|
||||
$log->causer?->name ?? 'System',
|
||||
ucfirst($log->description),
|
||||
$log->log_name,
|
||||
$log->created_at->toDateTimeString(),
|
||||
data_get($log->properties, 'ip', '-'),
|
||||
data_get($log->properties, 'agent', '-'),
|
||||
json_encode($log->properties),
|
||||
]);
|
||||
}
|
||||
});
|
||||
fclose($file);
|
||||
}, $filename);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ============================================================
|
||||
*
|
||||
* @project biiproject
|
||||
*
|
||||
* @author Andika Debi Putra
|
||||
*
|
||||
* @email andikadebiputra@gmail.com
|
||||
*
|
||||
* @website https://biiproject.com
|
||||
*
|
||||
* @copyright Copyright (c) 2026 Andika Debi Putra
|
||||
* @license Proprietary - All Rights Reserved
|
||||
*
|
||||
* @version 1.0.0
|
||||
*
|
||||
* @created 2026-05-01
|
||||
* ============================================================
|
||||
*
|
||||
* Unauthorized copying, modification, distribution, or use
|
||||
* of this file is strictly prohibited without prior written
|
||||
* permission from the author.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\AccessControl;
|
||||
|
||||
use App\Models\Permission;
|
||||
use App\Support\DataTable;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class PermissionManagementController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
// Middleware handled in web.php
|
||||
}
|
||||
|
||||
// show all permissions with relations
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (DataTable::isDataTableRequest($request)) {
|
||||
return $this->dataTable($request);
|
||||
}
|
||||
|
||||
// render view with data
|
||||
return view('pages.access_control.permissions');
|
||||
}
|
||||
|
||||
protected function dataTable(Request $request)
|
||||
{
|
||||
try {
|
||||
$query = Permission::query()->with(['roles:id,name', 'creator:id,email', 'updater:id,email']);
|
||||
$recordsTotal = Permission::count();
|
||||
$globalSearch = DataTable::globalSearch($request);
|
||||
|
||||
$authUser = $request->user();
|
||||
$canManage = $authUser->can('manage access rights');
|
||||
|
||||
if ($canManage) {
|
||||
$status = DataTable::columnSearch($request, 0);
|
||||
$name = DataTable::columnSearch($request, 1);
|
||||
$guard = DataTable::columnSearch($request, 2);
|
||||
$role = DataTable::columnSearch($request, 3);
|
||||
} else {
|
||||
$status = null;
|
||||
$name = DataTable::columnSearch($request, 0);
|
||||
$guard = DataTable::columnSearch($request, 1);
|
||||
$role = DataTable::columnSearch($request, 2);
|
||||
}
|
||||
|
||||
if ($status) {
|
||||
$query->where('is_active', $status === 'active');
|
||||
}
|
||||
|
||||
if ($name) {
|
||||
$query->where('name', 'like', "%{$name}%");
|
||||
}
|
||||
|
||||
if ($guard) {
|
||||
$query->where('guard_name', 'like', "%{$guard}%");
|
||||
}
|
||||
|
||||
if ($role) {
|
||||
$query->whereHas('roles', function ($roleQuery) use ($role) {
|
||||
$roleQuery->where('name', 'like', "%{$role}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($canManage) {
|
||||
$createdAt = DataTable::columnSearch($request, 4);
|
||||
$createdBy = DataTable::columnSearch($request, 5);
|
||||
$updatedAt = DataTable::columnSearch($request, 6);
|
||||
$updatedBy = DataTable::columnSearch($request, 7);
|
||||
} else {
|
||||
$createdAt = DataTable::columnSearch($request, 3);
|
||||
$createdBy = DataTable::columnSearch($request, 4);
|
||||
$updatedAt = DataTable::columnSearch($request, 5);
|
||||
$updatedBy = DataTable::columnSearch($request, 6);
|
||||
}
|
||||
|
||||
if ($createdAt) {
|
||||
$query->whereDate('created_at', $createdAt);
|
||||
}
|
||||
|
||||
if ($createdBy) {
|
||||
$query->whereHas('creator', function ($creatorQuery) use ($createdBy) {
|
||||
$creatorQuery->where('email', 'like', "%{$createdBy}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($updatedAt) {
|
||||
$query->whereDate('updated_at', $updatedAt);
|
||||
}
|
||||
|
||||
if ($updatedBy) {
|
||||
$query->whereHas('updater', function ($updaterQuery) use ($updatedBy) {
|
||||
$updaterQuery->where('email', 'like', "%{$updatedBy}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($globalSearch) {
|
||||
$query->where(function ($searchQuery) use ($globalSearch) {
|
||||
$searchQuery
|
||||
->where('name', 'like', "%{$globalSearch}%")
|
||||
->orWhere('guard_name', 'like', "%{$globalSearch}%")
|
||||
->orWhereHas('roles', function ($roleQuery) use ($globalSearch) {
|
||||
$roleQuery->where('name', 'like', "%{$globalSearch}%");
|
||||
})
|
||||
->orWhereHas('creator', function ($creatorQuery) use ($globalSearch) {
|
||||
$creatorQuery->where('email', 'like', "%{$globalSearch}%");
|
||||
})
|
||||
->orWhereHas('updater', function ($updaterQuery) use ($globalSearch) {
|
||||
$updaterQuery->where('email', 'like', "%{$globalSearch}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Perform filtered count WITHOUT eager loading or ordering
|
||||
$countQuery = clone $query;
|
||||
$countQuery->setEagerLoads([]);
|
||||
$countQuery->orders = null;
|
||||
$recordsFiltered = $countQuery->count();
|
||||
[$orderIndex, $orderDirection] = DataTable::order($request, $canManage ? 4 : 3, 'desc');
|
||||
|
||||
$sortColumn = match (true) {
|
||||
$canManage && $orderIndex === 0 => 'is_active',
|
||||
($canManage && $orderIndex === 1) || (! $canManage && $orderIndex === 0) => 'name',
|
||||
($canManage && $orderIndex === 2) || (! $canManage && $orderIndex === 1) => 'guard_name',
|
||||
($canManage && $orderIndex === 4) || (! $canManage && $orderIndex === 3) => 'created_at',
|
||||
($canManage && $orderIndex === 6) || (! $canManage && $orderIndex === 5) => 'updated_at',
|
||||
default => 'created_at',
|
||||
};
|
||||
|
||||
$permissions = $query
|
||||
->orderBy($sortColumn, $orderDirection)
|
||||
->skip(DataTable::start($request))
|
||||
->take(DataTable::length($request))
|
||||
->get();
|
||||
|
||||
$rows = $permissions->map(function (Permission $permission) use ($canManage) {
|
||||
$row = [];
|
||||
|
||||
if ($canManage) {
|
||||
$row[] = sprintf(
|
||||
'<div class="form-check form-switch d-flex align-items-center gap-2"><input class="form-check-input permission-toggle" type="checkbox" %s data-id="%d" data-name="%s"><span class="status-label %s">%s</span></div>',
|
||||
$permission->is_active ? 'checked' : '',
|
||||
$permission->id,
|
||||
e($permission->name),
|
||||
$permission->is_active ? 'text-success' : 'text-danger',
|
||||
$permission->is_active ? 'Active' : 'Inactive'
|
||||
);
|
||||
}
|
||||
|
||||
$roleNames = $permission->roles->pluck('name');
|
||||
$roleBadges = $roleNames->isNotEmpty()
|
||||
? $roleNames->map(fn ($role) => '<span class="badge badge-slate-pill">'.e($role).'</span>')->implode(' ')
|
||||
: '-';
|
||||
|
||||
$row[] = e($permission->name);
|
||||
$row[] = e($permission->guard_name);
|
||||
$row[] = $roleBadges;
|
||||
$row[] = e(format_datetime($permission->created_at));
|
||||
$row[] = e($permission->creator->email ?? 'System');
|
||||
$row[] = e(format_datetime($permission->updated_at));
|
||||
$row[] = e($permission->updater->email ?? '-');
|
||||
|
||||
if ($canManage) {
|
||||
$row[] = '<div class="text-end">'
|
||||
.'<button class="btn btn-link btn-edit" data-id="'.$permission->id.'" data-name="'.e($permission->name).'" data-guard="'.e($permission->guard_name).'" data-created="'.e(format_datetime($permission->created_at)).'" data-created-by="'.e($permission->creator->email ?? 'System').'" data-updated-at="'.e(format_datetime($permission->updated_at)).'" data-updated-by="'.e($permission->updater->email ?? '-').'" data-bs-toggle="modal" data-bs-target="#editPermissionModal"><i class="bi bi-pencil"></i></button>'
|
||||
.'<button class="btn btn-link text-danger btn-delete" data-id="'.$permission->id.'" data-name="'.e($permission->name).'"><i class="bi bi-trash"></i></button>'
|
||||
.'</div>';
|
||||
}
|
||||
|
||||
return $row;
|
||||
})->all();
|
||||
|
||||
return DataTable::response($request, $recordsTotal, $recordsFiltered, $rows);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('DataTable Error [PermissionManagement]: '.$e->getMessage());
|
||||
|
||||
return DataTable::response($request, 0, 0, []);
|
||||
}
|
||||
}
|
||||
|
||||
// create new permission
|
||||
public function store(Request $request)
|
||||
{
|
||||
// validate input fields
|
||||
$request->validate([
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:3',
|
||||
'max:100',
|
||||
'regex:/^[a-zA-Z0-9_\-\.\/]+$/',
|
||||
Rule::unique('permissions', 'name')
|
||||
->where(fn ($query) => $query->where('guard_name', $request->guard_name)
|
||||
),
|
||||
],
|
||||
'guard_name' => [
|
||||
'required',
|
||||
'string',
|
||||
'in:web,api',
|
||||
],
|
||||
], [
|
||||
'name.required' => __('Permission name is required.'),
|
||||
'name.regex' => __('Permission name contains invalid characters.'),
|
||||
'name.unique' => __('A permission with this Name & Guard combination already exists.'),
|
||||
'guard_name.required' => __('Guard is required.'),
|
||||
'guard_name.in' => __('Invalid guard selected.'),
|
||||
]);
|
||||
|
||||
try {
|
||||
// save permission record
|
||||
Permission::create([
|
||||
'name' => $request->name,
|
||||
'guard_name' => $request->guard_name,
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
// success feedback
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Permission has been successfully created.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Permission has been successfully created.'));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Failed to create permission: ').$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('error', __('Failed to create permission: ').$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// update permission by id
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
|
||||
// validate request input
|
||||
$request->validate([
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:3',
|
||||
'max:100',
|
||||
'regex:/^[a-zA-Z0-9_\-\.\/]+$/',
|
||||
Rule::unique('permissions', 'name')
|
||||
->where(fn ($query) => $query->where('guard_name', $request->guard_name)
|
||||
)
|
||||
->ignore($id),
|
||||
],
|
||||
'guard_name' => [
|
||||
'required',
|
||||
'string',
|
||||
'in:web,api',
|
||||
],
|
||||
], [
|
||||
'name.required' => __('Permission name is required.'),
|
||||
'name.regex' => __('Permission name contains invalid characters.'),
|
||||
'name.unique' => __('A permission with this Name & Guard combination is already registered.'),
|
||||
'guard_name.required' => __('Guard is required.'),
|
||||
'guard_name.in' => __('Invalid guard selected.'),
|
||||
]);
|
||||
|
||||
try {
|
||||
// find permission record
|
||||
$permission = Permission::findOrFail($id);
|
||||
|
||||
// update permission values
|
||||
$permission->update([
|
||||
'name' => $request->name,
|
||||
'guard_name' => $request->guard_name,
|
||||
'updated_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
// success response
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Permission has been successfully updated.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Permission has been successfully updated.'));
|
||||
|
||||
} catch (QueryException $e) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Failed to update permission: ').$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('error', __('Failed to update permission: ').$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// toggle active or inactive status
|
||||
public function toggleStatus(Request $request)
|
||||
{
|
||||
// validate status request
|
||||
$request->validate([
|
||||
'id' => 'required|exists:permissions,id',
|
||||
'status' => 'required|in:activate,deactivate',
|
||||
]);
|
||||
|
||||
$permission = Permission::findOrFail($request->id);
|
||||
|
||||
$permission->update([
|
||||
'is_active' => $request->status === 'activate' ? 1 : 0,
|
||||
'updated_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
// respond to client (ajax)
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Permission status updated successfully.'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$permission = Permission::findOrFail($id);
|
||||
$permission->delete(); // soft delete (not permanent)
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Permission has been archived.'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ============================================================
|
||||
*
|
||||
* @project biiproject
|
||||
*
|
||||
* @author Andika Debi Putra
|
||||
*
|
||||
* @email andikadebiputra@gmail.com
|
||||
*
|
||||
* @website https://biiproject.com
|
||||
*
|
||||
* @copyright Copyright (c) 2026 Andika Debi Putra
|
||||
* @license Proprietary - All Rights Reserved
|
||||
*
|
||||
* @version 1.0.0
|
||||
*
|
||||
* @created 2026-05-01
|
||||
* ============================================================
|
||||
*
|
||||
* Unauthorized copying, modification, distribution, or use
|
||||
* of this file is strictly prohibited without prior written
|
||||
* permission from the author.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\AccessControl;
|
||||
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Support\DataTable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class RoleManagementController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
// Middleware handled in web.php
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (DataTable::isDataTableRequest($request)) {
|
||||
return $this->dataTable($request);
|
||||
}
|
||||
|
||||
$permissions = Permission::where('is_active', 1)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Categorize permissions for UI Matrix
|
||||
$groupedPermissions = $this->groupPermissions($permissions);
|
||||
|
||||
return view('pages.access_control.roles', [
|
||||
'permissions' => $permissions,
|
||||
'groupedPermissions' => $groupedPermissions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group permissions into a hierarchical tree: category → menu → tabs.
|
||||
*
|
||||
* Each entry in $groups[$category][$baseName] has:
|
||||
* 'view' => Permission|null (menu-level view)
|
||||
* 'manage' => Permission|null (menu-level manage)
|
||||
* 'tabs' => [
|
||||
* $tabSlug => ['view' => Permission|null, 'manage' => Permission|null]
|
||||
* ]
|
||||
*/
|
||||
protected function groupPermissions($permissions)
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
$name = $permission->name;
|
||||
|
||||
// Determine category
|
||||
$category = match (true) {
|
||||
str_contains($name, 'ai ') => 'AI Intelligence',
|
||||
str_contains($name, 'dashboard') => 'Dashboard',
|
||||
str_contains($name, 'user directory') => 'User Directory',
|
||||
str_contains($name, 'access rights') => 'Access Rights',
|
||||
str_contains($name, 'health and logs') => 'Health & Logs',
|
||||
str_contains($name, 'action history') => 'Action History',
|
||||
str_contains($name, 'global settings') => 'Global Settings',
|
||||
str_contains($name, 'mobile settings') => 'Mobile Settings',
|
||||
str_contains($name, 'active sessions') => 'Active Sessions',
|
||||
str_contains($name, 'notification center') => 'Notification Center',
|
||||
str_contains($name, 'impersonate') => 'Impersonation',
|
||||
str_contains($name, 'backup') || str_contains($name, 'maintenance') => 'System Config',
|
||||
default => 'Other',
|
||||
};
|
||||
|
||||
// Strip action prefix
|
||||
$type = 'other';
|
||||
$resource = $name;
|
||||
if (str_starts_with($name, 'manage ')) {
|
||||
$type = 'manage';
|
||||
$resource = substr($name, 7);
|
||||
} elseif (str_starts_with($name, 'view ')) {
|
||||
$type = 'view';
|
||||
$resource = substr($name, 5);
|
||||
}
|
||||
|
||||
// Detect scoped (tab-level) permission: "resource:tab-slug"
|
||||
if (str_contains($resource, ':')) {
|
||||
[$menu, $tab] = explode(':', $resource, 2);
|
||||
$menu = trim($menu);
|
||||
$tab = trim($tab);
|
||||
|
||||
if (! isset($groups[$category][$menu])) {
|
||||
$groups[$category][$menu] = ['view' => null, 'manage' => null, 'tabs' => []];
|
||||
}
|
||||
if (! isset($groups[$category][$menu]['tabs'][$tab])) {
|
||||
$groups[$category][$menu]['tabs'][$tab] = ['view' => null, 'manage' => null];
|
||||
}
|
||||
$groups[$category][$menu]['tabs'][$tab][$type] = $permission;
|
||||
} else {
|
||||
$menu = trim($resource);
|
||||
if (! isset($groups[$category][$menu])) {
|
||||
$groups[$category][$menu] = ['view' => null, 'manage' => null, 'tabs' => []];
|
||||
}
|
||||
$groups[$category][$menu][$type] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort categories
|
||||
$priority = ['Dashboard', 'User Directory', 'Access Rights', 'AI Intelligence', 'Health & Logs', 'Action History', 'Global Settings', 'Mobile Settings', 'Notification Center', 'Active Sessions', 'Impersonation', 'System Config'];
|
||||
uksort($groups, function ($a, $b) use ($priority) {
|
||||
$posA = array_search($a, $priority);
|
||||
$posB = array_search($b, $priority);
|
||||
if ($posA === false && $posB === false) {
|
||||
return strcmp($a, $b);
|
||||
}
|
||||
return ($posA === false) ? 1 : (($posB === false) ? -1 : $posA - $posB);
|
||||
});
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
protected function dataTable(Request $request)
|
||||
{
|
||||
try {
|
||||
$authUser = $request->user();
|
||||
$canManage = $authUser->can('manage access rights');
|
||||
$query = Role::query()->withTrashed()->with(['permissions:id,name', 'creator:id,email', 'updater:id,email']);
|
||||
$globalSearch = DataTable::globalSearch($request);
|
||||
|
||||
$trashed = $request->input('trashed');
|
||||
if ($trashed === 'archived') {
|
||||
$query->onlyTrashed();
|
||||
} elseif ($trashed === 'active') {
|
||||
$query->withoutTrashed();
|
||||
}
|
||||
|
||||
if ($canManage) {
|
||||
$status = DataTable::columnSearch($request, 1);
|
||||
$name = DataTable::columnSearch($request, 2);
|
||||
$guard = DataTable::columnSearch($request, 3);
|
||||
$permission = DataTable::columnSearch($request, 4);
|
||||
} else {
|
||||
$status = null;
|
||||
$name = DataTable::columnSearch($request, 0);
|
||||
$guard = DataTable::columnSearch($request, 1);
|
||||
$permission = DataTable::columnSearch($request, 2);
|
||||
}
|
||||
|
||||
if ($status) {
|
||||
$query->where('is_active', $status === 'active');
|
||||
}
|
||||
|
||||
if ($name) {
|
||||
$query->where('name', 'like', "%{$name}%");
|
||||
}
|
||||
|
||||
if ($guard) {
|
||||
$query->where('guard_name', 'like', "%{$guard}%");
|
||||
}
|
||||
|
||||
if ($permission) {
|
||||
$query->whereHas('permissions', function ($permissionQuery) use ($permission) {
|
||||
$permissionQuery->where('name', 'like', "%{$permission}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Audit columns (fixed indices from the end or just using canManage logic)
|
||||
if ($canManage) {
|
||||
$createdAt = DataTable::columnSearch($request, 5);
|
||||
$createdBy = DataTable::columnSearch($request, 6);
|
||||
$updatedAt = DataTable::columnSearch($request, 7);
|
||||
$updatedBy = DataTable::columnSearch($request, 8);
|
||||
} else {
|
||||
$createdAt = DataTable::columnSearch($request, 3);
|
||||
$createdBy = DataTable::columnSearch($request, 4);
|
||||
$updatedAt = DataTable::columnSearch($request, 5);
|
||||
$updatedBy = DataTable::columnSearch($request, 6);
|
||||
}
|
||||
|
||||
if ($createdAt) {
|
||||
$query->whereDate('created_at', $createdAt);
|
||||
}
|
||||
|
||||
if ($createdBy) {
|
||||
$query->whereHas('creator', function ($creatorQuery) use ($createdBy) {
|
||||
$creatorQuery->where('email', 'like', "%{$createdBy}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($updatedAt) {
|
||||
$query->whereDate('updated_at', $updatedAt);
|
||||
}
|
||||
|
||||
if ($updatedBy) {
|
||||
$query->whereHas('updater', function ($updaterQuery) use ($updatedBy) {
|
||||
$updaterQuery->where('email', 'like', "%{$updatedBy}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($globalSearch) {
|
||||
$query->where(function ($searchQuery) use ($globalSearch) {
|
||||
$searchQuery
|
||||
->where('name', 'like', "%{$globalSearch}%")
|
||||
->orWhere('guard_name', 'like', "%{$globalSearch}%")
|
||||
->orWhereHas('permissions', function ($permissionQuery) use ($globalSearch) {
|
||||
$permissionQuery->where('name', 'like', "%{$globalSearch}%");
|
||||
})
|
||||
->orWhereHas('creator', function ($creatorQuery) use ($globalSearch) {
|
||||
$creatorQuery->where('email', 'like', "%{$globalSearch}%");
|
||||
})
|
||||
->orWhereHas('updater', function ($updaterQuery) use ($globalSearch) {
|
||||
$updaterQuery->where('email', 'like', "%{$globalSearch}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Perform filtered count WITHOUT eager loading or ordering
|
||||
$countQuery = clone $query;
|
||||
$countQuery->setEagerLoads([]);
|
||||
$countQuery->orders = null;
|
||||
$recordsFiltered = $countQuery->count();
|
||||
|
||||
$recordsTotal = Role::withTrashed()->count(); // Total across all states without eager loads
|
||||
|
||||
[$orderIndex, $orderDirection] = DataTable::order($request, $canManage ? 5 : 3, 'desc');
|
||||
|
||||
$sortColumn = match (true) {
|
||||
$canManage && $orderIndex === 1 => 'is_active',
|
||||
($canManage && $orderIndex === 2) || (! $canManage && $orderIndex === 0) => 'name',
|
||||
($canManage && $orderIndex === 3) || (! $canManage && $orderIndex === 1) => 'guard_name',
|
||||
($canManage && $orderIndex === 5) || (! $canManage && $orderIndex === 3) => 'created_at',
|
||||
($canManage && $orderIndex === 7) || (! $canManage && $orderIndex === 5) => 'updated_at',
|
||||
default => 'created_at',
|
||||
};
|
||||
|
||||
$roles = $query
|
||||
->orderBy($sortColumn, $orderDirection)
|
||||
->skip(DataTable::start($request))
|
||||
->take(DataTable::length($request))
|
||||
->get();
|
||||
|
||||
$rows = $roles->map(function (Role $role) use ($canManage) {
|
||||
$permissionNames = $role->permissions->pluck('name')->values();
|
||||
$permissionBadges = $permissionNames->isNotEmpty()
|
||||
? $permissionNames->map(fn ($permission) => '<span class="badge badge-slate-pill">'.e($permission).'</span>')->implode(' ')
|
||||
: '-';
|
||||
|
||||
$row = [];
|
||||
|
||||
if ($canManage) {
|
||||
$row[] = sprintf(
|
||||
'<input type="checkbox" class="form-check-input role-checkbox" value="%d">',
|
||||
$role->id
|
||||
);
|
||||
$row[] = sprintf(
|
||||
'<div class="form-check form-switch d-flex align-items-center gap-2"><input class="form-check-input role-toggle" type="checkbox" %s data-id="%d" data-name="%s"><span class="status-label %s">%s</span></div>',
|
||||
$role->is_active ? 'checked' : '',
|
||||
$role->id,
|
||||
e($role->name),
|
||||
$role->is_active ? 'text-success' : 'text-danger',
|
||||
$role->is_active ? 'Active' : 'Inactive'
|
||||
);
|
||||
}
|
||||
|
||||
$row[] = e($role->name);
|
||||
$row[] = e($role->guard_name);
|
||||
$row[] = '<div class="permission-clamp" title="'.e($permissionNames->implode(', ')).'">'.$permissionBadges.'</div>';
|
||||
$row[] = e(format_datetime($role->created_at));
|
||||
$row[] = e($role->creator->email ?? 'System');
|
||||
$row[] = e(format_datetime($role->updated_at));
|
||||
$row[] = e($role->updater->email ?? '-');
|
||||
|
||||
if ($canManage) {
|
||||
if ($role->trashed()) {
|
||||
$row[] = '<div class="text-end d-inline-flex gap-2 justify-content-end w-100">'
|
||||
.'<button class="btn btn-link text-success btn-restore" data-id="'.$role->id.'" data-name="'.e($role->name).'"><i class="bi bi-arrow-counterclockwise"></i> '.e(__('Restore')).'</button>'
|
||||
.'<button class="btn btn-link text-danger btn-force-delete" data-id="'.$role->id.'" data-name="'.e($role->name).'"><i class="bi bi-x-circle"></i> '.e(__('Terminate')).'</button>'
|
||||
.'</div>';
|
||||
} else {
|
||||
$row[] = '<div class="text-end d-inline-flex gap-2 justify-content-end w-100">'
|
||||
.'<button class="btn btn-link btn-edit" data-id="'.$role->id.'" data-name="'.e($role->name).'" data-guard="'.e($role->guard_name).'" data-permissions="'.e(json_encode($role->permissions->pluck('id')->values()->all())).'" data-created="'.e(format_datetime($role->created_at)).'" data-created-by="'.e($role->creator->email ?? 'System').'" data-updated-at="'.e(format_datetime($role->updated_at)).'" data-updated-by="'.e($role->updater->email ?? '-').'" data-bs-toggle="modal" data-bs-target="#editRoleModal"><i class="bi bi-pencil"></i></button>'
|
||||
.'<button class="btn btn-link text-danger btn-delete" type="button" data-id="'.$role->id.'" data-name="'.e($role->name).'"><i class="bi bi-trash"></i></button>'
|
||||
.'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
return $row;
|
||||
})->all();
|
||||
|
||||
return DataTable::response($request, $recordsTotal, $recordsFiltered, $rows);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('DataTable Error [RoleManagement]: '.$e->getMessage());
|
||||
|
||||
return DataTable::response($request, 0, 0, []);
|
||||
}
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:3',
|
||||
'max:50',
|
||||
'regex:/^[a-zA-Z0-9_\-]+$/',
|
||||
Rule::unique('roles', 'name'),
|
||||
],
|
||||
'guard_name' => [
|
||||
'required',
|
||||
'in:web,api',
|
||||
],
|
||||
'permissions' => [
|
||||
'required',
|
||||
'array',
|
||||
'min:1',
|
||||
],
|
||||
'permissions.*' => [
|
||||
Rule::exists('permissions', 'id'),
|
||||
],
|
||||
]);
|
||||
|
||||
try {
|
||||
$role = Role::create([
|
||||
'name' => $request->name,
|
||||
'guard_name' => $request->guard_name,
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
$permissionNames = Permission::whereIn('id', $request->permissions)
|
||||
->pluck('name')
|
||||
->toArray();
|
||||
|
||||
$role->syncPermissions($permissionNames);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Role has been successfully created and permissions assigned.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', __('Role has been successfully created and permissions assigned.'));
|
||||
} catch (\Exception $e) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Failed to create role: ').$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
return back()->with('error', __('Failed to create role: ').$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:3',
|
||||
'max:50',
|
||||
'regex:/^[a-zA-Z0-9_\-]+$/',
|
||||
Rule::unique('roles', 'name')->ignore($id),
|
||||
],
|
||||
'guard_name' => [
|
||||
'required',
|
||||
'in:web,api',
|
||||
],
|
||||
'permissions' => [
|
||||
'required',
|
||||
'array',
|
||||
'min:1',
|
||||
],
|
||||
'permissions.*' => [
|
||||
Rule::exists('permissions', 'id'),
|
||||
],
|
||||
]);
|
||||
|
||||
try {
|
||||
$role = Role::findOrFail($id);
|
||||
|
||||
$role->update([
|
||||
'name' => $request->name,
|
||||
'guard_name' => $request->guard_name,
|
||||
'updated_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
$permissionNames = Permission::whereIn('id', $request->permissions)
|
||||
->pluck('name')
|
||||
->toArray();
|
||||
|
||||
$role->syncPermissions($permissionNames);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Role successfully updated.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', __('Role successfully updated.'));
|
||||
} catch (\Exception $e) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Failed to update role: ').$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
return back()->with('error', __('Failed to update role: ').$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleStatus(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'id' => 'required|exists:roles,id',
|
||||
'status' => 'required|in:activate,deactivate',
|
||||
]);
|
||||
|
||||
$role = Role::findOrFail($request->id);
|
||||
|
||||
$role->update([
|
||||
'is_active' => $request->status === 'activate' ? 1 : 0,
|
||||
'updated_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Role status updated successfully.'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$role = Role::findOrFail($id);
|
||||
|
||||
// Check if role is still used by users
|
||||
if ($role->users()->exists()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Cannot archive. This role is still assigned to some users.'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$role->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Role has been archived.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* RESTORE — Restore role (Soft Delete)
|
||||
*/
|
||||
public function restore($id)
|
||||
{
|
||||
$role = Role::withTrashed()->findOrFail($id);
|
||||
$role->restore();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Role has been restored.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* FORCE DELETE — Permanent removal
|
||||
*/
|
||||
public function forceDelete($id)
|
||||
{
|
||||
$role = Role::withTrashed()->findOrFail($id);
|
||||
|
||||
// Final usage check before permanent deletion
|
||||
if ($role->users()->exists()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Cannot delete permanently. This role is still assigned to some users.'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$role->forceDelete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Role has been permanently removed.'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function bulkToggleStatus(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'ids' => 'required|array',
|
||||
'ids.*' => 'exists:roles,id',
|
||||
'status' => 'required|in:activate,deactivate',
|
||||
]);
|
||||
|
||||
Role::whereIn('id', $request->ids)->update([
|
||||
'is_active' => $request->status === 'activate' ? 1 : 0,
|
||||
'updated_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Selected roles status updated successfully.'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function bulkDelete(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'ids' => 'required|array',
|
||||
'ids.*' => 'exists:roles,id',
|
||||
]);
|
||||
|
||||
$roles = Role::whereIn('id', $request->ids)->get();
|
||||
$archivedCount = 0;
|
||||
$failedIds = [];
|
||||
|
||||
foreach ($roles as $role) {
|
||||
if (! $role->users()->exists()) {
|
||||
$role->delete();
|
||||
$archivedCount++;
|
||||
} else {
|
||||
$failedIds[] = $role->name;
|
||||
}
|
||||
}
|
||||
|
||||
$message = __(':count roles archived.', ['count' => $archivedCount]);
|
||||
if (! empty($failedIds)) {
|
||||
$message .= ' '.__('Could not archive :names because they are still assigned to users.', ['names' => implode(', ', $failedIds)]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
public function bulkRestore(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'ids' => 'required|array',
|
||||
'ids.*' => 'exists:roles,id',
|
||||
]);
|
||||
|
||||
Role::withTrashed()->whereIn('id', $request->ids)->restore();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Selected roles restored successfully.'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function bulkForceDelete(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'ids' => 'required|array',
|
||||
'ids.*' => 'exists:roles,id',
|
||||
]);
|
||||
|
||||
$roles = Role::withTrashed()->whereIn('id', $request->ids)->get();
|
||||
$deletedCount = 0;
|
||||
$failedIds = [];
|
||||
|
||||
foreach ($roles as $role) {
|
||||
if (! $role->users()->exists()) {
|
||||
$role->forceDelete();
|
||||
$deletedCount++;
|
||||
} else {
|
||||
$failedIds[] = $role->name;
|
||||
}
|
||||
}
|
||||
|
||||
$message = __(':count roles permanently removed.', ['count' => $deletedCount]);
|
||||
if (! empty($failedIds)) {
|
||||
$message .= ' '.__('Could not remove :names because they are still assigned to users.', ['names' => implode(', ', $failedIds)]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ============================================================
|
||||
*
|
||||
* @project biiproject
|
||||
*
|
||||
* @author Andika Debi Putra
|
||||
*
|
||||
* @email andikadebiputra@gmail.com
|
||||
*
|
||||
* @website https://biiproject.com
|
||||
*
|
||||
* @copyright Copyright (c) 2026 Andika Debi Putra
|
||||
* @license Proprietary - All Rights Reserved
|
||||
*
|
||||
* @version 1.0.0
|
||||
*
|
||||
* @created 2026-05-01
|
||||
* ============================================================
|
||||
*
|
||||
* Unauthorized copying, modification, distribution, or use
|
||||
* of this file is strictly prohibited without prior written
|
||||
* permission from the author.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\AccessControl;
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Support\DataTable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UserManagementController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
// Middleware handled in web.php
|
||||
}
|
||||
|
||||
/**
|
||||
* INDEX — Display user list & roles (for dropdowns)
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (DataTable::isDataTableRequest($request)) {
|
||||
return $this->dataTable($request);
|
||||
}
|
||||
|
||||
$roles = Role::where('is_active', 1)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('pages.access_control.users', compact('roles'));
|
||||
}
|
||||
|
||||
protected function dataTable(Request $request)
|
||||
{
|
||||
try {
|
||||
$authUser = $request->user();
|
||||
$canManage = $authUser->can('manage user directory');
|
||||
|
||||
$query = User::query()
|
||||
->withTrashed()
|
||||
->with(['roles:id,name', 'creator:id,email', 'updater:id,email']);
|
||||
|
||||
if (! $authUser->hasRole('Developer')) {
|
||||
$query->whereDoesntHave('roles', function ($roleQuery) {
|
||||
$roleQuery->where('name', 'Developer');
|
||||
});
|
||||
}
|
||||
|
||||
// Fast count total
|
||||
$recordsTotal = User::withTrashed();
|
||||
if (! $authUser->hasRole('Developer')) {
|
||||
$recordsTotal->whereDoesntHave('roles', function ($roleQuery) {
|
||||
$roleQuery->where('name', 'Developer');
|
||||
});
|
||||
}
|
||||
$recordsTotal = $recordsTotal->count();
|
||||
$globalSearch = DataTable::globalSearch($request);
|
||||
|
||||
if ($canManage) {
|
||||
$status = DataTable::columnSearch($request, 1);
|
||||
$name = DataTable::columnSearch($request, 2);
|
||||
$email = DataTable::columnSearch($request, 3);
|
||||
$role = DataTable::columnSearch($request, 4);
|
||||
$createdAt = DataTable::columnSearch($request, 5);
|
||||
$createdBy = DataTable::columnSearch($request, 6);
|
||||
$updatedAt = DataTable::columnSearch($request, 7);
|
||||
$updatedBy = DataTable::columnSearch($request, 8);
|
||||
} else {
|
||||
$status = null;
|
||||
$name = DataTable::columnSearch($request, 0);
|
||||
$email = DataTable::columnSearch($request, 1);
|
||||
$role = DataTable::columnSearch($request, 2);
|
||||
$createdAt = DataTable::columnSearch($request, 3);
|
||||
$createdBy = DataTable::columnSearch($request, 4);
|
||||
$updatedAt = DataTable::columnSearch($request, 5);
|
||||
$updatedBy = DataTable::columnSearch($request, 6);
|
||||
}
|
||||
|
||||
$trashed = $request->input('trashed');
|
||||
if ($trashed === 'archived') {
|
||||
$query->onlyTrashed();
|
||||
} elseif ($trashed === 'active') {
|
||||
$query->withoutTrashed();
|
||||
}
|
||||
|
||||
if ($status) {
|
||||
$query->where('is_active', $status === 'active');
|
||||
}
|
||||
|
||||
if ($name) {
|
||||
$query->where('name', 'like', "%{$name}%");
|
||||
}
|
||||
|
||||
if ($email) {
|
||||
$query->where('email', 'like', "%{$email}%");
|
||||
}
|
||||
|
||||
if ($role) {
|
||||
$query->whereHas('roles', function ($roleQuery) use ($role) {
|
||||
$roleQuery->where('name', 'like', "%{$role}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($createdAt) {
|
||||
$query->whereDate('created_at', $createdAt);
|
||||
}
|
||||
|
||||
if ($createdBy) {
|
||||
$query->whereHas('creator', function ($creatorQuery) use ($createdBy) {
|
||||
$creatorQuery->where('email', 'like', "%{$createdBy}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($updatedAt) {
|
||||
$query->whereDate('updated_at', $updatedAt);
|
||||
}
|
||||
|
||||
if ($updatedBy) {
|
||||
$query->whereHas('updater', function ($updaterQuery) use ($updatedBy) {
|
||||
$updaterQuery->where('email', 'like', "%{$updatedBy}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($globalSearch) {
|
||||
$query->where(function ($searchQuery) use ($globalSearch) {
|
||||
$searchQuery
|
||||
->where('name', 'like', "%{$globalSearch}%")
|
||||
->orWhere('email', 'like', "%{$globalSearch}%")
|
||||
->orWhereHas('roles', function ($roleQuery) use ($globalSearch) {
|
||||
$roleQuery->where('name', 'like', "%{$globalSearch}%");
|
||||
})
|
||||
->orWhereHas('creator', function ($creatorQuery) use ($globalSearch) {
|
||||
$creatorQuery->where('email', 'like', "%{$globalSearch}%");
|
||||
})
|
||||
->orWhereHas('updater', function ($updaterQuery) use ($globalSearch) {
|
||||
$updaterQuery->where('email', 'like', "%{$globalSearch}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Perform filtered count WITHOUT eager loading or ordering
|
||||
$countQuery = clone $query;
|
||||
$countQuery->setEagerLoads([]);
|
||||
$countQuery->orders = null;
|
||||
$recordsFiltered = $countQuery->count();
|
||||
[$orderIndex, $orderDirection] = DataTable::order($request, $canManage ? 5 : 3, 'desc');
|
||||
|
||||
$sortColumn = match (true) {
|
||||
$canManage && $orderIndex === 1 => 'is_active',
|
||||
($canManage && $orderIndex === 2) || (! $canManage && $orderIndex === 0) => 'name',
|
||||
($canManage && $orderIndex === 3) || (! $canManage && $orderIndex === 1) => 'email',
|
||||
($canManage && $orderIndex === 5) || (! $canManage && $orderIndex === 3) => 'created_at',
|
||||
($canManage && $orderIndex === 7) || (! $canManage && $orderIndex === 5) => 'updated_at',
|
||||
default => 'created_at',
|
||||
};
|
||||
|
||||
$users = $query
|
||||
->orderBy($sortColumn, $orderDirection)
|
||||
->skip(DataTable::start($request))
|
||||
->take(DataTable::length($request))
|
||||
->get();
|
||||
|
||||
$rows = $users->map(function (User $user) use ($authUser, $canManage) {
|
||||
$roles = $user->roles->pluck('name')->values();
|
||||
$roleBadges = $roles->isNotEmpty()
|
||||
? $roles->map(fn ($role) => '<span class="badge badge-slate-pill">'.e($role).'</span>')->implode(' ')
|
||||
: '-';
|
||||
|
||||
$row = [];
|
||||
|
||||
if ($canManage) {
|
||||
$row[] = sprintf(
|
||||
'<input type="checkbox" class="form-check-input user-checkbox" value="%d">',
|
||||
$user->id
|
||||
);
|
||||
$row[] = sprintf(
|
||||
'<div class="form-check form-switch d-flex align-items-center gap-2"><input class="form-check-input user-toggle" type="checkbox" %s data-id="%d" data-name="%s"><span class="status-label %s">%s</span></div>',
|
||||
$user->is_active ? 'checked' : '',
|
||||
$user->id,
|
||||
e($user->name),
|
||||
$user->is_active ? 'text-success' : 'text-danger',
|
||||
$user->is_active ? 'Active' : 'Inactive'
|
||||
);
|
||||
}
|
||||
|
||||
$row[] = e($user->name);
|
||||
$row[] = e($user->email);
|
||||
$row[] = $roleBadges;
|
||||
$row[] = e(format_datetime($user->created_at));
|
||||
$row[] = e($user->creator->email ?? 'System');
|
||||
$row[] = e(format_datetime($user->updated_at));
|
||||
$row[] = e($user->updater->email ?? '-');
|
||||
|
||||
if ($canManage) {
|
||||
$impersonateButton = '';
|
||||
if ($authUser->can('impersonate users')) {
|
||||
if ($authUser->id !== $user->id && ! $user->hasRole('Developer')) {
|
||||
$impersonateButton = '<form method="POST" action="'.route('impersonate.start', $user->id).'" class="d-inline">'
|
||||
.csrf_field()
|
||||
.'<button type="submit" class="btn btn-warning btn-sm rounded-pill px-3">'.e(__('Impersonate')).'</button>'
|
||||
.'</form>';
|
||||
} else {
|
||||
$impersonateButton = '<button type="button" class="btn btn-light btn-sm rounded-pill px-3 text-muted disabled" style="opacity: 0.6; cursor: not-allowed;">'
|
||||
.e(__('Impersonate'))
|
||||
.'</button>';
|
||||
}
|
||||
}
|
||||
|
||||
if ($user->trashed()) {
|
||||
$row[] = '<div class="d-inline-flex align-items-center gap-2">'
|
||||
.'<button class="btn btn-link text-success btn-restore" data-id="'.$user->id.'" data-name="'.e($user->name).'"><i class="bi bi-arrow-counterclockwise"></i> '.e(__('Restore')).'</button>'
|
||||
.'<button class="btn btn-link text-danger btn-force-delete" data-id="'.$user->id.'" data-name="'.e($user->name).'"><i class="bi bi-x-circle"></i> '.e(__('Terminated')).'</button>'
|
||||
.'</div>';
|
||||
} else {
|
||||
$row[] = '<div class="d-inline-flex align-items-center gap-2">'
|
||||
.$impersonateButton
|
||||
.'<button class="btn btn-link btn-edit" data-id="'.$user->id.'" data-name="'.e($user->name).'" data-email="'.e($user->email).'" data-roles="'.e(json_encode($user->roles->pluck('id')->values()->all())).'" data-created="'.e(format_datetime($user->created_at)).'" data-created-by="'.e($user->creator->email ?? 'System').'" data-updated-at="'.e(format_datetime($user->updated_at)).'" data-updated-by="'.e($user->updater->email ?? '-').'" data-bs-toggle="modal" data-bs-target="#editUserModal"><i class="bi bi-pencil"></i></button>'
|
||||
.'<button class="btn btn-link text-danger btn-delete" data-id="'.$user->id.'" data-name="'.e($user->name).'"><i class="bi bi-trash"></i></button>'
|
||||
.'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
return $row;
|
||||
})->all();
|
||||
|
||||
return DataTable::response($request, $recordsTotal, $recordsFiltered, $rows);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('DataTable Error [UserManagement]: '.$e->getMessage());
|
||||
|
||||
return DataTable::response($request, 0, 0, []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STORE — Tambah user + assign role
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:3',
|
||||
'max:100',
|
||||
'regex:/^[a-zA-Z\s]+$/',
|
||||
],
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
'max:150',
|
||||
Rule::unique('users', 'email'),
|
||||
],
|
||||
'password' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:12',
|
||||
'regex:/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{12,}$/',
|
||||
],
|
||||
'roles' => [
|
||||
'required',
|
||||
'array',
|
||||
'min:1',
|
||||
],
|
||||
'roles.*' => [
|
||||
'required',
|
||||
Rule::exists('roles', 'id'),
|
||||
],
|
||||
], [
|
||||
'name.regex' => __('Name may only contain letters and spaces.'),
|
||||
'email.unique' => __('This email is already registered.'),
|
||||
'password.regex' => __('Password must contain uppercase, lowercase, number, and symbol.'),
|
||||
'roles.required' => __('At least one role must be selected.'),
|
||||
]);
|
||||
|
||||
try {
|
||||
// create user
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => bcrypt($request->password),
|
||||
'is_active' => 1,
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
// 🔥 FIX: Convert role IDs → role names
|
||||
$roleNames = Role::whereIn('id', $request->roles)->pluck('name')->toArray();
|
||||
|
||||
// assign roles by name
|
||||
$user->syncRoles($roleNames);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('User created successfully.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', __('User created successfully.'));
|
||||
} catch (\Exception $e) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Failed to create user: ').$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
return back()->with('error', __('Failed to create user: ').$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE — Edit user data + (optional) password + roles
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:3',
|
||||
'max:100',
|
||||
'regex:/^[a-zA-Z\s]+$/',
|
||||
],
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
'max:150',
|
||||
Rule::unique('users', 'email')->ignore($id),
|
||||
],
|
||||
'password' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'min:12',
|
||||
'regex:/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{12,}$/',
|
||||
],
|
||||
'roles' => [
|
||||
'required',
|
||||
'array',
|
||||
'min:1',
|
||||
],
|
||||
'roles.*' => [
|
||||
Rule::exists('roles', 'id'),
|
||||
],
|
||||
], [
|
||||
'name.regex' => __('Name may only contain letters and spaces.'),
|
||||
'password.regex' => __('Password must contain uppercase, lowercase, number, and symbol.'),
|
||||
]);
|
||||
|
||||
try {
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
$data = [
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'updated_by' => Auth::id(),
|
||||
];
|
||||
|
||||
if ($request->filled('password')) {
|
||||
$data['password'] = bcrypt($request->password);
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
// 🔥 FIX: Convert ID role → Role Name
|
||||
$roleNames = Role::whereIn('id', $request->roles)->pluck('name')->toArray();
|
||||
$user->syncRoles($roleNames);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('User updated successfully.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', __('User updated successfully.'));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('Failed to update user: ').$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
return back()->with('error', __('Failed to update user: ').$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TOGGLE — Aktivasi/Non-aktifkan user
|
||||
*/
|
||||
public function toggleStatus(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'id' => 'required|exists:users,id',
|
||||
'status' => 'required|in:activate,deactivate',
|
||||
]);
|
||||
|
||||
$user = User::findOrFail($request->id);
|
||||
|
||||
$user->update([
|
||||
'is_active' => $request->status === 'activate' ? 1 : 0,
|
||||
'updated_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('User status updated successfully.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DESTROY — Archive (Soft Delete)
|
||||
*/
|
||||
public function destroy($id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
$user->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('User has been archived.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* RESTORE — Restore user (Soft Delete)
|
||||
*/
|
||||
public function restore($id)
|
||||
{
|
||||
$user = User::withTrashed()->findOrFail($id);
|
||||
$user->restore();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('User has been restored.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* FORCE DELETE — Permanent removal
|
||||
*/
|
||||
public function forceDelete($id)
|
||||
{
|
||||
$user = User::withTrashed()->findOrFail($id);
|
||||
|
||||
// Prevent accidental deletion of self
|
||||
if ($user->id === Auth::id()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('You cannot permanently delete yourself.'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
$user->forceDelete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('User has been permanently removed.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* BULK TOGGLE STATUS
|
||||
*/
|
||||
public function bulkToggleStatus(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'ids' => 'required|array',
|
||||
'ids.*' => 'exists:users,id',
|
||||
'status' => 'required|in:activate,deactivate',
|
||||
]);
|
||||
|
||||
$status = $request->status === 'activate' ? 1 : 0;
|
||||
|
||||
User::whereIn('id', $request->ids)->update([
|
||||
'is_active' => $status,
|
||||
'updated_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Selected users have been updated.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* BULK DELETE (Archive)
|
||||
*/
|
||||
public function bulkDelete(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'ids' => 'required|array',
|
||||
'ids.*' => 'exists:users,id',
|
||||
]);
|
||||
|
||||
User::whereIn('id', $request->ids)->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Selected users have been archived.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* BULK RESTORE
|
||||
*/
|
||||
public function bulkRestore(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'ids' => 'required|array',
|
||||
'ids.*' => 'exists:users,id',
|
||||
]);
|
||||
|
||||
User::withTrashed()->whereIn('id', $request->ids)->restore();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Selected users have been restored.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* BULK FORCE DELETE
|
||||
*/
|
||||
public function bulkForceDelete(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'ids' => 'required|array',
|
||||
'ids.*' => 'exists:users,id',
|
||||
]);
|
||||
|
||||
// Prevent self deletion
|
||||
$ids = array_filter($request->ids, fn ($id) => $id != Auth::id());
|
||||
|
||||
User::withTrashed()->whereIn('id', $ids)->forceDelete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Selected users have been permanently removed.'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user