Add blind-index search layer; fix captain detail 200-with-empty-body

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 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-25 15:16:09 +03:00
co-authored by Claude Opus 5
parent 81ee2acefd
commit 6802026dbd
10 changed files with 406 additions and 24 deletions
@@ -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);
@@ -1,9 +1,18 @@
<?php
require_once __DIR__ . '/../../connect.php';
// تشفير driver_id قبل استخدامه في SQL
if ($role !== 'admin' && $role !== 'super_admin') {
http_response_code(403);
echo json_encode(['error' => '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']);
}
+26 -10
View File
@@ -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) {
// ✅ الحقول المشفرة اللي لازم تنفك:
+13
View File
@@ -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);
+101
View File
@@ -0,0 +1,101 @@
<?php
/**
* core/Security/BlindIndex.php
*
* فهرس أعمى للبحث فوق حقول مشفّرة.
*
* المشكلة: التشفير الآمن (AES-GCM) عشوائي — نفس النص ينتج تشفيراً مختلفاً في
* كل مرة، فلا يمكن البحث بمقارنة النص المشفّر. والحل القديم (CBC بـ IV ثابت)
* يجعل التشفير حتمياً فينجح البحث، لكنه يسرّب المساواة والبادئات المشتركة.
*
* الحل: نفصل التخزين عن البحث.
* - التخزين: AES-GCM عشوائي (لا يسرّب شيئاً).
* - البحث: عمود إضافي يحمل HMAC-SHA256 حتمياً للقيمة بعد تطبيعها.
*
* لماذا HMAC وليس sha256 عارياً؟ لأن مساحة أرقام الهواتف صغيرة (ملايين
* قليلة) — جدول عكسي لكل الأرقام يُبنى في ثوانٍ. المفتاح السرّي (pepper)
* المخزَّن في البيئة وحده يمنع ذلك، فمن يسرق قاعدة البيانات لا يملكه.
*/
final class BlindIndex
{
private string $pepper;
public function __construct(?string $pepper = null)
{
$pepper = $pepper ?: (getenv('BLIND_INDEX_PEPPER') ?: '');
if ($pepper === '') {
throw new RuntimeException(
'BLIND_INDEX_PEPPER is not set. Generate one with: openssl rand -hex 32'
);
}
$this->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);
}
}
+11
View File
@@ -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';
@@ -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;
+143
View File
@@ -0,0 +1,143 @@
<?php
/**
* scripts/backfill_blind_index.php
*
* يملأ أعمدة الفهرس الأعمى للسجلات الموجودة.
*
* الاستخدام:
* php backfill_blind_index.php # تشغيل فعلي
* php backfill_blind_index.php --dry-run # عرض ما سيحدث دون كتابة
* php backfill_blind_index.php --table=driver --batch=200
*
* آمن للتشغيل والخدمة تعمل:
* - لا يقرأ ولا يكتب أي عمود مشفّر، يكتب أعمدة الفهرس فقط.
* - يعمل على دفعات مع فاصل قصير حتى لا يضغط القاعدة.
* - قابل لإعادة التشغيل: يتخطى الصفوف المملوءة ما لم يُمرَّر --force.
*/
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("This script runs from the command line only.\n");
}
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/../core/Security/BlindIndex.php';
$options = getopt('', ['dry-run', 'force', 'table::', 'batch::']);
$dryRun = isset($options['dry-run']);
$force = isset($options['force']);
$only = $options['table'] ?? null;
$batch = max(50, (int) ($options['batch'] ?? 500));
/** @var EncryptionHelper $encryptionHelper */
global $encryptionHelper;
try {
$blind = new BlindIndex();
} catch (RuntimeException $e) {
exit("✘ " . $e->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";
+2 -2
View File
@@ -15,7 +15,7 @@
<!-- ?v= must be bumped whenever css/main.css or js/app.js changes: the files
are served straight off a bind mount, so without it browsers keep
running the previously cached build after a deploy. -->
<link rel="stylesheet" href="css/main.css?v=2026-07-25-6">
<link rel="stylesheet" href="css/main.css?v=2026-07-25-7">
</head>
<body>
@@ -609,6 +609,6 @@
</div>
</div>
<script src="js/app.js?v=2026-07-25-6"></script>
<script src="js/app.js?v=2026-07-25-7"></script>
</body>
</html>
+33 -4
View File
@@ -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(() => {}));