feat: add app and database modules
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
<?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
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\AI;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AI\AiAssistantService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="AI Intelligence",
|
||||
* description="API Endpoints for AI-powered system features"
|
||||
* )
|
||||
*/
|
||||
class AiAssistantController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AiAssistantService $assistantService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/ai/assistant/ask",
|
||||
* summary="Ask the AI Assistant",
|
||||
* description="Sends a natural language query to the AI assistant for system guidance.",
|
||||
* operationId="askAiAssistant",
|
||||
* tags={"AI Intelligence"},
|
||||
* security={{"sanctum":{}}},
|
||||
*
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
* required={"question"},
|
||||
*
|
||||
* @OA\Property(property="question", type="string", example="How do I change the maintenance mode secret?")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="AI Response",
|
||||
*
|
||||
* @OA\JsonContent(
|
||||
*
|
||||
* @OA\Property(property="status", type="string", example="success"),
|
||||
* @OA\Property(property="answer", type="string", example="To change the secret, go to Global Settings > Maintenance.")
|
||||
* )
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(response=401, description="Unauthenticated"),
|
||||
* @OA\Response(response=422, description="Validation Error")
|
||||
* )
|
||||
*/
|
||||
public function ask(Request $request)
|
||||
{
|
||||
$this->authorize('use ai assistant');
|
||||
$request->validate([
|
||||
'question' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
$answer = $this->assistantService->answer($request->question);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'answer' => $answer,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?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
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\AI;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AI\LogAnalysisService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class LogAnalysisController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected LogAnalysisService $analysisService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the latest AI log analysis.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$this->authorize('view ai log analysis');
|
||||
|
||||
$analysis = Cache::get('ai_log_analysis_result', 'Analysis not generated yet. Click analyze to start.');
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'analysis' => $analysis,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a new AI log analysis.
|
||||
*/
|
||||
public function analyze(Request $request)
|
||||
{
|
||||
$this->authorize('view ai log analysis');
|
||||
|
||||
// Clear cache to force new analysis
|
||||
Cache::forget('ai_log_analysis_result');
|
||||
|
||||
$analysis = $this->analysisService->analyzeRecentLogs();
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'analysis' => $analysis,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the AI log analysis cache.
|
||||
*/
|
||||
public function clear()
|
||||
{
|
||||
$this->authorize('view ai log analysis');
|
||||
|
||||
Cache::forget('ai_log_analysis_result');
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Analysis cleared successfully',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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.'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\UpdateMobileSettingRequest;
|
||||
use App\Models\MobileSetting;
|
||||
use App\Services\MobileConfig\MobileConfigService;
|
||||
use App\Services\Monitoring\SystemMonitoringService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MobileSettingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected MobileConfigService $service,
|
||||
protected SystemMonitoringService $monitor
|
||||
) {}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$settings = $this->service->getGroupedSettingsForAdmin();
|
||||
$settings = collect($settings)->only([
|
||||
'branding',
|
||||
'control_center',
|
||||
'app_updates',
|
||||
'features',
|
||||
'security_auth',
|
||||
'connectivity',
|
||||
'notifications',
|
||||
'support_social',
|
||||
'analytics_system',
|
||||
'localization',
|
||||
]);
|
||||
|
||||
return view('pages.mobile-settings.index', compact('settings'));
|
||||
}
|
||||
|
||||
public function update(UpdateMobileSettingRequest $request)
|
||||
{
|
||||
try {
|
||||
// 1. Separate files and text data
|
||||
$files = $request->allFiles();
|
||||
$data = $request->except(array_merge(array_keys($files), ['_token', '_method', 'fakeuser', 'fakepass']));
|
||||
|
||||
// 2. Identify all boolean fields to handle "false" values (unchecked checkboxes)
|
||||
$booleanKeys = $this->service->getBooleanKeys();
|
||||
foreach ($booleanKeys as $key) {
|
||||
if (! isset($data[$key])) {
|
||||
$data[$key] = 'false';
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delegate to Service (service::update already clears its own cache)
|
||||
$this->service->update($data, $files);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Configuration and assets updated!'),
|
||||
'settings' => MobileSetting::all()->pluck('value', 'key'),
|
||||
'timestamp' => now()->timestamp,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', __('Configuration and assets updated!'));
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Mobile Settings Save Error: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Error: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('error', 'Error occurred while saving: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Helpers\ApiResponse;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\PasswordPolicyService;
|
||||
use App\Services\MobileConfig\MobileConfigService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected MobileConfigService $mobileConfig
|
||||
) {}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/v1/login',
|
||||
operationId: 'login',
|
||||
tags: ['Auth'],
|
||||
summary: 'Authenticate a user',
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['email', 'password'],
|
||||
properties: [
|
||||
new OA\Property(property: 'email', type: 'string', format: 'email', example: 'user@example.com'),
|
||||
new OA\Property(property: 'password', type: 'string', format: 'password', example: 'secret'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Login successful',
|
||||
content: new OA\JsonContent(properties: [
|
||||
new OA\Property(property: 'status', type: 'string', example: 'success'),
|
||||
new OA\Property(property: 'data', type: 'object', properties: [
|
||||
new OA\Property(property: 'user', ref: '#/components/schemas/User'),
|
||||
new OA\Property(property: 'token', type: 'string'),
|
||||
]),
|
||||
])
|
||||
),
|
||||
new OA\Response(response: 401, description: 'Invalid credentials'),
|
||||
new OA\Response(response: 403, description: 'Account inactive or role not permitted'),
|
||||
]
|
||||
)]
|
||||
public function login(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
'password' => 'required',
|
||||
]);
|
||||
|
||||
$config = $this->mobileConfig->all();
|
||||
$maxAttempts = $config['security_auth']['login_max_attempts'] ?? 5;
|
||||
$throttleKey = 'login_attempt_'.$request->ip().'_'.$request->email;
|
||||
|
||||
if (RateLimiter::tooManyAttempts($throttleKey, $maxAttempts)) {
|
||||
$seconds = RateLimiter::availableIn($throttleKey);
|
||||
|
||||
return ApiResponse::error("Too many login attempts. Please try again in {$seconds} seconds.", 429);
|
||||
}
|
||||
|
||||
$user = User::where('email', $request->email)->first();
|
||||
|
||||
if (! $user || ! Hash::check($request->password, $user->password)) {
|
||||
RateLimiter::hit($throttleKey, 60); // Lock for 60 seconds if limit reached
|
||||
|
||||
return ApiResponse::unauthorized('The provided credentials are incorrect.');
|
||||
}
|
||||
|
||||
RateLimiter::clear($throttleKey);
|
||||
|
||||
if (isset($user->is_active) && ! $user->is_active) {
|
||||
return ApiResponse::forbidden('Your account is currently inactive. Please contact support.');
|
||||
}
|
||||
|
||||
if (class_exists(Role::class)) {
|
||||
$allowedRoles = ['User', 'Administrator', 'Developer'];
|
||||
if (! $user->hasAnyRole($allowedRoles)) {
|
||||
return ApiResponse::forbidden('Access denied. Your role does not have permission to access the mobile application.');
|
||||
}
|
||||
}
|
||||
|
||||
$token = $user->createToken('mobile-app')->plainTextToken;
|
||||
|
||||
return ApiResponse::success(['user' => $user, 'token' => $token], 'Login successful');
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/v1/register',
|
||||
operationId: 'register',
|
||||
tags: ['Auth'],
|
||||
summary: 'Register a new user',
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['name', 'email', 'password'],
|
||||
properties: [
|
||||
new OA\Property(property: 'name', type: 'string', example: 'John Doe'),
|
||||
new OA\Property(property: 'email', type: 'string', format: 'email'),
|
||||
new OA\Property(property: 'password', type: 'string', minLength: 8),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Registration successful'),
|
||||
new OA\Response(response: 422, description: 'Validation error'),
|
||||
]
|
||||
)]
|
||||
public function register(Request $request)
|
||||
{
|
||||
$config = $this->mobileConfig->all();
|
||||
|
||||
// 1. Check if registration is enabled in Global Mobile Settings
|
||||
if (! ($config['features']['enable_registration'] ?? true)) {
|
||||
return ApiResponse::error('Registration is currently disabled by administrator.', 403);
|
||||
}
|
||||
|
||||
// 2. Check if OTP is required
|
||||
if (($config['features']['require_otp_registration'] ?? false) && ! $request->has('otp_token')) {
|
||||
return ApiResponse::error('OTP verification is required for registration.', 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|email|max:255|unique:users',
|
||||
'password' => ['required', 'string', PasswordPolicyService::getRules()],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
if (class_exists(Role::class)) {
|
||||
$user->assignRole('User');
|
||||
}
|
||||
|
||||
$token = $user->createToken('mobile-app')->plainTextToken;
|
||||
|
||||
return ApiResponse::created(['user' => $user, 'token' => $token], 'Registration successful');
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/v1/logout',
|
||||
operationId: 'logout',
|
||||
tags: ['Auth'],
|
||||
summary: 'Revoke current access token',
|
||||
security: [['sanctum' => []]],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Logged out successfully'),
|
||||
new OA\Response(response: 401, description: 'Unauthenticated'),
|
||||
]
|
||||
)]
|
||||
public function logout(Request $request)
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
|
||||
return ApiResponse::success(null, 'Logged out successfully');
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/v1/forgot-password',
|
||||
operationId: 'forgotPassword',
|
||||
tags: ['Auth'],
|
||||
summary: 'Send password reset link',
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['email'],
|
||||
properties: [new OA\Property(property: 'email', type: 'string', format: 'email')]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Reset link sent'),
|
||||
new OA\Response(response: 422, description: 'Email not found or throttled'),
|
||||
]
|
||||
)]
|
||||
public function forgotPassword(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
]);
|
||||
|
||||
$status = Password::sendResetLink($request->only('email'));
|
||||
|
||||
if ($status === Password::RESET_LINK_SENT) {
|
||||
return ApiResponse::success(null, __($status));
|
||||
}
|
||||
|
||||
return ApiResponse::error(__($status), 422);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/v1/profile/update',
|
||||
operationId: 'updateProfile',
|
||||
tags: ['Profile'],
|
||||
summary: 'Update authenticated user profile',
|
||||
security: [['sanctum' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['name', 'email'],
|
||||
properties: [
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'email', type: 'string', format: 'email'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Profile updated'),
|
||||
new OA\Response(response: 422, description: 'Validation error'),
|
||||
]
|
||||
)]
|
||||
public function updateProfile(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|email|max:255|unique:users,email,'.Auth::id(),
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
$user->update([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
]);
|
||||
|
||||
return ApiResponse::success(['user' => $user->fresh()], 'Profile updated successfully');
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/v1/profile/avatar',
|
||||
operationId: 'updateAvatar',
|
||||
tags: ['Profile'],
|
||||
summary: 'Upload user avatar',
|
||||
security: [['sanctum' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'multipart/form-data',
|
||||
schema: new OA\Schema(properties: [
|
||||
new OA\Property(property: 'avatar', type: 'string', format: 'binary'),
|
||||
])
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Avatar updated'),
|
||||
new OA\Response(response: 422, description: 'Validation error'),
|
||||
]
|
||||
)]
|
||||
public function updateAvatar(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'avatar' => 'required|image|mimes:jpeg,png,jpg,gif|max:10240',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if ($request->hasFile('avatar')) {
|
||||
$user->clearMediaCollection('avatar');
|
||||
$media = $user->addMediaFromRequest('avatar')->toMediaCollection('avatar');
|
||||
|
||||
return ApiResponse::success([
|
||||
'avatar_url' => $media->getFullUrl(),
|
||||
'user' => $user->fresh(),
|
||||
], 'Avatar updated successfully');
|
||||
}
|
||||
|
||||
return ApiResponse::error('No file uploaded', 400);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/v1/dashboard',
|
||||
operationId: 'getDashboard',
|
||||
tags: ['Dashboard'],
|
||||
summary: 'Get dashboard summary data',
|
||||
security: [['sanctum' => []]],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Dashboard data'),
|
||||
new OA\Response(response: 401, description: 'Unauthenticated'),
|
||||
]
|
||||
)]
|
||||
public function getDashboardData(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$cacheKey = "user_dashboard_v2_{$user->id}";
|
||||
|
||||
$data = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($user) {
|
||||
$transactions = Transaction::where('user_id', $user->id)->latest()->take(10)->get();
|
||||
|
||||
$totalBalance = Transaction::where('user_id', $user->id)
|
||||
->selectRaw("SUM(CASE WHEN type = 'income' THEN amount ELSE -amount END) as balance")
|
||||
->value('balance') ?? 0;
|
||||
|
||||
return [
|
||||
'balance' => number_format($totalBalance, 2),
|
||||
'transactions' => $transactions,
|
||||
'stats' => [
|
||||
'income' => Transaction::where('user_id', $user->id)->where('type', 'income')->sum('amount'),
|
||||
'expense' => Transaction::where('user_id', $user->id)->where('type', 'expense')->sum('amount'),
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
return ApiResponse::success($data);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/v1/profile/password',
|
||||
operationId: 'updatePassword',
|
||||
tags: ['Profile'],
|
||||
summary: 'Change authenticated user password',
|
||||
security: [['sanctum' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['current_password', 'password', 'password_confirmation'],
|
||||
properties: [
|
||||
new OA\Property(property: 'current_password', type: 'string'),
|
||||
new OA\Property(property: 'password', type: 'string', minLength: 8),
|
||||
new OA\Property(property: 'password_confirmation', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Password updated'),
|
||||
new OA\Response(response: 422, description: 'Current password incorrect'),
|
||||
]
|
||||
)]
|
||||
public function updatePassword(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'current_password' => 'required',
|
||||
'password' => 'required|string|min:8|confirmed',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if (! Hash::check($request->current_password, $user->password)) {
|
||||
return ApiResponse::error('The current password you entered is incorrect.', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
PasswordPolicyService::checkHistory($user, $request->password);
|
||||
} catch (ValidationException $e) {
|
||||
return ApiResponse::error($e->errors()['password'][0] ?? 'Password does not meet policy requirements.', 422);
|
||||
}
|
||||
|
||||
$passwordHash = Hash::make($request->password);
|
||||
$user->update(['password' => $passwordHash]);
|
||||
PasswordPolicyService::recordPasswordChange($user, $passwordHash);
|
||||
|
||||
return ApiResponse::success(null, 'Password updated successfully.');
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
path: '/v1/profile/delete',
|
||||
operationId: 'deleteAccount',
|
||||
tags: ['Profile'],
|
||||
summary: 'Permanently delete authenticated user account',
|
||||
security: [['sanctum' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['password'],
|
||||
properties: [
|
||||
new OA\Property(property: 'password', type: 'string', description: 'Current password to confirm deletion'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Account deleted'),
|
||||
new OA\Response(response: 422, description: 'Password incorrect or missing'),
|
||||
]
|
||||
)]
|
||||
public function deleteAccount(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if (! Hash::check($request->password, $user->password)) {
|
||||
return ApiResponse::error('The password you entered is incorrect.', 422);
|
||||
}
|
||||
|
||||
$user->tokens()->delete();
|
||||
$user->delete();
|
||||
|
||||
return ApiResponse::success(null, 'Your account has been deleted permanently.');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/v1/user',
|
||||
operationId: 'getUser',
|
||||
tags: ['Auth'],
|
||||
summary: 'Get authenticated user',
|
||||
security: [['sanctum' => []]],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'User object'),
|
||||
new OA\Response(response: 401, description: 'Unauthenticated'),
|
||||
]
|
||||
)]
|
||||
public function user(Request $request)
|
||||
{
|
||||
return ApiResponse::success(['user' => $request->user()]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/v1/app-config',
|
||||
operationId: 'getAppConfig',
|
||||
tags: ['Config'],
|
||||
summary: 'Get public app configuration (branding, taglines)',
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'App config'),
|
||||
]
|
||||
)]
|
||||
public function getAppConfig()
|
||||
{
|
||||
return ApiResponse::success([
|
||||
'logo' => asset(get_setting('app_logo', 'assets/img/logo.png')),
|
||||
'tagline1' => get_setting('app_tagline1', 'Welcome'),
|
||||
'tagline2' => strip_tags(get_setting('app_tagline2', 'Manage your assets efficiently')),
|
||||
'footer' => get_setting('footer_text', '© 2026 Your App'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Helpers\ApiResponse;
|
||||
use App\Models\DeviceToken;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class DeviceTokenController extends Controller
|
||||
{
|
||||
#[OA\Post(
|
||||
path: '/v1/devices/register',
|
||||
operationId: 'registerDevice',
|
||||
tags: ['Push Notifications'],
|
||||
summary: 'Register a device token for push notifications',
|
||||
security: [['sanctum' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['token', 'platform'],
|
||||
properties: [
|
||||
new OA\Property(property: 'token', type: 'string', description: 'FCM device token'),
|
||||
new OA\Property(property: 'platform', type: 'string', enum: ['ios', 'android', 'web']),
|
||||
new OA\Property(property: 'device_name', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'app_version', type: 'string', nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Device registered'),
|
||||
new OA\Response(response: 422, description: 'Validation error'),
|
||||
]
|
||||
)]
|
||||
public function register(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'token' => 'required|string',
|
||||
'platform' => 'required|in:ios,android,web',
|
||||
'device_name' => 'nullable|string|max:100',
|
||||
'app_version' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
$deviceToken = DeviceToken::updateOrCreate(
|
||||
['token' => $request->input('token')],
|
||||
[
|
||||
'user_id' => $request->user()->id,
|
||||
'platform' => $request->input('platform'),
|
||||
'device_name' => $request->input('device_name'),
|
||||
'app_version' => $request->input('app_version'),
|
||||
'last_used_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
return ApiResponse::success(['device_id' => $deviceToken->id], 'Device registered successfully');
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
path: '/v1/devices/unregister',
|
||||
operationId: 'unregisterDevice',
|
||||
tags: ['Push Notifications'],
|
||||
summary: 'Remove a device token',
|
||||
security: [['sanctum' => []]],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['token'],
|
||||
properties: [new OA\Property(property: 'token', type: 'string')]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Device unregistered'),
|
||||
]
|
||||
)]
|
||||
public function unregister(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'token' => 'required|string',
|
||||
]);
|
||||
|
||||
DeviceToken::where('token', $request->input('token'))
|
||||
->where('user_id', $request->user()->id)
|
||||
->delete();
|
||||
|
||||
return ApiResponse::success(null, 'Device unregistered successfully');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class HealthController extends Controller
|
||||
{
|
||||
#[OA\Get(
|
||||
path: '/health',
|
||||
operationId: 'healthCheck',
|
||||
tags: ['System'],
|
||||
summary: 'Application health check',
|
||||
description: 'Returns status of database, Redis, storage, and queue. Returns 503 if any check fails.',
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'All systems healthy',
|
||||
content: new OA\JsonContent(properties: [
|
||||
new OA\Property(property: 'status', type: 'string', example: 'healthy'),
|
||||
new OA\Property(property: 'timestamp', type: 'string', format: 'date-time'),
|
||||
new OA\Property(property: 'checks', type: 'object'),
|
||||
])
|
||||
),
|
||||
new OA\Response(response: 503, description: 'One or more checks degraded'),
|
||||
]
|
||||
)]
|
||||
public function check()
|
||||
{
|
||||
$checks = [
|
||||
'database' => $this->checkDatabase(),
|
||||
'redis' => $this->checkRedis(),
|
||||
'storage' => $this->checkStorage(),
|
||||
'queue' => $this->checkQueue(),
|
||||
];
|
||||
|
||||
$hasFailure = collect($checks)->contains(fn ($c) => $c['status'] === 'fail');
|
||||
$allOk = collect($checks)->every(fn ($c) => $c['status'] === 'ok');
|
||||
|
||||
return response()->json([
|
||||
'status' => $allOk ? 'healthy' : ($hasFailure ? 'degraded' : 'warn'),
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'checks' => $checks,
|
||||
], $hasFailure ? 503 : 200);
|
||||
}
|
||||
|
||||
private function checkDatabase(): array
|
||||
{
|
||||
try {
|
||||
DB::connection()->getPdo();
|
||||
|
||||
return ['status' => 'ok', 'latency_ms' => $this->measure(fn () => DB::select('SELECT 1'))];
|
||||
} catch (\Throwable $e) {
|
||||
return ['status' => 'fail', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
private function checkRedis(): array
|
||||
{
|
||||
try {
|
||||
$latency = $this->measure(fn () => Cache::store('redis')->put('health_check', true, 5));
|
||||
|
||||
return ['status' => 'ok', 'latency_ms' => $latency];
|
||||
} catch (\Throwable $e) {
|
||||
return ['status' => 'fail', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
private function checkStorage(): array
|
||||
{
|
||||
try {
|
||||
$free = disk_free_space(storage_path());
|
||||
$total = disk_total_space(storage_path());
|
||||
|
||||
if ($free === false || $total === false || $total === 0.0) {
|
||||
return ['status' => 'fail', 'error' => 'Unable to read disk space.'];
|
||||
}
|
||||
|
||||
$usedPct = round((($total - $free) / $total) * 100, 1);
|
||||
|
||||
return [
|
||||
'status' => $usedPct < 90 ? 'ok' : 'warn',
|
||||
'used_pct' => $usedPct,
|
||||
'free_gb' => round($free / 1073741824, 2),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['status' => 'fail', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
private function checkQueue(): array
|
||||
{
|
||||
try {
|
||||
$size = Queue::size('default');
|
||||
|
||||
return ['status' => 'ok', 'pending_jobs' => $size];
|
||||
} catch (\Throwable $e) {
|
||||
return ['status' => 'unknown', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
private function measure(callable $fn): int
|
||||
{
|
||||
$start = hrtime(true);
|
||||
$fn();
|
||||
|
||||
return (int) round((hrtime(true) - $start) / 1_000_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\MobileConfig\MobileConfigService;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class MobileConfigController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected MobileConfigService $service
|
||||
) {}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/mobile/sync',
|
||||
operationId: 'getMobileConfig',
|
||||
tags: ['Mobile'],
|
||||
summary: 'Get mobile app configuration',
|
||||
description: 'Returns cached mobile configuration including theme, flags, and assets.',
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'p',
|
||||
in: 'query',
|
||||
description: 'Platform (ios/android)',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'v',
|
||||
in: 'query',
|
||||
description: 'App Version',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Successful operation',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'status', type: 'string', example: 'success'),
|
||||
new OA\Property(property: 'version', type: 'string', example: '1.1.0'),
|
||||
new OA\Property(property: 'data', type: 'object'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function sync(Request $request)
|
||||
{
|
||||
$config = $this->service->all();
|
||||
$etag = md5(json_encode($config));
|
||||
|
||||
if ($request->hasHeader('If-None-Match') && $request->header('If-None-Match') === $etag) {
|
||||
return response()->json([], 304);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'version' => '1.1.0',
|
||||
'last_updated' => now()->toIso8601String(),
|
||||
'data' => $config,
|
||||
])->header('ETag', $etag);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Helpers\ApiResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MobileLogController extends Controller
|
||||
{
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'level' => 'required|string|in:debug,info,warning,error,critical',
|
||||
'message' => 'required|string',
|
||||
'context' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$context = [
|
||||
'timestamp' => now()->format('Y-m-d H:i:s'),
|
||||
'user_id' => auth()->id() ?? 'guest',
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'context' => $request->context,
|
||||
];
|
||||
|
||||
$message = $request->message.' Context: '.json_encode($context);
|
||||
|
||||
Log::channel('mobile')->log($request->level, $message);
|
||||
|
||||
return ApiResponse::success(null, 'Log recorded');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Helpers\ApiResponse;
|
||||
use App\Mail\TwoFactorOtp;
|
||||
use App\Services\Auth\OtpService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class OtpController extends Controller
|
||||
{
|
||||
public function __construct(protected OtpService $otpService) {}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/v1/otp/send',
|
||||
operationId: 'otpSend',
|
||||
tags: ['OTP'],
|
||||
summary: 'Send a 6-digit OTP to the given email',
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['email'],
|
||||
properties: [new OA\Property(property: 'email', type: 'string', format: 'email')]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'OTP sent'),
|
||||
new OA\Response(response: 500, description: 'Mail delivery failed'),
|
||||
]
|
||||
)]
|
||||
public function send(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
]);
|
||||
|
||||
$email = $request->email;
|
||||
$code = $this->otpService->generate($email);
|
||||
|
||||
try {
|
||||
Mail::to($email)->send(new TwoFactorOtp(
|
||||
otp: $code,
|
||||
ipAddress: $request->ip(),
|
||||
userAgent: $request->userAgent(),
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('OTP send failed', ['email' => $email, 'error' => $e->getMessage()]);
|
||||
|
||||
return ApiResponse::serverError('Failed to send OTP. Please try again later.');
|
||||
}
|
||||
|
||||
return ApiResponse::success(null, 'OTP has been sent to your email');
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/v1/otp/verify',
|
||||
operationId: 'otpVerify',
|
||||
tags: ['OTP'],
|
||||
summary: 'Verify an OTP code',
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['email', 'code'],
|
||||
properties: [
|
||||
new OA\Property(property: 'email', type: 'string', format: 'email'),
|
||||
new OA\Property(property: 'code', type: 'string', minLength: 6, maxLength: 6, example: '123456'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'OTP verified'),
|
||||
new OA\Response(response: 422, description: 'Invalid or expired OTP'),
|
||||
]
|
||||
)]
|
||||
public function verify(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
'code' => 'required|string|size:6',
|
||||
]);
|
||||
|
||||
if (! $this->otpService->verify($request->input('email'), $request->input('code'))) {
|
||||
return ApiResponse::error('Invalid or expired OTP code', 422);
|
||||
}
|
||||
|
||||
return ApiResponse::success(null, 'OTP verified successfully');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Info(
|
||||
version: '1.1.0',
|
||||
title: 'Premium Template API',
|
||||
description: 'Unified API for Mobile and Web Integration. Authenticated endpoints require a Bearer token from POST /v1/login.'
|
||||
)]
|
||||
#[OA\Contact(email: 'admin@example.com')]
|
||||
#[OA\Server(url: 'http://localhost:8000/api', description: 'Local')]
|
||||
#[OA\Server(url: 'https://yourdomain.com/api', description: 'Production')]
|
||||
#[OA\SecurityScheme(
|
||||
securityScheme: 'sanctum',
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'Token',
|
||||
description: 'Enter the token returned by POST /v1/login'
|
||||
)]
|
||||
#[OA\Schema(
|
||||
schema: 'User',
|
||||
properties: [
|
||||
new OA\Property(property: 'id', type: 'integer'),
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'email', type: 'string', format: 'email'),
|
||||
new OA\Property(property: 'is_active', type: 'boolean'),
|
||||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||||
]
|
||||
)]
|
||||
#[OA\Schema(
|
||||
schema: 'ApiSuccess',
|
||||
properties: [
|
||||
new OA\Property(property: 'status', type: 'string', example: 'success'),
|
||||
new OA\Property(property: 'message', type: 'string'),
|
||||
new OA\Property(property: 'data', type: 'object', nullable: true),
|
||||
]
|
||||
)]
|
||||
#[OA\Schema(
|
||||
schema: 'ApiError',
|
||||
properties: [
|
||||
new OA\Property(property: 'status', type: 'string', example: 'error'),
|
||||
new OA\Property(property: 'message', type: 'string'),
|
||||
new OA\Property(property: 'errors', type: 'object', nullable: true),
|
||||
]
|
||||
)]
|
||||
class Swagger {}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTrustedDevice;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the login view
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
// Batch common auth settings
|
||||
$settings = [
|
||||
'2fa_enabled' => get_setting('two_factor_auth', false),
|
||||
'allow_remember' => get_setting('session_allow_remember_me', true),
|
||||
'remember_duration' => get_setting('session_remember_me_duration', 30),
|
||||
'single_session' => get_setting('session_single_session', false),
|
||||
];
|
||||
|
||||
$credentials = $request->only('email', 'password');
|
||||
$remember = $settings['allow_remember'] && $request->boolean('remember');
|
||||
|
||||
// Check if 2FA is enabled globally
|
||||
if ($settings['2fa_enabled']) {
|
||||
if (Auth::validate($credentials)) {
|
||||
$user = User::where('email', $request->email)->first();
|
||||
|
||||
// Check Trust Device bypass (Secure check)
|
||||
$cookieValue = $request->cookie('2fa_trust_device');
|
||||
$trustedBypass = false;
|
||||
|
||||
if ($cookieValue && str_contains($cookieValue, '|')) {
|
||||
$parts = explode('|', $cookieValue, 2);
|
||||
|
||||
if (count($parts) === 2 && ! empty($parts[0]) && ! empty($parts[1])) {
|
||||
[$uuid, $secret] = $parts;
|
||||
|
||||
$trust = UserTrustedDevice::where('user_id', $user->id)
|
||||
->where('device_id', $uuid)
|
||||
->where('expires_at', '>', now())
|
||||
->first();
|
||||
|
||||
if ($trust && hash_equals($trust->token, hash('sha256', $secret))) {
|
||||
$trustedBypass = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($trustedBypass) {
|
||||
Auth::attempt($credentials, $remember);
|
||||
$request->session()->regenerate();
|
||||
$user->update(['last_session_id' => session()->getId()]);
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
// Generate & Send OTP
|
||||
TwoFactorController::generateAndSendOtp($user);
|
||||
session(['auth.2fa_remember' => $remember]);
|
||||
|
||||
return redirect()->route('2fa.index');
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate user credentials normally
|
||||
$request->authenticate();
|
||||
$request->session()->regenerate();
|
||||
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$user->update(['last_session_id' => session()->getId()]);
|
||||
|
||||
// Custom duration Remember Me
|
||||
if ($remember) {
|
||||
$minutes = 60 * 24 * $settings['remember_duration'];
|
||||
cookie()->queue(
|
||||
cookie(
|
||||
name: Auth::getRecallerName(),
|
||||
value: cookie()->get(Auth::getRecallerName()),
|
||||
minutes: $minutes,
|
||||
httpOnly: true,
|
||||
secure: app()->environment('production'),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// SINGLE SESSION ENFORCEMENT
|
||||
if ($settings['single_session']) {
|
||||
Auth::logoutOtherDevices($request->password);
|
||||
}
|
||||
|
||||
return redirect()->intended('/dashboard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
// Invalidate session for security
|
||||
$request->session()->invalidate();
|
||||
|
||||
// Regenerate CSRF token
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
// Redirect to homepage
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ConfirmablePasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the confirm password view.
|
||||
*/
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.confirm-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's password.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => $request->user()->email,
|
||||
'password' => $request->password,
|
||||
])) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('auth.password_confirmed_at', time());
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a new email verification notification.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with('status', 'verification-link-sent');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the email verification prompt.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse|View
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: view('auth.verify-email');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset view.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.reset-password', ['request' => $request]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
session()->regenerate();
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $status == Password::PASSWORD_RESET
|
||||
? redirect()->route('login')->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Auth\PasswordPolicyService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validated = $request->validateWithBag('updatePassword', [
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', PasswordPolicyService::getRules(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
$newPassword = $validated['password'];
|
||||
|
||||
// Check History
|
||||
PasswordPolicyService::checkHistory($user, $newPassword);
|
||||
|
||||
// Must be called before password is updated so current hash still matches
|
||||
Auth::logoutOtherDevices($request->current_password);
|
||||
|
||||
$passwordHash = Hash::make($newPassword);
|
||||
$user->update([
|
||||
'password' => $passwordHash,
|
||||
]);
|
||||
|
||||
// Record Change & History
|
||||
PasswordPolicyService::recordPasswordChange($user, $passwordHash);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Password updated successfully.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset link request view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.forgot-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
$user = User::where('email', $request->email)->first();
|
||||
|
||||
/**
|
||||
* ==========================================
|
||||
* ❌ BLOCK RESET PASSWORD SOCIAL USER
|
||||
* ==========================================
|
||||
*/
|
||||
if ($user && $user->isSocialUser()) {
|
||||
return back()
|
||||
->withInput($request->only('email'))
|
||||
->withErrors([
|
||||
'email' => __('Please sign in using your Social Provider for this account.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ==========================================
|
||||
* ✅ USER MANUAL → NORMAL
|
||||
* ==========================================
|
||||
*/
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return $status === Password::RESET_LINK_SENT
|
||||
? back()->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
|
||||
// namespace App\Http\Controllers\Auth;
|
||||
|
||||
// use App\Http\Controllers\Controller;
|
||||
// use Illuminate\Http\RedirectResponse;
|
||||
// use Illuminate\Http\Request;
|
||||
// use Illuminate\Support\Facades\Password;
|
||||
// use Illuminate\View\View;
|
||||
|
||||
// class PasswordResetLinkController extends Controller
|
||||
// {
|
||||
// /**
|
||||
// * Display the password reset link request view.
|
||||
// */
|
||||
// public function create(): View
|
||||
// {
|
||||
// return view('auth.forgot-password');
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Handle an incoming password reset link request.
|
||||
// *
|
||||
// * @throws \Illuminate\Validation\ValidationException
|
||||
// */
|
||||
// public function store(Request $request): RedirectResponse
|
||||
// {
|
||||
// $request->validate([
|
||||
// 'email' => ['required', 'email'],
|
||||
// ]);
|
||||
|
||||
// // We will send the password reset link to this user. Once we have attempted
|
||||
// // to send the link, we will examine the response then see the message we
|
||||
// // need to show to the user. Finally, we'll send out a proper response.
|
||||
// $status = Password::sendResetLink(
|
||||
// $request->only('email')
|
||||
// );
|
||||
|
||||
// return $status == Password::RESET_LINK_SENT
|
||||
// ? back()->with('status', __($status))
|
||||
// : back()->withInput($request->only('email'))
|
||||
// ->withErrors(['email' => __($status)]);
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\UserConsent;
|
||||
use App\Notifications\Auth\LegalConsentConfirmation;
|
||||
use App\Services\Auth\PasswordPolicyService;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected SystemConfigService $systemConfig
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', 'confirmed', PasswordPolicyService::getRules()],
|
||||
'agree_tos_pdp' => ['required', 'accepted'],
|
||||
'marketing_consent' => ['nullable'], // Fix: removed 'boolean' to handle "on" value from checkbox
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => $request->password, // Rely on 'hashed' cast in User model
|
||||
]);
|
||||
|
||||
// Record to history and set initial password_changed_at
|
||||
PasswordPolicyService::recordPasswordChange($user, $user->password);
|
||||
|
||||
// DEFAULT ROLE = User
|
||||
$user->assignRole('User');
|
||||
|
||||
// RECORD CONSENT AUDIT LOGS (UU PDP COMPLIANCE)
|
||||
$this->recordUserConsents($user, $request);
|
||||
|
||||
// TRIGGER CONFIRMATION EMAIL (Wrapped in try-catch to prevent registration failure on mail errors)
|
||||
try {
|
||||
$user->notify(new LegalConsentConfirmation([
|
||||
'tos' => $this->systemConfig->get('tos_document_version', 1),
|
||||
'privacy' => $this->systemConfig->get('pdp_document_version', 1),
|
||||
]));
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to send registration consent email: '.$e->getMessage());
|
||||
}
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the audit log for user consents.
|
||||
*/
|
||||
protected function recordUserConsents(User $user, Request $request): void
|
||||
{
|
||||
$ip = $request->ip();
|
||||
$ua = $request->userAgent();
|
||||
|
||||
// 1. TOS & PDP (Mandatory)
|
||||
UserConsent::create([
|
||||
'user_id' => $user->id,
|
||||
'consent_type' => 'tos',
|
||||
'version_id' => (int) $this->systemConfig->get('tos_document_version', 1),
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => $ua,
|
||||
]);
|
||||
|
||||
UserConsent::create([
|
||||
'user_id' => $user->id,
|
||||
'consent_type' => 'privacy',
|
||||
'version_id' => (int) $this->systemConfig->get('pdp_document_version', 1),
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => $ua,
|
||||
]);
|
||||
|
||||
// 2. Marketing (Optional)
|
||||
if ($request->boolean('marketing_consent')) {
|
||||
UserConsent::create([
|
||||
'user_id' => $user->id,
|
||||
'consent_type' => 'marketing',
|
||||
'version_id' => 1,
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => $ua,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
class SocialAuthController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected SystemConfigService $systemConfig
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Redirect to Provider OAuth
|
||||
*/
|
||||
public function redirect(string $provider)
|
||||
{
|
||||
$this->ensureFeatureEnabled($provider);
|
||||
|
||||
// Save provider to session for the unified callback
|
||||
session(['social_auth_provider' => $provider]);
|
||||
|
||||
return Socialite::driver($provider)->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Provider Callback
|
||||
*/
|
||||
public function callback()
|
||||
{
|
||||
$provider = session('social_auth_provider');
|
||||
|
||||
if (! $provider) {
|
||||
return redirect('/login')->with('error', __('Authentication provider not found in session.'));
|
||||
}
|
||||
|
||||
$this->ensureFeatureEnabled($provider);
|
||||
|
||||
try {
|
||||
$socialUser = Socialite::driver($provider)->user();
|
||||
} catch (Exception $e) {
|
||||
return redirect('/login')->with('error', __(':provider authentication failed.', ['provider' => ucfirst($provider)]));
|
||||
}
|
||||
|
||||
$idColumn = $provider.'_id'; // google_id, facebook_id, github_id
|
||||
|
||||
// Reject if the OAuth provider signals the email is not verified
|
||||
$emailVerified = $socialUser->user['email_verified'] ?? null;
|
||||
if ($emailVerified === false) {
|
||||
return redirect('/login')->with('error', __('Your :provider email address is not verified. Please verify it and try again.', ['provider' => ucfirst($provider)]));
|
||||
}
|
||||
|
||||
// Primary lookup: by provider-specific ID (not spoofable)
|
||||
$user = User::where($idColumn, $socialUser->id)->first();
|
||||
|
||||
// Secondary lookup: by email only if no provider-ID match exists yet
|
||||
// (covers first-time OAuth login for users who registered via email)
|
||||
if (! $user && $socialUser->email) {
|
||||
$byEmail = User::where('email', $socialUser->email)->first();
|
||||
if ($byEmail) {
|
||||
// Only link if the existing account does NOT already belong to a different OAuth identity
|
||||
if (empty($byEmail->{$idColumn})) {
|
||||
$user = $byEmail;
|
||||
} else {
|
||||
// Email already linked to a different identity on this provider — refuse silently
|
||||
// to avoid leaking that the account exists or letting an attacker take it over.
|
||||
return redirect('/login')->with('error', __('This email is already linked to a different account. Please sign in with your original method.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $user) {
|
||||
// Register new user
|
||||
$user = User::create([
|
||||
'name' => $socialUser->name ?? $socialUser->nickname ?? $socialUser->email,
|
||||
'email' => $socialUser->email,
|
||||
$idColumn => $socialUser->id,
|
||||
'avatar' => $socialUser->avatar,
|
||||
'password' => bcrypt(Str::random(32)),
|
||||
]);
|
||||
|
||||
// Assign default role
|
||||
$user->assignRole('User');
|
||||
} else {
|
||||
// Sync Social ID and Avatar
|
||||
$user->update([
|
||||
$idColumn => $socialUser->id,
|
||||
'avatar' => $socialUser->avatar,
|
||||
]);
|
||||
}
|
||||
|
||||
Auth::login($user, true);
|
||||
|
||||
session()->forget('social_auth_provider');
|
||||
session()->regenerate();
|
||||
|
||||
return redirect()->intended('/dashboard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the requested provider is enabled in settings
|
||||
*/
|
||||
protected function ensureFeatureEnabled(string $provider): void
|
||||
{
|
||||
$settingKey = 'feature_'.$provider.'_oauth';
|
||||
|
||||
if ($provider === 'facebook' || $provider === 'github') {
|
||||
// GitHub and Facebook keys follow the 'feature_{provider}_oauth' pattern
|
||||
}
|
||||
|
||||
abort_unless($this->systemConfig->get($settingKey, false), 404, __('Provider not enabled.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\TwoFactorOtp;
|
||||
use App\Models\UserTrustedDevice;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TwoFactorController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
if (! Session::has('auth.2fa_user_id')) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// Check if device is already trusted (Redirect to verify immediately with cookie token)
|
||||
$userId = Session::get('auth.2fa_user_id');
|
||||
$deviceId = request()->cookie('2fa_trust_device');
|
||||
|
||||
if ($deviceId && str_contains($deviceId, '|')) {
|
||||
$parts = explode('|', $deviceId, 2);
|
||||
if (count($parts) !== 2 || empty($parts[0]) || empty($parts[1])) {
|
||||
return view('auth.two-factor');
|
||||
}
|
||||
[$uuid, $secret] = $parts;
|
||||
|
||||
$trust = UserTrustedDevice::where('user_id', $userId)
|
||||
->where('device_id', $uuid)
|
||||
->where('expires_at', '>', now())
|
||||
->first();
|
||||
|
||||
if ($trust && hash_equals($trust->token, hash('sha256', $secret))) {
|
||||
// Auto login and skip 2FA view
|
||||
$remember = Session::get('auth.2fa_remember', false);
|
||||
Auth::loginUsingId($userId, $remember);
|
||||
Session::forget(['auth.2fa_user_id', 'auth.2fa_code', 'auth.2fa_expires_at', 'auth.2fa_remember']);
|
||||
session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
|
||||
return view('auth.two-factor');
|
||||
}
|
||||
|
||||
public function verify(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'code' => 'required|string|size:6',
|
||||
'trust_device' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$userId = Session::get('auth.2fa_user_id');
|
||||
$storedCode = Session::get('auth.2fa_code');
|
||||
$expiresAt = Session::get('auth.2fa_expires_at');
|
||||
|
||||
if (! $userId || ! $storedCode || ! hash_equals((string) $storedCode, (string) $request->code)) {
|
||||
return back()->with('error', __('Invalid verification code.'));
|
||||
}
|
||||
|
||||
if (! $expiresAt || now()->timestamp > $expiresAt) {
|
||||
Session::forget(['auth.2fa_user_id', 'auth.2fa_code', 'auth.2fa_expires_at', 'auth.2fa_remember']);
|
||||
|
||||
return redirect()->route('login')->with('error', __('Verification code has expired. Please log in again.'));
|
||||
}
|
||||
|
||||
// Handle Trust Device
|
||||
if ($request->boolean('trust_device')) {
|
||||
$this->issueTrustToken($userId);
|
||||
}
|
||||
|
||||
// Login user
|
||||
$remember = Session::get('auth.2fa_remember', false);
|
||||
Auth::loginUsingId($userId, $remember);
|
||||
|
||||
// Clear 2FA session then regenerate to prevent fixation
|
||||
Session::forget(['auth.2fa_user_id', 'auth.2fa_code', 'auth.2fa_expires_at', 'auth.2fa_remember']);
|
||||
session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
protected function issueTrustToken($userId)
|
||||
{
|
||||
$deviceId = Str::uuid();
|
||||
$token = Str::random(64);
|
||||
$days = get_setting('two_factor_trust_days', 30);
|
||||
|
||||
UserTrustedDevice::create([
|
||||
'user_id' => $userId,
|
||||
'device_id' => $deviceId,
|
||||
'token' => hash('sha256', $token),
|
||||
'expires_at' => now()->addDays($days),
|
||||
]);
|
||||
|
||||
// Queue cookie with both UUID and Secret
|
||||
cookie()->queue(
|
||||
'2fa_trust_device',
|
||||
$deviceId.'|'.$token,
|
||||
$days * 24 * 60,
|
||||
null, null, true, true // secure, httpOnly
|
||||
);
|
||||
}
|
||||
|
||||
public static function generateAndSendOtp($user)
|
||||
{
|
||||
$otp = str_pad((string) (hexdec(bin2hex(random_bytes(3))) % 1000000), 6, '0', STR_PAD_LEFT);
|
||||
$expiresAt = now()->addMinutes(10)->timestamp;
|
||||
|
||||
Session::put('auth.2fa_user_id', $user->id);
|
||||
Session::put('auth.2fa_code', $otp);
|
||||
Session::put('auth.2fa_expires_at', $expiresAt);
|
||||
|
||||
try {
|
||||
$request = request();
|
||||
Mail::to($user->email)->send(new TwoFactorOtp(
|
||||
otp: $otp,
|
||||
userName: $user->name,
|
||||
ipAddress: $request->ip(),
|
||||
userAgent: $request->userAgent(),
|
||||
));
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to send 2FA Email: '.$e->getMessage());
|
||||
}
|
||||
|
||||
session()->flash('info', __('Verification code has been sent to your email.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?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;
|
||||
|
||||
use App\Models\DashboardWidgetPreference;
|
||||
use App\Services\Monitoring\SystemMonitoringService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected SystemMonitoringService $monitor
|
||||
) {}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$stats = $this->monitor->getAll();
|
||||
$widgets = DashboardWidgetPreference::forUser(auth()->id());
|
||||
|
||||
return view('pages.dashboard', compact('stats', 'widgets'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save widget visibility + order for the authenticated user.
|
||||
*/
|
||||
public function saveWidgetPreferences(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'widgets' => ['required', 'array'],
|
||||
'widgets.*.key' => ['required', 'string', 'max:64'],
|
||||
'widgets.*.visible' => ['required', 'boolean'],
|
||||
'widgets.*.sort_order' => ['required', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
$userId = auth()->id();
|
||||
|
||||
foreach ($validated['widgets'] as $w) {
|
||||
DashboardWidgetPreference::updateOrCreate(
|
||||
['user_id' => $userId, 'widget_key' => $w['key']],
|
||||
['visible' => $w['visible'], 'sort_order' => $w['sort_order']]
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset widget preferences to defaults.
|
||||
*/
|
||||
public function resetWidgetPreferences()
|
||||
{
|
||||
DashboardWidgetPreference::where('user_id', auth()->id())->delete();
|
||||
|
||||
return back()->with('success', 'Dashboard layout reset to defaults.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Events\ImpersonationStatusChanged;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ImpersonateController extends Controller
|
||||
{
|
||||
/**
|
||||
* START IMPERSONATE
|
||||
*/
|
||||
public function start(User $user)
|
||||
{
|
||||
$authUser = Auth::user();
|
||||
|
||||
/**
|
||||
* =====================================================
|
||||
* CEGAH IMPERSONATE DIRI SENDIRI
|
||||
* =====================================================
|
||||
*/
|
||||
abort_if(
|
||||
$authUser->id === $user->id,
|
||||
403,
|
||||
__('You cannot impersonate yourself.')
|
||||
);
|
||||
|
||||
/**
|
||||
* =====================================================
|
||||
* CEGAH IMPERSONATE SUPER ADMIN
|
||||
* =====================================================
|
||||
*/
|
||||
abort_if(
|
||||
$user->hasRole('Developer', 'web'),
|
||||
403,
|
||||
__('You cannot impersonate a Super Admin.')
|
||||
);
|
||||
|
||||
/**
|
||||
* =====================================================
|
||||
* CEK STATUS USER
|
||||
* =====================================================
|
||||
*/
|
||||
abort_if(
|
||||
! $user->is_active,
|
||||
403,
|
||||
__('User is inactive.')
|
||||
);
|
||||
|
||||
/**
|
||||
* =====================================================
|
||||
* CEGAH LOOP IMPERSONATE
|
||||
* =====================================================
|
||||
*/
|
||||
if (session()->has('impersonator_id')) {
|
||||
return redirect()->back()
|
||||
->with('error', __('You are already impersonating another user.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* =====================================================
|
||||
* SIMPAN SUPER ADMIN ID
|
||||
* =====================================================
|
||||
*/
|
||||
session([
|
||||
'impersonator_id' => $authUser->id,
|
||||
]);
|
||||
|
||||
/**
|
||||
* =====================================================
|
||||
* LOGIN SEBAGAI USER TARGET
|
||||
* =====================================================
|
||||
*/
|
||||
Auth::loginUsingId($user->id);
|
||||
session()->regenerate();
|
||||
|
||||
// Mark user as being impersonated in cache for target user awareness
|
||||
Cache::put("is_being_impersonated:{$user->id}", Auth::id(), now()->addHours(2));
|
||||
|
||||
// 📡 Broadcast live alert to target user
|
||||
event(new ImpersonationStatusChanged($user->id, true));
|
||||
|
||||
return redirect()->route('dashboard')
|
||||
->with('success', __('You are now impersonating this user.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* STOP IMPERSONATE
|
||||
*/
|
||||
public function stop()
|
||||
{
|
||||
abort_if(
|
||||
! session()->has('impersonator_id'),
|
||||
403,
|
||||
__('No impersonation session found.')
|
||||
);
|
||||
|
||||
$targetUserId = Auth::id();
|
||||
$superAdminId = session()->pull('impersonator_id');
|
||||
$superAdmin = User::findOrFail($superAdminId);
|
||||
|
||||
Auth::login($superAdmin);
|
||||
session()->regenerate();
|
||||
|
||||
// Clear awareness flag for target user
|
||||
Cache::forget("is_being_impersonated:{$targetUserId}");
|
||||
|
||||
// 📡 Broadcast live alert (Remove) to target user
|
||||
event(new ImpersonationStatusChanged($targetUserId, false));
|
||||
|
||||
// Sync last_session_id to prevent single session logout
|
||||
$superAdmin->update(['last_session_id' => session()->getId()]);
|
||||
|
||||
return redirect()->route('users')
|
||||
->with('success', __('Returned to Super Admin account.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\UserConsent;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class LegalController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected SystemConfigService $systemConfig
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display the specified legal/content page.
|
||||
*/
|
||||
public function show(string $type): View
|
||||
{
|
||||
$validTypes = ['help', 'tos', 'privacy', 'about', 'security'];
|
||||
|
||||
if (! in_array($type, $validTypes)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$content = $this->systemConfig->get("page_{$type}_content", '');
|
||||
$title = $this->getPageTitle($type);
|
||||
|
||||
// Map 'privacy' type to 'pdp' key for versioning and content if needed
|
||||
$configKey = ($type === 'privacy' || $type === 'pdp') ? 'pdp' : $type;
|
||||
$version = $this->systemConfig->get("{$configKey}_document_version", 1);
|
||||
$lastUpdated = $this->systemConfig->get('legal_last_updated');
|
||||
|
||||
return view('pages.public.legal', [
|
||||
'type' => $type,
|
||||
'title' => $title,
|
||||
'content' => $content,
|
||||
'version' => $version,
|
||||
'lastUpdated' => $lastUpdated,
|
||||
'dpo_email' => $this->systemConfig->get('pdp_dpo_email'),
|
||||
'company_address' => $this->systemConfig->get('pdp_company_address'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the re-agreement page.
|
||||
*/
|
||||
public function reAgree(): View
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$missingTos = ! $user->hasAgreedToCurrentLegal('tos');
|
||||
$missingPrivacy = ! $user->hasAgreedToCurrentLegal('privacy');
|
||||
|
||||
return view('pages.public.re-agree', [
|
||||
'missingTos' => $missingTos,
|
||||
'missingPrivacy' => $missingPrivacy,
|
||||
'tosContent' => $this->systemConfig->get('page_tos_content'),
|
||||
'privacyContent' => $this->systemConfig->get('page_pdp_content') ?? $this->systemConfig->get('page_privacy_content'),
|
||||
'tosVersion' => $this->systemConfig->get('tos_document_version', 1),
|
||||
'privacyVersion' => $this->systemConfig->get('pdp_document_version', 1),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the re-agreement submission.
|
||||
*/
|
||||
public function postReAgree(Request $request): RedirectResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
$ip = $request->ip();
|
||||
$ua = $request->userAgent();
|
||||
|
||||
if ($request->has('agree_tos')) {
|
||||
UserConsent::create([
|
||||
'user_id' => $user->id,
|
||||
'consent_type' => 'tos',
|
||||
'version_id' => (int) $this->systemConfig->get('tos_document_version', 1),
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => $ua,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->has('agree_privacy')) {
|
||||
UserConsent::create([
|
||||
'user_id' => $user->id,
|
||||
'consent_type' => 'privacy',
|
||||
'version_id' => (int) $this->systemConfig->get('pdp_document_version', 1),
|
||||
'ip_address' => $ip,
|
||||
'user_agent' => $ua,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('dashboard')->with('success', __('Thank you for keeping your agreements up to date.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map type to human-readable title.
|
||||
*/
|
||||
protected function getPageTitle(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'help' => __('Help Center & FAQ'),
|
||||
'tos' => __('Terms of Use'),
|
||||
'privacy' => __('Privacy Policy (UU PDP)'),
|
||||
'about' => __('About Us'),
|
||||
'security' => __('Security Policy'),
|
||||
default => __('Legal Document'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ProfileUpdateRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the user's profile form.
|
||||
*/
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
return view('profile.edit', [
|
||||
'user' => $request->user(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request)
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Profile updated successfully.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validateWithBag('userDeletion', [
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return Redirect::to('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?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
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\System;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\System\GlobalSearchService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GlobalSearchController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected GlobalSearchService $searchService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Perform global search.
|
||||
*/
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$query = $request->get('q', '');
|
||||
|
||||
$results = $this->searchService->search($query);
|
||||
|
||||
return response()->json($results);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SystemSettings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiHealingLog;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AiSelfHealingController extends Controller
|
||||
{
|
||||
public function index(SystemConfigService $systemConfig)
|
||||
{
|
||||
$this->authorize('view ai self-healing');
|
||||
$grouped = $systemConfig->grouped();
|
||||
$settings = $grouped['ai_healing'] ?? [];
|
||||
$aiCfg = $grouped['ai_config'] ?? [];
|
||||
|
||||
$logs = AiHealingLog::latest()->take(100)->get();
|
||||
$stats = $this->buildStats();
|
||||
|
||||
$topExceptions = AiHealingLog::select('error_type', DB::raw('COUNT(*) as total'))
|
||||
->groupBy('error_type')
|
||||
->orderByDesc('total')
|
||||
->take(5)
|
||||
->get()
|
||||
->map(fn ($r) => [
|
||||
'type' => class_basename($r->error_type),
|
||||
'fqcn' => $r->error_type,
|
||||
'count' => (int) $r->getAttribute('total'),
|
||||
])
|
||||
->all();
|
||||
|
||||
$timeline = $this->build24hTimeline();
|
||||
$lastIncident = AiHealingLog::latest()->value('created_at');
|
||||
|
||||
$providerInfo = [
|
||||
'enabled' => (bool) ($aiCfg['ai_enabled'] ?? false),
|
||||
'provider' => strtoupper($aiCfg['ai_provider'] ?? 'gpt'),
|
||||
'has_key' => $this->resolveProviderKeyPresent($aiCfg),
|
||||
];
|
||||
|
||||
return view('pages.system_settings.ai-self-healing', compact(
|
||||
'logs', 'settings', 'stats', 'topExceptions', 'timeline', 'lastIncident', 'providerInfo'
|
||||
));
|
||||
}
|
||||
|
||||
public function update(Request $request, SystemConfigService $systemConfig)
|
||||
{
|
||||
$this->authorize('manage ai self-healing');
|
||||
$input = [
|
||||
'ai_healing_enabled' => $request->has('ai_healing_enabled'),
|
||||
'ai_healing_allow_cache' => $request->has('ai_healing_allow_cache'),
|
||||
'ai_healing_allow_queue' => $request->has('ai_healing_allow_queue'),
|
||||
'ai_healing_allow_maintenance' => $request->has('ai_healing_allow_maintenance'),
|
||||
'ai_healing_allow_db' => $request->has('ai_healing_allow_db'),
|
||||
'ai_healing_max_attempts_per_hour' => $request->input('ai_healing_max_attempts_per_hour', 5),
|
||||
];
|
||||
|
||||
$systemConfig->update($input, [], auth()->id(), $request);
|
||||
|
||||
if ($request->wantsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'AI Healing configuration saved successfully.',
|
||||
'settings' => $input,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'AI Healing configuration saved successfully.');
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$this->authorize('view ai self-healing');
|
||||
$log = AiHealingLog::findOrFail($id);
|
||||
|
||||
// Parse the action_taken marker for human-readable display + rollback info
|
||||
$action = (string) $log->action_taken;
|
||||
$actionPretty = $action;
|
||||
$rollbackInfo = null;
|
||||
|
||||
if (str_starts_with($action, 'CODE_EDIT|')) {
|
||||
$parts = explode('|', $action, 3);
|
||||
$filePath = $parts[1] ?? '';
|
||||
$backupPath = $parts[2] ?? '';
|
||||
$backupExists = $backupPath && file_exists($backupPath);
|
||||
$actionPretty = 'Code edit applied in ' . basename($filePath)
|
||||
. ($backupExists ? ' (backup available)' : ' (backup missing)');
|
||||
$rollbackInfo = [
|
||||
'available' => $backupExists,
|
||||
'file' => basename($filePath),
|
||||
'backup_file' => basename($backupPath),
|
||||
];
|
||||
} elseif (str_starts_with($action, 'ROLLED_BACK|')) {
|
||||
$parts = explode('|', $action, 3);
|
||||
$filePath = $parts[1] ?? '';
|
||||
$actionPretty = 'Rolled back: ' . basename($filePath) . ' restored from backup';
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'id' => $log->id,
|
||||
'error_type' => $log->error_type,
|
||||
'error_message' => $log->error_message,
|
||||
'stack_trace' => $log->stack_trace,
|
||||
'ai_diagnosis' => $log->ai_diagnosis,
|
||||
'action_taken' => $actionPretty,
|
||||
'action_raw' => $action,
|
||||
'rollback' => $rollbackInfo,
|
||||
'status' => $log->status,
|
||||
'original_code' => $log->original_code,
|
||||
'fixed_code' => $log->fixed_code,
|
||||
'created_at' => optional($log->created_at)->format('d M Y, H:i:s'),
|
||||
'relative_time' => optional($log->created_at)->diffForHumans(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function clearLogs()
|
||||
{
|
||||
$this->authorize('manage ai self-healing');
|
||||
AiHealingLog::truncate();
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'All AI Healing logs have been cleared successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function retry($id)
|
||||
{
|
||||
$this->authorize('manage ai self-healing');
|
||||
$log = AiHealingLog::findOrFail($id);
|
||||
|
||||
if (!\in_array($log->status, ['failed', 'diagnosing', 'pending'], true)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Only failed, pending, or stuck-diagnosing logs can be retried.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$log->update([
|
||||
'status' => 'pending',
|
||||
'action_taken' => 'Retry queued by ' . (auth()->user()->name ?? 'system'),
|
||||
]);
|
||||
|
||||
try {
|
||||
// Synchronous dispatch — matches the original interceptor behaviour
|
||||
// so the user sees the new outcome immediately on refresh.
|
||||
\App\Jobs\AiHealerJob::dispatchSync($log->id);
|
||||
} catch (\Throwable $e) {
|
||||
$log->update([
|
||||
'status' => 'failed',
|
||||
'ai_diagnosis' => 'Retry crashed: ' . $e->getMessage(),
|
||||
'action_taken' => 'Retry pipeline error',
|
||||
]);
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Retry failed: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Healing re-attempted. Status: ' . $log->fresh()->status,
|
||||
'status' => $log->fresh()->status,
|
||||
]);
|
||||
}
|
||||
|
||||
public function rollback($id)
|
||||
{
|
||||
$this->authorize('manage ai self-healing');
|
||||
$log = AiHealingLog::findOrFail($id);
|
||||
|
||||
$action = (string) $log->action_taken;
|
||||
if (!str_starts_with($action, 'CODE_EDIT|')) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'This incident has no code-edit backup to roll back.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$parts = explode('|', $action, 3);
|
||||
$filePath = $parts[1] ?? null;
|
||||
$backupPath = $parts[2] ?? null;
|
||||
|
||||
if (!$filePath || !$backupPath || !file_exists($backupPath)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Backup file not found. It may have been deleted manually.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
if (!is_writable($filePath)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Target file is not writable.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$backupContent = file_get_contents($backupPath);
|
||||
file_put_contents($filePath, $backupContent);
|
||||
\Illuminate\Support\Facades\Artisan::call('optimize:clear');
|
||||
|
||||
$log->update([
|
||||
'action_taken' => 'ROLLED_BACK|' . $filePath . '|from ' . basename($backupPath),
|
||||
'ai_diagnosis' => '[rolled back by ' . (auth()->user()->name ?? 'system') . '] ' . $log->ai_diagnosis,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Rollback completed. ' . basename($filePath) . ' restored from backup.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function stats()
|
||||
{
|
||||
$this->authorize('view ai self-healing');
|
||||
return response()->json($this->buildStats() + [
|
||||
'timeline' => $this->build24hTimeline(),
|
||||
'last_incident' => optional(AiHealingLog::latest()->value('created_at'))->diffForHumans() ?? 'No incidents',
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildStats(): array
|
||||
{
|
||||
$total = AiHealingLog::count();
|
||||
$resolved = AiHealingLog::where('status', 'resolved')->count();
|
||||
$failed = AiHealingLog::where('status', 'failed')->count();
|
||||
$pending = AiHealingLog::whereIn('status', ['pending', 'diagnosing'])->count();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'resolved' => $resolved,
|
||||
'failed' => $failed,
|
||||
'pending' => $pending,
|
||||
'rate' => $total > 0 ? round(($resolved / $total) * 100) : 0,
|
||||
'last_24h' => AiHealingLog::where('created_at', '>=', now()->subDay())->count(),
|
||||
];
|
||||
}
|
||||
|
||||
private function build24hTimeline(): array
|
||||
{
|
||||
$from = now()->subHours(23)->startOfHour();
|
||||
|
||||
$buckets = [];
|
||||
for ($i = 23; $i >= 0; $i--) {
|
||||
$hour = now()->subHours($i)->startOfHour();
|
||||
$buckets[$hour->format('Y-m-d H')] = [
|
||||
'label' => $hour->format('H:00'),
|
||||
'count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
AiHealingLog::where('created_at', '>=', $from)
|
||||
->select('created_at')
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->each(function ($row) use (&$buckets) {
|
||||
$key = Carbon::parse($row->created_at)->startOfHour()->format('Y-m-d H');
|
||||
if (isset($buckets[$key])) {
|
||||
$buckets[$key]['count']++;
|
||||
}
|
||||
});
|
||||
|
||||
return array_values($buckets);
|
||||
}
|
||||
|
||||
private function resolveProviderKeyPresent(array $aiCfg): bool
|
||||
{
|
||||
$provider = $aiCfg['ai_provider'] ?? 'gpt';
|
||||
$keyMap = [
|
||||
'gpt' => 'ai_gpt_key',
|
||||
'gemini' => 'ai_gemini_key',
|
||||
'claude' => 'ai_claude_key',
|
||||
'deepseek' => 'ai_deepseek_key',
|
||||
'grok' => 'ai_grok_key',
|
||||
'mistral' => 'ai_mistral_key',
|
||||
'openrouter' => 'ai_openrouter_key',
|
||||
];
|
||||
|
||||
$field = $keyMap[$provider] ?? null;
|
||||
return $field !== null && !empty($aiCfg[$field] ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SystemSettings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\System\BackupManagementService;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
class BackupRestoreController extends Controller
|
||||
{
|
||||
protected $backupService;
|
||||
|
||||
protected $systemConfig;
|
||||
|
||||
public function __construct(
|
||||
BackupManagementService $backupService,
|
||||
SystemConfigService $systemConfig
|
||||
) {
|
||||
$this->backupService = $backupService;
|
||||
$this->systemConfig = $systemConfig;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
if ($request->wantsJson() || $request->ajax()) {
|
||||
try {
|
||||
session_write_close();
|
||||
$driver = $request->get('driver');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'backups' => $this->backupService->getBackupList(),
|
||||
'stats' => $this->backupService->getStorageStats($driver),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
return view('pages.system_settings.backup-restore', [
|
||||
'settings' => $this->systemConfig->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
try {
|
||||
// Prevent session locking during the long-running backup process
|
||||
session_write_close();
|
||||
|
||||
set_time_limit(0);
|
||||
ini_set('memory_limit', '512M');
|
||||
|
||||
$this->backupService->createBackup();
|
||||
|
||||
// ✍️ Log "Action Log" backup creation
|
||||
activity('backups')
|
||||
->causedBy(auth()->user())
|
||||
->withProperties([
|
||||
'ip' => request()->ip(),
|
||||
'agent' => request()->userAgent(),
|
||||
'details' => __('Manually triggered a system and database backup.'),
|
||||
])
|
||||
->log(__('Generated System Backup'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Backup created successfully'),
|
||||
'backups' => $this->backupService->getBackupList(forceRefresh: true),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Backup failed: '.$e->getMessage());
|
||||
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function allowedDisks(): array
|
||||
{
|
||||
return ['local', 's3', 'gdrive'];
|
||||
}
|
||||
|
||||
public function download(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'disk' => ['required', 'string', Rule::in($this->allowedDisks())],
|
||||
'path' => ['required', 'string'],
|
||||
]);
|
||||
$disk = $validated['disk'];
|
||||
$path = $validated['path'];
|
||||
|
||||
if (! Storage::disk($disk)->exists($path)) {
|
||||
abort(404, __('File not found'));
|
||||
}
|
||||
|
||||
// ✍️ Log "Action Log" backup download
|
||||
activity('backups')
|
||||
->causedBy(auth()->user())
|
||||
->withProperties([
|
||||
'ip' => $request->ip(),
|
||||
'agent' => $request->userAgent(),
|
||||
'file' => $path,
|
||||
'disk' => $disk,
|
||||
'details' => __('Downloaded backup file: :file from :disk storage.', ['file' => $path, 'disk' => $disk]),
|
||||
])
|
||||
->log(__('Downloaded Backup File'));
|
||||
|
||||
return Storage::disk($disk)->download($path);
|
||||
}
|
||||
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
try {
|
||||
session_write_close();
|
||||
$validated = $request->validate([
|
||||
'disk' => ['required', 'string', Rule::in($this->allowedDisks())],
|
||||
'path' => ['required', 'string'],
|
||||
]);
|
||||
$disk = $validated['disk'];
|
||||
$path = $validated['path'];
|
||||
|
||||
if (Storage::disk($disk)->exists($path)) {
|
||||
Storage::disk($disk)->delete($path);
|
||||
|
||||
// ✍️ Log "Action Log" backup deletion
|
||||
activity('backups')
|
||||
->causedBy(auth()->user())
|
||||
->withProperties([
|
||||
'ip' => $request->ip(),
|
||||
'agent' => $request->userAgent(),
|
||||
'file' => $path,
|
||||
'details' => __('Permanently deleted backup file: :file', ['file' => $path]),
|
||||
])
|
||||
->log(__('Deleted Backup File'));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Backup deleted successfully'),
|
||||
'backups' => $this->backupService->getBackupList(forceRefresh: true),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function restore(Request $request)
|
||||
{
|
||||
try {
|
||||
session_write_close();
|
||||
set_time_limit(0);
|
||||
ini_set('memory_limit', '512M');
|
||||
|
||||
$validated = $request->validate([
|
||||
'disk' => ['required', 'string', Rule::in($this->allowedDisks())],
|
||||
'path' => ['required', 'string'],
|
||||
]);
|
||||
$disk = $validated['disk'];
|
||||
$path = $validated['path'];
|
||||
|
||||
$this->backupService->restoreBackup($disk, $path);
|
||||
|
||||
// ✍️ Log "Action Log" system restore
|
||||
activity('backups')
|
||||
->causedBy(auth()->user())
|
||||
->withProperties([
|
||||
'ip' => $request->ip(),
|
||||
'agent' => $request->userAgent(),
|
||||
'file' => $path,
|
||||
'details' => __('Successfully restored system from backup: :file', ['file' => $path]),
|
||||
])
|
||||
->log(__('Restored System from Backup'));
|
||||
|
||||
return response()->json([
|
||||
'message' => __('System restored successfully. Cache cleared.'),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Restore failed: '.$e->getMessage());
|
||||
|
||||
return response()->json(['message' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function testConnection()
|
||||
{
|
||||
try {
|
||||
$result = $this->backupService->testConnection();
|
||||
|
||||
return response()->json($result);
|
||||
} catch (Exception $e) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to Google for authorization.
|
||||
*/
|
||||
public function googleAuth()
|
||||
{
|
||||
$settings = $this->systemConfig->all();
|
||||
|
||||
if (empty($settings['gdrive_client_id']) || empty($settings['gdrive_client_secret'])) {
|
||||
return redirect()->back()->with('error', __('Please save Client ID and Secret first.'));
|
||||
}
|
||||
|
||||
// Dynamically set config for Socialite to use settings from database
|
||||
config([
|
||||
'services.google.client_id' => $settings['gdrive_client_id'],
|
||||
'services.google.client_secret' => $settings['gdrive_client_secret'],
|
||||
'services.google.redirect' => route('backup-restore.google-callback'),
|
||||
]);
|
||||
|
||||
return Socialite::driver('google')
|
||||
->scopes(['https://www.googleapis.com/auth/drive.file'])
|
||||
->with(['access_type' => 'offline', 'prompt' => 'consent'])
|
||||
->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Google authorization callback.
|
||||
*/
|
||||
public function googleCallback()
|
||||
{
|
||||
try {
|
||||
$settings = $this->systemConfig->all();
|
||||
|
||||
config([
|
||||
'services.google.client_id' => $settings['gdrive_client_id'],
|
||||
'services.google.client_secret' => $settings['gdrive_client_secret'],
|
||||
'services.google.redirect' => route('backup-restore.google-callback'),
|
||||
]);
|
||||
|
||||
$user = Socialite::driver('google')->user();
|
||||
$refreshToken = $user->refreshToken;
|
||||
|
||||
if (! $refreshToken) {
|
||||
return redirect()->route('backup-restore.index')->with('error', __('Failed to get refresh token. Please ensure you have not already authorized this app, or try revoking access and authorizing again.'));
|
||||
}
|
||||
|
||||
// Save the refresh token to system settings
|
||||
$this->systemConfig->update(['gdrive_refresh_token' => $refreshToken]);
|
||||
|
||||
return redirect()->route('backup-restore.index')->with('status', __('Google Drive authorized successfully!'));
|
||||
} catch (Exception $e) {
|
||||
Log::error('Google Auth Failed: '.$e->getMessage());
|
||||
|
||||
return redirect()->route('backup-restore.index')->with('error', __('Authorization failed: ').$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SystemSettings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EditorUploadController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle Image Upload from CKEditor 5 (Simple Upload Adapter)
|
||||
*/
|
||||
public function upload(Request $request)
|
||||
{
|
||||
if (! $request->hasFile('upload')) {
|
||||
return response()->json(['error' => ['message' => 'No file uploaded.']], 400);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'upload' => 'required|image|mimes:jpeg,png,jpg,gif,webp|max:5120', // 5MB limit
|
||||
]);
|
||||
|
||||
try {
|
||||
$file = $request->file('upload');
|
||||
$fileName = $file->getClientOriginalName();
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
|
||||
// Generate clean filename
|
||||
$name = Str::slug(pathinfo($fileName, PATHINFO_FILENAME));
|
||||
$finalName = 'editor_'.$name.'_'.time().'.'.$extension;
|
||||
|
||||
// Store to public disk (storage/app/public/editor)
|
||||
$path = $file->storeAs('editor', $finalName, 'public');
|
||||
|
||||
return response()->json([
|
||||
'uploaded' => 1,
|
||||
'fileName' => $finalName,
|
||||
'url' => '/storage/'.$path,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => ['message' => 'Upload failed: '.$e->getMessage()]], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SystemSettings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\System\MaintenanceManagementService;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MaintenanceModeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected SystemConfigService $systemConfig,
|
||||
protected MaintenanceManagementService $maintenanceService
|
||||
) {
|
||||
// Middleware handled in web.php
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return view('pages.system_settings.maintenance-mode', [
|
||||
'settings' => $this->systemConfig->all(),
|
||||
'is_down' => $this->maintenanceService->isDown(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function broadcast(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'minutes' => ['required', 'integer', 'min:1', 'max:60'],
|
||||
]);
|
||||
|
||||
$this->maintenanceService->broadcastWarning($validated['minutes']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Broadcast warning sent to all active users.'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SystemSettings;
|
||||
|
||||
use App\Events\NotificationSent;
|
||||
use App\Models\Notification;
|
||||
use App\Repositories\NotificationRepository;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use App\Support\DataTable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class NotificationCenterController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected SystemConfigService $systemConfig,
|
||||
protected NotificationRepository $notificationRepo
|
||||
) {
|
||||
// Middleware handled in web.php
|
||||
}
|
||||
|
||||
protected function ensureFeatureEnabled(): void
|
||||
{
|
||||
$featureEnabled = $this->systemConfig->get('feature_notification_center', true);
|
||||
|
||||
if (! $featureEnabled) {
|
||||
// Instead of 404, we change the authorization requirement.
|
||||
// If feature is disabled, only users who can 'manage global settings' can access.
|
||||
abort_unless(
|
||||
Auth::user()?->can('manage global settings'),
|
||||
403,
|
||||
__('The Notification Center feature is currently disabled by the system administrator.')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* =====================================================
|
||||
* INDEX
|
||||
* =====================================================
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->ensureFeatureEnabled();
|
||||
|
||||
if (DataTable::isDataTableRequest($request)) {
|
||||
return $this->dataTable($request);
|
||||
}
|
||||
|
||||
$roles = Role::pluck('name')->toArray();
|
||||
|
||||
return view('pages.system_settings.notification-center', compact('roles'));
|
||||
}
|
||||
|
||||
/**
|
||||
* DATATABLE (Supports personal status)
|
||||
*/
|
||||
protected function dataTable(Request $request)
|
||||
{
|
||||
try {
|
||||
$user = Auth::user();
|
||||
$authorizedRecipients = $this->notificationRepo->getAuthorizedRecipients($user);
|
||||
|
||||
$start = DataTable::start($request);
|
||||
$length = DataTable::length($request);
|
||||
|
||||
// Efficient query using LEFT JOIN (no N+1)
|
||||
$baseQuery = Notification::whereIn('recipient', $authorizedRecipients)
|
||||
->leftJoin('system_notification_user', function ($join) use ($user) {
|
||||
$join->on('system_notifications.id', '=', 'system_notification_user.notification_id')
|
||||
->where('system_notification_user.user_id', '=', $user->id);
|
||||
})
|
||||
->whereNull('system_notification_user.deleted_at')
|
||||
->select('system_notifications.*', 'system_notification_user.read_at as personal_read_at');
|
||||
|
||||
$recordsTotal = $baseQuery->count();
|
||||
$recordsFiltered = $recordsTotal;
|
||||
|
||||
$notifications = (clone $baseQuery)
|
||||
->latest('system_notifications.created_at')
|
||||
->skip($start)
|
||||
->take($length)
|
||||
->get();
|
||||
|
||||
$rows = $notifications->map(function ($n) {
|
||||
return [
|
||||
'id' => $n->id,
|
||||
'is_unread' => is_null($n->personal_read_at),
|
||||
'title' => e($n->title),
|
||||
'message' => e(strip_tags($n->message)),
|
||||
'type' => $n->type,
|
||||
'recipient' => $n->recipient,
|
||||
'time_ago' => $n->created_at ? $n->created_at->diffForHumans() : '',
|
||||
'read_url' => route('notification-center.read', $n->id),
|
||||
'delete_url' => route('notification-center.destroy', $n->id),
|
||||
'created_at' => $n->created_at ? $n->created_at->format('Y-m-d H:i:s') : '',
|
||||
];
|
||||
})->values();
|
||||
|
||||
return DataTable::response($request, $recordsTotal, $recordsFiltered, $rows->toArray());
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('NotificationCenter DataTable Error: '.$e->getMessage());
|
||||
|
||||
return response()->json(['error' => $e->getMessage(), 'data' => []], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STORE (Send Broadcast)
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$this->ensureFeatureEnabled();
|
||||
|
||||
$roles = Role::pluck('name')->toArray();
|
||||
$roles[] = 'all';
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'message' => 'required|string',
|
||||
'recipient' => 'required|in:'.implode(',', $roles),
|
||||
'type' => 'required|in:info,warning,system',
|
||||
]);
|
||||
|
||||
$notification = $this->notificationRepo->create([
|
||||
...$validated,
|
||||
'created_by' => Auth::id(),
|
||||
'read_at' => null,
|
||||
]);
|
||||
|
||||
event(new NotificationSent($notification));
|
||||
|
||||
return response()->json(['success' => true, 'message' => __('Notification broadcasted.')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* MARK AS READ (Personal)
|
||||
*/
|
||||
public function markAsRead(Notification $notification)
|
||||
{
|
||||
$this->ensureFeatureEnabled();
|
||||
$this->notificationRepo->markAsRead($notification->id, Auth::id());
|
||||
|
||||
return response()->json(['success' => true, 'message' => __('Marked as read.')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE (Personal Hide)
|
||||
*/
|
||||
public function destroy(Notification $notification)
|
||||
{
|
||||
$this->ensureFeatureEnabled();
|
||||
$this->notificationRepo->personalDelete($notification->id, Auth::id());
|
||||
|
||||
return response()->json(['success' => true, 'message' => __('Notification hidden.')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* MARK ALL AS READ (Personal)
|
||||
*/
|
||||
public function markAllAsRead()
|
||||
{
|
||||
$this->ensureFeatureEnabled();
|
||||
$this->notificationRepo->markAllAsReadForUser(Auth::user());
|
||||
|
||||
return response()->json(['success' => true, 'message' => __('All marked as read.')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* CLEAR READ (Personal Hide all read)
|
||||
*/
|
||||
public function clearRead()
|
||||
{
|
||||
$this->ensureFeatureEnabled();
|
||||
$user = Auth::user();
|
||||
|
||||
$readIds = \DB::table('system_notification_user')
|
||||
->where('user_id', $user->id)
|
||||
->whereNotNull('read_at')
|
||||
->pluck('notification_id');
|
||||
|
||||
foreach ($readIds as $id) {
|
||||
$this->notificationRepo->personalDelete($id, $user->id);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'message' => __('Read notifications cleared from your view.')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* RECENT NOTIFICATIONS API (Personal status)
|
||||
*/
|
||||
public function recentNotifications(Request $request)
|
||||
{
|
||||
$this->ensureFeatureEnabled();
|
||||
$user = Auth::user();
|
||||
$offset = $request->get('offset', 0);
|
||||
$limit = $request->get('limit', 10);
|
||||
|
||||
$notifications = $this->notificationRepo->getActiveNotificationsForUser($user, $offset, $limit);
|
||||
|
||||
$mapped = $notifications->map(function ($n) {
|
||||
return [
|
||||
'id' => $n->id,
|
||||
'title' => e($n->title),
|
||||
'message' => e(strip_tags($n->message)),
|
||||
'type' => $n->type,
|
||||
'time_ago' => $n->created_at->diffForHumans(),
|
||||
'read_url' => route('notification-center.read', $n->id),
|
||||
'delete_url' => route('notification-center.destroy', $n->id),
|
||||
'is_unread' => is_null($n->personal_read_at),
|
||||
'recipient' => $n->recipient,
|
||||
];
|
||||
})->values()->all();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'unread_count' => $this->notificationRepo->getUnreadCount($user),
|
||||
'notifications' => $mapped,
|
||||
'has_more' => count($mapped) === (int) $limit,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SystemSettings;
|
||||
|
||||
use App\Helpers\SessionHelper;
|
||||
use App\Services\Monitoring\SystemMonitoringService;
|
||||
use App\Support\DataTable;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class SessionManagerController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
// Middleware handled in web.php
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (DataTable::isDataTableRequest($request)) {
|
||||
return $this->dataTable($request);
|
||||
}
|
||||
|
||||
$stats = $this->getStatsData();
|
||||
|
||||
return view('pages.system_settings.session-manager', compact('stats'));
|
||||
}
|
||||
|
||||
protected function getDriver()
|
||||
{
|
||||
return config('session.driver');
|
||||
}
|
||||
|
||||
public function getStats()
|
||||
{
|
||||
return response()->json($this->getStatsData());
|
||||
}
|
||||
|
||||
protected function getStatsData()
|
||||
{
|
||||
$driver = $this->getDriver();
|
||||
|
||||
if ($driver === 'database') {
|
||||
$activeCutoff = now()->subMinutes(30)->timestamp;
|
||||
|
||||
return [
|
||||
'total' => DB::table('sessions')->count(),
|
||||
'active' => DB::table('sessions')->where('last_activity', '>=', $activeCutoff)->count(),
|
||||
'users' => DB::table('sessions')->whereNotNull('user_id')->count(),
|
||||
'guests' => DB::table('sessions')->whereNull('user_id')->count(),
|
||||
'unique_ips' => DB::table('sessions')->distinct('ip_address')->count('ip_address'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($driver === 'redis') {
|
||||
$service = app(SystemMonitoringService::class);
|
||||
$total = $service->getActiveUsers();
|
||||
|
||||
// For Redis, we'd need to iterate all to get specific user/guest breakdown
|
||||
// For performance, we'll return total for now or a limited set
|
||||
return [
|
||||
'total' => $total,
|
||||
'active' => $total,
|
||||
'users' => 'N/A (Redis)',
|
||||
'guests' => 'N/A (Redis)',
|
||||
'unique_ips' => 'N/A (Redis)',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => 0, 'active' => 0, 'users' => 0, 'guests' => 0, 'unique_ips' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function dataTable(Request $request)
|
||||
{
|
||||
$driver = $this->getDriver();
|
||||
$sessions = [];
|
||||
$recordsTotal = 0;
|
||||
$recordsFiltered = 0;
|
||||
$activeCutoff = now()->subMinutes(30)->timestamp;
|
||||
$idleCutoff = now()->subMinutes(5)->timestamp;
|
||||
$currentSessionId = session()->getId();
|
||||
|
||||
if ($driver === 'database') {
|
||||
$query = DB::table('sessions')
|
||||
->leftJoin('users', 'sessions.user_id', '=', 'users.id')
|
||||
->select(
|
||||
'sessions.*',
|
||||
'users.email as user_email',
|
||||
'users.name as user_name'
|
||||
);
|
||||
|
||||
$recordsTotal = DB::table('sessions')->count();
|
||||
|
||||
// Status Filter
|
||||
if ($status = DataTable::columnSearch($request, 0)) {
|
||||
$operator = $status === 'active' ? '>=' : '<';
|
||||
$query->where('sessions.last_activity', $operator, $activeCutoff);
|
||||
}
|
||||
|
||||
// User Filter
|
||||
if ($userSearch = DataTable::columnSearch($request, 1)) {
|
||||
$query->where(function ($q) use ($userSearch) {
|
||||
$q->where('users.email', 'like', "%{$userSearch}%")
|
||||
->orWhere('users.name', 'like', "%{$userSearch}%");
|
||||
});
|
||||
}
|
||||
|
||||
// IP Filter
|
||||
if ($ipSearch = DataTable::columnSearch($request, 3)) {
|
||||
$query->where('sessions.ip_address', 'like', "%{$ipSearch}%");
|
||||
}
|
||||
|
||||
// Global Search
|
||||
if ($globalSearch = DataTable::globalSearch($request)) {
|
||||
$query->where(function ($q) use ($globalSearch) {
|
||||
$q->where('users.email', 'like', "%{$globalSearch}%")
|
||||
->orWhere('users.name', 'like', "%{$globalSearch}%")
|
||||
->orWhere('sessions.id', 'like', "%{$globalSearch}%")
|
||||
->orWhere('sessions.ip_address', 'like', "%{$globalSearch}%");
|
||||
});
|
||||
}
|
||||
|
||||
$recordsFiltered = (clone $query)->count();
|
||||
[$orderIndex, $orderDirection] = DataTable::order($request, 4, 'desc');
|
||||
|
||||
$sortColumn = match ($orderIndex) {
|
||||
0 => 'sessions.last_activity',
|
||||
1 => 'users.name',
|
||||
3 => 'sessions.ip_address',
|
||||
4 => 'sessions.last_activity',
|
||||
default => 'sessions.last_activity',
|
||||
};
|
||||
|
||||
$sessions = $query
|
||||
->orderBy($sortColumn, $orderDirection)
|
||||
->skip(DataTable::start($request))
|
||||
->take(DataTable::length($request))
|
||||
->get();
|
||||
} else {
|
||||
// REDIS DRIVER LOGIC
|
||||
$connection = config('session.connection') ?? 'default';
|
||||
$redis = Redis::connection($connection);
|
||||
$sessionCookie = config('session.cookie', 'laravel_session');
|
||||
$prefix = config('database.redis.options.prefix', '');
|
||||
|
||||
// Optimization: Get keys once
|
||||
$patterns = [$sessionCookie.':*'];
|
||||
$keys = [];
|
||||
foreach ($patterns as $p) {
|
||||
$searchPattern = str_replace($prefix, '', $p);
|
||||
$keys = array_merge($keys, $redis->keys($searchPattern));
|
||||
}
|
||||
$keys = array_unique($keys);
|
||||
$recordsTotal = count($keys);
|
||||
$recordsFiltered = $recordsTotal;
|
||||
|
||||
$start = DataTable::start($request);
|
||||
$length = DataTable::length($request);
|
||||
$pagedKeys = array_slice($keys, $start, $length);
|
||||
|
||||
$tempSessions = [];
|
||||
$userIds = [];
|
||||
|
||||
foreach ($pagedKeys as $key) {
|
||||
$pureKey = str_replace($prefix, '', $key);
|
||||
$data = $redis->get($pureKey);
|
||||
|
||||
if ($data) {
|
||||
try {
|
||||
$unserialized = unserialize($data, ['allowed_classes' => false]);
|
||||
if (is_string($unserialized)) {
|
||||
$unserialized = unserialize($unserialized, ['allowed_classes' => false]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode(':', $key);
|
||||
$sessionId = end($parts);
|
||||
|
||||
if (isset($unserialized['user_id'])) {
|
||||
$userIds[] = $unserialized['user_id'];
|
||||
}
|
||||
|
||||
$tempSessions[] = [
|
||||
'id' => $sessionId,
|
||||
'user_id' => $unserialized['user_id'] ?? null,
|
||||
'ip_address' => $unserialized['ip_address'] ?? 'N/A',
|
||||
'user_agent' => $unserialized['user_agent'] ?? 'N/A',
|
||||
'last_activity' => $unserialized['last_activity'] ?? time(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Batch fetch users to prevent N+1
|
||||
$users = ! empty($userIds) ? DB::table('users')->whereIn('id', array_unique($userIds))->get()->keyBy('id') : collect();
|
||||
|
||||
foreach ($tempSessions as $sessData) {
|
||||
$user = $users->get($sessData['user_id']);
|
||||
$sessions[] = (object) array_merge($sessData, [
|
||||
'user_name' => $user->name ?? 'Guest',
|
||||
'user_email' => $user->email ?? 'N/A',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$rows = collect($sessions)->map(function ($session) use ($activeCutoff, $idleCutoff, $currentSessionId) {
|
||||
$isCurrent = $session->id === $currentSessionId;
|
||||
$userAgent = SessionHelper::parseUserAgent($session->user_agent);
|
||||
|
||||
// Status Logic
|
||||
if ($isCurrent) {
|
||||
$statusHtml = '<span class="badge text-bg-success border border-success px-3 py-2 small fw-bold">
|
||||
<span class="spinner-grow spinner-grow-sm me-1" role="status"></span>'.__('LIVE').'
|
||||
</span>';
|
||||
} elseif ($session->last_activity >= $idleCutoff) {
|
||||
$statusHtml = '<span class="badge text-bg-info px-3 py-2 small fw-bold">'.__('ACTIVE').'</span>';
|
||||
} elseif ($session->last_activity >= $activeCutoff) {
|
||||
$statusHtml = '<span class="badge text-bg-warning px-3 py-2 small fw-bold">'.__('IDLE').'</span>';
|
||||
} else {
|
||||
$statusHtml = '<span class="badge text-bg-secondary px-3 py-2 small">'.__('EXPIRED').'</span>';
|
||||
}
|
||||
|
||||
// User Column
|
||||
$userHtml = '<div class="d-flex align-items-center">
|
||||
<div>
|
||||
<div class="fw-bold small">'.e($session->user_name ?? 'Guest').'</div>
|
||||
<div class="text-secondary extra-small">'.e($session->user_email ?? substr($session->id, 0, 8).'...').'</div>
|
||||
</div>
|
||||
</div>';
|
||||
|
||||
// Device Column
|
||||
$deviceHtml = '<div class="d-flex align-items-center">
|
||||
<i class="bi '.$userAgent['browser_icon'].' h5 mb-0 me-2 text-theme-1"></i>
|
||||
<div>
|
||||
<div class="small fw-medium">'.$userAgent['browser'].'</div>
|
||||
<div class="extra-small text-secondary"><i class="bi '.$userAgent['os_icon'].' me-1"></i>'.$userAgent['os'].'</div>
|
||||
</div>
|
||||
</div>';
|
||||
|
||||
$timestamp = Carbon::createFromTimestamp($session->last_activity);
|
||||
|
||||
$actionsHtml = '<div class="text-end d-flex justify-content-end gap-2">';
|
||||
$actionsHtml .= '<button class="btn btn-square btn-outline-theme btn-sm rounded-circle btn-detail-session"
|
||||
data-activity=\''.e(json_encode([
|
||||
'session' => $session,
|
||||
'device' => $userAgent,
|
||||
'is_current' => $isCurrent,
|
||||
'time' => format_datetime($timestamp),
|
||||
], JSON_HEX_APOS | JSON_HEX_QUOT)).'\'>
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>';
|
||||
|
||||
if (! $isCurrent) {
|
||||
if (auth()->user()->can('manage active sessions')) {
|
||||
$actionsHtml .= '<button type="button" class="btn btn-square btn-outline-danger btn-sm rounded-circle btn-terminate-session"
|
||||
data-id="'.e($session->id).'"
|
||||
data-url="'.route('session-manager.terminate', $session->id).'">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>';
|
||||
} else {
|
||||
$actionsHtml .= '<button class="btn btn-square btn-light btn-sm rounded-circle" disabled title="Insufficient permission">
|
||||
<i class="bi bi-shield-lock text-muted"></i>
|
||||
</button>';
|
||||
}
|
||||
} else {
|
||||
$actionsHtml .= '<button class="btn btn-square btn-light btn-sm rounded-circle" disabled title="Current session cannot be terminated">
|
||||
<i class="bi bi-lock-fill text-muted"></i>
|
||||
</button>';
|
||||
}
|
||||
$actionsHtml .= '</div>';
|
||||
|
||||
return [
|
||||
$statusHtml,
|
||||
$userHtml,
|
||||
$deviceHtml,
|
||||
'<code class="extra-small">'.e($session->ip_address).'</code>',
|
||||
e($timestamp->diffForHumans()),
|
||||
$actionsHtml,
|
||||
$isCurrent, // For custom row coloring in JS
|
||||
];
|
||||
})->all();
|
||||
|
||||
return DataTable::response($request, $recordsTotal, $recordsFiltered, $rows);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, $id)
|
||||
{
|
||||
if ($id === session()->getId()) {
|
||||
return response()->json(['success' => false, 'message' => __('Cannot terminate current session.')], 403);
|
||||
}
|
||||
|
||||
$driver = $this->getDriver();
|
||||
if ($driver === 'database') {
|
||||
DB::table('sessions')->where('id', $id)->delete();
|
||||
} elseif ($driver === 'redis') {
|
||||
$connection = config('session.connection') ?? 'default';
|
||||
$redis = Redis::connection($connection);
|
||||
$sessionCookie = config('session.cookie', 'laravel_session');
|
||||
$redis->del($sessionCookie.':'.$id);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'message' => __('Session terminated successfully.')]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SystemSettings;
|
||||
|
||||
use App\Http\Requests\SystemSettings\UpdateSystemConfigRequest;
|
||||
use App\Models\AI\AiUsageLog;
|
||||
use App\Services\AI\AiService;
|
||||
use App\Services\System\MaintenanceManagementService;
|
||||
use App\Services\SystemConfig\SystemConfigService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use SAPNWRFC\Connection;
|
||||
|
||||
class SystemConfigController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected SystemConfigService $systemConfig,
|
||||
protected MaintenanceManagementService $maintenanceService,
|
||||
protected AiService $aiService
|
||||
) {
|
||||
// Middleware handled in web.php
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return view('pages.system_settings.system-config', [
|
||||
'settings' => $this->systemConfig->all(),
|
||||
'groups' => $this->systemConfig->grouped(),
|
||||
'is_down' => $this->maintenanceService->isDown(),
|
||||
'ai_models' => AiService::getSupportedModels(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateSystemConfigRequest $request)
|
||||
{
|
||||
$this->systemConfig->update(
|
||||
input: $request->validated(),
|
||||
files: $request->allFiles(),
|
||||
actorId: $request->user()?->id,
|
||||
request: $request,
|
||||
);
|
||||
|
||||
// ✍️ Log "Action Log" bulk update
|
||||
activity('system-config')
|
||||
->causedBy($request->user())
|
||||
->withProperties([
|
||||
'ip' => $request->ip(),
|
||||
'agent' => $request->userAgent(),
|
||||
'details' => __('Bulk system configuration update performed.'),
|
||||
])
|
||||
->log(__('Updated System Settings'));
|
||||
|
||||
// Only sync maintenance state if maintenance fields were part of the request
|
||||
$maintenanceKeys = [
|
||||
'maintenance_mode_enabled',
|
||||
'maintenance_mode_secret',
|
||||
'maintenance_mode_allowed_ips',
|
||||
'maintenance_mode_end_at',
|
||||
];
|
||||
|
||||
if ($request->hasAny($maintenanceKeys)) {
|
||||
$this->maintenanceService->syncState();
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Configuration updated successfully!'),
|
||||
'settings' => $this->systemConfig->all(true),
|
||||
'is_down' => $this->maintenanceService->isDown(),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', __('Configuration updated successfully!'));
|
||||
}
|
||||
|
||||
public function testEmail(Request $request): JsonResponse
|
||||
{
|
||||
$to = $request->input('to') ?: $this->systemConfig->get('mail_from_address');
|
||||
|
||||
if (! $to || ! filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
return response()->json(['success' => false, 'message' => __('No valid recipient email address. Please enter a valid email.')], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::raw(
|
||||
__('This automated message confirms that the SMTP configuration for :app has been successfully verified and is operating correctly.', ['app' => config('app.name')]),
|
||||
function ($message) use ($to) {
|
||||
$message->to($to)
|
||||
->subject(__('SMTP Configuration Verification — :app', ['app' => config('app.name')]));
|
||||
}
|
||||
);
|
||||
|
||||
// ✍️ Log "Action Log" test email
|
||||
activity('system-config')
|
||||
->causedBy($request->user())
|
||||
->withProperties([
|
||||
'ip' => $request->ip(),
|
||||
'agent' => $request->userAgent(),
|
||||
'recipient' => $to,
|
||||
'details' => __('Sent a test email to :email to verify SMTP configuration.', ['email' => $to]),
|
||||
])
|
||||
->log(__('Sent Test Email'));
|
||||
|
||||
return response()->json(['success' => true, 'message' => __('Test email sent successfully to :email', ['email' => $to])]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Test email failed: '.$e->getMessage());
|
||||
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function testSapConnection(Request $request): JsonResponse
|
||||
{
|
||||
$config = [
|
||||
'ashost' => $request->input('sap_rfc_ashost'),
|
||||
'sysnr' => $request->input('sap_rfc_sysnr'),
|
||||
'client' => $request->input('sap_rfc_client'),
|
||||
'user' => $request->input('sap_rfc_user'),
|
||||
'passwd' => $request->input('sap_rfc_passwd'),
|
||||
'router' => $request->input('sap_rfc_router'),
|
||||
];
|
||||
|
||||
// Check if required fields are present
|
||||
if (empty($config['ashost']) || empty($config['sysnr']) || empty($config['user'])) {
|
||||
return response()->json(['success' => false, 'message' => __('Host, System Number, and User are required for testing.')], 422);
|
||||
}
|
||||
|
||||
if (! extension_loaded('sapnwrfc')) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('The sapnwrfc PHP extension is not installed on this server. Connection cannot be established.'),
|
||||
], 500);
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt to connect using the extension
|
||||
$conn = new Connection($config);
|
||||
$attributes = $conn->getAttributes();
|
||||
$conn->close();
|
||||
|
||||
activity('system-config')
|
||||
->causedBy($request->user())
|
||||
->withProperties(['host' => $config['ashost']])
|
||||
->log(__('Successful SAP RFC connection test'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Successfully connected to SAP! (System: :sysid)', ['sysid' => $attributes['sysId'] ?? 'N/A']),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['success' => false, 'message' => 'SAP Error: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function testDatabaseConnection(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
\Illuminate\Support\Facades\DB::connection()->getPdo();
|
||||
$dbName = \Illuminate\Support\Facades\DB::connection()->getDatabaseName();
|
||||
|
||||
activity('system-config')
|
||||
->causedBy($request->user())
|
||||
->withProperties(['db' => $dbName])
|
||||
->log(__('Successful database connection test'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Database connection verified successfully! Connected to: :db', ['db' => $dbName]),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['success' => false, 'message' => 'Database Error: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function publicConfig(): JsonResponse
|
||||
{
|
||||
$settings = $this->systemConfig->getPublicSettings();
|
||||
|
||||
if (! empty($settings['app_logo'])) {
|
||||
$settings['app_logo_url'] = asset($settings['app_logo']);
|
||||
}
|
||||
|
||||
if (! empty($settings['app_favicon'])) {
|
||||
$settings['app_favicon_url'] = asset($settings['app_favicon']);
|
||||
}
|
||||
|
||||
return response()
|
||||
->json([
|
||||
'data' => $settings,
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
])
|
||||
->header('Cache-Control', 'public, max-age=300, s-maxage=300');
|
||||
}
|
||||
|
||||
public function simulateAi(Request $request): JsonResponse
|
||||
{
|
||||
$prompt = $request->input('prompt');
|
||||
$providerId = $request->input('ai_provider', $this->systemConfig->get('ai_provider', 'gpt'));
|
||||
|
||||
if (! $prompt) {
|
||||
return response()->json(['success' => false, 'message' => __('Prompt is required.')], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$options = [
|
||||
'key' => $request->input('ai_key'),
|
||||
'model' => $request->input('ai_model'),
|
||||
'instruction' => $request->input('ai_instruction'),
|
||||
'temperature' => $request->input('ai_temperature'),
|
||||
'max_tokens' => $request->input('ai_max_tokens'),
|
||||
];
|
||||
|
||||
$result = $this->aiService->provider($providerId)->generate($prompt, $options);
|
||||
|
||||
if (! $result['success']) {
|
||||
return response()->json(['success' => false, 'message' => $result['error']], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'provider' => $providerId,
|
||||
'response' => $result['response'],
|
||||
'usage' => $result['usage'] ?? null,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function getAiUsageStats(Request $request): JsonResponse
|
||||
{
|
||||
$period = $request->input('period', 'all');
|
||||
$query = AiUsageLog::query();
|
||||
|
||||
if ($period !== 'all') {
|
||||
$date = match ($period) {
|
||||
'day' => now()->startOfDay(),
|
||||
'week' => now()->startOfWeek(),
|
||||
'month' => now()->startOfMonth(),
|
||||
'year' => now()->startOfYear(),
|
||||
default => null
|
||||
};
|
||||
|
||||
if ($date) {
|
||||
$query->where('created_at', '>=', $date);
|
||||
}
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'total_requests' => (clone $query)->count(),
|
||||
'total_tokens' => (clone $query)->sum('total_tokens'),
|
||||
'total_cost' => (clone $query)->sum('estimated_cost'),
|
||||
'providers' => (clone $query)->select('provider', \DB::raw('count(*) as count'))
|
||||
->groupBy('provider')
|
||||
->get(),
|
||||
'recent' => (clone $query)->latest()->take(5)->get(),
|
||||
'period' => $period,
|
||||
];
|
||||
|
||||
return response()->json(['success' => true, 'stats' => $stats]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
<?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\SystemSettings;
|
||||
|
||||
use App\Services\AI\SecurityHardeningService;
|
||||
use App\Services\Monitoring\SystemMonitoringService;
|
||||
use App\Support\DataTable;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Response;
|
||||
|
||||
class SystemMonitoringController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected SystemMonitoringService $monitor
|
||||
) {
|
||||
// Middleware handled in web.php
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
if (! auth()->user()->can('view health and logs')) {
|
||||
abort(403);
|
||||
}
|
||||
$stats = $this->monitor->getAll();
|
||||
|
||||
return view('pages.system_settings.system-monitoring', compact('stats'));
|
||||
}
|
||||
|
||||
public function getStats(Request $request)
|
||||
{
|
||||
if (! $request->user()->can('view health and logs')) {
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
return response()->json($this->monitor->getAll());
|
||||
}
|
||||
|
||||
public function logsDataTable(Request $request)
|
||||
{
|
||||
if (! $request->user()->can('view health and logs')) {
|
||||
return DataTable::response($request, 0, 0, []);
|
||||
}
|
||||
$logFile = storage_path('logs/laravel.log');
|
||||
if (! File::exists($logFile)) {
|
||||
return DataTable::response($request, 0, 0, []);
|
||||
}
|
||||
|
||||
// Optimization: Don't read the whole file into memory. Use tail to get last 2000 lines.
|
||||
// This prevents memory exhaustion on large log files.
|
||||
$handle = shell_exec('tail -n 2000 '.escapeshellarg($logFile));
|
||||
$lines = $handle ? explode("\n", trim($handle)) : [];
|
||||
$logs = [];
|
||||
|
||||
$currentLog = null;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// Pattern for standard Laravel logs: [YYYY-MM-DD HH:MM:SS] env.LEVEL: message
|
||||
if (preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] (.*?)\.(.*?): (.*)/', $line, $matches)) {
|
||||
if ($currentLog) {
|
||||
$logs[] = $currentLog;
|
||||
}
|
||||
$currentLog = [
|
||||
'timestamp' => $matches[1],
|
||||
'env' => $matches[2],
|
||||
'level' => strtolower($matches[3]),
|
||||
'message' => $matches[4],
|
||||
];
|
||||
} else {
|
||||
if ($currentLog) {
|
||||
$currentLog['message'] .= "\n".$line;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($currentLog) {
|
||||
$logs[] = $currentLog;
|
||||
}
|
||||
|
||||
$logs = array_reverse($logs); // Newest first
|
||||
|
||||
$recordsTotal = count($logs);
|
||||
|
||||
// Filter
|
||||
$searchValue = DataTable::globalSearch($request);
|
||||
if ($searchValue) {
|
||||
$searchValue = strtolower($searchValue);
|
||||
$logs = array_filter($logs, function ($log) use ($searchValue) {
|
||||
return str_contains(strtolower($log['timestamp']), $searchValue) ||
|
||||
str_contains(strtolower($log['level']), $searchValue) ||
|
||||
str_contains(strtolower($log['message']), $searchValue);
|
||||
});
|
||||
}
|
||||
|
||||
$recordsFiltered = count($logs);
|
||||
|
||||
// Sorting
|
||||
[$orderIndex, $orderDir] = DataTable::order($request, 0, 'desc');
|
||||
$sortKey = match ($orderIndex) {
|
||||
0 => 'timestamp',
|
||||
1 => 'level',
|
||||
2 => 'message',
|
||||
default => 'timestamp'
|
||||
};
|
||||
|
||||
usort($logs, function ($a, $b) use ($sortKey, $orderDir) {
|
||||
if ($orderDir === 'asc') {
|
||||
return $a[$sortKey] <=> $b[$sortKey];
|
||||
}
|
||||
|
||||
return $b[$sortKey] <=> $a[$sortKey];
|
||||
});
|
||||
|
||||
// Pagination
|
||||
$data = array_slice($logs, DataTable::start($request), DataTable::length($request));
|
||||
|
||||
$rows = array_map(function ($log) {
|
||||
$levelClass = match ($log['level']) {
|
||||
'error', 'critical', 'alert', 'emergency' => 'text-bg-danger',
|
||||
'warning' => 'text-bg-warning',
|
||||
'notice', 'info' => 'text-bg-info',
|
||||
'debug' => 'text-bg-light border',
|
||||
default => 'text-bg-secondary'
|
||||
};
|
||||
|
||||
return [
|
||||
'<div class="text-nowrap">'.e($log['timestamp']).'</div>',
|
||||
'<span class="badge px-2 py-1 '.$levelClass.'">'.strtoupper(e($log['level'])).'</span>',
|
||||
'<div class="text-truncate small" style="max-width: 600px;" title="'.e($log['message']).'">'.e(str_replace("\n", ' ', substr($log['message'], 0, 300))).'</div>',
|
||||
'<div class="text-end">
|
||||
<button class="btn btn-sm btn-outline-theme view-log-detail" data-log=\''.htmlspecialchars(json_encode($log), ENT_QUOTES, 'UTF-8').'\'>
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>',
|
||||
];
|
||||
}, $data);
|
||||
|
||||
return DataTable::response($request, $recordsTotal, $recordsFiltered, $rows);
|
||||
}
|
||||
|
||||
public function downloadLogs()
|
||||
{
|
||||
$logFile = storage_path('logs/laravel.log');
|
||||
if (! File::exists($logFile)) {
|
||||
return back()->with('error', 'Log file not found.');
|
||||
}
|
||||
|
||||
return Response::download($logFile, 'laravel-'.now()->format('Y-m-d').'.log');
|
||||
}
|
||||
|
||||
public function clearLogs()
|
||||
{
|
||||
$logFile = storage_path('logs/laravel.log');
|
||||
File::put($logFile, '');
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Logs cleared successfully.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* SAP RFC LOGS DATATABLE
|
||||
*/
|
||||
public function sapLogsDataTable(Request $request)
|
||||
{
|
||||
$possiblePaths = [
|
||||
base_path('dev_rfc.trc'),
|
||||
public_path('dev_rfc.trc'),
|
||||
storage_path('logs/dev_rfc.trc'),
|
||||
];
|
||||
|
||||
$logFile = null;
|
||||
foreach ($possiblePaths as $path) {
|
||||
if (File::exists($path)) {
|
||||
$logFile = $path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $logFile) {
|
||||
return DataTable::response($request, 0, 0, []);
|
||||
}
|
||||
|
||||
// Optimization: Use tail for large SAP logs
|
||||
$handle = shell_exec('tail -c 500000 '.escapeshellarg($logFile)); // Read last 500KB
|
||||
$content = $handle ?: '';
|
||||
$blocks = explode('****', $content);
|
||||
$logs = [];
|
||||
|
||||
foreach ($blocks as $block) {
|
||||
$block = trim($block);
|
||||
if (empty($block)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$timestamp = 'N/A';
|
||||
if (preg_match('/at (\d{8} \d{6})/', $block, $matches)) {
|
||||
$timestamp = Carbon::createFromFormat('Ymd His', $matches[1])->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
// Extract a clean summary (e.g. "Trace file opened", "RFC Call", etc.)
|
||||
$summary = 'General RFC Event';
|
||||
if (preg_match('/Trace file opened/i', $block)) {
|
||||
$summary = 'SYSTEM: Trace Initialized';
|
||||
} elseif (preg_match('/RFC\s+Call\s+to\s+function\s+([\w_]+)/i', $block, $m)) {
|
||||
$summary = 'CALL: '.$m[1];
|
||||
} elseif (preg_match('/RFC\s+Call/i', $block)) {
|
||||
$summary = 'NETWORK: RFC Call Initiated';
|
||||
} elseif (preg_match('/Error/i', $block)) {
|
||||
$summary = 'ERROR: RFC Fault';
|
||||
} elseif (preg_match('/Connection/i', $block)) {
|
||||
$summary = 'CONN: State Changed';
|
||||
}
|
||||
|
||||
$logs[] = [
|
||||
'timestamp' => $timestamp,
|
||||
'summary' => $summary,
|
||||
'message' => $block,
|
||||
];
|
||||
}
|
||||
|
||||
$logs = array_reverse($logs);
|
||||
$recordsTotal = count($logs);
|
||||
|
||||
// Search
|
||||
$searchValue = DataTable::globalSearch($request);
|
||||
if ($searchValue) {
|
||||
$searchValue = strtolower($searchValue);
|
||||
$logs = array_filter($logs, function ($log) use ($searchValue) {
|
||||
return str_contains(strtolower($log['timestamp']), $searchValue) ||
|
||||
str_contains(strtolower($log['summary']), $searchValue) ||
|
||||
str_contains(strtolower($log['message']), $searchValue);
|
||||
});
|
||||
}
|
||||
|
||||
$recordsFiltered = count($logs);
|
||||
$data = array_slice($logs, DataTable::start($request), DataTable::length($request));
|
||||
|
||||
$rows = array_map(function ($log) {
|
||||
$summaryClass = 'text-primary';
|
||||
if (str_contains($log['summary'], 'ERROR')) {
|
||||
$summaryClass = 'text-danger fw-bold';
|
||||
}
|
||||
if (str_contains($log['summary'], 'SYSTEM')) {
|
||||
$summaryClass = 'text-success';
|
||||
}
|
||||
if (str_contains($log['summary'], 'CALL:')) {
|
||||
$summaryClass = 'text-info fw-bold';
|
||||
}
|
||||
|
||||
return [
|
||||
'<div class="text-nowrap">'.e($log['timestamp']).'</div>',
|
||||
'<div class="'.$summaryClass.' small">'.e($log['summary']).'</div>',
|
||||
'<div class="text-end">
|
||||
<button class="btn btn-sm btn-outline-theme view-sap-log-detail" data-message=\''.htmlspecialchars($log['message'], ENT_QUOTES, 'UTF-8').'\'>
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>',
|
||||
];
|
||||
}, $data);
|
||||
|
||||
return DataTable::response($request, $recordsTotal, $recordsFiltered, $rows);
|
||||
}
|
||||
|
||||
public function clearSapLogs()
|
||||
{
|
||||
$possiblePaths = [
|
||||
base_path('dev_rfc.trc'),
|
||||
public_path('dev_rfc.trc'),
|
||||
storage_path('logs/dev_rfc.trc'),
|
||||
];
|
||||
|
||||
foreach ($possiblePaths as $path) {
|
||||
if (File::exists($path)) {
|
||||
File::put($path, '');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'SAP RFC logs cleared successfully.']);
|
||||
}
|
||||
|
||||
public function downloadSapLogs()
|
||||
{
|
||||
$possiblePaths = [
|
||||
base_path('dev_rfc.trc'),
|
||||
public_path('dev_rfc.trc'),
|
||||
storage_path('logs/dev_rfc.trc'),
|
||||
];
|
||||
|
||||
foreach ($possiblePaths as $path) {
|
||||
if (File::exists($path)) {
|
||||
return Response::download($path, 'dev_rfc-'.now()->format('Y-m-d').'.trc');
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('error', 'SAP log file not found.');
|
||||
}
|
||||
|
||||
/**
|
||||
* MOBILE LOGS DATATABLE
|
||||
*/
|
||||
public function mobileLogsDataTable(Request $request)
|
||||
{
|
||||
$logFile = storage_path('logs/mobile.log');
|
||||
if (! File::exists($logFile)) {
|
||||
return DataTable::response($request, 0, 0, []);
|
||||
}
|
||||
|
||||
// Optimization: Get last 2000 lines only
|
||||
$handle = shell_exec('tail -n 2000 '.escapeshellarg($logFile));
|
||||
$lines = $handle ? explode("\n", trim($handle)) : [];
|
||||
$logs = [];
|
||||
$currentLog = null;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] (.*?)\.(.*?): (.*)/', $line, $matches)) {
|
||||
if ($currentLog) {
|
||||
$logs[] = $currentLog;
|
||||
}
|
||||
$currentLog = [
|
||||
'timestamp' => $matches[1],
|
||||
'level' => strtolower($matches[3]),
|
||||
'message' => $matches[4],
|
||||
];
|
||||
} else {
|
||||
if ($currentLog) {
|
||||
$currentLog['message'] .= "\n".$line;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($currentLog) {
|
||||
$logs[] = $currentLog;
|
||||
}
|
||||
|
||||
$logs = array_reverse($logs);
|
||||
$recordsTotal = count($logs);
|
||||
|
||||
// Search
|
||||
$searchValue = DataTable::globalSearch($request);
|
||||
if ($searchValue) {
|
||||
$searchValue = strtolower($searchValue);
|
||||
$logs = array_filter($logs, function ($log) use ($searchValue) {
|
||||
return str_contains(strtolower($log['timestamp']), $searchValue) ||
|
||||
str_contains(strtolower($log['message']), $searchValue);
|
||||
});
|
||||
}
|
||||
|
||||
$recordsFiltered = count($logs);
|
||||
$data = array_slice($logs, DataTable::start($request), DataTable::length($request));
|
||||
|
||||
$rows = array_map(function ($log) {
|
||||
$levelClass = match ($log['level']) {
|
||||
'error', 'critical' => 'text-bg-danger',
|
||||
'warning' => 'text-bg-warning',
|
||||
default => 'text-bg-info'
|
||||
};
|
||||
|
||||
// Try to extract user info from context if it was logged as JSON
|
||||
$userInfo = 'Guest';
|
||||
if (preg_match('/Context: (\{.*\})/s', $log['message'], $matches)) {
|
||||
$ctx = json_decode($matches[1], true);
|
||||
if (isset($ctx['user_id'])) {
|
||||
$userInfo = 'ID: '.$ctx['user_id'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'<div class="text-nowrap">'.e($log['timestamp']).'</div>',
|
||||
'<span class="badge px-2 py-1 '.$levelClass.'">'.strtoupper(e($log['level'])).'</span>',
|
||||
'<div class="small fw-bold text-theme-1">'.e($userInfo).'</div>',
|
||||
'<div class="text-truncate small" style="max-width: 500px;">'.e(str_replace("\n", ' ', substr($log['message'], 0, 300))).'</div>',
|
||||
'<div class="text-end">
|
||||
<button class="btn btn-sm btn-outline-theme view-log-detail" data-log=\''.htmlspecialchars(json_encode($log), ENT_QUOTES, 'UTF-8').'\'>
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>',
|
||||
];
|
||||
}, $data);
|
||||
|
||||
return DataTable::response($request, $recordsTotal, $recordsFiltered, $rows);
|
||||
}
|
||||
|
||||
public function clearMobileLogs()
|
||||
{
|
||||
File::put(storage_path('logs/mobile.log'), '');
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Mobile logs cleared successfully.']);
|
||||
}
|
||||
|
||||
public function downloadMobileLogs()
|
||||
{
|
||||
$logFile = storage_path('logs/mobile.log');
|
||||
if (! File::exists($logFile)) {
|
||||
return back()->with('error', 'Mobile log file not found.');
|
||||
}
|
||||
|
||||
return Response::download($logFile, 'mobile-'.now()->format('Y-m-d').'.log');
|
||||
}
|
||||
|
||||
/**
|
||||
* UNIFIED BACKGROUND JOBS DATATABLE
|
||||
*/
|
||||
public function backgroundJobsDataTable(Request $request)
|
||||
{
|
||||
$canManage = $request->user()->can('manage health and logs');
|
||||
$pendingJobs = DB::table('jobs')->get()->map(function ($j) {
|
||||
$j->status = 'PENDING';
|
||||
$j->timestamp = Carbon::createFromTimestamp($j->available_at)->format('Y-m-d H:i:s');
|
||||
$j->exception = '';
|
||||
|
||||
return $j;
|
||||
});
|
||||
|
||||
$failedJobs = DB::table('failed_jobs')->get()->map(function ($j) {
|
||||
$j->status = 'FAILED';
|
||||
$j->timestamp = is_numeric($j->failed_at) ? Carbon::createFromTimestamp($j->failed_at)->format('Y-m-d H:i:s') : $j->failed_at;
|
||||
|
||||
return $j;
|
||||
});
|
||||
|
||||
$allJobs = collect($pendingJobs)->merge($failedJobs);
|
||||
$recordsTotal = $allJobs->count();
|
||||
|
||||
// Search
|
||||
$searchValue = DataTable::globalSearch($request);
|
||||
if ($searchValue) {
|
||||
$searchValue = strtolower($searchValue);
|
||||
$allJobs = $allJobs->filter(function ($j) use ($searchValue) {
|
||||
return str_contains(strtolower($j->id), $searchValue) ||
|
||||
str_contains(strtolower($j->queue), $searchValue) ||
|
||||
str_contains(strtolower($j->status), $searchValue) ||
|
||||
str_contains(strtolower($j->payload), $searchValue) ||
|
||||
str_contains(strtolower($j->exception), $searchValue);
|
||||
});
|
||||
}
|
||||
$recordsFiltered = $allJobs->count();
|
||||
|
||||
// Sort
|
||||
[$orderIndex, $orderDir] = DataTable::order($request, 0, 'desc');
|
||||
$sortKey = match ($orderIndex) {
|
||||
0 => 'timestamp',
|
||||
1 => 'status',
|
||||
2 => 'queue',
|
||||
default => 'timestamp'
|
||||
};
|
||||
|
||||
if ($orderDir === 'asc') {
|
||||
$allJobs = $allJobs->sortBy($sortKey);
|
||||
} else {
|
||||
$allJobs = $allJobs->sortByDesc($sortKey);
|
||||
}
|
||||
|
||||
// Paginate
|
||||
$allJobs = $allJobs->slice(DataTable::start($request), DataTable::length($request));
|
||||
|
||||
$rows = $allJobs->map(function ($job) use ($canManage) {
|
||||
$payload = json_decode($job->payload);
|
||||
$displayName = $payload->displayName ?? 'Unknown Job';
|
||||
|
||||
$statusBadge = $job->status === 'PENDING'
|
||||
? '<span class="badge px-2 py-1 text-bg-warning">PENDING</span>'
|
||||
: '<span class="badge px-2 py-1 text-bg-danger">FAILED</span>';
|
||||
|
||||
$details = '';
|
||||
if ($job->status === 'FAILED') {
|
||||
$exceptionShort = substr($job->exception, 0, 100);
|
||||
if (preg_match('/^([^\s:]+):/', $job->exception, $matches)) {
|
||||
$exceptionClass = explode('\\', $matches[1]);
|
||||
$exceptionClass = end($exceptionClass);
|
||||
} else {
|
||||
$exceptionClass = 'Error';
|
||||
}
|
||||
$details = '<div class="text-truncate small" style="max-width: 400px;" title="'.e($job->exception).'">
|
||||
<strong class="text-danger">'.e($exceptionClass).'</strong>: '.e(str_replace("\n", ' ', $exceptionShort)).'...
|
||||
</div>';
|
||||
} else {
|
||||
$details = '<div class="text-truncate small" style="max-width: 400px;" title="'.e($displayName).'">
|
||||
<strong class="text-dark">'.e($displayName).'</strong>
|
||||
</div>';
|
||||
}
|
||||
|
||||
// 🚀 HUMAN-READABLE DESCRIPTION MAPPING (TEXT ONLY)
|
||||
$taskDescription = match (true) {
|
||||
str_contains($displayName, 'SystemManagementNotification') => 'Mengirim Notifikasi Manajemen Sistem',
|
||||
str_contains($displayName, 'BroadcastEvent') => 'Penyiaran Data Real-time (Websocket)',
|
||||
str_contains($displayName, 'Backup') => 'Menjalankan Pencadangan Sistem',
|
||||
str_contains($displayName, 'Cleanup') => 'Pembersihan Data Berkala',
|
||||
str_contains($displayName, 'Sync') => 'Sinkronisasi Data Eksternal',
|
||||
default => 'Tugas Sistem Latar Belakang'
|
||||
};
|
||||
|
||||
$row = [
|
||||
'<div class="text-nowrap">'.e($job->timestamp).'</div>',
|
||||
$statusBadge,
|
||||
'<span class="badge text-bg-secondary">'.strtoupper(e($job->queue)).'</span>',
|
||||
$details,
|
||||
'<div class="small fw-bold text-theme-1">'.$taskDescription.'</div>',
|
||||
];
|
||||
|
||||
if ($canManage) {
|
||||
$actions = '<div class="text-end d-flex justify-content-end gap-1">';
|
||||
if ($job->status === 'FAILED') {
|
||||
$actions .= '<button class="btn btn-sm btn-outline-theme retry-job-btn" data-id="'.$job->id.'" title="Retry Job"><i class="bi bi-arrow-repeat"></i></button>';
|
||||
$actions .= '<button class="btn btn-sm btn-outline-theme view-job-detail" data-job=\''.htmlspecialchars(json_encode($job), ENT_QUOTES, 'UTF-8').'\' title="View Detail"><i class="bi bi-eye"></i></button>';
|
||||
$actions .= '<button class="btn btn-sm btn-outline-danger delete-failed-job-btn" data-id="'.$job->id.'" title="Delete Failed Job"><i class="bi bi-trash"></i></button>';
|
||||
} else {
|
||||
// Pending jobs cannot be deleted individually as per user request
|
||||
$actions .= '<button class="btn btn-sm btn-outline-secondary" disabled title="Pending job"><i class="bi bi-hourglass-split"></i></button>';
|
||||
}
|
||||
$actions .= '</div>';
|
||||
$row[] = $actions;
|
||||
}
|
||||
|
||||
return $row;
|
||||
})->values()->all();
|
||||
|
||||
return DataTable::response($request, $recordsTotal, $recordsFiltered, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* CLEAR ALL PENDING AND FAILED JOBS
|
||||
*/
|
||||
public function clearFailedJobs()
|
||||
{
|
||||
// 1. Clear Failed Jobs
|
||||
Artisan::call('queue:flush');
|
||||
|
||||
// 2. Clear Pending Jobs
|
||||
DB::table('jobs')->delete();
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'All pending and failed jobs have been cleared.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* RETRY FAILED JOB(S)
|
||||
*/
|
||||
public function retryFailedJob(Request $request, $id = null)
|
||||
{
|
||||
if ($id) {
|
||||
Artisan::call('queue:retry', ['id' => [$id]]);
|
||||
$msg = "Job #{$id} has been pushed back to the queue.";
|
||||
} else {
|
||||
Artisan::call('queue:retry', ['id' => ['all']]);
|
||||
$msg = 'All failed jobs have been pushed back to the queue.';
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'message' => $msg]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE SINGLE FAILED JOB
|
||||
*/
|
||||
public function deleteFailedJob($id)
|
||||
{
|
||||
DB::table('failed_jobs')->where('id', $id)->delete();
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Failed job deleted successfully.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE SINGLE PENDING JOB
|
||||
*/
|
||||
public function deletePendingJob($id)
|
||||
{
|
||||
DB::table('jobs')->where('id', $id)->delete();
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Pending job deleted successfully.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* DOWNLOAD BACKGROUND JOBS (Pending & Failed)
|
||||
*/
|
||||
public function downloadBackgroundJobs(Request $request)
|
||||
{
|
||||
$format = $request->get('format', 'csv');
|
||||
$delimiter = $format === 'excel' ? "\t" : ',';
|
||||
$filename = 'background-jobs-'.now()->format('Y-m-d-His').'.'.($format === 'excel' ? 'xls' : 'csv');
|
||||
|
||||
return response()->streamDownload(function () use ($delimiter) {
|
||||
$file = fopen('php://output', 'w');
|
||||
|
||||
// Add BOM for Excel UTF-8 support
|
||||
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||
|
||||
$columns = ['ID', 'Type', 'Status', 'Queue', 'Timestamp', 'Message (Payload/Exception)'];
|
||||
fputcsv($file, $columns, $delimiter);
|
||||
|
||||
// Pending Jobs
|
||||
DB::table('jobs')->orderBy('available_at', 'desc')->chunk(100, function ($jobs) use ($file, $delimiter) {
|
||||
foreach ($jobs as $job) {
|
||||
$payload = json_decode($job->payload);
|
||||
fputcsv($file, [
|
||||
$job->id,
|
||||
'Pending Job',
|
||||
'PENDING',
|
||||
$job->queue,
|
||||
Carbon::createFromTimestamp($job->available_at)->toDateTimeString(),
|
||||
$payload->displayName ?? 'Unknown',
|
||||
], $delimiter);
|
||||
}
|
||||
});
|
||||
|
||||
// Failed Jobs
|
||||
DB::table('failed_jobs')->orderBy('failed_at', 'desc')->chunk(100, function ($jobs) use ($file, $delimiter) {
|
||||
foreach ($jobs as $job) {
|
||||
fputcsv($file, [
|
||||
$job->id,
|
||||
'Failed Job',
|
||||
'FAILED',
|
||||
$job->queue,
|
||||
$job->failed_at,
|
||||
$job->exception,
|
||||
], $delimiter);
|
||||
}
|
||||
});
|
||||
|
||||
fclose($file);
|
||||
}, $filename, [
|
||||
'Content-Type' => $format === 'excel' ? 'application/vnd.ms-excel' : 'text/csv',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* TOGGLE MAINTENANCE MODE
|
||||
*/
|
||||
public function toggleMaintenance(Request $request)
|
||||
{
|
||||
$status = $request->get('status'); // true = down, false = up
|
||||
|
||||
try {
|
||||
if ($status) {
|
||||
Artisan::call('down', [
|
||||
'--refresh' => 15,
|
||||
'--secret' => 'debug-access',
|
||||
]);
|
||||
$msg = 'System is now in MAINTENANCE mode.';
|
||||
} else {
|
||||
Artisan::call('up');
|
||||
$msg = 'System is now LIVE.';
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $msg,
|
||||
'is_down' => app()->isDownForMaintenance(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to toggle maintenance mode: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI SECURITY AUDIT
|
||||
*/
|
||||
public function securityAudit(Request $request, SecurityHardeningService $service)
|
||||
{
|
||||
if (! $request->user()->can('view ai log analysis')) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
if ($request->has('refresh')) {
|
||||
Cache::forget('security_audit_result');
|
||||
}
|
||||
|
||||
$result = $service->auditSecurity();
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\WebAuthn;
|
||||
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Http\Response;
|
||||
use Laragear\WebAuthn\Http\Requests\AssertedRequest;
|
||||
use Laragear\WebAuthn\Http\Requests\AssertionRequest;
|
||||
|
||||
use function response;
|
||||
|
||||
class WebAuthnLoginController
|
||||
{
|
||||
/**
|
||||
* Returns the challenge to assertion.
|
||||
*/
|
||||
public function options(AssertionRequest $request): Responsable
|
||||
{
|
||||
\Log::info('WebAuthn Login Options Requested', ['input' => $request->all()]);
|
||||
|
||||
return $request->toVerify($request->validate(['email' => 'sometimes|email|string']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user in.
|
||||
*/
|
||||
public function login(AssertedRequest $request): Response
|
||||
{
|
||||
$success = $request->login();
|
||||
|
||||
if ($success) {
|
||||
\Log::info('WebAuthn Login Successful', ['user' => auth()->id()]);
|
||||
|
||||
return response()->noContent(204);
|
||||
}
|
||||
|
||||
\Log::warning('WebAuthn Login Failed');
|
||||
|
||||
return response()->noContent(422);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\WebAuthn;
|
||||
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Http\Response;
|
||||
use Laragear\WebAuthn\Http\Requests\AttestationRequest;
|
||||
use Laragear\WebAuthn\Http\Requests\AttestedRequest;
|
||||
|
||||
use function response;
|
||||
|
||||
class WebAuthnRegisterController
|
||||
{
|
||||
/**
|
||||
* Returns a challenge to be verified by the user device.
|
||||
*/
|
||||
public function options(AttestationRequest $request): Responsable
|
||||
{
|
||||
\Log::info('WebAuthn Registration Options Requested', ['user' => auth()->id()]);
|
||||
|
||||
return $request
|
||||
// ->fastRegistration() // Removed to allow more compatibility on local dev
|
||||
->allowDuplicates() // Allow re-registering for testing
|
||||
->toCreate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a device for further WebAuthn authentication.
|
||||
*/
|
||||
public function register(AttestedRequest $request): Response
|
||||
{
|
||||
try {
|
||||
$request->save();
|
||||
\Log::info('WebAuthn Registration Successful', ['user' => auth()->id()]);
|
||||
|
||||
return response()->noContent();
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('WebAuthn Registration Failed', [
|
||||
'user' => auth()->id(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json(['error' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user