Fix dashbord.php parse error; require super_admin on pricing and crypto tools

Hotfix: a comment added to the dashboard SQL contained double quotes inside
the double-quoted PHP string, terminating it and making dashbord.php fail to
parse. Production was returning a parse error for every dashboard request.

Authorisation gaps closed — connect.php only proves a token is valid, it does
not check what the caller is allowed to do:

- Admin/ggg.php decrypts any database field and was authorised solely by an
  admin phone number sent in the request body. Anyone who knew a listed
  number could decrypt platform data without signing in. It now runs behind
  connect.php, requires super_admin, keeps the phone list as a second factor,
  and records every use.
- ride/kazan/update.php, kazan/add.php and ride/promo/{add,update,delete}.php
  changed live pricing and discount codes with no role check at all, so any
  valid token — including a driver's or passenger's — could rewrite the fare
  table. All now require super_admin.

Staff/pending.php: adminUser has no `status` column in this deployment, so
the query failed with an opaque "unavailable". It now checks for the column
and reports the actual reason.

Console: Kazan tariff editor for super admins — sends only changed fields,
shows an old → new confirmation before saving, and stays read-only with an
explanatory notice for plain admins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-25 02:00:14 +03:00
co-authored by Claude Opus 5
parent 852c6ece5c
commit db4ca7dd7a
10 changed files with 299 additions and 12 deletions
+22 -1
View File
@@ -18,7 +18,28 @@ $sources = [];
// كل مصدر يُجلب على حدة: غياب جدول users في بعض عمليات النشر كان يُفشل
// الطلب بالكامل ويخفي طلبات المشرفين المعلقة أيضاً.
/**
* بعض عمليات النشر أنشأت adminUser بلا عمود status (انظر schema_primary.sql)،
* وعندها لا يمكن تمييز الحسابات المعلقة أصلاً. نفحص العمود أولاً لنُرجع سبباً
* واضحاً بدل فشل عام.
*/
function columnExists(PDO $con, string $table, string $column): bool
{
try {
$stmt = $con->prepare("SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?");
$stmt->execute([$table, $column]);
return (int) $stmt->fetchColumn() > 0;
} catch (Throwable $e) {
return false;
}
}
try {
if (!columnExists($con, 'adminUser', 'status')) {
throw new RuntimeException("adminUser.status column is missing — admin approvals cannot be tracked until it is added.");
}
$stmt1 = $con->query("SELECT id, name, phone, role, created_at, 'admin' as type FROM adminUser WHERE status = 'pending'");
$admins = $stmt1->fetchAll(PDO::FETCH_ASSOC);
@@ -32,7 +53,7 @@ try {
$sources['admins'] = 'ok';
} catch (Throwable $e) {
error_log("[Staff Pending] adminUser query failed: " . $e->getMessage());
$sources['admins'] = 'unavailable';
$sources['admins'] = 'unavailable: ' . $e->getMessage();
}
try {
+1 -1
View File
@@ -30,7 +30,7 @@ SELECT
-- إحصائيات وقت ومسافة الرحلات
-- تُستثنى الفروق السالبة (رحلات سجّلت وقت نهاية أقدم من البداية) لأنها
-- كانت تُنتج متوسطاً سالباً مثل "-00h 22m".
-- كانت تُنتج متوسط مدة سالباً.
(SELECT TIME_FORMAT(SEC_TO_TIME(AVG(TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish))), '%Hh %im') FROM ride WHERE rideTimeStart IS NOT NULL AND rideTimeFinish IS NOT NULL AND TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish) > 0) AS driver_avg_duration,
(SELECT MAX(SEC_TO_TIME(TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish))) FROM ride WHERE rideTimeStart IS NOT NULL AND rideTimeFinish IS NOT NULL) AS longest_duration,
(SELECT ROUND(SUM(distance),2) FROM ride) AS total_distance,
+31 -4
View File
@@ -4,17 +4,33 @@
// أداة تشفير وفك تشفير للمشرفين
// ============================================================
require_once __DIR__ . '/../core/bootstrap.php';
// ============================================================
// المصادقة: هذه الأداة تفك تشفير أي حقل في قاعدة البيانات، لذا تمر عبر
// connect.php (JWT + بصمة الجهاز + Rate limiting) ثم تتطلب دور super_admin.
//
// سابقاً كان الإذن الوحيد هو رقم هاتف يُرسل داخل جسم الطلب نفسه — وهو ليس
// سرّاً: أي شخص يعرف رقماً من القائمة كان يستطيع فك تشفير بيانات المنصة
// كاملةً بلا تسجيل دخول. أُبقيت قائمة الأرقام كطبقة ثانية فوق التوكن.
// ============================================================
require_once __DIR__ . '/../connect.php';
// نضمن أن الرد دائماً JSON
header('Content-Type: application/json; charset=utf-8');
if ($role !== 'super_admin') {
securityLog("Unauthorized encrypt/decrypt attempt", [
'user_id' => $user_id ?? 'unknown',
'role' => $role ?? 'none',
]);
jsonError('Forbidden. Super Admin access required.', 403);
}
// 1) قراءة الـ body كـ JSON أو POST
$action = filterRequest('action');
$text = filterRequest('text');
$adminPhoneParam = filterRequest('admin_phone');
// 2) التحقق من رقم هاتف الأدمن المصرّح له
// 2) طبقة ثانية: رقم الهاتف يجب أن يكون ضمن القائمة المصرّح لها (إن وُجدت)
$phonesRaw = getenv('ADMIN_PHONE_NUMBERS') ?: '';
$ALLOWED_TOOL_PHONES = array_values(
array_filter(
@@ -26,11 +42,22 @@ $ALLOWED_TOOL_PHONES = array_values(
$adminPhoneParam = $adminPhoneParam ? preg_replace('/\D+/', '', $adminPhoneParam) : '';
if ($adminPhoneParam === '' || !in_array($adminPhoneParam, $ALLOWED_TOOL_PHONES, true)) {
securityLog("Unauthorized encrypt/decrypt attempt", ['phone' => $adminPhoneParam]);
if (!empty($ALLOWED_TOOL_PHONES)
&& ($adminPhoneParam === '' || !in_array($adminPhoneParam, $ALLOWED_TOOL_PHONES, true))) {
securityLog("Encrypt/decrypt phone not in allow-list", [
'user_id' => $user_id ?? 'unknown',
'phone' => $adminPhoneParam,
]);
jsonError('Access denied for this admin phone.', 403);
}
// 3) سجل تدقيق: كل استخدام لهذه الأداة يُسجَّل مع هوية المنفّذ
securityLog("Encryption tool used", [
'user_id' => $user_id ?? 'unknown',
'action' => $action,
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
]);
if (empty($text) || ($action !== 'encrypt' && $action !== 'decrypt')) {
jsonError('Invalid input: need action=encrypt|decrypt and non-empty text.', 400);
}
+12
View File
@@ -1,6 +1,18 @@
<?php
require_once __DIR__ . '/../../connect.php';
// حارس الصلاحيات: هذه النقطة تعدّل التسعير/الأكواد الترويجية على الإنتاج.
// connect.php يتحقق من صحة التوكن فقط، لذا بدون هذا الفحص كان أي توكن صالح
// (سائق أو راكب) قادراً على تعديلها.
if ($role !== 'super_admin') {
http_response_code(403);
echo json_encode([
'status' => 'failure',
'message' => 'Forbidden. Super Admin access required.',
]);
exit;
}
$kazanPercent = filterRequest("kazanPercent") ?: filterRequest("kazan");
$adminId = filterRequest("adminId");
$fuelPrice = filterRequest("fuelPrice");
+12
View File
@@ -1,6 +1,18 @@
<?php
require_once __DIR__ . '/../../connect.php';
// هذه النقطة تغيّر أجور الركاب على الإنتاج فوراً. connect.php يتحقق من صحة
// التوكن فقط — وبدون فحص الدور كان أي توكن صالح (سائق أو راكب) قادراً على
// تعديل تسعير المنصة بالكامل.
if ($role !== 'super_admin') {
http_response_code(403);
echo json_encode([
'status' => 'failure',
'message' => 'Forbidden. Super Admin access required to change pricing.',
]);
exit;
}
$id = filterRequest("id");
$allowedFields = [
+12
View File
@@ -1,6 +1,18 @@
<?php
require_once __DIR__ . '/../../connect.php';
// حارس الصلاحيات: هذه النقطة تعدّل التسعير/الأكواد الترويجية على الإنتاج.
// connect.php يتحقق من صحة التوكن فقط، لذا بدون هذا الفحص كان أي توكن صالح
// (سائق أو راكب) قادراً على تعديلها.
if ($role !== 'super_admin') {
http_response_code(403);
echo json_encode([
'status' => 'failure',
'message' => 'Forbidden. Super Admin access required.',
]);
exit;
}
$promo_code = filterRequest("promo_code");
$amount = filterRequest("amount");
$description = filterRequest("description");
+12
View File
@@ -1,6 +1,18 @@
<?php
require_once __DIR__ . '/../../connect.php';
// حارس الصلاحيات: هذه النقطة تعدّل التسعير/الأكواد الترويجية على الإنتاج.
// connect.php يتحقق من صحة التوكن فقط، لذا بدون هذا الفحص كان أي توكن صالح
// (سائق أو راكب) قادراً على تعديلها.
if ($role !== 'super_admin') {
http_response_code(403);
echo json_encode([
'status' => 'failure',
'message' => 'Forbidden. Super Admin access required.',
]);
exit;
}
$id = filterRequest("id");
$sql = "DELETE FROM `promos` WHERE `id` = :id";
+12
View File
@@ -1,6 +1,18 @@
<?php
require_once __DIR__ . '/../../connect.php';
// حارس الصلاحيات: هذه النقطة تعدّل التسعير/الأكواد الترويجية على الإنتاج.
// connect.php يتحقق من صحة التوكن فقط، لذا بدون هذا الفحص كان أي توكن صالح
// (سائق أو راكب) قادراً على تعديلها.
if ($role !== 'super_admin') {
http_response_code(403);
echo json_encode([
'status' => 'failure',
'message' => 'Forbidden. Super Admin access required.',
]);
exit;
}
$id = filterRequest("id");
if (empty($id)) {
jsonError("ID is required for update");
+38
View File
@@ -1215,3 +1215,41 @@ h1, h2, h3, h4, h5, h6 {
.coord-link { color: var(--text-muted); text-decoration: none; }
.coord-link:hover { color: var(--primary); }
/* Tariff editor */
.notice-card {
display: flex;
align-items: flex-start;
gap: 0.7rem;
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.6;
}
.notice-card i { font-size: 1.2rem; color: var(--info); flex-shrink: 0; }
.notice-danger { border-color: rgba(244, 63, 94, 0.35); }
.notice-danger i { color: var(--danger); }
.notice-card strong { color: var(--text-main); }
.tariff-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.9rem;
}
.tariff-field { display: flex; flex-direction: column; gap: 0.35rem; }
.tariff-label {
font-size: 0.78rem;
color: var(--text-muted);
font-weight: 500;
}
.tariff-label em {
font-style: normal;
color: var(--text-subtle);
font-size: 0.72rem;
}
.tariff-field .form-input { padding-left: 1rem; font-size: 0.9rem; }
.tariff-field .form-input:disabled { opacity: 0.65; cursor: not-allowed; }
+147 -6
View File
@@ -848,12 +848,14 @@
],
},
{
id: 'tariff', group: 'Growth & Pricing', icon: 'ph-currency-circle-dollar', title: 'Tariff & Promos',
subtitle: 'The live Kazan tariff table and active promo codes (read-only)',
panels: [
{ title: 'Kazan tariff', path: '/ride/kazan/get.php' },
{ title: 'Promo codes', path: '/ride/promo/get.php' },
],
id: 'tariff', group: 'Growth & Pricing', icon: 'ph-currency-circle-dollar', title: 'Tariff Editor',
subtitle: 'The live Kazan tariff — every change here alters what passengers pay',
custom: renderTariffEditor,
},
{
id: 'promos', group: 'Growth & Pricing', icon: 'ph-ticket', title: 'Promo Codes',
subtitle: 'Active discount codes',
panels: [{ title: 'Promo codes', path: '/ride/promo/get.php' }],
},
{
id: 'geofence', group: 'Growth & Pricing', icon: 'ph-map-trifold', title: 'Demand Heatmap',
@@ -949,6 +951,11 @@
loadedModules.add(mod.id);
const host = $(`panels_${mod.id}`);
if (mod.custom) {
await mod.custom(host);
return;
}
host.innerHTML = mod.panels.map((p) => `
<div class="card" data-panel="${esc(p.path)}">
<div class="card-header"><h3 class="card-title">${esc(p.title)}</h3></div>
@@ -967,6 +974,140 @@
}));
}
// ── Kazan tariff editor ──────────────────────────────────────────────────
// Only these columns are accepted by ride/kazan/update.php; anything else
// sent would be silently dropped, so the form mirrors that list exactly.
const TARIFF_FIELDS = [
{ key: 'kazanPercent', label: 'Platform commission', hint: '% taken by Siro' },
{ key: 'fuelPrice', label: 'Fuel price' },
{ key: 'currency', label: 'Currency', type: 'text' },
{ key: 'normalMinPrice', label: 'Minimum fare — normal' },
{ key: 'peakMinPrice', label: 'Minimum fare — peak' },
{ key: 'lateMinPrice', label: 'Minimum fare — late night' },
{ key: 'fixedPrice', label: 'Fixed price' },
{ key: 'speedPrice', label: 'Speed' },
{ key: 'comfortPrice', label: 'Comfort' },
{ key: 'ladyPrice', label: 'Lady' },
{ key: 'electricPrice', label: 'Electric' },
{ key: 'vanPrice', label: 'Van' },
{ key: 'deliveryPrice', label: 'Delivery' },
{ key: 'mishwarVipPrice', label: 'Mishwar VIP' },
{ key: 'awfarPrice', label: 'Awfar' },
];
let tariffRows = [];
async function renderTariffEditor(host) {
host.innerHTML = '<div class="card"><div class="table-msg">Loading tariff…</div></div>';
try {
const payload = await api('/ride/kazan/get.php');
tariffRows = Array.isArray(payload) ? payload : normaliseRows(payload);
} catch (err) {
if (handleApiError(err, 'tariff')) return;
host.innerHTML = `<div class="card"><div class="table-msg is-error">${esc(err.message)}</div></div>`;
return;
}
if (!tariffRows.length) {
host.innerHTML = '<div class="card"><div class="table-msg">No tariff rows configured.</div></div>';
return;
}
const readOnly = !isSuperAdmin();
host.innerHTML = `
${readOnly ? `
<div class="card notice-card">
<i class="ph-fill ph-info"></i>
<span>You are signed in as an admin, so the tariff is shown read-only. Only a super admin can change prices.</span>
</div>` : `
<div class="card notice-card notice-danger">
<i class="ph-fill ph-warning"></i>
<span><strong>These values are live.</strong> Saving changes what every passenger is charged from the next ride onwards. Changes are recorded in the audit log against your account.</span>
</div>`}
${tariffRows.map((row, index) => tariffCard(row, index, readOnly)).join('')}`;
if (readOnly) return;
host.querySelectorAll('[data-tariff-save]').forEach((btn) =>
btn.addEventListener('click', () => saveTariff(Number(btn.dataset.tariffSave), host)));
host.querySelectorAll('[data-tariff-reset]').forEach((btn) =>
btn.addEventListener('click', () => renderTariffEditor(host)));
}
function tariffCard(row, index, readOnly) {
const fields = TARIFF_FIELDS.filter((f) => row[f.key] !== undefined);
return `
<div class="card" data-tariff-card="${index}">
<div class="card-header">
<h3 class="card-title">
${esc(row.country || 'Tariff')} <span class="card-sub">row #${esc(row.id)}</span>
</h3>
${readOnly ? '' : `
<div style="display:flex; gap:0.5rem;">
<button class="btn btn-secondary btn-sm" data-tariff-reset="${index}"><i class="ph ph-arrow-counter-clockwise"></i> Reset</button>
<button class="btn btn-primary btn-sm" data-tariff-save="${index}"><i class="ph ph-floppy-disk"></i> <span>Review &amp; save</span></button>
</div>`}
</div>
<div class="tariff-grid">
${fields.map((f) => `
<label class="tariff-field">
<span class="tariff-label">${esc(f.label)}${f.hint ? ` <em>${esc(f.hint)}</em>` : ''}</span>
<input class="form-input" type="${f.type === 'text' ? 'text' : 'number'}" step="any"
data-tariff-input="${index}" data-field="${esc(f.key)}"
value="${esc(row[f.key] ?? '')}" ${readOnly ? 'disabled' : ''}>
</label>`).join('')}
</div>
</div>`;
}
async function saveTariff(index, host) {
if (!isSuperAdmin()) {
toast('Only a super admin can change pricing.', 'warning');
return;
}
const row = tariffRows[index];
const inputs = host.querySelectorAll(`[data-tariff-input="${index}"]`);
const changes = {};
inputs.forEach((input) => {
const field = input.dataset.field;
const current = String(row[field] ?? '');
const next = input.value.trim();
if (next !== current) changes[field] = next;
});
if (!Object.keys(changes).length) {
toast('Nothing changed on this tariff row.', 'info');
return;
}
const summary = Object.entries(changes)
.map(([field, value]) => {
const label = TARIFF_FIELDS.find((f) => f.key === field)?.label || field;
return `• ${label}: ${row[field] ?? '—'} → ${value}`;
})
.join('\n');
const confirmed = confirm(
`Apply these pricing changes to "${row.country || 'tariff'}" (row #${row.id})?\n\n` +
`${summary}\n\n` +
'This takes effect immediately for passengers.'
);
if (!confirmed) return;
try {
await api('/ride/kazan/update.php', {
params: { id: row.id, adminId: session?.id ?? '', ...changes },
});
toast('Tariff updated and recorded in the audit log.', 'success');
renderTariffEditor(host);
} catch (err) {
if (!handleApiError(err, 'tariff-save')) toast(`Update failed: ${err.message}`, 'danger');
}
}
function cssEscape(value) {
return String(value).replace(/["\\]/g, '\\$&');
}