feat: N1 — خدمة اللوحات + فرض تعليق المستأجر + النظرة الشاملة

- اللوحات الثلاث تُخدم من نفس أصل الـAPI (/panel/{superadmin,admin,service})
  عبر useStaticAssets + bind mount، فترث TLS القائم بلا دومين ولا CORS.
- تعليق المستأجر كان زخرفة: tenant.status يُكتب ويُعرض بلا أي فرض. الآن
  يُفرض في JwtStrategy (نقطة واحدة) + AuthService.resolveTenant، مع إبطال
  الكاش ليسري فوراً، وتمرير عند تعذّر القراءة حتى لا تسقط المنصة كلها.
- GET /admin/overview: رحلات · GMV · إيراد المنصة لكل مستأجر باستعلام
  واحد مجمَّع (لا N+1).
- توثيق قرار طبقات التطبيق (const flags + tree-shaking · أصيل موحّد
  لـShorebird · طبقتا الإعداد) في docs/22 §1.5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-18 12:35:20 +03:00
co-authored by Claude Fable 5
parent 5cad6adbde
commit 3ab74a13a8
12 changed files with 343 additions and 15 deletions
+49 -9
View File
@@ -48,12 +48,22 @@
<button class="ghost" id="logoutBtn" style="display:none">تسجيل خروج</button>
</header>
<div class="wrap">
<!-- النظرة الشاملة -->
<div class="card">
<h2>النظرة الشاملة
<select id="ovDays" style="width:auto; display:inline-block; margin-right:10px">
<option value="7">٧ أيام</option><option value="30" selected>٣٠ يوماً</option><option value="90">٩٠ يوماً</option>
</select>
</h2>
<div id="ovTotals" class="muted">…</div>
</div>
<!-- المستأجرون -->
<div class="card">
<h2>المستأجرون</h2>
<table>
<thead><tr><th>الاسم</th><th>slug</th><th>الدولة</th><th>الباقة</th><th>الحالة</th><th></th></tr></thead>
<tbody id="tenantRows"><tr><td colspan="6" class="muted">…</td></tr></tbody>
<thead><tr><th>الاسم</th><th>slug</th><th>الدولة</th><th>الباقة</th><th>الحالة</th><th>رحلات</th><th>GMV</th><th>إيراد المنصة</th><th></th></tr></thead>
<tbody id="tenantRows"><tr><td colspan="9" class="muted">…</td></tr></tbody>
</table>
</div>
@@ -145,24 +155,54 @@
}
}
// اسم المستأجر يدخله بشر ويُحقن في innerHTML — بلا تهريب يصير اسمٌ فيه
// <script> ثغرة XSS في أخطر لوحة عندنا.
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const money = (n) => Number(n || 0).toLocaleString('en-US', { maximumFractionDigits: 2 });
async function loadTenants() {
const rows = document.getElementById('tenantRows');
const days = document.getElementById('ovDays').value;
try {
const tenants = await api('GET', '/admin/tenants');
rows.innerHTML = tenants.length ? '' : '<tr><td colspan="6" class="muted">لا مستأجرين بعد</td></tr>';
const ov = await api('GET', `/admin/overview?days=${days}`);
const t0 = ov.totals || {};
document.getElementById('ovTotals').innerHTML =
`مستأجرون: <b>${t0.tenants ?? 0}</b> (فعّال: ${t0.active_tenants ?? 0}) ·
رحلات: <b>${t0.trips ?? 0}</b> (مكتملة: ${t0.completed_trips ?? 0}) ·
GMV: <b>${money(t0.gmv)}</b> · إيراد المنصة: <b>${money(t0.revenue)}</b>`;
const tenants = ov.tenants || [];
rows.innerHTML = tenants.length ? '' : '<tr><td colspan="9" class="muted">لا مستأجرين بعد</td></tr>';
for (const t of tenants) {
const suspended = t.status !== 'active';
const tr = document.createElement('tr');
tr.innerHTML = `<td>${t.name}</td><td class="muted">${t.slug}</td><td>${t.countryPack||t.country_pack||'-'}</td>
<td>${t.plan}</td><td><span class="badge active">${t.status}</span></td>
<td><button class="ghost" data-id="${t.id}">مانيفست</button></td>`;
tr.querySelector('button').onclick = async () => {
tr.innerHTML = `<td>${esc(t.name)}</td><td class="muted">${esc(t.slug)}</td><td>${esc(t.countryPack || '-')}</td>
<td>${esc(t.plan)}</td>
<td><span class="badge ${suspended ? '' : 'active'}">${esc(t.status)}</span></td>
<td>${t.completed_trips}/${t.trips}</td><td>${money(t.gmv)}</td><td>${money(t.revenue)}</td>
<td><button class="ghost" data-act="manifest">مانيفست</button>
<button class="ghost" data-act="status">${suspended ? 'تفعيل' : 'تعليق'}</button></td>`;
tr.querySelector('[data-act=manifest]').onclick = async () => {
const m = await api('GET', `/admin/tenants/${t.id}/app-manifest`);
alert(JSON.stringify(m, null, 2));
};
tr.querySelector('[data-act=status]').onclick = async () => {
// التعليق يقطع الخدمة عن مستأجر حيّ فوراً — لذلك تأكيد صريح.
const next = suspended ? 'active' : 'suspended';
if (!suspended && !confirm(`تعليق «${t.name}»؟ سيتوقّف تطبيقه وسائقوه وركّابه فوراً.`)) return;
try {
await api('PATCH', `/admin/tenants/${t.id}/status`, { status: next });
toast(next === 'active' ? 'أُعيد التفعيل ✅' : 'عُلّق المستأجر ⛔');
loadTenants();
} catch (e) { toast(e.message, false); }
};
rows.appendChild(tr);
}
} catch (e) { if (e.message !== 'unauthorized') rows.innerHTML = `<tr><td colspan="6" class="muted">${e.message}</td></tr>`; }
} catch (e) { if (e.message !== 'unauthorized') rows.innerHTML = `<tr><td colspan="9" class="muted">${esc(e.message)}</td></tr>`; }
}
document.getElementById('ovDays').onchange = loadTenants;
document.getElementById('provisionBtn').onclick = async () => {
const btn = document.getElementById('provisionBtn'); btn.disabled = true;