feat: add app and database modules

This commit is contained in:
2026-05-21 16:05:11 +07:00
parent 37b7e783f5
commit fad70d096b
212 changed files with 23901 additions and 0 deletions
@@ -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());
}
}
}
+440
View File
@@ -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');
}
}
+48
View File
@@ -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');
}
}
+10
View File
@@ -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.'));
}
}
+114
View File
@@ -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);
}
}
}