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:
co-authored by
Claude Fable 5
parent
5cad6adbde
commit
3ab74a13a8
@@ -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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|
||||
|
||||
Reference in New Issue
Block a user