diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 5306b33..566c453 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -94,10 +94,10 @@ class AuthController extends Controller } if (!$user) { - $logger = new \App\Services\Logger(); - $logger->warning("Login failed - User not found or not active", [ + $this->logger->warning("Login failed - User not found or not active", [ 'username' => $username, - 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown' + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown' ]); $_SESSION['error'] = 'Invalid username or password'; $this->redirect('/login'); @@ -106,11 +106,23 @@ class AuthController extends Controller // Verify password if (!$this->userModel->verifyPassword($password, $user['password'])) { - $logger = new \App\Services\Logger(); - $logger->warning("Login failed - Password verification failed", [ + $this->logger->warning("Login failed - Wrong password", [ 'username' => $username, - 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown' + 'user_id' => $user['id'], + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown' ]); + + // Notify the target user about failed login attempt (wrong password) + try { + $ipAddress = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $notificationService = new \App\Services\NotificationService(); + $notificationService->notifyFailedLogin($user['id'], 'Wrong password', $ipAddress, $userAgent, $username); + } catch (\Exception $e) { + // Don't block response if notification fails + } + $_SESSION['error'] = 'Invalid username or password'; $this->redirect('/login'); return; @@ -183,6 +195,16 @@ class AuthController extends Controller // Update last login $this->userModel->updateLastLogin($user['id']); + // Create login notification + try { + $ipAddress = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $notificationService = new \App\Services\NotificationService(); + $notificationService->notifyNewLogin($user['id'], 'Direct login', $ipAddress, $userAgent); + } catch (\Exception $e) { + // Don't block login if notification fails + } + // Set success message for login $_SESSION['success'] = 'Login successful! Welcome back, ' . htmlspecialchars($user['full_name']) . '.'; @@ -744,6 +766,19 @@ class AuthController extends Controller // Session is automatically tracked by DatabaseSessionHandler // No need to manually create session record + // Update last login timestamp + $this->userModel->updateLastLogin($user['id']); + + // Create login notification for remember-me auto-login + try { + $ipAddress = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $notificationService = new \App\Services\NotificationService(); + $notificationService->notifyNewLogin($user['id'], 'Remember me', $ipAddress, $userAgent); + } catch (\Exception $e) { + // Don't block login if notification fails + } + return true; } } diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php index 8c8ccbe..23a8d5d 100644 --- a/app/Controllers/DomainController.php +++ b/app/Controllers/DomainController.php @@ -680,28 +680,41 @@ class DomainController extends Controller $availableCount++; } - $domainId = $this->domainModel->create([ - 'domain_name' => $domainName, - 'notification_group_id' => $groupId, - 'registrar' => $whoisData['registrar'], - 'registrar_url' => $whoisData['registrar_url'] ?? null, - 'expiration_date' => $whoisData['expiration_date'], - 'updated_date' => $whoisData['updated_date'] ?? null, - 'abuse_email' => $whoisData['abuse_email'] ?? null, - 'last_checked' => date('Y-m-d H:i:s'), - 'status' => $status, - 'whois_data' => json_encode($whoisData), - 'is_active' => 1, - 'user_id' => \Core\Auth::id() - ]); + try { + $domainId = $this->domainModel->create([ + 'domain_name' => $domainName, + 'notification_group_id' => $groupId, + 'registrar' => $whoisData['registrar'], + 'registrar_url' => $whoisData['registrar_url'] ?? null, + 'expiration_date' => $whoisData['expiration_date'], + 'updated_date' => $whoisData['updated_date'] ?? null, + 'abuse_email' => $whoisData['abuse_email'] ?? null, + 'last_checked' => date('Y-m-d H:i:s'), + 'status' => $status, + 'whois_data' => json_encode($whoisData), + 'is_active' => 1, + 'user_id' => \Core\Auth::id() + ]); - // Handle tags using the new tag system - if (!empty($tags) && $domainId) { - $tagModel = new \App\Models\Tag(); - $tagModel->updateDomainTags($domainId, $tags, $userId); + // Handle tags using the new tag system + if (!empty($tags) && $domainId) { + $tagModel = new \App\Models\Tag(); + $tagModel->updateDomainTags($domainId, $tags, $userId); + } + + $added++; + } catch (\PDOException $e) { + // Handle duplicate key (race condition between existsByDomain check and insert) + if (str_contains($e->getMessage(), 'Duplicate entry')) { + $skipped++; + } else { + $logger->error('Failed to add domain in bulk', [ + 'domain' => $domainName, + 'error' => $e->getMessage() + ]); + $errors[] = $domainName; + } } - - $added++; } // Log bulk add completion diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php index dd87d9d..953c07f 100644 --- a/app/Controllers/InstallerController.php +++ b/app/Controllers/InstallerController.php @@ -54,6 +54,7 @@ class InstallerController extends Controller '021_add_avatar_field.sql', '022_add_pushover_channel_type.sql', '023_update_app_version_to_1.1.1.sql', + '024_add_status_notifications_v1.1.2.sql', ]; try { @@ -194,6 +195,7 @@ class InstallerController extends Controller '021_add_avatar_field.sql', '022_add_pushover_channel_type.sql', '023_update_app_version_to_1.1.1.sql', + '024_add_status_notifications_v1.1.2.sql', ]; } @@ -379,6 +381,7 @@ class InstallerController extends Controller '021_add_avatar_field.sql', '022_add_pushover_channel_type.sql', '023_update_app_version_to_1.1.1.sql', + '024_add_status_notifications_v1.1.2.sql', ]; $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration"); @@ -597,10 +600,12 @@ class InstallerController extends Controller // Determine from/to versions based on migrations $fromVersion = '1.0.0'; - $toVersion = '1.1.1'; + $toVersion = '1.1.2'; // Detect version based on which migrations were run - if (in_array('022_add_pushover_channel_type.sql', $executed)) { + if (in_array('024_add_status_notifications_v1.1.2.sql', $executed)) { + $toVersion = '1.1.2'; + } elseif (in_array('022_add_pushover_channel_type.sql', $executed)) { $toVersion = '1.1.1'; } elseif (in_array('011_create_sessions_table.sql', $executed) || in_array('012_link_remember_tokens_to_sessions.sql', $executed) || diff --git a/app/Controllers/NotificationController.php b/app/Controllers/NotificationController.php index b7113ac..c2b9828 100644 --- a/app/Controllers/NotificationController.php +++ b/app/Controllers/NotificationController.php @@ -57,6 +57,7 @@ class NotificationController extends Controller $notification['time_ago'] = $this->timeAgo($notification['created_at']); $notification['icon'] = $this->getNotificationIcon($notification['type']); $notification['color'] = $this->getNotificationColor($notification['type']); + $notification['login_data'] = \App\Helpers\LayoutHelper::parseLoginData($notification); } $this->view('notifications/index', [ @@ -77,6 +78,7 @@ class NotificationController extends Controller /** * Mark notification as read + * Supports optional redirect to domain if ?redirect=domain */ public function markAsRead($params = []) { @@ -90,6 +92,27 @@ class NotificationController extends Controller } $this->notificationModel->markAsRead($notificationId, $userId); + + // If redirect=domain, go to the domain view page + $redirect = $_GET['redirect'] ?? ''; + if ($redirect === 'domain') { + $domainId = (int)($_GET['domain_id'] ?? 0); + if ($domainId > 0) { + $this->redirect('/domains/' . $domainId); + return; + } + } + + // AJAX request - return JSON (check multiple detection methods) + $isAjax = (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') + || (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false) + || !empty($_GET['ajax']); + if ($isAjax) { + $unreadCount = $this->notificationModel->getUnreadCount($userId); + $this->json(['success' => true, 'id' => $notificationId, 'unread_count' => $unreadCount]); + return; + } + $_SESSION['success'] = 'Notification marked as read'; $this->redirect('/notifications'); } @@ -191,9 +214,14 @@ class NotificationController extends Controller { return match($type) { 'domain_expiring' => 'exclamation-triangle', - 'domain_expired' => 'times-circle', + 'domain_expired', 'domain_expired_status' => 'times-circle', + 'domain_available' => 'check-circle', + 'domain_registered' => 'globe', + 'domain_redemption' => 'hourglass-half', + 'domain_pending_delete' => 'trash-alt', 'domain_updated' => 'sync-alt', 'session_new' => 'sign-in-alt', + 'session_failed' => 'shield-alt', 'whois_failed' => 'exclamation-circle', 'system_welcome' => 'hand-sparkles', 'system_upgrade' => 'arrow-up', @@ -208,9 +236,14 @@ class NotificationController extends Controller { return match($type) { 'domain_expiring' => 'orange', - 'domain_expired' => 'red', + 'domain_expired', 'domain_expired_status' => 'red', + 'domain_available' => 'blue', + 'domain_registered' => 'green', + 'domain_redemption' => 'amber', + 'domain_pending_delete' => 'rose', 'domain_updated' => 'green', 'session_new' => 'blue', + 'session_failed' => 'red', 'whois_failed' => 'gray', 'system_welcome' => 'purple', 'system_upgrade' => 'indigo', diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 3012b9a..a0eafee 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -66,6 +66,9 @@ class SettingsController extends Controller ['label' => 'Weekly (168 hours)', 'value' => 168] ]; + // Status notification triggers + $statusTriggers = $this->settingModel->getNotificationStatusTriggers(); + $this->view('settings/index', [ 'settings' => $settings, 'appSettings' => $appSettings, @@ -75,6 +78,7 @@ class SettingsController extends Controller 'isolationSettings' => $isolationSettings, 'notificationPresets' => $notificationPresets, 'checkIntervalPresets' => $checkIntervalPresets, + 'statusTriggers' => $statusTriggers, 'title' => 'Settings' ]); } @@ -132,9 +136,16 @@ class SettingsController extends Controller return; } + // Update status notification triggers + $statusTriggers = $_POST['notification_status_triggers'] ?? []; + if (!is_array($statusTriggers)) { + $statusTriggers = []; + } + // Save settings $this->settingModel->setValue('notification_days_before', $notificationDays); $this->settingModel->setValue('check_interval_hours', $checkInterval); + $this->settingModel->updateNotificationStatusTriggers($statusTriggers); $_SESSION['success'] = 'Settings updated successfully'; $this->redirect('/settings#monitoring'); diff --git a/app/Controllers/TwoFactorController.php b/app/Controllers/TwoFactorController.php index acabded..c7446fc 100644 --- a/app/Controllers/TwoFactorController.php +++ b/app/Controllers/TwoFactorController.php @@ -295,9 +295,30 @@ class TwoFactorController extends Controller 'method' => $method ]); + // Update last login timestamp + $this->userModel->updateLastLogin($userId); + + // Create login notification + try { + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $notificationService = new \App\Services\NotificationService(); + $notificationService->notifyNewLogin($userId, "2FA ($method)", $ipAddress, $userAgent); + } catch (\Exception $e) { + // Don't block login if notification fails + } + $_SESSION['success'] = 'Login successful! Welcome back, ' . htmlspecialchars($user['full_name']) . '.'; $this->redirect('/'); } else { + // Notify user about failed 2FA attempt + try { + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $notificationService = new \App\Services\NotificationService(); + $notificationService->notifyFailedLogin($userId, 'Failed 2FA verification', $ipAddress, $userAgent, $user['username']); + } catch (\Exception $e) { + // Don't block response if notification fails + } + $_SESSION['error'] = 'Invalid verification code. Please try again.'; $this->redirect('/2fa/verify'); } diff --git a/app/Helpers/DomainHelper.php b/app/Helpers/DomainHelper.php index bb3b188..a54d5ef 100644 --- a/app/Helpers/DomainHelper.php +++ b/app/Helpers/DomainHelper.php @@ -145,6 +145,16 @@ class DomainHelper 'text' => 'Expired', 'icon' => 'fa-times-circle' ], + 'redemption_period' => [ + 'class' => 'bg-amber-100 text-amber-700 border-amber-200', + 'text' => 'Redemption Period', + 'icon' => 'fa-hourglass-half' + ], + 'pending_delete' => [ + 'class' => 'bg-rose-100 text-rose-700 border-rose-200', + 'text' => 'Pending Delete', + 'icon' => 'fa-trash-alt' + ], 'error' => [ 'class' => 'bg-gray-100 text-gray-700 border-gray-200', 'text' => 'Error', diff --git a/app/Helpers/LayoutHelper.php b/app/Helpers/LayoutHelper.php index 6f77203..29ff410 100644 --- a/app/Helpers/LayoutHelper.php +++ b/app/Helpers/LayoutHelper.php @@ -22,6 +22,7 @@ class LayoutHelper $notif['time_ago'] = self::timeAgo($notif['created_at']); $notif['icon'] = self::getNotificationIcon($notif['type']); $notif['color'] = self::getNotificationColor($notif['type']); + $notif['login_data'] = self::parseLoginData($notif); } return [ @@ -52,6 +53,43 @@ class LayoutHelper } } + /** + * Parse session_new notification message (JSON) + * Returns structured data for rich display, or null if not parseable + */ + public static function parseLoginData(array $notification): ?array + { + if ($notification['type'] !== 'session_new' && $notification['type'] !== 'session_failed') { + return null; + } + + $data = json_decode($notification['message'] ?? '', true); + + if (is_array($data) && isset($data['ip'])) { + return $data; + } + + return null; + } + + /** + * Format session_new notification for dropdown display (compact) + */ + public static function formatLoginDropdown(array $loginData): string + { + $parts = []; + if ($loginData['city'] !== 'Unknown' && $loginData['city'] !== 'Local') { + $parts[] = $loginData['city']; + } + if ($loginData['country'] !== 'Unknown' && $loginData['country'] !== 'Local') { + $parts[] = $loginData['country']; + } + $location = !empty($parts) ? implode(', ', $parts) : $loginData['ip']; + + $browser = $loginData['browser'] ?? 'Unknown'; + return "{$location} · {$browser}"; + } + /** * Convert timestamp to "time ago" format */ @@ -83,9 +121,14 @@ class LayoutHelper { return match($type) { 'domain_expiring' => 'exclamation-triangle', - 'domain_expired' => 'times-circle', + 'domain_expired', 'domain_expired_status' => 'times-circle', + 'domain_available' => 'check-circle', + 'domain_registered' => 'globe', + 'domain_redemption' => 'hourglass-half', + 'domain_pending_delete' => 'trash-alt', 'domain_updated' => 'sync-alt', 'session_new' => 'sign-in-alt', + 'session_failed' => 'shield-alt', 'whois_failed' => 'exclamation-circle', 'system_welcome' => 'hand-sparkles', 'system_upgrade' => 'arrow-up', @@ -100,9 +143,14 @@ class LayoutHelper { return match($type) { 'domain_expiring' => 'orange', - 'domain_expired' => 'red', + 'domain_expired', 'domain_expired_status' => 'red', + 'domain_available' => 'blue', + 'domain_registered' => 'green', + 'domain_redemption' => 'amber', + 'domain_pending_delete' => 'rose', 'domain_updated' => 'green', 'session_new' => 'blue', + 'session_failed' => 'red', 'whois_failed' => 'gray', 'system_welcome' => 'purple', 'system_upgrade' => 'indigo', diff --git a/app/Helpers/ViewHelper.php b/app/Helpers/ViewHelper.php index 868a323..103d5b6 100644 --- a/app/Helpers/ViewHelper.php +++ b/app/Helpers/ViewHelper.php @@ -53,6 +53,9 @@ class ViewHelper 'active' => 'bg-green-100 text-green-800 border-green-200', 'expiring_soon' => 'bg-orange-100 text-orange-800 border-orange-200', 'expired' => 'bg-red-100 text-red-800 border-red-200', + 'available' => 'bg-blue-100 text-blue-800 border-blue-200', + 'redemption_period' => 'bg-amber-100 text-amber-800 border-amber-200', + 'pending_delete' => 'bg-rose-100 text-rose-800 border-rose-200', 'inactive' => 'bg-gray-100 text-gray-800 border-gray-200', ]; @@ -60,6 +63,9 @@ class ViewHelper 'active' => 'Active', 'expiring_soon' => 'Expiring Soon', 'expired' => 'Expired', + 'available' => 'Available', + 'redemption_period' => 'Redemption Period', + 'pending_delete' => 'Pending Delete', 'inactive' => 'Inactive', ]; diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 775e3c3..aa1d327 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -122,7 +122,7 @@ class Setting extends Model */ public function getAppVersion(): string { - return $this->getValue('app_version', '1.1.1'); + return $this->getValue('app_version', '1.1.2'); } /** @@ -301,6 +301,27 @@ class Setting extends Model return $result; } + /** + * Get notification status triggers as array + * Returns which domain status changes should trigger notifications + */ + public function getNotificationStatusTriggers(): array + { + $value = $this->getValue('notification_status_triggers', 'available,registered,expired,redemption_period,pending_delete'); + return array_map('trim', explode(',', $value)); + } + + /** + * Update notification status triggers + */ + public function updateNotificationStatusTriggers(array $triggers): bool + { + $validTriggers = ['available', 'registered', 'expired', 'redemption_period', 'pending_delete']; + $triggers = array_intersect($triggers, $validTriggers); + $value = implode(',', $triggers); + return $this->setValue('notification_status_triggers', $value); + } + /** * Clear old notification logs */ diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index e312e89..a13c4e7 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -119,6 +119,95 @@ class NotificationService return $results; } + /** + * Send domain status change notification via external channels + */ + public function sendDomainStatusAlert(array $domain, array $notificationChannels, string $newStatus, string $oldStatus): array + { + $message = $this->formatStatusChangeMessage($domain, $newStatus, $oldStatus); + + $results = []; + + foreach ($notificationChannels as $channel) { + $config = json_decode($channel['channel_config'], true); + $success = $this->send( + $channel['channel_type'], + $config, + $message, + [ + 'domain' => $domain['domain_name'], + 'domain_id' => $domain['id'], + 'new_status' => $newStatus, + 'old_status' => $oldStatus, + 'registrar' => $domain['registrar'] ?? 'Unknown' + ] + ); + + $results[] = [ + 'channel' => $channel['channel_type'], + 'success' => $success + ]; + } + + return $results; + } + + /** + * Format status change notification message + */ + private function formatStatusChangeMessage(array $domain, string $newStatus, string $oldStatus): string + { + $domainName = $domain['domain_name']; + $registrar = $domain['registrar'] ?? 'Unknown'; + $oldStatusLabel = self::getStatusLabel($oldStatus); + $newStatusLabel = self::getStatusLabel($newStatus); + + return match($newStatus) { + 'available' => "🟢 AVAILABLE: Domain '$domainName' is now available for registration!\n\n" . + "Previous status: $oldStatusLabel\n" . + "This domain can now be registered.", + + 'active' => "✅ REGISTERED: Domain '$domainName' is now registered and active.\n\n" . + "Previous status: $oldStatusLabel\n" . + "Registrar: $registrar", + + 'expired' => "🚨 EXPIRED: Domain '$domainName' has expired!\n\n" . + "Previous status: $oldStatusLabel\n" . + "Registrar: $registrar\n" . + "Please renew immediately to avoid losing your domain.", + + 'redemption_period' => "⚠️ REDEMPTION PERIOD: Domain '$domainName' has entered the redemption period!\n\n" . + "Previous status: $oldStatusLabel\n" . + "Registrar: $registrar\n" . + "The domain can still be recovered, but additional fees may apply. Act quickly!", + + 'pending_delete' => "🔴 PENDING DELETE: Domain '$domainName' is scheduled for deletion!\n\n" . + "Previous status: $oldStatusLabel\n" . + "Registrar: $registrar\n" . + "The domain will be released for public registration soon.", + + default => "ℹ️ STATUS CHANGE: Domain '$domainName' status changed from $oldStatusLabel to $newStatusLabel.\n\n" . + "Registrar: $registrar" + }; + } + + /** + * Get human-readable status label + */ + public static function getStatusLabel(string $status): string + { + return match($status) { + 'active' => 'Active', + 'expiring_soon' => 'Expiring Soon', + 'expired' => 'Expired', + 'available' => 'Available', + 'redemption_period' => 'Redemption Period', + 'pending_delete' => 'Pending Delete', + 'error' => 'Error', + default => ucfirst(str_replace('_', ' ', $status)) + }; + } + /** * Format expiration message */ @@ -195,6 +284,67 @@ class NotificationService ); } + /** + * Create a domain available notification (in-app) + */ + public function notifyDomainAvailable(int $userId, string $domainName, int $domainId): void + { + $notificationModel = new \App\Models\Notification(); + $notificationModel->createNotification( + $userId, + 'domain_available', + 'Domain Available', + "{$domainName} is now available for registration", + $domainId + ); + } + + /** + * Create a domain registered notification (in-app) + * Triggered when a domain transitions from available/expired/pending_delete to active + */ + public function notifyDomainRegistered(int $userId, string $domainName, int $domainId): void + { + $notificationModel = new \App\Models\Notification(); + $notificationModel->createNotification( + $userId, + 'domain_registered', + 'Domain Registered', + "{$domainName} has been registered and is now active", + $domainId + ); + } + + /** + * Create a domain redemption period notification (in-app) + */ + public function notifyDomainRedemption(int $userId, string $domainName, int $domainId): void + { + $notificationModel = new \App\Models\Notification(); + $notificationModel->createNotification( + $userId, + 'domain_redemption', + 'Domain in Redemption Period', + "{$domainName} has entered the redemption period - recovery fees may apply", + $domainId + ); + } + + /** + * Create a domain pending delete notification (in-app) + */ + public function notifyDomainPendingDelete(int $userId, string $domainName, int $domainId): void + { + $notificationModel = new \App\Models\Notification(); + $notificationModel->createNotification( + $userId, + 'domain_pending_delete', + 'Domain Pending Deletion', + "{$domainName} is scheduled for deletion and will be available soon", + $domainId + ); + } + /** * Create a domain WHOIS updated notification (in-app) */ @@ -234,20 +384,174 @@ class NotificationService } /** - * Create a new login notification (in-app) + * Create a new login notification (in-app) with rich geolocation data */ - public function notifyNewLogin(int $userId, string $location, string $ipAddress): void + public function notifyNewLogin(int $userId, string $method, string $ipAddress, ?string $userAgent = null): void { + // Get geolocation data + $geo = \App\Models\SessionManager::getGeolocationData($ipAddress); + + // Parse browser/device from user agent + $browser = 'Unknown Browser'; + $device = 'Desktop'; + $deviceIcon = 'desktop'; + + if ($userAgent) { + $ua = strtolower($userAgent); + + // Browser detection + if (strpos($ua, 'edg') !== false) { + $browser = 'Edge'; + } elseif (strpos($ua, 'opr') !== false || strpos($ua, 'opera') !== false) { + $browser = 'Opera'; + } elseif (strpos($ua, 'chrome') !== false) { + $browser = 'Chrome'; + } elseif (strpos($ua, 'safari') !== false) { + $browser = 'Safari'; + } elseif (strpos($ua, 'firefox') !== false) { + $browser = 'Firefox'; + } + + // Device detection + if (strpos($ua, 'mobile') !== false || strpos($ua, 'android') !== false || strpos($ua, 'iphone') !== false) { + $device = 'Mobile'; + $deviceIcon = 'mobile-alt'; + } elseif (strpos($ua, 'tablet') !== false || strpos($ua, 'ipad') !== false) { + $device = 'Tablet'; + $deviceIcon = 'tablet-alt'; + } + + // OS detection + $os = 'Unknown'; + if (strpos($ua, 'windows') !== false) $os = 'Windows'; + elseif (strpos($ua, 'macintosh') !== false || strpos($ua, 'mac os') !== false) $os = 'macOS'; + elseif (strpos($ua, 'linux') !== false) $os = 'Linux'; + elseif (strpos($ua, 'android') !== false) $os = 'Android'; + elseif (strpos($ua, 'iphone') !== false || strpos($ua, 'ipad') !== false) $os = 'iOS'; + } + + // Build location string + $locationParts = []; + if ($geo['city'] !== 'Unknown' && $geo['city'] !== 'Local') { + $locationParts[] = $geo['city']; + } + if ($geo['country'] !== 'Unknown' && $geo['country'] !== 'Local') { + $locationParts[] = $geo['country']; + } + $locationStr = !empty($locationParts) ? implode(', ', $locationParts) : 'Unknown location'; + + // Store rich data as JSON in message field + $messageData = json_encode([ + 'method' => $method, + 'ip' => $ipAddress, + 'country' => $geo['country'], + 'country_code' => $geo['country_code'], + 'city' => $geo['city'], + 'region' => $geo['region'], + 'isp' => $geo['isp'], + 'browser' => $browser, + 'device' => $device, + 'device_icon' => $deviceIcon, + 'os' => $os ?? 'Unknown', + 'location' => $locationStr, + ]); + $notificationModel = new \App\Models\Notification(); $notificationModel->createNotification( $userId, 'session_new', 'New Login Detected', - "Login from {$location} ({$ipAddress})", + $messageData, null ); } + /** + * Create a failed login notification (in-app) with rich geolocation data + */ + public function notifyFailedLogin(int $userId, string $reason, string $ipAddress, ?string $userAgent = null, ?string $attemptedUsername = null): void + { + // Get geolocation data + $geo = \App\Models\SessionManager::getGeolocationData($ipAddress); + + // Parse browser/device from user agent + $browser = 'Unknown Browser'; + $device = 'Desktop'; + $deviceIcon = 'desktop'; + + if ($userAgent) { + $ua = strtolower($userAgent); + + // Browser detection + if (strpos($ua, 'edg') !== false) { + $browser = 'Edge'; + } elseif (strpos($ua, 'opr') !== false || strpos($ua, 'opera') !== false) { + $browser = 'Opera'; + } elseif (strpos($ua, 'chrome') !== false) { + $browser = 'Chrome'; + } elseif (strpos($ua, 'safari') !== false) { + $browser = 'Safari'; + } elseif (strpos($ua, 'firefox') !== false) { + $browser = 'Firefox'; + } + + // Device detection + if (strpos($ua, 'mobile') !== false || strpos($ua, 'android') !== false || strpos($ua, 'iphone') !== false) { + $device = 'Mobile'; + $deviceIcon = 'mobile-alt'; + } elseif (strpos($ua, 'tablet') !== false || strpos($ua, 'ipad') !== false) { + $device = 'Tablet'; + $deviceIcon = 'tablet-alt'; + } + + // OS detection + $os = 'Unknown'; + if (strpos($ua, 'windows') !== false) $os = 'Windows'; + elseif (strpos($ua, 'macintosh') !== false || strpos($ua, 'mac os') !== false) $os = 'macOS'; + elseif (strpos($ua, 'linux') !== false) $os = 'Linux'; + elseif (strpos($ua, 'android') !== false) $os = 'Android'; + elseif (strpos($ua, 'iphone') !== false || strpos($ua, 'ipad') !== false) $os = 'iOS'; + } + + // Build location string + $locationParts = []; + if ($geo['city'] !== 'Unknown' && $geo['city'] !== 'Local') { + $locationParts[] = $geo['city']; + } + if ($geo['country'] !== 'Unknown' && $geo['country'] !== 'Local') { + $locationParts[] = $geo['country']; + } + $locationStr = !empty($locationParts) ? implode(', ', $locationParts) : 'Unknown location'; + + // Store rich data as JSON in message field + $messageData = json_encode([ + 'reason' => $reason, + 'attempted_username' => $attemptedUsername, + 'ip' => $ipAddress, + 'country' => $geo['country'], + 'country_code' => $geo['country_code'], + 'city' => $geo['city'], + 'region' => $geo['region'], + 'isp' => $geo['isp'], + 'browser' => $browser, + 'device' => $device, + 'device_icon' => $deviceIcon, + 'os' => $os ?? 'Unknown', + 'location' => $locationStr, + ]); + + $notificationModel = new \App\Models\Notification(); + $notificationModel->createNotification( + $userId, + 'session_failed', + 'Failed Login Attempt', + $messageData, + null + ); + } + + // Future improvement: Add notifyAdminsFailedLogin() to send in-app alerts to all admins on failed login attempts (e.g. unknown usernames, brute-force detection) + /** * Create welcome notification for new users/fresh install (in-app) */ diff --git a/app/Services/WhoisService.php b/app/Services/WhoisService.php index 51a5b3a..59aaf90 100644 --- a/app/Services/WhoisService.php +++ b/app/Services/WhoisService.php @@ -1107,6 +1107,29 @@ class WhoisService } } + // Check for pending delete status (EPP: pendingDelete) + // Must check before active/registered indicators since a domain can have both + foreach ($statusArray as $status) { + if (stripos($status, 'pendingDelete') !== false || + stripos($status, 'pending delete') !== false || + stripos($status, 'pending_delete') !== false || + stripos($status, 'PENDING-DELETE') !== false) { + return 'pending_delete'; + } + } + + // Check for redemption period status (EPP: redemptionPeriod) + // Must check before active/registered indicators + foreach ($statusArray as $status) { + if (stripos($status, 'redemptionPeriod') !== false || + stripos($status, 'redemption period') !== false || + stripos($status, 'redemption_period') !== false || + stripos($status, 'REDEMPTION-PERIOD') !== false || + stripos($status, 'pendingRestore') !== false) { + return 'redemption_period'; + } + } + // If domain has "active" status but no expiration date, consider it active // This handles TLDs like .nl that don't provide expiration dates via RDAP foreach ($statusArray as $status) { diff --git a/app/Views/domains/index.php b/app/Views/domains/index.php index bd4a77e..f93e6f5 100644 --- a/app/Views/domains/index.php +++ b/app/Views/domains/index.php @@ -68,7 +68,10 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's + + + diff --git a/app/Views/errors/admin-detail.php b/app/Views/errors/admin-detail.php index cc38b72..149daca 100644 --- a/app/Views/errors/admin-detail.php +++ b/app/Views/errors/admin-detail.php @@ -9,14 +9,18 @@ $isResolved = (bool)$error['is_resolved']; $errorTypeShort = substr(strrchr($error['error_type'], '\\'), 1) ?: $error['error_type']; ?> - +
= htmlspecialchars($notif['title']) ?>
- -= htmlspecialchars($notif['message']) ?>
-= $notif['time_ago'] ?>
-= htmlspecialchars($notif['title']) ?>
+ ++ = htmlspecialchars(\App\Helpers\LayoutHelper::formatLoginDropdown($loginData)) ?> +
++ + = htmlspecialchars($loginData['reason'] ?? 'Failed') ?> · = $notif['time_ago'] ?> +
+ + + + + + + + + + + += htmlspecialchars($notif['title']) ?>
+ ++ = htmlspecialchars(\App\Helpers\LayoutHelper::formatLoginDropdown($loginData)) ?> +
++ + = htmlspecialchars($loginData['method'] ?? 'Login') ?> · = $notif['time_ago'] ?> +
+ + + + + + + += htmlspecialchars($notif['title']) ?>
+ += htmlspecialchars($notif['message']) ?>
++ = $notif['time_ago'] ?> + + View domain + +
+ + += htmlspecialchars($notification['message']) ?>
+ + + += htmlspecialchars($notification['message']) ?>
+ + + View domain + + +