الباك إند: - تعيين أول أدمن صار يُنشئ المستخدم إن لم يوجد — كان يشترط دخولاً سابقاً، وهي بيضة ودجاجة تمنع الدخول إلى لوحة أي مستأجر جديد أصلاً. - OTP_DEV_MODE كان يفشل مفتوحاً (`!== 'false'`): غياب المتغيّر أو خطأ مطبعي يترك الإنتاج برمز ثابت يفتح كل حساب، ويحرس السحب المالي كذلك. صار `=== 'true'` في الإعداد وفي قارئَيه، مع تحذير عند الإقلاع. - N2: التحقّق من اللوغو عند الرفع (PNG/JPEG · 512+ · مربّع · سقف 5MB) عبر قراءة الترويسة بلا اعتمادية — بدل اكتشاف أيقونة ممطوطة بعد النشر. N3: السكربت يولّد build_config.dart بأعلام const (طبقات docs/22 §1.5)، ويضبط bundle IDs واسم التطبيق والأيقونات/splash، و--build يشغّل Shorebird. فلاتر: - docs/23: قانون مُلزِم للتطبيقين (البنية · Cubit · طبقة الشبكة · الأعلام). - السائق: أُزيل Dart القديم (GetX) مع الإبقاء على الأصيل والإضافات وشهادات التوقيع وShorebird وFirebase الخاص به، وأُعيد هيكلته مطابقاً للراكب. - طبقة الشبكة ترسل x-device-id وتجدّد التوكن عند 401 مرة واحدة فقط. - الحزم موحّدة بين التطبيقين، والـAPI https حصراً في الاثنين. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
278 lines
15 KiB
HTML
278 lines
15 KiB
HTML
<!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) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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="admin">تعيين أدمن</button>
|
||
<button class="ghost" data-act="logo">اللوغو</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));
|
||
};
|
||
// أول أدمن للمستأجر: تُنشئ الحساب إن لم يوجد (docs/22 — N1d).
|
||
// بلا هذا لا يمكن الدخول إلى لوحة مستأجر مزوَّد حديثاً إطلاقاً.
|
||
tr.querySelector('[data-act=admin]').onclick = async () => {
|
||
const phone = prompt(`رقم هاتف أدمن «${t.name}» (بصيغة دولته: ${t.countryPack})`);
|
||
if (!phone) return;
|
||
try {
|
||
const r = await api('POST', '/platform/users/role', {
|
||
tenantSlug: t.slug, phone: phone.trim(), role: 'admin',
|
||
});
|
||
toast(r.created ? 'أُنشئ الحساب وعُيّن أدمن ✅' : 'صار أدمن ✅');
|
||
} catch (e) { toast(e.message, false); }
|
||
};
|
||
// رفع اللوغو (docs/22 — N2). لا نمرّ بـapi() لأن الرفع multipart
|
||
// لا JSON: ضبط Content-Type يدوياً يكسر حدّ الأجزاء (boundary).
|
||
tr.querySelector('[data-act=logo]').onclick = () => {
|
||
const inp = document.createElement('input');
|
||
inp.type = 'file'; inp.accept = 'image/png,image/jpeg';
|
||
inp.onchange = async () => {
|
||
const f = inp.files?.[0]; if (!f) return;
|
||
const fd = new FormData(); fd.append('file', f);
|
||
try {
|
||
const res = await fetch(`${API}/admin/tenants/${t.id}/logo`, {
|
||
method: 'POST', headers: { 'x-platform-secret': SECRET }, body: fd,
|
||
});
|
||
const j = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(j.message || `HTTP ${res.status}`);
|
||
toast(`رُفع اللوغو ${j.logo?.width}×${j.logo?.height} ✅`);
|
||
} catch (e) { toast(e.message, false); }
|
||
};
|
||
inp.click();
|
||
};
|
||
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>
|