Files
tripz-llc/dashboards/superadmin-web/index.html
T
Hamza-AyedandClaude Fable 5 3ab74a13a8 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>
2026-07-18 12:35:20 +03:00

245 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tripz — لوحة السوبر-أدمن</title>
<!--
لوحة مالك المنصة (docs/22 — N1). SPA مكتفية ذاتياً بلا خطوة بناء:
Vanilla JS + fetch. تُخدَم كملف ثابت خلف نفس نطاق الـTLS (Nginx location).
المصادقة: سرّ المنصة (x-platform-secret) يُدخَل مرة ويُحفظ في sessionStorage.
كل الحماية على السيرفر (PlatformGuard) — هذه واجهة فقط.
-->
<style>
:root { --bg:#0f1420; --card:#1a2130; --line:#2a3346; --fg:#e6ebf5; --mut:#8b97ad; --acc:#4f8cff; --ok:#2ecc71; --bad:#e74c3c; }
* { box-sizing:border-box; }
body { margin:0; font-family:system-ui,'Segoe UI',Tahoma,sans-serif; background:var(--bg); color:var(--fg); }
header { padding:16px 24px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; }
header h1 { font-size:18px; margin:0; }
.wrap { max-width:1100px; margin:0 auto; padding:24px; }
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:20px; margin-bottom:20px; }
.card h2 { margin:0 0 16px; font-size:15px; color:var(--mut); font-weight:600; }
label { display:block; font-size:13px; color:var(--mut); margin:10px 0 4px; }
input, select { width:100%; padding:9px 11px; background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--fg); font-size:14px; }
button { background:var(--acc); color:#fff; border:0; border-radius:8px; padding:10px 18px; font-size:14px; cursor:pointer; }
button.ghost { background:transparent; border:1px solid var(--line); color:var(--fg); }
button:disabled { opacity:.5; cursor:default; }
.row { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
.chips { display:flex; flex-wrap:wrap; gap:8px; margin-top:6px; }
.chip { padding:6px 12px; border:1px solid var(--line); border-radius:20px; font-size:13px; cursor:pointer; user-select:none; }
.chip.on { background:var(--acc); border-color:var(--acc); }
table { width:100%; border-collapse:collapse; }
th, td { text-align:right; padding:10px; border-bottom:1px solid var(--line); font-size:14px; }
th { color:var(--mut); font-weight:600; font-size:12px; }
.badge { padding:2px 9px; border-radius:6px; font-size:12px; }
.badge.active { background:rgba(46,204,113,.15); color:var(--ok); }
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); padding:12px 20px; border-radius:8px; font-size:14px; opacity:0; transition:opacity .2s; }
.toast.ok { background:var(--ok); } .toast.bad { background:var(--bad); } .toast.show { opacity:1; }
.muted { color:var(--mut); font-size:13px; }
dialog { background:var(--card); color:var(--fg); border:1px solid var(--line); border-radius:12px; padding:24px; max-width:460px; width:90%; }
dialog::backdrop { background:rgba(0,0,0,.6); }
</style>
</head>
<body>
<header>
<h1>🚕 Tripz — لوحة السوبر-أدمن</h1>
<span class="muted" id="apiLabel"></span>
<span style="flex:1"></span>
<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><th>GMV</th><th>إيراد المنصة</th><th></th></tr></thead>
<tbody id="tenantRows"><tr><td colspan="9" class="muted">…</td></tr></tbody>
</table>
</div>
<!-- تزويد مستأجر جديد -->
<div class="card">
<h2>تزويد مستأجر جديد</h2>
<div class="row">
<div><label>الاسم</label><input id="pName" placeholder="Siro" /></div>
<div><label>slug (معرّف)</label><input id="pSlug" placeholder="siro" /></div>
</div>
<div class="row">
<div><label>الدولة</label>
<select id="pCountry"><option value="jo">الأردن</option><option value="sy">سوريا</option><option value="eg">مصر</option></select>
</div>
<div><label>الباقة</label>
<select id="pPlan"><option value="launch">انطلاقة</option><option value="brand">علامة</option><option value="fleet">أسطول+</option><option value="sovereign">سيادة</option></select>
</div>
</div>
<label>اسم التطبيق</label><input id="pAppName" placeholder="سيرو" />
<div class="row">
<div><label>bundle ID (أندرويد)</label><input id="pBidA" placeholder="com.siro.rider" /></div>
<div><label>bundle ID (iOS)</label><input id="pBidI" placeholder="com.siro.rider" /></div>
</div>
<label>وسائل الدفع</label><div class="chips" id="payChips"></div>
<label>الميزات المدفوعة المفعّلة</label><div class="chips" id="featChips"></div>
<div style="margin-top:18px"><button id="provisionBtn">تزويد المستأجر</button></div>
</div>
</div>
<div class="toast" id="toast"></div>
<!-- نافذة السرّ -->
<dialog id="authDlg">
<h2 style="margin-top:0">سرّ المنصة</h2>
<p class="muted">أدخل <code>PLATFORM_SECRET</code> للوصول. يُحفظ في هذه الجلسة فقط.</p>
<input id="secretInput" type="password" placeholder="platform secret" />
<div style="margin-top:16px; text-align:left"><button id="secretOk">دخول</button></div>
</dialog>
<script>
const API = (location.origin.includes('localhost') ? 'http://localhost:4010/api' : '/api');
document.getElementById('apiLabel').textContent = API;
let SECRET = sessionStorage.getItem('tripz_platform_secret') || '';
let CATALOG = { features: [], payment_methods: [] };
const selectedPay = new Set(), selectedFeat = new Set();
const toast = (msg, ok = true) => {
const t = document.getElementById('toast');
t.textContent = msg; t.className = `toast show ${ok ? 'ok' : 'bad'}`;
setTimeout(() => (t.className = 'toast'), 2600);
};
async function api(method, path, body) {
const res = await fetch(API + path, {
method,
headers: { 'Content-Type': 'application/json', 'x-platform-secret': SECRET },
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401) { openAuth(); throw new Error('unauthorized'); }
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.message || `HTTP ${res.status}`);
return json;
}
function openAuth() { document.getElementById('authDlg').showModal(); }
document.getElementById('secretOk').onclick = () => {
SECRET = document.getElementById('secretInput').value.trim();
sessionStorage.setItem('tripz_platform_secret', SECRET);
document.getElementById('authDlg').close();
boot();
};
document.getElementById('logoutBtn').onclick = () => {
sessionStorage.removeItem('tripz_platform_secret'); location.reload();
};
function renderChips(el, items, selected, labelKey = 'name_ar', codeKey = 'code') {
el.innerHTML = '';
for (const it of items) {
const code = typeof it === 'string' ? it : it[codeKey];
const label = typeof it === 'string' ? it : (it[labelKey] || code);
const chip = document.createElement('span');
chip.className = 'chip' + (selected.has(code) ? ' on' : '');
chip.textContent = label;
chip.onclick = () => {
selected.has(code) ? selected.delete(code) : selected.add(code);
chip.classList.toggle('on');
};
el.appendChild(chip);
}
}
// اسم المستأجر يدخله بشر ويُحقن في 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 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>${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="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;
try {
const feats = {}; for (const f of selectedFeat) feats[f] = true;
await api('POST', '/admin/tenants/provision', {
name: document.getElementById('pName').value.trim(),
slug: document.getElementById('pSlug').value.trim(),
countryPack: document.getElementById('pCountry').value,
plan: document.getElementById('pPlan').value,
features: feats,
paymentMethods: [...selectedPay],
branding: {
app_name: document.getElementById('pAppName').value.trim(),
bundle_id_android: document.getElementById('pBidA').value.trim() || null,
bundle_id_ios: document.getElementById('pBidI').value.trim() || null,
},
});
toast('تمّ تزويد المستأجر ✅');
loadTenants();
} catch (e) { toast(e.message, false); }
finally { btn.disabled = false; }
};
async function boot() {
if (!SECRET) { openAuth(); return; }
document.getElementById('logoutBtn').style.display = '';
try {
CATALOG = await api('GET', '/admin/features');
renderChips(document.getElementById('payChips'), CATALOG.payment_methods || [], selectedPay);
renderChips(document.getElementById('featChips'),
(CATALOG.features || []).map((f) => ({ code: f, name_ar: f })), selectedFeat);
loadTenants();
} catch (e) { /* openAuth already fired on 401 */ }
}
boot();
</script>
</body>
</html>