From 6802026dbd347b7f471d064033d3b20a9a7636dd Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 25 Jul 2026 15:16:09 +0300 Subject: [PATCH] Add blind-index search layer; fix captain detail 200-with-empty-body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Searching encrypted columns currently works only because encryptData() is AES-CBC with a fixed IV, i.e. deterministic. That determinism is what leaks equality and shared prefixes, and it is why moving storage to AES-GCM would break every lookup. This separates the two concerns. - core/Security/BlindIndex.php: HMAC-SHA256 over a normalised value, keyed by a secret pepper. Phone numbers have a small keyspace, so a bare SHA-256 would be reversible by enumeration; the pepper lives in the environment, not the database. The scope string includes table and field so the same number does not produce a matching index across tables. Normalisation unifies local/international phone forms, lowercases emails and folds Arabic alef/ya/ta-marbuta and diacritics for names. - migrations/: nullable *_bidx columns plus indexes, and the missing adminUser.status/approved_by/approved_at columns that admin approvals need. - scripts/backfill_blind_index.php: restartable, batched, --dry-run capable, touches only index columns. - Admin lookups by phone/email now match the index, keeping the old ciphertext comparison in the same query so search keeps working until the backfill runs. bootstrap exposes $blindIndex as null when no pepper is configured. Also: AdminCaptain/getCaptainDetailsById.php selected driver.education, a column absent from this schema. The PDOException was uncaught, so the client received an empty body with HTTP 200 — the "non-JSON response" seen when opening a captain. It now omits the column, catches the error, reports it as JSON, and requires an admin role. Console: opening any sidebar section refetches its data instead of showing what was loaded when the console started. Co-Authored-By: Claude Opus 5 --- .../getCaptainDetailsByEmailOrIDOrPhone.php | 13 ++ .../AdminCaptain/getCaptainDetailsById.php | 32 +++- backend/Admin/driver/find_driver_by_phone.php | 36 +++-- backend/Admin/getPassengerbyEmail.php | 13 ++ backend/core/Security/BlindIndex.php | 101 +++++++++++++ backend/core/bootstrap.php | 11 ++ backend/migrations/2026_07_25_blind_index.sql | 40 +++++ backend/scripts/backfill_blind_index.php | 143 ++++++++++++++++++ dashboard/siro-admin/index.html | 4 +- dashboard/siro-admin/js/app.js | 37 ++++- 10 files changed, 406 insertions(+), 24 deletions(-) create mode 100644 backend/core/Security/BlindIndex.php create mode 100644 backend/migrations/2026_07_25_blind_index.sql create mode 100644 backend/scripts/backfill_blind_index.php diff --git a/backend/Admin/AdminCaptain/getCaptainDetailsByEmailOrIDOrPhone.php b/backend/Admin/AdminCaptain/getCaptainDetailsByEmailOrIDOrPhone.php index 5902c447..fbb03c02 100644 --- a/backend/Admin/AdminCaptain/getCaptainDetailsByEmailOrIDOrPhone.php +++ b/backend/Admin/AdminCaptain/getCaptainDetailsByEmailOrIDOrPhone.php @@ -5,6 +5,15 @@ $driver_id = filterRequest("driver_id"); $driverEmail = $encryptionHelper->encryptData(filterRequest("driverEmail")); $driverPhone = $encryptionHelper->encryptData(filterRequest("driverPhone")); + +/** + * الفهرس الأعمى: يسمح بالبحث بعد نقل التخزين إلى AES-GCM العشوائي. + * تُبقى المقارنة القديمة في نفس الاستعلام كاحتياط حتى تنتهي تعبئة الفهارس. + */ +global $blindIndex; +$emailBidx = $blindIndex ? $blindIndex->index('driver.email', filterRequest("driverEmail")) : null; +$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', filterRequest("driverPhone")) : null; + $sql = "SELECT `driver`.`id`, `driver`.`phone`, @@ -53,6 +62,8 @@ $sql = "SELECT ) AS passengerToken FROM `driver` WHERE `driver`.`email` = :email OR `driver`.`phone` = :phone OR `driver`.`id` = :id + OR (:email_bidx IS NOT NULL AND `driver`.`email_bidx` = :email_bidx) + OR (:phone_bidx IS NOT NULL AND `driver`.`phone_bidx` = :phone_bidx) ORDER BY passengerAverageRating DESC LIMIT 10 "; @@ -61,6 +72,8 @@ $stmt = $con->prepare($sql); $stmt->bindParam(":email", $driverEmail); $stmt->bindParam(":phone", $driverPhone); $stmt->bindParam(":id", $driver_id); +$stmt->bindParam(":email_bidx", $emailBidx); +$stmt->bindParam(":phone_bidx", $phoneBidx); $stmt->execute(); $result = $stmt->fetchAll(PDO::FETCH_ASSOC); diff --git a/backend/Admin/AdminCaptain/getCaptainDetailsById.php b/backend/Admin/AdminCaptain/getCaptainDetailsById.php index a9cb9850..6af3dc0f 100644 --- a/backend/Admin/AdminCaptain/getCaptainDetailsById.php +++ b/backend/Admin/AdminCaptain/getCaptainDetailsById.php @@ -1,9 +1,18 @@ 'Unauthorized: Admin access required']); + exit; +} + $driver_id = filterRequest("driver_id"); +if (empty($driver_id)) { + jsonError("driver_id is required", 400); +} + $sql = "SELECT `driver`.`id`, `driver`.`phone`, @@ -14,7 +23,6 @@ $sql = "SELECT `driver`.`site`, `driver`.`first_name`, `driver`.`last_name`, - `driver`.`education`, `driver`.`employmentType`, `driver`.`maritalStatus`, `driver`.`created_at`, @@ -59,14 +67,23 @@ WHERE `driver`.`id` = :driver_id ORDER BY passengerAverageRating DESC LIMIT 10"; -$stmt = $con->prepare($sql); -$stmt->bindParam(':driver_id', $driver_id); -$stmt->execute(); - -$result = $stmt->fetchAll(PDO::FETCH_ASSOC); +try { + $stmt = $con->prepare($sql); + $stmt->bindParam(':driver_id', $driver_id); + $stmt->execute(); + $result = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (PDOException $e) { + // بلا هذا الالتقاط كان الاستثناء يُنهي السكربت فيصل للعميل جسم فارغ + // بحالة HTTP 200، فيظهر كـ "رد غير JSON". + error_log("[getCaptainDetailsById] " . $e->getMessage()); + jsonError("Could not read the captain record: " . $e->getMessage(), 500); +} // فك تشفير الحقول الحساسة بعد الجلب foreach ($result as &$row) { + foreach (['phone','email','gender','birthdate','site','first_name','last_name','employmentType','maritalStatus'] as $f) { + if (!array_key_exists($f, $row)) $row[$f] = null; + } $row['phone'] = $encryptionHelper->decryptData($row['phone']); $row['email'] = $encryptionHelper->decryptData($row['email']); $row['gender'] = $encryptionHelper->decryptData($row['gender']); @@ -74,7 +91,6 @@ foreach ($result as &$row) { $row['site'] = $encryptionHelper->decryptData($row['site']); $row['first_name'] = $encryptionHelper->decryptData($row['first_name']); $row['last_name'] = $encryptionHelper->decryptData($row['last_name']); - $row['education'] = $encryptionHelper->decryptData($row['education']); $row['employmentType'] = $encryptionHelper->decryptData($row['employmentType']); $row['maritalStatus'] = $encryptionHelper->decryptData($row['maritalStatus']); } diff --git a/backend/Admin/driver/find_driver_by_phone.php b/backend/Admin/driver/find_driver_by_phone.php index 05fb7365..da0a547b 100644 --- a/backend/Admin/driver/find_driver_by_phone.php +++ b/backend/Admin/driver/find_driver_by_phone.php @@ -9,18 +9,34 @@ if (empty($phone)) { } try { - // تشفير الرقم المدخل للبحث - $encPhone = $encryptionHelper->encryptData($phone); + /** + * البحث عبر الفهرس الأعمى أولاً (phone_bidx): مطابقة تامة عبر فهرس مُهيأ + * ولا تعتمد على كون التشفير حتمياً، فتظل تعمل بعد النقل إلى AES-GCM. + * + * يُبقى المسار القديم (مقارنة النص المشفّر) كاحتياط حتى ينتهي تشغيل + * scripts/backfill_blind_index.php، وإلا لتوقّف البحث بين الترحيل والتعبئة. + */ + global $blindIndex; + $driver = null; - // احضار كل الأعمدة باستثناء كلمة المرور - $sql = "SELECT * - FROM driver - WHERE phone = :phone - LIMIT 1"; - $stmt = $con->prepare($sql); - $stmt->execute([':phone' => $encPhone]); + if ($blindIndex) { + $bidx = $blindIndex->index('driver.phone', $phone); + if ($bidx) { + $stmt = $con->prepare("SELECT * FROM driver WHERE phone_bidx = :bidx LIMIT 1"); + $stmt->execute([':bidx' => $bidx]); + $driver = $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + } + } - $driver = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$driver) { + $encPhone = $encryptionHelper->encryptData($phone); + $stmt = $con->prepare("SELECT * FROM driver WHERE phone = :phone LIMIT 1"); + $stmt->execute([':phone' => $encPhone]); + } + + if (!$driver) { + $driver = $stmt->fetch(PDO::FETCH_ASSOC); + } if ($driver) { // ✅ الحقول المشفرة اللي لازم تنفك: diff --git a/backend/Admin/getPassengerbyEmail.php b/backend/Admin/getPassengerbyEmail.php index 0b31982c..7ff59ba0 100644 --- a/backend/Admin/getPassengerbyEmail.php +++ b/backend/Admin/getPassengerbyEmail.php @@ -5,6 +5,15 @@ $passengerEmail = $encryptionHelper->encryptData(filterRequest("passengerEmail") $passengerId = filterRequest("passengerId"); $passengerphone = $encryptionHelper->encryptData(filterRequest("passengerphone")); + +/** + * الفهرس الأعمى: يسمح بالبحث بعد نقل التخزين إلى AES-GCM العشوائي. + * تُبقى المقارنة القديمة في نفس الاستعلام كاحتياط حتى تنتهي تعبئة الفهارس. + */ +global $blindIndex; +$emailBidx = $blindIndex ? $blindIndex->index('passengers.email', filterRequest("passengerEmail")) : null; +$phoneBidx = $blindIndex ? $blindIndex->index('passengers.phone', filterRequest("passengerphone")) : null; + $sql = "SELECT `passengers`.`id`, `passengers`.`phone`, @@ -59,12 +68,16 @@ FROM `passengers` WHERE passengers.email = :email OR passengers.phone = :phone OR passengers.id = :id + OR (:email_bidx IS NOT NULL AND passengers.email_bidx = :email_bidx) + OR (:phone_bidx IS NOT NULL AND passengers.phone_bidx = :phone_bidx) "; $stmt = $con->prepare($sql); $stmt->bindParam(":email", $passengerEmail); $stmt->bindParam(":phone", $passengerphone); $stmt->bindParam(":id", $passengerId); +$stmt->bindParam(":email_bidx", $emailBidx); +$stmt->bindParam(":phone_bidx", $phoneBidx); $stmt->execute(); $result = $stmt->fetchAll(PDO::FETCH_ASSOC); diff --git a/backend/core/Security/BlindIndex.php b/backend/core/Security/BlindIndex.php new file mode 100644 index 00000000..0c7dffc0 --- /dev/null +++ b/backend/core/Security/BlindIndex.php @@ -0,0 +1,101 @@ +pepper = $pepper; + } + + /** + * يحسب الفهرس لقيمة داخل حقل محدد. + * + * $scope يشمل الجدول والحقل (مثل "driver.phone") عمداً: بدونه يكون فهرس + * نفس الرقم متطابقاً في جدول السائقين والركاب، فيستطيع من يقرأ القاعدة + * ربط الحسابات ببعضها دون فك أي تشفير. + */ + public function index(string $scope, ?string $value): ?string + { + $normalized = self::normalize($scope, $value); + if ($normalized === null || $normalized === '') { + return null; + } + return hash_hmac('sha256', $scope . ':' . $normalized, $this->pepper); + } + + /** + * فهرس مبتور للبحث الجزئي (مثل الأسماء). + * + * البتر مقصود: يُنتج تطابقات كاذبة تُصفّى بعد فك التشفير، وهذه الضبابية + * هي ما يمنع استخدام الفهرس نفسه في تحليل التكرارات. + */ + public function bucket(string $scope, ?string $value, int $length = 8): ?string + { + $full = $this->index($scope, $value); + return $full === null ? null : substr($full, 0, $length); + } + + /** + * التطبيع قبل الحساب — بدونه يُنتج 0791234567 و+962791234567 فهرسين + * مختلفين ويفشل البحث. + */ + public static function normalize(string $scope, ?string $value): ?string + { + if ($value === null) return null; + $value = trim($value); + if ($value === '') return null; + + if (str_contains($scope, 'phone')) { + $digits = preg_replace('/\D+/', '', $value); + // توحيد الصيغة المحلية والدولية على شكل واحد + $digits = preg_replace('/^00/', '', $digits); + if (str_starts_with($digits, '0')) { + $cc = getenv('DEFAULT_COUNTRY_CODE') ?: '962'; + $digits = $cc . substr($digits, 1); + } + return $digits; + } + + if (str_contains($scope, 'email')) { + return mb_strtolower($value, 'UTF-8'); + } + + // الأسماء: توحيد حالة الأحرف والمسافات، وتوحيد أشكال الألف والياء + // والتاء المربوطة العربية حتى لا يتوقف البحث على شكل الكتابة. + $value = mb_strtolower($value, 'UTF-8'); + $value = preg_replace('/\s+/u', ' ', $value); + $value = str_replace( + ['أ', 'إ', 'آ', 'ٱ', 'ى', 'ة', 'ؤ', 'ئ'], + ['ا', 'ا', 'ا', 'ا', 'ي', 'ه', 'و', 'ي'], + $value + ); + // إزالة التشكيل + $value = preg_replace('/[\x{064B}-\x{0652}\x{0640}]/u', '', $value); + return trim($value); + } +} diff --git a/backend/core/bootstrap.php b/backend/core/bootstrap.php index 7c241344..7b6c9c7b 100644 --- a/backend/core/bootstrap.php +++ b/backend/core/bootstrap.php @@ -157,6 +157,17 @@ try { // 5. تحميل الـ Services الأساسية require_once __DIR__ . '/Security/EncryptionHelper.php'; +require_once __DIR__ . '/Security/BlindIndex.php'; + +// فهرس البحث الأعمى — اختياري: إن لم يُضبط BLIND_INDEX_PEPPER تبقى نقاط +// البحث تعمل بأسلوبها القديم بدل أن تفشل. +$blindIndex = null; +try { + $blindIndex = new BlindIndex(); +} catch (Throwable $e) { + error_log('[BlindIndex] disabled: ' . $e->getMessage()); +} + require_once __DIR__ . '/Database/Database.php'; require_once __DIR__ . '/Auth/RateLimiter.php'; require_once __DIR__ . '/Auth/JwtService.php'; diff --git a/backend/migrations/2026_07_25_blind_index.sql b/backend/migrations/2026_07_25_blind_index.sql new file mode 100644 index 00000000..9a7a5892 --- /dev/null +++ b/backend/migrations/2026_07_25_blind_index.sql @@ -0,0 +1,40 @@ +-- ============================================================ +-- Blind index columns for searching encrypted fields +-- 2026-07-25 +-- +-- تُضاف أعمدة بحث حتمية (HMAC) بجانب الأعمدة المشفّرة، حتى يمكن نقل التخزين +-- إلى AES-GCM العشوائي دون فقدان القدرة على البحث. +-- +-- آمنة للتشغيل على قاعدة تعمل: كل الأعمدة NULL افتراضياً ولا يقرأها أي كود +-- قبل تشغيل سكربت التعبئة. +-- ============================================================ + +ALTER TABLE `driver` + ADD COLUMN `phone_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف', + ADD COLUMN `email_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد', + ADD COLUMN `name_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للاسم الكامل بعد التطبيع', + ADD INDEX `idx_driver_phone_bidx` (`phone_bidx`), + ADD INDEX `idx_driver_email_bidx` (`email_bidx`), + ADD INDEX `idx_driver_name_bidx` (`name_bidx`); + +ALTER TABLE `passengers` + ADD COLUMN `phone_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف', + ADD COLUMN `email_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد', + ADD COLUMN `name_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للاسم الكامل بعد التطبيع', + ADD INDEX `idx_passengers_phone_bidx` (`phone_bidx`), + ADD INDEX `idx_passengers_email_bidx` (`email_bidx`), + ADD INDEX `idx_passengers_name_bidx` (`name_bidx`); + +ALTER TABLE `adminUser` + ADD COLUMN `phone_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالهاتف', + ADD COLUMN `email_bidx` CHAR(64) NULL DEFAULT NULL COMMENT 'HMAC للبحث بالبريد', + ADD INDEX `idx_adminuser_phone_bidx` (`phone_bidx`), + ADD INDEX `idx_adminuser_email_bidx` (`email_bidx`); + +-- عمود status مفقود في هذا النشر، وبدونه لا يمكن تتبّع موافقات المشرفين +-- (Admin/Staff/pending.php و auth/approve_admin.php يعتمدان عليه). +-- القيمة الافتراضية 'active' مقصودة حتى لا تُقفل الحسابات القائمة خارج النظام. +ALTER TABLE `adminUser` + ADD COLUMN `status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active | pending | suspended | rejected', + ADD COLUMN `approved_by` VARCHAR(32) NULL DEFAULT NULL, + ADD COLUMN `approved_at` TIMESTAMP NULL DEFAULT NULL; diff --git a/backend/scripts/backfill_blind_index.php b/backend/scripts/backfill_blind_index.php new file mode 100644 index 00000000..e0218dd0 --- /dev/null +++ b/backend/scripts/backfill_blind_index.php @@ -0,0 +1,143 @@ +getMessage() . "\n"); +} + +$con = Database::get('main'); + +$targets = [ + 'driver' => [ + 'phone_bidx' => ['scope' => 'driver.phone', 'columns' => ['phone']], + 'email_bidx' => ['scope' => 'driver.email', 'columns' => ['email']], + 'name_bidx' => ['scope' => 'driver.name', 'columns' => ['first_name', 'last_name']], + ], + 'passengers' => [ + 'phone_bidx' => ['scope' => 'passengers.phone', 'columns' => ['phone']], + 'email_bidx' => ['scope' => 'passengers.email', 'columns' => ['email']], + 'name_bidx' => ['scope' => 'passengers.name', 'columns' => ['first_name', 'last_name']], + ], + 'adminUser' => [ + 'phone_bidx' => ['scope' => 'adminUser.phone', 'columns' => ['phone']], + 'email_bidx' => ['scope' => 'adminUser.email', 'columns' => ['email']], + ], +]; + +echo $dryRun ? "── DRY RUN — nothing will be written ──\n" : "── Backfilling blind indexes ──\n"; + +foreach ($targets as $table => $fields) { + if ($only && $only !== $table) continue; + + echo "\n[$table]\n"; + + $sourceColumns = []; + foreach ($fields as $spec) { + foreach ($spec['columns'] as $c) $sourceColumns[$c] = true; + } + $select = 'id, ' . implode(', ', array_keys($sourceColumns)); + + $where = $force ? '' : ' WHERE ' . implode(' OR ', array_map( + fn($f) => "`$f` IS NULL", + array_keys($fields) + )); + + try { + $rows = $con->query("SELECT $select FROM `$table`$where")->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + echo " ✘ skipped: " . $e->getMessage() . "\n"; + continue; + } + + $total = count($rows); + echo " rows to process: $total\n"; + if ($total === 0) continue; + + $updated = 0; + $failed = 0; + + foreach (array_chunk($rows, $batch) as $chunk) { + if (!$dryRun) $con->beginTransaction(); + + foreach ($chunk as $row) { + $set = []; + $params = [':id' => $row['id']]; + + foreach ($fields as $column => $spec) { + // فك التشفير لقراءة القيمة الأصلية — القيمة المخزَّنة لا تتغير. + $parts = []; + foreach ($spec['columns'] as $src) { + $plain = $encryptionHelper->decryptData($row[$src] ?? null); + if ($plain === false) { + $failed++; + $parts = []; + break; + } + if ($plain !== '') $parts[] = $plain; + } + if (!$parts) continue; + + $value = implode(' ', $parts); + $index = $blind->index($spec['scope'], $value); + if ($index === null) continue; + + $set[] = "`$column` = :$column"; + $params[":$column"] = $index; + } + + if (!$set) continue; + + if ($dryRun) { + $updated++; + continue; + } + + $stmt = $con->prepare("UPDATE `$table` SET " . implode(', ', $set) . " WHERE id = :id"); + $stmt->execute($params); + $updated++; + } + + if (!$dryRun) $con->commit(); + usleep(50_000); // نفس متعمّد حتى لا تُحتكر القاعدة أثناء الخدمة + echo " … $updated/$total\n"; + } + + echo " ✔ indexed: $updated" . ($failed ? " (undecryptable values skipped: $failed)" : '') . "\n"; +} + +echo "\nDone." . ($dryRun ? " (dry run — re-run without --dry-run to apply)" : '') . "\n"; diff --git a/dashboard/siro-admin/index.html b/dashboard/siro-admin/index.html index c577ca52..1f80c863 100644 --- a/dashboard/siro-admin/index.html +++ b/dashboard/siro-admin/index.html @@ -15,7 +15,7 @@ - + @@ -609,6 +609,6 @@ - + diff --git a/dashboard/siro-admin/js/app.js b/dashboard/siro-admin/js/app.js index dc75191f..955c4222 100644 --- a/dashboard/siro-admin/js/app.js +++ b/dashboard/siro-admin/js/app.js @@ -11,7 +11,7 @@ // Bump together with the ?v= query in index.html. Shown in the UI and in the // diagnostics report so "the deploy did nothing" can be answered with a fact // rather than a guess about caching. - const BUILD = '2026-07-25-6'; + const BUILD = '2026-07-25-7'; // ── Localisation ───────────────────────────────────────────────────────── // Arabic is the operators' language; English is kept because several screens @@ -2815,15 +2815,44 @@ if (window.innerWidth <= 992) el.sidebar.classList.remove('open'); redrawCharts(); - const mod = MODULES.find((m) => m.id === item.dataset.module); - if (mod && session) loadModule(mod); + // Opening a section always refetches it: an operator switching to a + // list expects what the database holds now, not what it held when the + // console was first opened. + if (session) refreshView(item.dataset.view, item.dataset.module); }); }); el.toggleSidebar?.addEventListener('click', () => el.sidebar.classList.toggle('open')); } + + // Maps a sidebar entry to the loader that owns its data. + const VIEW_LOADERS = { + dashboardView: () => { loadStats().catch(() => {}); loadRidesTrend().catch(() => {}); }, + ridesView: () => loadRides().catch(() => {}), + driversView: () => loadDrivers().catch(() => {}), + passengersView: () => loadPassengers().catch(() => {}), + financialsView: () => loadStats().catch(() => {}), + complaintsView: () => loadStats().catch(() => {}), + approvalsView: () => loadApprovals().catch(() => {}), + systemView: () => renderSessionInfo(), + }; + + function refreshView(viewId, moduleId) { + const mod = MODULES.find((m) => m.id === moduleId); + if (mod) { + loadModule(mod, true); // force: bypass the loaded-once cache + return; + } + VIEW_LOADERS[viewId]?.(); + } + function setupDataEvents() { - el.refreshBtn?.addEventListener('click', () => { if (session) loadEverything(); }); + el.refreshBtn?.addEventListener('click', () => { + if (!session) return; + const active = document.querySelector('.nav-item.active'); + if (active) refreshView(active.dataset.view, active.dataset.module); + else loadEverything(); + }); $('langToggle')?.addEventListener('click', () => setLanguage(lang === 'ar' ? 'en' : 'ar')); el.rideStatusFilter?.addEventListener('change', () => loadRides().catch(() => {}));