From fad70d096bcf96a88e87f1fc95c96534a0e906cb Mon Sep 17 00:00:00 2001 From: debesocial Date: Thu, 21 May 2026 16:05:11 +0700 Subject: [PATCH] feat: add app and database modules --- .../AI/GenerateSwaggerAnnotations.php | 110 + app/Console/Commands/AuditPermissions.php | 81 + .../Commands/BroadcastDashboardStats.php | 37 + .../Commands/SendSystemHealthDigest.php | 83 + app/Console/Commands/SystemCheck.php | 90 + app/Console/Commands/SystemHealthCheck.php | 149 ++ app/Console/Commands/SystemOptimize.php | 85 + app/Console/Commands/VerifyBackups.php | 163 ++ app/Events/ActivityLogCreated.php | 87 + app/Events/AiHealingLogCreated.php | 36 + app/Events/DashboardStatsUpdated.php | 31 + app/Events/ImpersonationStatusChanged.php | 44 + app/Events/NotificationSent.php | 57 + app/Events/SettingUpdated.php | 36 + app/Events/SystemNotification.php | 59 + app/Exceptions/BackupOperationException.php | 25 + app/Exceptions/MonitoringException.php | 20 + app/Exceptions/SystemConfigException.php | 20 + app/Helpers/ImpersonateHelper.php | 15 + app/Helpers/PasswordRuleHelper.php | 41 + app/Helpers/SessionHelper.php | 69 + app/Helpers/SettingsHelper.php | 161 ++ .../Controllers/AI/AiAssistantController.php | 89 + .../Controllers/AI/LogAnalysisController.php | 83 + .../AccessControl/ActionLogController.php | 267 +++ .../PermissionManagementController.php | 367 ++++ .../RoleManagementController.php | 613 ++++++ .../UserManagementController.php | 574 +++++ .../Admin/MobileSettingController.php | 81 + app/Http/Controllers/Api/AuthController.php | 440 ++++ .../Controllers/Api/DeviceTokenController.php | 88 + app/Http/Controllers/Api/HealthController.php | 113 + .../Api/MobileConfigController.php | 68 + .../Controllers/Api/MobileLogController.php | 34 + app/Http/Controllers/Api/OtpController.php | 92 + app/Http/Controllers/Api/Swagger.php | 48 + .../Auth/AuthenticatedSessionController.php | 128 ++ .../Auth/ConfirmablePasswordController.php | 40 + ...mailVerificationNotificationController.php | 24 + .../EmailVerificationPromptController.php | 21 + .../Auth/NewPasswordController.php | 64 + .../Controllers/Auth/PasswordController.php | 49 + .../Auth/PasswordResetLinkController.php | 103 + .../Auth/RegisteredUserController.php | 116 + .../Controllers/Auth/SocialAuthController.php | 119 + .../Controllers/Auth/TwoFactorController.php | 134 ++ .../Auth/VerifyEmailController.php | 27 + app/Http/Controllers/Controller.php | 10 + app/Http/Controllers/DashboardController.php | 82 + .../Controllers/ImpersonateController.php | 119 + app/Http/Controllers/LegalController.php | 114 + app/Http/Controllers/ProfileController.php | 67 + .../System/GlobalSearchController.php | 46 + .../AiSelfHealingController.php | 288 +++ .../BackupRestoreController.php | 256 +++ .../SystemSettings/EditorUploadController.php | 47 + .../MaintenanceModeController.php | 40 + .../NotificationCenterController.php | 224 ++ .../SessionManagerController.php | 310 +++ .../SystemSettings/SystemConfigController.php | 267 +++ .../SystemMonitoringController.php | 699 ++++++ .../WebAuthn/WebAuthnLoginController.php | 41 + .../WebAuthn/WebAuthnRegisterController.php | 46 + app/Http/Helpers/ApiResponse.php | 62 + app/Http/Middleware/CheckActivePermission.php | 30 + app/Http/Middleware/CheckLegalAgreement.php | 38 + app/Http/Middleware/CheckMenuPermission.php | 42 + app/Http/Middleware/CheckTabPermission.php | 47 + app/Http/Middleware/GzipCompression.php | 50 + app/Http/Middleware/IpAccessControl.php | 99 + .../MobileMaintenanceMiddleware.php | 65 + .../Middleware/PasswordExpiryMiddleware.php | 34 + app/Http/Middleware/SecurityHeaders.php | 77 + .../Admin/UpdateMobileSettingRequest.php | 83 + app/Http/Requests/Auth/LoginRequest.php | 92 + app/Http/Requests/ProfileUpdateRequest.php | 31 + .../UpdateSystemConfigRequest.php | 246 +++ app/Jobs/AiHealerJob.php | 311 +++ app/Jobs/Monitoring/WorkerHeartbeatJob.php | 32 + app/Listeners/AnalyzeSystemError.php | 93 + app/Listeners/LogFailedLogin.php | 24 + app/Listeners/LogLogout.php | 38 + app/Listeners/LogSuccessfulLogin.php | 29 + app/Mail/SystemHealthDigest.php | 53 + app/Mail/TwoFactorOtp.php | 53 + app/Models/AI/AiUsageLog.php | 65 + app/Models/AiHealingLog.php | 34 + app/Models/DashboardWidgetPreference.php | 59 + app/Models/DeviceToken.php | 27 + app/Models/MobileErrorLog.php | 63 + app/Models/MobileSetting.php | 63 + app/Models/MobileSyncLog.php | 40 + app/Models/Notification.php | 55 + app/Models/OtpCode.php | 29 + app/Models/PasswordHistory.php | 24 + app/Models/Permission.php | 70 + app/Models/Permission.php.bak.20260516064435 | 85 + app/Models/Role.php | 81 + app/Models/SystemSetting.php | 64 + app/Models/SystemSettingRevision.php | 21 + app/Models/Transaction.php | 27 + app/Models/User.php | 214 ++ app/Models/UserConsent.php | 35 + app/Models/UserTrustedDevice.php | 28 + .../Auth/LegalConsentConfirmation.php | 56 + .../Auth/ResetPasswordNotification.php | 50 + .../Auth/VerifyEmailNotification.php | 28 + .../SystemManagementNotification.php | 71 + app/Observers/AiHealingLogObserver.php | 48 + app/Observers/PermissionObserver.php | 50 + app/Observers/RoleObserver.php | 50 + app/Observers/UserObserver.php | 24 + app/Providers/AppServiceProvider.php | 327 +++ app/Providers/Filament/AdminPanelProvider.php | 61 + app/Providers/HorizonServiceProvider.php | 34 + app/Providers/SystemConfigServiceProvider.php | 175 ++ app/Providers/TelescopeServiceProvider.php | 63 + app/Repositories/BaseRepository.php | 102 + app/Repositories/NotificationRepository.php | 138 ++ app/Repositories/SystemSettingRepository.php | 49 + app/Repositories/UserRepository.php | 36 + app/Services/AI/AiAssistantService.php | 65 + app/Services/AI/AiProviderInterface.php | 18 + app/Services/AI/AiService.php | 132 ++ app/Services/AI/BaseAiProvider.php | 50 + app/Services/AI/ClaudeProvider.php | 84 + app/Services/AI/GeminiProvider.php | 82 + app/Services/AI/GptProvider.php | 96 + app/Services/AI/LogAnalysisService.php | 86 + app/Services/AI/OllamaProvider.php | 77 + app/Services/AI/OpenAiCompatibleProvider.php | 92 + app/Services/AI/SecurityHardeningService.php | 93 + app/Services/Auth/OtpService.php | 61 + app/Services/Auth/PasswordPolicyService.php | 112 + app/Services/FcmService.php | 116 + .../MobileConfig/MobileConfigService.php | 394 ++++ .../Monitoring/MonitoringFormatter.php | 60 + .../Monitoring/SystemMonitoringService.php | 710 ++++++ app/Services/Notification/TelegramService.php | 45 + app/Services/System/ActivityFormatter.php | 149 ++ .../System/BackupManagementService.php | 550 +++++ app/Services/System/GlobalSearchService.php | 121 ++ .../System/MaintenanceManagementService.php | 203 ++ ...ceManagementService.php.bak.20260516234440 | 204 ++ .../SystemConfig/SettingDefinitions.php | 1047 +++++++++ .../SystemConfig/SettingFileUploader.php | 34 + .../SystemConfig/SettingValueCaster.php | 57 + .../SystemConfig/SystemConfigService.php | 225 ++ app/Support/DataTable.php | 70 + app/Traits/HasAutoCode.php | 61 + app/View/Components/AppLayout.php | 17 + app/View/Components/GuestLayout.php | 17 + database/.gitignore | 1 + database/factories/AiHealingLogFactory.php | 23 + database/factories/UserFactory.php | 45 + .../0001_01_01_000000_create_users_table.php | 49 + .../0001_01_01_000001_create_cache_table.php | 35 + .../0001_01_01_000002_create_jobs_table.php | 57 + ..._12_24_100239_create_permission_tables.php | 140 ++ ...12_30_063114_create_activity_log_table.php | 29 + ...1_02_161054_create_notifications_table.php | 42 + .../2026_04_16_120608_create_media_table.php | 32 + ...16_200000_create_system_settings_table.php | 29 + ..._create_system_setting_revisions_table.php | 28 + ...17_125720_add_advanced_security_tables.php | 49 + ..._17_125913_create_webauthn_credentials.php | 10 + ..._131918_create_telescope_entries_table.php | 70 + ..._162554_create_notification_user_table.php | 34 + ...4_21_132019_create_user_consents_table.php | 32 + ...4745_rename_custom_notification_tables.php | 25 + ...800_create_laravel_notifications_table.php | 31 + ...19_create_personal_access_tokens_table.php | 33 + ...04_24_000159_create_transactions_table.php | 29 + ...24_135530_create_mobile_settings_table.php | 31 + ...4_214539_create_mobile_sync_logs_table.php | 26 + ..._214850_create_mobile_error_logs_table.php | 28 + ...2026_04_25_092344_create_imports_table.php | 35 + ...2026_04_25_092345_create_exports_table.php | 35 + ...092346_create_failed_import_rows_table.php | 30 + .../2026_04_25_092347_create_pulse_tables.php | 84 + ...4_26_205430_create_ai_usage_logs_table.php | 39 + ..._audit_and_status_to_roles_permissions.php | 40 + ..._05_07_054445_add_missing_user_columns.php | 29 + ..._05_07_062510_add_audit_to_users_table.php | 33 + ...soft_deletes_and_status_to_users_table.php | 36 + ...active_to_roles_and_permissions_tables.php | 40 + ...26_05_08_022324_create_otp_codes_table.php | 25 + ...5_08_100000_create_device_tokens_table.php | 29 + .../2026_05_08_222600_create_pulse_tables.php | 84 + ...0000_add_social_columns_to_users_table.php | 34 + ...5_12_213000_fix_notification_type_enum.php | 34 + ...0757_add_indices_to_transactions_table.php | 32 + ...2137_add_indices_to_activity_log_table.php | 31 + ...6_05_14_100000_add_performance_indexes.php | 25 + ...6_05_14_110000_add_fk_to_audit_columns.php | 49 + ...15_015531_create_ai_healing_logs_table.php | 33 + ...add_code_diff_to_ai_healing_logs_table.php | 29 + ..._212809_add_scope_to_permissions_table.php | 27 + ...ate_dashboard_widget_preferences_table.php | 28 + database/seeders/AdminUserSeeder.php | 79 + database/seeders/DatabaseSeeder.php | 49 + database/seeders/MobileSettingSeeder.php | 93 + .../seeders/MobileSettingsTableSeeder.php | 786 +++++++ .../ModelHasPermissionsTableSeeder.php | 24 + database/seeders/ModelHasRolesTableSeeder.php | 50 + database/seeders/PermissionsTableSeeder.php | 350 +++ database/seeders/RoleAndPermissionSeeder.php | 144 ++ .../seeders/RoleHasPermissionsTableSeeder.php | 211 ++ database/seeders/RolesTableSeeder.php | 62 + database/seeders/SystemSettingSeeder.php | 1100 ++++++++++ .../seeders/SystemSettingsTableSeeder.php | 1930 +++++++++++++++++ database/seeders/UsersTableSeeder.php | 114 + 212 files changed, 23901 insertions(+) create mode 100644 app/Console/Commands/AI/GenerateSwaggerAnnotations.php create mode 100644 app/Console/Commands/AuditPermissions.php create mode 100644 app/Console/Commands/BroadcastDashboardStats.php create mode 100644 app/Console/Commands/SendSystemHealthDigest.php create mode 100644 app/Console/Commands/SystemCheck.php create mode 100644 app/Console/Commands/SystemHealthCheck.php create mode 100644 app/Console/Commands/SystemOptimize.php create mode 100644 app/Console/Commands/VerifyBackups.php create mode 100644 app/Events/ActivityLogCreated.php create mode 100644 app/Events/AiHealingLogCreated.php create mode 100644 app/Events/DashboardStatsUpdated.php create mode 100644 app/Events/ImpersonationStatusChanged.php create mode 100644 app/Events/NotificationSent.php create mode 100644 app/Events/SettingUpdated.php create mode 100644 app/Events/SystemNotification.php create mode 100644 app/Exceptions/BackupOperationException.php create mode 100644 app/Exceptions/MonitoringException.php create mode 100644 app/Exceptions/SystemConfigException.php create mode 100644 app/Helpers/ImpersonateHelper.php create mode 100644 app/Helpers/PasswordRuleHelper.php create mode 100644 app/Helpers/SessionHelper.php create mode 100644 app/Helpers/SettingsHelper.php create mode 100644 app/Http/Controllers/AI/AiAssistantController.php create mode 100644 app/Http/Controllers/AI/LogAnalysisController.php create mode 100644 app/Http/Controllers/AccessControl/ActionLogController.php create mode 100644 app/Http/Controllers/AccessControl/PermissionManagementController.php create mode 100644 app/Http/Controllers/AccessControl/RoleManagementController.php create mode 100644 app/Http/Controllers/AccessControl/UserManagementController.php create mode 100644 app/Http/Controllers/Admin/MobileSettingController.php create mode 100644 app/Http/Controllers/Api/AuthController.php create mode 100644 app/Http/Controllers/Api/DeviceTokenController.php create mode 100644 app/Http/Controllers/Api/HealthController.php create mode 100644 app/Http/Controllers/Api/MobileConfigController.php create mode 100644 app/Http/Controllers/Api/MobileLogController.php create mode 100644 app/Http/Controllers/Api/OtpController.php create mode 100644 app/Http/Controllers/Api/Swagger.php create mode 100644 app/Http/Controllers/Auth/AuthenticatedSessionController.php create mode 100644 app/Http/Controllers/Auth/ConfirmablePasswordController.php create mode 100644 app/Http/Controllers/Auth/EmailVerificationNotificationController.php create mode 100644 app/Http/Controllers/Auth/EmailVerificationPromptController.php create mode 100644 app/Http/Controllers/Auth/NewPasswordController.php create mode 100644 app/Http/Controllers/Auth/PasswordController.php create mode 100644 app/Http/Controllers/Auth/PasswordResetLinkController.php create mode 100644 app/Http/Controllers/Auth/RegisteredUserController.php create mode 100644 app/Http/Controllers/Auth/SocialAuthController.php create mode 100644 app/Http/Controllers/Auth/TwoFactorController.php create mode 100644 app/Http/Controllers/Auth/VerifyEmailController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/DashboardController.php create mode 100644 app/Http/Controllers/ImpersonateController.php create mode 100644 app/Http/Controllers/LegalController.php create mode 100644 app/Http/Controllers/ProfileController.php create mode 100644 app/Http/Controllers/System/GlobalSearchController.php create mode 100644 app/Http/Controllers/SystemSettings/AiSelfHealingController.php create mode 100644 app/Http/Controllers/SystemSettings/BackupRestoreController.php create mode 100644 app/Http/Controllers/SystemSettings/EditorUploadController.php create mode 100644 app/Http/Controllers/SystemSettings/MaintenanceModeController.php create mode 100644 app/Http/Controllers/SystemSettings/NotificationCenterController.php create mode 100644 app/Http/Controllers/SystemSettings/SessionManagerController.php create mode 100644 app/Http/Controllers/SystemSettings/SystemConfigController.php create mode 100644 app/Http/Controllers/SystemSettings/SystemMonitoringController.php create mode 100644 app/Http/Controllers/WebAuthn/WebAuthnLoginController.php create mode 100644 app/Http/Controllers/WebAuthn/WebAuthnRegisterController.php create mode 100644 app/Http/Helpers/ApiResponse.php create mode 100644 app/Http/Middleware/CheckActivePermission.php create mode 100644 app/Http/Middleware/CheckLegalAgreement.php create mode 100644 app/Http/Middleware/CheckMenuPermission.php create mode 100644 app/Http/Middleware/CheckTabPermission.php create mode 100644 app/Http/Middleware/GzipCompression.php create mode 100644 app/Http/Middleware/IpAccessControl.php create mode 100644 app/Http/Middleware/MobileMaintenanceMiddleware.php create mode 100644 app/Http/Middleware/PasswordExpiryMiddleware.php create mode 100644 app/Http/Middleware/SecurityHeaders.php create mode 100644 app/Http/Requests/Admin/UpdateMobileSettingRequest.php create mode 100644 app/Http/Requests/Auth/LoginRequest.php create mode 100644 app/Http/Requests/ProfileUpdateRequest.php create mode 100644 app/Http/Requests/SystemSettings/UpdateSystemConfigRequest.php create mode 100644 app/Jobs/AiHealerJob.php create mode 100644 app/Jobs/Monitoring/WorkerHeartbeatJob.php create mode 100644 app/Listeners/AnalyzeSystemError.php create mode 100644 app/Listeners/LogFailedLogin.php create mode 100644 app/Listeners/LogLogout.php create mode 100644 app/Listeners/LogSuccessfulLogin.php create mode 100644 app/Mail/SystemHealthDigest.php create mode 100644 app/Mail/TwoFactorOtp.php create mode 100644 app/Models/AI/AiUsageLog.php create mode 100644 app/Models/AiHealingLog.php create mode 100644 app/Models/DashboardWidgetPreference.php create mode 100644 app/Models/DeviceToken.php create mode 100644 app/Models/MobileErrorLog.php create mode 100644 app/Models/MobileSetting.php create mode 100644 app/Models/MobileSyncLog.php create mode 100644 app/Models/Notification.php create mode 100644 app/Models/OtpCode.php create mode 100644 app/Models/PasswordHistory.php create mode 100644 app/Models/Permission.php create mode 100644 app/Models/Permission.php.bak.20260516064435 create mode 100644 app/Models/Role.php create mode 100644 app/Models/SystemSetting.php create mode 100644 app/Models/SystemSettingRevision.php create mode 100644 app/Models/Transaction.php create mode 100644 app/Models/User.php create mode 100644 app/Models/UserConsent.php create mode 100644 app/Models/UserTrustedDevice.php create mode 100644 app/Notifications/Auth/LegalConsentConfirmation.php create mode 100644 app/Notifications/Auth/ResetPasswordNotification.php create mode 100644 app/Notifications/Auth/VerifyEmailNotification.php create mode 100644 app/Notifications/SystemManagementNotification.php create mode 100644 app/Observers/AiHealingLogObserver.php create mode 100644 app/Observers/PermissionObserver.php create mode 100644 app/Observers/RoleObserver.php create mode 100644 app/Observers/UserObserver.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/Providers/Filament/AdminPanelProvider.php create mode 100644 app/Providers/HorizonServiceProvider.php create mode 100644 app/Providers/SystemConfigServiceProvider.php create mode 100644 app/Providers/TelescopeServiceProvider.php create mode 100644 app/Repositories/BaseRepository.php create mode 100644 app/Repositories/NotificationRepository.php create mode 100644 app/Repositories/SystemSettingRepository.php create mode 100644 app/Repositories/UserRepository.php create mode 100644 app/Services/AI/AiAssistantService.php create mode 100644 app/Services/AI/AiProviderInterface.php create mode 100644 app/Services/AI/AiService.php create mode 100644 app/Services/AI/BaseAiProvider.php create mode 100644 app/Services/AI/ClaudeProvider.php create mode 100644 app/Services/AI/GeminiProvider.php create mode 100644 app/Services/AI/GptProvider.php create mode 100644 app/Services/AI/LogAnalysisService.php create mode 100644 app/Services/AI/OllamaProvider.php create mode 100644 app/Services/AI/OpenAiCompatibleProvider.php create mode 100644 app/Services/AI/SecurityHardeningService.php create mode 100644 app/Services/Auth/OtpService.php create mode 100644 app/Services/Auth/PasswordPolicyService.php create mode 100644 app/Services/FcmService.php create mode 100644 app/Services/MobileConfig/MobileConfigService.php create mode 100644 app/Services/Monitoring/MonitoringFormatter.php create mode 100644 app/Services/Monitoring/SystemMonitoringService.php create mode 100644 app/Services/Notification/TelegramService.php create mode 100644 app/Services/System/ActivityFormatter.php create mode 100644 app/Services/System/BackupManagementService.php create mode 100644 app/Services/System/GlobalSearchService.php create mode 100644 app/Services/System/MaintenanceManagementService.php create mode 100644 app/Services/System/MaintenanceManagementService.php.bak.20260516234440 create mode 100644 app/Services/SystemConfig/SettingDefinitions.php create mode 100644 app/Services/SystemConfig/SettingFileUploader.php create mode 100644 app/Services/SystemConfig/SettingValueCaster.php create mode 100644 app/Services/SystemConfig/SystemConfigService.php create mode 100644 app/Support/DataTable.php create mode 100644 app/Traits/HasAutoCode.php create mode 100644 app/View/Components/AppLayout.php create mode 100644 app/View/Components/GuestLayout.php create mode 100644 database/.gitignore create mode 100644 database/factories/AiHealingLogFactory.php create mode 100644 database/factories/UserFactory.php create mode 100644 database/migrations/0001_01_01_000000_create_users_table.php create mode 100644 database/migrations/0001_01_01_000001_create_cache_table.php create mode 100644 database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 database/migrations/2025_12_24_100239_create_permission_tables.php create mode 100644 database/migrations/2025_12_30_063114_create_activity_log_table.php create mode 100644 database/migrations/2026_01_02_161054_create_notifications_table.php create mode 100644 database/migrations/2026_04_16_120608_create_media_table.php create mode 100644 database/migrations/2026_04_16_200000_create_system_settings_table.php create mode 100644 database/migrations/2026_04_16_200100_create_system_setting_revisions_table.php create mode 100644 database/migrations/2026_04_17_125720_add_advanced_security_tables.php create mode 100644 database/migrations/2026_04_17_125913_create_webauthn_credentials.php create mode 100644 database/migrations/2026_04_20_131918_create_telescope_entries_table.php create mode 100644 database/migrations/2026_04_20_162554_create_notification_user_table.php create mode 100644 database/migrations/2026_04_21_132019_create_user_consents_table.php create mode 100644 database/migrations/2026_04_22_064745_rename_custom_notification_tables.php create mode 100644 database/migrations/2026_04_22_064800_create_laravel_notifications_table.php create mode 100644 database/migrations/2026_04_23_225319_create_personal_access_tokens_table.php create mode 100644 database/migrations/2026_04_24_000159_create_transactions_table.php create mode 100644 database/migrations/2026_04_24_135530_create_mobile_settings_table.php create mode 100644 database/migrations/2026_04_24_214539_create_mobile_sync_logs_table.php create mode 100644 database/migrations/2026_04_24_214850_create_mobile_error_logs_table.php create mode 100644 database/migrations/2026_04_25_092344_create_imports_table.php create mode 100644 database/migrations/2026_04_25_092345_create_exports_table.php create mode 100644 database/migrations/2026_04_25_092346_create_failed_import_rows_table.php create mode 100644 database/migrations/2026_04_25_092347_create_pulse_tables.php create mode 100644 database/migrations/2026_04_26_205430_create_ai_usage_logs_table.php create mode 100644 database/migrations/2026_05_07_054201_add_audit_and_status_to_roles_permissions.php create mode 100644 database/migrations/2026_05_07_054445_add_missing_user_columns.php create mode 100644 database/migrations/2026_05_07_062510_add_audit_to_users_table.php create mode 100644 database/migrations/2026_05_07_232149_add_soft_deletes_and_status_to_users_table.php create mode 100644 database/migrations/2026_05_07_232802_add_is_active_to_roles_and_permissions_tables.php create mode 100644 database/migrations/2026_05_08_022324_create_otp_codes_table.php create mode 100644 database/migrations/2026_05_08_100000_create_device_tokens_table.php create mode 100644 database/migrations/2026_05_08_222600_create_pulse_tables.php create mode 100644 database/migrations/2026_05_12_120000_add_social_columns_to_users_table.php create mode 100644 database/migrations/2026_05_12_213000_fix_notification_type_enum.php create mode 100644 database/migrations/2026_05_12_230757_add_indices_to_transactions_table.php create mode 100644 database/migrations/2026_05_12_232137_add_indices_to_activity_log_table.php create mode 100644 database/migrations/2026_05_14_100000_add_performance_indexes.php create mode 100644 database/migrations/2026_05_14_110000_add_fk_to_audit_columns.php create mode 100644 database/migrations/2026_05_15_015531_create_ai_healing_logs_table.php create mode 100644 database/migrations/2026_05_15_015532_add_code_diff_to_ai_healing_logs_table.php create mode 100644 database/migrations/2026_05_16_212809_add_scope_to_permissions_table.php create mode 100644 database/migrations/2026_05_16_220000_create_dashboard_widget_preferences_table.php create mode 100644 database/seeders/AdminUserSeeder.php create mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 database/seeders/MobileSettingSeeder.php create mode 100644 database/seeders/MobileSettingsTableSeeder.php create mode 100644 database/seeders/ModelHasPermissionsTableSeeder.php create mode 100644 database/seeders/ModelHasRolesTableSeeder.php create mode 100644 database/seeders/PermissionsTableSeeder.php create mode 100644 database/seeders/RoleAndPermissionSeeder.php create mode 100644 database/seeders/RoleHasPermissionsTableSeeder.php create mode 100644 database/seeders/RolesTableSeeder.php create mode 100644 database/seeders/SystemSettingSeeder.php create mode 100644 database/seeders/SystemSettingsTableSeeder.php create mode 100644 database/seeders/UsersTableSeeder.php diff --git a/app/Console/Commands/AI/GenerateSwaggerAnnotations.php b/app/Console/Commands/AI/GenerateSwaggerAnnotations.php new file mode 100644 index 0000000..4db85df --- /dev/null +++ b/app/Console/Commands/AI/GenerateSwaggerAnnotations.php @@ -0,0 +1,110 @@ +argument('controller'); + + if (! File::exists($path)) { + // Try to find it in app/Http/Controllers + $fullPath = app_path('Http/Controllers/'.ltrim($path, '/')); + if (! File::exists($fullPath)) { + $this->error("Controller not found at: {$path}"); + + return 1; + } + $path = $fullPath; + } + + $content = File::get($path); + + $this->info('Analyzing controller: '.basename($path)); + + $prompt = "Generate PHP Swagger (L5-Swagger) annotations for the following Laravel controller. + Focus on @OA\Get, @OA\Post, etc. with proper @OA\Response and @OA\Parameter. + + Guidelines: + - Use modern OpenAPI 3.0 standards. + - Include common responses like 200, 401, 403, and 500. + - Identify request parameters from the code. + - OUTPUT ONLY THE PHP CODE FOR THE ANNOTATIONS, no extra explanation. + + CONTROLLER CODE: + {$content}"; + + $result = $this->aiService->provider()->generate($prompt); + + if (isset($result['success']) && $result['success']) { + $annotations = $result['response']; + + $this->warn('AI Generated Annotations:'); + $this->line($annotations); + + if ($this->confirm('Do you want to prepend these annotations to the file?')) { + // Find where to insert (usually before the class declaration) + $pattern = '/class\s+'.basename($path, '.php').'/'; + if (preg_match($pattern, $content)) { + $newContent = preg_replace($pattern, $annotations."\n".'$0', $content); + File::put($path, $newContent); + $this->success('Annotations added to '.basename($path)); + } else { + $this->error('Could not find class declaration to insert annotations.'); + } + } + } else { + $this->error('AI Error: '.($result['error'] ?? 'Unknown error')); + } + + return 0; + } +} diff --git a/app/Console/Commands/AuditPermissions.php b/app/Console/Commands/AuditPermissions.php new file mode 100644 index 0000000..5ae2720 --- /dev/null +++ b/app/Console/Commands/AuditPermissions.php @@ -0,0 +1,81 @@ +collectRoutePermissions(); + $dbPerms = Permission::pluck('name')->map(fn ($n) => strtolower($n))->toArray(); + + $missing = $routePerms->filter(fn ($p) => ! in_array(strtolower($p), $dbPerms))->values(); + $orphaned = collect($dbPerms)->filter(fn ($p) => ! $routePerms->map(fn ($r) => strtolower($r))->contains($p))->values(); + + if ($this->option('json')) { + $this->line(json_encode(compact('missing', 'orphaned'), JSON_PRETTY_PRINT)); + + return self::SUCCESS; + } + + $this->info('=== Permission Audit ==='); + $this->newLine(); + + if ($missing->isEmpty()) { + $this->line('✓ All route permissions exist in database.'); + } else { + $this->warn("Missing in database ({$missing->count()} permissions used in routes but not in DB):"); + $missing->each(fn ($p) => $this->line(" - {$p}")); + + if ($this->option('fix')) { + $missing->each(fn ($p) => Permission::findOrCreate($p, 'web')); + $this->info("Created {$missing->count()} missing permission(s)."); + } + } + + $this->newLine(); + + if ($orphaned->isEmpty()) { + $this->line('✓ No orphaned permissions in database.'); + } else { + $this->warn("Orphaned in database ({$orphaned->count()} permissions in DB but not used in any route):"); + $orphaned->each(fn ($p) => $this->line(" - {$p}")); + } + + return $missing->isEmpty() ? self::SUCCESS : self::FAILURE; + } + + private function collectRoutePermissions(): Collection + { + $permissions = collect(); + + foreach ($this->router->getRoutes() as $route) { + $middleware = $route->gatherMiddleware(); + + foreach ($middleware as $mw) { + if (is_string($mw) && str_starts_with($mw, 'permission:')) { + $perm = trim(str_replace('permission:', '', $mw)); + $permissions->push($perm); + } + } + } + + return $permissions->unique()->sort()->values(); + } +} diff --git a/app/Console/Commands/BroadcastDashboardStats.php b/app/Console/Commands/BroadcastDashboardStats.php new file mode 100644 index 0000000..e3c4173 --- /dev/null +++ b/app/Console/Commands/BroadcastDashboardStats.php @@ -0,0 +1,37 @@ +getAll(); + + // Slim payload — only what the dashboard widgets need + $payload = [ + 'cpu' => $stats['cpu'], + 'ram' => $stats['ram'], + 'disk' => $stats['disk'], + 'users' => $stats['users'], + 'queues' => $stats['queues'], + 'uptime' => $stats['uptime'], + 'has_reverb' => $stats['has_reverb'], + 'last_update'=> $stats['last_update'], + ]; + + DashboardStatsUpdated::dispatch($payload); + } +} diff --git a/app/Console/Commands/SendSystemHealthDigest.php b/app/Console/Commands/SendSystemHealthDigest.php new file mode 100644 index 0000000..a64f7bb --- /dev/null +++ b/app/Console/Commands/SendSystemHealthDigest.php @@ -0,0 +1,83 @@ +info('Gathering system metrics...'); + $stats = $this->monitoringService->getAll(); + + if (! get_setting('ai_enabled', false)) { + $this->error('AI Service is disabled. Cannot generate analysis.'); + + return 1; + } + + $this->info('Generating AI analysis...'); + + $prompt = 'As a Senior Systems Architect, analyze the following system metrics and provide a concise, professional summary of the system health. + Detect any issues and provide recommendations. + + METRICS: + '.json_encode($stats, JSON_PRETTY_PRINT); + + $result = $this->aiService->provider()->generate($prompt); + + if (isset($result['success']) && $result['success']) { + $analysis = $result['response']; + + $admins = User::role(['Developer', 'Administrator'])->get(); + $this->info('Sending digest to '.$admins->count().' administrators...'); + + foreach ($admins as $admin) { + Mail::to($admin->email)->send(new SystemHealthDigest($analysis, $stats)); + } + + $this->info('Digest sent successfully!'); + } else { + $this->error('AI Analysis failed: '.($result['error'] ?? 'Unknown error')); + } + + return 0; + } +} diff --git a/app/Console/Commands/SystemCheck.php b/app/Console/Commands/SystemCheck.php new file mode 100644 index 0000000..fbc76e6 --- /dev/null +++ b/app/Console/Commands/SystemCheck.php @@ -0,0 +1,90 @@ +title('BIIProject System Health Check'); + + $rows = []; + + // 1. Database + try { + DB::connection()->getPdo(); + $rows[] = ['Database', 'PostgreSQL', 'CONNECTED']; + } catch (\Exception $e) { + $rows[] = ['Database', 'PostgreSQL', 'FAILED']; + } + + // 2. Redis + try { + Redis::ping(); + $rows[] = ['Cache', 'Redis', 'CONNECTED']; + } catch (\Exception $e) { + $rows[] = ['Cache', 'Redis', 'FAILED']; + } + + // 3. Storage + $storageOk = true; + try { + Storage::disk('local')->put('health-check.txt', 'ok'); + Storage::disk('local')->delete('health-check.txt'); + } catch (\Exception $e) { + $storageOk = false; + } + $rows[] = ['Storage', 'Local Writable', $storageOk ? 'OK' : 'FAILED']; + + // 4. AI + $aiEnabled = get_setting('ai_enabled', false); + $aiProvider = get_setting('ai_provider', 'N/A'); + $rows[] = ['Intelligence', 'AI Service', $aiEnabled ? "ENABLED ({$aiProvider})" : 'DISABLED']; + + // 5. Broadcast + $rows[] = ['Real-time', 'Reverb', config('reverb') ? 'CONFIGURED' : 'NOT CONFIGURED']; + + $this->table(['Component', 'Service', 'Status'], $rows); + + $this->newLine(); + $this->info('System check completed at '.now()->toDateTimeString()); + + return 0; + } + + protected function title($text) + { + $this->newLine(); + $this->line(' '.strtoupper($text).' '); + $this->newLine(); + } +} diff --git a/app/Console/Commands/SystemHealthCheck.php b/app/Console/Commands/SystemHealthCheck.php new file mode 100644 index 0000000..9ad7f74 --- /dev/null +++ b/app/Console/Commands/SystemHealthCheck.php @@ -0,0 +1,149 @@ + 80, // percentage + 'ram' => 90, // percentage + 'disk' => 90, // percentage + ]; + + /** + * Cool-down period in minutes to prevent spamming notifications. + */ + protected $cooldownMinutes = 30; + + /** + * Execute the console command. + */ + public function handle(SystemMonitoringService $monitor, TelegramService $telegram) + { + $this->info('Starting System Health Check...'); + + $issues = []; + $metrics = []; + + // 1. Check Database Connectivity + try { + DB::connection()->getPdo(); + $metrics[] = '✅ Database: Connected'; + } catch (\Exception $e) { + $issues[] = '❌ DATABASE DOWN: Unable to connect to the database. Error: '.$e->getMessage(); + } + + // 2. Check CPU Usage + $cpu = $monitor->getCpuUsage(); + $metrics[] = "📊 CPU Usage: {$cpu}%"; + if ($cpu >= $this->thresholds['cpu']) { + $issues[] = "⚠️ High CPU Usage: {$cpu}% (Threshold: {$this->thresholds['cpu']}%)"; + } + + // 3. Check RAM Usage + $ram = $monitor->getRamUsage(); + $metrics[] = "📊 RAM Usage: {$ram}%"; + if ($ram >= $this->thresholds['ram']) { + $issues[] = "⚠️ High RAM Usage: {$ram}% (Threshold: {$this->thresholds['ram']}%)"; + } + + // 4. Check Disk Usage + $disk = $monitor->getDiskUsage(); + $metrics[] = "📊 Disk Usage: {$disk}%"; + if ($disk >= $this->thresholds['disk']) { + $issues[] = "⚠️ High Disk Usage: {$disk}% (Threshold: {$this->thresholds['disk']}%)"; + } + + // Output to console + foreach ($metrics as $metric) { + $this->line($metric); + } + + if (empty($issues)) { + $this->info('System health is optimal. No issues detected.'); + + return 0; + } + + $this->error(count($issues).' issue(s) detected!'); + + // Check for cool-down + $cacheKey = 'system_health_alert_last_sent'; + if (Cache::has($cacheKey) && ! $this->option('force')) { + $this->warn('Issues detected, but notification is in cool-down period. Use --force to override.'); + + return 0; + } + + // Prepare Telegram Message + $hostname = gethostname(); + $ip = request()->server('SERVER_ADDR') ?? gethostbyname($hostname); + $time = now()->format('Y-m-d H:i:s'); + + $message = "🚨 SYSTEM HEALTH ALERT 🚨\n"; + $message .= "--------------------------------\n"; + $message .= "Host: {$hostname} ({$ip})\n"; + $message .= "Time: {$time}\n"; + $message .= "--------------------------------\n\n"; + $message .= implode("\n", $issues)."\n\n"; + $message .= 'Please check the system dashboard for more details.'; + + // Send Notification + if ($telegram->sendMessage($message)) { + $this->info('Alert notification sent to Telegram.'); + Cache::put($cacheKey, true, now()->addMinutes($this->cooldownMinutes)); + } else { + $this->error('Failed to send Telegram notification. Check laravel.log for details.'); + } + + return 0; + } +} diff --git a/app/Console/Commands/SystemOptimize.php b/app/Console/Commands/SystemOptimize.php new file mode 100644 index 0000000..a45bc46 --- /dev/null +++ b/app/Console/Commands/SystemOptimize.php @@ -0,0 +1,85 @@ +info('🚀 Starting Full System Optimization...'); + + $steps = [ + 'Clearing Cache...' => 'cache:clear', + 'Caching Configuration...' => 'config:cache', + 'Caching Routes...' => 'route:cache', + 'Caching Views...' => 'view:cache', + 'Caching Events...' => 'event:cache', + 'Pruning Database Logs...' => 'model:prune', + ]; + + $bar = $this->output->createProgressBar(count($steps)); + $bar->start(); + + foreach ($steps as $label => $command) { + $this->line("\n".$label); + try { + Artisan::call($command); + $this->info("✓ Done: {$command}"); + } catch (\Exception $e) { + $this->error("✗ Failed: {$command}. Error: ".$e->getMessage()); + } + $bar->advance(); + } + + $bar->finish(); + $this->line("\n"); + $this->info('✨ System Optimization Complete! Everything is running at peak performance.'); + + return 0; + } +} diff --git a/app/Console/Commands/VerifyBackups.php b/app/Console/Commands/VerifyBackups.php new file mode 100644 index 0000000..4f83cfe --- /dev/null +++ b/app/Console/Commands/VerifyBackups.php @@ -0,0 +1,163 @@ +option('disk'); + $maxAgeDays = (int) $this->option('max-age'); + $minBytes = (int) $this->option('min-size'); + + $this->info("Verifying backups on disk: {$disk}"); + + $storage = Storage::disk($disk); + + if (! $storage->exists('Laravel')) { + $reason = 'Backup directory "Laravel" not found on disk.'; + $this->error($reason); + $this->notifyFailure($reason, $disk, $telegram); + + return self::FAILURE; + } + + $files = collect($storage->allFiles('Laravel')) + ->filter(fn ($f) => Str::endsWith($f, ['.zip', '.gz', '.sql'])) + ->sortByDesc(fn ($f) => $storage->lastModified($f)) + ->values(); + + if ($files->isEmpty()) { + $reason = 'No backup files found.'; + $this->error($reason); + $this->notifyFailure($reason, $disk, $telegram); + + return self::FAILURE; + } + + $latest = $files->first(); + $latestModified = $storage->lastModified($latest); + $latestSize = $storage->size($latest); + $ageDays = (int) round((time() - $latestModified) / 86400); + + $this->table(['File', 'Age (days)', 'Size (KB)', 'Status'], [[ + basename($latest), + $ageDays, + round($latestSize / 1024, 1), + $this->statusLabel($ageDays, $latestSize, $maxAgeDays, $minBytes), + ]]); + + $problems = []; + + if ($ageDays > $maxAgeDays) { + $problems[] = "Latest backup is {$ageDays} days old (threshold: {$maxAgeDays} days)"; + } + + if ($latestSize < $minBytes) { + $problems[] = "Latest backup is suspiciously small ({$latestSize} bytes)"; + } + + if (! empty($problems)) { + foreach ($problems as $problem) { + $this->warn($problem); + } + $this->notifyFailure(implode('; ', $problems), $disk, $telegram); + + return self::FAILURE; + } + + $this->info("✓ Backup verification passed. {$files->count()} backup(s) found."); + Log::channel('single')->info('[VerifyBackups] OK', ['count' => $files->count(), 'age_days' => $ageDays]); + + return self::SUCCESS; + } + + private function statusLabel(int $age, int $size, int $maxAge, int $minBytes): string + { + if ($age > $maxAge || $size < $minBytes) { + return '⚠ WARN'; + } + + return '✓ OK'; + } + + private function notifyFailure(string $reason, string $disk, TelegramService $telegram): void + { + Log::channel('single')->error('[VerifyBackups] FAILED', compact('reason', 'disk')); + + // Prepare Telegram Alert + $hostname = gethostname(); + $message = "🚨 BACKUP VERIFICATION FAILED 🚨\n"; + $message .= "--------------------------------\n"; + $message .= "Host: {$hostname}\n"; + $message .= "Disk: {$disk}\n"; + $message .= "Reason: {$reason}\n"; + $message .= "--------------------------------\n"; + $message .= 'Please check the backup logs immediately!'; + + $telegram->sendMessage($message); + + // Notify developer role via database notification + try { + $developers = User::role('Developer')->get(); + foreach ($developers as $dev) { + $dev->notify(new BackupFailedNotification($reason)); + } + } catch (\Throwable) { + // Silently skip if notification classes don't exist yet + } + } +} diff --git a/app/Events/ActivityLogCreated.php b/app/Events/ActivityLogCreated.php new file mode 100644 index 0000000..1e0cc65 --- /dev/null +++ b/app/Events/ActivityLogCreated.php @@ -0,0 +1,87 @@ +log = [ + 'time' => $activity->created_at->diffForHumans(), + 'datetime' => $activity->created_at->format('Y-m-d H:i:s'), + 'level' => $this->getLevel($activity->description), + 'manifest' => ActivityFormatter::getFriendlyModelName($activity->subject_type), + 'description' => ucfirst($activity->description), + 'causer' => $activity->causer ? $activity->causer->name : 'System', + ]; + } + + private function getLevel(string $description): string + { + if (str_contains($description, 'deleted') || str_contains($description, 'failed')) { + return 'CRIT'; + } + if (str_contains($description, 'updated')) { + return 'MODI'; + } + + return 'INFO'; + } + + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('admin.monitoring'), + ]; + } + + /** + * The event's broadcast name. + */ + public function broadcastAs(): string + { + return 'activity.created'; + } +} diff --git a/app/Events/AiHealingLogCreated.php b/app/Events/AiHealingLogCreated.php new file mode 100644 index 0000000..a8a393f --- /dev/null +++ b/app/Events/AiHealingLogCreated.php @@ -0,0 +1,36 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Events/DashboardStatsUpdated.php b/app/Events/DashboardStatsUpdated.php new file mode 100644 index 0000000..07b8e9e --- /dev/null +++ b/app/Events/DashboardStatsUpdated.php @@ -0,0 +1,31 @@ +stats; + } +} diff --git a/app/Events/ImpersonationStatusChanged.php b/app/Events/ImpersonationStatusChanged.php new file mode 100644 index 0000000..af875e5 --- /dev/null +++ b/app/Events/ImpersonationStatusChanged.php @@ -0,0 +1,44 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('App.Models.User.'.$this->userId), + ]; + } + + /** + * The event's broadcast name. + */ + public function broadcastAs(): string + { + return 'impersonation.updated'; + } +} diff --git a/app/Events/NotificationSent.php b/app/Events/NotificationSent.php new file mode 100644 index 0000000..4e3722d --- /dev/null +++ b/app/Events/NotificationSent.php @@ -0,0 +1,57 @@ +notification = $notification; + } + + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + $recipient = $this->notification->recipient; + + // 1. GLOBAL / PUBLIC + if ($recipient === 'all') { + return [new Channel('notifications')]; + } + + // 2. SPECIFIC USER (ID is numeric) + if (is_numeric($recipient)) { + return [new PrivateChannel('App.Models.User.'.$recipient)]; + } + + // 3. BY ROLE (Default fallback for strings like 'admin', 'superadmin', 'user', etc.) + return [new PrivateChannel('roles.'.$recipient)]; + } + + /** + * The event's broadcast name. + */ + public function broadcastAs(): string + { + return 'notification.sent'; + } +} diff --git a/app/Events/SettingUpdated.php b/app/Events/SettingUpdated.php new file mode 100644 index 0000000..9e2201c --- /dev/null +++ b/app/Events/SettingUpdated.php @@ -0,0 +1,36 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel('channel-name'), + ]; + } +} diff --git a/app/Events/SystemNotification.php b/app/Events/SystemNotification.php new file mode 100644 index 0000000..9acb573 --- /dev/null +++ b/app/Events/SystemNotification.php @@ -0,0 +1,59 @@ +message = $message; + $this->type = $type; + $this->title = $title ?? 'System Notification'; + $this->data = [ + 'message' => $this->message, + 'type' => $this->type, + 'title' => $this->title, + ]; + } + + /** + * Get the channels the event should broadcast on. + * + * @return array + */ + public function broadcastOn(): array + { + return [ + new Channel('system-notifications'), + ]; + } + + /** + * The event's broadcast name. + */ + public function broadcastAs(): string + { + return 'message.sent'; + } +} diff --git a/app/Exceptions/BackupOperationException.php b/app/Exceptions/BackupOperationException.php new file mode 100644 index 0000000..a01c48f --- /dev/null +++ b/app/Exceptions/BackupOperationException.php @@ -0,0 +1,25 @@ +has('impersonator_id'); + } +} + +if (! function_exists('impersonatorId')) { + function impersonatorId(): ?int + { + return session('impersonator_id'); + } +} diff --git a/app/Helpers/PasswordRuleHelper.php b/app/Helpers/PasswordRuleHelper.php new file mode 100644 index 0000000..794cef1 --- /dev/null +++ b/app/Helpers/PasswordRuleHelper.php @@ -0,0 +1,41 @@ + __('Password must be at least 12 characters.'), + 'password.regex' => __('Password must contain uppercase, lowercase, number, and symbol.'), + ]; + } +} diff --git a/app/Helpers/SessionHelper.php b/app/Helpers/SessionHelper.php new file mode 100644 index 0000000..b0c4ae7 --- /dev/null +++ b/app/Helpers/SessionHelper.php @@ -0,0 +1,69 @@ + 'Unknown', + 'os' => 'Unknown', + 'browser_icon' => 'bi-question-circle', + 'os_icon' => 'bi-question-circle', + ]; + } + + $browser = 'Unknown'; + $os = 'Unknown'; + $browserIcon = 'bi-globe'; + $osIcon = 'bi-display'; + + // OS Detection + if (preg_match('/android/i', $userAgent)) { + $os = 'Android'; + $osIcon = 'bi-phone'; + } elseif (preg_match('/iphone|ipad|ipod/i', $userAgent)) { + $os = 'iOS'; + $osIcon = 'bi-phone'; + } elseif (preg_match('/windows|win32/i', $userAgent)) { + $os = 'Windows'; + $osIcon = 'bi-windows'; + } elseif (preg_match('/apple|macintosh|mac os x/i', $userAgent)) { + $os = 'macOS'; + $osIcon = 'bi-apple'; + } elseif (preg_match('/linux/i', $userAgent)) { + $os = 'Linux'; + $osIcon = 'bi-ubuntu'; + } + + // Browser Detection + if (preg_match('/edge|edg/i', $userAgent)) { + $browser = 'Edge'; + $browserIcon = 'bi-browser-edge'; + } elseif (preg_match('/chrome|chromium/i', $userAgent)) { + $browser = 'Chrome'; + $browserIcon = 'bi-browser-chrome'; + } elseif (preg_match('/firefox/i', $userAgent)) { + $browser = 'Firefox'; + $browserIcon = 'bi-browser-firefox'; + } elseif (preg_match('/safari/i', $userAgent)) { + $browser = 'Safari'; + $browserIcon = 'bi-browser-safari'; + } elseif (preg_match('/opera|opr/i', $userAgent)) { + $browser = 'Opera'; + $browserIcon = 'bi-browser-opera'; + } + + return [ + 'browser' => $browser, + 'os' => $os, + 'browser_icon' => $browserIcon, + 'os_icon' => $osIcon, + ]; + } +} diff --git a/app/Helpers/SettingsHelper.php b/app/Helpers/SettingsHelper.php new file mode 100644 index 0000000..ab0346c --- /dev/null +++ b/app/Helpers/SettingsHelper.php @@ -0,0 +1,161 @@ +check()) { + return false; + } + $user = auth()->user(); + $tab = str_replace('_', '-', $tab); + + // Developer gate (super-admin) — already handled by Gate::before, but + // we check explicitly here because this is a helper, not a gate call. + if ($user->hasRole('Developer')) { + return true; + } + + // Legacy menu-level manage permission grants full tab access + if ($user->can("manage {$menu}")) { + return true; + } + + // Scoped view or manage grants read access to that tab + return $user->can("view {$menu}:{$tab}") + || $user->can("manage {$menu}:{$tab}"); + } +} + +if (! function_exists('can_manage_tab')) { + function can_manage_tab(string $menu, string $tab): bool + { + if (! auth()->check()) { + return false; + } + $user = auth()->user(); + $tab = str_replace('_', '-', $tab); + + if ($user->hasRole('Developer')) { + return true; + } + + // Legacy menu-level manage = write access to all tabs + if ($user->can("manage {$menu}")) { + return true; + } + + return $user->can("manage {$menu}:{$tab}"); + } +} + +if (! function_exists('can_view_any_tab')) { + /** + * Returns true if the user can access at least one tab of a given menu. + * Used to decide whether to show the menu item in the sidebar at all. + */ + function can_view_any_tab(string $menu): bool + { + if (! auth()->check()) { + return false; + } + $user = auth()->user(); + + if ($user->hasRole('Developer')) { + return true; + } + if ($user->can("view {$menu}") || $user->can("manage {$menu}")) { + return true; + } + + // Check granular permissions (both direct and role-based) + return $user->getAllPermissions() + ->contains(fn ($p) => str_starts_with($p->name, "view {$menu}:") || str_starts_with($p->name, "manage {$menu}:")); + } +} + +if (! function_exists('can_manage_any_tab')) { + /** + * Returns true if the user can manage at least one tab of a given menu. + */ + function can_manage_any_tab(string $menu): bool + { + if (! auth()->check()) { + return false; + } + $user = auth()->user(); + + if ($user->hasRole('Developer') || $user->can("manage {$menu}")) { + return true; + } + + // Check granular permissions (both direct and role-based) + return $user->getAllPermissions() + ->contains(fn ($p) => str_starts_with($p->name, "manage {$menu}:")); + } +} + +if (! function_exists('get_setting')) { + function get_setting(string $key, mixed $default = null): mixed + { + return app(SystemConfigService::class)->get($key, $default); + } +} + +if (! function_exists('set_setting')) { + function set_setting(string $key, mixed $value): bool + { + $request = app()->bound('request') ? request() : null; + + app(SystemConfigService::class)->update([$key => $value], [], Auth::id(), $request); + + return true; + } +} + +if (! function_exists('format_date')) { + function format_date($date): string + { + if (! $date) { + return '-'; + } + $format = get_setting('regional_date_format', 'd/m/Y'); + + return Carbon::parse($date)->format($format); + } +} + +if (! function_exists('format_time')) { + function format_time($time): string + { + if (! $time) { + return '-'; + } + $format = get_setting('regional_time_format', 'H:i'); + + return Carbon::parse($time)->format($format); + } +} + +if (! function_exists('format_datetime')) { + function format_datetime($datetime): string + { + if (! $datetime) { + return '-'; + } + $dateFormat = get_setting('regional_date_format', 'd/m/Y'); + $timeFormat = get_setting('regional_time_format', 'H:i'); + + return Carbon::parse($datetime)->format("{$dateFormat} {$timeFormat}"); + } +} diff --git a/app/Http/Controllers/AI/AiAssistantController.php b/app/Http/Controllers/AI/AiAssistantController.php new file mode 100644 index 0000000..5d3f659 --- /dev/null +++ b/app/Http/Controllers/AI/AiAssistantController.php @@ -0,0 +1,89 @@ + 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, + ]); + } +} diff --git a/app/Http/Controllers/AI/LogAnalysisController.php b/app/Http/Controllers/AI/LogAnalysisController.php new file mode 100644 index 0000000..bb83ca4 --- /dev/null +++ b/app/Http/Controllers/AI/LogAnalysisController.php @@ -0,0 +1,83 @@ +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', + ]); + } +} diff --git a/app/Http/Controllers/AccessControl/ActionLogController.php b/app/Http/Controllers/AccessControl/ActionLogController.php new file mode 100644 index 0000000..2d644d4 --- /dev/null +++ b/app/Http/Controllers/AccessControl/ActionLogController.php @@ -0,0 +1,267 @@ +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 = '
+
'.e($log->causer?->name ?? 'System').'
+
'.e($log->causer?->email ?? 'no-email').'
+
'; + + // Event Column + $eventHtml = ' + '.$eventLabel.' + '; + + // Information Column (Preview of changes) + $infoHtml = '
'; + if (! empty($changes)) { + $first = $changes[0]; + $infoHtml .= ''.e($first['field']).': '.e($first['new']); + if (count($changes) > 1) { + $infoHtml .= ' +'.(count($changes) - 1).''; + } + } else { + $infoHtml .= e(data_get($properties, 'details', '-')); + } + $infoHtml .= '
'; + + // 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, + ''.e($modelName).'', + e(format_datetime($log->created_at)), + ''.e($ip).'', + ''.e($agent).'', + '
'.e(json_encode($properties)).'
', + '
+ +
', + ]; + })->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); + } +} diff --git a/app/Http/Controllers/AccessControl/PermissionManagementController.php b/app/Http/Controllers/AccessControl/PermissionManagementController.php new file mode 100644 index 0000000..725b9e7 --- /dev/null +++ b/app/Http/Controllers/AccessControl/PermissionManagementController.php @@ -0,0 +1,367 @@ +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( + '
%s
', + $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) => ''.e($role).'')->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[] = '
' + .'' + .'' + .'
'; + } + + 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.'), + ]); + } +} diff --git a/app/Http/Controllers/AccessControl/RoleManagementController.php b/app/Http/Controllers/AccessControl/RoleManagementController.php new file mode 100644 index 0000000..a64e150 --- /dev/null +++ b/app/Http/Controllers/AccessControl/RoleManagementController.php @@ -0,0 +1,613 @@ +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) => ''.e($permission).'')->implode(' ') + : '-'; + + $row = []; + + if ($canManage) { + $row[] = sprintf( + '', + $role->id + ); + $row[] = sprintf( + '
%s
', + $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[] = '
'.$permissionBadges.'
'; + $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[] = '
' + .'' + .'' + .'
'; + } else { + $row[] = '
' + .'' + .'' + .'
'; + } + } + + 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, + ]); + } +} diff --git a/app/Http/Controllers/AccessControl/UserManagementController.php b/app/Http/Controllers/AccessControl/UserManagementController.php new file mode 100644 index 0000000..0dff155 --- /dev/null +++ b/app/Http/Controllers/AccessControl/UserManagementController.php @@ -0,0 +1,574 @@ +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) => ''.e($role).'')->implode(' ') + : '-'; + + $row = []; + + if ($canManage) { + $row[] = sprintf( + '', + $user->id + ); + $row[] = sprintf( + '
%s
', + $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 = '
' + .csrf_field() + .'' + .'
'; + } else { + $impersonateButton = ''; + } + } + + if ($user->trashed()) { + $row[] = '
' + .'' + .'' + .'
'; + } else { + $row[] = '
' + .$impersonateButton + .'' + .'' + .'
'; + } + } + + 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.'), + ]); + } +} diff --git a/app/Http/Controllers/Admin/MobileSettingController.php b/app/Http/Controllers/Admin/MobileSettingController.php new file mode 100644 index 0000000..4cf1161 --- /dev/null +++ b/app/Http/Controllers/Admin/MobileSettingController.php @@ -0,0 +1,81 @@ +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()); + } + } +} diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php new file mode 100644 index 0000000..65786c7 --- /dev/null +++ b/app/Http/Controllers/Api/AuthController.php @@ -0,0 +1,440 @@ +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'), + ]); + } +} diff --git a/app/Http/Controllers/Api/DeviceTokenController.php b/app/Http/Controllers/Api/DeviceTokenController.php new file mode 100644 index 0000000..cd9e12d --- /dev/null +++ b/app/Http/Controllers/Api/DeviceTokenController.php @@ -0,0 +1,88 @@ + []]], + 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'); + } +} diff --git a/app/Http/Controllers/Api/HealthController.php b/app/Http/Controllers/Api/HealthController.php new file mode 100644 index 0000000..d90fa69 --- /dev/null +++ b/app/Http/Controllers/Api/HealthController.php @@ -0,0 +1,113 @@ + $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); + } +} diff --git a/app/Http/Controllers/Api/MobileConfigController.php b/app/Http/Controllers/Api/MobileConfigController.php new file mode 100644 index 0000000..1dd6642 --- /dev/null +++ b/app/Http/Controllers/Api/MobileConfigController.php @@ -0,0 +1,68 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/MobileLogController.php b/app/Http/Controllers/Api/MobileLogController.php new file mode 100644 index 0000000..d53693e --- /dev/null +++ b/app/Http/Controllers/Api/MobileLogController.php @@ -0,0 +1,34 @@ +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'); + } +} diff --git a/app/Http/Controllers/Api/OtpController.php b/app/Http/Controllers/Api/OtpController.php new file mode 100644 index 0000000..0e0c4eb --- /dev/null +++ b/app/Http/Controllers/Api/OtpController.php @@ -0,0 +1,92 @@ +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'); + } +} diff --git a/app/Http/Controllers/Api/Swagger.php b/app/Http/Controllers/Api/Swagger.php new file mode 100644 index 0000000..cce83f1 --- /dev/null +++ b/app/Http/Controllers/Api/Swagger.php @@ -0,0 +1,48 @@ + 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('/'); + } +} diff --git a/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/app/Http/Controllers/Auth/ConfirmablePasswordController.php new file mode 100644 index 0000000..712394a --- /dev/null +++ b/app/Http/Controllers/Auth/ConfirmablePasswordController.php @@ -0,0 +1,40 @@ +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)); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php new file mode 100644 index 0000000..f64fa9b --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php @@ -0,0 +1,24 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(route('dashboard', absolute: false)); + } + + $request->user()->sendEmailVerificationNotification(); + + return back()->with('status', 'verification-link-sent'); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/app/Http/Controllers/Auth/EmailVerificationPromptController.php new file mode 100644 index 0000000..ee3cb6f --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -0,0 +1,21 @@ +user()->hasVerifiedEmail() + ? redirect()->intended(route('dashboard', absolute: false)) + : view('auth.verify-email'); + } +} diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php new file mode 100644 index 0000000..d284ed0 --- /dev/null +++ b/app/Http/Controllers/Auth/NewPasswordController.php @@ -0,0 +1,64 @@ + $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)]); + } +} diff --git a/app/Http/Controllers/Auth/PasswordController.php b/app/Http/Controllers/Auth/PasswordController.php new file mode 100644 index 0000000..c07a30b --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordController.php @@ -0,0 +1,49 @@ +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'); + } +} diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php new file mode 100644 index 0000000..2a874c9 --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -0,0 +1,103 @@ +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)]); +// } +// } diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php new file mode 100644 index 0000000..1dbf6b4 --- /dev/null +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -0,0 +1,116 @@ +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, + ]); + } + } +} diff --git a/app/Http/Controllers/Auth/SocialAuthController.php b/app/Http/Controllers/Auth/SocialAuthController.php new file mode 100644 index 0000000..84013e8 --- /dev/null +++ b/app/Http/Controllers/Auth/SocialAuthController.php @@ -0,0 +1,119 @@ +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.')); + } +} diff --git a/app/Http/Controllers/Auth/TwoFactorController.php b/app/Http/Controllers/Auth/TwoFactorController.php new file mode 100644 index 0000000..8684c3e --- /dev/null +++ b/app/Http/Controllers/Auth/TwoFactorController.php @@ -0,0 +1,134 @@ +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.')); + } +} diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php new file mode 100644 index 0000000..784765e --- /dev/null +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -0,0 +1,27 @@ +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'); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..e7f7c94 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,10 @@ +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.'); + } +} diff --git a/app/Http/Controllers/ImpersonateController.php b/app/Http/Controllers/ImpersonateController.php new file mode 100644 index 0000000..f37cfaa --- /dev/null +++ b/app/Http/Controllers/ImpersonateController.php @@ -0,0 +1,119 @@ +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.')); + } +} diff --git a/app/Http/Controllers/LegalController.php b/app/Http/Controllers/LegalController.php new file mode 100644 index 0000000..4b53de9 --- /dev/null +++ b/app/Http/Controllers/LegalController.php @@ -0,0 +1,114 @@ +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'), + }; + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php new file mode 100644 index 0000000..d529e85 --- /dev/null +++ b/app/Http/Controllers/ProfileController.php @@ -0,0 +1,67 @@ + $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('/'); + } +} diff --git a/app/Http/Controllers/System/GlobalSearchController.php b/app/Http/Controllers/System/GlobalSearchController.php new file mode 100644 index 0000000..9762ee7 --- /dev/null +++ b/app/Http/Controllers/System/GlobalSearchController.php @@ -0,0 +1,46 @@ +get('q', ''); + + $results = $this->searchService->search($query); + + return response()->json($results); + } +} diff --git a/app/Http/Controllers/SystemSettings/AiSelfHealingController.php b/app/Http/Controllers/SystemSettings/AiSelfHealingController.php new file mode 100644 index 0000000..3b4119c --- /dev/null +++ b/app/Http/Controllers/SystemSettings/AiSelfHealingController.php @@ -0,0 +1,288 @@ +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] ?? ''); + } +} diff --git a/app/Http/Controllers/SystemSettings/BackupRestoreController.php b/app/Http/Controllers/SystemSettings/BackupRestoreController.php new file mode 100644 index 0000000..286fd03 --- /dev/null +++ b/app/Http/Controllers/SystemSettings/BackupRestoreController.php @@ -0,0 +1,256 @@ +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()); + } + } +} diff --git a/app/Http/Controllers/SystemSettings/EditorUploadController.php b/app/Http/Controllers/SystemSettings/EditorUploadController.php new file mode 100644 index 0000000..d5197a9 --- /dev/null +++ b/app/Http/Controllers/SystemSettings/EditorUploadController.php @@ -0,0 +1,47 @@ +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); + } + } +} diff --git a/app/Http/Controllers/SystemSettings/MaintenanceModeController.php b/app/Http/Controllers/SystemSettings/MaintenanceModeController.php new file mode 100644 index 0000000..69b0f2f --- /dev/null +++ b/app/Http/Controllers/SystemSettings/MaintenanceModeController.php @@ -0,0 +1,40 @@ + $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.'), + ]); + } +} diff --git a/app/Http/Controllers/SystemSettings/NotificationCenterController.php b/app/Http/Controllers/SystemSettings/NotificationCenterController.php new file mode 100644 index 0000000..0db87ea --- /dev/null +++ b/app/Http/Controllers/SystemSettings/NotificationCenterController.php @@ -0,0 +1,224 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/SystemSettings/SessionManagerController.php b/app/Http/Controllers/SystemSettings/SessionManagerController.php new file mode 100644 index 0000000..bb6c4c9 --- /dev/null +++ b/app/Http/Controllers/SystemSettings/SessionManagerController.php @@ -0,0 +1,310 @@ +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 = ' + '.__('LIVE').' + '; + } elseif ($session->last_activity >= $idleCutoff) { + $statusHtml = ''.__('ACTIVE').''; + } elseif ($session->last_activity >= $activeCutoff) { + $statusHtml = ''.__('IDLE').''; + } else { + $statusHtml = ''.__('EXPIRED').''; + } + + // User Column + $userHtml = '
+
+
'.e($session->user_name ?? 'Guest').'
+
'.e($session->user_email ?? substr($session->id, 0, 8).'...').'
+
+
'; + + // Device Column + $deviceHtml = '
+ +
+
'.$userAgent['browser'].'
+
'.$userAgent['os'].'
+
+
'; + + $timestamp = Carbon::createFromTimestamp($session->last_activity); + + $actionsHtml = '
'; + $actionsHtml .= ''; + + if (! $isCurrent) { + if (auth()->user()->can('manage active sessions')) { + $actionsHtml .= ''; + } else { + $actionsHtml .= ''; + } + } else { + $actionsHtml .= ''; + } + $actionsHtml .= '
'; + + return [ + $statusHtml, + $userHtml, + $deviceHtml, + ''.e($session->ip_address).'', + 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.')]); + } +} diff --git a/app/Http/Controllers/SystemSettings/SystemConfigController.php b/app/Http/Controllers/SystemSettings/SystemConfigController.php new file mode 100644 index 0000000..17a642c --- /dev/null +++ b/app/Http/Controllers/SystemSettings/SystemConfigController.php @@ -0,0 +1,267 @@ + $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]); + } +} diff --git a/app/Http/Controllers/SystemSettings/SystemMonitoringController.php b/app/Http/Controllers/SystemSettings/SystemMonitoringController.php new file mode 100644 index 0000000..8472e84 --- /dev/null +++ b/app/Http/Controllers/SystemSettings/SystemMonitoringController.php @@ -0,0 +1,699 @@ +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 [ + '
'.e($log['timestamp']).'
', + ''.strtoupper(e($log['level'])).'', + '
'.e(str_replace("\n", ' ', substr($log['message'], 0, 300))).'
', + '
+ +
', + ]; + }, $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 [ + '
'.e($log['timestamp']).'
', + '
'.e($log['summary']).'
', + '
+ +
', + ]; + }, $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 [ + '
'.e($log['timestamp']).'
', + ''.strtoupper(e($log['level'])).'', + '
'.e($userInfo).'
', + '
'.e(str_replace("\n", ' ', substr($log['message'], 0, 300))).'
', + '
+ +
', + ]; + }, $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' + ? 'PENDING' + : 'FAILED'; + + $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 = '
+ '.e($exceptionClass).': '.e(str_replace("\n", ' ', $exceptionShort)).'... +
'; + } else { + $details = '
+ '.e($displayName).' +
'; + } + + // 🚀 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 = [ + '
'.e($job->timestamp).'
', + $statusBadge, + ''.strtoupper(e($job->queue)).'', + $details, + '
'.$taskDescription.'
', + ]; + + if ($canManage) { + $actions = '
'; + if ($job->status === 'FAILED') { + $actions .= ''; + $actions .= ''; + $actions .= ''; + } else { + // Pending jobs cannot be deleted individually as per user request + $actions .= ''; + } + $actions .= '
'; + $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); + } +} diff --git a/app/Http/Controllers/WebAuthn/WebAuthnLoginController.php b/app/Http/Controllers/WebAuthn/WebAuthnLoginController.php new file mode 100644 index 0000000..c084542 --- /dev/null +++ b/app/Http/Controllers/WebAuthn/WebAuthnLoginController.php @@ -0,0 +1,41 @@ + $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); + } +} diff --git a/app/Http/Controllers/WebAuthn/WebAuthnRegisterController.php b/app/Http/Controllers/WebAuthn/WebAuthnRegisterController.php new file mode 100644 index 0000000..946b342 --- /dev/null +++ b/app/Http/Controllers/WebAuthn/WebAuthnRegisterController.php @@ -0,0 +1,46 @@ + 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); + } + } +} diff --git a/app/Http/Helpers/ApiResponse.php b/app/Http/Helpers/ApiResponse.php new file mode 100644 index 0000000..ad40b10 --- /dev/null +++ b/app/Http/Helpers/ApiResponse.php @@ -0,0 +1,62 @@ + 'success', 'message' => $message]; + + if ($data !== null) { + $payload['data'] = $data; + } + + return response()->json($payload, $code); + } + + public static function error(string $message = 'Error', int $code = 400, mixed $errors = null): JsonResponse + { + $payload = ['status' => 'error', 'message' => $message]; + + if ($errors !== null) { + $payload['errors'] = $errors; + } + + return response()->json($payload, $code); + } + + public static function created(mixed $data = null, string $message = 'Created successfully'): JsonResponse + { + return self::success($data, $message, 201); + } + + public static function notFound(string $message = 'Resource not found'): JsonResponse + { + return self::error($message, 404); + } + + public static function unauthorized(string $message = 'Unauthorized'): JsonResponse + { + return self::error($message, 401); + } + + public static function forbidden(string $message = 'Forbidden'): JsonResponse + { + return self::error($message, 403); + } + + public static function validationError(mixed $errors, string $message = 'Validation failed'): JsonResponse + { + return self::error($message, 422, $errors); + } + + public static function serverError(string $message = 'Internal server error'): JsonResponse + { + return self::error($message, 500); + } +} diff --git a/app/Http/Middleware/CheckActivePermission.php b/app/Http/Middleware/CheckActivePermission.php new file mode 100644 index 0000000..63502d7 --- /dev/null +++ b/app/Http/Middleware/CheckActivePermission.php @@ -0,0 +1,30 @@ +addMinutes(5), function () use ($permission) { + $permissionModel = Permission::where('name', $permission)->first(); + + return $permissionModel && $permissionModel->is_active; + }); + + // If permission not found OR inactive -> deny access + if (! $isActive) { + abort(403, 'This permission is inactive or not available.'); + } + + // Continue request (permission is active) + return $next($request); + } +} diff --git a/app/Http/Middleware/CheckLegalAgreement.php b/app/Http/Middleware/CheckLegalAgreement.php new file mode 100644 index 0000000..e366a21 --- /dev/null +++ b/app/Http/Middleware/CheckLegalAgreement.php @@ -0,0 +1,38 @@ +routeIs('legal.*', 'verification.*', 'password.*') || $request->is('logout')) { + return $next($request); + } + + // Check if user has agreed to current ToS and PDP versions + if (! $user->hasAgreedToCurrentLegal('tos') || ! $user->hasAgreedToCurrentLegal('privacy')) { + return redirect()->route('legal.re-agree'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/CheckMenuPermission.php b/app/Http/Middleware/CheckMenuPermission.php new file mode 100644 index 0000000..fc9ec6d --- /dev/null +++ b/app/Http/Middleware/CheckMenuPermission.php @@ -0,0 +1,42 @@ +middleware('menu-permission:global settings') + * ->middleware('menu-permission:mobile settings,manage') + */ +class CheckMenuPermission +{ + public function handle(Request $request, Closure $next, string $menu, string $action = 'view'): Response + { + if (! auth()->check()) { + return $request->expectsJson() + ? response()->json(['message' => 'Unauthenticated.'], 401) + : redirect()->route('login'); + } + + $allowed = $action === 'manage' + ? can_manage_any_tab($menu) + : can_view_any_tab($menu); + + if (! $allowed) { + return $request->expectsJson() + ? response()->json(['message' => 'This action is unauthorized.'], 403) + : abort(403, "Access denied to menu: {$menu}"); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/CheckTabPermission.php b/app/Http/Middleware/CheckTabPermission.php new file mode 100644 index 0000000..04fef12 --- /dev/null +++ b/app/Http/Middleware/CheckTabPermission.php @@ -0,0 +1,47 @@ +middleware('tab-permission:global settings,login-security') + * ->middleware('tab-permission:mobile settings,branding,manage') + * + * Parameters: + * $menu — the menu slug, e.g. "global settings" + * $tab — the tab slug, e.g. "login-security" + * $action — "view" (default) or "manage" + */ +class CheckTabPermission +{ + public function handle(Request $request, Closure $next, string $menu, string $tab, string $action = 'view'): Response + { + if (! auth()->check()) { + return $request->expectsJson() + ? response()->json(['message' => 'Unauthenticated.'], 401) + : redirect()->route('login'); + } + + $allowed = $action === 'manage' + ? can_manage_tab($menu, $tab) + : can_view_tab($menu, $tab); + + if (! $allowed) { + return $request->expectsJson() + ? response()->json(['message' => 'This action is unauthorized.'], 403) + : abort(403, "Access denied to tab: {$tab}"); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/GzipCompression.php b/app/Http/Middleware/GzipCompression.php new file mode 100644 index 0000000..28fa4b2 --- /dev/null +++ b/app/Http/Middleware/GzipCompression.php @@ -0,0 +1,50 @@ +header('Accept-Encoding'), 'gzip') && + function_exists('gzencode') && + ! $response->headers->has('Content-Encoding') + ) { + $content = $response->getContent(); + $compressedContent = gzencode($content, 6); + + if ($compressedContent !== false) { + $response->setContent($compressedContent); + $response->headers->add([ + 'Content-Encoding' => 'gzip', + 'Content-Length' => strlen($compressedContent), + 'X-Compressed' => 'true', + ]); + } + } + + return $response; + } +} diff --git a/app/Http/Middleware/IpAccessControl.php b/app/Http/Middleware/IpAccessControl.php new file mode 100644 index 0000000..afed817 --- /dev/null +++ b/app/Http/Middleware/IpAccessControl.php @@ -0,0 +1,99 @@ +ip(); + + // Batch get common settings to reduce function overhead + $settings = [ + 'blacklist' => get_setting('ip_blacklist', ''), + 'whitelist_admin' => get_setting('ip_whitelist_admin', ''), + 'auto_block' => get_setting('auto_block_ip', false), + 'single_session' => get_setting('session_single_session', false), + 'hsts' => get_setting('hsts_enabled', false), + ]; + + // 1. GLOBAL BLACKLIST + $blacklistArr = array_filter(array_map('trim', explode(',', $settings['blacklist']))); + if (in_array($ip, $blacklistArr)) { + abort(403, 'Your IP address has been blocked.'); + } + + // 2. ADMIN WHITELIST (Protects specific routes) + // Check if current route is an admin/restricted route + if ($request->is('system-config*') || $request->is('users*') || $request->is('roles*') || $request->is('permissions*') || $request->is('backups*') || $request->is('admin/*')) { + $whitelistArr = array_filter(array_map('trim', explode(',', $settings['whitelist_admin']))); + if (! empty($whitelistArr) && ! in_array($ip, $whitelistArr)) { + abort(403, 'Access denied: Admin IP Whitelist restricted.'); + } + } + + // 3. RATE LIMITING & AUTO BLOCK + if ($settings['auto_block']) { + $cacheKey = "ip_block:{$ip}"; + if (Cache::has($cacheKey)) { + abort(429, 'Your IP has been temporarily blocked due to excessive requests.'); + } + + $threshold = get_setting('threshold_auto_block', 100); + + $hitKey = "ip_hits:{$ip}"; + Cache::add($hitKey, 0, now()->addMinute()); + $hits = Cache::increment($hitKey); + + if ($hits > $threshold) { + Cache::put($cacheKey, true, now()->addHours(24)); + + // 🚨 Send Security Alert to Telegram + try { + $telegram = app(TelegramService::class); + $msg = "[FIREWALL BLOCK]\n\n"; + $msg .= "IP Address: {$ip}\n"; + $msg .= "Reason: Excessive Requests ({$hits} hits)\n"; + $msg .= "Action: Auto-Blocked (24h)\n\n"; + $msg .= "Check configuration: Admin Panel"; + $telegram->sendMessage($msg); + } catch (\Exception $e) { + \Log::error('Firewall Telegram Alert Failed: '.$e->getMessage()); + } + + abort(429, 'Excessive requests detected. Your IP has been blocked for 24 hours.'); + } + } + + // 4. SINGLE SESSION ENFORCEMENT + // Skip check if we are currently impersonating to prevent logout + if ($request->user() && $settings['single_session'] && ! session()->has('impersonator_id')) { + if ($request->user()->last_session_id !== session()->getId()) { + Auth::logout(); + + return redirect()->route('login')->with('error', 'You have been logged out because another device logged into your account.'); + } + } + + $response = $next($request); + + // 5. HSTS (Transport Security) + if ($request->isSecure() && $settings['hsts']) { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + return $response; + } +} diff --git a/app/Http/Middleware/MobileMaintenanceMiddleware.php b/app/Http/Middleware/MobileMaintenanceMiddleware.php new file mode 100644 index 0000000..0028eaa --- /dev/null +++ b/app/Http/Middleware/MobileMaintenanceMiddleware.php @@ -0,0 +1,65 @@ +is('api/v1/*')) { + return $next($request); + } + + $config = $this->service->all(); + $control = $config['control_center'] ?? []; + $updates = $config['app_updates'] ?? []; + + // 1. Check KILL SWITCH (Emergency Maintenance) + if (filter_var($control['kill_switch_active'] ?? false, FILTER_VALIDATE_BOOLEAN)) { + // Check IP Bypass + $bypassIps = array_map('trim', explode(',', $control['maintenance_bypass_ips'] ?? '')); + if (! in_array($request->ip(), $bypassIps)) { + return response()->json([ + 'status' => 'maintenance', + 'message' => $control['kill_switch_message'] ?? 'System is currently undergoing emergency maintenance.', + 'maintenance_info' => [ + 'start' => $control['maintenance_start_at'] ?? null, + 'end' => $control['maintenance_end_at'] ?? null, + ], + ], 503); + } + } + + // 2. Check APP VERSION (Force Update) + $clientVersion = $request->query('v') ?: $request->header('X-App-Version'); + $minVersion = $updates['min_app_version'] ?? '1.0.0'; + + if ($clientVersion && version_compare($clientVersion, $minVersion, '<')) { + return response()->json([ + 'status' => 'upgrade_required', + 'message' => 'A mandatory update is required to continue using the application.', + 'current_version' => $clientVersion, + 'required_version' => $minVersion, + 'update_urls' => [ + 'android' => $updates['store_url_android'] ?? null, + 'ios' => $updates['store_url_ios'] ?? null, + ], + ], 426); // 426 Upgrade Required + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/PasswordExpiryMiddleware.php b/app/Http/Middleware/PasswordExpiryMiddleware.php new file mode 100644 index 0000000..6f2f746 --- /dev/null +++ b/app/Http/Middleware/PasswordExpiryMiddleware.php @@ -0,0 +1,34 @@ +is('profile/password*') || $request->is('logout')) { + return $next($request); + } + + if (PasswordPolicyService::isPasswordExpired($user)) { + return redirect()->route('profile.edit') + ->with('warning', __('Your password has expired. Please update it to continue using the application.')); + } + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..a4d0037 --- /dev/null +++ b/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,77 @@ +headers->set('X-Frame-Options', 'SAMEORIGIN'); + + // Prevent Mime-Type Sniffing + $response->headers->set('X-Content-Type-Options', 'nosniff'); + + // Cross-Site Scripting (XSS) Protection (for older browsers) + $response->headers->set('X-XSS-Protection', '1; mode=block'); + + // Referrer Policy + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + // Permissions Policy + $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + + // Content Security Policy — enforced (current CDN stack requires unsafe-inline/eval) + $cdnSources = 'https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://cdn.datatables.net https://cdn.ckeditor.com https://unpkg.com https://code.jquery.com https://www.google.com https://www.gstatic.com'; + $csp = implode('; ', [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' {$cdnSources}", + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://cdn.datatables.net https://unpkg.com https://fonts.googleapis.com", + "font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com https://cdnjs.cloudflare.com", + "img-src 'self' data: blob: https:", + "connect-src 'self' https://o*.ingest.sentry.io wss:", + "frame-src 'self' https://www.google.com", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + ]); + $response->headers->set('Content-Security-Policy', $csp); + + // Report-Only: stricter policy (no unsafe-inline/eval) — violations logged in browser + // console without blocking users. Use this to audit inline scripts before tightening + // the enforced policy above. + $cspReportOnly = implode('; ', [ + "default-src 'self'", + "script-src 'self' {$cdnSources}", + "style-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://cdn.datatables.net https://unpkg.com https://fonts.googleapis.com", + "font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com https://cdnjs.cloudflare.com", + "img-src 'self' data: blob: https:", + "connect-src 'self' https://o*.ingest.sentry.io wss:", + "frame-src 'self' https://www.google.com", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + ]); + $response->headers->set('Content-Security-Policy-Report-Only', $cspReportOnly); + + // Strict-Transport-Security (Only if HTTPS) + if ($request->isSecure()) { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + return $response; + } +} diff --git a/app/Http/Requests/Admin/UpdateMobileSettingRequest.php b/app/Http/Requests/Admin/UpdateMobileSettingRequest.php new file mode 100644 index 0000000..8506e3e --- /dev/null +++ b/app/Http/Requests/Admin/UpdateMobileSettingRequest.php @@ -0,0 +1,83 @@ +|string> + */ + public function rules(): array + { + return [ + // General + 'app_name' => ['nullable', 'string', 'max:50'], + 'app_version' => ['nullable', 'string', 'max:20'], + + // Theme & Colors + 'primary_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'], + 'secondary_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'], + 'dark_background' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'], + 'splash_bg_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/'], + 'border_radius' => ['nullable', 'string', 'max:10'], + + // Dashboard Grid + 'welcome_text' => ['nullable', 'string', 'max:100'], + 'label_grid_1' => ['nullable', 'string', 'max:30'], + 'label_grid_2' => ['nullable', 'string', 'max:30'], + 'label_grid_3' => ['nullable', 'string', 'max:30'], + 'label_grid_4' => ['nullable', 'string', 'max:30'], + 'icon_grid_1' => ['nullable', 'string', 'max:50'], + 'icon_grid_2' => ['nullable', 'string', 'max:50'], + 'icon_grid_3' => ['nullable', 'string', 'max:50'], + 'icon_grid_4' => ['nullable', 'string', 'max:50'], + 'color_grid_1' => ['nullable', 'string'], + 'color_grid_2' => ['nullable', 'string'], + 'color_grid_3' => ['nullable', 'string'], + 'color_grid_4' => ['nullable', 'string'], + + // Assets (Files) + 'logo_light_url' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif,svg,webp', 'max:5120'], + 'logo_dark_url' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif,svg,webp', 'max:5120'], + 'splash_image_url' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif,svg,webp', 'max:5120'], + 'maintenance_image' => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif,svg,webp', 'max:5120'], + + // Support & Navigation + 'support_email' => ['nullable', 'email'], + 'support_whatsapp' => ['nullable', 'string', 'max:20'], + 'emergency_phone' => ['nullable', 'string', 'max:20'], + 'tab_home' => ['nullable', 'string', 'max:20'], + + // Flags & Announcements + 'kill_switch_active' => ['nullable', 'boolean'], + 'kill_switch_message' => ['nullable', 'string'], + 'announcement_enabled' => ['nullable', 'boolean'], + 'announcement_text' => ['nullable', 'string'], + 'announcement_type' => ['nullable', 'string', 'in:info,warning,danger'], + + // System & Engagement + 'min_app_version' => ['nullable', 'string', 'max:20'], + 'onboarding_version' => ['nullable', 'string', 'max:20'], + 'maintenance_start_at' => ['nullable', 'string', 'nullable'], + 'maintenance_end_at' => ['nullable', 'string', 'nullable'], + 'review_prompt_enabled' => ['nullable', 'boolean'], + 'min_actions_before_review' => ['nullable', 'integer', 'min:0'], + + // Catch-all for other dynamic settings + '*' => ['nullable'], + ]; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 0000000..d28f9d2 --- /dev/null +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,92 @@ +|string> + */ + public function rules(): array + { + $rules = [ + 'email' => ['required', 'string', 'email'], + 'password' => ['required', 'string'], + ]; + + if (get_setting('captcha_enabled', false)) { + $rules['g-recaptcha-response'] = ['required', 'captcha']; + } + + return $rules; + } + + /** + * Attempt to authenticate the request's credentials. + * + * @throws ValidationException + */ + public function authenticate(): void + { + // validasi input + $this->ensureIsNotRateLimited(); + + if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { + $lockoutDuration = get_setting('login_lockout_duration', 15) * 60; // Convert minutes to seconds + RateLimiter::hit($this->throttleKey(), $lockoutDuration); + + throw ValidationException::withMessages([ + 'email' => __('The email or password is incorrect.'), + ]); + } + + RateLimiter::clear($this->throttleKey()); + } + + public function ensureIsNotRateLimited(): void + { + $maxAttempts = get_setting('login_max_attempts', 5); + if (! RateLimiter::tooManyAttempts($this->throttleKey(), $maxAttempts)) { + return; + } + + $seconds = RateLimiter::availableIn($this->throttleKey()); + + // Trigger lockout event for notification if enabled + if (get_setting('login_lockout_notify', true)) { + event(new Lockout($this)); + } + + throw ValidationException::withMessages([ + 'email' => __('Your account is temporarily locked. Please try again in :seconds seconds.', [ + 'seconds' => $seconds, + ]), + ]); + } + + /** + * Get the rate limiting throttle key for the request. + */ + public function throttleKey(): string + { + return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip()); + } +} diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php new file mode 100644 index 0000000..e2202dd --- /dev/null +++ b/app/Http/Requests/ProfileUpdateRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => [ + 'required', + 'string', + 'lowercase', + 'email', + 'max:255', + Rule::unique(User::class)->ignore($this->user()->id), + ], + ]; + } +} diff --git a/app/Http/Requests/SystemSettings/UpdateSystemConfigRequest.php b/app/Http/Requests/SystemSettings/UpdateSystemConfigRequest.php new file mode 100644 index 0000000..083b184 --- /dev/null +++ b/app/Http/Requests/SystemSettings/UpdateSystemConfigRequest.php @@ -0,0 +1,246 @@ +user(); + if (!$user) { + return false; + } + + // Allow if Developer or legacy manage permission exists + if ($user->hasRole('Developer') || $user->can('manage global settings')) { + return true; + } + + // Allow if any granular manage global settings permission exists (direct or via role) + return $user->getAllPermissions() + ->contains(fn ($p) => str_starts_with($p->name, 'manage global settings:')); + } + + public function rules(): array + { + return [ + 'app_name' => ['sometimes', 'required', 'string', 'min:3', 'max:100'], + 'app_tagline' => ['nullable', 'string', 'max:150'], + 'app_tagline1' => ['nullable', 'string', 'max:150'], + 'app_tagline2' => ['nullable', 'string', 'max:300'], + 'footer_text' => ['nullable', 'string', 'max:200'], + 'default_locale' => ['nullable', 'string', 'max:10'], + 'instance_mode' => ['nullable', 'string', 'in:Production,Trial,Demo'], + 'app_logo' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + 'app_favicon' => ['nullable', 'image', 'mimes:png,ico,webp', 'max:1024'], + 'enable_landing_page' => ['nullable', 'boolean'], + 'feature_notification_center' => ['nullable', 'boolean'], + 'feature_google_oauth' => ['nullable', 'boolean'], + 'google_client_id' => ['nullable', 'string', 'max:255'], + 'google_client_secret' => ['nullable', 'string', 'max:255'], + 'feature_facebook_oauth' => ['nullable', 'boolean'], + 'facebook_app_id' => ['nullable', 'string', 'max:255'], + 'facebook_app_secret' => ['nullable', 'string', 'max:255'], + 'feature_github_oauth' => ['nullable', 'boolean'], + 'github_client_id' => ['nullable', 'string', 'max:255'], + 'github_client_secret' => ['nullable', 'string', 'max:255'], + 'social_login_callback_url' => ['nullable', 'string', 'max:255'], + + // Regional + 'regional_timezone' => ['nullable', 'string', 'max:100'], + 'regional_date_format' => ['nullable', 'string', 'max:20'], + 'regional_time_format' => ['nullable', 'string', 'max:20'], + + // Login Security + 'login_max_attempts' => ['nullable', 'integer', 'min:1', 'max:20'], + 'login_lockout_duration' => ['nullable', 'integer', 'min:1', 'max:1440'], + 'login_lockout_notify' => ['nullable', 'boolean'], + 'two_factor_auth' => ['nullable', 'boolean'], + 'two_factor_method' => ['nullable', 'string', 'in:email,app'], + 'captcha_enabled' => ['nullable', 'boolean'], + 'captcha_site_key' => ['nullable', 'string', 'max:255'], + 'captcha_secret_key' => ['nullable', 'string', 'max:255'], + 'captcha_version' => ['nullable', 'string', 'in:v2,v3'], + 'login_log_enabled' => ['nullable', 'boolean'], + + // Password Policy + 'password_min_length' => ['nullable', 'integer', 'min:8', 'max:100'], + 'password_max_length' => ['nullable', 'integer', 'min:8', 'max:255'], + 'password_require_uppercase' => ['nullable', 'boolean'], + 'password_require_lowercase' => ['nullable', 'boolean'], + 'password_require_numeric' => ['nullable', 'boolean'], + 'password_require_special' => ['nullable', 'boolean'], + 'password_expiry_days' => ['nullable', 'integer', 'min:0', 'max:365'], + 'password_history_count' => ['nullable', 'integer', 'min:0', 'max:10'], + 'password_reset_link_expiry' => ['nullable', 'integer', 'min:1', 'max:10080'], + + // Session Security + 'session_driver' => ['nullable', 'string', 'in:file,redis,database'], + 'session_lifetime' => ['nullable', 'integer', 'min:1', 'max:10080'], + 'session_single_session' => ['nullable', 'boolean'], + 'session_auto_logout_idle' => ['nullable', 'integer', 'min:1', 'max:10080'], + 'session_allow_remember_me' => ['nullable', 'boolean'], + 'session_remember_me_duration' => ['nullable', 'integer', 'min:1', 'max:365'], + 'session_secure_cookie' => ['nullable', 'boolean'], + 'session_encrypt' => ['nullable', 'boolean'], + 'session_concurrent_limit' => ['nullable', 'integer', 'min:0', 'max:50'], + + // WebAuthn + 'two_factor_trust_days' => ['nullable', 'integer', 'min:1', 'max:365'], + 'webauthn_enabled' => ['nullable', 'boolean'], + + // IP & Access Control + 'ip_whitelist_admin' => ['nullable', 'string'], + 'ip_blacklist' => ['nullable', 'string'], + 'rate_limit_per_ip' => ['nullable', 'integer', 'min:1', 'max:1000'], + 'auto_block_ip' => ['nullable', 'boolean'], + 'threshold_auto_block' => ['nullable', 'integer', 'min:1', 'max:5000'], + 'force_https' => ['nullable', 'boolean'], + 'hsts_enabled' => ['nullable', 'boolean'], + 'cors_origins' => ['nullable', 'string'], + 'cors_methods' => ['nullable', 'string'], + 'cors_headers' => ['nullable', 'string'], + + // Notifications + 'mail_driver' => ['nullable', 'string', 'in:smtp,mailgun,ses,mail'], + 'mail_host' => ['nullable', 'string', 'max:255'], + 'mail_port' => ['nullable', 'integer', 'min:1', 'max:65535'], + 'mail_username' => ['nullable', 'string', 'max:255'], + 'mail_password' => ['nullable', 'string', 'max:255'], + 'mail_encryption' => ['nullable', 'string', 'in:tls,ssl,null'], + 'mail_from_address' => ['nullable', 'email', 'max:255'], + 'mail_from_name' => ['nullable', 'string', 'max:255'], + 'telegram_bot_token' => ['nullable', 'string', 'max:255'], + 'telegram_chat_id' => ['nullable', 'string', 'max:255'], + + // Backups + 'backup_db_enabled' => ['nullable', 'boolean'], + 'backup_db_driver' => ['nullable', 'string', 'in:local,s3,gdrive'], + 'backup_db_frequency' => ['nullable', 'string', 'in:hourly,daily,weekly,monthly'], + 'backup_db_time' => ['nullable', 'string', 'regex:/^[0-9]{2}:[0-9]{2}$/'], + 'backup_db_retention' => ['nullable', 'integer', 'min:1', 'max:365'], + 'backup_db_compress' => ['nullable', 'boolean'], + 'backup_db_encrypt' => ['nullable', 'boolean'], + 'backup_db_encrypt_key' => ['nullable', 'string', 'min:32', 'max:255'], + 'backup_db_exclude' => ['nullable', 'string'], + 'backup_db_notify_on' => ['nullable', 'string', 'in:success,failed,both,none'], + 'backup_db_notify_to' => ['nullable', 'string', 'max:255'], + + // Google Drive Backups + 'gdrive_client_id' => ['nullable', 'string', 'max:500'], + 'gdrive_client_secret' => ['nullable', 'string', 'max:500'], + 'gdrive_refresh_token' => ['nullable', 'string', 'max:1000'], + 'gdrive_folder' => ['nullable', 'string', 'max:255'], + + // S3 Backups + 's3_key' => ['nullable', 'string', 'max:255'], + 's3_secret' => ['nullable', 'string', 'max:255'], + 's3_region' => ['nullable', 'string', 'max:100'], + 's3_bucket' => ['nullable', 'string', 'max:255'], + 's3_endpoint' => ['nullable', 'string', 'max:500'], + + // Maintenance Mode + 'maintenance_mode_enabled' => ['nullable', 'boolean'], + 'maintenance_mode_message' => ['nullable', 'string', 'max:1000'], + 'maintenance_mode_title' => ['nullable', 'string', 'max:200'], + 'maintenance_mode_secret' => ['nullable', 'string', 'alpha_dash', 'max:100'], + 'maintenance_mode_allowed_ips' => ['nullable', 'string'], + 'maintenance_mode_end_at' => ['nullable', 'date'], + 'maintenance_mode_retry' => ['nullable', 'integer', 'min:0', 'max:3600'], + 'maintenance_mode_image' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + + // Content & Legal + 'page_help_content' => ['nullable', 'string'], + 'page_tos_content' => ['nullable', 'string'], + 'page_privacy_content' => ['nullable', 'string'], + 'page_about_content' => ['nullable', 'string'], + 'page_security_content' => ['nullable', 'string'], + 'require_pdp_on_registration' => ['nullable', 'boolean'], + 'feature_cookie_banner' => ['nullable', 'boolean'], + 'pdp_document_version' => ['nullable', 'integer', 'min:1'], + 'tos_document_version' => ['nullable', 'integer', 'min:1'], + 'pdp_dpo_email' => ['nullable', 'email', 'max:255'], + 'pdp_company_address' => ['nullable', 'string', 'max:500'], + + // AI Configuration + 'ai_enabled' => ['nullable', 'boolean'], + 'ai_provider' => ['nullable', 'string', 'in:gpt,gemini,claude,deepseek,grok,mistral,ollama,openrouter'], + 'ai_gpt_key' => ['nullable', 'string', 'max:255'], + 'ai_gemini_key' => ['nullable', 'string', 'max:255'], + 'ai_claude_key' => ['nullable', 'string', 'max:255'], + 'ai_deepseek_key' => ['nullable', 'string', 'max:255'], + 'ai_grok_key' => ['nullable', 'string', 'max:255'], + 'ai_mistral_key' => ['nullable', 'string', 'max:255'], + 'ai_ollama_base_url' => ['nullable', 'string', 'max:255'], + 'ai_openrouter_key' => ['nullable', 'string', 'max:255'], + 'ai_default_model' => ['nullable', 'string', 'max:100'], + 'ai_system_instruction' => ['nullable', 'string', 'max:5000'], + 'ai_temperature' => ['nullable', 'numeric', 'min:0', 'max:2'], + 'ai_max_tokens' => ['nullable', 'integer', 'min:1', 'max:32000'], + + // SAP RFC Configuration + 'sap_rfc_ashost' => ['nullable', 'string', 'max:255'], + 'sap_rfc_sysnr' => ['nullable', 'string', 'max:20'], + 'sap_rfc_client' => ['nullable', 'string', 'max:20'], + 'sap_rfc_user' => ['nullable', 'string', 'max:255'], + 'sap_rfc_passwd' => ['nullable', 'string', 'max:255'], + 'sap_rfc_router' => ['nullable', 'string', 'max:255'], + 'sap_rfc_trace' => ['nullable', 'string', 'in:0,1,2'], + 'engine_pulse_enabled' => ['nullable', 'boolean'], + 'engine_telescope_enabled' => ['nullable', 'boolean'], + 'engine_swagger_enabled' => ['nullable', 'boolean'], + 'engine_horizon_enabled' => ['nullable', 'boolean'], + 'ai_healing_enabled' => ['nullable', 'boolean'], + ]; + } + + protected function prepareForValidation(): void + { + $booleans = [ + 'feature_notification_center', + 'feature_google_oauth', + 'feature_facebook_oauth', + 'feature_github_oauth', + 'login_lockout_notify', + 'two_factor_auth', + 'captcha_enabled', + 'login_log_enabled', + 'password_require_uppercase', + 'password_require_lowercase', + 'password_require_numeric', + 'password_require_special', + 'webauthn_enabled', + 'session_single_session', + 'session_allow_remember_me', + 'session_secure_cookie', + 'session_encrypt', + 'auto_block_ip', + 'force_https', + 'hsts_enabled', + 'backup_db_enabled', + 'backup_db_compress', + 'backup_db_encrypt', + 'maintenance_mode_enabled', + 'require_pdp_on_registration', + 'feature_cookie_banner', + 'ai_enabled', + 'enable_landing_page', + 'engine_pulse_enabled', + 'engine_telescope_enabled', + 'engine_swagger_enabled', + 'engine_horizon_enabled', + 'ai_healing_enabled', + ]; + + $updates = []; + foreach ($booleans as $key) { + $updates[$key] = $this->boolean($key); + } + + if (!empty($updates)) { + $this->merge($updates); + } + } +} diff --git a/app/Jobs/AiHealerJob.php b/app/Jobs/AiHealerJob.php new file mode 100644 index 0000000..966ec5e --- /dev/null +++ b/app/Jobs/AiHealerJob.php @@ -0,0 +1,311 @@ +logId = $logId; + $this->faultyFile = $faultyFile; + $this->lineNum = $lineNum; + } + + public function handle(AiService $aiService, SystemConfigService $cfg) + { + $healingLog = AiHealingLog::find($this->logId); + if (!$healingLog) { + return; + } + + $healingLog->update(['status' => 'diagnosing']); + + // ── Capability gates ──────────────────────────────────────────────── + // These honour the toggles on the AI Self-Healing config panel. + $allowCache = (bool) $cfg->get('ai_healing_allow_cache', true); + $allowQueue = (bool) $cfg->get('ai_healing_allow_queue', true); + $allowMaintenance = (bool) $cfg->get('ai_healing_allow_maintenance', false); + $allowDb = (bool) $cfg->get('ai_healing_allow_db', false); + + $commandCapabilityMap = [ + 'cache:clear' => $allowCache, + 'view:clear' => $allowCache, + 'config:clear' => $allowCache, + 'route:clear' => $allowCache, + 'optimize:clear' => $allowCache, + 'queue:restart' => $allowQueue, + 'down' => $allowMaintenance, + 'up' => $allowMaintenance, + 'migrate' => $allowDb, + 'migrate:fresh' => false, // never permitted — destructive + ]; + + $stackTrace = $healingLog->stack_trace; + $faultyFile = $this->faultyFile; + $lineNum = (int) $this->lineNum; + + if (!$faultyFile || str_contains($faultyFile, 'vendor/')) { + if (preg_match('/(\/var\/www\/html\/(?!vendor\/)[^\s:]+\.php):(\d+)/', $stackTrace, $matches)) { + $faultyFile = $matches[1]; + $lineNum = (int) $matches[2]; + } + } + + if ($faultyFile && str_contains($faultyFile, 'storage/framework/views')) { + $compiledContent = @file_get_contents($faultyFile); + if ($compiledContent && preg_match('/PATH (.*?) ENDPATH/', $compiledContent, $m)) { + $faultyFile = trim($m[1]); + } + } + + $fileContext = ''; + if ($faultyFile && file_exists($faultyFile)) { + $lines = file($faultyFile); + $start = max(0, $lineNum - 25); + $end = min(count($lines), $lineNum + 25); + $fileContext = implode('', array_slice($lines, $start, $end - $start)); + } + + $enabledCommands = array_keys(array_filter($commandCapabilityMap, fn ($v) => $v === true)); + $enabledList = $enabledCommands ? implode(', ', $enabledCommands) : '(none — only code edits are permitted)'; + + $isBlade = $faultyFile && str_ends_with($faultyFile, '.blade.php'); + $isView = $isBlade + || str_contains($healingLog->error_type, 'View') + || str_contains($healingLog->error_message, 'storage/framework/views'); + + $prompt = "You are an autonomous Laravel 11 self-healing agent. The user has explicitly authorised you to apply fixes WITHOUT human confirmation when the error is unambiguous. A timestamped .bak backup is written before every code edit, so the user has a rollback path.\n\n"; + $prompt .= "EXCEPTION DETAILS:\n"; + $prompt .= 'Error Type: ' . $healingLog->error_type . "\n"; + $prompt .= 'Error Message: ' . $healingLog->error_message . "\n"; + + if ($fileContext) { + $prompt .= "The error originated in file: {$faultyFile}\n"; + $prompt .= "Here is the code snippet around the error (±25 lines):\n```php\n{$fileContext}\n```\n"; + } else { + $prompt .= 'Stack Trace (first 1200 chars): ' . substr($stackTrace, 0, 1200) . "\n\n"; + } + + if ($isView) { + $prompt .= "\nBLADE-SPECIFIC HINTS:\n"; + $prompt .= "- Literal `@directive` in HTML output (e.g. documentation that mentions @can, @if, @foreach) MUST be escaped as `@@directive` so Blade outputs it as text. Example: change `@can('view users')` to `@@can('view users')`.\n"; + $prompt .= "- Literal `{{ }}` in HTML must be escaped as `@{{ }}`. Literal `{!! !!}` as `@{!! !!}`.\n"; + $prompt .= "- 'unexpected end of file, expecting elseif/else/endif' usually means an unescaped `@can` / `@if` / `@auth` directive in plain text — Blade thinks it must close it. Find the directive that has no matching `@endX` and either escape it (`@@X`) or add the missing `@endX`.\n"; + $prompt .= "- Unmatched `@foreach`/`@endforeach`, `@if`/`@endif`, `@can`/`@endcan` pairs cause the same EOF error.\n"; + $prompt .= "- When the error's file is in `storage/framework/views/` (compiled cache), the real bug is in the source `.blade.php` shown above.\n\n"; + } + + $prompt .= "===== DECISION POLICY =====\n"; + $prompt .= "Classify this error into one of three tiers and act accordingly:\n\n"; + + $prompt .= "TIER 1 — AUTO-FIX MANDATORY (confidence ≥ 0.8). You MUST return a code_edit or action_command. Examples:\n"; + $prompt .= " • Typo in variable / method / class / route name visible in the snippet\n"; + $prompt .= " • Missing or wrong `use` import statement\n"; + $prompt .= " • Unescaped Blade directive in plain text (apply @@ escape)\n"; + $prompt .= " • Unmatched `@if`/`@endif`, `@foreach`/`@endforeach`, `@can`/`@endcan` (add the missing closer)\n"; + $prompt .= " • `Undefined array key` where a safe default is obvious → use `?? default`\n"; + $prompt .= " • `Trying to access property of null` → apply `?->` null-safe operator\n"; + $prompt .= " • `Class \"X\" not found` after a clear rename / typo → fix the FQCN\n"; + $prompt .= " • Stale Blade / route / config cache → return action_command (view:clear, route:clear, optimize:clear)\n"; + $prompt .= " • Failed worker after code deploy → return action_command queue:restart (if allowed)\n"; + $prompt .= " • Wrong helper signature with obvious right form (Cache::set → Cache::put)\n"; + + $prompt .= "\nTIER 2 — BEST-EFFORT FIX (confidence 0.5–0.79). Attempt the most likely fix if you can identify a concrete change. Examples:\n"; + $prompt .= " • Type mismatch where a cast solves it (e.g. `(int) \$id`)\n"; + $prompt .= " • SQL function not available in this driver — replace with portable PHP equivalent (e.g. Postgres lacks DATE_FORMAT; use Carbon bucketing in PHP)\n"; + $prompt .= " • Missing model attribute that has a safe fallback\n"; + + $prompt .= "\nTIER 3 — DO NOT AUTO-FIX (confidence < 0.5). Set both code_edit and action_command to null, write a precise diagnosis. Examples:\n"; + $prompt .= " • Business logic / data semantics (you cannot infer intent)\n"; + $prompt .= " • Multi-file refactor (you can only edit one file per fix)\n"; + $prompt .= " • Race conditions / concurrency issues\n"; + $prompt .= " • Anything in vendor/ (never touch)\n"; + $prompt .= " • The fix would require deleting / re-creating significant code blocks\n"; + $prompt .= " • The exception is itself coming from the healing pipeline (avoid loops)\n"; + + $prompt .= "\nGENERAL RULES:\n"; + $prompt .= "- Bias toward decisive ACTION for Tier 1 and Tier 2. The user has authorised auto-fix and there is a backup.\n"; + $prompt .= "- For code_edit, `target_content` MUST be an EXACT substring that exists in the file (preserve all whitespace and indentation).\n"; + $prompt .= "- `replacement_content` must be the minimal change that fixes the bug. Do NOT refactor surrounding code.\n"; + $prompt .= "- Only use commands from the allow-list: {$enabledList}\n"; + $prompt .= "- If you return code_edit, action_command MUST be null (apply exactly one remedy).\n"; + $prompt .= "- Diagnosis should be ≤ 2 sentences, plain language, explaining what was wrong AND what you changed.\n"; + + $prompt .= "\nRESPOND ONLY WITH VALID JSON (no markdown fences, no prose around it):\n"; + $prompt .= "{\n"; + $prompt .= " \"confidence\": 0.0-1.0,\n"; + $prompt .= " \"tier\": 1|2|3,\n"; + $prompt .= " \"diagnosis\": \"What was wrong + what you changed (or why you can't fix it).\",\n"; + $prompt .= " \"action_command\": \"cache:clear\" | null,\n"; + $prompt .= " \"code_edit\": {\"file_path\":\"...\",\"target_content\":\"...\",\"replacement_content\":\"...\"} | null\n"; + $prompt .= "}"; + + try { + $aiResult = $aiService->provider()->generate($prompt); + + if (!$aiResult['success']) { + throw new \Exception($aiResult['error'] ?? 'Unknown AI Provider error'); + } + + $response = $aiResult['response']; + $jsonStr = trim(preg_replace('/```json|```/', '', $response)); + $result = json_decode($jsonStr, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + $healingLog->update([ + 'status' => 'failed', + 'ai_diagnosis' => 'AI response was not valid JSON: ' . $response, + 'action_taken' => 'Invalid JSON from provider', + ]); + return; + } + + $diagnosis = $result['diagnosis'] ?? 'AI could not provide a diagnosis.'; + $action = $result['action_command'] ?? null; + $codeEdit = $result['code_edit'] ?? null; + $confidence = isset($result['confidence']) ? (float) $result['confidence'] : null; + $tier = isset($result['tier']) ? (int) $result['tier'] : null; + + if ($confidence !== null || $tier !== null) { + $tag = ''; + if ($confidence !== null) { + $tag .= '[conf=' . number_format($confidence, 2) . ']'; + } + if ($tier !== null) { + $tag .= '[tier=' . $tier . ']'; + } + $diagnosis = trim($tag . ' ' . $diagnosis); + } + + // ── Try code edit first ───────────────────────────────────────── + if (is_array($codeEdit) && isset($codeEdit['file_path'], $codeEdit['target_content'], $codeEdit['replacement_content'])) { + $applied = $this->applyCodeEdit($healingLog, $codeEdit, $diagnosis); + if ($applied !== null) { + return; // resolved or hard-failed + } + // If $applied is null, the edit couldn't be applied (e.g., file missing) — fall through to command attempt. + } + + // ── Try Artisan command ──────────────────────────────────────── + if (is_string($action) && $action !== '') { + if (!array_key_exists($action, $commandCapabilityMap)) { + $healingLog->update([ + 'status' => 'failed', + 'action_taken' => "Refused: command '{$action}' is not in security policy", + 'ai_diagnosis' => $diagnosis, + ]); + return; + } + + if (!$commandCapabilityMap[$action]) { + $healingLog->update([ + 'status' => 'failed', + 'action_taken' => "Refused: command '{$action}' is currently disabled by capability toggle", + 'ai_diagnosis' => $diagnosis, + ]); + return; + } + + try { + Artisan::call($action); + $healingLog->update([ + 'status' => 'resolved', + 'action_taken' => 'Executed: ' . $action, + 'ai_diagnosis' => $diagnosis, + ]); + Log::info("AI Healer executed Artisan command: {$action}"); + } catch (\Throwable $e) { + $healingLog->update([ + 'status' => 'failed', + 'action_taken' => "Artisan '{$action}' threw: " . $e->getMessage(), + 'ai_diagnosis' => $diagnosis, + ]); + } + return; + } + + // ── Nothing actionable ───────────────────────────────────────── + $healingLog->update([ + 'status' => 'failed', + 'ai_diagnosis' => $diagnosis, + 'action_taken' => 'No fix applied (AI provided no actionable remedy)', + ]); + } catch (\Throwable $e) { + Log::error('AI Healer failed: ' . $e->getMessage()); + $healingLog->update([ + 'status' => 'failed', + 'ai_diagnosis' => 'Internal Error: ' . $e->getMessage(), + 'action_taken' => 'Healer pipeline crashed', + ]); + } + } + + /** + * Apply a code edit suggested by the AI. + * Returns true on resolved, false on hard fail, null if it should fall through to command attempt. + */ + private function applyCodeEdit(AiHealingLog $log, array $edit, string $diagnosis): ?bool + { + $filePath = $edit['file_path']; + + if (!file_exists($filePath) || !is_writable($filePath)) { + // Fall through — let the action_command branch try instead. + return null; + } + + $content = file_get_contents($filePath); + + if (!str_contains($content, $edit['target_content'])) { + $log->update([ + 'status' => 'failed', + 'action_taken' => 'Code edit failed: target snippet not found in ' . basename($filePath), + 'ai_diagnosis' => $diagnosis, + ]); + return false; + } + + $newContent = str_replace($edit['target_content'], $edit['replacement_content'], $content); + + if (trim($newContent) === '') { + $log->update([ + 'status' => 'failed', + 'action_taken' => 'Code edit aborted: replacement would empty the file', + 'ai_diagnosis' => $diagnosis, + ]); + return false; + } + + // Write a .bak before editing — gives the user a rollback path. + $backupPath = $filePath . '.bak.' . date('YmdHis'); + @file_put_contents($backupPath, $content); + file_put_contents($filePath, $newContent); + + Artisan::call('optimize:clear'); + + $log->update([ + 'status' => 'resolved', + 'action_taken' => 'CODE_EDIT|' . $filePath . '|' . $backupPath, + 'ai_diagnosis' => $diagnosis, + 'original_code' => $content, + 'fixed_code' => $newContent, + ]); + return true; + } +} diff --git a/app/Jobs/Monitoring/WorkerHeartbeatJob.php b/app/Jobs/Monitoring/WorkerHeartbeatJob.php new file mode 100644 index 0000000..ab5d155 --- /dev/null +++ b/app/Jobs/Monitoring/WorkerHeartbeatJob.php @@ -0,0 +1,32 @@ +timestamp, 600); // 10 minutes TTL + } +} diff --git a/app/Listeners/AnalyzeSystemError.php b/app/Listeners/AnalyzeSystemError.php new file mode 100644 index 0000000..2d9bbbc --- /dev/null +++ b/app/Listeners/AnalyzeSystemError.php @@ -0,0 +1,93 @@ +level, ['error', 'critical', 'alert', 'emergency'])) { + return; + } + + // Avoid infinite loops if AI service itself fails + if (str_contains($event->message, 'AI Analysis')) { + return; + } + + if (! get_setting('ai_healing_enabled', false)) { + return; + } + + // Limit analysis to once per 5 minutes for the same error message to save tokens + $cacheKey = 'ai_error_analysis_'.md5($event->message); + if (Cache::has($cacheKey)) { + return; + } + + try { + $prompt = "As a Senior DevOps Engineer, analyze this Laravel system error and provide a concise 'Root Cause' and 'Suggested Fix'. + ERROR: [{$event->level}] {$event->message} + CONTEXT: ".json_encode($event->context); + + $result = $this->aiService->provider()->generate($prompt); + + if (isset($result['success']) && $result['success']) { + $analysis = $result['response']; + + // Cache it + Cache::put($cacheKey, $analysis, 300); + + // Log the AI insight (quietly) + Log::channel('single')->info('AI Analysis for Error: '.$analysis); + + $admins = User::role(['Developer', 'Administrator'])->get(); + foreach ($admins as $admin) { + $admin->notify(new SystemManagementNotification( + "AI Diagnostic: {$event->level} Detected", + $analysis, + 'error', + 'Developer' + )); + } + } + } catch (\Exception $e) { + // Silently fail to avoid loops + } + } +} diff --git a/app/Listeners/LogFailedLogin.php b/app/Listeners/LogFailedLogin.php new file mode 100644 index 0000000..5fdc9db --- /dev/null +++ b/app/Listeners/LogFailedLogin.php @@ -0,0 +1,24 @@ +causedBy($event->user ?? null) + ->withProperties([ + 'email_attempt' => request()->email, + 'ip' => request()->ip(), + 'agent' => request()->userAgent(), + ]) + ->log('failed login'); + } +} diff --git a/app/Listeners/LogLogout.php b/app/Listeners/LogLogout.php new file mode 100644 index 0000000..d653e05 --- /dev/null +++ b/app/Listeners/LogLogout.php @@ -0,0 +1,38 @@ +user) { + activity('auth') + ->withProperties([ + 'ip' => request()->ip(), + 'agent' => request()->userAgent(), + ]) + ->log('logout - user session expired / forced logout'); + + return; // <-- exit, because there is no user to inject + } + + // 🧍 user ada → pastikan pakai Model User asli + $user = $event->user instanceof User + ? $event->user + : User::find($event->user->id); + + activity('auth') + ->causedBy($user) + ->withProperties([ + 'email' => $user?->email, + 'ip' => request()->ip(), + 'agent' => request()->userAgent(), + ]) + ->log('logout'); + } +} diff --git a/app/Listeners/LogSuccessfulLogin.php b/app/Listeners/LogSuccessfulLogin.php new file mode 100644 index 0000000..afb9363 --- /dev/null +++ b/app/Listeners/LogSuccessfulLogin.php @@ -0,0 +1,29 @@ +user instanceof User + ? $event->user + : User::find($event->user->id); + + if (! get_setting('login_log_enabled', true)) { + return; + } + + activity('auth') + ->causedBy($user) + ->withProperties([ + 'email' => $user?->email, + 'ip' => request()->ip(), + 'agent' => request()->userAgent(), + ]) + ->log('login'); + } +} diff --git a/app/Mail/SystemHealthDigest.php b/app/Mail/SystemHealthDigest.php new file mode 100644 index 0000000..bfe70da --- /dev/null +++ b/app/Mail/SystemHealthDigest.php @@ -0,0 +1,53 @@ + + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Mail/TwoFactorOtp.php b/app/Mail/TwoFactorOtp.php new file mode 100644 index 0000000..61bc565 --- /dev/null +++ b/app/Mail/TwoFactorOtp.php @@ -0,0 +1,53 @@ + $this->otp, + 'app' => config('app.name'), + ]), + ); + } + + public function content(): Content + { + $client = SessionHelper::parseUserAgent($this->userAgent); + + return new Content( + markdown: 'emails.two-factor-otp', + with: [ + 'otp' => $this->otp, + 'userName' => $this->userName, + 'expiresInMinutes' => $this->expiresInMinutes, + 'ipAddress' => $this->ipAddress, + 'browser' => $client['browser'], + 'os' => $client['os'], + 'requestedAt' => now()->format('d M Y, H:i T'), + ], + ); + } +} diff --git a/app/Models/AI/AiUsageLog.php b/app/Models/AI/AiUsageLog.php new file mode 100644 index 0000000..e7428ef --- /dev/null +++ b/app/Models/AI/AiUsageLog.php @@ -0,0 +1,65 @@ + 'array', + 'estimated_cost' => 'decimal:6', + ]; + + /** + * Get the prunable model query. + */ + public function prunable() + { + return static::where('created_at', '<=', now()->subMonths(3)); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/AiHealingLog.php b/app/Models/AiHealingLog.php new file mode 100644 index 0000000..388bee8 --- /dev/null +++ b/app/Models/AiHealingLog.php @@ -0,0 +1,34 @@ + 'encrypted', + 'original_code' => 'encrypted', + 'fixed_code' => 'encrypted', + ]; + + public function prunable(): Builder + { + return self::query()->where('created_at', '<=', now()->subDays(90)); + } +} diff --git a/app/Models/DashboardWidgetPreference.php b/app/Models/DashboardWidgetPreference.php new file mode 100644 index 0000000..ef7b7d4 --- /dev/null +++ b/app/Models/DashboardWidgetPreference.php @@ -0,0 +1,59 @@ + 'boolean']; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Return merged widget config: defaults overlaid with user prefs. + * + * @return array + */ + public static function forUser(int $userId): array + { + $defaults = self::defaults(); + + $prefs = self::where('user_id', $userId) + ->get() + ->keyBy('widget_key'); + + foreach ($defaults as $key => &$widget) { + if ($prefs->has($key)) { + $widget['visible'] = $prefs[$key]->visible; + $widget['sort_order'] = $prefs[$key]->sort_order; + } + } + + uasort($defaults, fn ($a, $b) => $a['sort_order'] <=> $b['sort_order']); + + return $defaults; + } + + /** + * All available widgets with their defaults. + */ + public static function defaults(): array + { + return [ + 'cpu' => ['label' => 'CPU Load', 'visible' => true, 'sort_order' => 1, 'permission' => null], + 'ram' => ['label' => 'Memory', 'visible' => true, 'sort_order' => 2, 'permission' => null], + 'disk' => ['label' => 'Storage', 'visible' => true, 'sort_order' => 3, 'permission' => null], + 'live_users' => ['label' => 'Live Users', 'visible' => true, 'sort_order' => 4, 'permission' => null], + 'queues' => ['label' => 'Queue Stats', 'visible' => true, 'sort_order' => 5, 'permission' => null], + 'activity_feed' => ['label' => 'Activity Feed', 'visible' => true, 'sort_order' => 6, 'permission' => 'view health and logs'], + 'ai_insight' => ['label' => 'AI Security Insight','visible' => true, 'sort_order' => 7, 'permission' => 'view ai log analysis'], + ]; + } +} diff --git a/app/Models/DeviceToken.php b/app/Models/DeviceToken.php new file mode 100644 index 0000000..53009a0 --- /dev/null +++ b/app/Models/DeviceToken.php @@ -0,0 +1,27 @@ + 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/MobileErrorLog.php b/app/Models/MobileErrorLog.php new file mode 100644 index 0000000..380bf17 --- /dev/null +++ b/app/Models/MobileErrorLog.php @@ -0,0 +1,63 @@ +where('occurred_at', '<=', now()->subDays(90)); + } + + protected $fillable = [ + 'user_id', + 'error_type', + 'message', + 'stack_trace', + 'platform', + 'app_version', + 'device_info', + 'occurred_at', + ]; + + protected $casts = [ + 'device_info' => 'array', + 'occurred_at' => 'datetime', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/MobileSetting.php b/app/Models/MobileSetting.php new file mode 100644 index 0000000..3d8f20d --- /dev/null +++ b/app/Models/MobileSetting.php @@ -0,0 +1,63 @@ +useLogName('mobile-config') + ->logOnly(['key', 'value', 'group', 'type']) + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + protected static function booted() + { + static::saved(fn () => MobileConfigService::clearCacheStatic()); + static::deleted(fn () => MobileConfigService::clearCacheStatic()); + } +} diff --git a/app/Models/MobileSyncLog.php b/app/Models/MobileSyncLog.php new file mode 100644 index 0000000..0f46f6f --- /dev/null +++ b/app/Models/MobileSyncLog.php @@ -0,0 +1,40 @@ +where('synced_at', '<=', now()->subDays(30)); + } + + protected $fillable = [ + 'user_id', + 'platform', + 'device_model', + 'app_version', + 'ip_address', + 'synced_at', + ]; + + protected $casts = [ + 'synced_at' => 'datetime', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Notification.php b/app/Models/Notification.php new file mode 100644 index 0000000..3e3e584 --- /dev/null +++ b/app/Models/Notification.php @@ -0,0 +1,55 @@ + 'datetime', + ]; + + /** + * Get the user who created the notification. + */ + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Users who have interacted with this notification (read/deleted). + */ + public function users() + { + return $this->belongsToMany(User::class, 'notification_user') + ->withPivot('read_at', 'deleted_at') + ->withTimestamps(); + } + + /** + * Get the prunable model query. + * Auto-delete notifications older than 30 days. + */ + public function prunable(): Builder + { + return self::query()->where('created_at', '<=', now()->subDays(30)); + } +} diff --git a/app/Models/OtpCode.php b/app/Models/OtpCode.php new file mode 100644 index 0000000..71a2ac9 --- /dev/null +++ b/app/Models/OtpCode.php @@ -0,0 +1,29 @@ + 'datetime', + 'verified_at' => 'datetime', + ]; + + public function prunable(): Builder + { + return self::query()->where('expires_at', '<', now()); + } +} diff --git a/app/Models/PasswordHistory.php b/app/Models/PasswordHistory.php new file mode 100644 index 0000000..b7c789c --- /dev/null +++ b/app/Models/PasswordHistory.php @@ -0,0 +1,24 @@ +where('created_at', '<=', now()->subDays(365)); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Permission.php b/app/Models/Permission.php new file mode 100644 index 0000000..88941c7 --- /dev/null +++ b/app/Models/Permission.php @@ -0,0 +1,70 @@ +useLogName('permission-management') + ->logOnly(['name', 'guard_name', 'is_active']) + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + /** + * Fillable attributes + */ + protected $fillable = [ + 'name', + 'scope', + 'guard_name', + 'is_active', + 'created_by', + 'updated_by', + ]; + + /** + * Casting + */ + protected $casts = [ + 'is_active' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + /** + * Boot model to automatically set created_by & updated_by + */ + protected static function boot() + { + parent::boot(); + } + + /** + * Audit trail relations + */ + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function updater() + { + return $this->belongsTo(User::class, 'updated_by'); + } +} diff --git a/app/Models/Permission.php.bak.20260516064435 b/app/Models/Permission.php.bak.20260516064435 new file mode 100644 index 0000000..620f5a0 --- /dev/null +++ b/app/Models/Permission.php.bak.20260516064435 @@ -0,0 +1,85 @@ +useLogName('permission-management') + ->logOnly(['name', 'guard_name', 'is_active']) + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + /** + * Ketika permission diupdate + */ + public function updated(\App\Models\Permission $permission) + { + \Illuminate\Support\Facades\Cache::forget("permission_status:{$permission->name}"); + } + + /** + * Ketika permission didelete (termasuk soft delete) + */ + public function deleted(\App\Models\Permission $permission) + { + \Illuminate\Support\Facades\Cache::forget("permission_status:{$permission->name}"); + } + + /** + * Fillable attributes + */ + protected $fillable = [ + 'name', + 'guard_name', + 'is_active', + 'created_by', + 'updated_by', + ]; + + /** + * Casting + */ + protected $casts = [ + 'is_active' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + /** + * Boot model to automatically set created_by & updated_by + */ + protected static function boot() + { + parent::boot(); + } + + /** + * Audit trail relations + */ + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function updater() + { + return $this->belongsTo(User::class, 'updated_by'); + } +} diff --git a/app/Models/Role.php b/app/Models/Role.php new file mode 100644 index 0000000..0edcdc4 --- /dev/null +++ b/app/Models/Role.php @@ -0,0 +1,81 @@ +useLogName('role-management') // module log category + ->logOnly(['name', 'guard_name', 'is_active']) // fields to monitor + ->logOnlyDirty() // only log if changed + ->dontSubmitEmptyLogs(); // skip if no changes + } + + protected $fillable = [ + 'name', + 'guard_name', + 'is_active', + 'created_by', + 'updated_by', + ]; + + protected $casts = [ + 'is_active' => 'boolean', + ]; + + /** + * Boot model to automatically set created_by & updated_by + */ + protected static function boot() + { + parent::boot(); + + // Automatically set created_by + static::creating(function ($model) { + if (Auth::check()) { + $model->created_by = Auth::id(); + } + }); + + // Automatically set updated_by (including soft deletes) + static::updating(function ($model) { + if (Auth::check()) { + $model->updated_by = Auth::id(); + } + }); + + static::deleting(function ($model) { + if (Auth::check()) { + $model->updated_by = Auth::id(); + $model->saveQuietly(); // save without triggering infinite update log + } + }); + } + + /** + * Audit Trail Relationships + */ + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function updater() + { + return $this->belongsTo(User::class, 'updated_by'); + } +} diff --git a/app/Models/SystemSetting.php b/app/Models/SystemSetting.php new file mode 100644 index 0000000..95eb7bd --- /dev/null +++ b/app/Models/SystemSetting.php @@ -0,0 +1,64 @@ +useLogName('system-config') + ->logOnly(['key', 'value', 'group']) + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + protected $fillable = [ + 'key', + 'value', + 'type', + 'group', + 'is_public', + 'description', + 'created_by', + 'updated_by', + ]; + + protected $casts = [ + 'is_public' => 'boolean', + ]; +} diff --git a/app/Models/SystemSettingRevision.php b/app/Models/SystemSettingRevision.php new file mode 100644 index 0000000..e41e189 --- /dev/null +++ b/app/Models/SystemSettingRevision.php @@ -0,0 +1,21 @@ +belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..5be66b6 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,214 @@ +useLogName('user-management') + ->logOnly(['name', 'username', 'email', 'phone_number', 'is_active']) + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + /** + * The attributes that are mass assignable. + */ + protected $fillable = [ + 'name', + 'username', + 'email', + 'phone_number', + 'password', + 'google_id', + 'facebook_id', + 'github_id', + 'is_active', + 'password_changed_at', + 'last_session_id', + 'created_by', + 'updated_by', + ]; + + /** + * Hidden fields + */ + protected $hidden = [ + 'password', + 'remember_token', + 'media', // Hide raw media relation + ]; + + /** + * Appended attributes + */ + protected $appends = ['avatar']; + + /** + * Avatar Accessor (using Media Library) + */ + public function getAvatarAttribute(): ?string + { + return $this->getFirstMediaUrl('avatar') ?: null; + } + + /** + * The attributes that should be cast. + */ + protected $casts = [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + 'is_active' => 'boolean', + 'password_changed_at' => 'datetime', + ]; + + /** + * Password History Relation + */ + public function passwordHistories() + { + return $this->hasMany(PasswordHistory::class); + } + + /** + * Trusted Devices Relation + */ + public function trustedDevices() + { + return $this->hasMany(UserTrustedDevice::class); + } + + /** + * ========================================== + * DISABLE RESET PASSWORD FOR SOCIAL USERS + * ========================================== + */ + public function sendPasswordResetNotification($token) + { + // ❌ OAuth user → BLOCK reset password + if ($this->google_id || $this->facebook_id || $this->github_id) { + return; + } + + // ✅ User manual → normal + $this->notify(new ResetPasswordNotification($token)); + } + + /** + * Use our branded email verification template. + */ + public function sendEmailVerificationNotification() + { + $this->notify(new VerifyEmailNotification); + } + + /** + * Helpers + */ + public function isSocialUser(): bool + { + return ! is_null($this->google_id) || ! is_null($this->facebook_id) || ! is_null($this->github_id); + } + + /** + * Consents Relation + */ + public function consents() + { + return $this->hasMany(UserConsent::class); + } + + /** + * Check if user has agreed to the current version of a document. + */ + public function hasAgreedToCurrentLegal(string $type): bool + { + // Settings use 'pdp' prefix for Privacy Policy, while code uses 'privacy' type + $keyPrefix = ($type === 'privacy' || $type === 'pdp') ? 'pdp' : $type; + $currentVersion = (int) get_setting("{$keyPrefix}_document_version", 1); + + return $this->consents() + ->where(function ($q) use ($type) { + $q->where('consent_type', $type) + ->orWhere('consent_type', 'privacy') + ->orWhere('consent_type', 'pdp'); + }) + ->where('version_id', '>=', $currentVersion) + ->exists(); + } + + /** + * Notifications this user has interacted with (including personal read/deleted status). + */ + public function broadcastNotifications() + { + return $this->belongsToMany(Notification::class, 'system_notification_user') + ->withPivot('read_at', 'deleted_at') + ->withTimestamps(); + } + + /** + * Get the user who created this record. + */ + public function creator() + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Get the user who last updated this record. + */ + public function updater() + { + return $this->belongsTo(User::class, 'updated_by'); + } +} diff --git a/app/Models/UserConsent.php b/app/Models/UserConsent.php new file mode 100644 index 0000000..7f8f564 --- /dev/null +++ b/app/Models/UserConsent.php @@ -0,0 +1,35 @@ +belongsTo(User::class); + } +} diff --git a/app/Models/UserTrustedDevice.php b/app/Models/UserTrustedDevice.php new file mode 100644 index 0000000..f79ee1b --- /dev/null +++ b/app/Models/UserTrustedDevice.php @@ -0,0 +1,28 @@ + 'datetime', + ]; + + public function prunable(): Builder + { + return self::query()->where('expires_at', '<', now()); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Notifications/Auth/LegalConsentConfirmation.php b/app/Notifications/Auth/LegalConsentConfirmation.php new file mode 100644 index 0000000..7f9ee6f --- /dev/null +++ b/app/Notifications/Auth/LegalConsentConfirmation.php @@ -0,0 +1,56 @@ + + */ + public function via(object $notifiable): array + { + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + $appName = config('app.name'); + $tosVersion = $this->consents['tos'] ?? 1; + $privacyVersion = $this->consents['privacy'] ?? 1; + $signedAt = now()->format('d M Y, H:i T'); + + return (new MailMessage) + ->subject(__(':app — Official Confirmation of Legal Consents and Agreements', ['app' => $appName])) + ->greeting(__('Dear :name,', ['name' => $notifiable->name])) + ->line(__('Welcome to :app. This communication serves as the official and legally binding confirmation of the agreements executed during your account provisioning process.', ['app' => $appName])) + ->line('**'.__('Executed Legal Documents Portfolio').'**') + ->line('• '.__('Terms of Use — version :version', ['version' => $tosVersion])) + ->line('• '.__('Privacy Policy — version :version', ['version' => $privacyVersion])) + ->line(__('Timestamp of Execution: :date.', ['date' => $signedAt])) + ->action(__('Access Official Documentation'), route('legal.show', 'privacy')) + ->line(__('Please be advised that a cryptographically hashed record of this consent has been securely archived within our immutable audit trail, ensuring strict adherence to regulatory compliance frameworks, notably **UU PDP No. 27/2022**.')) + ->line(__('Should you require further clarification regarding your data privacy rights—including formal requests for data portability, rectification, or erasure—please direct your inquiries to our designated Data Protection Officer via return correspondence. Our standard SLA for such requests is 7 business days.')) + ->salutation(__('Sincerely,')."\n**".$appName." Legal & Compliance Division**"); + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return []; + } +} diff --git a/app/Notifications/Auth/ResetPasswordNotification.php b/app/Notifications/Auth/ResetPasswordNotification.php new file mode 100644 index 0000000..5dc9dc8 --- /dev/null +++ b/app/Notifications/Auth/ResetPasswordNotification.php @@ -0,0 +1,50 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $appName = config('app.name'); + $url = url(route('password.reset', [ + 'token' => $this->token, + 'email' => $notifiable->getEmailForPasswordReset(), + ], false)); + + $expireMinutes = Config::get('auth.passwords.'.Config::get('auth.defaults.passwords').'.expire', 60); + + return (new MailMessage) + ->subject(__('Password Reset Request for :app', ['app' => $appName])) + ->greeting(__('Dear :name,', ['name' => $notifiable->name ?? 'Valued Client'])) + ->line(__('We have received a formal request to reset the password associated with your :app account.', ['app' => $appName])) + ->line(__('To authenticate this request and establish a new password, please click the button provided below. In accordance with our security protocols, this authorization link will expire in **:minutes minutes**.', ['minutes' => $expireMinutes])) + ->action(__('Authorize Password Reset'), $url) + ->line(__('If the button does not work, copy and paste this URL into your browser:')) + ->line('`'.$url.'`') + ->line(__('If you did not authorize this password reset request, please disregard this communication. Your account security remains intact, and no further action is required on your part.')) + ->salutation(__('Sincerely,')."\n**".$appName." Security Operations**"); + } +} diff --git a/app/Notifications/Auth/VerifyEmailNotification.php b/app/Notifications/Auth/VerifyEmailNotification.php new file mode 100644 index 0000000..1c3ee3b --- /dev/null +++ b/app/Notifications/Auth/VerifyEmailNotification.php @@ -0,0 +1,28 @@ +subject(__('Action Required: Secure Account Verification for :app', ['app' => $appName])) + ->greeting(__('Dear Valued Client,', ['app' => $appName])) + ->line(__('Thank you for registering with :app. To officially finalize your registration and ensure the security of your account, we kindly request that you authenticate your email address.', ['app' => $appName])) + ->action(__('Authenticate Account'), $url) + ->line(__('Please note that this secure verification link will expire in 60 minutes. Should this request be made in error, or if you did not authorize the creation of this account, please disregard this communication.')) + ->salutation(__('Sincerely,')."\n**".$appName." Identity Management**"); + } +} diff --git a/app/Notifications/SystemManagementNotification.php b/app/Notifications/SystemManagementNotification.php new file mode 100644 index 0000000..41ec6ad --- /dev/null +++ b/app/Notifications/SystemManagementNotification.php @@ -0,0 +1,71 @@ +createdBy = $createdBy ?? Auth::id(); + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via(object $notifiable): array + { + // Manually save to our custom notifications table here + $this->saveToCustomDatabase($notifiable); + + return []; + } + + /** + * Save to the custom notifications table. + */ + protected function saveToCustomDatabase(object $notifiable): void + { + // Prevent duplicate entries for the same system notification within a short window. + // This is necessary because system notifications are often sent to multiple users + // (e.g., all Administrators), but we only want one entry in the global log. + $recent = NotificationModel::where('title', $this->title) + ->where('message', $this->message) + ->where('recipient', $this->recipient) + ->where('created_at', '>=', now()->subSeconds(5)) + ->exists(); + + if ($recent) { + return; + } + + $notification = NotificationModel::create([ + 'title' => $this->title, + 'message' => $this->message, + 'type' => $this->type, + 'recipient' => $this->recipient, + 'created_by' => $this->createdBy, + ]); + + // Broadcast a NotificationSent event so the UI updates in real-time. + // We do this here once after the database record is created. + event(new NotificationSent($notification)); + } +} diff --git a/app/Observers/AiHealingLogObserver.php b/app/Observers/AiHealingLogObserver.php new file mode 100644 index 0000000..6b3c362 --- /dev/null +++ b/app/Observers/AiHealingLogObserver.php @@ -0,0 +1,48 @@ +created_by = Auth::id(); + } + } + + /** + * Ketika permission sedang diupdate + */ + public function updating(\App\Models\Permission $permission) + { + if (Auth::check()) { + $permission->updated_by = Auth::id(); + } + } + + /** + * Ketika permission selesai diupdate + */ + public function updated(\App\Models\Permission $permission) + { + \Illuminate\Support\Facades\Cache::forget("permission_status:{$permission->name}"); + } + + /** + * Ketika permission didelete (termasuk soft delete) + */ + public function deleted(\App\Models\Permission $permission) + { + \Illuminate\Support\Facades\Cache::forget("permission_status:{$permission->name}"); + + if (Auth::check()) { + $permission->updated_by = Auth::id(); + $permission->saveQuietly(); + } + } +} diff --git a/app/Observers/RoleObserver.php b/app/Observers/RoleObserver.php new file mode 100644 index 0000000..12f10c0 --- /dev/null +++ b/app/Observers/RoleObserver.php @@ -0,0 +1,50 @@ +created_by = Auth::id(); + } + } + + /** + * Ketika role diupdate + */ + public function updating(Role $role) + { + if (Auth::check()) { + $role->updated_by = Auth::id(); + } + } + + /** + * Handle the Role "deleting" event. + */ + public function deleting(Role $role) + { + if (Auth::check()) { + $role->updated_by = Auth::id(); + $role->saveQuietly(); // Hindari loop & double log + } + } + + /** + * Saat status role di-toggle + */ + public function toggleStatus(Role $role) + { + if (Auth::check()) { + $role->updated_by = Auth::id(); + } + } +} diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php new file mode 100644 index 0000000..a7883fd --- /dev/null +++ b/app/Observers/UserObserver.php @@ -0,0 +1,24 @@ +created_by = Auth::id(); + } + + public function updating(User $user) + { + // $user->updated_by = Auth::id(); + } + + public function deleting(User $user) + { + // $user->updated_by = Auth::id(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..30a41be --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,327 @@ +configureRateLimiting(); + + // Super Admin Gate (Developer has full power) + Gate::before(function ($user, $capability) { + // Monitoring dynamic gates + $monitoringKeys = [ + 'view pulse' => 'engine_pulse_enabled', + 'view telescope' => 'engine_telescope_enabled', + 'view api docs' => 'engine_swagger_enabled', + 'viewHorizon' => 'engine_horizon_enabled', + ]; + + if (isset($monitoringKeys[$capability])) { + if (!get_setting($monitoringKeys[$capability], true)) { + return false; // Force deny if disabled at engine level + } + } + + return $user->hasRole('Developer') ? true : null; + }); + + // Force HTTPS in production + if ($this->app->environment('production')) { + URL::forceScheme('https'); + } + + // Global Password Reset Expiry Override + if ($this->app->bound(SystemConfigService::class)) { + $expiry = get_setting('password_reset_link_expiry', 60); + config(['auth.passwords.users.expire' => $expiry]); + } + + // User Observer + User::observe(UserObserver::class); + + // Role Observer + Role::observe(RoleObserver::class); + + // Permission Observer + Permission::observe(PermissionObserver::class); + + // Windows Backup Compatibility Layer + $this->ensureWindowsBackupCompatibility(); + + // Google Drive Filesystem Adapter + $this->registerGoogleDriveAdapter(); + + // S3 Dynamic Config + $this->injectS3DynamicConfig(); + + // Backup Dynamic Config + $this->injectBackupDynamicConfig(); + + // Monitoring Dynamic Config + $this->injectMonitoringDynamicConfig(); + + // Dashboard View Composer + $this->registerDashboardComposer(); + + // Tab-permission Blade directives + $this->registerTabPermissionDirectives(); + + // 📡 Real-time Activity Log Broadcasting + Activity::created(function ($activity) { + try { + broadcast(new ActivityLogCreated($activity))->toOthers(); + } catch (\Throwable $e) { + // Silently fail if Reverb is not running + } + }); + + // 🔍 Register AI error analysis listener (not auto-discovered for MessageLogged) + Event::listen( + MessageLogged::class, + AnalyzeSystemError::class + ); + } + + /** + * Configure the rate limiters for the application. + */ + protected function configureRateLimiting(): void + { + // Rate limiting for API + RateLimiter::for('api', function (Request $request) { + return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); + }); + + // Rate limiting for Login attempts + RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->ip())->response(function (Request $request, array $headers) { + return response()->json([ + 'message' => 'Too many login attempts. Please try again in 1 minute.', + 'status' => 'error', + ], 429, $headers); + }); + }); + } + + /** + * Inject Monitoring configuration from database. + */ + protected function injectMonitoringDynamicConfig(): void + { + if (!$this->app->bound(SystemConfigService::class)) { + return; + } + + config(['pulse.enabled' => get_setting('engine_pulse_enabled', true)]); + config(['telescope.enabled' => get_setting('engine_telescope_enabled', true)]); + + if (!get_setting('engine_swagger_enabled', true)) { + config(['l5-swagger.documentations.default.routes.api' => null]); + config(['l5-swagger.defaults.routes.docs' => null]); + } + } + + /** + * Register a view composer for the dashboard. + */ + protected function registerDashboardComposer(): void + { + view()->composer('pages.dashboard', function ($view) { + $stats = Cache::remember('dashboard_stats', now()->addMinutes(5), function () { + return [ + 'total_users' => User::count(), + 'active_sessions' => app(SystemMonitoringService::class)->getActiveUsers(), + 'today_actions' => Activity::whereDate('created_at', now()->today())->count(), + 'failed_jobs' => DB::table('failed_jobs')->count(), + 'recent_activities' => Activity::with('causer')->latest()->take(5)->get(), + ]; + }); + + $view->with('dashboardStats', $stats); + }); + } + + /** + * Register Google Drive filesystem adapter. + */ + protected function registerGoogleDriveAdapter(): void + { + Storage::extend('google', function ($app, $config) { + $clientId = get_setting('gdrive_client_id', $config['clientId'] ?? ''); + $clientSecret = get_setting('gdrive_client_secret', $config['clientSecret'] ?? ''); + $refreshToken = get_setting('gdrive_refresh_token', $config['refreshToken'] ?? ''); + $folder = get_setting('gdrive_folder', $config['folder'] ?? 'LaravelBackups'); + + $client = new Client; + $client->setClientId($clientId); + $client->setClientSecret($clientSecret); + + if ($refreshToken) { + $token = $client->refreshToken($refreshToken); + if (!isset($token['error'])) { + $client->setAccessToken($token); + } + } + + $service = new Drive($client); + $adapter = new GoogleDriveAdapter($service, $folder, []); + $driver = new Filesystem($adapter); + + return new FilesystemAdapter($driver, $adapter, $config); + }); + } + + /** + * Inject S3 credentials from database if set. + */ + protected function injectS3DynamicConfig(): void + { + if (get_setting('backup_db_driver') === 's3') { + config([ + 'filesystems.disks.s3.key' => get_setting('s3_key', config('filesystems.disks.s3.key')), + 'filesystems.disks.s3.secret' => get_setting('s3_secret', config('filesystems.disks.s3.secret')), + 'filesystems.disks.s3.region' => get_setting('s3_region', config('filesystems.disks.s3.region')), + 'filesystems.disks.s3.bucket' => get_setting('s3_bucket', config('filesystems.disks.s3.bucket')), + 'filesystems.disks.s3.endpoint' => get_setting('s3_endpoint', config('filesystems.disks.s3.endpoint')), + ]); + } + } + + /** + * Ensures Windows backup compatibility. + */ + protected function ensureWindowsBackupCompatibility(): void + { + if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') { + return; + } + + $dbDriver = config('database.default'); + if (config("database.connections.{$dbDriver}.dump.dump_binary_path")) { + return; + } + + $isPg = $dbDriver === 'pgsql'; + $binary = $isPg ? 'pg_dump.exe' : 'mysqldump.exe'; + $paths = $isPg ? ['C:\laragon\bin\postgresql\*\bin', 'C:\Program Files\PostgreSQL\*\bin'] : ['C:\laragon\bin\mysql\*\bin', 'C:\xampp\mysql\bin']; + + foreach ($paths as $path) { + $matchedPaths = glob($path, GLOB_ONLYDIR); + if (!$matchedPaths) { + continue; + } + $foundPath = $matchedPaths[count($matchedPaths) - 1]; + if (file_exists($foundPath . DIRECTORY_SEPARATOR . $binary)) { + config(["database.connections.{$dbDriver}.dump.dump_binary_path" => $foundPath]); + break; + } + } + } + + /* + * Register Blade if/unless directives for tab-level permission checks. + * + * Usage in Blade: + * @cantab('global settings', 'login-security') ... @endcantab + * @managetab('mobile settings', 'branding') ... @endmanagetab + */ + protected function registerTabPermissionDirectives(): void + { + Blade::if('cantab', fn (string $menu, string $tab) => can_view_tab($menu, $tab)); + Blade::if('managetab', fn (string $menu, string $tab) => can_manage_tab($menu, $tab)); + Blade::if('canviewanytab', fn (string $menu) => can_view_any_tab($menu)); + Blade::if('canmanageanytab', fn (string $menu) => can_manage_any_tab($menu)); + } + + /** + * Inject Backup configuration from database. + */ + protected function injectBackupDynamicConfig(): void + { + if (!$this->app->bound(SystemConfigService::class)) { + return; + } + + $driver = get_setting('backup_db_driver', 'local'); + config(['backup.backup.destination.disks' => [$driver]]); + config(['backup.monitor_backups.0.disks' => [$driver]]); + + if ($driver === 'gdrive') { + config([ + 'filesystems.disks.gdrive.clientId' => get_setting('gdrive_client_id'), + 'filesystems.disks.gdrive.clientSecret' => get_setting('gdrive_client_secret'), + 'filesystems.disks.gdrive.refreshToken' => get_setting('gdrive_refresh_token'), + 'filesystems.disks.gdrive.folder' => get_setting('gdrive_folder', 'LaravelBackups'), + ]); + } + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php new file mode 100644 index 0000000..8c6ab20 --- /dev/null +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -0,0 +1,61 @@ +default() + ->id('admin') + ->path('admin') + ->login() + ->colors([ + 'primary' => Color::Indigo, + ]) + ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') + ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') + ->pages([ + Dashboard::class, + ]) + ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets') + ->widgets([ + AccountWidget::class, + FilamentInfoWidget::class, + ]) + ->middleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + AuthenticateSession::class, + ShareErrorsFromSession::class, + PreventRequestForgery::class, + SubstituteBindings::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + ]) + ->authMiddleware([ + Authenticate::class, + ]); + + return $panel; + } +} diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php new file mode 100644 index 0000000..a64fb09 --- /dev/null +++ b/app/Providers/HorizonServiceProvider.php @@ -0,0 +1,34 @@ +hasRole('Administrator'); + }); + } +} diff --git a/app/Providers/SystemConfigServiceProvider.php b/app/Providers/SystemConfigServiceProvider.php new file mode 100644 index 0000000..f5a851d --- /dev/null +++ b/app/Providers/SystemConfigServiceProvider.php @@ -0,0 +1,175 @@ +app->singleton(SystemConfigService::class); + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + // 1. Ensure settings helper is loaded + $helper = app_path('Helpers/SettingsHelper.php'); + if (file_exists($helper)) { + require_once $helper; + } + + /** @var SystemConfigService $systemConfig */ + $systemConfig = $this->app->make(SystemConfigService::class); + + // 2. Localization + $locale = $systemConfig->get('default_locale', 'en'); + app()->setLocale($locale); + + // Sync app.name globally so mail sender name, document title etc. use DB value + config(['app.name' => $systemConfig->get('app_name', config('app.name'))]); + + // 3. Performance: Only run config overrides if not in console (optional, but speeds up Artisan) + // or prioritize based on current context + + // Apply timezone globally from settings + $timezone = $systemConfig->get('regional_timezone', 'Asia/Jakarta'); + config(['app.timezone' => $timezone]); + date_default_timezone_set($timezone); + + // HTTPS Enforcement + if ($systemConfig->get('force_https', false)) { + URL::forceScheme('https'); + } + + // CORS Dynamic Configuration + config([ + 'cors.allowed_origins' => array_filter(array_map('trim', explode(',', $systemConfig->get('cors_origins', '*')))), + 'cors.allowed_methods' => array_filter(array_map('trim', explode(',', $systemConfig->get('cors_methods', '*')))), + 'cors.allowed_headers' => array_filter(array_map('trim', explode(',', $systemConfig->get('cors_headers', '*')))), + ]); + + // Apply Captcha Config + config([ + 'captcha.sitekey' => $systemConfig->get('captcha_site_key'), + 'captcha.secret' => $systemConfig->get('captcha_secret_key'), + 'captcha.options' => [ + 'timeout' => 30, + ], + ]); + + // Unified Social Login Callback Base + $callbackUrl = $systemConfig->get('social_login_callback_url', '/auth/callback'); + + // Apply Socialite Config + config([ + 'services.google' => [ + 'client_id' => $systemConfig->get('google_client_id'), + 'client_secret' => $systemConfig->get('google_client_secret'), + 'redirect' => url($callbackUrl), + ], + 'services.facebook' => [ + 'client_id' => $systemConfig->get('facebook_app_id'), + 'client_secret' => $systemConfig->get('facebook_app_secret'), + 'redirect' => url($callbackUrl), + ], + 'services.github' => [ + 'client_id' => $systemConfig->get('github_client_id'), + 'client_secret' => $systemConfig->get('github_client_secret'), + 'redirect' => url($callbackUrl), + ], + ]); + + // Apply Session Configuration + config([ + 'session.driver' => $systemConfig->get('session_driver', 'file'), + 'session.lifetime' => $systemConfig->get('session_lifetime', 120), + 'session.secure' => $systemConfig->get('session_secure_cookie', false), + 'session.encrypt' => $systemConfig->get('session_encrypt', false), + 'session.remember' => $systemConfig->get('session_remember_me_duration', 30) * 1440, // Days to Minutes + ]); + + config(['auth.passwords.users.expire' => $systemConfig->get('password_reset_link_expiry', 60)]); + + // 7. DYNAMIC MAIL CONFIGURATION + if ($systemConfig->get('mail_host')) { + config([ + 'mail.default' => $systemConfig->get('mail_driver', 'smtp'), + 'mail.mailers.smtp.host' => $systemConfig->get('mail_host'), + 'mail.mailers.smtp.port' => $systemConfig->get('mail_port', 587), + 'mail.mailers.smtp.encryption' => $systemConfig->get('mail_encryption', 'tls'), + 'mail.mailers.smtp.username' => $systemConfig->get('mail_username'), + 'mail.mailers.smtp.password' => $systemConfig->get('mail_password'), + 'mail.from.address' => $systemConfig->get('mail_from_address', 'noreply@example.com'), + 'mail.from.name' => $systemConfig->get('mail_from_name', config('app.name')), + ]); + + // If using smtp transport specifically + } + + // 8. DYNAMIC BACKUP CONFIGURATION (Spatie Backup) + if ($systemConfig->get('backup_db_enabled')) { + $driver = $systemConfig->get('backup_db_driver', 'local'); + config([ + 'backup.backup.destination.disks' => [$driver], + 'backup.monitor_backups.0.disks' => [$driver], + 'backup.cleanup.default_strategy.keep_all_backups_for_days' => (int) $systemConfig->get('backup_db_retention', 7), + ]); + + // Encryption + if ($systemConfig->get('backup_db_encrypt') && $systemConfig->get('backup_db_encrypt_key')) { + config([ + 'backup.backup.password' => $systemConfig->get('backup_db_encrypt_key'), + 'backup.backup.encryption' => 'aes256', + ]); + } else { + config([ + 'backup.backup.password' => null, + 'backup.backup.encryption' => 'none', + ]); + } + + // Exclude Tables (Injected via database config dump key) + if ($excludeTables = $systemConfig->get('backup_db_exclude')) { + $tables = array_map('trim', explode(',', $excludeTables)); + $dbDriver = config('database.default'); + config(["database.connections.{$dbDriver}.dump.exclude_tables" => $tables]); + } + + // Notifications + $notifyTo = $systemConfig->get('backup_db_notify_to'); + if ($notifyTo) { + if (filter_var($notifyTo, FILTER_VALIDATE_EMAIL)) { + config(['backup.notifications.mail.to' => $notifyTo]); + } elseif (str_starts_with($notifyTo, 'http')) { + config(['backup.notifications.notifications.webhook.url' => $notifyTo]); + } + } + } + + // 9. DYNAMIC MOBILE AUTH CONFIGURATION + $mobileConfig = $this->app->make(MobileConfigService::class); + $allMobile = $mobileConfig->all(); + if ($ttl = ($allMobile['security_auth']['token_ttl_minutes'] ?? null)) { + config(['sanctum.expiration' => (int) $ttl]); + } + + // 3. View Sharing (Cached for request duration) + view()->share($systemConfig->getPublicSettings()); + + // 4. Pulse Access Gate + Gate::define('viewPulse', function ($user) { + return $user->can('view pulse'); + }); + } +} diff --git a/app/Providers/TelescopeServiceProvider.php b/app/Providers/TelescopeServiceProvider.php new file mode 100644 index 0000000..5080a9e --- /dev/null +++ b/app/Providers/TelescopeServiceProvider.php @@ -0,0 +1,63 @@ +hideSensitiveRequestDetails(); + + $isLocal = $this->app->environment('local'); + + Telescope::filter(function (IncomingEntry $entry) use ($isLocal) { + return $isLocal || + $entry->isReportableException() || + $entry->isFailedRequest() || + $entry->isFailedJob() || + $entry->isScheduledTask() || + $entry->hasMonitoredTag(); + }); + } + + /** + * Prevent sensitive request details from being logged by Telescope. + */ + protected function hideSensitiveRequestDetails(): void + { + if ($this->app->environment('local')) { + return; + } + + Telescope::hideRequestParameters(['_token']); + + Telescope::hideRequestHeaders([ + 'cookie', + 'x-csrf-token', + 'x-xsrf-token', + ]); + } + + /** + * Register the Telescope gate. + * + * This gate determines who can access Telescope in non-local environments. + */ + protected function gate(): void + { + Gate::define('viewTelescope', function (User $user) { + return $user->can('view telescope'); + }); + } +} diff --git a/app/Repositories/BaseRepository.php b/app/Repositories/BaseRepository.php new file mode 100644 index 0000000..fd7d8dc --- /dev/null +++ b/app/Repositories/BaseRepository.php @@ -0,0 +1,102 @@ +model = $model; + } + + /** + * Get all records. + */ + public function all(array $columns = ['*']): Collection + { + return $this->model->all($columns); + } + + /** + * Find a record by ID. + */ + public function find(int|string $id, array $columns = ['*']): ?Model + { + return $this->model->find($id, $columns); + } + + /** + * Find a record by ID or throw an exception. + */ + public function findOrFail(int|string $id, array $columns = ['*']): Model + { + return $this->model->findOrFail($id, $columns); + } + + /** + * Create a new record. + */ + public function create(array $data): Model + { + return $this->model->create($data); + } + + /** + * Update an existing record. + */ + public function update(int|string $id, array $data): bool + { + $model = $this->find($id); + + if ($model) { + return $model->update($data); + } + + return false; + } + + /** + * Delete a record by ID. + */ + public function delete(int|string $id): bool + { + $model = $this->find($id); + + if ($model) { + return $model->delete(); + } + + return false; + } + + /** + * Paginate records. + */ + public function paginate(int $perPage = 15, array $columns = ['*']): LengthAwarePaginator + { + return $this->model->paginate($perPage, $columns); + } + + /** + * Get query builder. + * + * @return Builder + */ + public function query() + { + return $this->model->newQuery(); + } +} diff --git a/app/Repositories/NotificationRepository.php b/app/Repositories/NotificationRepository.php new file mode 100644 index 0000000..9683f97 --- /dev/null +++ b/app/Repositories/NotificationRepository.php @@ -0,0 +1,138 @@ + Administrator > User > all + */ + public function getAuthorizedRecipients($user) + { + $recipients = ['all']; + + if ($user->hasRole('Developer')) { + return ['Developer', 'Administrator', 'User', 'all']; + } + + if ($user->hasRole('Administrator')) { + return ['Administrator', 'User', 'all']; + } + + // Default: only see notifications for 'User' and 'all' + return ['User', 'all']; + } + + /** + * Get ALL notifications for a user based on their roles, + * BUT exclude those they have personally deleted/hidden. + */ + public function getActiveNotificationsForUser($user, $offset = 0, $limit = 10) + { + $query = $this->model->query(); + + // 1. Filter by roles (Broadcast targeting) hierarchical + $authorizedRecipients = $this->getAuthorizedRecipients($user); + $query->whereIn('recipient', $authorizedRecipients); + + // 2. Left Join personal interactions + $query->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); + }); + + // 3. Exclude personally deleted ones + $query->whereNull('system_notification_user.deleted_at'); + + // 4. Select needed columns (avoiding ID collision with Join) + return $query->select('system_notifications.*', 'system_notification_user.read_at as personal_read_at') + ->latest('system_notifications.created_at') + ->skip($offset) + ->take($limit) + ->get(); + } + + /** + * Get personal unread count. + */ + public function getUnreadCount($user) + { + $query = $this->model->query(); + + // Target filtering hierarchical + $authorizedRecipients = $this->getAuthorizedRecipients($user); + $query->whereIn('recipient', $authorizedRecipients); + + // Join to check read/deleted status + $query->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); + }); + + // Unread = (Global read_at is null AND Personal read_at is null) AND not deleted + $query->whereNull('system_notification_user.read_at') + ->whereNull('system_notification_user.deleted_at'); + + return $query->count(); + } + + /** + * Personal Mark As Read + */ + public function markAsRead($notificationId, $userId) + { + \DB::table('system_notification_user')->updateOrInsert( + ['notification_id' => $notificationId, 'user_id' => $userId], + ['read_at' => now(), 'updated_at' => now()] + ); + } + + /** + * Personal Delete (Hide) + */ + public function personalDelete($notificationId, $userId) + { + \DB::table('system_notification_user')->updateOrInsert( + ['notification_id' => $notificationId, 'user_id' => $userId], + ['deleted_at' => now(), 'updated_at' => now()] + ); + } + + /** + * Mark ALL visible notifications as read for this user. + */ + public function markAllAsReadForUser($user) + { + $authorizedRecipients = $this->getAuthorizedRecipients($user); + + // 1. Get IDs of notifications that are visible to user but either: + // - Have no entry in pivot (meaning unread) + // - Have an entry with read_at IS NULL and deleted_at IS NULL + $unreadIds = $this->model->query() + ->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.read_at') + ->whereNull('system_notification_user.deleted_at') + ->pluck('system_notifications.id'); + + if ($unreadIds->isEmpty()) { + return; + } + + // 2. Perform batch updateOrInsert for each (or just loop if small, but let's stick to markAsRead helper) + foreach ($unreadIds as $id) { + $this->markAsRead($id, $user->id); + } + } +} diff --git a/app/Repositories/SystemSettingRepository.php b/app/Repositories/SystemSettingRepository.php new file mode 100644 index 0000000..85308c5 --- /dev/null +++ b/app/Repositories/SystemSettingRepository.php @@ -0,0 +1,49 @@ +get(); + } + + public function allPublic(): Collection + { + return SystemSetting::query()->where('is_public', true)->get(); + } + + public function findByKey(string $key): ?SystemSetting + { + return SystemSetting::query()->where('key', $key)->first(); + } + + public function upsert(array $payload): SystemSetting + { + /** @var SystemSetting $setting */ + $setting = SystemSetting::query()->updateOrCreate( + ['key' => $payload['key']], + [ + 'value' => $payload['value'], + 'type' => $payload['type'], + 'group' => $payload['group'], + 'is_public' => $payload['is_public'], + 'description' => $payload['description'] ?? null, + 'created_by' => $payload['created_by'] ?? null, + 'updated_by' => $payload['updated_by'] ?? null, + ] + ); + + return $setting; + } +} diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php new file mode 100644 index 0000000..37de9f5 --- /dev/null +++ b/app/Repositories/UserRepository.php @@ -0,0 +1,36 @@ +model->role($role)->get(); + } + + /** + * Search users by name or email. + * + * @return Collection + */ + public function search(string $query) + { + return $this->model->where('name', 'like', "%{$query}%") + ->orWhere('email', 'like', "%{$query}%") + ->get(); + } +} diff --git a/app/Services/AI/AiAssistantService.php b/app/Services/AI/AiAssistantService.php new file mode 100644 index 0000000..558f6fb --- /dev/null +++ b/app/Services/AI/AiAssistantService.php @@ -0,0 +1,65 @@ +aiService->provider()->generate($question, [ + 'system_instruction' => $systemPrompt, + ]); + + if (isset($result['success']) && $result['success']) { + return $result['response']; + } + + return 'Sorry, I encountered an error: '.($result['error'] ?? 'Unknown error'); + } catch (\Exception $e) { + return 'Error: '.$e->getMessage(); + } + } +} diff --git a/app/Services/AI/AiProviderInterface.php b/app/Services/AI/AiProviderInterface.php new file mode 100644 index 0000000..f41bfea --- /dev/null +++ b/app/Services/AI/AiProviderInterface.php @@ -0,0 +1,18 @@ + bool, response => string, usage => array, error => string] + */ + public function generate(string $prompt, array $options = []): array; + + /** + * Get the provider identifier. + */ + public function getIdentifier(): string; +} diff --git a/app/Services/AI/AiService.php b/app/Services/AI/AiService.php new file mode 100644 index 0000000..dfb6a7d --- /dev/null +++ b/app/Services/AI/AiService.php @@ -0,0 +1,132 @@ +systemConfig = $systemConfig; + } + + /** + * Get a provider instance. + */ + public function provider(?string $identifier = null): AiProviderInterface + { + $identifier = $identifier ?: $this->systemConfig->get('ai_provider', 'gpt'); + + if (isset($this->providers[$identifier])) { + return $this->providers[$identifier]; + } + + $config = $this->getProviderConfig($identifier); + + $instance = match ($identifier) { + 'gpt' => new GptProvider($config), + 'gemini' => new GeminiProvider($config), + 'claude' => new ClaudeProvider($config), + 'deepseek' => new OpenAiCompatibleProvider('deepseek', 'https://api.deepseek.com/chat/completions', $config), + 'grok' => new OpenAiCompatibleProvider('grok', 'https://api.x.ai/v1/chat/completions', $config), + 'mistral' => new OpenAiCompatibleProvider('mistral', 'https://api.mistral.ai/v1/chat/completions', $config), + 'openrouter' => new OpenAiCompatibleProvider('openrouter', 'https://openrouter.ai/api/v1/chat/completions', $config), + 'ollama' => new OllamaProvider($config), + default => throw new \Exception("Unsupported AI provider: {$identifier}"), + }; + + return $this->providers[$identifier] = $instance; + } + + /** + * Get configuration for a specific provider from system settings. + */ + protected function getProviderConfig(string $identifier): array + { + return [ + 'key' => $this->systemConfig->get("ai_{$identifier}_key"), + 'base_url' => $this->systemConfig->get("ai_{$identifier}_base_url"), + 'default_model' => $this->systemConfig->get('ai_default_model'), + 'instruction' => $this->systemConfig->get('ai_system_instruction'), + 'temperature' => $this->systemConfig->get('ai_temperature', 0.7), + 'max_tokens' => $this->systemConfig->get('ai_max_tokens', 2000), + ]; + } + + /** + * Static method to get supported models for each provider. + */ + public static function getSupportedModels(): array + { + return [ + 'gpt' => [ + ['id' => 'gpt-4o', 'name' => 'GPT-4o (Newest)'], + ['id' => 'gpt-4-turbo', 'name' => 'GPT-4 Turbo'], + ['id' => 'gpt-3.5-turbo', 'name' => 'GPT-3.5 Turbo'], + ], + 'gemini' => [ + ['id' => 'gemini-1.5-pro', 'name' => 'Gemini 1.5 Pro'], + ['id' => 'gemini-1.5-flash', 'name' => 'Gemini 1.5 Flash'], + ['id' => 'gemini-1.0-pro', 'name' => 'Gemini 1.0 Pro'], + ], + 'claude' => [ + ['id' => 'claude-3-5-sonnet-20240620', 'name' => 'Claude 3.5 Sonnet'], + ['id' => 'claude-3-opus-20240229', 'name' => 'Claude 3 Opus'], + ['id' => 'claude-3-sonnet-20240229', 'name' => 'Claude 3 Sonnet'], + ['id' => 'claude-3-haiku-20240307', 'name' => 'Claude 3 Haiku'], + ], + 'deepseek' => [ + ['id' => 'deepseek-chat', 'name' => 'DeepSeek Chat'], + ['id' => 'deepseek-coder', 'name' => 'DeepSeek Coder'], + ], + 'grok' => [ + ['id' => 'grok-1', 'name' => 'Grok-1'], + ], + 'mistral' => [ + ['id' => 'mistral-large-latest', 'name' => 'Mistral Large'], + ['id' => 'mistral-medium-latest', 'name' => 'Mistral Medium'], + ['id' => 'mistral-small-latest', 'name' => 'Mistral Small'], + ], + 'openrouter' => [ + ['id' => 'google/gemini-pro-1.5', 'name' => 'Gemini Pro 1.5'], + ['id' => 'anthropic/claude-3.5-sonnet', 'name' => 'Claude 3.5 Sonnet'], + ['id' => 'meta-llama/llama-3-70b-instruct', 'name' => 'Llama 3 70B'], + ], + 'ollama' => [ + ['id' => 'llama3', 'name' => 'Llama 3'], + ['id' => 'mistral', 'name' => 'Mistral'], + ['id' => 'phi3', 'name' => 'Phi-3'], + ], + ]; + } +} diff --git a/app/Services/AI/BaseAiProvider.php b/app/Services/AI/BaseAiProvider.php new file mode 100644 index 0000000..ba6e56c --- /dev/null +++ b/app/Services/AI/BaseAiProvider.php @@ -0,0 +1,50 @@ +config = $config; + } + + /** + * Log the AI usage to the database. + */ + protected function logUsage(array $data): void + { + try { + AiUsageLog::create([ + 'user_id' => Auth::id(), + 'provider' => $this->getIdentifier(), + 'model' => $data['model'] ?? 'unknown', + 'prompt' => $data['prompt'] ?? null, + 'response' => $data['response'] ?? null, + 'prompt_tokens' => $data['usage']['prompt_tokens'] ?? 0, + 'completion_tokens' => $data['usage']['completion_tokens'] ?? 0, + 'total_tokens' => $data['usage']['total_tokens'] ?? 0, + 'estimated_cost' => $this->calculateCost($data), + 'status' => $data['status'] ?? 'success', + 'error_message' => $data['error'] ?? null, + 'metadata' => $data['metadata'] ?? null, + ]); + } catch (\Exception $e) { + \Log::error('Failed to log AI usage: '.$e->getMessage()); + } + } + + /** + * Abstract cost calculation, to be implemented by each provider. + */ + protected function calculateCost(array $data): float + { + // Default implementation, can be overridden + return 0.0; + } +} diff --git a/app/Services/AI/ClaudeProvider.php b/app/Services/AI/ClaudeProvider.php new file mode 100644 index 0000000..ba1e8e5 --- /dev/null +++ b/app/Services/AI/ClaudeProvider.php @@ -0,0 +1,84 @@ +config['key'] ?? null; + if (! $key) { + return ['success' => false, 'error' => 'API Key not configured.']; + } + + $model = $options['model'] ?? $this->config['default_model'] ?? 'claude-3-5-sonnet-20240620'; + $instruction = $options['instruction'] ?? $this->config['instruction'] ?? ''; + + $startTime = microtime(true); + + try { + $res = Http::timeout(60)->withHeaders([ + 'x-api-key' => $key, + 'anthropic-version' => '2023-06-01', + 'content-type' => 'application/json', + ])->post('https://api.anthropic.com/v1/messages', [ + 'model' => $model, + 'max_tokens' => (int) ($options['max_tokens'] ?? $this->config['max_tokens'] ?? 2000), + 'system' => $instruction, + 'messages' => [ + ['role' => 'user', 'content' => $prompt], + ], + 'temperature' => (float) ($options['temperature'] ?? $this->config['temperature'] ?? 0.7), + ]); + + if ($res->failed()) { + $error = $res->json()['error']['message'] ?? 'Claude Error'; + $this->logUsage([ + 'model' => $model, + 'prompt' => $prompt, + 'status' => 'failed', + 'error' => $error, + ]); + + return ['success' => false, 'error' => $error]; + } + + $data = $res->json(); + $response = $data['content'][0]['text']; + $usage = [ + 'prompt_tokens' => $data['usage']['input_tokens'] ?? 0, + 'completion_tokens' => $data['usage']['output_tokens'] ?? 0, + 'total_tokens' => ($data['usage']['input_tokens'] ?? 0) + ($data['usage']['output_tokens'] ?? 0), + ]; + + $result = [ + 'success' => true, + 'response' => $response, + 'usage' => $usage, + 'model' => $model, + 'latency' => microtime(true) - $startTime, + ]; + + $this->logUsage([ + 'model' => $model, + 'prompt' => $prompt, + 'response' => $response, + 'usage' => $usage, + 'status' => 'success', + 'metadata' => ['latency' => $result['latency']], + ]); + + return $result; + + } catch (\Exception $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } + } +} diff --git a/app/Services/AI/GeminiProvider.php b/app/Services/AI/GeminiProvider.php new file mode 100644 index 0000000..656a9d6 --- /dev/null +++ b/app/Services/AI/GeminiProvider.php @@ -0,0 +1,82 @@ +config['key'] ?? null; + if (! $key) { + return ['success' => false, 'error' => 'API Key not configured.']; + } + + $model = $options['model'] ?? $this->config['default_model'] ?? 'gemini-1.5-flash'; + $instruction = $options['instruction'] ?? $this->config['instruction'] ?? ''; + + $startTime = microtime(true); + + try { + $res = Http::timeout(60)->post("https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$key}", [ + 'contents' => [ + ['parts' => [['text' => $instruction."\n\n".$prompt]]], + ], + 'generationConfig' => [ + 'temperature' => (float) ($options['temperature'] ?? $this->config['temperature'] ?? 0.7), + 'maxOutputTokens' => (int) ($options['max_tokens'] ?? $this->config['max_tokens'] ?? 2000), + ], + ]); + + if ($res->failed()) { + $error = $res->json()['error']['message'] ?? 'Gemini Error'; + $this->logUsage([ + 'model' => $model, + 'prompt' => $prompt, + 'status' => 'failed', + 'error' => $error, + ]); + + return ['success' => false, 'error' => $error]; + } + + $data = $res->json(); + $response = $data['candidates'][0]['content']['parts'][0]['text'] ?? 'No response'; + + // Gemini doesn't always return token count in the same way, simplified for now + $usage = [ + 'prompt_tokens' => 0, + 'completion_tokens' => 0, + 'total_tokens' => 0, + ]; + + $result = [ + 'success' => true, + 'response' => $response, + 'usage' => $usage, + 'model' => $model, + 'latency' => microtime(true) - $startTime, + ]; + + $this->logUsage([ + 'model' => $model, + 'prompt' => $prompt, + 'response' => $response, + 'usage' => $usage, + 'status' => 'success', + 'metadata' => ['latency' => $result['latency']], + ]); + + return $result; + + } catch (\Exception $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } + } +} diff --git a/app/Services/AI/GptProvider.php b/app/Services/AI/GptProvider.php new file mode 100644 index 0000000..ee735c2 --- /dev/null +++ b/app/Services/AI/GptProvider.php @@ -0,0 +1,96 @@ +config['key'] ?? null; + if (! $key) { + return ['success' => false, 'error' => 'API Key not configured.']; + } + + $model = $options['model'] ?? $this->config['default_model'] ?? 'gpt-4o'; + $instruction = $options['instruction'] ?? $this->config['instruction'] ?? ''; + + $startTime = microtime(true); + + try { + $res = Http::withToken($key) + ->timeout(60) + ->post('https://api.openai.com/v1/chat/completions', [ + 'model' => $model, + 'messages' => [ + ['role' => 'system', 'content' => $instruction], + ['role' => 'user', 'content' => $prompt], + ], + 'temperature' => (float) ($options['temperature'] ?? $this->config['temperature'] ?? 0.7), + 'max_tokens' => (int) ($options['max_tokens'] ?? $this->config['max_tokens'] ?? 2000), + ]); + + if ($res->failed()) { + $error = $res->json()['error']['message'] ?? 'OpenAI Error'; + $this->logUsage([ + 'model' => $model, + 'prompt' => $prompt, + 'status' => 'failed', + 'error' => $error, + ]); + + return ['success' => false, 'error' => $error]; + } + + $data = $res->json(); + $response = $data['choices'][0]['message']['content']; + $usage = $data['usage'] ?? []; + + $result = [ + 'success' => true, + 'response' => $response, + 'usage' => $usage, + 'model' => $model, + 'latency' => microtime(true) - $startTime, + ]; + + $this->logUsage([ + 'model' => $model, + 'prompt' => $prompt, + 'response' => $response, + 'usage' => $usage, + 'status' => 'success', + 'metadata' => ['latency' => $result['latency']], + ]); + + return $result; + + } catch (\Exception $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + protected function calculateCost(array $data): float + { + $model = $data['model'] ?? ''; + $promptTokens = $data['usage']['prompt_tokens'] ?? 0; + $completionTokens = $data['usage']['completion_tokens'] ?? 0; + + // Simplified estimation + $rates = [ + 'gpt-4o' => ['prompt' => 0.005 / 1000, 'completion' => 0.015 / 1000], + 'gpt-4-turbo' => ['prompt' => 0.01 / 1000, 'completion' => 0.03 / 1000], + 'gpt-3.5-turbo' => ['prompt' => 0.0005 / 1000, 'completion' => 0.0015 / 1000], + ]; + + $rate = $rates[$model] ?? $rates['gpt-4o']; + + return ($promptTokens * $rate['prompt']) + ($completionTokens * $rate['completion']); + } +} diff --git a/app/Services/AI/LogAnalysisService.php b/app/Services/AI/LogAnalysisService.php new file mode 100644 index 0000000..896d253 --- /dev/null +++ b/app/Services/AI/LogAnalysisService.php @@ -0,0 +1,86 @@ +latest() + ->take($limit) + ->get() + ->map(function ($log) { + return [ + 'time' => $log->created_at->toDateTimeString(), + 'user' => $log->causer ? $log->causer->name : 'System', + 'action' => $log->description, + 'subject' => $log->subject_type ? class_basename($log->subject_type) : 'N/A', + ]; + }) + ->toArray(); + + if (empty($logs)) { + return 'No activity logs found to analyze.'; + } + + $prompt = 'As a Security and Operational Auditor, analyze the following system activity logs and provide: + 1. A brief summary of recent operations. + 2. Security insights (detect any suspicious patterns or potential privilege abuse). + 3. Operational health status. + 4. Recommendations (if any). + + FORMAT: Use Markdown with bold headers. Be concise and professional. + + LOGS DATA: + '.json_encode($logs, JSON_PRETTY_PRINT); + + try { + return Cache::remember('ai_log_analysis_result', 3600, function () use ($prompt) { + $result = $this->aiService->provider()->generate($prompt); + + if (isset($result['success']) && $result['success']) { + return $result['response'] ?? 'AI failed to generate analysis.'; + } + + return 'AI Provider Error: '.($result['error'] ?? 'Unknown error'); + }); + } catch (\Exception $e) { + return 'Error during AI analysis: '.$e->getMessage(); + } + } +} diff --git a/app/Services/AI/OllamaProvider.php b/app/Services/AI/OllamaProvider.php new file mode 100644 index 0000000..74bf562 --- /dev/null +++ b/app/Services/AI/OllamaProvider.php @@ -0,0 +1,77 @@ +config['base_url'] ?? 'http://localhost:11434'; + $model = $options['model'] ?? $this->config['default_model'] ?? 'llama3'; + $instruction = $options['instruction'] ?? $this->config['instruction'] ?? ''; + + $startTime = microtime(true); + + try { + $res = Http::timeout(120)->post("{$baseUrl}/api/generate", [ + 'model' => $model, + 'prompt' => $instruction."\n\n".$prompt, + 'stream' => false, + 'options' => [ + 'temperature' => (float) ($options['temperature'] ?? $this->config['temperature'] ?? 0.7), + 'num_predict' => (int) ($options['max_tokens'] ?? $this->config['max_tokens'] ?? 2000), + ], + ]); + + if ($res->failed()) { + $error = 'Ollama Error: Make sure the server is running and the model is pulled.'; + $this->logUsage([ + 'model' => $model, + 'prompt' => $prompt, + 'status' => 'failed', + 'error' => $error, + ]); + + return ['success' => false, 'error' => $error]; + } + + $data = $res->json(); + $response = $data['response']; + + $usage = [ + 'prompt_tokens' => $data['prompt_eval_count'] ?? 0, + 'completion_tokens' => $data['eval_count'] ?? 0, + 'total_tokens' => ($data['prompt_eval_count'] ?? 0) + ($data['eval_count'] ?? 0), + ]; + + $result = [ + 'success' => true, + 'response' => $response, + 'usage' => $usage, + 'model' => $model, + 'latency' => microtime(true) - $startTime, + ]; + + $this->logUsage([ + 'model' => $model, + 'prompt' => $prompt, + 'response' => $response, + 'usage' => $usage, + 'status' => 'success', + 'metadata' => ['latency' => $result['latency']], + ]); + + return $result; + + } catch (\Exception $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } + } +} diff --git a/app/Services/AI/OpenAiCompatibleProvider.php b/app/Services/AI/OpenAiCompatibleProvider.php new file mode 100644 index 0000000..8c2b482 --- /dev/null +++ b/app/Services/AI/OpenAiCompatibleProvider.php @@ -0,0 +1,92 @@ +identifier = $identifier; + $this->baseUrl = $baseUrl; + } + + public function getIdentifier(): string + { + return $this->identifier; + } + + public function generate(string $prompt, array $options = []): array + { + $key = $options['key'] ?? $this->config['key'] ?? null; + if (! $key) { + return ['success' => false, 'error' => ucfirst($this->identifier).' API Key not configured.']; + } + + $model = $options['model'] ?? $this->config['default_model'] ?? null; + $instruction = $options['instruction'] ?? $this->config['instruction'] ?? ''; + + $startTime = microtime(true); + + try { + $res = Http::timeout(60)->withToken($key) + ->post($this->baseUrl, [ + 'model' => $model, + 'messages' => [ + ['role' => 'system', 'content' => $instruction], + ['role' => 'user', 'content' => $prompt], + ], + 'temperature' => (float) ($options['temperature'] ?? $this->config['temperature'] ?? 0.7), + 'max_tokens' => (int) ($options['max_tokens'] ?? $this->config['max_tokens'] ?? 2000), + ]); + + if ($res->failed()) { + $error = $res->json()['error']['message'] ?? $res->json()['message'] ?? ucfirst($this->identifier).' Error'; + $this->logUsage([ + 'model' => $model, + 'prompt' => $prompt, + 'status' => 'failed', + 'error' => $error, + ]); + + return ['success' => false, 'error' => $error]; + } + + $data = $res->json(); + $response = $data['choices'][0]['message']['content']; + $usage = $data['usage'] ?? [ + 'prompt_tokens' => 0, + 'completion_tokens' => 0, + 'total_tokens' => 0, + ]; + + $result = [ + 'success' => true, + 'response' => $response, + 'usage' => $usage, + 'model' => $model, + 'latency' => microtime(true) - $startTime, + ]; + + $this->logUsage([ + 'model' => $model, + 'prompt' => $prompt, + 'response' => $response, + 'usage' => $usage, + 'status' => 'success', + 'metadata' => ['latency' => $result['latency']], + ]); + + return $result; + + } catch (\Exception $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } + } +} diff --git a/app/Services/AI/SecurityHardeningService.php b/app/Services/AI/SecurityHardeningService.php new file mode 100644 index 0000000..ada2c50 --- /dev/null +++ b/app/Services/AI/SecurityHardeningService.php @@ -0,0 +1,93 @@ + 'AI Service disabled.']; + } + + // Collect relevant security settings + $settings = [ + 'force_https' => get_setting('force_https'), + 'hsts_enabled' => get_setting('hsts_enabled'), + 'two_factor_auth' => get_setting('two_factor_auth'), + 'password_min_length' => get_setting('password_min_length'), + 'login_max_attempts' => get_setting('login_max_attempts'), + 'session_lifetime' => get_setting('session_lifetime'), + 'ip_whitelist_admin' => ! empty(get_setting('ip_whitelist_admin')), + 'backup_db_encrypt' => get_setting('backup_db_encrypt'), + 'maintenance_mode' => get_setting('maintenance_mode_enabled'), + 'environment' => app()->environment(), + 'debug_mode' => config('app.debug'), + ]; + + $prompt = 'As a Cyber Security Expert, audit the following Laravel system security configurations and provide: + 1. A Security Score (0-100). + 2. Critical Vulnerabilities (if any). + 3. Hardening Recommendations. + 4. A JSON object summary at the end. + + CONFIGURATIONS: + '.json_encode($settings, JSON_PRETTY_PRINT); + + try { + return Cache::remember('security_audit_result', 86400, function () use ($prompt) { + $result = $this->aiService->provider()->generate($prompt); + + if (isset($result['success']) && $result['success']) { + return [ + 'analysis' => $result['response'], + 'score' => $this->extractScore($result['response']), + 'timestamp' => now()->toDateTimeString(), + ]; + } + + return ['error' => $result['error'] ?? 'Unknown error']; + }); + } catch (\Exception $e) { + return ['error' => $e->getMessage()]; + } + } + + private function extractScore(string $text): int + { + preg_match('/Score:?\s*(\d+)/i', $text, $matches); + + return isset($matches[1]) ? (int) $matches[1] : 70; + } +} diff --git a/app/Services/Auth/OtpService.php b/app/Services/Auth/OtpService.php new file mode 100644 index 0000000..6f042e2 --- /dev/null +++ b/app/Services/Auth/OtpService.php @@ -0,0 +1,61 @@ +whereNull('verified_at') + ->delete(); + + $code = (string) random_int(100000, 999999); + + OtpCode::create([ + 'identifier' => $identifier, + 'code' => $code, + 'expires_at' => Carbon::now()->addMinutes($expiryMinutes), + ]); + + return $code; + } + + /** + * Verify the OTP code. + */ + public function verify(string $identifier, string $code): bool + { + $otp = OtpCode::where('identifier', $identifier) + ->where('code', $code) + ->whereNull('verified_at') + ->where('expires_at', '>', Carbon::now()) + ->latest() + ->first(); + + if ($otp) { + $otp->update(['verified_at' => Carbon::now()]); + + return true; + } + + return false; + } + + /** + * Clear expired codes. + */ + public function cleanup(): void + { + OtpCode::where('expires_at', '<', Carbon::now()) + ->whereNull('verified_at') + ->delete(); + } +} diff --git a/app/Services/Auth/PasswordPolicyService.php b/app/Services/Auth/PasswordPolicyService.php new file mode 100644 index 0000000..6d208b7 --- /dev/null +++ b/app/Services/Auth/PasswordPolicyService.php @@ -0,0 +1,112 @@ +max(get_setting('password_max_length', 64)); + + $requireUpper = get_setting('password_require_uppercase', false); + $requireLower = get_setting('password_require_lowercase', false); + + if ($requireUpper && $requireLower) { + $rules->mixedCase(); + } elseif ($requireUpper) { + $rules->rules(['regex:/[A-Z]/']); + } elseif ($requireLower) { + $rules->rules(['regex:/[a-z]/']); + } + + if (get_setting('password_require_numeric', false)) { + $rules->numbers(); + } + + if (get_setting('password_require_special', false)) { + $rules->symbols(); + } + + return $rules; + } + + /** + * Check if the password has expired for a user. + */ + public static function isPasswordExpired(User $user): bool + { + $expiryDays = get_setting('password_expiry_days', 0); + + if ($expiryDays <= 0) { + return false; + } + + $lastChanged = $user->password_changed_at ?? $user->created_at; + + return $lastChanged->addDays($expiryDays)->isPast(); + } + + /** + * Verify that the new password is not in the user's password history. + */ + public static function checkHistory(User $user, string $newPassword): void + { + $historyCount = get_setting('password_history_count', 0); + + if ($historyCount <= 0) { + return; + } + + $histories = $user->passwordHistories() + ->latest() + ->take($historyCount) + ->get(); + + foreach ($histories as $history) { + if (Hash::check($newPassword, $history->password)) { + throw ValidationException::withMessages([ + 'password' => __('You cannot reuse any of your last :count passwords.', ['count' => $historyCount]), + ]); + } + } + } + + /** + * Record the current password into history and update changed_at timestamp. + */ + public static function recordPasswordChange(User $user, string $newPasswordHash): void + { + $historyCount = get_setting('password_history_count', 0); + + // 1. Record to history (only if enabled) + if ($historyCount > 0) { + $user->passwordHistories()->create([ + 'password' => $newPasswordHash, + ]); + } + + // 2. Update timestamp + $user->update([ + 'password_changed_at' => now(), + ]); + + // 3. Optional: Prune old history — keep exactly $historyCount entries + $historyCount = get_setting('password_history_count', 0); + if ($historyCount > 0) { + $user->passwordHistories() + ->orderBy('created_at', 'desc') + ->skip($historyCount) + ->take(100) + ->delete(); + } + } +} diff --git a/app/Services/FcmService.php b/app/Services/FcmService.php new file mode 100644 index 0000000..ae41601 --- /dev/null +++ b/app/Services/FcmService.php @@ -0,0 +1,116 @@ +serverKey = config('services.fcm.server_key', ''); + $this->mobileConfig = $mobileConfig; + } + + /** + * Check if notifications are enabled globally in settings. + */ + private function isEnabled(): bool + { + $config = $this->mobileConfig->all(); + + return filter_var($config['notifications']['enable_push_notifications'] ?? true, FILTER_VALIDATE_BOOLEAN); + } + + /** + * Send push notification to a single user (all their devices). + */ + public function sendToUser(int $userId, string $title, string $body, array $data = []): void + { + if (! $this->isEnabled()) { + return; + } + + $tokens = DeviceToken::where('user_id', $userId)->pluck('token')->toArray(); + + if (empty($tokens)) { + return; + } + + $this->sendToTokens($tokens, $title, $body, $data); + } + + /** + * Send to a list of device tokens. + */ + public function sendToTokens(array $tokens, string $title, string $body, array $data = []): void + { + if (! $this->isEnabled()) { + return; + } + + if (empty($this->serverKey)) { + Log::warning('FCM server key not configured — push notification skipped'); + + return; + } + + // FCM supports max 1000 tokens per request + foreach (array_chunk($tokens, 1000) as $chunk) { + $this->dispatch($chunk, $title, $body, $data); + } + } + + private function dispatch(array $tokens, string $title, string $body, array $data): void + { + $payload = [ + 'registration_ids' => $tokens, + 'notification' => ['title' => $title, 'body' => $body], + 'data' => $data, + 'priority' => 'high', + ]; + + try { + $response = Http::withHeaders([ + 'Authorization' => 'key='.$this->serverKey, + 'Content-Type' => 'application/json', + ])->post($this->endpoint, $payload); + + if (! $response->successful()) { + Log::error('FCM dispatch failed', ['status' => $response->status(), 'body' => $response->body()]); + } else { + $this->handleFcmResponse($response->json(), $tokens); + } + } catch (\Throwable $e) { + Log::error('FCM HTTP error', ['error' => $e->getMessage()]); + } + } + + private function handleFcmResponse(array $result, array $tokens): void + { + if (empty($result['results'])) { + return; + } + + $invalidTokens = []; + foreach ($result['results'] as $index => $res) { + if (isset($res['error']) && in_array($res['error'], ['NotRegistered', 'InvalidRegistration'])) { + $invalidTokens[] = $tokens[$index]; + } + } + + if (! empty($invalidTokens)) { + DeviceToken::whereIn('token', $invalidTokens)->delete(); + Log::info('FCM removed stale tokens', ['count' => count($invalidTokens)]); + } + } +} diff --git a/app/Services/MobileConfig/MobileConfigService.php b/app/Services/MobileConfig/MobileConfigService.php new file mode 100644 index 0000000..8844c03 --- /dev/null +++ b/app/Services/MobileConfig/MobileConfigService.php @@ -0,0 +1,394 @@ + ['type' => 'string', 'group' => 'branding', 'default' => 'biiproject'], + 'app_tagline' => ['type' => 'string', 'group' => 'branding', 'default' => 'Smart Solution for Your Business'], + 'app_icon_url' => ['type' => 'image_path', 'group' => 'branding', 'default' => null], + 'logo_url' => ['type' => 'image_path', 'group' => 'branding', 'default' => null], + 'splash_image_url' => ['type' => 'image_path', 'group' => 'branding', 'default' => null], + 'brand_color' => ['type' => 'string', 'group' => 'branding', 'default' => '#C6F135'], + 'theme_color_primary' => ['type' => 'string', 'group' => 'branding', 'default' => '#C6F135'], + 'theme_color_secondary' => ['type' => 'string', 'group' => 'branding', 'default' => '#1A1A1A'], + 'primary_font_family' => ['type' => 'string', 'group' => 'branding', 'default' => 'Outfit'], + + // 2. CONTROL CENTER + 'kill_switch_active' => ['type' => 'boolean', 'group' => 'control_center', 'default' => false], + 'kill_switch_message' => ['type' => 'string', 'group' => 'control_center', 'default' => 'System is currently undergoing emergency maintenance. Please try again later.'], + 'maintenance_start_at' => ['type' => 'string', 'group' => 'control_center', 'default' => null], + 'maintenance_end_at' => ['type' => 'string', 'group' => 'control_center', 'default' => null], + 'maintenance_bypass_ips' => ['type' => 'string', 'group' => 'control_center', 'default' => '127.0.0.1'], + 'announcement_enabled' => ['type' => 'boolean', 'group' => 'control_center', 'default' => false], + 'announcement_text' => ['type' => 'string', 'group' => 'control_center', 'default' => 'Maintenance is scheduled for tonight at 12:00 AM.'], + 'announcement_type' => ['type' => 'string', 'group' => 'control_center', 'default' => 'info'], + + // 3. APP UPDATES + 'app_version' => ['type' => 'string', 'group' => 'app_updates', 'default' => '2.0.0'], + 'min_app_version' => ['type' => 'string', 'group' => 'app_updates', 'default' => '1.0.0'], + 'onboarding_version' => ['type' => 'string', 'group' => 'app_updates', 'default' => '1.0.0'], + 'store_url_android' => ['type' => 'string', 'group' => 'app_updates', 'default' => 'https://play.google.com/store/apps/details?id=com.biiproject'], + 'store_url_ios' => ['type' => 'string', 'group' => 'app_updates', 'default' => 'https://apps.apple.com/app/biiproject'], + 'store_url_huawei' => ['type' => 'string', 'group' => 'app_updates', 'default' => 'https://appgallery.huawei.com/'], + + // 4. FEATURES + 'enable_registration' => ['type' => 'boolean', 'group' => 'features', 'default' => true], + 'enable_guest_mode' => ['type' => 'boolean', 'group' => 'features', 'default' => false], + 'require_otp_registration' => ['type' => 'boolean', 'group' => 'features', 'default' => false], + 'enable_biometrics' => ['type' => 'boolean', 'group' => 'features', 'default' => false], + 'enable_remember_me' => ['type' => 'boolean', 'group' => 'features', 'default' => true], + 'review_prompt_enabled' => ['type' => 'boolean', 'group' => 'features', 'default' => true], + 'min_actions_before_review' => ['type' => 'integer', 'group' => 'features', 'default' => 10], + 'region_lock_enabled' => ['type' => 'boolean', 'group' => 'features', 'default' => false], + + // 5. SECURITY & AUTH + 'login_title' => ['type' => 'string', 'group' => 'security_auth', 'default' => 'biiproject'], + 'login_subtitle' => ['type' => 'string', 'group' => 'security_auth', 'default' => 'Sign in to continue'], + 'token_ttl_minutes' => ['type' => 'integer', 'group' => 'security_auth', 'default' => 43200], + 'session_max_age' => ['type' => 'integer', 'group' => 'security_auth', 'default' => 86400], + 'login_max_attempts' => ['type' => 'integer', 'group' => 'security_auth', 'default' => 5], + 'biometric_auth_type' => ['type' => 'string', 'group' => 'security_auth', 'default' => 'any'], + 'oauth_google_enabled' => ['type' => 'boolean', 'group' => 'security_auth', 'default' => false], + 'oauth_apple_enabled' => ['type' => 'boolean', 'group' => 'security_auth', 'default' => false], + 'oauth_facebook_enabled' => ['type' => 'boolean', 'group' => 'security_auth', 'default' => false], + + // 6. CONNECTIVITY + 'api_base_url' => ['type' => 'string', 'group' => 'connectivity', 'default' => 'https://api.biiproject.com'], + 'api_version' => ['type' => 'string', 'group' => 'connectivity', 'default' => 'v1'], + 'api_timeout_ms' => ['type' => 'integer', 'group' => 'connectivity', 'default' => 30000], + 'api_retry_count' => ['type' => 'integer', 'group' => 'connectivity', 'default' => 3], + 'request_cache_ttl' => ['type' => 'integer', 'group' => 'connectivity', 'default' => 3600], + 'sync_interval_ms' => ['type' => 'integer', 'group' => 'connectivity', 'default' => 10000], + 'enable_ssl_pinning' => ['type' => 'boolean', 'group' => 'connectivity', 'default' => false], + 'ssl_pinning_hash' => ['type' => 'string', 'group' => 'connectivity', 'default' => null], + 'environment_selector' => ['type' => 'string', 'group' => 'connectivity', 'default' => 'production'], + + // 7. NOTIFICATIONS + 'enable_push_notifications' => ['type' => 'boolean', 'group' => 'notifications', 'default' => true], + 'fcm_topic_default' => ['type' => 'string', 'group' => 'notifications', 'default' => 'all_users'], + 'default_channel_id' => ['type' => 'string', 'group' => 'notifications', 'default' => 'default_channel'], + 'notification_sound_enabled' => ['type' => 'boolean', 'group' => 'notifications', 'default' => true], + 'badge_count_enabled' => ['type' => 'boolean', 'group' => 'notifications', 'default' => true], + 'priority_level' => ['type' => 'string', 'group' => 'notifications', 'default' => 'high'], + + // 8. SUPPORT & SOCIAL + 'support_email' => ['type' => 'string', 'group' => 'support_social', 'default' => 'support@biiproject.com'], + 'support_whatsapp' => ['type' => 'string', 'group' => 'support_social', 'default' => '628123456789'], + 'live_chat_url' => ['type' => 'string', 'group' => 'support_social', 'default' => null], + 'faq_url' => ['type' => 'string', 'group' => 'support_social', 'default' => null], + 'privacy_policy_url' => ['type' => 'string', 'group' => 'support_social', 'default' => 'https://biiproject.com/privacy'], + 'social_instagram_url' => ['type' => 'string', 'group' => 'support_social', 'default' => null], + 'social_twitter_url' => ['type' => 'string', 'group' => 'support_social', 'default' => null], + 'social_facebook_url' => ['type' => 'string', 'group' => 'support_social', 'default' => null], + 'social_youtube_url' => ['type' => 'string', 'group' => 'support_social', 'default' => null], + + // 9. ANALYTICS & SYSTEM + 'crashlytics_enabled' => ['type' => 'boolean', 'group' => 'analytics_system', 'default' => true], + 'log_level' => ['type' => 'string', 'group' => 'analytics_system', 'default' => 'error'], + 'event_sampling_rate' => ['type' => 'string', 'group' => 'analytics_system', 'default' => '1.0'], + 'google_analytics_id' => ['type' => 'string', 'group' => 'analytics_system', 'default' => null], + 'gdpr_compliance_enabled' => ['type' => 'boolean', 'group' => 'analytics_system', 'default' => false], + 'target_sdk_version' => ['type' => 'string', 'group' => 'analytics_system', 'default' => '34'], + 'system_timezone' => ['type' => 'string', 'group' => 'analytics_system', 'default' => 'Asia/Jakarta'], + 'default_locale' => ['type' => 'string', 'group' => 'analytics_system', 'default' => 'en'], + + // 10. DYNAMIC CONTENT + 'dashboard_categories' => ['type' => 'string', 'group' => 'features', 'default' => 'All,Tech,Finance,Health,Coding'], + 'faq_json' => ['type' => 'json', 'group' => 'support_social', 'default' => '[{"q":"How to sync?","a":"Click the sync button on dashboard."}]'], + 'help_topics_json' => ['type' => 'json', 'group' => 'support_social', 'default' => '[{"id":"1","name":"Account","icon":"user"},{"id":"2","name":"System","icon":"cpu"}]'], + ]; + + public static function getDefinitions(): array + { + return self::DEFINITIONS; + } + + public function all(bool $refresh = false): array + { + if ($refresh) { + $this->clearCache(); + } + + return Cache::remember(self::CACHE_KEY, now()->addHours(24), function () { + $settings = MobileSetting::all(); + $config = []; + + // 1. Initialize from definitions + foreach (self::DEFINITIONS as $key => $def) { + if (! isset($config[$def['group']])) { + $config[$def['group']] = []; + } + $config[$def['group']][$key] = $def['default']; + } + + // 2. Fallback from Global System Settings (Branding) + // This ensures if mobile settings are empty, we use the main site branding + if (class_exists(SystemSetting::class)) { + $systemSettings = SystemSetting::whereIn('key', ['app_name', 'app_logo', 'app_tagline', 'app_tagline1', 'app_tagline2']) + ->pluck('value', 'key'); + + if ($systemSettings->has('app_name')) { + $config['branding']['app_name'] = $systemSettings->get('app_name'); + $config['security_auth']['login_title'] = $systemSettings->get('app_name'); + } + + if ($systemSettings->has('app_logo')) { + $logoPath = $systemSettings->get('app_logo'); + $logoUrl = Str::startsWith($logoPath, 'http') ? $logoPath : asset($logoPath); + $config['branding']['logo_url'] = $logoUrl; + } + + if ($systemSettings->has('app_tagline')) { + $config['security_auth']['login_subtitle'] = strip_tags($systemSettings->get('app_tagline')); + $config['branding']['app_tagline'] = strip_tags($systemSettings->get('app_tagline')); + } + } + + // 3. Overlay with Mobile-Specific settings (Overwrites fallbacks) + foreach ($settings as $setting) { + $value = $this->castValue($setting->value, $setting->type); + + // Handle Assets (Storage URLs) + if ($setting->type === 'image_path' && $value && ! Str::startsWith($value, 'http')) { + $value = asset($value); + } + + $group = $setting->group; + + // Skip if value is default/null to keep system fallback + if (($setting->key === 'logo_url' || $setting->key === 'app_name') && (empty($value) || $value === 'biiproject')) { + continue; + } + + // Handle Localization Dynamic Keys + if ($group === 'localization') { + if (! isset($config['localization'])) { + $config['localization'] = ['English' => [], 'Indonesian' => []]; + } + + if (str_starts_with($setting->key, 'lang_en_')) { + $key = str_replace('lang_en_', '', $setting->key); + $config['localization']['English'][$key] = $value; + } elseif (str_starts_with($setting->key, 'lang_id_')) { + $key = str_replace('lang_id_', '', $setting->key); + $config['localization']['Indonesian'][$key] = $value; + } + + continue; + } + + if (! isset($config[$group])) { + $config[$group] = []; + } + + // Clean HTML tags for mobile display on critical text fields + if (in_array($setting->key, ['kill_switch_message', 'announcement_text'])) { + $value = trim(strip_tags((string) $value)); + } + + $config[$group][$setting->key] = $value; + } + + return $config; + }); + } + + public function getGroupedSettingsForAdmin(): array + { + $settings = MobileSetting::all()->keyBy('key'); + $grouped = []; + + // 1. Process from Definitions (Ensure defaults) + foreach (self::DEFINITIONS as $key => $def) { + $setting = $settings->get($key); + + if (! $setting) { + $setting = new MobileSetting([ + 'key' => $key, + 'group' => $def['group'], + 'type' => $def['type'], + 'value' => is_bool($def['default']) ? ($def['default'] ? 'true' : 'false') : (string) $def['default'], + ]); + } + + if (! isset($grouped[$def['group']])) { + $grouped[$def['group']] = collect(); + } + $grouped[$def['group']]->push($setting); + $settings->forget($key); // Mark as processed + } + + // 2. Process remaining settings in DB (like dynamic localization keys) + foreach ($settings as $setting) { + if (! isset($grouped[$setting->group])) { + $grouped[$setting->group] = collect(); + } + $grouped[$setting->group]->push($setting); + } + + return $grouped; + } + + /** + * Get all keys that should be treated as boolean. + */ + public function getBooleanKeys(): array + { + $definitions = collect(self::DEFINITIONS) + ->filter(fn ($def) => $def['type'] === 'boolean') + ->keys() + ->toArray(); + + $db = MobileSetting::where('type', 'boolean')->pluck('key')->toArray(); + + return array_unique(array_merge($definitions, $db)); + } + + public function update(array $data, array $files = []): void + { + \DB::transaction(function () use ($data, $files) { + $settings = MobileSetting::all()->keyBy('key'); + + // Handle Files + foreach ($files as $key => $file) { + if ($file instanceof UploadedFile) { + $setting = $settings->get($key); + $def = self::DEFINITIONS[$key] ?? null; + + if (! $setting && $def) { + $setting = MobileSetting::create([ + 'key' => $key, + 'group' => $def['group'], + 'type' => $def['type'], + ]); + $settings->put($key, $setting); // Sync to collection + } + + if ($setting && $setting->type === 'image_path') { + // Delete old file if exists + if ($setting->value) { + $oldPath = str_replace('/storage/', '', $setting->value); + Storage::disk('public')->delete($oldPath); + } + + // OPTIMIZATION: Convert to WebP (only if GD extension is available) + $hasGd = function_exists('imagewebp') && function_exists('imagecreatefromstring'); + $filename = $key.'_'.time().($hasGd ? '.webp' : '.'.$file->getClientOriginalExtension()); + + if ($hasGd) { + $tempPath = $file->getRealPath(); + $content = file_get_contents($tempPath); + $image = imagecreatefromstring($content); + + if ($image) { + imagepalettetotruecolor($image); + imagealphablending($image, true); + imagesavealpha($image, true); + + // NEW: Auto Resize (Max width 1200px) + $width = imagesx($image); + $height = imagesy($image); + if ($width > 1200) { + $newWidth = 1200; + $newHeight = (int) ($height * (1200 / $width)); + $resized = imagecreatetruecolor($newWidth, $newHeight); + imagealphablending($resized, false); + imagesavealpha($resized, true); + imagecopyresampled($resized, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height); + imagedestroy($image); + $image = $resized; + } + + ob_start(); + imagewebp($image, null, 80); + $webpContent = ob_get_clean(); + imagedestroy($image); + + Storage::disk('public')->put('mobile-assets/'.$filename, $webpContent); + $data[$key] = '/storage/mobile-assets/'.$filename; + } else { + $hasGd = false; + } + } + + if (! $hasGd) { + // Standard store fallback + $path = $file->storeAs('mobile-assets', $filename, 'public'); + $data[$key] = '/storage/'.$path; + } + } + } + } + + // Handle Values + foreach ($data as $key => $value) { + $setting = $settings->get($key); + $def = self::DEFINITIONS[$key] ?? null; + + // Handle dynamic keys (like localization) that are already in DB but not in DEFINITIONS + if (! $def && ! $setting) { + continue; + } + + if (! $setting && $def) { + $setting = MobileSetting::create([ + 'key' => $key, + 'group' => $def['group'], + 'type' => $def['type'], + ]); + } + + $newValue = $value; + if ($setting->type === 'boolean') { + $newValue = (filter_var($value, FILTER_VALIDATE_BOOLEAN) || $value === '1' || $value === 'true') ? 'true' : 'false'; + } + + $setting->update(['value' => $newValue]); + } + }); + + $this->clearCache(); + } + + /** + * Clear the cache. + */ + public function clearCache(): void + { + self::clearCacheStatic(); + } + + public static function clearCacheStatic(): void + { + Cache::forget(self::CACHE_KEY); + } + + /** + * Cast raw string value from DB to appropriate PHP type. + */ + private function castValue(mixed $value, string $type): mixed + { + if ($value === null) { + return null; + } + + return match ($type) { + 'boolean' => $value === 'true' || $value === '1' || $value === true, + 'int', 'integer' => (int) $value, + 'json' => json_decode($value, true), + default => $value, + }; + } +} diff --git a/app/Services/Monitoring/MonitoringFormatter.php b/app/Services/Monitoring/MonitoringFormatter.php new file mode 100644 index 0000000..1a31171 --- /dev/null +++ b/app/Services/Monitoring/MonitoringFormatter.php @@ -0,0 +1,60 @@ + 0) { + $parts[] = "{$days}d"; + } + if ($hours > 0) { + $parts[] = "{$hours}h"; + } + if ($minutes > 0) { + $parts[] = "{$minutes}m"; + } + + return count($parts) > 0 ? implode(' ', $parts) : '< 1m'; + } +} diff --git a/app/Services/Monitoring/SystemMonitoringService.php b/app/Services/Monitoring/SystemMonitoringService.php new file mode 100644 index 0000000..0ee13b2 --- /dev/null +++ b/app/Services/Monitoring/SystemMonitoringService.php @@ -0,0 +1,710 @@ +getFormattedRamUsage()['percentage']; + } + + public function getFormattedRamUsage() + { + if (PHP_OS_FAMILY === 'Windows') { + $output = shell_exec('wmic OS get FreePhysicalMemory,TotalVisibleMemorySize /Value'); + if ($output) { + preg_match('/TotalVisibleMemorySize=(\d+)/', $output, $total); + preg_match('/FreePhysicalMemory=(\d+)/', $output, $free); + if (isset($total[1], $free[1])) { + $totalBytes = (float) $total[1] * 1024; + $freeBytes = (float) $free[1] * 1024; + $usedBytes = $totalBytes - $freeBytes; + + return [ + 'total' => MonitoringFormatter::bytes($totalBytes), + 'free' => MonitoringFormatter::bytes($freeBytes), + 'used' => MonitoringFormatter::bytes($usedBytes), + 'percentage' => (int) round(($usedBytes / $totalBytes) * 100), + ]; + } + } + } else { + $info = @file_get_contents('/proc/meminfo'); + if ($info) { + preg_match('/MemTotal:\s+(\d+)/', $info, $total); + preg_match('/MemAvailable:\s+(\d+)/', $info, $available); + preg_match('/SwapTotal:\s+(\d+)/', $info, $swapTotal); + preg_match('/SwapFree:\s+(\d+)/', $info, $swapFree); + + if (isset($total[1], $available[1])) { + $totalBytes = (float) $total[1] * 1024; + $availableBytes = (float) $available[1] * 1024; + $usedBytes = $totalBytes - $availableBytes; + + $swapTotalBytes = isset($swapTotal[1]) ? (float) $swapTotal[1] * 1024 : 0; + $swapFreeBytes = isset($swapFree[1]) ? (float) $swapFree[1] * 1024 : 0; + $swapUsedBytes = $swapTotalBytes - $swapFreeBytes; + + return [ + 'total' => MonitoringFormatter::bytes($totalBytes), + 'free' => MonitoringFormatter::bytes($availableBytes), + 'used' => MonitoringFormatter::bytes($usedBytes), + 'percentage' => (int) round(($usedBytes / $totalBytes) * 100), + 'swap' => [ + 'total' => MonitoringFormatter::bytes($swapTotalBytes), + 'used' => MonitoringFormatter::bytes($swapUsedBytes), + 'percentage' => $swapTotalBytes > 0 ? (int) round(($swapUsedBytes / $swapTotalBytes) * 100) : 0, + ], + ]; + } + } + } + + return [ + 'total' => 'Unknown', + 'free' => 'Unknown', + 'used' => 'Unknown', + 'percentage' => 0, + ]; + } + + // ======================== + // Disk Usage + // ======================== + public function getDiskUsage() + { + return $this->getFormattedDiskUsage()['percentage']; + } + + public function getFormattedDiskUsage() + { + $path = PHP_OS_FAMILY === 'Windows' ? 'C:' : '/'; + $total = @disk_total_space($path); + $free = @disk_free_space($path); + + if (! $total) { + return [ + 'total' => 'Unknown', + 'free' => 'Unknown', + 'used' => 'Unknown', + 'percentage' => 0, + ]; + } + + $used = $total - $free; + + return [ + 'total' => MonitoringFormatter::bytes($total), + 'free' => MonitoringFormatter::bytes($free), + 'used' => MonitoringFormatter::bytes($used), + 'percentage' => (int) round(($used / $total) * 100), + ]; + } + + // ======================== + // Active Users (Laravel Session) + // ======================== + public function getActiveUsers() + { + return Cache::remember('monitoring_active_users', 60, function () { + $driver = config('session.driver'); + $data = ['total' => 0, 'authenticated' => 0]; + + try { + if ($driver === 'database') { + if (! DB::getSchemaBuilder()->hasTable('sessions')) { + return $data; + } + $data['total'] = DB::table('sessions')->count(); + $data['authenticated'] = DB::table('sessions')->whereNotNull('user_id')->count(); + + return $data; + } + + if ($driver === 'redis') { + $connection = config('session.connection') ?? 'default'; + $redis = Redis::connection($connection); + + $sessionCookie = config('session.cookie', 'laravel_session'); + $patterns = [ + $sessionCookie.':*', + str_replace('-', '_', $sessionCookie).':*', + str_replace('_', '-', $sessionCookie).':*', + '*_session:*', + ]; + + $count = 0; + foreach ($patterns as $pattern) { + $cursor = '0'; + do { + $result = $redis->scan($cursor, ['match' => $pattern, 'count' => 100]); + $cursor = $result[0]; + $count += count($result[1]); + } while ($cursor !== '0'); + } + + $data['total'] = $count; + $data['authenticated'] = 'N/A'; // Redis scan doesn't expose user_id easily + + return $data; + } + + if ($driver === 'file') { + $path = config('session.files'); + if (! file_exists($path)) { + return $data; + } + $data['total'] = count(glob($path.'/*')); + $data['authenticated'] = 'N/A'; + + return $data; + } + } catch (\Exception $e) { + return $data; + } + + return $data; + }); + } + + public function getRegisteredUsers() + { + try { + return User::count(); + } catch (\Exception $e) { + return 0; + } + } + + // ======================== + // System Info + // ======================== + public function getPhpVersion() + { + return PHP_VERSION; + } + + public function getDatabaseVersion() + { + try { + return DB::connection()->getPdo()->getAttribute(\PDO::ATTR_SERVER_VERSION); + } catch (\Exception $e) { + return 'Unknown'; + } + } + + public function getServerIp() + { + return request()->server('SERVER_ADDR') ?? gethostbyname(gethostname()); + } + + public function getUptime() + { + if (PHP_OS_FAMILY === 'Windows') { + $output = shell_exec('wmic os get lastbootuptime /value'); + if ($output && preg_match('/LastBootUpTime=(\d+)/', $output, $matches)) { + $bootTime = $matches[1]; // Format: YYYYMMDDHHMMSS.MMMMMM+UUU + $year = substr($bootTime, 0, 4); + $month = substr($bootTime, 4, 2); + $day = substr($bootTime, 6, 2); + $hour = substr($bootTime, 8, 2); + $minute = substr($bootTime, 10, 2); + $second = substr($bootTime, 12, 2); + + $bootTimestamp = strtotime("$year-$month-$day $hour:$minute:$second"); + $diff = time() - $bootTimestamp; + + return MonitoringFormatter::duration($diff); + } + } else { + $uptime = @file_get_contents('/proc/uptime'); + if ($uptime) { + $uptime = explode(' ', $uptime)[0]; + + return MonitoringFormatter::duration((int) $uptime); + } + } + + return 'Unknown'; + } + + // ======================== + // Queue Stats + // ======================== + public function getQueueStats() + { + try { + // Use Cache Heartbeat (updated by WorkerHeartbeatJob) + $lastHeartbeat = Cache::get('queue_worker_heartbeat'); + $workerRunning = $lastHeartbeat && (now()->timestamp - $lastHeartbeat) < 300; // 5 minute threshold + + // Fallback to PS if heartbeat is missing (optional, but keep for robustness) + if (! $workerRunning && PHP_OS_FAMILY !== 'Windows') { + $output = @shell_exec('ps aux | grep "queue:work" | grep -v grep'); + $workerRunning = ! empty($output); + } + + // Throughput Estimation (Activities in last 5 minutes) + $throughput = 0; + if (DB::getSchemaBuilder()->hasTable('activity_log')) { + $throughput = DB::table('activity_log') + ->where('created_at', '>=', now()->subMinutes(5)) + ->count(); + } + + return [ + 'pending' => DB::table('jobs')->count(), + 'failed' => DB::table('failed_jobs')->count(), + 'worker_active' => (bool) $workerRunning, + 'throughput' => $throughput, + 'load_factor' => $throughput > 0 ? round($throughput / 5, 1) : 0, // tasks per minute + ]; + } catch (\Exception $e) { + return [ + 'pending' => 0, + 'failed' => 0, + 'worker_active' => false, + ]; + } + } + + // ======================== + // Database Info + // ======================== + public function getDatabaseInfo() + { + return Cache::remember('monitoring_database_info', 300, function () { + try { + $driver = DB::getDriverName(); + $tables = 0; + $size = 0; + $topTables = []; + + // Performance Check + $start = microtime(true); + DB::select('SELECT 1'); + $latency = (int) round((microtime(true) - $start) * 1000); + + if ($driver === 'pgsql') { + $tableCount = DB::select("SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'"); + $tables = $tableCount[0]->count ?? 0; + + $dbSize = DB::select('SELECT pg_database_size(current_database()) AS size'); + $size = $dbSize[0]->size ?? 0; + + // Technical detail: Top 5 tables by size + $topTables = DB::select(' + SELECT relname AS table, + pg_size_pretty(pg_total_relation_size(relid)) AS size_pretty, + pg_total_relation_size(relid) AS size_bytes + FROM pg_stat_user_tables + ORDER BY pg_total_relation_size(relid) DESC + LIMIT 5 + '); + } else { + $tableCount = DB::select('SHOW TABLES'); + $tables = count($tableCount); + $dbSize = DB::select('SELECT SUM(data_length + index_length) AS size FROM information_schema.TABLES WHERE table_schema = DATABASE()'); + $size = $dbSize[0]->size ?? 0; + } + + return [ + 'tables' => $tables, + 'size' => MonitoringFormatter::bytes($size), + 'size_bytes' => $size, + 'top_tables' => $topTables, + 'latency' => $latency.'ms', + 'status' => $latency < 100 ? 'STABLE' : 'DEGRADED', + ]; + } catch (\Exception $e) { + return [ + 'tables' => 0, + 'size' => 'Unknown', + 'size_bytes' => 0, + 'top_tables' => [], + 'latency' => '0ms', + 'status' => 'OFFLINE', + ]; + } + }); + } + + // ======================== + // Redis Info + // ======================== + public function getRedisStats() + { + try { + $redis = Redis::connection(); + + $start = microtime(true); + $redis->ping(); + $latency = (int) round((microtime(true) - $start) * 1000); + + $info = $redis->info(); + + // Flatten if grouped (Predis often returns sections as sub-arrays) + $flatInfo = []; + foreach ($info as $key => $value) { + if (is_array($value)) { + $flatInfo = array_merge($flatInfo, $value); + } else { + $flatInfo[$key] = $value; + } + } + + return [ + 'status' => 'connected', + 'version' => $flatInfo['redis_version'] ?? 'Unknown', + 'memory_used' => $flatInfo['used_memory_human'] ?? 'Unknown', + 'clients' => $flatInfo['connected_clients'] ?? 0, + 'latency' => $latency.'ms', + 'uptime' => MonitoringFormatter::duration((int) ($flatInfo['uptime_in_seconds'] ?? 0)), + ]; + } catch (\Exception $e) { + return [ + 'status' => 'disconnected', + 'error' => 'Connection failed', + 'version' => 'Unknown', + 'memory_used' => 'Unknown', + 'clients' => 0, + 'latency' => '0ms', + 'uptime' => 'Unknown', + ]; + } + } + + public function getRedisClients() + { + try { + $redis = Redis::connection(); + $clientsRaw = $redis->executeRaw(['CLIENT', 'LIST']); + + if (! is_string($clientsRaw)) { + return ['clients' => [], 'has_reverb' => false]; + } + + $lines = explode("\n", trim($clientsRaw)); + $clients = []; + $hasReverb = false; + + foreach ($lines as $line) { + if (empty($line)) { + continue; + } + $props = []; + foreach (explode(' ', $line) as $prop) { + if (strpos($prop, '=') !== false) { + [$k, $v] = explode('=', $prop, 2); + $props[$k] = $v; + } + } + + // Identify client based on name or source + $addr = $props['addr'] ?? 'Unknown'; + $name = $props['name'] ?: ($props['cmd'] === 'subscribe' ? 'Reverb' : 'Worker'); + + if (strpos($addr, '127.0.0.1') !== false) { + $type = 'Local Node'; + } elseif ($props['cmd'] === 'subscribe') { + $type = 'WebSocket'; + $hasReverb = true; + } else { + $type = 'Background'; + } + + $clients[] = [ + 'id' => $props['id'] ?? '?', + 'name' => $name, + 'type' => $type, + 'addr' => $addr, + 'age' => MonitoringFormatter::duration((int) ($props['age'] ?? 0)), + 'idle' => MonitoringFormatter::duration((int) ($props['idle'] ?? 0)), + 'db' => $props['db'] ?? '0', + 'cmd' => $props['cmd'] ?? 'N/A', + ]; + } + + return [ + 'clients' => $clients, + 'has_reverb' => $hasReverb, + ]; + } catch (\Exception $e) { + return ['clients' => [], 'has_reverb' => false]; + } + } + + public function getQueueDetails() + { + try { + return DB::table('jobs') + ->select('id', 'queue', 'payload', 'available_at') + ->latest('available_at') + ->take(10) + ->get() + ->map(function ($job) { + $payload = json_decode($job->payload); + + return [ + 'id' => $job->id, + 'name' => $payload->displayName ?? 'Unknown Job', + 'queue' => $job->queue, + 'time' => Carbon::createFromTimestamp($job->available_at)->diffForHumans(), + ]; + }); + } catch (\Exception $e) { + return []; + } + } + + // ======================== + // App Health + // ======================== + public function getAppHealth() + { + return [ + 'maintenance' => app()->isDownForMaintenance(), + 'storage_link' => file_exists(public_path('storage')), + 'env_safe' => ! config('app.debug'), // In production, debug should be false + 'cache_active' => $this->checkCache(), + 'reverb_active' => $this->checkReverbConnection(), + 'logs_size' => MonitoringFormatter::bytes(file_exists(storage_path('logs/laravel.log')) ? filesize(storage_path('logs/laravel.log')) : 0), + ]; + } + + private function checkCache() + { + try { + Cache::put('monitoring_check', true, 5); + + return Cache::get('monitoring_check') === true; + } catch (\Exception $e) { + return false; + } + } + + public function checkReverbConnection() + { + $host = config('reverb.servers.reverb.host', '127.0.0.1'); + $port = config('reverb.servers.reverb.port', 8080); + + return Cache::remember('monitoring_reverb_status', 30, function () use ($host, $port) { + // If localhost fails, try 'reverb' container name as fallback for Docker + $targets = [$host]; + if ($host === 'localhost' || $host === '127.0.0.1') { + $targets[] = 'reverb'; + } + + foreach ($targets as $target) { + try { + $connection = @fsockopen($target, $port, $errno, $errstr, 0.2); // Reduced timeout to 0.2s + if (is_resource($connection)) { + fclose($connection); + + return true; + } + } catch (\Exception $e) { + continue; + } + } + + return false; + }); + } + + // ======================== + // Recent Activity + // ======================== + public function getRecentActivity() + { + if (! DB::getSchemaBuilder()->hasTable('activity_log')) { + return []; + } + + return DB::table('activity_log') + ->leftJoin('users', 'activity_log.causer_id', '=', 'users.id') + ->select('activity_log.*', 'users.name as causer_name') + ->latest() + ->take(5) + ->get() + ->map(function ($item) { + return [ + 'description' => ucfirst(str_replace('_', ' ', $item->description)), + 'subject' => $item->subject_type ? class_basename($item->subject_type) : 'System', + 'causer' => $item->causer_name ?? 'System', + 'time' => Carbon::parse($item->created_at)->diffForHumans(), + ]; + }); + } + + // ======================== + // Backup Info + // ======================== + public function getBackupStatus() + { + $backupDisk = config('backup.backup.destination.disks.0', 'local'); + $backupName = config('backup.backup.name', 'laravel-backup'); + + return Cache::remember('monitoring_backup_status', 1800, function () use ($backupDisk, $backupName) { + try { + $files = Storage::disk($backupDisk)->files($backupName); + if (empty($files)) { + return ['last_backup' => 'Never', 'count' => 0]; + } + + $lastFile = end($files); + $timestamp = Storage::disk($backupDisk)->lastModified($lastFile); + + return [ + 'last_backup' => Carbon::createFromTimestamp($timestamp)->diffForHumans(), + 'count' => count($files), + 'latest_file' => basename($lastFile), + ]; + } catch (\Exception $e) { + return ['last_backup' => 'Unknown', 'count' => 0]; + } + }); + } + + // ======================== + // SAP RFC Stats + // ======================== + public function getSapStatus() + { + $possiblePaths = [ + base_path('dev_rfc.trc'), + public_path('dev_rfc.trc'), + storage_path('logs/dev_rfc.trc'), + ]; + + $exists = false; + $size = 0; + foreach ($possiblePaths as $path) { + if (file_exists($path)) { + $exists = true; + $size = filesize($path); + break; + } + } + + return [ + 'active' => $exists, + 'size' => MonitoringFormatter::bytes($size), + 'status' => $exists ? 'TRACKING' : 'IDLE', + ]; + } + + // ======================== + // Mobile Stats + // ======================== + public function getMobileStats() + { + $logFile = storage_path('logs/mobile.log'); + $count = 0; + if (file_exists($logFile)) { + // Optimization: Use shell grep -c to count entries without loading file into memory + $count = (int) shell_exec("grep -c '^\[[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}' ".escapeshellarg($logFile)); + } + + return [ + 'total_logs' => $count, + 'last_activity' => file_exists($logFile) ? Carbon::createFromTimestamp(filemtime($logFile))->diffForHumans() : 'Never', + ]; + } + + // ======================== + // Bundle All Metrics + // ======================== + public function getAll() + { + return Cache::remember('monitoring_full_bundle', 10, function () { + $redisData = $this->getRedisClients(); + + return [ + 'os' => PHP_OS_FAMILY, + 'maintenance' => app()->isDownForMaintenance(), + 'hostname' => gethostname(), + 'ip' => $this->getServerIp(), + 'php' => $this->getPhpVersion(), + 'db' => $this->getDatabaseVersion(), + 'uptime' => $this->getUptime(), + 'cpu' => $this->getCpuUsage(), + 'ram' => $this->getFormattedRamUsage(), + 'disk' => $this->getFormattedDiskUsage(), + 'users' => $this->getActiveUsers(), + 'total_users' => $this->getRegisteredUsers(), + 'queues' => $this->getQueueStats(), + 'db_stats' => $this->getDatabaseInfo(), + 'redis' => $this->getRedisStats(), + 'health' => $this->getAppHealth(), + 'activity' => $this->getRecentActivity(), + 'backup' => $this->getBackupStatus(), + 'sap' => $this->getSapStatus(), + 'mobile' => $this->getMobileStats(), + 'redis_clients' => $redisData['clients'] ?? [], + 'has_reverb' => $redisData['has_reverb'] ?? false, + 'queue_details' => $this->getQueueDetails(), + 'last_update' => now()->format('H:i:s'), + ]; + }); + } +} diff --git a/app/Services/Notification/TelegramService.php b/app/Services/Notification/TelegramService.php new file mode 100644 index 0000000..86fed28 --- /dev/null +++ b/app/Services/Notification/TelegramService.php @@ -0,0 +1,45 @@ + $chatId, + 'text' => $message, + 'parse_mode' => 'HTML', + 'disable_web_page_preview' => true, + ]); + + if ($response->successful()) { + return true; + } + + Log::error('Telegram API Error: '.$response->body()); + + return false; + } catch (\Exception $e) { + Log::error('Telegram Service Exception: '.$e->getMessage()); + + return false; + } + } +} diff --git a/app/Services/System/ActivityFormatter.php b/app/Services/System/ActivityFormatter.php new file mode 100644 index 0000000..7e5ba38 --- /dev/null +++ b/app/Services/System/ActivityFormatter.php @@ -0,0 +1,149 @@ + 'System Config', + 'User' => 'User Profile', + 'Role' => 'Access Role', + 'Permission' => 'Access Permission', + 'MobileSetting' => 'Mobile Config', + ]; + + return $mapping[$className] ?? Str::headline($className); + } + + /** + * Get badge class for the event. + */ + public static function getEventBadgeClass(string $event): string + { + return match (strtolower($event)) { + 'created' => 'text-bg-success', + 'updated' => 'text-bg-warning', + 'deleted' => 'text-bg-danger', + 'restored' => 'text-bg-info', + 'login', 'login_attempt' => 'text-bg-info', + 'logout' => 'text-bg-secondary', + 'password_changed', 'password reset' => 'text-bg-primary', + default => 'text-bg-theme-1', + }; + } + + /** + * Get icon for the event. + */ + public static function getEventIcon(string $event): string + { + return match (strtolower($event)) { + 'created' => 'bi-plus-circle', + 'updated' => 'bi-pencil-square', + 'deleted' => 'bi-trash', + 'restored' => 'bi-arrow-counterclockwise', + 'login', 'login_attempt' => 'bi-box-arrow-in-right', + 'logout' => 'bi-box-arrow-right', + 'password_changed', 'password reset' => 'bi-key', + default => 'bi-info-circle', + }; + } + + /** + * Formats the changes between old and new properties. + */ + public static function formatChanges(array $properties): array + { + $old = $properties['old'] ?? []; + $new = $properties['attributes'] ?? []; + + $changes = []; + + // If it's a "created" event, show all attributes + if (empty($old) && ! empty($new)) { + foreach ($new as $key => $value) { + if (self::isSensitive($key)) { + continue; + } + $changes[] = [ + 'field' => Str::headline($key), + 'old' => null, + 'new' => self::formatValue($value), + ]; + } + + return $changes; + } + + // For updates, show only changed fields + foreach ($new as $key => $value) { + if (self::isSensitive($key)) { + continue; + } + + $oldValue = $old[$key] ?? null; + + // Loose comparison to handle type juggling from DB + if ($oldValue != $value) { + $changes[] = [ + 'field' => Str::headline($key), + 'old' => self::formatValue($oldValue), + 'new' => self::formatValue($value), + ]; + } + } + + return $changes; + } + + private static function isSensitive(string $key): bool + { + $sensitive = ['password', 'remember_token', 'secret', 'key', 'token', '2fa_secret']; + foreach ($sensitive as $s) { + if (str_contains(strtolower($key), $s)) { + return true; + } + } + + return false; + } + + private static function formatValue(mixed $value): string + { + if (is_null($value)) { + return 'NULL'; + } + if (is_bool($value)) { + return $value ? 'TRUE' : 'FALSE'; + } + + if (is_array($value) || is_object($value)) { + return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + if ($value === '') { + return '[empty]'; + } + + // Truncate long values but keep it readable + if (is_string($value) && strlen($value) > 200) { + return substr($value, 0, 197).'...'; + } + + return (string) $value; + } +} diff --git a/app/Services/System/BackupManagementService.php b/app/Services/System/BackupManagementService.php new file mode 100644 index 0000000..dc20603 --- /dev/null +++ b/app/Services/System/BackupManagementService.php @@ -0,0 +1,550 @@ +configService = $configService; + } + + /** + * Apply database settings to Spatie Backup config at runtime. + * + * @param bool $skipNotifications Whether to skip setting up notifications (useful for manual UI backups) + * @param string|null $driver Optional driver to override the database setting + */ + public function applyDynamicConfig(bool $skipNotifications = false, ?string $driver = null) + { + $settings = $this->configService->all(); + + // 1. Storage Disks + $driver = $driver ?? get_setting('backup_db_driver', 'local'); + Config::set('backup.backup.destination.disks', [$driver]); + Config::set('backup.monitor_backups.0.disks', [$driver]); + + // Dynamic Cloud Storage Configuration + if ($driver === 'gdrive') { + Config::set('filesystems.disks.gdrive.clientId', $settings['gdrive_client_id'] ?? ''); + Config::set('filesystems.disks.gdrive.clientSecret', $settings['gdrive_client_secret'] ?? ''); + Config::set('filesystems.disks.gdrive.refreshToken', $settings['gdrive_refresh_token'] ?? ''); + Config::set('filesystems.disks.gdrive.folder', $settings['gdrive_folder'] ?? 'LaravelBackups'); + } + + // 2. Cleanup Policy + $retention = (int) ($settings['backup_db_retention'] ?? 7); + Config::set('backup.cleanup.default_strategy.keep_all_backups_for_days', $retention); + + // 3. Encryption + if (! empty($settings['backup_db_encrypt']) && ! empty($settings['backup_db_encrypt_key'])) { + Config::set('backup.backup.password', $settings['backup_db_encrypt_key']); + Config::set('backup.backup.encryption', 'aes256'); + } else { + Config::set('backup.backup.password', null); + Config::set('backup.backup.encryption', 'none'); + } + + // 4. Notifications + $notifyOn = $settings['backup_db_notify_on'] ?? 'failed'; + $notifyTo = $settings['backup_db_notify_to'] ?? ''; + + $channels = []; + if (! empty($notifyTo)) { + if (filter_var($notifyTo, FILTER_VALIDATE_EMAIL)) { + $channels[] = 'mail'; + Config::set('backup.notifications.mail.to', $notifyTo); + } elseif (str_starts_with($notifyTo, 'http')) { + $channels[] = 'webhook'; + Config::set('backup.notifications.notifications.webhook.url', $notifyTo); + } + } + + if ($skipNotifications || $notifyOn === 'none') { + // Use empty channel arrays per-class (Spatie requires all keys to exist) + $allClasses = [ + BackupHasFailedNotification::class, + UnhealthyBackupWasFoundNotification::class, + CleanupHasFailedNotification::class, + BackupWasSuccessfulNotification::class, + HealthyBackupWasFoundNotification::class, + CleanupWasSuccessfulNotification::class, + ]; + Config::set('backup.notifications.notifications', array_fill_keys($allClasses, [])); + } else { + $map = [ + 'success' => [BackupWasSuccessfulNotification::class], + 'failed' => [BackupHasFailedNotification::class], + 'both' => [ + BackupWasSuccessfulNotification::class, + BackupHasFailedNotification::class, + ], + ]; + + $classes = $map[$notifyOn] ?? $map['failed']; + $newNotifs = []; + foreach ($classes as $class) { + $newNotifs[$class] = $channels; + } + Config::set('backup.notifications.notifications', $newNotifs); + } + + // 5. Exclude Tables (Injected via database config) + $dbDriver = config('database.default'); + if (! empty($settings['backup_db_exclude'])) { + $excluded = array_map('trim', explode(',', $settings['backup_db_exclude'])); + Config::set("database.connections.{$dbDriver}.dump.exclude_tables", $excluded); + } + + // 6. DB-Specific Options (mysqldump / pg_dump) + if ($dbDriver === 'mysql') { + $extraOptions = ['--hex-blob']; + Config::set('database.connections.mysql.dump.add_extra_option', implode(' ', $extraOptions)); + + // Windows/Laragon mysqldump path fix + if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { + $candidates = ['C:/laragon/bin/mysql/mysql-8.0.30-winx64/bin']; + $globMatches = glob('C:/laragon/bin/mysql/*/bin', GLOB_ONLYDIR); + if ($globMatches) { + $candidates = array_merge($candidates, $globMatches); + } + foreach ($candidates as $candidate) { + if (is_dir($candidate) && file_exists($candidate.'/mysqldump.exe')) { + Config::set('database.connections.mysql.dump.dump_binary_path', $candidate); + break; + } + } + } + } elseif ($dbDriver === 'pgsql') { + // Postgres specific dump options + // Spatie pg_dump usually works well with defaults, but we can add path if needed + if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { + $globMatches = glob('C:/laragon/bin/postgresql/*/bin', GLOB_ONLYDIR); + if ($globMatches) { + Config::set('database.connections.pgsql.dump.dump_binary_path', end($globMatches)); + } + } + } + } + + public function getBackupList(bool $forceConfig = true, bool $forceRefresh = false) + { + if ($forceConfig) { + $this->applyDynamicConfig(true); // Don't need notifications just to list files + } + + $cacheKey = 'system.backup_list'; + + // Helper function for the scan logic + $scan = function () { + $config = SpatieConfig::fromArray(config('backup')); + $backupDestinations = BackupDestinationFactory::createFromArray($config); + + $backups = []; + foreach ($backupDestinations as $destination) { + foreach ($destination->backups() as $backup) { + $backups[] = [ + 'name' => $backup->path(), + 'size' => $this->formatBytes($backup->sizeInBytes()), + 'storage' => $destination->diskName(), + 'date' => $backup->date()->format('Y-m-d H:i:s'), + 'timestamp' => $backup->date()->timestamp, + 'status' => 'Success', + ]; + } + } + + // Sort by date descending + usort($backups, fn ($a, $b) => $b['timestamp'] <=> $a['timestamp']); + + return $backups; + }; + + if ($forceRefresh) { + $data = $scan(); + Cache::put($cacheKey, $data, 30); + + return $data; + } + + return Cache::remember($cacheKey, 30, $scan); + } + + /** + * Get storage health statistics. + */ + public function getStorageStats(?string $driver = null) + { + $this->applyDynamicConfig(true, $driver); + $driver = $driver ?? config('backup.backup.destination.disks.0', 'local'); + + $total = 0; + $free = 0; + $used = 0; + $label = 'Local Disk'; + + try { + if ($driver === 'local') { + $total = @disk_total_space(storage_path('app')) ?: 0; + $free = @disk_free_space(storage_path('app')) ?: 0; + $used = $total - $free; + } else { + // For cloud, we might not get total/free easily depending on API + // We'll return just the used size and assume a default total for UI progress (e.g., 15GB for GDrive free) + $backups = $this->getBackupList(forceRefresh: false); + foreach ($backups as $b) { + if ($b['storage'] === $driver) { + $used += $this->parseBytes($b['size']); + } + } + $label = strtoupper($driver); + + // Estimate total for progress bar visibility + $total = $driver === 'gdrive' ? (15 * 1024 * 1024 * 1024) : 0; + $free = max(0, $total - $used); + } + } catch (Exception $e) { + Log::error('Failed to get storage stats: '.$e->getMessage()); + } + + return [ + 'label' => $label, + 'driver' => $driver, + 'total' => $this->formatBytes($total), + 'free' => $this->formatBytes($free), + 'used' => $this->formatBytes($used), + 'percentage' => $total > 0 ? round(($used / $total) * 100, 1) : 0, + 'health' => ($total > 0 && ($free / $total) < 0.1) ? 'danger' : 'success', + 'requirements' => $this->checkRequirements(), + ]; + } + + /** + * Check if system requirements (binaries) for current driver are met. + */ + public function checkRequirements() + { + $dbDriver = config('database.default'); + $binary = $dbDriver === 'pgsql' ? 'pg_dump' : ($dbDriver === 'mysql' ? 'mysqldump' : null); + + if (! $binary) { + return ['status' => true]; + } + + $process = new Process(['which', $binary]); + $process->run(); + + $missing = ! $process->isSuccessful(); + + return [ + 'status' => ! $missing, + 'binary' => $binary, + 'message' => $missing ? __(':bin not found on server. Please install it to enable backups.', ['bin' => $binary]) : null, + ]; + } + + /** + * Test connection to a specific storage disk. + */ + public function testConnection() + { + $this->applyDynamicConfig(true); + $disk = config('backup.backup.destination.disks.0', 'local'); + + try { + $backupName = config('backup.backup.name', 'biiproject'); + + // Pre-create directory for Google Drive to avoid reachability check failure + if ($disk === 'gdrive' && ! Storage::disk($disk)->exists($backupName)) { + Storage::disk($disk)->makeDirectory($backupName); + } + + Storage::disk($disk)->files($backupName); + + return [ + 'success' => true, + 'message' => __('Successfully connected to :disk storage.', ['disk' => strtoupper($disk)]), + ]; + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => __('Connection failed: ').$e->getMessage(), + ]; + } + } + + private function parseBytes(string $value): float + { + return MonitoringFormatter::parseBytes($value); + } + + /** + * Create a new backup. + * + * Uses a child process instead of Artisan::call() to avoid inheriting the + * broken TCP/IP socket context from php artisan serve on Windows. + */ + public function createBackup() + { + $phpBinary = PHP_BINARY; + $artisan = base_path('artisan'); + + // Ensure dynamic config is applied in the current process + $this->applyDynamicConfig(true); + + $disk = config('backup.backup.destination.disks.0', 'local'); + $backupName = config('backup.backup.name', 'biiproject'); + + // Pre-create directory for Google Drive to avoid reachability check failure in child process + if ($disk === 'gdrive') { + try { + if (! Storage::disk($disk)->exists($backupName)) { + Storage::disk($disk)->makeDirectory($backupName); + } + } catch (Exception $e) { + Log::warning('Failed to pre-create GDrive backup directory: '.$e->getMessage()); + } + } + + $dbDriver = config('database.default'); + $binaryPath = config("database.connections.{$dbDriver}.dump.dump_binary_path"); + + $env = getenv(); + + // Prepend binary path to PATH for child process + if ($binaryPath) { + $separator = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' ? ';' : ':'; + $env['PATH'] = $binaryPath.$separator.($env['PATH'] ?? ''); + } + + // Add some basic required env vars if missing + $env['APP_ENV'] = app()->environment(); + $env['APP_KEY'] = config('app.key'); + + $command = array_filter([ + $phpBinary, + $artisan, + 'backup:run', + '--only-db', + '--disable-notifications', + ]); + + $process = new Process($command, base_path(), $env); + $process->setTimeout(300); // 5 minutes max + + Log::info('Backup: starting subprocess — '.implode(' ', $command)); + + $process->run(); + + $stdout = $process->getOutput(); + $stderr = $process->getErrorOutput(); + + Log::info('Backup stdout: '.trim($stdout)); + if ($stderr) { + Log::warning('Backup stderr: '.trim($stderr)); + } + + if (! $process->isSuccessful()) { + $detail = trim($stderr ?: $stdout ?: 'No output captured.'); + + Notification::send( + User::permission('view notification center')->get(), + new SystemManagementNotification('Backup Failed', "Database backup failed: {$detail}", 'warning', 'Developer') + ); + + throw new Exception( + "Backup failed (exit {$process->getExitCode()}): {$detail}" + ); + } + + Notification::send( + User::permission('view notification center')->get(), + new SystemManagementNotification('Backup Successful', 'Automated database backup completed successfully.', 'info', 'Developer') + ); + + // Bust the cache AFTER the file is confirmed written to disk + Cache::forget('system.backup_list'); + + return $stdout; + } + + /** + * Restore from a backup file. + */ + public function restoreBackup($disk, $path) + { + $this->applyDynamicConfig(); + + if (! Storage::disk($disk)->exists($path)) { + throw new Exception("Backup file not found on disk: {$disk}"); + } + + // 1. Enable Maintenance Mode to prevent data corruption + Artisan::call('down', [ + '--refresh' => 15, + '--secret' => 'restore-mode', + '--render' => 'errors::503', + ]); + + try { + // 2. Download/Copy to temp via Stream (Safer for large files) + $tempPath = storage_path('app/temp_restore.zip'); + $diskStream = Storage::disk($disk)->readStream($path); + $localStream = fopen($tempPath, 'w+'); + + if (! $diskStream || ! $localStream) { + throw new Exception('Failed to open streams for restoration.'); + } + + stream_copy_to_stream($diskStream, $localStream); + + fclose($localStream); + if (is_resource($diskStream)) { + fclose($diskStream); + } + + // 3. Extract SQL from Zip (with Zip Slip protection) + $zip = new \ZipArchive; + $sqlFile = null; + $extractBase = realpath(storage_path('app')); + if ($zip->open($tempPath) === true) { + for ($i = 0; $i < $zip->numFiles; $i++) { + $filename = $zip->getNameIndex($i); + if (! str_ends_with($filename, '.sql')) { + continue; + } + // Prevent path traversal + $resolvedPath = $extractBase.DIRECTORY_SEPARATOR.ltrim($filename, '/\\'); + $realResolved = realpath(dirname($resolvedPath)); + if ($realResolved === false || ! str_starts_with($realResolved, $extractBase)) { + $zip->close(); + throw new Exception("Invalid path in backup archive: {$filename}"); + } + $zip->extractTo($extractBase, $filename); + $sqlFile = $extractBase.DIRECTORY_SEPARATOR.$filename; + break; + } + $zip->close(); + } + + if (! $sqlFile || ! file_exists($sqlFile)) { + throw new Exception('Could not find SQL file in the backup archive.'); + } + + // 4. Execute Restore using array-based Process with Streamed Input + $dbDriver = config('database.default'); + $dbConfig = config("database.connections.{$dbDriver}"); + $binPath = config("database.connections.{$dbDriver}.dump.dump_binary_path", ''); + $env = array_merge(getenv(), [ + 'SystemRoot' => getenv('SystemRoot') ?: 'C:\WINDOWS', + 'SystemDrive' => getenv('SystemDrive') ?: 'C:', + 'windir' => getenv('windir') ?: 'C:\WINDOWS', + ]); + + if ($dbDriver === 'mysql') { + $command = [ + $binPath ? rtrim($binPath, '/\\').DIRECTORY_SEPARATOR.'mysql' : 'mysql', + '--host='.($dbConfig['host'] ?? '127.0.0.1'), + '--port='.($dbConfig['port'] ?? '3306'), + '--user='.($dbConfig['username'] ?? ''), + '--password='.($dbConfig['password'] ?? ''), + $dbConfig['database'] ?? '', + ]; + } elseif ($dbDriver === 'pgsql') { + $command = [ + $binPath ? rtrim($binPath, '/\\').DIRECTORY_SEPARATOR.'psql' : 'psql', + '--host='.($dbConfig['host'] ?? '127.0.0.1'), + '--port='.($dbConfig['port'] ?? '5432'), + '--username='.($dbConfig['username'] ?? 'postgres'), + '--dbname='.($dbConfig['database'] ?? ''), + ]; + $env['PGPASSWORD'] = $dbConfig['password'] ?? ''; + } else { + throw new Exception('Restore currently only supports mysql or pgsql drivers.'); + } + + // High-performance streamed restore: open SQL file and pass the handle to Process + $sqlHandle = fopen($sqlFile, 'r'); + $process = new Process($command, base_path(), $env, $sqlHandle); + $process->setTimeout(1200); // 20 minutes for very large DBs + $process->run(); + + if (is_resource($sqlHandle)) { + fclose($sqlHandle); + } + + // Cleanup temp files + @unlink($tempPath); + @unlink($sqlFile); + + if (! $process->isSuccessful()) { + throw new Exception('Restore execution failed: '.($process->getErrorOutput() ?: $process->getOutput() ?: 'Check logs.')); + } + + // 5. Clear Caches & Optimization + Artisan::call('optimize:clear'); + + return true; + + } catch (Exception $e) { + throw $e; + } finally { + // 6. Ensure system comes back UP even if restore failed + Artisan::call('up'); + } + } + + protected function formatBytes(int|float $bytes, int $precision = 2): string + { + return MonitoringFormatter::bytes($bytes, $precision); + } +} diff --git a/app/Services/System/GlobalSearchService.php b/app/Services/System/GlobalSearchService.php new file mode 100644 index 0000000..8f8446b --- /dev/null +++ b/app/Services/System/GlobalSearchService.php @@ -0,0 +1,121 @@ +orWhere('email', 'ILIKE', "%{$query}%") + ->orWhere('username', 'ILIKE', "%{$query}%") + ->take(5) + ->get() + ->map(function ($user) { + return [ + 'title' => $user->name, + 'subtitle' => 'User: '.$user->email, + 'url' => route('users').'?search='.urlencode($user->email), + 'icon' => 'bi-person', + 'category' => 'Users', + ]; + }); + $results = array_merge($results, $users->toArray()); + + // 2. Search Pages (Menus) + $pages = [ + ['title' => 'Dashboard', 'url' => route('dashboard'), 'icon' => 'bi-speedometer2'], + ['title' => 'User Directory', 'url' => route('users'), 'icon' => 'bi-people'], + ['title' => 'Access Rights (Roles)', 'url' => route('roles'), 'icon' => 'bi-shield-lock'], + ['title' => 'Permissions', 'url' => route('permissions'), 'icon' => 'bi-key'], + ['title' => 'Action Logs', 'url' => route('action-logs'), 'icon' => 'bi-journal-text'], + ['title' => 'Notification Center', 'url' => route('notification-center.index'), 'icon' => 'bi-bell'], + ['title' => 'Health & Logs', 'url' => route('system-monitoring'), 'icon' => 'bi-activity'], + ['title' => 'Session Manager', 'url' => route('session-manager'), 'icon' => 'bi-person-badge'], + ['title' => 'Global Settings', 'url' => route('system-config'), 'icon' => 'bi-gear'], + ['title' => 'Mobile Settings', 'url' => route('mobile-settings.index'), 'icon' => 'bi-phone'], + ['title' => 'Backup & Storage', 'url' => route('backup-restore.index'), 'icon' => 'bi-cloud-download'], + ['title' => 'Maintenance Mode', 'url' => route('maintenance-mode'), 'icon' => 'bi-wrench'], + ['title' => 'AI Self-Healing', 'url' => route('ai-self-healing.index'), 'icon' => 'bi-heart-pulse'], + ['title' => 'My Profile', 'url' => route('profile.edit'), 'icon' => 'bi-person-circle'], + ]; + + foreach ($pages as $page) { + if (str_contains(strtolower($page['title']), $query)) { + $page['category'] = 'Pages'; + $page['subtitle'] = 'Navigation'; + $results[] = $page; + } + } + + // 3. Search Settings (from SettingDefinitions) + $settingsResults = []; + foreach (SettingDefinitions::ALL as $key => $meta) { + $title = str_replace('_', ' ', $key); + $description = $meta['description']; + + if (str_contains(strtolower($title), $query) || str_contains(strtolower($description), $query)) { + $settingsResults[] = [ + 'title' => ucwords($title), + 'subtitle' => 'Setting: '.$description, + 'url' => route('system-config').'?anchor='.$meta['group'], + 'icon' => 'bi-sliders', + 'category' => 'Settings', + ]; + } + if (count($settingsResults) >= 10) { + break; + } + } + $results = array_merge($results, $settingsResults); + + // 4. AI Assistance Trigger + if ((str_ends_with($query, '?') || strlen($query) > 10) && auth()->user()->can('use ai assistant')) { + $results[] = [ + 'title' => 'Ask AI Assistant', + 'subtitle' => 'Query: "'.$query.'"', + 'url' => '#ask-ai', + 'icon' => 'bi-robot', + 'category' => 'AI Help', + 'is_ai' => true, + 'query' => $query, + ]; + } + + return $results; + } +} diff --git a/app/Services/System/MaintenanceManagementService.php b/app/Services/System/MaintenanceManagementService.php new file mode 100644 index 0000000..18658b6 --- /dev/null +++ b/app/Services/System/MaintenanceManagementService.php @@ -0,0 +1,203 @@ +configService->all(); + $enabled = filter_var($settings['maintenance_mode_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN); + $isCurrentlyDown = $this->isDown(); + + if ($enabled) { + $this->activateMaintenance($settings); + + // Only notify if it was NOT already down + if (! $isCurrentlyDown) { + Notification::send( + User::permission('view notification center')->get(), + new SystemManagementNotification('Maintenance Active', 'System is entering Maintenance Mode.', 'warning') + ); + } + } else { + $this->deactivateMaintenance(); + + // Only notify if it WAS previously down + if ($isCurrentlyDown) { + Notification::send( + User::permission('view notification center')->get(), + new SystemManagementNotification('System Online', 'System is now LIVE and online.', 'success') + ); + } + } + + return true; + } catch (Exception $e) { + Log::error('Maintenance Mode Sync Error: '.$e->getMessage()); + + return false; + } + } + + /** + * Activate Laravel's native maintenance mode with dynamic parameters. + */ + protected function activateMaintenance(array $settings) + { + $options = []; + + // 1. Secret Bypass URL + if (! empty($settings['maintenance_mode_secret'])) { + $options['--secret'] = $settings['maintenance_mode_secret']; + } + + // 2. Refresh (Browser refresh interval) + $options['--refresh'] = 60; // Default 60 seconds + + // 3. Retry After Header + if (! empty($settings['maintenance_mode_retry'])) { + $options['--retry'] = (int) $settings['maintenance_mode_retry']; + } + + // 4. Allowed IPs + if (! empty($settings['maintenance_mode_allowed_ips'])) { + // Convert textarea lines/commas to array + $ips = preg_split('/[\s,]+/', $settings['maintenance_mode_allowed_ips'], -1, PREG_SPLIT_NO_EMPTY); + if (! empty($ips)) { + $options['--allow'] = $ips; + } + } + + // 5. Status Code (Always 503 for maintenance) + $options['--status'] = 503; + + Log::info('System: Activating Maintenance Mode', $options); + + // Note: Laravel 11+ down command handles these options + Artisan::call('down', $options); + } + + /** + * Deactivate maintenance mode. + */ + protected function deactivateMaintenance() + { + if (app()->isDownForMaintenance()) { + Log::info('System: Deactivating Maintenance Mode'); + Artisan::call('up'); + } + } + + /** + * Check if the system is currently in maintenance mode. + */ + public function isDown(): bool + { + return app()->isDownForMaintenance(); + } + + /** + * Automatically release maintenance mode if the end time has passed. + */ + public function autoCheckAndRelease(): void + { + $settings = $this->configService->all(); + $enabled = filter_var($settings['maintenance_mode_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN); + $endAt = $settings['maintenance_mode_end_at'] ?? null; + + if (! $enabled || empty($endAt)) { + return; + } + + try { + $endTime = new \DateTime($endAt); + $now = new \DateTime; + + if ($now >= $endTime) { + Log::info('System: Maintenance window expired. Automatically deactivating maintenance mode.', [ + 'expired_at' => $endAt, + 'current_time' => $now->format('Y-m-d H:i:s'), + ]); + + // Update configuration in DB + $this->configService->update([ + 'maintenance_mode_enabled' => false, + 'maintenance_mode_end_at' => null, // Optional: Clear the end time + ]); + + // Physical release (artisan up) + $this->syncState(); + } + } catch (Exception $e) { + Log::error('System: Automatic Maintenance Release failed: '.$e->getMessage()); + } + } + + /** + * Broadcast a real-time warning to all active users via WebSockets. + */ + public function broadcastWarning(int $minutes) + { + $message = __('SYSTEM ALERT: The system will enter maintenance mode in :min minutes. Please save your work immediately.', ['min' => $minutes]); + + // 1. Broadcast event for real-time UI notification (WebSockets) + event(new SystemNotification( + message: $message, + type: 'warning', + title: __('Maintenance Warning') + )); + + // 2. Persistent notification in the database + Notification::send( + User::all(), // Notify everyone + new SystemManagementNotification('Scheduled Maintenance', $message, 'warning', 'Developer') + ); + + Log::info("System: Broadcasted maintenance warning (Starting in {$minutes}m)."); + + return true; + } +} diff --git a/app/Services/System/MaintenanceManagementService.php.bak.20260516234440 b/app/Services/System/MaintenanceManagementService.php.bak.20260516234440 new file mode 100644 index 0000000..2670a05 --- /dev/null +++ b/app/Services/System/MaintenanceManagementService.php.bak.20260516234440 @@ -0,0 +1,204 @@ +configService->all(); + $enabled = filter_var($settings['maintenance_mode_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN); + $isCurrentlyDown = $this->isDown(); + + if ($enabled) { + $this->activateMaintenance($settings); + + // Only notify if it was NOT already down + if (! $isCurrentlyDown) { + Notification::send( + User::permission('view notification center')->get(), + new SystemManagementNotification('Maintenance Active', 'System is entering Maintenance Mode.', 'warning') + ); + } + } else { + $this->deactivateMaintenance(); + + // Only notify if it WAS previously down + if ($isCurrentlyDown) { + Notification::send( + User::permission('view notification center')->get(), + new SystemManagementNotification('System Online', 'System is now LIVE and online.', 'success') + ); + } + } + + return true; + } catch (Exception $e) { + Log::error('Maintenance Mode Sync Error: '.$e->getMessage()); + + return false; + } + } + + /** + * Activate Laravel's native maintenance mode with dynamic parameters. + */ + protected function activateMaintenance(array $settings) + { + $options = []; + + // 1. Secret Bypass URL + if (! empty($settings['maintenance_mode_secret'])) { + $options['--secret'] = $settings['maintenance_mode_secret']; + } + + // 2. Refresh (Browser refresh interval) + $options['--refresh'] = 60; // Default 60 seconds + + // 3. Retry After Header + if (! empty($settings['maintenance_mode_retry'])) { + $options['--retry'] = (int) $settings['maintenance_mode_retry']; + } + + // 4. Allowed IPs + if (! empty($settings['maintenance_mode_allowed_ips'])) { + // Convert textarea lines/commas to array + $ips = preg_split('/[\s,]+/', $settings['maintenance_mode_allowed_ips'], -1, PREG_SPLIT_NO_EMPTY); + if (! empty($ips)) { + $options['--allow'] = $ips; + } + } + + // 5. Status Code (Always 503 for maintenance) + $options['--status'] = 503; + + Log::info('System: Activating Maintenance Mode', $options); + + // Note: Laravel 11+ down command handles these options + Artisan::call('down', $options); + } + + /** + * Deactivate maintenance mode. + */ + protected function deactivateMaintenance() + { + if (app()->isDownForMaintenance()) { + Log::info('System: Deactivating Maintenance Mode'); + Artisan::call('up'); + } + } + + /** + * Check if the system is currently in maintenance mode. + */ + public function isDown(): bool + { + return app()->isDownForMaintenance(); + } + + /** + * Automatically release maintenance mode if the end time has passed. + */ + public function autoCheckAndRelease(): void + { + $settings = $this->configService->all(); + $enabled = filter_var($settings['maintenance_mode_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN); + $endAt = $settings['maintenance_mode_end_at'] ?? null; + + if (! $enabled || empty($endAt)) { + return; + } + + try { + $endTime = new \DateTime($endAt); + $now = new \DateTime; + + if ($now >= $endTime) { + Log::info('System: Maintenance window expired. Automatically deactivating maintenance mode.', [ + 'expired_at' => $endAt, + 'current_time' => $now->format('Y-m-d H:i:s'), + ]); + + // Update configuration in DB + $this->configService->update([ + 'maintenance_mode_enabled' => false, + 'maintenance_mode_end_at' => null, // Optional: Clear the end time + ]); + + // Physical release (artisan up) + $this->syncState(); + } + } catch (Exception $e) { + Log::error('System: Automatic Maintenance Release failed: '.$e->getMessage()); + } + } + + /** + * Broadcast a real-time warning to all active users via WebSockets. + */ + public function broadcastWarning(int $minutes) + { + $message = __('SYSTEM ALERT: The system will enter maintenance mode in :min minutes. Please save your work immediately.', ['min' => $minutes]); + + // 1. Broadcast event for real-time UI notification (WebSockets) + event(new SystemNotification( + title: __('Maintenance Warning'), + message: $message, + type: 'warning', + user_id: null // Broadcast to all + )); + + // 2. Persistent notification in the database + Notification::send( + User::all(), // Notify everyone + new SystemManagementNotification('Scheduled Maintenance', $message, 'warning', 'Developer') + ); + + Log::info("System: Broadcasted maintenance warning (Starting in {$minutes}m)."); + + return true; + } +} diff --git a/app/Services/SystemConfig/SettingDefinitions.php b/app/Services/SystemConfig/SettingDefinitions.php new file mode 100644 index 0000000..a494fa5 --- /dev/null +++ b/app/Services/SystemConfig/SettingDefinitions.php @@ -0,0 +1,1047 @@ + [ + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'default' => 'Laravel', + 'description' => 'Application name', + ], + 'enable_landing_page' => [ + 'type' => 'bool', + 'group' => 'branding', + 'is_public' => true, + 'default' => true, + 'description' => 'Enable/Disable the landing page (welcome page)', + ], + 'app_tagline' => [ + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'default' => '', + 'description' => 'Primary tagline', + ], + 'app_tagline1' => [ + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'default' => '', + 'description' => 'Secondary tagline', + ], + 'app_tagline2' => [ + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'default' => '', + 'description' => 'Description text', + ], + 'footer_text' => [ + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'default' => '', + 'description' => 'Footer text', + ], + 'default_locale' => [ + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'default' => 'en', + 'description' => 'Default language', + ], + 'instance_mode' => [ + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'default' => 'Production', + 'description' => 'Environment/Instance Mode', + ], + 'app_logo' => [ + 'type' => 'image_path', + 'group' => 'branding', + 'is_public' => true, + 'default' => null, + 'description' => 'Application logo path', + ], + 'app_favicon' => [ + 'type' => 'image_path', + 'group' => 'branding', + 'is_public' => true, + 'default' => null, + 'description' => 'Application favicon path', + ], + 'regional_timezone' => [ + 'type' => 'string', + 'group' => 'regional', + 'is_public' => true, + 'default' => 'Asia/Jakarta', + 'description' => 'System default timezone', + ], + 'regional_date_format' => [ + 'type' => 'string', + 'group' => 'regional', + 'is_public' => true, + 'default' => 'd/m/Y', + 'description' => 'Date display format', + ], + 'regional_time_format' => [ + 'type' => 'string', + 'group' => 'regional', + 'is_public' => true, + 'default' => 'H:i', + 'description' => 'Time display format (12/24 hour)', + ], + 'feature_notification_center' => [ + 'type' => 'bool', + 'group' => 'feature_flags', + 'is_public' => false, + 'default' => true, + 'description' => 'Toggle notification center feature', + ], + 'feature_google_oauth' => [ + 'type' => 'bool', + 'group' => 'feature_flags', + 'is_public' => false, + 'default' => false, + 'description' => 'Toggle Google OAuth login', + ], + 'google_client_id' => [ + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'default' => '', + 'description' => 'Google Client ID', + ], + 'google_client_secret' => [ + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'default' => '', + 'description' => 'Google Client Secret', + ], + 'feature_facebook_oauth' => [ + 'type' => 'bool', + 'group' => 'feature_flags', + 'is_public' => false, + 'default' => false, + 'description' => 'Toggle Facebook OAuth login', + ], + 'facebook_app_id' => [ + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'default' => '', + 'description' => 'Facebook App ID', + ], + 'facebook_app_secret' => [ + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'default' => '', + 'description' => 'Facebook App Secret', + ], + 'feature_github_oauth' => [ + 'type' => 'bool', + 'group' => 'feature_flags', + 'is_public' => false, + 'default' => false, + 'description' => 'Toggle GitHub OAuth login', + ], + 'github_client_id' => [ + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'default' => '', + 'description' => 'GitHub Client ID', + ], + 'github_client_secret' => [ + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'default' => '', + 'description' => 'GitHub Client Secret', + ], + 'social_login_callback_url' => [ + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'default' => '/auth/callback', + 'description' => 'Global Social Login Callback URL', + ], + // LOGIN SECURITY + 'login_max_attempts' => [ + 'type' => 'int', + 'group' => 'login_security', + 'is_public' => false, + 'default' => 5, + 'description' => 'Maximum Login Attempts', + ], + 'login_lockout_duration' => [ + 'type' => 'int', + 'group' => 'login_security', + 'is_public' => false, + 'default' => 15, + 'description' => 'Lockout Duration (Minutes)', + ], + 'login_lockout_notify' => [ + 'type' => 'bool', + 'group' => 'login_security', + 'is_public' => false, + 'default' => true, + 'description' => 'Notify upon Lockout', + ], + 'two_factor_auth' => [ + 'type' => 'bool', + 'group' => 'login_security', + 'is_public' => false, + 'default' => false, + 'description' => 'Enable 2FA', + ], + 'two_factor_method' => [ + 'type' => 'string', + 'group' => 'login_security', + 'is_public' => false, + 'default' => 'email', + 'description' => '2FA Method', + ], + 'captcha_enabled' => [ + 'type' => 'bool', + 'group' => 'login_security', + 'is_public' => true, + 'default' => false, + 'description' => 'Enable Captcha', + ], + 'captcha_site_key' => [ + 'type' => 'string', + 'group' => 'login_security', + 'is_public' => true, + 'default' => '', + 'description' => 'reCAPTCHA Site Key', + ], + 'captcha_secret_key' => [ + 'type' => 'string', + 'group' => 'login_security', + 'is_public' => false, + 'default' => '', + 'description' => 'reCAPTCHA Secret', + ], + 'captcha_version' => [ + 'type' => 'string', + 'group' => 'login_security', + 'is_public' => true, + 'default' => 'v2', + 'description' => 'reCAPTCHA Version', + ], + 'login_log_enabled' => [ + 'type' => 'bool', + 'group' => 'login_security', + 'is_public' => false, + 'default' => true, + 'description' => 'Log Login Activity', + ], + + // PASSWORD POLICY + 'password_min_length' => [ + 'type' => 'int', + 'group' => 'password_policy', + 'is_public' => true, + 'default' => 8, + 'description' => 'Minimum Password Length', + ], + 'password_max_length' => [ + 'type' => 'int', + 'group' => 'password_policy', + 'is_public' => true, + 'default' => 64, + 'description' => 'Maximum Password Length', + ], + 'password_require_uppercase' => [ + 'type' => 'bool', + 'group' => 'password_policy', + 'is_public' => true, + 'default' => false, + 'description' => 'Require Uppercase Letters', + ], + 'password_require_lowercase' => [ + 'type' => 'bool', + 'group' => 'password_policy', + 'is_public' => true, + 'default' => false, + 'description' => 'Require Lowercase Letters', + ], + 'password_require_numeric' => [ + 'type' => 'bool', + 'group' => 'password_policy', + 'is_public' => true, + 'default' => false, + 'description' => 'Require Numbers', + ], + 'password_require_special' => [ + 'type' => 'bool', + 'group' => 'password_policy', + 'is_public' => true, + 'default' => false, + 'description' => 'Require Symbols / Special Characters', + ], + 'password_expiry_days' => [ + 'type' => 'int', + 'group' => 'password_policy', + 'is_public' => false, + 'default' => 0, // 0 = Never expire + 'description' => 'Password Validity (Days)', + ], + 'password_history_count' => [ + 'type' => 'int', + 'group' => 'password_policy', + 'is_public' => false, + 'default' => 0, // 0 = No history + 'description' => 'Prevent Password Reuse (Count)', + ], + 'password_reset_link_expiry' => [ + 'type' => 'int', + 'group' => 'password_policy', + 'is_public' => false, + 'default' => 60, + 'description' => 'Password Reset Link Expiry (Minutes)', + ], + + // SESSION SECURITY + 'session_driver' => [ + 'type' => 'string', + 'group' => 'session_security', + 'is_public' => false, + 'default' => 'redis', + 'description' => 'Session Driver (file/redis/database)', + ], + 'session_lifetime' => [ + 'type' => 'int', + 'group' => 'session_security', + 'is_public' => false, + 'default' => 120, + 'description' => 'Session Lifetime / Idle Timeout (Minutes)', + ], + 'session_single_session' => [ + 'type' => 'bool', + 'group' => 'session_security', + 'is_public' => false, + 'default' => false, + 'description' => 'Only allow 1 active session per user', + ], + 'session_auto_logout_idle' => [ + 'type' => 'int', + 'group' => 'session_security', + 'is_public' => false, + 'default' => 30, + 'description' => 'Auto Logout Idle (Minutes)', + ], + 'session_allow_remember_me' => [ + 'type' => 'bool', + 'group' => 'session_security', + 'is_public' => true, + 'default' => true, + 'description' => 'Allow Remember Me on Login', + ], + 'session_remember_me_duration' => [ + 'type' => 'int', + 'group' => 'session_security', + 'is_public' => false, + 'default' => 30, + 'description' => 'Remember Me Duration (Days)', + ], + 'session_secure_cookie' => [ + 'type' => 'bool', + 'group' => 'session_security', + 'is_public' => true, + 'default' => false, + 'description' => 'Secure Cookie (HTTPS Only)', + ], + 'session_encrypt' => [ + 'type' => 'bool', + 'group' => 'session_security', + 'is_public' => false, + 'default' => false, + 'description' => 'Encrypt Session Data', + ], + 'session_concurrent_limit' => [ + 'type' => 'int', + 'group' => 'session_security', + 'is_public' => false, + 'default' => 0, // 0 = Unlimited + 'description' => 'Concurrent Session Limit (Per User)', + ], + + // 2FA ENHANCEMENT + 'two_factor_trust_days' => [ + 'type' => 'int', + 'group' => 'login_security', + 'is_public' => false, + 'default' => 30, + 'description' => 'Remember Trusted Devices (Days)', + ], + + // WEBAUTHN (PASSKEYS) + 'webauthn_enabled' => [ + 'type' => 'bool', + 'group' => 'login_security', + 'is_public' => true, + 'default' => false, + 'description' => 'Enable Passkeys (Biometric/FIDO2)', + ], + + // IP & ACCESS CONTROL + 'ip_whitelist_admin' => [ + 'type' => 'string', + 'group' => 'ip_access', + 'is_public' => false, + 'default' => '', + 'description' => 'Admin IP Whitelist (Comma separated)', + ], + 'ip_blacklist' => [ + 'type' => 'string', + 'group' => 'ip_access', + 'is_public' => false, + 'default' => '', + 'description' => 'Global IP Blacklist (Comma separated)', + ], + 'rate_limit_per_ip' => [ + 'type' => 'int', + 'group' => 'ip_access', + 'is_public' => false, + 'default' => 60, + 'description' => 'Max requests per minute per IP', + ], + 'auto_block_ip' => [ + 'type' => 'bool', + 'group' => 'ip_access', + 'is_public' => false, + 'default' => false, + 'description' => 'Automatically block suspicious IPs', + ], + 'threshold_auto_block' => [ + 'type' => 'int', + 'group' => 'ip_access', + 'is_public' => false, + 'default' => 100, + 'description' => 'Hits threshold before auto-block', + ], + 'force_https' => [ + 'type' => 'bool', + 'group' => 'ip_access', + 'is_public' => true, + 'default' => false, + 'description' => 'Force HTTPS redirection', + ], + 'hsts_enabled' => [ + 'type' => 'bool', + 'group' => 'ip_access', + 'is_public' => true, + 'default' => false, + 'description' => 'Enable HTTP Strict Transport Security', + ], + 'cors_origins' => [ + 'type' => 'string', + 'group' => 'ip_access', + 'is_public' => false, + 'default' => '*', + 'description' => 'Allowed CORS Origins', + ], + 'cors_methods' => [ + 'type' => 'string', + 'group' => 'ip_access', + 'is_public' => false, + 'default' => '*', + 'description' => 'Allowed CORS Methods', + ], + 'cors_headers' => [ + 'type' => 'string', + 'group' => 'ip_access', + 'is_public' => false, + 'default' => '*', + 'description' => 'Allowed CORS Headers', + ], + + // NOTIFICATIONS (EMAIL) + 'mail_driver' => [ + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'default' => 'smtp', + 'description' => 'Email Driver (smtp, mailgun, ses)', + ], + 'mail_host' => [ + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'default' => '', + 'description' => 'SMTP Host Server', + ], + 'mail_port' => [ + 'type' => 'int', + 'group' => 'notifications', + 'is_public' => false, + 'default' => 587, + 'description' => 'SMTP Port (587/465/25)', + ], + 'mail_username' => [ + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'default' => '', + 'description' => 'SMTP Username', + ], + 'mail_password' => [ + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'default' => '', + 'description' => 'SMTP Password', + ], + 'mail_encryption' => [ + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'default' => 'tls', + 'description' => 'Encryption (tls/ssl/null)', + ], + 'mail_from_address' => [ + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'default' => 'noreply@example.com', + 'description' => 'Sender Email Address', + ], + 'mail_from_name' => [ + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'default' => 'System Notification', + 'description' => 'Sender Display Name', + ], + + 'telegram_bot_token' => [ + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'default' => '', + 'description' => 'Telegram Bot Token', + ], + + 'telegram_chat_id' => [ + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'default' => '', + 'description' => 'Default Telegram Chat ID', + ], + + // JADWAL BACKUP + 'backup_db_enabled' => [ + 'type' => 'bool', + 'group' => 'backups', + 'is_public' => false, + 'default' => true, + 'description' => 'Enable automated database backup', + ], + 'backup_db_driver' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => 'local', + 'description' => 'Storage Driver (local/s3/gdrive)', + ], + 'backup_db_frequency' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => 'daily', + 'description' => 'Backup Frequency', + ], + 'backup_db_time' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => '02:00', + 'description' => 'Execution Time', + ], + 'backup_db_retention' => [ + 'type' => 'int', + 'group' => 'backups', + 'is_public' => false, + 'default' => 7, + 'description' => 'Retention Policy (Keep last N)', + ], + 'backup_db_compress' => [ + 'type' => 'bool', + 'group' => 'backups', + 'is_public' => false, + 'default' => true, + 'description' => 'Compress with gzip', + ], + 'backup_db_encrypt' => [ + 'type' => 'bool', + 'group' => 'backups', + 'is_public' => false, + 'default' => false, + 'description' => 'AES-256 Encryption', + ], + 'backup_db_encrypt_key' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => '', + 'description' => 'Encryption Key (min 32 chars)', + ], + 'backup_db_exclude' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => '', + 'description' => 'Excluded Tables (comma separated)', + ], + 'backup_db_notify_on' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => 'failed', + 'description' => 'Notification Trigger (success/failed/both)', + ], + 'backup_db_notify_to' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => '', + 'description' => 'Notification Target (Email/Webhook)', + ], + 'gdrive_client_id' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => '', + 'description' => 'Google Drive Client ID', + ], + 'gdrive_client_secret' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => '', + 'description' => 'Google Drive Client Secret', + ], + 'gdrive_refresh_token' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => '', + 'description' => 'Google Drive Refresh Token', + ], + 'gdrive_folder' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => 'LaravelBackups', + 'description' => 'Google Drive Folder Name', + ], + 's3_key' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => '', + 'description' => 'S3 Access Key', + ], + 's3_secret' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => '', + 'description' => 'S3 Secret Key', + ], + 's3_region' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => 'us-east-1', + 'description' => 'S3 Region', + ], + 's3_bucket' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => '', + 'description' => 'S3 Bucket Name', + ], + 's3_endpoint' => [ + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'default' => '', + 'description' => 'S3 Custom Endpoint (Optional)', + ], + + // MAINTENANCE MODE + 'maintenance_mode_enabled' => [ + 'type' => 'bool', + 'group' => 'maintenance', + 'is_public' => false, + 'default' => false, + 'description' => 'Enable maintenance mode', + ], + 'maintenance_mode_message' => [ + 'type' => 'string', + 'group' => 'maintenance', + 'is_public' => true, + 'default' => 'The system is undergoing routine optimization to improve service. We will be back online shortly.', + 'description' => 'Maintenance message', + ], + 'maintenance_mode_title' => [ + 'type' => 'string', + 'group' => 'maintenance', + 'is_public' => true, + 'default' => 'Scheduled System Maintenance', + 'description' => 'Maintenance page title', + ], + 'maintenance_mode_secret' => [ + 'type' => 'string', + 'group' => 'maintenance', + 'is_public' => false, + 'default' => '', + 'description' => 'Bypass secret key', + ], + 'maintenance_mode_allowed_ips' => [ + 'type' => 'string', + 'group' => 'maintenance', + 'is_public' => false, + 'default' => '', + 'description' => 'Whitelisted IP addresses', + ], + 'maintenance_mode_end_at' => [ + 'type' => 'string', + 'group' => 'maintenance', + 'is_public' => true, + 'default' => '', + 'description' => 'Estimated end time', + ], + 'maintenance_mode_retry' => [ + 'type' => 'int', + 'group' => 'maintenance', + 'is_public' => false, + 'default' => 3600, + 'description' => 'Retry-After seconds', + ], + 'maintenance_mode_image' => [ + 'type' => 'image_path', + 'group' => 'maintenance', + 'is_public' => true, + 'default' => null, + 'description' => 'Maintenance illustration image', + ], + + // CONTENT & LEGAL (UU PDP COMPLIANCE) + 'page_help_content' => [ + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'default' => '', + 'description' => 'Help Center / FAQ Content', + ], + 'page_tos_content' => [ + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'default' => '', + 'description' => 'Terms of Use Content', + ], + 'page_privacy_content' => [ + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'default' => '', + 'description' => 'Privacy Policy (UU PDP) Content', + ], + 'page_about_content' => [ + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'default' => '', + 'description' => 'About Us Content', + ], + 'page_security_content' => [ + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'default' => '', + 'description' => 'Security Policy Content', + ], + 'require_pdp_on_registration' => [ + 'type' => 'bool', + 'group' => 'content_legal', + 'is_public' => false, + 'default' => true, + 'description' => 'Make PDP agreement mandatory during sign-up', + ], + 'feature_cookie_banner' => [ + 'type' => 'bool', + 'group' => 'content_legal', + 'is_public' => true, + 'default' => true, + 'description' => 'Enable Cookie Consent Banner', + ], + 'pdp_document_version' => [ + 'type' => 'int', + 'group' => 'content_legal', + 'is_public' => true, + 'default' => 1, + 'description' => 'Current version of legal documents (increment to force re-agreement)', + ], + 'tos_document_version' => [ + 'type' => 'int', + 'group' => 'content_legal', + 'is_public' => true, + 'default' => 1, + 'description' => 'Current version of Terms of Service', + ], + + 'pdp_dpo_email' => [ + 'type' => 'string', + 'group' => 'content_legal', + 'is_public' => true, + 'default' => '', + 'description' => 'Contact email for Data Protection Officer (PDP)', + ], + 'pdp_company_address' => [ + 'type' => 'string', + 'group' => 'content_legal', + 'is_public' => true, + 'default' => '', + 'description' => 'Official company address for legal documentation', + ], + + // AI CONFIGURATION + 'ai_enabled' => [ + 'type' => 'bool', + 'group' => 'ai_config', + 'is_public' => false, + 'default' => false, + 'description' => 'Enable AI services', + ], + 'ai_provider' => [ + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'default' => 'gpt', + 'description' => 'Active AI provider', + ], + 'ai_gpt_key' => [ + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'default' => '', + 'description' => 'OpenAI GPT API Key', + ], + 'ai_gemini_key' => [ + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'default' => '', + 'description' => 'Google Gemini API Key', + ], + 'ai_claude_key' => [ + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'default' => '', + 'description' => 'Anthropic Claude API Key', + ], + 'ai_deepseek_key' => [ + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'default' => '', + 'description' => 'DeepSeek API Key', + ], + 'ai_grok_key' => [ + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'default' => '', + 'description' => 'xAI Grok API Key', + ], + 'ai_mistral_key' => [ + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'default' => '', + 'description' => 'Mistral AI API Key', + ], + 'ai_openrouter_key' => [ + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'default' => '', + 'description' => 'OpenRouter API Key', + ], + 'ai_ollama_base_url' => [ + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'default' => 'http://localhost:11434', + 'description' => 'Ollama Base URL', + ], + 'ai_default_model' => [ + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'default' => 'gpt-4o', + 'description' => 'Default AI model', + ], + 'ai_system_instruction' => [ + 'type' => 'text', + 'group' => 'ai_config', + 'is_public' => false, + 'default' => '', + 'description' => 'Default system instruction', + ], + 'ai_temperature' => [ + 'type' => 'float', + 'group' => 'ai_config', + 'is_public' => false, + 'default' => 0.7, + 'description' => 'AI temperature', + ], + 'ai_max_tokens' => [ + 'type' => 'int', + 'group' => 'ai_config', + 'is_public' => false, + 'default' => 2000, + 'description' => 'AI max tokens', + ], + + // SAP RFC Configuration + 'sap_rfc_ashost' => [ + 'type' => 'string', + 'group' => 'sap_integration', + 'is_public' => false, + 'default' => '', + 'description' => 'SAP Application Server Host', + ], + 'sap_rfc_sysnr' => [ + 'type' => 'string', + 'group' => 'sap_integration', + 'is_public' => false, + 'default' => '00', + 'description' => 'SAP System Number', + ], + 'sap_rfc_client' => [ + 'type' => 'string', + 'group' => 'sap_integration', + 'is_public' => false, + 'default' => '100', + 'description' => 'SAP Client Number', + ], + 'sap_rfc_user' => [ + 'type' => 'string', + 'group' => 'sap_integration', + 'is_public' => false, + 'default' => '', + 'description' => 'SAP Username', + ], + 'sap_rfc_passwd' => [ + 'type' => 'string', + 'group' => 'sap_integration', + 'is_public' => false, + 'default' => '', + 'description' => 'SAP Password', + ], + 'sap_rfc_router' => [ + 'type' => 'string', + 'group' => 'sap_integration', + 'is_public' => false, + 'default' => '', + 'description' => 'SAP Router string (optional)', + ], + 'sap_rfc_trace' => [ + 'type' => 'string', + 'group' => 'sap_integration', + 'is_public' => false, + 'default' => '0', + 'description' => 'SAP RFC Trace Level', + ], + + // MONITORING ENGINE + 'engine_pulse_enabled' => [ + 'type' => 'bool', + 'group' => 'monitoring', + 'is_public' => false, + 'default' => true, + 'description' => 'Enable Laravel Pulse recording and dashboard', + ], + 'engine_telescope_enabled' => [ + 'type' => 'bool', + 'group' => 'monitoring', + 'is_public' => false, + 'default' => true, + 'description' => 'Enable Laravel Telescope recording and dashboard', + ], + 'engine_swagger_enabled' => [ + 'type' => 'bool', + 'group' => 'monitoring', + 'is_public' => false, + 'default' => true, + 'description' => 'Enable API Documentation (L5-Swagger)', + ], + 'engine_horizon_enabled' => [ + 'type' => 'bool', + 'group' => 'monitoring', + 'is_public' => false, + 'default' => true, + 'description' => 'Enable Laravel Horizon (Redis Queue Monitor)', + ], + + // AI HEALING ENGINE + 'ai_healing_enabled' => [ + 'type' => 'bool', + 'group' => 'ai_healing', + 'is_public' => false, + 'default' => false, + 'description' => 'Enable AI Self-Healing Engine (Intercept & Fix Exceptions)', + ], + 'ai_healing_allow_cache' => [ + 'type' => 'bool', + 'group' => 'ai_healing', + 'is_public' => false, + 'default' => true, + 'description' => 'Allow AI to clear cache', + ], + 'ai_healing_allow_queue' => [ + 'type' => 'bool', + 'group' => 'ai_healing', + 'is_public' => false, + 'default' => true, + 'description' => 'Allow AI to restart queue workers', + ], + 'ai_healing_allow_maintenance' => [ + 'type' => 'bool', + 'group' => 'ai_healing', + 'is_public' => false, + 'default' => false, + 'description' => 'Allow AI to toggle maintenance mode', + ], + 'ai_healing_allow_db' => [ + 'type' => 'bool', + 'group' => 'ai_healing', + 'is_public' => false, + 'default' => false, + 'description' => 'Allow AI to run migrations (Caution)', + ], + 'ai_healing_max_attempts_per_hour' => [ + 'type' => 'int', + 'group' => 'ai_healing', + 'is_public' => false, + 'default' => 5, + 'description' => 'Maximum auto-fix attempts per hour (Circuit Breaker)', + ], + ]; +} diff --git a/app/Services/SystemConfig/SettingFileUploader.php b/app/Services/SystemConfig/SettingFileUploader.php new file mode 100644 index 0000000..1d3a3cf --- /dev/null +++ b/app/Services/SystemConfig/SettingFileUploader.php @@ -0,0 +1,34 @@ + 'assets/img/logo.png', + 'app_favicon' => 'assets/img/favicon.png', + 'maintenance_mode_image' => 'assets/img/maintenance.png', + ]; + + public function replace(string $key, UploadedFile $file, mixed $oldValue): ?string + { + $dir = public_path('assets/img'); + + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + if (isset(self::FIXED_PATHS[$key])) { + $filename = basename(self::FIXED_PATHS[$key]); + $file->move($dir, $filename); + + return self::FIXED_PATHS[$key]; + } + + return $file->store('uploads/settings', 'public'); + } +} diff --git a/app/Services/SystemConfig/SettingValueCaster.php b/app/Services/SystemConfig/SettingValueCaster.php new file mode 100644 index 0000000..2998690 --- /dev/null +++ b/app/Services/SystemConfig/SettingValueCaster.php @@ -0,0 +1,57 @@ + filter_var($value, FILTER_VALIDATE_BOOL), + 'int' => $value === null || $value === '' ? null : (int) $value, + 'float' => $value === null || $value === '' ? null : (float) $value, + 'json' => is_array($value) ? $value : (json_decode((string) $value, true) ?: []), + 'image_path' => is_string($value) ? trim($value) : null, + default => $value === null ? null : trim((string) $value), + }; + } + + public static function serialize(mixed $value): ?string + { + if ($value === null) { + return null; + } + + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + if (is_array($value)) { + return json_encode($value, JSON_UNESCAPED_SLASHES); + } + + return (string) $value; + } + + public static function deserialize(?string $value, string $type): mixed + { + if ($value === null) { + return null; + } + + return match ($type) { + 'bool' => $value === '1', + 'int' => (int) $value, + 'float' => (float) $value, + 'json' => json_decode($value, true) ?: [], + default => $value, + }; + } + + public static function isUnchanged(mixed $oldValue, mixed $newValue): bool + { + return self::serialize($oldValue) === self::serialize($newValue); + } +} diff --git a/app/Services/SystemConfig/SystemConfigService.php b/app/Services/SystemConfig/SystemConfigService.php new file mode 100644 index 0000000..9ac1aae --- /dev/null +++ b/app/Services/SystemConfig/SystemConfigService.php @@ -0,0 +1,225 @@ +invalidateCache(); + } + + if (self::$resolvedSettings !== null) { + return self::$resolvedSettings; + } + + try { + return self::$resolvedSettings = Cache::remember( + self::CACHE_KEY, + now()->addMinutes(self::CACHE_TTL_MINUTES), + fn () => $this->resolveAll(), + ); + } catch (\Exception) { + return self::$resolvedSettings = $this->resolveAll(); + } + } + + public function getPublicSettings(): array + { + try { + return Cache::remember( + self::PUBLIC_CACHE_KEY, + now()->addMinutes(self::CACHE_TTL_MINUTES), + fn () => $this->buildPublicSettings(), + ); + } catch (\Exception) { + return $this->buildPublicSettings(); + } + } + + public function grouped(): array + { + $all = $this->all(); + $grouped = []; + + foreach (SettingDefinitions::ALL as $key => $meta) { + $grouped[$meta['group']][$key] = $all[$key] ?? $meta['default'] ?? null; + } + + return $grouped; + } + + public function get(string $key, mixed $default = null): mixed + { + if (! array_key_exists($key, SettingDefinitions::ALL)) { + return $default; + } + + return $this->all()[$key] ?? $default; + } + + public function update(array $input, array $files = [], ?int $actorId = null, ?Request $request = null): void + { + $current = $this->all(); + + foreach (SettingDefinitions::ALL as $key => $meta) { + $oldValue = $current[$key] ?? $meta['default'] ?? null; + $newValue = $this->resolveNewValue($key, $meta, $input, $files, $oldValue); + + if (SettingValueCaster::isUnchanged($oldValue, $newValue)) { + continue; + } + + $setting = $this->repository->upsert([ + 'key' => $key, + 'value' => SettingValueCaster::serialize($newValue), + 'type' => $meta['type'], + 'group' => $meta['group'], + 'is_public' => (bool) $meta['is_public'], + 'description' => $meta['description'], + 'created_by' => $actorId, + 'updated_by' => $actorId, + ]); + + $this->writeRevision($setting->id, $key, $oldValue, $newValue, $actorId, $request); + } + + $this->invalidateCache(); + } + + protected function resolveNewValue(string $key, array $meta, array $input, array $files, mixed $oldValue): mixed + { + if ($meta['type'] === 'image_path') { + return isset($files[$key]) + ? $this->fileUploader->replace($key, $files[$key], $oldValue) + : $oldValue; + } + + return array_key_exists($key, $input) + ? SettingValueCaster::normalize($input[$key], (string) $meta['type']) + : $oldValue; + } + + protected function resolveAll(): array + { + try { + if (! $this->repository->tableExists()) { + return $this->definitionDefaults(); + } + $rows = $this->repository->all(); + } catch (\Exception) { + return $this->definitionDefaults(); + } + + $resolved = []; + foreach (SettingDefinitions::ALL as $key => $meta) { + $record = $rows->firstWhere('key', $key); + $resolved[$key] = $record + ? SettingValueCaster::deserialize($record->value, (string) $record->type) + : ($meta['default'] ?? null); + } + + return $resolved; + } + + protected function buildPublicSettings(): array + { + $all = $this->all(); + $public = []; + + foreach (SettingDefinitions::ALL as $key => $meta) { + if (! $meta['is_public']) { + continue; + } + $public[$key] = $all[$key] ?? $meta['default'] ?? null; + } + + return $public; + } + + protected function definitionDefaults(): array + { + $defaults = []; + foreach (SettingDefinitions::ALL as $key => $meta) { + $defaults[$key] = $meta['default'] ?? null; + } + + return $defaults; + } + + protected function writeRevision( + int $settingId, + string $key, + mixed $oldValue, + mixed $newValue, + ?int $actorId, + ?Request $request, + ): void { + SystemSettingRevision::query()->create([ + 'system_setting_id' => $settingId, + 'key' => $key, + 'old_value' => $oldValue === null ? null : json_encode($oldValue, JSON_UNESCAPED_SLASHES), + 'new_value' => $newValue === null ? null : json_encode($newValue, JSON_UNESCAPED_SLASHES), + 'changed_by' => $actorId, + 'changed_ip' => $request?->ip(), + 'changed_agent' => $request?->userAgent(), + 'created_at' => now(), + ]); + } + + protected function invalidateCache(): void + { + Cache::forget(self::CACHE_KEY); + Cache::forget(self::PUBLIC_CACHE_KEY); + self::$resolvedSettings = null; + } +} diff --git a/app/Support/DataTable.php b/app/Support/DataTable.php new file mode 100644 index 0000000..7c45afd --- /dev/null +++ b/app/Support/DataTable.php @@ -0,0 +1,70 @@ +ajax() || $request->has('draw'); + } + + public static function draw(Request $request): int + { + return max(0, (int) $request->input('draw', 0)); + } + + public static function start(Request $request): int + { + return max(0, (int) $request->input('start', 0)); + } + + public static function length(Request $request, int $default = 25): int + { + $length = (int) $request->input('length', $default); + + return $length > 0 ? $length : $default; + } + + public static function globalSearch(Request $request): ?string + { + return self::normalize($request->input('search.value')); + } + + public static function columnSearch(Request $request, int $index): ?string + { + return self::normalize($request->input("columns.$index.search.value")); + } + + public static function order(Request $request, int $defaultIndex = 0, string $defaultDir = 'asc'): array + { + $index = (int) $request->input('order.0.column', $defaultIndex); + $dir = strtolower((string) $request->input('order.0.dir', $defaultDir)) === 'desc' ? 'desc' : 'asc'; + + return [$index, $dir]; + } + + public static function response(Request $request, int $recordsTotal, int $recordsFiltered, array $data): JsonResponse + { + return response()->json([ + 'draw' => self::draw($request), + 'recordsTotal' => $recordsTotal, + 'recordsFiltered' => $recordsFiltered, + 'data' => $data, + ]); + } + + protected static function normalize(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $value = trim($value); + + return $value === '' ? null : $value; + } +} diff --git a/app/Traits/HasAutoCode.php b/app/Traits/HasAutoCode.php new file mode 100644 index 0000000..bf90f09 --- /dev/null +++ b/app/Traits/HasAutoCode.php @@ -0,0 +1,61 @@ +code)) { + return; + } + + $prefix = $model->getCodePrefix(); + $column = $model->getCodeColumn(); + $length = $model->getCodeLength(); + + $lastCode = DB::table($model->getTable()) + ->where($column, 'like', $prefix.'%') + ->orderBy($column, 'desc') + ->value($column); + + $nextNumber = 1; + + if ($lastCode) { + $nextNumber = (int) substr($lastCode, strlen($prefix)) + 1; + } + + $model->{$column} = $prefix.str_pad($nextNumber, $length, '0', STR_PAD_LEFT); + }); + } + + /** + * Prefix code (WAJIB override di model) + */ + abstract protected function getCodePrefix(): string; + + /** + * Code column name + */ + protected function getCodeColumn(): string + { + return 'code'; + } + + /** + * Numeric length + */ + protected function getCodeLength(): int + { + return 3; + } +} diff --git a/app/View/Components/AppLayout.php b/app/View/Components/AppLayout.php new file mode 100644 index 0000000..de0d46f --- /dev/null +++ b/app/View/Components/AppLayout.php @@ -0,0 +1,17 @@ + 'Exception', + 'error_message' => $this->faker->sentence, + 'stack_trace' => $this->faker->text, + 'status' => 'pending', + 'ai_diagnosis' => $this->faker->paragraph, + 'action_taken' => $this->faker->sentence, + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..c4ceb07 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,45 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..05fb5d9 --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..b9c106b --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration'); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..425e705 --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2025_12_24_100239_create_permission_tables.php b/database/migrations/2025_12_24_100239_create_permission_tables.php new file mode 100644 index 0000000..a37b13e --- /dev/null +++ b/database/migrations/2025_12_24_100239_create_permission_tables.php @@ -0,0 +1,140 @@ +engine('InnoDB'); + $table->bigIncrements('id'); // permission id + $table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format) + $table->string('guard_name'); // For MyISAM use string('guard_name', 25); + $table->timestamps(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->softDeletes(); + + $table->unique(['name', 'guard_name']); + }); + + Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) { + // $table->engine('InnoDB'); + $table->bigIncrements('id'); // role id + if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + $table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format) + $table->string('guard_name'); // For MyISAM use string('guard_name', 25); + $table->timestamps(); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->softDeletes(); + if ($teams || config('permission.testing')) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } + }); + + Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) { + $table->unsignedBigInteger($pivotPermission); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->onDelete('cascade'); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } else { + $table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary'); + } + + }); + + Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) { + $table->unsignedBigInteger($pivotRole); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->onDelete('cascade'); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + + $table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } else { + $table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary'); + } + }); + + Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->onDelete('cascade'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->onDelete('cascade'); + + $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache') + ->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tableNames = config('permission.table_names'); + + throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + + Schema::drop($tableNames['role_has_permissions']); + Schema::drop($tableNames['model_has_roles']); + Schema::drop($tableNames['model_has_permissions']); + Schema::drop($tableNames['roles']); + Schema::drop($tableNames['permissions']); + } +}; diff --git a/database/migrations/2025_12_30_063114_create_activity_log_table.php b/database/migrations/2025_12_30_063114_create_activity_log_table.php new file mode 100644 index 0000000..3ea666d --- /dev/null +++ b/database/migrations/2025_12_30_063114_create_activity_log_table.php @@ -0,0 +1,29 @@ +create(config('activitylog.table_name'), function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('log_name')->nullable(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->nullableMorphs('causer', 'causer'); + $table->json('properties')->nullable(); + $table->uuid('batch_uuid')->nullable(); + $table->string('event')->nullable(); + $table->timestamps(); + $table->index('log_name'); + }); + } + + public function down() + { + Schema::connection(config('activitylog.database_connection'))->dropIfExists(config('activitylog.table_name')); + } +} diff --git a/database/migrations/2026_01_02_161054_create_notifications_table.php b/database/migrations/2026_01_02_161054_create_notifications_table.php new file mode 100644 index 0000000..37b24c0 --- /dev/null +++ b/database/migrations/2026_01_02_161054_create_notifications_table.php @@ -0,0 +1,42 @@ +id(); + + // Konten utama + $table->string('title'); + $table->text('message'); + + // Target penerima (Hierarchical: all, user, admin, superadmin, or user_id) + $table->string('recipient')->index(); + + // Jenis notifikasi + $table->enum('type', ['info', 'warning', 'system'])->default('info'); + + // Status baca + $table->timestamp('read_at')->nullable(); + + // Pembuat notifikasi + $table->foreignId('created_by') + ->nullable() + ->constrained('users') + ->nullOnDelete(); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_04_16_120608_create_media_table.php b/database/migrations/2026_04_16_120608_create_media_table.php new file mode 100644 index 0000000..47a4be9 --- /dev/null +++ b/database/migrations/2026_04_16_120608_create_media_table.php @@ -0,0 +1,32 @@ +id(); + + $table->morphs('model'); + $table->uuid()->nullable()->unique(); + $table->string('collection_name'); + $table->string('name'); + $table->string('file_name'); + $table->string('mime_type')->nullable(); + $table->string('disk'); + $table->string('conversions_disk')->nullable(); + $table->unsignedBigInteger('size'); + $table->json('manipulations'); + $table->json('custom_properties'); + $table->json('generated_conversions'); + $table->json('responsive_images'); + $table->unsignedInteger('order_column')->nullable()->index(); + + $table->nullableTimestamps(); + }); + } +}; diff --git a/database/migrations/2026_04_16_200000_create_system_settings_table.php b/database/migrations/2026_04_16_200000_create_system_settings_table.php new file mode 100644 index 0000000..de6e850 --- /dev/null +++ b/database/migrations/2026_04_16_200000_create_system_settings_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('key', 120)->unique(); + $table->text('value')->nullable(); + $table->string('type', 30)->default('string'); + $table->string('group', 50)->default('general'); + $table->boolean('is_public')->default(false); + $table->string('description', 190)->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('system_settings'); + } +}; diff --git a/database/migrations/2026_04_16_200100_create_system_setting_revisions_table.php b/database/migrations/2026_04_16_200100_create_system_setting_revisions_table.php new file mode 100644 index 0000000..b6392f8 --- /dev/null +++ b/database/migrations/2026_04_16_200100_create_system_setting_revisions_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('system_setting_id')->constrained('system_settings')->cascadeOnDelete(); + $table->string('key', 120); + $table->longText('old_value')->nullable(); + $table->longText('new_value')->nullable(); + $table->foreignId('changed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('changed_ip', 45)->nullable(); + $table->text('changed_agent')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::dropIfExists('system_setting_revisions'); + } +}; diff --git a/database/migrations/2026_04_17_125720_add_advanced_security_tables.php b/database/migrations/2026_04_17_125720_add_advanced_security_tables.php new file mode 100644 index 0000000..1494cf1 --- /dev/null +++ b/database/migrations/2026_04_17_125720_add_advanced_security_tables.php @@ -0,0 +1,49 @@ +timestamp('password_changed_at')->nullable()->after('password'); + }); + + // 2. Create Password Histories Table + Schema::create('password_histories', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('password'); + $table->timestamps(); + }); + + // 3. Create User Trusted Devices Table + Schema::create('user_trusted_devices', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('device_id')->index(); // Identitas browser/perangkat + $table->string('token')->index(); // Signed token + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_trusted_devices'); + Schema::dropIfExists('password_histories'); + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('password_changed_at'); + }); + } +}; diff --git a/database/migrations/2026_04_17_125913_create_webauthn_credentials.php b/database/migrations/2026_04_17_125913_create_webauthn_credentials.php new file mode 100644 index 0000000..7561794 --- /dev/null +++ b/database/migrations/2026_04_17_125913_create_webauthn_credentials.php @@ -0,0 +1,10 @@ +with(function (Blueprint $table) { + // Here you can add custom columns to the Two Factor table. + // + // $table->string('alias')->nullable(); +}); diff --git a/database/migrations/2026_04_20_131918_create_telescope_entries_table.php b/database/migrations/2026_04_20_131918_create_telescope_entries_table.php new file mode 100644 index 0000000..031b6f4 --- /dev/null +++ b/database/migrations/2026_04_20_131918_create_telescope_entries_table.php @@ -0,0 +1,70 @@ +getConnection()); + + $schema->create('telescope_entries', function (Blueprint $table) { + $table->bigIncrements('sequence'); + $table->uuid('uuid'); + $table->uuid('batch_id'); + $table->string('family_hash')->nullable(); + $table->boolean('should_display_on_index')->default(true); + $table->string('type', 20); + $table->longText('content'); + $table->dateTime('created_at')->nullable(); + + $table->unique('uuid'); + $table->index('batch_id'); + $table->index('family_hash'); + $table->index('created_at'); + $table->index(['type', 'should_display_on_index']); + }); + + $schema->create('telescope_entries_tags', function (Blueprint $table) { + $table->uuid('entry_uuid'); + $table->string('tag'); + + $table->primary(['entry_uuid', 'tag']); + $table->index('tag'); + + $table->foreign('entry_uuid') + ->references('uuid') + ->on('telescope_entries') + ->cascadeOnDelete(); + }); + + $schema->create('telescope_monitoring', function (Blueprint $table) { + $table->string('tag')->primary(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $schema = Schema::connection($this->getConnection()); + + $schema->dropIfExists('telescope_entries_tags'); + $schema->dropIfExists('telescope_entries'); + $schema->dropIfExists('telescope_monitoring'); + } +}; diff --git a/database/migrations/2026_04_20_162554_create_notification_user_table.php b/database/migrations/2026_04_20_162554_create_notification_user_table.php new file mode 100644 index 0000000..e17d06a --- /dev/null +++ b/database/migrations/2026_04_20_162554_create_notification_user_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('notification_id')->constrained()->onDelete('cascade'); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->timestamp('read_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); // Personal soft-delete for the broadcast + $table->timestamps(); + + $table->index(['notification_id', 'user_id']); + $table->unique(['notification_id', 'user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notification_user'); + } +}; diff --git a/database/migrations/2026_04_21_132019_create_user_consents_table.php b/database/migrations/2026_04_21_132019_create_user_consents_table.php new file mode 100644 index 0000000..11932a8 --- /dev/null +++ b/database/migrations/2026_04_21_132019_create_user_consents_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('consent_type'); // 'tos', 'pdp', 'marketing', etc. + $table->unsignedInteger('version_id'); // Current version ID of the document + $table->string('ip_address', 45); // IPv4 or IPv6 + $table->text('user_agent')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_consents'); + } +}; diff --git a/database/migrations/2026_04_22_064745_rename_custom_notification_tables.php b/database/migrations/2026_04_22_064745_rename_custom_notification_tables.php new file mode 100644 index 0000000..80d32bd --- /dev/null +++ b/database/migrations/2026_04_22_064745_rename_custom_notification_tables.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_04_23_225319_create_personal_access_tokens_table.php b/database/migrations/2026_04_23_225319_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_04_23_225319_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_04_24_000159_create_transactions_table.php b/database/migrations/2026_04_24_000159_create_transactions_table.php new file mode 100644 index 0000000..1d6ffe7 --- /dev/null +++ b/database/migrations/2026_04_24_000159_create_transactions_table.php @@ -0,0 +1,29 @@ +id(); + $blueprint->foreignId('user_id')->constrained()->onDelete('cascade'); + $blueprint->string('title'); + $blueprint->decimal('amount', 15, 2); + $blueprint->string('type'); // income, expense + $blueprint->string('category')->nullable(); + $blueprint->string('icon')->nullable(); + $blueprint->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('transactions'); + } +}; diff --git a/database/migrations/2026_04_24_135530_create_mobile_settings_table.php b/database/migrations/2026_04_24_135530_create_mobile_settings_table.php new file mode 100644 index 0000000..0d0e4fa --- /dev/null +++ b/database/migrations/2026_04_24_135530_create_mobile_settings_table.php @@ -0,0 +1,31 @@ +id(); + $t->string('key')->unique(); + $t->text('value')->nullable(); + $t->string('group')->default('general'); // theme, localization, flags, etc. + $t->string('type')->default('string'); // string, json, boolean + $t->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('mobile_settings'); + } +}; diff --git a/database/migrations/2026_04_24_214539_create_mobile_sync_logs_table.php b/database/migrations/2026_04_24_214539_create_mobile_sync_logs_table.php new file mode 100644 index 0000000..1632cc1 --- /dev/null +++ b/database/migrations/2026_04_24_214539_create_mobile_sync_logs_table.php @@ -0,0 +1,26 @@ +id(); + $row->foreignId('user_id')->nullable()->constrained()->onDelete('set null'); + $row->string('platform')->nullable(); + $row->string('device_model')->nullable(); + $row->string('app_version')->nullable(); + $row->string('ip_address')->nullable(); + $row->timestamp('synced_at')->useCurrent(); + }); + } + + public function down() + { + Schema::dropIfExists('mobile_sync_logs'); + } +}; diff --git a/database/migrations/2026_04_24_214850_create_mobile_error_logs_table.php b/database/migrations/2026_04_24_214850_create_mobile_error_logs_table.php new file mode 100644 index 0000000..3d586db --- /dev/null +++ b/database/migrations/2026_04_24_214850_create_mobile_error_logs_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->onDelete('set null'); + $table->string('error_type')->default('RuntimeError'); + $table->text('message'); + $table->longText('stack_trace')->nullable(); + $table->string('platform')->nullable(); + $table->string('app_version')->nullable(); + $table->json('device_info')->nullable(); + $table->timestamp('occurred_at')->useCurrent(); + }); + } + + public function down() + { + Schema::dropIfExists('mobile_error_logs'); + } +}; diff --git a/database/migrations/2026_04_25_092344_create_imports_table.php b/database/migrations/2026_04_25_092344_create_imports_table.php new file mode 100644 index 0000000..1d9c09d --- /dev/null +++ b/database/migrations/2026_04_25_092344_create_imports_table.php @@ -0,0 +1,35 @@ +id(); + $table->timestamp('completed_at')->nullable(); + $table->string('file_name'); + $table->string('file_path'); + $table->string('importer'); + $table->unsignedInteger('processed_rows')->default(0); + $table->unsignedInteger('total_rows'); + $table->unsignedInteger('successful_rows')->default(0); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('imports'); + } +}; diff --git a/database/migrations/2026_04_25_092345_create_exports_table.php b/database/migrations/2026_04_25_092345_create_exports_table.php new file mode 100644 index 0000000..6a87ac3 --- /dev/null +++ b/database/migrations/2026_04_25_092345_create_exports_table.php @@ -0,0 +1,35 @@ +id(); + $table->timestamp('completed_at')->nullable(); + $table->string('file_disk'); + $table->string('file_name')->nullable(); + $table->string('exporter'); + $table->unsignedInteger('processed_rows')->default(0); + $table->unsignedInteger('total_rows'); + $table->unsignedInteger('successful_rows')->default(0); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('exports'); + } +}; diff --git a/database/migrations/2026_04_25_092346_create_failed_import_rows_table.php b/database/migrations/2026_04_25_092346_create_failed_import_rows_table.php new file mode 100644 index 0000000..2f77805 --- /dev/null +++ b/database/migrations/2026_04_25_092346_create_failed_import_rows_table.php @@ -0,0 +1,30 @@ +id(); + $table->json('data'); + $table->foreignId('import_id')->constrained()->cascadeOnDelete(); + $table->text('validation_error')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('failed_import_rows'); + } +}; diff --git a/database/migrations/2026_04_25_092347_create_pulse_tables.php b/database/migrations/2026_04_25_092347_create_pulse_tables.php new file mode 100644 index 0000000..5d194e2 --- /dev/null +++ b/database/migrations/2026_04_25_092347_create_pulse_tables.php @@ -0,0 +1,84 @@ +shouldRun()) { + return; + } + + Schema::create('pulse_values', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger('timestamp'); + $table->string('type'); + $table->mediumText('key'); + match ($this->driver()) { + 'mariadb', 'mysql' => $table->char('key_hash', 16)->charset('binary')->virtualAs('unhex(md5(`key`))'), + 'pgsql' => $table->uuid('key_hash')->storedAs('md5("key")::uuid'), + 'sqlite' => $table->string('key_hash'), + }; + $table->mediumText('value'); + + $table->index('timestamp'); // For trimming... + $table->index('type'); // For fast lookups and purging... + $table->unique(['type', 'key_hash']); // For data integrity and upserts... + }); + + Schema::create('pulse_entries', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger('timestamp'); + $table->string('type'); + $table->mediumText('key'); + match ($this->driver()) { + 'mariadb', 'mysql' => $table->char('key_hash', 16)->charset('binary')->virtualAs('unhex(md5(`key`))'), + 'pgsql' => $table->uuid('key_hash')->storedAs('md5("key")::uuid'), + 'sqlite' => $table->string('key_hash'), + }; + $table->bigInteger('value')->nullable(); + + $table->index('timestamp'); // For trimming... + $table->index('type'); // For purging... + $table->index('key_hash'); // For mapping... + $table->index(['timestamp', 'type', 'key_hash', 'value']); // For aggregate queries... + }); + + Schema::create('pulse_aggregates', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger('bucket'); + $table->unsignedMediumInteger('period'); + $table->string('type'); + $table->mediumText('key'); + match ($this->driver()) { + 'mariadb', 'mysql' => $table->char('key_hash', 16)->charset('binary')->virtualAs('unhex(md5(`key`))'), + 'pgsql' => $table->uuid('key_hash')->storedAs('md5("key")::uuid'), + 'sqlite' => $table->string('key_hash'), + }; + $table->string('aggregate'); + $table->decimal('value', 20, 2); + $table->unsignedInteger('count')->nullable(); + + $table->unique(['bucket', 'period', 'type', 'aggregate', 'key_hash']); // Force "on duplicate update"... + $table->index(['period', 'bucket']); // For trimming... + $table->index('type'); // For purging... + $table->index(['period', 'type', 'aggregate', 'bucket']); // For aggregate queries... + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pulse_values'); + Schema::dropIfExists('pulse_entries'); + Schema::dropIfExists('pulse_aggregates'); + } +}; diff --git a/database/migrations/2026_04_26_205430_create_ai_usage_logs_table.php b/database/migrations/2026_04_26_205430_create_ai_usage_logs_table.php new file mode 100644 index 0000000..8490d01 --- /dev/null +++ b/database/migrations/2026_04_26_205430_create_ai_usage_logs_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->onDelete('set null'); + $table->string('provider'); + $table->string('model'); + $table->longText('prompt')->nullable(); + $table->longText('response')->nullable(); + $table->integer('prompt_tokens')->default(0); + $table->integer('completion_tokens')->default(0); + $table->integer('total_tokens')->default(0); + $table->decimal('estimated_cost', 10, 6)->default(0); + $table->string('status')->default('success'); // success, failed + $table->text('error_message')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ai_usage_logs'); + } +}; diff --git a/database/migrations/2026_05_07_054201_add_audit_and_status_to_roles_permissions.php b/database/migrations/2026_05_07_054201_add_audit_and_status_to_roles_permissions.php new file mode 100644 index 0000000..cd1a106 --- /dev/null +++ b/database/migrations/2026_05_07_054201_add_audit_and_status_to_roles_permissions.php @@ -0,0 +1,40 @@ +softDeletes(); + } + }); + + Schema::table('permissions', function (Blueprint $table) { + if (! Schema::hasColumn('permissions', 'deleted_at')) { + $table->softDeletes(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('roles', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + + Schema::table('permissions', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/database/migrations/2026_05_07_054445_add_missing_user_columns.php b/database/migrations/2026_05_07_054445_add_missing_user_columns.php new file mode 100644 index 0000000..3e4a1ff --- /dev/null +++ b/database/migrations/2026_05_07_054445_add_missing_user_columns.php @@ -0,0 +1,29 @@ +string('username')->nullable()->unique()->after('name'); + $table->string('phone_number')->nullable()->after('email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['username', 'phone_number']); + }); + } +}; diff --git a/database/migrations/2026_05_07_062510_add_audit_to_users_table.php b/database/migrations/2026_05_07_062510_add_audit_to_users_table.php new file mode 100644 index 0000000..2af1599 --- /dev/null +++ b/database/migrations/2026_05_07_062510_add_audit_to_users_table.php @@ -0,0 +1,33 @@ +foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + } + if (! Schema::hasColumn('users', 'updated_by')) { + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['created_by', 'updated_by']); + }); + } +}; diff --git a/database/migrations/2026_05_07_232149_add_soft_deletes_and_status_to_users_table.php b/database/migrations/2026_05_07_232149_add_soft_deletes_and_status_to_users_table.php new file mode 100644 index 0000000..0745608 --- /dev/null +++ b/database/migrations/2026_05_07_232149_add_soft_deletes_and_status_to_users_table.php @@ -0,0 +1,36 @@ +softDeletes(); + } + if (! Schema::hasColumn('users', 'is_active')) { + $table->boolean('is_active')->default(true); + } + if (! Schema::hasColumn('users', 'last_session_id')) { + $table->string('last_session_id')->nullable(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['deleted_at', 'is_active', 'last_session_id']); + }); + } +}; diff --git a/database/migrations/2026_05_07_232802_add_is_active_to_roles_and_permissions_tables.php b/database/migrations/2026_05_07_232802_add_is_active_to_roles_and_permissions_tables.php new file mode 100644 index 0000000..75dec5c --- /dev/null +++ b/database/migrations/2026_05_07_232802_add_is_active_to_roles_and_permissions_tables.php @@ -0,0 +1,40 @@ +boolean('is_active')->default(true); + } + }); + + Schema::table('permissions', function (Blueprint $table) { + if (! Schema::hasColumn('permissions', 'is_active')) { + $table->boolean('is_active')->default(true); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('roles', function (Blueprint $table) { + $table->dropColumn('is_active'); + }); + + Schema::table('permissions', function (Blueprint $table) { + $table->dropColumn('is_active'); + }); + } +}; diff --git a/database/migrations/2026_05_08_022324_create_otp_codes_table.php b/database/migrations/2026_05_08_022324_create_otp_codes_table.php new file mode 100644 index 0000000..a1fe5d9 --- /dev/null +++ b/database/migrations/2026_05_08_022324_create_otp_codes_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('identifier')->index(); + $table->string('code', 6); + $table->timestamp('expires_at'); + $table->timestamp('verified_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('otp_codes'); + } +}; diff --git a/database/migrations/2026_05_08_100000_create_device_tokens_table.php b/database/migrations/2026_05_08_100000_create_device_tokens_table.php new file mode 100644 index 0000000..0a10844 --- /dev/null +++ b/database/migrations/2026_05_08_100000_create_device_tokens_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('token')->unique(); + $table->enum('platform', ['ios', 'android', 'web'])->default('android'); + $table->string('device_name')->nullable(); + $table->string('app_version')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'platform']); + }); + } + + public function down(): void + { + Schema::dropIfExists('device_tokens'); + } +}; diff --git a/database/migrations/2026_05_08_222600_create_pulse_tables.php b/database/migrations/2026_05_08_222600_create_pulse_tables.php new file mode 100644 index 0000000..1846094 --- /dev/null +++ b/database/migrations/2026_05_08_222600_create_pulse_tables.php @@ -0,0 +1,84 @@ +shouldRun() || Schema::hasTable('pulse_values')) { + return; + } + + Schema::create('pulse_values', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger('timestamp'); + $table->string('type'); + $table->mediumText('key'); + match ($this->driver()) { + 'mariadb', 'mysql' => $table->char('key_hash', 16)->charset('binary')->virtualAs('unhex(md5(`key`))'), + 'pgsql' => $table->uuid('key_hash')->storedAs('md5("key")::uuid'), + 'sqlite' => $table->string('key_hash'), + }; + $table->mediumText('value'); + + $table->index('timestamp'); // For trimming... + $table->index('type'); // For fast lookups and purging... + $table->unique(['type', 'key_hash']); // For data integrity and upserts... + }); + + Schema::create('pulse_entries', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger('timestamp'); + $table->string('type'); + $table->mediumText('key'); + match ($this->driver()) { + 'mariadb', 'mysql' => $table->char('key_hash', 16)->charset('binary')->virtualAs('unhex(md5(`key`))'), + 'pgsql' => $table->uuid('key_hash')->storedAs('md5("key")::uuid'), + 'sqlite' => $table->string('key_hash'), + }; + $table->bigInteger('value')->nullable(); + + $table->index('timestamp'); // For trimming... + $table->index('type'); // For purging... + $table->index('key_hash'); // For mapping... + $table->index(['timestamp', 'type', 'key_hash', 'value']); // For aggregate queries... + }); + + Schema::create('pulse_aggregates', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger('bucket'); + $table->unsignedMediumInteger('period'); + $table->string('type'); + $table->mediumText('key'); + match ($this->driver()) { + 'mariadb', 'mysql' => $table->char('key_hash', 16)->charset('binary')->virtualAs('unhex(md5(`key`))'), + 'pgsql' => $table->uuid('key_hash')->storedAs('md5("key")::uuid'), + 'sqlite' => $table->string('key_hash'), + }; + $table->string('aggregate'); + $table->decimal('value', 20, 2); + $table->unsignedInteger('count')->nullable(); + + $table->unique(['bucket', 'period', 'type', 'aggregate', 'key_hash']); // Force "on duplicate update"... + $table->index(['period', 'bucket']); // For trimming... + $table->index('type'); // For purging... + $table->index(['period', 'type', 'aggregate', 'bucket']); // For aggregate queries... + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pulse_values'); + Schema::dropIfExists('pulse_entries'); + Schema::dropIfExists('pulse_aggregates'); + } +}; diff --git a/database/migrations/2026_05_12_120000_add_social_columns_to_users_table.php b/database/migrations/2026_05_12_120000_add_social_columns_to_users_table.php new file mode 100644 index 0000000..e806287 --- /dev/null +++ b/database/migrations/2026_05_12_120000_add_social_columns_to_users_table.php @@ -0,0 +1,34 @@ +string('google_id')->nullable()->after('password')->index(); + } + if (! Schema::hasColumn('users', 'facebook_id')) { + $table->string('facebook_id')->nullable()->after('google_id')->index(); + } + if (! Schema::hasColumn('users', 'github_id')) { + $table->string('github_id')->nullable()->after('facebook_id')->index(); + } + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + foreach (['google_id', 'facebook_id', 'github_id'] as $col) { + if (Schema::hasColumn('users', $col)) { + $table->dropColumn($col); + } + } + }); + } +}; diff --git a/database/migrations/2026_05_12_213000_fix_notification_type_enum.php b/database/migrations/2026_05_12_213000_fix_notification_type_enum.php new file mode 100644 index 0000000..de8ddc4 --- /dev/null +++ b/database/migrations/2026_05_12_213000_fix_notification_type_enum.php @@ -0,0 +1,34 @@ +string('type')->default('info')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('system_notifications', function (Blueprint $table) { + $table->enum('type', ['info', 'warning', 'system'])->default('info')->change(); + }); + } +}; diff --git a/database/migrations/2026_05_12_230757_add_indices_to_transactions_table.php b/database/migrations/2026_05_12_230757_add_indices_to_transactions_table.php new file mode 100644 index 0000000..50e09b0 --- /dev/null +++ b/database/migrations/2026_05_12_230757_add_indices_to_transactions_table.php @@ -0,0 +1,32 @@ +index('type'); + $table->index('category'); + $table->index('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropIndex(['type']); + $table->dropIndex(['category']); + $table->dropIndex(['created_at']); + }); + } +}; diff --git a/database/migrations/2026_05_12_232137_add_indices_to_activity_log_table.php b/database/migrations/2026_05_12_232137_add_indices_to_activity_log_table.php new file mode 100644 index 0000000..07f8b37 --- /dev/null +++ b/database/migrations/2026_05_12_232137_add_indices_to_activity_log_table.php @@ -0,0 +1,31 @@ +index('created_at', 'activity_log_created_at_idx'); + $table->index('event', 'activity_log_event_idx'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('activity_log', function (Blueprint $table) { + $table->dropIndex('activity_log_created_at_idx'); + $table->dropIndex('activity_log_event_idx'); + }); + } +}; diff --git a/database/migrations/2026_05_14_100000_add_performance_indexes.php b/database/migrations/2026_05_14_100000_add_performance_indexes.php new file mode 100644 index 0000000..443037f --- /dev/null +++ b/database/migrations/2026_05_14_100000_add_performance_indexes.php @@ -0,0 +1,25 @@ +addFkIfMissing('roles_created_by_foreign', 'roles', 'created_by'); + $this->addFkIfMissing('roles_updated_by_foreign', 'roles', 'updated_by'); + $this->addFkIfMissing('permissions_created_by_foreign', 'permissions', 'created_by'); + $this->addFkIfMissing('permissions_updated_by_foreign', 'permissions', 'updated_by'); + } + + public function down(): void + { + foreach (['roles_created_by_foreign', 'roles_updated_by_foreign'] as $c) { + DB::statement("ALTER TABLE roles DROP CONSTRAINT IF EXISTS {$c}"); + } + foreach (['permissions_created_by_foreign', 'permissions_updated_by_foreign'] as $c) { + DB::statement("ALTER TABLE permissions DROP CONSTRAINT IF EXISTS {$c}"); + } + } + + private function addFkIfMissing(string $name, string $table, string $column): void + { + $exists = DB::selectOne( + 'SELECT 1 FROM pg_constraint WHERE conname = ?', + [$name] + ); + if ($exists) { + return; + } + + // Null out any orphan references first so the FK can be created. + DB::statement("UPDATE {$table} SET {$column} = NULL WHERE {$column} IS NOT NULL AND {$column} NOT IN (SELECT id FROM users)"); + + DB::statement( + "ALTER TABLE {$table} ADD CONSTRAINT {$name} " + ."FOREIGN KEY ({$column}) REFERENCES users(id) ON DELETE SET NULL" + ); + } +}; diff --git a/database/migrations/2026_05_15_015531_create_ai_healing_logs_table.php b/database/migrations/2026_05_15_015531_create_ai_healing_logs_table.php new file mode 100644 index 0000000..36dd3fc --- /dev/null +++ b/database/migrations/2026_05_15_015531_create_ai_healing_logs_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('error_type')->nullable(); + $table->text('error_message'); + $table->longText('stack_trace')->nullable(); + $table->text('ai_diagnosis')->nullable(); + $table->string('action_taken')->nullable(); + $table->string('status')->default('pending'); // pending, diagnosing, resolved, failed + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ai_healing_logs'); + } +}; diff --git a/database/migrations/2026_05_15_015532_add_code_diff_to_ai_healing_logs_table.php b/database/migrations/2026_05_15_015532_add_code_diff_to_ai_healing_logs_table.php new file mode 100644 index 0000000..0f69cb8 --- /dev/null +++ b/database/migrations/2026_05_15_015532_add_code_diff_to_ai_healing_logs_table.php @@ -0,0 +1,29 @@ +longText('original_code')->after('ai_diagnosis')->nullable(); + $table->longText('fixed_code')->after('original_code')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('ai_healing_logs', function (Blueprint $table) { + $table->dropColumn(['original_code', 'fixed_code']); + }); + } +}; diff --git a/database/migrations/2026_05_16_212809_add_scope_to_permissions_table.php b/database/migrations/2026_05_16_212809_add_scope_to_permissions_table.php new file mode 100644 index 0000000..7cc9e24 --- /dev/null +++ b/database/migrations/2026_05_16_212809_add_scope_to_permissions_table.php @@ -0,0 +1,27 @@ +string('scope')->nullable()->after('name')->index(); + }); + } + + public function down(): void + { + Schema::table('permissions', function (Blueprint $table) { + $table->dropColumn('scope'); + }); + } +}; diff --git a/database/migrations/2026_05_16_220000_create_dashboard_widget_preferences_table.php b/database/migrations/2026_05_16_220000_create_dashboard_widget_preferences_table.php new file mode 100644 index 0000000..979262c --- /dev/null +++ b/database/migrations/2026_05_16_220000_create_dashboard_widget_preferences_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('widget_key', 64); + $table->boolean('visible')->default(true); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['user_id', 'widget_key']); + $table->index('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('dashboard_widget_preferences'); + } +}; diff --git a/database/seeders/AdminUserSeeder.php b/database/seeders/AdminUserSeeder.php new file mode 100644 index 0000000..fc68ec6 --- /dev/null +++ b/database/seeders/AdminUserSeeder.php @@ -0,0 +1,79 @@ + 'Admin', + 'email' => 'admin@biiproject.com', + 'role' => 'Administrator', + ], + [ + 'name' => 'Developer', + 'email' => 'developer@biiproject.com', + 'role' => 'Developer', + ], + [ + 'name' => 'User', + 'email' => 'user@biiproject.com', + 'role' => 'User', + ], + ]; + + foreach ($users as $userData) { + $user = User::updateOrCreate( + ['email' => $userData['email']], + [ + 'name' => $userData['name'], + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ] + ); + + // Pastikan role ada sebelum ditugaskan + if (Role::where('name', $userData['role'])->exists()) { + $user->syncRoles([$userData['role']]); + } + } + + $this->command->info('All role-based users created successfully with password: password'); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..5ffd70e --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,49 @@ +call([ + // Use the newly generated seeders from current database state + UsersTableSeeder::class, + RolesTableSeeder::class, + PermissionsTableSeeder::class, + ModelHasRolesTableSeeder::class, + ModelHasPermissionsTableSeeder::class, + RoleHasPermissionsTableSeeder::class, + SystemSettingsTableSeeder::class, + MobileSettingsTableSeeder::class, + ]); + $this->call(SystemSettingsTableSeeder::class); + } +} diff --git a/database/seeders/MobileSettingSeeder.php b/database/seeders/MobileSettingSeeder.php new file mode 100644 index 0000000..fb46eb9 --- /dev/null +++ b/database/seeders/MobileSettingSeeder.php @@ -0,0 +1,93 @@ + 'theme_color_secondary', 'value' => '#1a1a1a', 'group' => 'assets', 'type' => 'string'], + ['key' => 'theme_color_primary', 'value' => '#c6f135', 'group' => 'assets', 'type' => 'string'], + ['key' => 'primary_font_family', 'value' => 'Outfit', 'group' => 'assets', 'type' => 'string'], + ['key' => 'logo_url', 'value' => '/storage/mobile-assets/logo_url_1777816317.png', 'group' => 'assets', 'type' => 'image_path'], + ['key' => 'splash_image_url', 'value' => '/storage/mobile-assets/splash_image_url_1777816317.png', 'group' => 'assets', 'type' => 'image_path'], + + // --- GENERAL --- + ['key' => 'brand_color', 'value' => '#c6f135', 'group' => 'general', 'type' => 'string'], + ['key' => 'app_name', 'value' => 'biiproject', 'group' => 'general', 'type' => 'string'], + ['key' => 'app_version', 'value' => '2.0.0', 'group' => 'general', 'type' => 'string'], + ['key' => 'app_icon_url', 'value' => '/storage/mobile-assets/app_icon_url_1777816326.png', 'group' => 'general', 'type' => 'image_path'], + ['key' => 'store_url_android', 'value' => 'https://play.google.com/store/apps/details?id=com.biiproject', 'group' => 'general', 'type' => 'string'], + ['key' => 'store_url_ios', 'value' => 'https://apps.apple.com/app/biiproject', 'group' => 'general', 'type' => 'string'], + + // --- FLAGS --- + ['key' => 'enable_registration', 'value' => 'true', 'group' => 'flags', 'type' => 'boolean'], + ['key' => 'enable_remember_me', 'value' => 'true', 'group' => 'flags', 'type' => 'boolean'], + ['key' => 'kill_switch_active', 'value' => 'false', 'group' => 'flags', 'type' => 'boolean'], + ['key' => 'require_otp_registration', 'value' => 'false', 'group' => 'flags', 'type' => 'boolean'], + ['key' => 'enable_biometrics', 'value' => 'false', 'group' => 'flags', 'type' => 'boolean'], + ['key' => 'enable_push_notifications', 'value' => 'true', 'group' => 'flags', 'type' => 'boolean'], + + // --- NETWORK --- + ['key' => 'api_base_url', 'value' => 'http://192.168.8.129:8000', 'group' => 'network', 'type' => 'string'], + ['key' => 'api_timeout_ms', 'value' => '30000', 'group' => 'network', 'type' => 'integer'], + ['key' => 'api_retry_count', 'value' => '3', 'group' => 'network', 'type' => 'integer'], + ['key' => 'enable_ssl_pinning', 'value' => 'false', 'group' => 'network', 'type' => 'boolean'], + ['key' => 'environment_selector', 'value' => 'development', 'group' => 'network', 'type' => 'string'], + ['key' => 'api_version', 'value' => 'v1', 'group' => 'network', 'type' => 'string'], + ['key' => 'request_cache_ttl', 'value' => '3600', 'group' => 'network', 'type' => 'integer'], + + // --- AUTH --- + ['key' => 'token_ttl_minutes', 'value' => '43200', 'group' => 'auth', 'type' => 'integer'], + ['key' => 'session_max_age', 'value' => '86400', 'group' => 'auth', 'type' => 'integer'], + ['key' => 'biometric_auth_type', 'value' => 'fingerprint', 'group' => 'auth', 'type' => 'string'], + ['key' => 'oauth_google_enabled', 'value' => 'false', 'group' => 'auth', 'type' => 'boolean'], + ['key' => 'oauth_apple_enabled', 'value' => 'false', 'group' => 'auth', 'type' => 'boolean'], + ['key' => 'login_max_attempts', 'value' => '5', 'group' => 'auth', 'type' => 'integer'], + + // --- NOTIFICATION --- + ['key' => 'fcm_topic_default', 'value' => 'all_users', 'group' => 'notification', 'type' => 'string'], + ['key' => 'default_channel_id', 'value' => 'general', 'group' => 'notification', 'type' => 'string'], + ['key' => 'notification_sound_enabled', 'value' => 'true', 'group' => 'notification', 'type' => 'boolean'], + ['key' => 'badge_count_enabled', 'value' => 'true', 'group' => 'notification', 'type' => 'boolean'], + ['key' => 'priority_level', 'value' => 'high', 'group' => 'notification', 'type' => 'string'], + + // --- SYSTEM --- + ['key' => 'sync_interval_ms', 'value' => '10000', 'group' => 'system', 'type' => 'integer'], + ['key' => 'min_app_version', 'value' => '1.0.0', 'group' => 'system', 'type' => 'string'], + ['key' => 'target_sdk_version', 'value' => '34', 'group' => 'system', 'type' => 'string'], + ['key' => 'system_timezone', 'value' => 'Asia/Jakarta', 'group' => 'system', 'type' => 'string'], + ['key' => 'google_analytics_id', 'value' => 'G-BII-2026-X', 'group' => 'system', 'type' => 'string'], + ['key' => 'default_locale', 'value' => 'en', 'group' => 'system', 'type' => 'string'], + ['key' => 'privacy_policy_url', 'value' => 'https://biiproject.com/privacy', 'group' => 'system', 'type' => 'string'], + ['key' => 'region_lock_enabled', 'value' => 'false', 'group' => 'system', 'type' => 'boolean'], + ['key' => 'min_sdk_version', 'value' => '21', 'group' => 'system', 'type' => 'string'], + + // --- SUPPORT --- + ['key' => 'social_instagram_url', 'value' => '', 'group' => 'support', 'type' => 'string'], + ['key' => 'social_twitter_url', 'value' => '', 'group' => 'support', 'type' => 'string'], + ['key' => 'support_email', 'value' => 'support@biiproject.com', 'group' => 'support', 'type' => 'string'], + ['key' => 'support_whatsapp', 'value' => '628123456789', 'group' => 'support', 'type' => 'string'], + ['key' => 'live_chat_url', 'value' => '', 'group' => 'support', 'type' => 'string'], + ['key' => 'faq_url', 'value' => '', 'group' => 'support', 'type' => 'string'], + + // --- ANALYTICS --- + ['key' => 'crashlytics_enabled', 'value' => 'true', 'group' => 'analytics', 'type' => 'boolean'], + ['key' => 'log_level', 'value' => 'error', 'group' => 'analytics', 'type' => 'string'], + ['key' => 'event_sampling_rate', 'value' => '1.0', 'group' => 'analytics', 'type' => 'string'], + ['key' => 'gdpr_compliance_enabled', 'value' => 'false', 'group' => 'analytics', 'type' => 'boolean'], + ]; + + foreach ($settings as $setting) { + MobileSetting::updateOrCreate( + ['key' => $setting['key']], + $setting + ); + } + } +} diff --git a/database/seeders/MobileSettingsTableSeeder.php b/database/seeders/MobileSettingsTableSeeder.php new file mode 100644 index 0000000..6b34c25 --- /dev/null +++ b/database/seeders/MobileSettingsTableSeeder.php @@ -0,0 +1,786 @@ +delete(); + + \DB::table('mobile_settings')->insert(array ( + 0 => + array ( + 'id' => 1, + 'key' => 'theme_color_secondary', + 'value' => '#1a1a1a', + 'group' => 'assets', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 1 => + array ( + 'id' => 2, + 'key' => 'theme_color_primary', + 'value' => '#c6f135', + 'group' => 'assets', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 2 => + array ( + 'id' => 3, + 'key' => 'primary_font_family', + 'value' => 'Outfit', + 'group' => 'assets', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 3 => + array ( + 'id' => 4, + 'key' => 'logo_url', + 'value' => '/storage/mobile-assets/logo_url_1777816317.png', + 'group' => 'assets', + 'type' => 'image_path', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 4 => + array ( + 'id' => 5, + 'key' => 'splash_image_url', + 'value' => '/storage/mobile-assets/splash_image_url_1777816317.png', + 'group' => 'assets', + 'type' => 'image_path', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 5 => + array ( + 'id' => 6, + 'key' => 'brand_color', + 'value' => '#c6f135', + 'group' => 'general', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 6 => + array ( + 'id' => 7, + 'key' => 'app_name', + 'value' => 'biiproject', + 'group' => 'general', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 7 => + array ( + 'id' => 8, + 'key' => 'app_version', + 'value' => '2.0.0', + 'group' => 'general', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 8 => + array ( + 'id' => 9, + 'key' => 'app_icon_url', + 'value' => '/storage/mobile-assets/app_icon_url_1777816326.png', + 'group' => 'general', + 'type' => 'image_path', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 9 => + array ( + 'id' => 10, + 'key' => 'store_url_android', + 'value' => 'https://play.google.com/store/apps/details?id=com.biiproject', + 'group' => 'general', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 10 => + array ( + 'id' => 11, + 'key' => 'store_url_ios', + 'value' => 'https://apps.apple.com/app/biiproject', + 'group' => 'general', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 11 => + array ( + 'id' => 12, + 'key' => 'enable_registration', + 'value' => 'true', + 'group' => 'flags', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 12 => + array ( + 'id' => 13, + 'key' => 'enable_remember_me', + 'value' => 'true', + 'group' => 'flags', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 13 => + array ( + 'id' => 15, + 'key' => 'require_otp_registration', + 'value' => 'false', + 'group' => 'flags', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 14 => + array ( + 'id' => 16, + 'key' => 'enable_biometrics', + 'value' => 'false', + 'group' => 'flags', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 15 => + array ( + 'id' => 17, + 'key' => 'enable_push_notifications', + 'value' => 'true', + 'group' => 'flags', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 16 => + array ( + 'id' => 19, + 'key' => 'api_timeout_ms', + 'value' => '30000', + 'group' => 'network', + 'type' => 'integer', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 17 => + array ( + 'id' => 20, + 'key' => 'api_retry_count', + 'value' => '3', + 'group' => 'network', + 'type' => 'integer', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 18 => + array ( + 'id' => 21, + 'key' => 'enable_ssl_pinning', + 'value' => 'false', + 'group' => 'network', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 19 => + array ( + 'id' => 22, + 'key' => 'environment_selector', + 'value' => 'development', + 'group' => 'network', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 20 => + array ( + 'id' => 23, + 'key' => 'api_version', + 'value' => 'v1', + 'group' => 'network', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 21 => + array ( + 'id' => 24, + 'key' => 'request_cache_ttl', + 'value' => '3600', + 'group' => 'network', + 'type' => 'integer', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 22 => + array ( + 'id' => 25, + 'key' => 'token_ttl_minutes', + 'value' => '43200', + 'group' => 'auth', + 'type' => 'integer', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 23 => + array ( + 'id' => 26, + 'key' => 'session_max_age', + 'value' => '86400', + 'group' => 'auth', + 'type' => 'integer', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 24 => + array ( + 'id' => 28, + 'key' => 'oauth_google_enabled', + 'value' => 'false', + 'group' => 'auth', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 25 => + array ( + 'id' => 29, + 'key' => 'oauth_apple_enabled', + 'value' => 'false', + 'group' => 'auth', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 26 => + array ( + 'id' => 30, + 'key' => 'login_max_attempts', + 'value' => '5', + 'group' => 'auth', + 'type' => 'integer', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 27 => + array ( + 'id' => 31, + 'key' => 'fcm_topic_default', + 'value' => 'all_users', + 'group' => 'notification', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 28 => + array ( + 'id' => 32, + 'key' => 'default_channel_id', + 'value' => 'general', + 'group' => 'notification', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 29 => + array ( + 'id' => 33, + 'key' => 'notification_sound_enabled', + 'value' => 'true', + 'group' => 'notification', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 30 => + array ( + 'id' => 34, + 'key' => 'badge_count_enabled', + 'value' => 'true', + 'group' => 'notification', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 31 => + array ( + 'id' => 35, + 'key' => 'priority_level', + 'value' => 'high', + 'group' => 'notification', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 32 => + array ( + 'id' => 36, + 'key' => 'sync_interval_ms', + 'value' => '10000', + 'group' => 'system', + 'type' => 'integer', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 33 => + array ( + 'id' => 37, + 'key' => 'min_app_version', + 'value' => '1.0.0', + 'group' => 'system', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 34 => + array ( + 'id' => 38, + 'key' => 'target_sdk_version', + 'value' => '34', + 'group' => 'system', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 35 => + array ( + 'id' => 39, + 'key' => 'system_timezone', + 'value' => 'Asia/Jakarta', + 'group' => 'system', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 36 => + array ( + 'id' => 40, + 'key' => 'google_analytics_id', + 'value' => 'G-BII-2026-X', + 'group' => 'system', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 37 => + array ( + 'id' => 41, + 'key' => 'default_locale', + 'value' => 'en', + 'group' => 'system', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 38 => + array ( + 'id' => 42, + 'key' => 'privacy_policy_url', + 'value' => 'https://biiproject.com/privacy', + 'group' => 'system', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 39 => + array ( + 'id' => 43, + 'key' => 'region_lock_enabled', + 'value' => 'false', + 'group' => 'system', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 40 => + array ( + 'id' => 44, + 'key' => 'min_sdk_version', + 'value' => '21', + 'group' => 'system', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 41 => + array ( + 'id' => 47, + 'key' => 'support_email', + 'value' => 'support@biiproject.com', + 'group' => 'support', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 42 => + array ( + 'id' => 48, + 'key' => 'support_whatsapp', + 'value' => '628123456789', + 'group' => 'support', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 43 => + array ( + 'id' => 51, + 'key' => 'crashlytics_enabled', + 'value' => 'true', + 'group' => 'analytics', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 44 => + array ( + 'id' => 52, + 'key' => 'log_level', + 'value' => 'error', + 'group' => 'analytics', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 45 => + array ( + 'id' => 53, + 'key' => 'event_sampling_rate', + 'value' => '1.0', + 'group' => 'analytics', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 46 => + array ( + 'id' => 54, + 'key' => 'gdpr_compliance_enabled', + 'value' => 'false', + 'group' => 'analytics', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 47 => + array ( + 'id' => 55, + 'key' => 'app_tagline', + 'value' => 'Smart Solution for Your Business', + 'group' => 'branding', + 'type' => 'string', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 48 => + array ( + 'id' => 57, + 'key' => 'maintenance_start_at', + 'value' => NULL, + 'group' => 'control_center', + 'type' => 'string', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 49 => + array ( + 'id' => 58, + 'key' => 'maintenance_end_at', + 'value' => NULL, + 'group' => 'control_center', + 'type' => 'string', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 50 => + array ( + 'id' => 59, + 'key' => 'maintenance_bypass_ips', + 'value' => '127.0.0.1', + 'group' => 'control_center', + 'type' => 'string', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 51 => + array ( + 'id' => 60, + 'key' => 'announcement_text', + 'value' => 'Maintenance is scheduled for tonight at 12:00 AM.', + 'group' => 'control_center', + 'type' => 'string', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 52 => + array ( + 'id' => 61, + 'key' => 'announcement_type', + 'value' => 'info', + 'group' => 'control_center', + 'type' => 'string', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 53 => + array ( + 'id' => 62, + 'key' => 'onboarding_version', + 'value' => '1.0.0', + 'group' => 'app_updates', + 'type' => 'string', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 54 => + array ( + 'id' => 63, + 'key' => 'store_url_huawei', + 'value' => 'https://appgallery.huawei.com/', + 'group' => 'app_updates', + 'type' => 'string', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 55 => + array ( + 'id' => 64, + 'key' => 'review_prompt_enabled', + 'value' => 'true', + 'group' => 'features', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 56 => + array ( + 'id' => 65, + 'key' => 'min_actions_before_review', + 'value' => '10', + 'group' => 'features', + 'type' => 'integer', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 57 => + array ( + 'id' => 66, + 'key' => 'dashboard_categories', + 'value' => 'All,Tech,Finance,Health,Coding', + 'group' => 'features', + 'type' => 'string', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 58 => + array ( + 'id' => 67, + 'key' => 'login_title', + 'value' => 'biiproject', + 'group' => 'security_auth', + 'type' => 'string', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 59 => + array ( + 'id' => 56, + 'key' => 'kill_switch_message', + 'value' => '

System is currently undergoing emergency maintenance. Please try again laters

', + 'group' => 'control_center', + 'type' => 'string', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:16:11', + ), + 60 => + array ( + 'id' => 18, + 'key' => 'api_base_url', + 'value' => 'http://192.168.81.59:8000', + 'group' => 'network', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-13 16:23:15', + ), + 61 => + array ( + 'id' => 14, + 'key' => 'kill_switch_active', + 'value' => 'false', + 'group' => 'flags', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-14 11:23:15', + ), + 62 => + array ( + 'id' => 68, + 'key' => 'login_subtitle', + 'value' => 'Sign in to continue', + 'group' => 'security_auth', + 'type' => 'string', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 63 => + array ( + 'id' => 27, + 'key' => 'biometric_auth_type', + 'value' => 'any', + 'group' => 'auth', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:15:47', + ), + 64 => + array ( + 'id' => 69, + 'key' => 'ssl_pinning_hash', + 'value' => NULL, + 'group' => 'connectivity', + 'type' => 'string', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 65 => + array ( + 'id' => 49, + 'key' => 'live_chat_url', + 'value' => NULL, + 'group' => 'support', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:15:47', + ), + 66 => + array ( + 'id' => 50, + 'key' => 'faq_url', + 'value' => NULL, + 'group' => 'support', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:15:47', + ), + 67 => + array ( + 'id' => 45, + 'key' => 'social_instagram_url', + 'value' => NULL, + 'group' => 'support', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:15:47', + ), + 68 => + array ( + 'id' => 46, + 'key' => 'social_twitter_url', + 'value' => NULL, + 'group' => 'support', + 'type' => 'string', + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:15:47', + ), + 69 => + array ( + 'id' => 70, + 'key' => 'social_facebook_url', + 'value' => NULL, + 'group' => 'support_social', + 'type' => 'string', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 70 => + array ( + 'id' => 71, + 'key' => 'social_youtube_url', + 'value' => NULL, + 'group' => 'support_social', + 'type' => 'string', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 71 => + array ( + 'id' => 72, + 'key' => 'faq_json', + 'value' => '[{"q":"How to sync?","a":"Click the sync button on dashboard."}]', + 'group' => 'support_social', + 'type' => 'json', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 72 => + array ( + 'id' => 73, + 'key' => 'help_topics_json', + 'value' => '[{"id":"1","name":"Account","icon":"user"},{"id":"2","name":"System","icon":"cpu"}]', + 'group' => 'support_social', + 'type' => 'json', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 73 => + array ( + 'id' => 74, + 'key' => 'announcement_enabled', + 'value' => 'false', + 'group' => 'control_center', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 74 => + array ( + 'id' => 75, + 'key' => 'enable_guest_mode', + 'value' => 'false', + 'group' => 'features', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + 75 => + array ( + 'id' => 76, + 'key' => 'oauth_facebook_enabled', + 'value' => 'false', + 'group' => 'security_auth', + 'type' => 'boolean', + 'created_at' => '2026-05-12 22:15:47', + 'updated_at' => '2026-05-12 22:15:47', + ), + )); + + + } +} \ No newline at end of file diff --git a/database/seeders/ModelHasPermissionsTableSeeder.php b/database/seeders/ModelHasPermissionsTableSeeder.php new file mode 100644 index 0000000..fe8a50f --- /dev/null +++ b/database/seeders/ModelHasPermissionsTableSeeder.php @@ -0,0 +1,24 @@ +delete(); + + + + } +} \ No newline at end of file diff --git a/database/seeders/ModelHasRolesTableSeeder.php b/database/seeders/ModelHasRolesTableSeeder.php new file mode 100644 index 0000000..d1ba455 --- /dev/null +++ b/database/seeders/ModelHasRolesTableSeeder.php @@ -0,0 +1,50 @@ +delete(); + + \DB::table('model_has_roles')->insert(array ( + 0 => + array ( + 'role_id' => 2, + 'model_type' => 'App\\Models\\User', + 'model_id' => 1, + ), + 1 => + array ( + 'role_id' => 1, + 'model_type' => 'App\\Models\\User', + 'model_id' => 2, + ), + 2 => + array ( + 'role_id' => 3, + 'model_type' => 'App\\Models\\User', + 'model_id' => 3, + ), + 3 => + array ( + 'role_id' => 1, + 'model_type' => 'App\\Models\\User', + 'model_id' => 5, + ), + )); + + + } +} \ No newline at end of file diff --git a/database/seeders/PermissionsTableSeeder.php b/database/seeders/PermissionsTableSeeder.php new file mode 100644 index 0000000..21c89d6 --- /dev/null +++ b/database/seeders/PermissionsTableSeeder.php @@ -0,0 +1,350 @@ +delete(); + + \DB::table('permissions')->insert(array ( + 0 => + array ( + 'id' => 1, + 'name' => 'view dashboard', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 1 => + array ( + 'id' => 2, + 'name' => 'view user directory', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 2 => + array ( + 'id' => 3, + 'name' => 'manage user directory', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 3 => + array ( + 'id' => 4, + 'name' => 'impersonate users', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 4 => + array ( + 'id' => 5, + 'name' => 'view access rights', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 5 => + array ( + 'id' => 6, + 'name' => 'manage access rights', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 6 => + array ( + 'id' => 7, + 'name' => 'view health and logs', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 7 => + array ( + 'id' => 8, + 'name' => 'manage health and logs', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 8 => + array ( + 'id' => 9, + 'name' => 'view system health', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 9 => + array ( + 'id' => 10, + 'name' => 'manage system health', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 10 => + array ( + 'id' => 11, + 'name' => 'view action history', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 11 => + array ( + 'id' => 12, + 'name' => 'manage action history', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 12 => + array ( + 'id' => 13, + 'name' => 'view pulse', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 13 => + array ( + 'id' => 14, + 'name' => 'view telescope', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 14 => + array ( + 'id' => 15, + 'name' => 'view api docs', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 15 => + array ( + 'id' => 16, + 'name' => 'view active sessions', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 16 => + array ( + 'id' => 17, + 'name' => 'manage active sessions', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 17 => + array ( + 'id' => 18, + 'name' => 'view global settings', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 18 => + array ( + 'id' => 19, + 'name' => 'manage global settings', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 19 => + array ( + 'id' => 20, + 'name' => 'view maintenance mode', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 20 => + array ( + 'id' => 21, + 'name' => 'manage maintenance mode', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 21 => + array ( + 'id' => 22, + 'name' => 'view backup and storage', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 22 => + array ( + 'id' => 23, + 'name' => 'manage backup and storage', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 23 => + array ( + 'id' => 24, + 'name' => 'view mobile settings', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:12', + 'updated_at' => '2026-05-12 22:01:12', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 24 => + array ( + 'id' => 25, + 'name' => 'manage mobile settings', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 25 => + array ( + 'id' => 26, + 'name' => 'view notification center', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 26 => + array ( + 'id' => 27, + 'name' => 'manage notification center', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + )); + + + } +} \ No newline at end of file diff --git a/database/seeders/RoleAndPermissionSeeder.php b/database/seeders/RoleAndPermissionSeeder.php new file mode 100644 index 0000000..a93ebe5 --- /dev/null +++ b/database/seeders/RoleAndPermissionSeeder.php @@ -0,0 +1,144 @@ +forgetCachedPermissions(); + + // ── MENU-LEVEL PERMISSIONS (scope = null) ───────────────────────────── + $menuPermissions = [ + 'view dashboard', + 'view user directory', 'manage user directory', + 'impersonate users', + 'view access rights', 'manage access rights', + 'view health and logs', 'manage health and logs', + 'view system health', 'manage system health', + 'view action history', 'manage action history', + 'view pulse', 'view telescope', 'view api docs', + 'view active sessions', 'manage active sessions', + 'view global settings', 'manage global settings', + 'view maintenance mode', 'manage maintenance mode', + 'view backup and storage', 'manage backup and storage', + 'view mobile settings', 'manage mobile settings', + 'view notification center', 'manage notification center', + 'view ai self-healing', 'manage ai self-healing', + 'view ai log analysis', 'use ai assistant', + ]; + + foreach ($menuPermissions as $name) { + Permission::firstOrCreate( + ['name' => $name, 'guard_name' => 'web'], + ['scope' => null, 'is_active' => true] + ); + } + + // ── TAB-LEVEL PERMISSIONS [name, scope] ─────────────────────────────── + $tabPermissions = [ + // Global Settings + ['view global settings:general', 'general'], + ['manage global settings:general', 'general'], + ['view global settings:login-security', 'login-security'], + ['manage global settings:login-security', 'login-security'], + ['view global settings:password-policy', 'password-policy'], + ['manage global settings:password-policy', 'password-policy'], + ['view global settings:social-login', 'social-login'], + ['manage global settings:social-login', 'social-login'], + ['view global settings:ip-access', 'ip-access'], + ['manage global settings:ip-access', 'ip-access'], + ['view global settings:notifications', 'notifications'], + ['manage global settings:notifications', 'notifications'], + ['view global settings:content-legal', 'content-legal'], + ['manage global settings:content-legal', 'content-legal'], + ['view global settings:ai-config', 'ai-config'], + ['manage global settings:ai-config', 'ai-config'], + ['view global settings:sap-integration', 'sap-integration'], + ['manage global settings:sap-integration', 'sap-integration'], + ['view global settings:monitoring', 'monitoring'], + ['manage global settings:monitoring', 'monitoring'], + // Mobile Settings + ['view mobile settings:branding', 'branding'], + ['manage mobile settings:branding', 'branding'], + ['view mobile settings:control-center', 'control-center'], + ['manage mobile settings:control-center', 'control-center'], + ['view mobile settings:app-updates', 'app-updates'], + ['manage mobile settings:app-updates', 'app-updates'], + ['view mobile settings:features', 'features'], + ['manage mobile settings:features', 'features'], + ['view mobile settings:security-auth', 'security-auth'], + ['manage mobile settings:security-auth', 'security-auth'], + ['view mobile settings:connectivity', 'connectivity'], + ['manage mobile settings:connectivity', 'connectivity'], + ['view mobile settings:notifications', 'notifications'], + ['manage mobile settings:notifications', 'notifications'], + ['view mobile settings:support-social', 'support-social'], + ['manage mobile settings:support-social', 'support-social'], + ['view mobile settings:analytics-system', 'analytics-system'], + ['manage mobile settings:analytics-system','analytics-system'], + ['view mobile settings:localization', 'localization'], + ['manage mobile settings:localization', 'localization'], + ['view mobile settings:developer', 'developer'], + ['manage mobile settings:developer', 'developer'], + // Health & Logs + ['view health and logs:system-monitor', 'system-monitor'], + ['manage health and logs:system-monitor', 'system-monitor'], + ['view health and logs:ai-log-analysis', 'ai-log-analysis'], + ['view health and logs:error-logs', 'error-logs'], + ['manage health and logs:error-logs', 'error-logs'], + ['view health and logs:query-logs', 'query-logs'], + ['manage health and logs:query-logs', 'query-logs'], + // Action History + ['view action history:all', 'all'], + ['view action history:own', 'own'], + ['export action history', null], + // Active Sessions + ['view active sessions:all', 'all'], + ['view active sessions:own', 'own'], + ]; + + foreach ($tabPermissions as [$name, $scope]) { + Permission::firstOrCreate( + ['name' => $name, 'guard_name' => 'web'], + ['scope' => $scope, 'is_active' => true] + ); + } + + // ── ROLES ───────────────────────────────────────────────────────────── + $developer = Role::findOrCreate('Developer', 'web'); + $developer->syncPermissions(Permission::where('guard_name', 'web')->get()); + + $globalTabPerms = array_column( + array_filter($tabPermissions, fn ($p) => str_contains($p[0], 'global settings:')), 0 + ); + $mobileTabPerms = array_column( + array_filter($tabPermissions, fn ($p) => str_contains($p[0], 'mobile settings:')), 0 + ); + $healthTabPerms = array_column( + array_filter($tabPermissions, fn ($p) => str_contains($p[0], 'health and logs:')), 0 + ); + + $administrator = Role::findOrCreate('Administrator', 'web'); + $administrator->syncPermissions(array_merge([ + 'view dashboard', + 'view user directory', 'manage user directory', + 'impersonate users', + 'view mobile settings', 'manage mobile settings', + 'view notification center', 'manage notification center', + 'view global settings', 'manage global settings', + 'view health and logs', 'manage health and logs', + 'view action history', 'manage action history', + 'export action history', + 'view active sessions', 'manage active sessions', + ], $globalTabPerms, $mobileTabPerms, $healthTabPerms)); + + $user = Role::findOrCreate('User', 'web'); + $user->syncPermissions(['view dashboard', 'view notification center']); + } +} diff --git a/database/seeders/RoleHasPermissionsTableSeeder.php b/database/seeders/RoleHasPermissionsTableSeeder.php new file mode 100644 index 0000000..483e8c3 --- /dev/null +++ b/database/seeders/RoleHasPermissionsTableSeeder.php @@ -0,0 +1,211 @@ +delete(); + + \DB::table('role_has_permissions')->insert(array ( + 0 => + array ( + 'permission_id' => 1, + 'role_id' => 1, + ), + 1 => + array ( + 'permission_id' => 2, + 'role_id' => 1, + ), + 2 => + array ( + 'permission_id' => 3, + 'role_id' => 1, + ), + 3 => + array ( + 'permission_id' => 4, + 'role_id' => 1, + ), + 4 => + array ( + 'permission_id' => 5, + 'role_id' => 1, + ), + 5 => + array ( + 'permission_id' => 6, + 'role_id' => 1, + ), + 6 => + array ( + 'permission_id' => 7, + 'role_id' => 1, + ), + 7 => + array ( + 'permission_id' => 8, + 'role_id' => 1, + ), + 8 => + array ( + 'permission_id' => 9, + 'role_id' => 1, + ), + 9 => + array ( + 'permission_id' => 10, + 'role_id' => 1, + ), + 10 => + array ( + 'permission_id' => 11, + 'role_id' => 1, + ), + 11 => + array ( + 'permission_id' => 12, + 'role_id' => 1, + ), + 12 => + array ( + 'permission_id' => 13, + 'role_id' => 1, + ), + 13 => + array ( + 'permission_id' => 14, + 'role_id' => 1, + ), + 14 => + array ( + 'permission_id' => 15, + 'role_id' => 1, + ), + 15 => + array ( + 'permission_id' => 16, + 'role_id' => 1, + ), + 16 => + array ( + 'permission_id' => 17, + 'role_id' => 1, + ), + 17 => + array ( + 'permission_id' => 18, + 'role_id' => 1, + ), + 18 => + array ( + 'permission_id' => 19, + 'role_id' => 1, + ), + 19 => + array ( + 'permission_id' => 20, + 'role_id' => 1, + ), + 20 => + array ( + 'permission_id' => 21, + 'role_id' => 1, + ), + 21 => + array ( + 'permission_id' => 22, + 'role_id' => 1, + ), + 22 => + array ( + 'permission_id' => 23, + 'role_id' => 1, + ), + 23 => + array ( + 'permission_id' => 24, + 'role_id' => 1, + ), + 24 => + array ( + 'permission_id' => 25, + 'role_id' => 1, + ), + 25 => + array ( + 'permission_id' => 26, + 'role_id' => 1, + ), + 26 => + array ( + 'permission_id' => 27, + 'role_id' => 1, + ), + 27 => + array ( + 'permission_id' => 1, + 'role_id' => 3, + ), + 28 => + array ( + 'permission_id' => 26, + 'role_id' => 3, + ), + 29 => + array ( + 'permission_id' => 1, + 'role_id' => 2, + ), + 30 => + array ( + 'permission_id' => 2, + 'role_id' => 2, + ), + 31 => + array ( + 'permission_id' => 3, + 'role_id' => 2, + ), + 32 => + array ( + 'permission_id' => 4, + 'role_id' => 2, + ), + 33 => + array ( + 'permission_id' => 5, + 'role_id' => 2, + ), + 34 => + array ( + 'permission_id' => 6, + 'role_id' => 2, + ), + 35 => + array ( + 'permission_id' => 26, + 'role_id' => 2, + ), + 36 => + array ( + 'permission_id' => 27, + 'role_id' => 2, + ), + )); + + + } +} \ No newline at end of file diff --git a/database/seeders/RolesTableSeeder.php b/database/seeders/RolesTableSeeder.php new file mode 100644 index 0000000..d17beb8 --- /dev/null +++ b/database/seeders/RolesTableSeeder.php @@ -0,0 +1,62 @@ +delete(); + + \DB::table('roles')->insert(array ( + 0 => + array ( + 'id' => 1, + 'name' => 'Developer', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 1 => + array ( + 'id' => 3, + 'name' => 'User', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + ), + 2 => + array ( + 'id' => 2, + 'name' => 'Administrator', + 'guard_name' => 'web', + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:02:18', + 'created_by' => NULL, + 'updated_by' => 2, + 'deleted_at' => NULL, + 'is_active' => true, + ), + )); + + + } +} \ No newline at end of file diff --git a/database/seeders/SystemSettingSeeder.php b/database/seeders/SystemSettingSeeder.php new file mode 100644 index 0000000..97425d1 --- /dev/null +++ b/database/seeders/SystemSettingSeeder.php @@ -0,0 +1,1100 @@ + [ + 'key' => 'app_logo', + 'value' => 'assets/img/logo.png', + 'type' => 'image_path', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Application logo path', + ], + 1 => [ + 'key' => 'app_favicon', + 'value' => 'assets/img/favicon.png', + 'type' => 'image_path', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Application favicon path', + ], + 2 => [ + 'key' => 'regional_timezone', + 'value' => 'Asia/Jakarta', + 'type' => 'string', + 'group' => 'regional', + 'is_public' => true, + 'description' => 'System default timezone', + ], + 3 => [ + 'key' => 'regional_date_format', + 'value' => 'Y-m-d', + 'type' => 'string', + 'group' => 'regional', + 'is_public' => true, + 'description' => 'Date display format', + ], + 4 => [ + 'key' => 'regional_time_format', + 'value' => 'H:i', + 'type' => 'string', + 'group' => 'regional', + 'is_public' => true, + 'description' => 'Time display format (12/24 hour)', + ], + 5 => [ + 'key' => 'feature_facebook_oauth', + 'value' => '0', + 'type' => 'bool', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Toggle Facebook OAuth login', + ], + 6 => [ + 'key' => 'feature_github_oauth', + 'value' => '0', + 'type' => 'bool', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Toggle GitHub OAuth login', + ], + 7 => [ + 'key' => 'social_login_callback_url', + 'value' => '/auth/callback', + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Global Social Login Callback URL', + ], + 8 => [ + 'key' => 'login_max_attempts', + 'value' => '5', + 'type' => 'int', + 'group' => 'login_security', + 'is_public' => false, + 'description' => 'Maximum Login Attempts', + ], + 9 => [ + 'key' => 'login_lockout_duration', + 'value' => '15', + 'type' => 'int', + 'group' => 'login_security', + 'is_public' => false, + 'description' => 'Lockout Duration (Minutes)', + ], + 10 => [ + 'key' => 'login_lockout_notify', + 'value' => '0', + 'type' => 'bool', + 'group' => 'login_security', + 'is_public' => false, + 'description' => 'Notify upon Lockout', + ], + 11 => [ + 'key' => 'two_factor_auth', + 'value' => '0', + 'type' => 'bool', + 'group' => 'login_security', + 'is_public' => false, + 'description' => 'Enable 2FA', + ], + 12 => [ + 'key' => 'two_factor_method', + 'value' => 'app', + 'type' => 'string', + 'group' => 'login_security', + 'is_public' => false, + 'description' => '2FA Method', + ], + 13 => [ + 'key' => 'captcha_enabled', + 'value' => '0', + 'type' => 'bool', + 'group' => 'login_security', + 'is_public' => true, + 'description' => 'Enable Captcha', + ], + 14 => [ + 'key' => 'captcha_version', + 'value' => 'v3', + 'type' => 'string', + 'group' => 'login_security', + 'is_public' => true, + 'description' => 'reCAPTCHA Version', + ], + 15 => [ + 'key' => 'login_log_enabled', + 'value' => '0', + 'type' => 'bool', + 'group' => 'login_security', + 'is_public' => false, + 'description' => 'Log Login Activity', + ], + 16 => [ + 'key' => 'password_min_length', + 'value' => '12', + 'type' => 'int', + 'group' => 'password_policy', + 'is_public' => true, + 'description' => 'Minimum Password Length', + ], + 17 => [ + 'key' => 'password_max_length', + 'value' => '30', + 'type' => 'int', + 'group' => 'password_policy', + 'is_public' => true, + 'description' => 'Maximum Password Length', + ], + 18 => [ + 'key' => 'password_expiry_days', + 'value' => '0', + 'type' => 'int', + 'group' => 'password_policy', + 'is_public' => false, + 'description' => 'Password Validity (Days)', + ], + 19 => [ + 'key' => 'password_reset_link_expiry', + 'value' => '60', + 'type' => 'int', + 'group' => 'password_policy', + 'is_public' => false, + 'description' => 'Password Reset Link Expiry (Minutes)', + ], + 20 => [ + 'key' => 'session_lifetime', + 'value' => '120', + 'type' => 'int', + 'group' => 'session_security', + 'is_public' => false, + 'description' => 'Session Lifetime / Idle Timeout (Minutes)', + ], + 21 => [ + 'key' => 'session_single_session', + 'value' => '0', + 'type' => 'bool', + 'group' => 'session_security', + 'is_public' => false, + 'description' => 'Only allow 1 active session per user', + ], + 22 => [ + 'key' => 'session_auto_logout_idle', + 'value' => '30', + 'type' => 'int', + 'group' => 'session_security', + 'is_public' => false, + 'description' => 'Auto Logout Idle (Minutes)', + ], + 23 => [ + 'key' => 'session_allow_remember_me', + 'value' => '0', + 'type' => 'bool', + 'group' => 'session_security', + 'is_public' => true, + 'description' => 'Allow Remember Me on Login', + ], + 24 => [ + 'key' => 'session_remember_me_duration', + 'value' => '30', + 'type' => 'int', + 'group' => 'session_security', + 'is_public' => false, + 'description' => 'Remember Me Duration (Days)', + ], + 25 => [ + 'key' => 'session_secure_cookie', + 'value' => '0', + 'type' => 'bool', + 'group' => 'session_security', + 'is_public' => true, + 'description' => 'Secure Cookie (HTTPS Only)', + ], + 26 => [ + 'key' => 'session_encrypt', + 'value' => '0', + 'type' => 'bool', + 'group' => 'session_security', + 'is_public' => false, + 'description' => 'Encrypt Session Data', + ], + 27 => [ + 'key' => 'session_concurrent_limit', + 'value' => '0', + 'type' => 'int', + 'group' => 'session_security', + 'is_public' => false, + 'description' => 'Concurrent Session Limit (Per User)', + ], + 28 => [ + 'key' => 'two_factor_trust_days', + 'value' => '30', + 'type' => 'int', + 'group' => 'login_security', + 'is_public' => false, + 'description' => 'Remember Trusted Devices (Days)', + ], + 29 => [ + 'key' => 'webauthn_enabled', + 'value' => '0', + 'type' => 'bool', + 'group' => 'login_security', + 'is_public' => true, + 'description' => 'Enable Passkeys (Biometric/FIDO2)', + ], + 30 => [ + 'key' => 'rate_limit_per_ip', + 'value' => '60', + 'type' => 'int', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Max requests per minute per IP', + ], + 31 => [ + 'key' => 'auto_block_ip', + 'value' => '0', + 'type' => 'bool', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Automatically block suspicious IPs', + ], + 32 => [ + 'key' => 'threshold_auto_block', + 'value' => '100', + 'type' => 'int', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Hits threshold before auto-block', + ], + 33 => [ + 'key' => 'force_https', + 'value' => '0', + 'type' => 'bool', + 'group' => 'ip_access', + 'is_public' => true, + 'description' => 'Force HTTPS redirection', + ], + 34 => [ + 'key' => 'github_client_id', + 'value' => '', + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'GitHub Client ID', + ], + 35 => [ + 'key' => 'github_client_secret', + 'value' => '', + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'GitHub Client Secret', + ], + 36 => [ + 'key' => 'captcha_site_key', + 'value' => '6LdPs7ssAAAAAGMvwfGmadPEyKDrWD2pEhsbZigh', + 'type' => 'string', + 'group' => 'login_security', + 'is_public' => true, + 'description' => 'reCAPTCHA Site Key', + ], + 37 => [ + 'key' => 'ip_blacklist', + 'value' => '', + 'type' => 'string', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Global IP Blacklist (Comma separated)', + ], + 38 => [ + 'key' => 'footer_text', + 'value' => '© 2026 biiproject. All rights reserved.', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Footer text', + ], + 39 => [ + 'key' => 'google_client_secret', + 'value' => '', + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Google Client Secret', + ], + 40 => [ + 'key' => 'facebook_app_id', + 'value' => '', + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Facebook App ID', + ], + 41 => [ + 'key' => 'facebook_app_secret', + 'value' => '', + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Facebook App Secret', + ], + 42 => [ + 'key' => 'feature_google_oauth', + 'value' => '0', + 'type' => 'bool', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Toggle Google OAuth login', + ], + 43 => [ + 'key' => 'session_driver', + 'value' => 'database', + 'type' => 'string', + 'group' => 'session_security', + 'is_public' => false, + 'description' => 'Session Driver (file/redis/database)', + ], + 44 => [ + 'key' => 'password_require_lowercase', + 'value' => '0', + 'type' => 'bool', + 'group' => 'password_policy', + 'is_public' => true, + 'description' => 'Require Lowercase Letters', + ], + 45 => [ + 'key' => 'feature_notification_center', + 'value' => '1', + 'type' => 'bool', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Toggle notification center feature', + ], + 46 => [ + 'key' => 'captcha_secret_key', + 'value' => '6LdPs7ssAAAAAFSjfPdODZv3ddG2O6eFOG1klRI4', + 'type' => 'string', + 'group' => 'login_security', + 'is_public' => false, + 'description' => 'reCAPTCHA Secret', + ], + 47 => [ + 'key' => 'ip_whitelist_admin', + 'value' => '', + 'type' => 'string', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Admin IP Whitelist (Comma separated)', + ], + 48 => [ + 'key' => 'app_tagline1', + 'value' => 'We design, deploy, and optimize AI solutions. Built around your business needs.', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Secondary tagline', + ], + 49 => [ + 'key' => 'default_locale', + 'value' => 'en', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Default language', + ], + 50 => [ + 'key' => 'app_name', + 'value' => 'biiproject', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Application name', + ], + 51 => [ + 'key' => 'password_require_numeric', + 'value' => '0', + 'type' => 'bool', + 'group' => 'password_policy', + 'is_public' => true, + 'description' => 'Require Numbers', + ], + 52 => [ + 'key' => 'password_require_special', + 'value' => '0', + 'type' => 'bool', + 'group' => 'password_policy', + 'is_public' => true, + 'description' => 'Require Symbols / Special Characters', + ], + 53 => [ + 'key' => 'app_tagline', + 'value' => 'Custom AI Solutions for Modern Businesses', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Primary tagline', + ], + 54 => [ + 'key' => 'password_require_uppercase', + 'value' => '0', + 'type' => 'bool', + 'group' => 'password_policy', + 'is_public' => true, + 'description' => 'Require Uppercase Letters', + ], + 55 => [ + 'key' => 'google_client_id', + 'value' => '', + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Google Client ID', + ], + 56 => [ + 'key' => 'hsts_enabled', + 'value' => '0', + 'type' => 'bool', + 'group' => 'ip_access', + 'is_public' => true, + 'description' => 'Enable HTTP Strict Transport Security', + ], + 57 => [ + 'key' => 'cors_origins', + 'value' => '*', + 'type' => 'string', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Allowed CORS Origins', + ], + 58 => [ + 'key' => 'cors_methods', + 'value' => '*', + 'type' => 'string', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Allowed CORS Methods', + ], + 59 => [ + 'key' => 'cors_headers', + 'value' => '*', + 'type' => 'string', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Allowed CORS Headers', + ], + 60 => [ + 'key' => 'mail_driver', + 'value' => 'smtp', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'Email Driver (smtp, mailgun, ses)', + ], + 61 => [ + 'key' => 'mail_port', + 'value' => '587', + 'type' => 'int', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'SMTP Port (587/465/25)', + ], + 62 => [ + 'key' => 'mail_encryption', + 'value' => 'tls', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'Encryption (tls/ssl/null)', + ], + 63 => [ + 'key' => 'mail_from_address', + 'value' => 'noreply@example.com', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'Sender Email Address', + ], + 64 => [ + 'key' => 'mail_from_name', + 'value' => 'System Notification', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'Sender Display Name', + ], + 65 => [ + 'key' => 'backup_db_frequency', + 'value' => 'daily', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Backup Frequency', + ], + 66 => [ + 'key' => 'backup_db_time', + 'value' => '11:00', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Execution Time', + ], + 67 => [ + 'key' => 'backup_db_retention', + 'value' => '7', + 'type' => 'int', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Retention Policy (Keep last N)', + ], + 68 => [ + 'key' => 'backup_db_compress', + 'value' => '0', + 'type' => 'bool', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Compress with gzip', + ], + 69 => [ + 'key' => 'backup_db_encrypt', + 'value' => '0', + 'type' => 'bool', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'AES-256 Encryption', + ], + 70 => [ + 'key' => 'backup_db_notify_on', + 'value' => 'failed', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Notification Trigger (success/failed/both)', + ], + 71 => [ + 'key' => 'maintenance_mode_message', + 'value' => 'We are currently performing scheduled maintenance. We will be back shortly!', + 'type' => 'string', + 'group' => 'maintenance', + 'is_public' => true, + 'description' => 'Maintenance message', + ], + 72 => [ + 'key' => 'maintenance_mode_title', + 'value' => 'biiproject.com', + 'type' => 'string', + 'group' => 'maintenance', + 'is_public' => true, + 'description' => 'Maintenance page title', + ], + 73 => [ + 'key' => 'maintenance_mode_retry', + 'value' => '120', + 'type' => 'int', + 'group' => 'maintenance', + 'is_public' => false, + 'description' => 'Retry-After seconds', + ], + 74 => [ + 'key' => 'maintenance_mode_image', + 'value' => 'assets/img/maintenance.png', + 'type' => 'image_path', + 'group' => 'maintenance', + 'is_public' => true, + 'description' => 'Maintenance illustration image', + ], + 75 => [ + 'key' => 'tos_document_version', + 'value' => '1', + 'type' => 'int', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Current version of Terms of Use', + ], + 76 => [ + 'key' => 'maintenance_mode_secret', + 'value' => 'xxx', + 'type' => 'string', + 'group' => 'maintenance', + 'is_public' => false, + 'description' => 'Bypass secret key', + ], + 77 => [ + 'key' => 'maintenance_mode_enabled', + 'value' => '0', + 'type' => 'bool', + 'group' => 'maintenance', + 'is_public' => false, + 'description' => 'Enable maintenance mode', + ], + 78 => [ + 'key' => 'telegram_chat_id', + 'value' => '', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'Default Telegram Chat ID', + ], + 79 => [ + 'key' => 'backup_db_encrypt_key', + 'value' => '', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Encryption Key (min 32 chars)', + ], + 80 => [ + 'key' => 'maintenance_mode_end_at', + 'value' => '2026-04-21T11:10', + 'type' => 'string', + 'group' => 'maintenance', + 'is_public' => true, + 'description' => 'Estimated end time', + ], + 81 => [ + 'key' => 'backup_db_exclude', + 'value' => '', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Excluded Tables (comma separated)', + ], + 82 => [ + 'key' => 'backup_db_notify_to', + 'value' => '', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Notification Target (Email/Webhook)', + ], + 83 => [ + 'key' => 'maintenance_mode_allowed_ips', + 'value' => '', + 'type' => 'string', + 'group' => 'maintenance', + 'is_public' => false, + 'description' => 'Whitelisted IP addresses', + ], + 84 => [ + 'key' => 'feature_cookie_banner', + 'value' => '1', + 'type' => 'bool', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Enable Cookie Consent Banner', + ], + 85 => [ + 'key' => 'pdp_dpo_email', + 'value' => '', + 'type' => 'string', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Contact email for Data Protection Officer (PDP)', + ], + 86 => [ + 'key' => 'backup_db_enabled', + 'value' => '1', + 'type' => 'bool', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Enable automated database backup', + ], + 87 => [ + 'key' => 'require_pdp_on_registration', + 'value' => '1', + 'type' => 'bool', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Make PDP agreement mandatory during sign-up', + ], + 88 => [ + 'key' => 'pdp_document_version', + 'value' => '2', + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Current version of legal documents (increment to force re-agreement)', + ], + 89 => [ + 'key' => 'backup_db_driver', + 'value' => 'local', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Storage Driver (local/s3/gdrive)', + ], + 90 => [ + 'key' => 'mail_host', + 'value' => '', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'SMTP Host Server', + ], + 91 => [ + 'key' => 'mail_username', + 'value' => '', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'SMTP Username', + ], + 92 => [ + 'key' => 'mail_password', + 'value' => '', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'SMTP Password', + ], + 93 => [ + 'key' => 'telegram_bot_token', + 'value' => '', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'Telegram Bot Token', + ], + 94 => [ + 'key' => 'app_tagline2', + 'value' => 'Secure, scalable, and designed for real business impact.', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Description text', + ], + 95 => [ + 'key' => 'pdp_company_address', + 'value' => '', + 'type' => 'string', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Official company address for legal documentation', + ], + 96 => [ + 'key' => 'page_tos_content', + 'value' => ' +
+

Syarat dan Ketentuan Penggunaan (Terms of Use)

+

Terakhir Diperbarui: 03 Mei 2026

+

Selamat datang di Biiproject. Dengan mengakses atau menggunakan layanan kami, Anda dianggap telah membaca, memahami, dan menyetujui untuk terikat oleh Ketentuan berikut:

+

1. Pendaftaran Akun

+

Pengguna wajib memberikan informasi yang akurat dan lengkap selama proses registrasi. Anda bertanggung jawab penuh atas kerahasiaan kredensial akun Anda.

+

2. Pembatasan Penggunaan

+

Anda dilarang menggunakan layanan untuk tindakan ilegal, mendistribusikan malware, atau melakukan reverse engineering terhadap kode sumber aplikasi kami.

+

3. Hak Kekayaan Intelektual

+

Seluruh logo, desain, dan kode program dalam Biiproject adalah milik sah kami dan dilindungi oleh Undang-Undang Hak Cipta yang berlaku.

+

4. Batasan Tanggung Jawab

+

Biiproject tidak bertanggung jawab atas kerugian tidak langsung yang timbul dari penggunaan atau ketidakmampuan penggunaan layanan.

+
', + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Terms of Use Content', + ], + 97 => [ + 'key' => 'instance_mode', + 'value' => 'Production', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Environment/Instance Mode', + ], + 98 => [ + 'key' => 'page_privacy_content', + 'value' => ' +
+

Kebijakan Privasi (Sesuai UU PDP No. 27/2022)

+

Kami berkomitmen penuh untuk melindungi data pribadi Anda sesuai dengan regulasi pelindungan data terbaru di Indonesia.

+

1. Data yang Kami Kumpulkan

+
    +
  • Data Identitas: Nama, alamat email, nomor telepon, dan foto profil.
  • +
  • Data Teknis: Alamat IP, jenis perangkat, sistem operasi, dan log aktivitas aplikasi.
  • +
  • Data Biometrik: Digunakan secara lokal pada perangkat untuk fitur keamanan login.
  • +
+

2. Tujuan Pemrosesan Data

+

Data digunakan untuk verifikasi identitas, pengiriman notifikasi penting, serta peningkatan pengalaman pengguna melalui analitik sistem.

+

3. Hak Anda sebagai Subjek Data

+

Sesuai UU PDP, Anda memiliki hak untuk:

+
    +
  • Mengakses data pribadi Anda yang kami simpan.
  • +
  • Meminta perbaikan atau pemutakhiran data jika terjadi ketidakakuratan.
  • +
  • Meminta penghapusan data (Hak untuk dilupakan) dalam kondisi tertentu.
  • +
  • Menarik kembali persetujuan pemrosesan data sewaktu-waktu.
  • +
+

4. Keamanan dan Retensi

+

Data Anda disimpan dalam database terenkripsi selama akun Anda aktif atau selama dibutuhkan oleh regulasi hukum yang berlaku.

+
', + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Privacy Policy (UU PDP) Content', + ], + 99 => [ + 'key' => 'gdrive_folder', + 'value' => 'LaravelBackups', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Google Drive Folder Name', + ], + 100 => [ + 'key' => 's3_region', + 'value' => 'us-east-1', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'S3 Region', + ], + 101 => [ + 'key' => 'ai_enabled', + 'value' => '0', + 'type' => 'bool', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Enable AI services', + ], + 102 => [ + 'key' => 'ai_provider', + 'value' => 'gpt', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Active AI provider', + ], + 103 => [ + 'key' => 'ai_ollama_base_url', + 'value' => 'http://localhost:11434', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Ollama Base URL', + ], + 104 => [ + 'key' => 'ai_default_model', + 'value' => 'gpt-4o', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Default AI model', + ], + 105 => [ + 'key' => 'ai_temperature', + 'value' => '0.7', + 'type' => 'float', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'AI temperature', + ], + 106 => [ + 'key' => 'ai_max_tokens', + 'value' => '2000', + 'type' => 'int', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'AI max tokens', + ], + 107 => [ + 'key' => 'gdrive_client_id', + 'value' => '', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Google Drive Client ID', + ], + 108 => [ + 'key' => 'gdrive_client_secret', + 'value' => '', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Google Drive Client Secret', + ], + 109 => [ + 'key' => 'gdrive_refresh_token', + 'value' => '', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Google Drive Refresh Token', + ], + 110 => [ + 'key' => 's3_key', + 'value' => '', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'S3 Access Key', + ], + 111 => [ + 'key' => 's3_secret', + 'value' => '', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'S3 Secret Key', + ], + 112 => [ + 'key' => 's3_bucket', + 'value' => '', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'S3 Bucket Name', + ], + 113 => [ + 'key' => 's3_endpoint', + 'value' => '', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'S3 Custom Endpoint (Optional)', + ], + 114 => [ + 'key' => 'ai_gpt_key', + 'value' => '', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'OpenAI GPT API Key', + ], + 115 => [ + 'key' => 'ai_gemini_key', + 'value' => '', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Google Gemini API Key', + ], + 116 => [ + 'key' => 'ai_claude_key', + 'value' => '', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Anthropic Claude API Key', + ], + 117 => [ + 'key' => 'ai_deepseek_key', + 'value' => '', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'DeepSeek API Key', + ], + 118 => [ + 'key' => 'ai_grok_key', + 'value' => '', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'xAI Grok API Key', + ], + 119 => [ + 'key' => 'ai_mistral_key', + 'value' => '', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Mistral AI API Key', + ], + 120 => [ + 'key' => 'ai_openrouter_key', + 'value' => '', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'OpenRouter API Key', + ], + 121 => [ + 'key' => 'ai_system_instruction', + 'value' => '', + 'type' => 'text', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Default system instruction', + ], + 122 => [ + 'key' => 'page_about_content', + 'value' => ' +
+

Tentang Biiproject

+

Biiproject adalah sebuah ekosistem solusi bisnis digital mutakhir yang dirancang khusus untuk era transformasi industri 4.0. Kami menghadirkan integrasi antara efisiensi alur kerja dengan kecerdasan buatan (AI) untuk membantu perusahaan dan tim profesional mencapai produktivitas maksimal.

+

Visi Kami

+

Menjadi pionir dalam penyediaan infrastruktur kolaborasi digital yang paling aman dan intuitif di Asia Tenggara.

+

Misi Kami

+
    +
  • Inovasi Berkelanjutan: Mengembangkan fitur-fitur yang relevan dengan kebutuhan pasar global.
  • +
  • Keamanan Tanpa Kompromi: Melindungi setiap bit data pengguna dengan standar enkripsi tertinggi.
  • +
  • Aksesibilitas Global: Memastikan platform dapat diakses dari berbagai perangkat dengan performa yang tetap stabil.
  • +
+
', + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'About Us Content', + ], + 123 => [ + 'key' => 'page_security_content', + 'value' => ' +
+

Kebijakan Keamanan Sistem

+

Keamanan adalah fondasi utama dari Biiproject. Kami menerapkan strategi keamanan berlapis:

+

1. Enkripsi Data

+

Seluruh data sensitif dienkripsi menggunakan algoritma AES-256 (At-Rest) dan transmisi data dilakukan melalui protokol TLS 1.3 (In-Transit).

+

2. Autentikasi Multi-Faktor (MFA)

+

Kami mendukung penggunaan One-Time Password (OTP) dan Passkeys (WebAuthn) untuk memastikan hanya Anda yang dapat mengakses akun Anda.

+

3. Pemantauan Real-Time

+

Sistem kami dilengkapi dengan deteksi intrusi otomatis yang memantau setiap aktivitas mencurigakan 24/7.

+
', + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Security Policy Content', + ], + 124 => [ + 'key' => 'page_help_content', + 'value' => ' +
+

Pusat Bantuan & FAQ

+

Pertanyaan Umum (FAQ)

+

Q: Bagaimana cara mengganti kata sandi?
A: Anda dapat masuk ke Pengaturan Profil dan pilih menu Keamanan Akun.

+

Q: Saya tidak menerima kode OTP?
A: Pastikan alamat email Anda benar dan periksa folder spam. Jika masih bermasalah, hubungi dukungan teknis.

+

Kontak Dukungan

+

Email: support@biiproject.com
WhatsApp Support: +62 812-xxxx-xxxx
Jam Operasional: 09:00 - 18:00 WIB (Senin - Jumat)

+
', + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Help Center / FAQ Content', + ], + 125 => [ + 'key' => 'password_history_count', + 'value' => '2', + 'type' => 'int', + 'group' => 'password_policy', + 'is_public' => false, + 'description' => 'Prevent Password Reuse (Count)', + ], + 126 => [ + 'key' => 'enable_landing_page', + 'value' => '1', + 'type' => 'bool', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Enable/Disable the landing page (welcome page)', + ], + ]; + + foreach ($settings as $setting) { + SystemSetting::updateOrCreate(['key' => $setting['key']], $setting); + } + } +} diff --git a/database/seeders/SystemSettingsTableSeeder.php b/database/seeders/SystemSettingsTableSeeder.php new file mode 100644 index 0000000..321f4c7 --- /dev/null +++ b/database/seeders/SystemSettingsTableSeeder.php @@ -0,0 +1,1930 @@ +delete(); + + \DB::table('system_settings')->insert(array ( + 0 => + array ( + 'id' => 1, + 'key' => 'app_logo', + 'value' => 'assets/img/logo.png', + 'type' => 'image_path', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Application logo path', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 1 => + array ( + 'id' => 2, + 'key' => 'app_favicon', + 'value' => 'assets/img/favicon.png', + 'type' => 'image_path', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Application favicon path', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 2 => + array ( + 'id' => 3, + 'key' => 'regional_timezone', + 'value' => 'Asia/Jakarta', + 'type' => 'string', + 'group' => 'regional', + 'is_public' => true, + 'description' => 'System default timezone', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 3 => + array ( + 'id' => 4, + 'key' => 'regional_date_format', + 'value' => 'Y-m-d', + 'type' => 'string', + 'group' => 'regional', + 'is_public' => true, + 'description' => 'Date display format', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 4 => + array ( + 'id' => 5, + 'key' => 'regional_time_format', + 'value' => 'H:i', + 'type' => 'string', + 'group' => 'regional', + 'is_public' => true, + 'description' => 'Time display format (12/24 hour)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 5 => + array ( + 'id' => 6, + 'key' => 'feature_facebook_oauth', + 'value' => '0', + 'type' => 'bool', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Toggle Facebook OAuth login', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 6 => + array ( + 'id' => 7, + 'key' => 'feature_github_oauth', + 'value' => '0', + 'type' => 'bool', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Toggle GitHub OAuth login', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 7 => + array ( + 'id' => 8, + 'key' => 'social_login_callback_url', + 'value' => '/auth/callback', + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Global Social Login Callback URL', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 8 => + array ( + 'id' => 9, + 'key' => 'login_max_attempts', + 'value' => '5', + 'type' => 'int', + 'group' => 'login_security', + 'is_public' => false, + 'description' => 'Maximum Login Attempts', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 9 => + array ( + 'id' => 10, + 'key' => 'login_lockout_duration', + 'value' => '15', + 'type' => 'int', + 'group' => 'login_security', + 'is_public' => false, + 'description' => 'Lockout Duration (Minutes)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 10 => + array ( + 'id' => 11, + 'key' => 'login_lockout_notify', + 'value' => '0', + 'type' => 'bool', + 'group' => 'login_security', + 'is_public' => false, + 'description' => 'Notify upon Lockout', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 11 => + array ( + 'id' => 14, + 'key' => 'captcha_enabled', + 'value' => '0', + 'type' => 'bool', + 'group' => 'login_security', + 'is_public' => true, + 'description' => 'Enable Captcha', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 12 => + array ( + 'id' => 15, + 'key' => 'captcha_version', + 'value' => 'v3', + 'type' => 'string', + 'group' => 'login_security', + 'is_public' => true, + 'description' => 'reCAPTCHA Version', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 13 => + array ( + 'id' => 16, + 'key' => 'login_log_enabled', + 'value' => '0', + 'type' => 'bool', + 'group' => 'login_security', + 'is_public' => false, + 'description' => 'Log Login Activity', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 14 => + array ( + 'id' => 17, + 'key' => 'password_min_length', + 'value' => '12', + 'type' => 'int', + 'group' => 'password_policy', + 'is_public' => true, + 'description' => 'Minimum Password Length', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 15 => + array ( + 'id' => 18, + 'key' => 'password_max_length', + 'value' => '30', + 'type' => 'int', + 'group' => 'password_policy', + 'is_public' => true, + 'description' => 'Maximum Password Length', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 16 => + array ( + 'id' => 19, + 'key' => 'password_expiry_days', + 'value' => '0', + 'type' => 'int', + 'group' => 'password_policy', + 'is_public' => false, + 'description' => 'Password Validity (Days)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 17 => + array ( + 'id' => 20, + 'key' => 'password_reset_link_expiry', + 'value' => '60', + 'type' => 'int', + 'group' => 'password_policy', + 'is_public' => false, + 'description' => 'Password Reset Link Expiry (Minutes)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 18 => + array ( + 'id' => 21, + 'key' => 'session_lifetime', + 'value' => '120', + 'type' => 'int', + 'group' => 'session_security', + 'is_public' => false, + 'description' => 'Session Lifetime / Idle Timeout (Minutes)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 19 => + array ( + 'id' => 23, + 'key' => 'session_auto_logout_idle', + 'value' => '30', + 'type' => 'int', + 'group' => 'session_security', + 'is_public' => false, + 'description' => 'Auto Logout Idle (Minutes)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 20 => + array ( + 'id' => 24, + 'key' => 'session_allow_remember_me', + 'value' => '0', + 'type' => 'bool', + 'group' => 'session_security', + 'is_public' => true, + 'description' => 'Allow Remember Me on Login', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 21 => + array ( + 'id' => 25, + 'key' => 'session_remember_me_duration', + 'value' => '30', + 'type' => 'int', + 'group' => 'session_security', + 'is_public' => false, + 'description' => 'Remember Me Duration (Days)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 22 => + array ( + 'id' => 26, + 'key' => 'session_secure_cookie', + 'value' => '0', + 'type' => 'bool', + 'group' => 'session_security', + 'is_public' => true, + 'description' => 'Secure Cookie (HTTPS Only)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 23 => + array ( + 'id' => 27, + 'key' => 'session_encrypt', + 'value' => '0', + 'type' => 'bool', + 'group' => 'session_security', + 'is_public' => false, + 'description' => 'Encrypt Session Data', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 24 => + array ( + 'id' => 28, + 'key' => 'session_concurrent_limit', + 'value' => '0', + 'type' => 'int', + 'group' => 'session_security', + 'is_public' => false, + 'description' => 'Concurrent Session Limit (Per User)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 25 => + array ( + 'id' => 29, + 'key' => 'two_factor_trust_days', + 'value' => '30', + 'type' => 'int', + 'group' => 'login_security', + 'is_public' => false, + 'description' => 'Remember Trusted Devices (Days)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 26 => + array ( + 'id' => 30, + 'key' => 'webauthn_enabled', + 'value' => '0', + 'type' => 'bool', + 'group' => 'login_security', + 'is_public' => true, + 'description' => 'Enable Passkeys (Biometric/FIDO2)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 27 => + array ( + 'id' => 31, + 'key' => 'rate_limit_per_ip', + 'value' => '60', + 'type' => 'int', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Max requests per minute per IP', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 28 => + array ( + 'id' => 32, + 'key' => 'auto_block_ip', + 'value' => '0', + 'type' => 'bool', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Automatically block suspicious IPs', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 29 => + array ( + 'id' => 33, + 'key' => 'threshold_auto_block', + 'value' => '100', + 'type' => 'int', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Hits threshold before auto-block', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 30 => + array ( + 'id' => 34, + 'key' => 'force_https', + 'value' => '0', + 'type' => 'bool', + 'group' => 'ip_access', + 'is_public' => true, + 'description' => 'Force HTTPS redirection', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 31 => + array ( + 'id' => 37, + 'key' => 'captcha_site_key', + 'value' => '6LdPs7ssAAAAAGMvwfGmadPEyKDrWD2pEhsbZigh', + 'type' => 'string', + 'group' => 'login_security', + 'is_public' => true, + 'description' => 'reCAPTCHA Site Key', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 32 => + array ( + 'id' => 39, + 'key' => 'footer_text', + 'value' => '© 2026 biiproject. All rights reserved.', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Footer text', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 33 => + array ( + 'id' => 44, + 'key' => 'session_driver', + 'value' => 'database', + 'type' => 'string', + 'group' => 'session_security', + 'is_public' => false, + 'description' => 'Session Driver (file/redis/database)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 34 => + array ( + 'id' => 47, + 'key' => 'captcha_secret_key', + 'value' => '6LdPs7ssAAAAAFSjfPdODZv3ddG2O6eFOG1klRI4', + 'type' => 'string', + 'group' => 'login_security', + 'is_public' => false, + 'description' => 'reCAPTCHA Secret', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 35 => + array ( + 'id' => 50, + 'key' => 'default_locale', + 'value' => 'en', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Default language', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 36 => + array ( + 'id' => 52, + 'key' => 'password_require_numeric', + 'value' => '0', + 'type' => 'bool', + 'group' => 'password_policy', + 'is_public' => true, + 'description' => 'Require Numbers', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 37 => + array ( + 'id' => 53, + 'key' => 'password_require_special', + 'value' => '0', + 'type' => 'bool', + 'group' => 'password_policy', + 'is_public' => true, + 'description' => 'Require Symbols / Special Characters', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 38 => + array ( + 'id' => 54, + 'key' => 'app_tagline', + 'value' => 'Custom AI Solutions for Modern Businesses', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Primary tagline', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 39 => + array ( + 'id' => 55, + 'key' => 'password_require_uppercase', + 'value' => '0', + 'type' => 'bool', + 'group' => 'password_policy', + 'is_public' => true, + 'description' => 'Require Uppercase Letters', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 40 => + array ( + 'id' => 57, + 'key' => 'hsts_enabled', + 'value' => '0', + 'type' => 'bool', + 'group' => 'ip_access', + 'is_public' => true, + 'description' => 'Enable HTTP Strict Transport Security', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 41 => + array ( + 'id' => 58, + 'key' => 'cors_origins', + 'value' => '*', + 'type' => 'string', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Allowed CORS Origins', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 42 => + array ( + 'id' => 59, + 'key' => 'cors_methods', + 'value' => '*', + 'type' => 'string', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Allowed CORS Methods', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 43 => + array ( + 'id' => 60, + 'key' => 'cors_headers', + 'value' => '*', + 'type' => 'string', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Allowed CORS Headers', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 44 => + array ( + 'id' => 42, + 'key' => 'facebook_app_secret', + 'value' => NULL, + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Facebook App Secret', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:25:22', + ), + 45 => + array ( + 'id' => 35, + 'key' => 'github_client_id', + 'value' => NULL, + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'GitHub Client ID', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:25:22', + ), + 46 => + array ( + 'id' => 48, + 'key' => 'ip_whitelist_admin', + 'value' => NULL, + 'type' => 'string', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Admin IP Whitelist (Comma separated)', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:25:23', + ), + 47 => + array ( + 'id' => 38, + 'key' => 'ip_blacklist', + 'value' => NULL, + 'type' => 'string', + 'group' => 'ip_access', + 'is_public' => false, + 'description' => 'Global IP Blacklist (Comma separated)', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:25:23', + ), + 48 => + array ( + 'id' => 43, + 'key' => 'feature_google_oauth', + 'value' => '0', + 'type' => 'bool', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Toggle Google OAuth login', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-15 00:35:10', + ), + 49 => + array ( + 'id' => 22, + 'key' => 'session_single_session', + 'value' => '0', + 'type' => 'bool', + 'group' => 'session_security', + 'is_public' => false, + 'description' => 'Only allow 1 active session per user', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 09:34:59', + ), + 50 => + array ( + 'id' => 49, + 'key' => 'app_tagline1', + 'value' => 'We design, deploy, and optimize AI solutions. Built around your business needs', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Secondary tagline', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:17:08', + ), + 51 => + array ( + 'id' => 12, + 'key' => 'two_factor_auth', + 'value' => '1', + 'type' => 'bool', + 'group' => 'login_security', + 'is_public' => false, + 'description' => 'Enable 2FA', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-15 01:41:32', + ), + 52 => + array ( + 'id' => 51, + 'key' => 'app_name', + 'value' => 'biiproject', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Application name', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:18:50', + ), + 53 => + array ( + 'id' => 13, + 'key' => 'two_factor_method', + 'value' => 'email', + 'type' => 'string', + 'group' => 'login_security', + 'is_public' => false, + 'description' => '2FA Method', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-15 01:41:32', + ), + 54 => + array ( + 'id' => 45, + 'key' => 'password_require_lowercase', + 'value' => '0', + 'type' => 'bool', + 'group' => 'password_policy', + 'is_public' => true, + 'description' => 'Require Lowercase Letters', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 09:34:59', + ), + 55 => + array ( + 'id' => 40, + 'key' => 'google_client_secret', + 'value' => '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', + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Google Client Secret', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:17:01', + ), + 56 => + array ( + 'id' => 41, + 'key' => 'facebook_app_id', + 'value' => NULL, + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Facebook App ID', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:25:22', + ), + 57 => + array ( + 'id' => 61, + 'key' => 'mail_driver', + 'value' => 'smtp', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'Email Driver (smtp, mailgun, ses)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 58 => + array ( + 'id' => 62, + 'key' => 'mail_port', + 'value' => '587', + 'type' => 'int', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'SMTP Port (587/465/25)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 59 => + array ( + 'id' => 63, + 'key' => 'mail_encryption', + 'value' => 'tls', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'Encryption (tls/ssl/null)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 60 => + array ( + 'id' => 65, + 'key' => 'mail_from_name', + 'value' => 'System Notification', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'Sender Display Name', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 61 => + array ( + 'id' => 66, + 'key' => 'backup_db_frequency', + 'value' => 'daily', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Backup Frequency', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 62 => + array ( + 'id' => 69, + 'key' => 'backup_db_compress', + 'value' => '0', + 'type' => 'bool', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Compress with gzip', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 63 => + array ( + 'id' => 70, + 'key' => 'backup_db_encrypt', + 'value' => '0', + 'type' => 'bool', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'AES-256 Encryption', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 64 => + array ( + 'id' => 71, + 'key' => 'backup_db_notify_on', + 'value' => 'failed', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Notification Trigger (success/failed/both)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 65 => + array ( + 'id' => 72, + 'key' => 'maintenance_mode_message', + 'value' => 'We are currently performing scheduled maintenance. We will be back shortly!', + 'type' => 'string', + 'group' => 'maintenance', + 'is_public' => true, + 'description' => 'Maintenance message', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 66 => + array ( + 'id' => 73, + 'key' => 'maintenance_mode_title', + 'value' => 'biiproject.com', + 'type' => 'string', + 'group' => 'maintenance', + 'is_public' => true, + 'description' => 'Maintenance page title', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 67 => + array ( + 'id' => 74, + 'key' => 'maintenance_mode_retry', + 'value' => '120', + 'type' => 'int', + 'group' => 'maintenance', + 'is_public' => false, + 'description' => 'Retry-After seconds', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 68 => + array ( + 'id' => 75, + 'key' => 'maintenance_mode_image', + 'value' => 'assets/img/maintenance.png', + 'type' => 'image_path', + 'group' => 'maintenance', + 'is_public' => true, + 'description' => 'Maintenance illustration image', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 69 => + array ( + 'id' => 76, + 'key' => 'tos_document_version', + 'value' => '1', + 'type' => 'int', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Current version of Terms of Use', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 70 => + array ( + 'id' => 77, + 'key' => 'maintenance_mode_secret', + 'value' => 'xxx', + 'type' => 'string', + 'group' => 'maintenance', + 'is_public' => false, + 'description' => 'Bypass secret key', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 71 => + array ( + 'id' => 78, + 'key' => 'maintenance_mode_enabled', + 'value' => '0', + 'type' => 'bool', + 'group' => 'maintenance', + 'is_public' => false, + 'description' => 'Enable maintenance mode', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 72 => + array ( + 'id' => 81, + 'key' => 'maintenance_mode_end_at', + 'value' => '2026-04-21T11:10', + 'type' => 'string', + 'group' => 'maintenance', + 'is_public' => true, + 'description' => 'Estimated end time', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 73 => + array ( + 'id' => 84, + 'key' => 'maintenance_mode_allowed_ips', + 'value' => '', + 'type' => 'string', + 'group' => 'maintenance', + 'is_public' => false, + 'description' => 'Whitelisted IP addresses', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 74 => + array ( + 'id' => 89, + 'key' => 'pdp_document_version', + 'value' => '2', + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Current version of legal documents (increment to force re-agreement)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 75 => + array ( + 'id' => 100, + 'key' => 'gdrive_folder', + 'value' => 'LaravelBackups', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Google Drive Folder Name', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 76 => + array ( + 'id' => 88, + 'key' => 'require_pdp_on_registration', + 'value' => '0', + 'type' => 'bool', + 'group' => 'content_legal', + 'is_public' => false, + 'description' => 'Make PDP agreement mandatory during sign-up', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 09:34:59', + ), + 77 => + array ( + 'id' => 85, + 'key' => 'feature_cookie_banner', + 'value' => '0', + 'type' => 'bool', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Enable Cookie Consent Banner', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 09:34:59', + ), + 78 => + array ( + 'id' => 94, + 'key' => 'telegram_bot_token', + 'value' => '8661526667:AAGh14ue2B0dGPA1HcCd6zqR8BOcOsqGczs', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'Telegram Bot Token', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:04:22', + ), + 79 => + array ( + 'id' => 98, + 'key' => 'instance_mode', + 'value' => 'Production', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Environment/Instance Mode', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 12:02:52', + ), + 80 => + array ( + 'id' => 79, + 'key' => 'telegram_chat_id', + 'value' => '5985966244', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'Default Telegram Chat ID', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:04:22', + ), + 81 => + array ( + 'id' => 91, + 'key' => 'mail_host', + 'value' => 'smtp.gmail.com', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'SMTP Host Server', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:13:54', + ), + 82 => + array ( + 'id' => 92, + 'key' => 'mail_username', + 'value' => 'debesocial@gmail.com', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'SMTP Username', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:13:54', + ), + 83 => + array ( + 'id' => 64, + 'key' => 'mail_from_address', + 'value' => 'noreply@biiproject.com', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'Sender Email Address', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:14:13', + ), + 84 => + array ( + 'id' => 80, + 'key' => 'backup_db_encrypt_key', + 'value' => NULL, + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Encryption Key (min 32 chars)', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:25:23', + ), + 85 => + array ( + 'id' => 82, + 'key' => 'backup_db_exclude', + 'value' => NULL, + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Excluded Tables (comma separated)', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:25:23', + ), + 86 => + array ( + 'id' => 83, + 'key' => 'backup_db_notify_to', + 'value' => NULL, + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Notification Target (Email/Webhook)', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:25:23', + ), + 87 => + array ( + 'id' => 86, + 'key' => 'pdp_dpo_email', + 'value' => NULL, + 'type' => 'string', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Contact email for Data Protection Officer (PDP)', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:25:23', + ), + 88 => + array ( + 'id' => 96, + 'key' => 'pdp_company_address', + 'value' => NULL, + 'type' => 'string', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Official company address for legal documentation', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:25:23', + ), + 89 => + array ( + 'id' => 90, + 'key' => 'backup_db_driver', + 'value' => 'gdrive', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Storage Driver (local/s3/gdrive)', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-15 00:35:10', + ), + 90 => + array ( + 'id' => 87, + 'key' => 'backup_db_enabled', + 'value' => '1', + 'type' => 'bool', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Enable automated database backup', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-15 01:25:25', + ), + 91 => + array ( + 'id' => 67, + 'key' => 'backup_db_time', + 'value' => '02:00', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Execution Time', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-15 01:25:25', + ), + 92 => + array ( + 'id' => 68, + 'key' => 'backup_db_retention', + 'value' => '1', + 'type' => 'int', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Retention Policy (Keep last N)', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-15 01:25:25', + ), + 93 => + array ( + 'id' => 101, + 'key' => 's3_region', + 'value' => 'us-east-1', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'S3 Region', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 94 => + array ( + 'id' => 104, + 'key' => 'ai_ollama_base_url', + 'value' => 'http://localhost:11434', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Ollama Base URL', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 95 => + array ( + 'id' => 107, + 'key' => 'ai_max_tokens', + 'value' => '2000', + 'type' => 'int', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'AI max tokens', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:01:13', + ), + 96 => + array ( + 'id' => 126, + 'key' => 'password_history_count', + 'value' => '2', + 'type' => 'int', + 'group' => 'password_policy', + 'is_public' => false, + 'description' => 'Prevent Password Reuse (Count)', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + ), + 97 => + array ( + 'id' => 95, + 'key' => 'app_tagline2', + 'value' => '

Secure, scalable, and designed for real business impact.

', + 'type' => 'string', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Description text', + 'created_by' => 1, + 'updated_by' => 1, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-12 22:04:11', + ), + 98 => + array ( + 'id' => 111, + 'key' => 's3_key', + 'value' => NULL, + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'S3 Access Key', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-14 23:25:23', + ), + 99 => + array ( + 'id' => 119, + 'key' => 'ai_grok_key', + 'value' => NULL, + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'xAI Grok API Key', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-14 23:25:23', + ), + 100 => + array ( + 'id' => 93, + 'key' => 'mail_password', + 'value' => 'ctag zdqd nhrg cbuo', + 'type' => 'string', + 'group' => 'notifications', + 'is_public' => false, + 'description' => 'SMTP Password', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:13:54', + ), + 101 => + array ( + 'id' => 103, + 'key' => 'ai_provider', + 'value' => 'openrouter', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Active AI provider', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-15 02:02:39', + ), + 102 => + array ( + 'id' => 110, + 'key' => 'gdrive_refresh_token', + 'value' => '1//0gqVglu1W3UG_CgYIARAAGBASNwF-L9Ir3paG7BZMVQ1Z11uOlSKpYZaH3z8Bm-me6s1OaR2O3-_pzYj28qG4yCY8z9nH9iAwBg0', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Google Drive Refresh Token', + 'created_by' => NULL, + 'updated_by' => NULL, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-15 00:35:40', + ), + 103 => + array ( + 'id' => 125, + 'key' => 'page_help_content', + 'value' => '

Pusat Bantuan & FAQ

Pertanyaan Umum (FAQ)

Q: Bagaimana cara mengganti kata sandi?
A: Anda dapat masuk ke Pengaturan Profil dan pilih menu Keamanan Akun.

Q: Saya tidak menerima kode OTP?
A: Pastikan alamat email Anda benar dan periksa folder spam. Jika masih bermasalah, hubungi dukungan teknis.

Kontak Dukungan

Email: support@biiproject.com
WhatsApp Support: +62 812-xxxx-xxxx
Jam Operasional: 09:00 - 18:00 WIB (Senin - Jumat)

', + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Help Center / FAQ Content', + 'created_by' => 1, + 'updated_by' => 1, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:04:11', + ), + 104 => + array ( + 'id' => 124, + 'key' => 'page_security_content', + 'value' => '

Kebijakan Keamanan Sistem

Keamanan adalah fondasi utama dari Biiproject. Kami menerapkan strategi keamanan berlapis:

1. Enkripsi Data

Seluruh data sensitif dienkripsi menggunakan algoritma AES-256 (At-Rest) dan transmisi data dilakukan melalui protokol TLS 1.3 (In-Transit).

2. Autentikasi Multi-Faktor (MFA)

Kami mendukung penggunaan One-Time Password (OTP) dan Passkeys (WebAuthn) untuk memastikan hanya Anda yang dapat mengakses akun Anda.

3. Pemantauan Real-Time

Sistem kami dilengkapi dengan deteksi intrusi otomatis yang memantau setiap aktivitas mencurigakan 24/7.

', + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Security Policy Content', + 'created_by' => 1, + 'updated_by' => 1, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:04:11', + ), + 105 => + array ( + 'id' => 112, + 'key' => 's3_secret', + 'value' => NULL, + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'S3 Secret Key', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-14 23:25:23', + ), + 106 => + array ( + 'id' => 106, + 'key' => 'ai_temperature', + 'value' => '0.9', + 'type' => 'float', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'AI temperature', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 16:47:33', + ), + 107 => + array ( + 'id' => 113, + 'key' => 's3_bucket', + 'value' => NULL, + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'S3 Bucket Name', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-14 23:25:23', + ), + 108 => + array ( + 'id' => 118, + 'key' => 'ai_deepseek_key', + 'value' => 'password', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'DeepSeek API Key', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-14 06:37:00', + ), + 109 => + array ( + 'id' => 121, + 'key' => 'ai_openrouter_key', + 'value' => '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', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'OpenRouter API Key', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-15 02:02:39', + ), + 110 => + array ( + 'id' => 120, + 'key' => 'ai_mistral_key', + 'value' => 'CJo3XXTBOXPYm1UKyOByGi9iDpF8REer', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Mistral AI API Key', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-14 10:17:59', + ), + 111 => + array ( + 'id' => 97, + 'key' => 'page_tos_content', + 'value' => '

Syarat dan Ketentuan Penggunaan (Terms of Use)

Terakhir Diperbarui: 03 Mei 2026

Selamat datang di Biiproject. Dengan mengakses atau menggunakan layanan kami, Anda dianggap telah membaca, memahami, dan menyetujui untuk terikat oleh Ketentuan berikut:

1. Pendaftaran Akun

Pengguna wajib memberikan informasi yang akurat dan lengkap selama proses registrasi. Anda bertanggung jawab penuh atas kerahasiaan kredensial akun Anda.

2. Pembatasan Penggunaan

Anda dilarang menggunakan layanan untuk tindakan ilegal, mendistribusikan malware, atau melakukan reverse engineering terhadap kode sumber aplikasi kami.

3. Hak Kekayaan Intelektual

Seluruh logo, desain, dan kode program dalam Biiproject adalah milik sah kami dan dilindungi oleh Undang-Undang Hak Cipta yang berlaku.

4. Batasan Tanggung Jawab

Biiproject tidak bertanggung jawab atas kerugian tidak langsung yang timbul dari penggunaan atau ketidakmampuan penggunaan layanan.

', + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Terms of Use Content', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 18:15:47', + ), + 112 => + array ( + 'id' => 56, + 'key' => 'google_client_id', + 'value' => '949708152906-dhtd19q12ta7o8m3ojelug888vhc854q.apps.googleusercontent.com', + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Google Client ID', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:17:01', + ), + 113 => + array ( + 'id' => 114, + 'key' => 's3_endpoint', + 'value' => NULL, + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'S3 Custom Endpoint (Optional)', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-14 23:25:23', + ), + 114 => + array ( + 'id' => 115, + 'key' => 'ai_gpt_key', + 'value' => NULL, + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'OpenAI GPT API Key', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-14 23:25:23', + ), + 115 => + array ( + 'id' => 116, + 'key' => 'ai_gemini_key', + 'value' => 'AIzaSyCF4kJCd2ETfh8HS5dvqTp7NgpD7d9rUgQ', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Google Gemini API Key', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-15 02:02:06', + ), + 116 => + array ( + 'id' => 105, + 'key' => 'ai_default_model', + 'value' => 'openai/gpt-4o', + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Default AI model', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-15 02:02:39', + ), + 117 => + array ( + 'id' => 117, + 'key' => 'ai_claude_key', + 'value' => NULL, + 'type' => 'string', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Anthropic Claude API Key', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-14 23:25:23', + ), + 118 => + array ( + 'id' => 122, + 'key' => 'ai_system_instruction', + 'value' => NULL, + 'type' => 'text', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Default system instruction', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-14 23:25:23', + ), + 119 => + array ( + 'id' => 127, + 'key' => 'enable_landing_page', + 'value' => '0', + 'type' => 'bool', + 'group' => 'branding', + 'is_public' => true, + 'description' => 'Enable/Disable the landing page (welcome page)', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-15 00:35:10', + ), + 120 => + array ( + 'id' => 108, + 'key' => 'gdrive_client_id', + 'value' => '949708152906-dhtd19q12ta7o8m3ojelug888vhc854q.apps.googleusercontent.com', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Google Drive Client ID', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-15 00:35:10', + ), + 121 => + array ( + 'id' => 109, + 'key' => 'gdrive_client_secret', + 'value' => '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', + 'type' => 'string', + 'group' => 'backups', + 'is_public' => false, + 'description' => 'Google Drive Client Secret', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-15 00:35:10', + ), + 122 => + array ( + 'id' => 102, + 'key' => 'ai_enabled', + 'value' => '1', + 'type' => 'bool', + 'group' => 'ai_config', + 'is_public' => false, + 'description' => 'Enable AI services', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-15 02:06:32', + ), + 123 => + array ( + 'id' => 36, + 'key' => 'github_client_secret', + 'value' => NULL, + 'type' => 'string', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'GitHub Client Secret', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 23:25:23', + ), + 124 => + array ( + 'id' => 123, + 'key' => 'page_about_content', + 'value' => '

Tentang Biiproject

Biiproject adalah sebuah ekosistem solusi bisnis digital mutakhir yang dirancang khusus untuk era transformasi industri 4.0. Kami menghadirkan integrasi antara efisiensi alur kerja dengan kecerdasan buatan (AI) untuk membantu perusahaan dan tim profesional mencapai produktivitas maksimal.

Visi Kami

Menjadi pionir dalam penyediaan infrastruktur kolaborasi digital yang paling aman dan intuitif di Asia Tenggara.

Misi Kami

  • Inovasi Berkelanjutan: Mengembangkan fitur-fitur yang relevan dengan kebutuhan pasar global.
  • Keamanan Tanpa Kompromi: Melindungi setiap bit data pengguna dengan standar enkripsi tertinggi.
  • Aksesibilitas Global: Memastikan platform dapat diakses dari berbagai perangkat dengan performa yang tetap stabil.
', + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'About Us Content', + 'created_by' => 1, + 'updated_by' => 1, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:04:11', + ), + 125 => + array ( + 'id' => 46, + 'key' => 'feature_notification_center', + 'value' => '1', + 'type' => 'bool', + 'group' => 'feature_flags', + 'is_public' => false, + 'description' => 'Toggle notification center feature', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-15 01:34:41', + ), + 126 => + array ( + 'id' => 136, + 'key' => 'ai_healing_allow_db', + 'value' => '1', + 'type' => 'bool', + 'group' => 'ai_healing', + 'is_public' => false, + 'description' => 'Allow Database Migration', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-15 02:04:34', + 'updated_at' => '2026-05-15 02:05:19', + ), + 127 => + array ( + 'id' => 129, + 'key' => 'sap_rfc_user', + 'value' => 'developer@biiproject.com', + 'type' => 'string', + 'group' => 'sap_integration', + 'is_public' => false, + 'description' => 'SAP Username', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:04:12', + 'updated_at' => '2026-05-14 10:17:59', + ), + 128 => + array ( + 'id' => 130, + 'key' => 'sap_rfc_passwd', + 'value' => 'password', + 'type' => 'string', + 'group' => 'sap_integration', + 'is_public' => false, + 'description' => 'SAP Password', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:04:12', + 'updated_at' => '2026-05-14 10:17:59', + ), + 129 => + array ( + 'id' => 135, + 'key' => 'engine_horizon_enabled', + 'value' => '1', + 'type' => 'bool', + 'group' => 'monitoring', + 'is_public' => false, + 'description' => 'Enable Laravel Horizon (Redis Queue Monitor)', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-13 16:16:28', + 'updated_at' => '2026-05-15 03:05:38', + ), + 130 => + array ( + 'id' => 132, + 'key' => 'engine_pulse_enabled', + 'value' => '1', + 'type' => 'bool', + 'group' => 'monitoring', + 'is_public' => false, + 'description' => 'Enable Laravel Pulse recording and dashboard', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-13 16:16:28', + 'updated_at' => '2026-05-15 03:05:48', + ), + 131 => + array ( + 'id' => 133, + 'key' => 'engine_telescope_enabled', + 'value' => '1', + 'type' => 'bool', + 'group' => 'monitoring', + 'is_public' => false, + 'description' => 'Enable Laravel Telescope recording and dashboard', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-13 16:16:28', + 'updated_at' => '2026-05-15 03:05:49', + ), + 132 => + array ( + 'id' => 134, + 'key' => 'engine_swagger_enabled', + 'value' => '1', + 'type' => 'bool', + 'group' => 'monitoring', + 'is_public' => false, + 'description' => 'Enable API Documentation (L5-Swagger)', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-13 16:16:28', + 'updated_at' => '2026-05-15 03:05:49', + ), + 133 => + array ( + 'id' => 99, + 'key' => 'page_privacy_content', + 'value' => '

Kebijakan Privasi (Sesuai UU PDP No. 27/2022)

Kami berkomitmen penuh untuk melindungi data pribadi Anda sesuai dengan regulasi pelindungan data terbaru di Indonesia.

1. Data yang Kami Kumpulkan

  • Data Identitas: Nama, alamat email, nomor telepon, dan foto profil.
  • Data Teknis: Alamat IP, jenis perangkat, sistem operasi, dan log aktivitas aplikasi.
  • Data Biometrik: Digunakan secara lokal pada perangkat untuk fitur keamanan login.

2. Tujuan Pemrosesan Data

Data digunakan untuk verifikasi identitas, pengiriman notifikasi penting, serta peningkatan pengalaman pengguna melalui analitik sistem.

3. Hak Anda sebagai Subjek Data

Sesuai UU PDP, Anda memiliki hak untuk:

  • Mengakses data pribadi Anda yang kami simpan.
  • Meminta perbaikan atau pemutakhiran data jika terjadi ketidakakuratan.
  • Meminta penghapusan data (Hak untuk dilupakan) dalam kondisi tertentu.
  • Menarik kembali persetujuan pemrosesan data sewaktu-waktu.

4. Keamanan dan Retensi

Data Anda disimpan dalam database terenkripsi selama akun Anda aktif atau selama dibutuhkan oleh regulasi hukum yang berlaku.

', + 'type' => 'text', + 'group' => 'content_legal', + 'is_public' => true, + 'description' => 'Privacy Policy (UU PDP) Content', + 'created_by' => 2, + 'updated_by' => 2, + 'created_at' => '2026-05-12 22:01:13', + 'updated_at' => '2026-05-14 18:03:24', + ), + 134 => + array ( + 'id' => 128, + 'key' => 'sap_rfc_ashost', + 'value' => NULL, + 'type' => 'string', + 'group' => 'sap_integration', + 'is_public' => false, + 'description' => 'SAP Application Server Host', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:04:12', + 'updated_at' => '2026-05-14 23:25:23', + ), + 135 => + array ( + 'id' => 131, + 'key' => 'sap_rfc_router', + 'value' => NULL, + 'type' => 'string', + 'group' => 'sap_integration', + 'is_public' => false, + 'description' => 'SAP Router string (optional)', + 'created_by' => 5, + 'updated_by' => 5, + 'created_at' => '2026-05-12 22:04:12', + 'updated_at' => '2026-05-14 23:25:23', + ), + )); + + + } +} \ No newline at end of file diff --git a/database/seeders/UsersTableSeeder.php b/database/seeders/UsersTableSeeder.php new file mode 100644 index 0000000..4ea9c1c --- /dev/null +++ b/database/seeders/UsersTableSeeder.php @@ -0,0 +1,114 @@ +delete(); + + \DB::table('users')->insert(array ( + 0 => + array ( + 'id' => 1, + 'name' => 'Admin', + 'email' => 'admin@biiproject.com', + 'email_verified_at' => '2026-05-12 22:01:14', + 'password' => '$2y$12$b3xJdw.sj00MbqWychUNEuOS/ZYL8Hp8suXrtAsBIW6bmzqTBQZES', + 'remember_token' => NULL, + 'created_at' => '2026-05-12 22:01:14', + 'updated_at' => '2026-05-12 22:01:14', + 'password_changed_at' => NULL, + 'username' => NULL, + 'phone_number' => NULL, + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + 'last_session_id' => NULL, + 'google_id' => NULL, + 'facebook_id' => NULL, + 'github_id' => NULL, + ), + 1 => + array ( + 'id' => 3, + 'name' => 'User', + 'email' => 'user@biiproject.com', + 'email_verified_at' => '2026-05-12 22:01:15', + 'password' => '$2y$12$bKXz5NL0DE5P0HLbooNQU.sLWt21qAD08Pw.m75pX3i69xvgbxhRu', + 'remember_token' => NULL, + 'created_at' => '2026-05-12 22:01:15', + 'updated_at' => '2026-05-13 16:08:00', + 'password_changed_at' => NULL, + 'username' => NULL, + 'phone_number' => NULL, + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + 'last_session_id' => NULL, + 'google_id' => NULL, + 'facebook_id' => NULL, + 'github_id' => NULL, + ), + 2 => + array ( + 'id' => 2, + 'name' => 'Developer', + 'email' => 'developer@biiproject.com', + 'email_verified_at' => '2026-05-12 22:01:15', + 'password' => '$2y$12$6O6erPgiw75ivUASdm95tOEHBG4bCnRjxIygFHH3IPf4EJkVokqrK', + 'remember_token' => NULL, + 'created_at' => '2026-05-12 22:01:15', + 'updated_at' => '2026-05-14 22:57:49', + 'password_changed_at' => NULL, + 'username' => NULL, + 'phone_number' => NULL, + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + 'last_session_id' => 'AFghrj0VNIgvVm1q5pJn0U1x0TnJwX30srp3yKp9', + 'google_id' => NULL, + 'facebook_id' => NULL, + 'github_id' => NULL, + ), + 3 => + array ( + 'id' => 5, + 'name' => 'Debe', + 'email' => 'debesocial@gmail.com', + 'email_verified_at' => '2026-05-14 23:20:40', + 'password' => '$2y$12$LQ1wN1Ws28SmKvZuSG.JdOHBp5FscKsKrXy3V5pO5zPDnBymU2Yku', + 'remember_token' => 'Bp4HtFWjgAL4Xt9oBqxrCMfE8zQjDBD7ErGwvHDIY3nKNOPdtItItfsyrVkt', + 'created_at' => '2026-05-14 23:20:23', + 'updated_at' => '2026-05-14 23:22:44', + 'password_changed_at' => '2026-05-14 23:20:40', + 'username' => 'Debe', + 'phone_number' => NULL, + 'created_by' => NULL, + 'updated_by' => NULL, + 'deleted_at' => NULL, + 'is_active' => true, + 'last_session_id' => NULL, + 'google_id' => NULL, + 'facebook_id' => NULL, + 'github_id' => NULL, + ), + )); + + + } +} \ No newline at end of file