+
-
Устройства
+ Карточка юзера
-
-
-
-
-
-
-
+
+
+
Подписки
+
Устройства
+
+
+
+
+
+
+
+
+
+
+
+
@@ -660,6 +682,8 @@ function initDropdowns() {
if (c) document.getElementById("edit-label").value = `${flagEmoji(code)} ${c[1]}`;
},
});
+ DD.ucGrantNode = createDropdown("uc-grant-node", { options: [], placeholder: "Сервер" });
+ DD.ucGrantPlan = createDropdown("uc-grant-plan", { options: [], placeholder: "Срок" });
}
const ICONS = {
@@ -745,26 +769,57 @@ async function loadSubscriptions() {
| ${s.username ? "@" + esc(s.username) : "tg" + s.tg_id} | ${esc(s.node_label)} | ${esc(s.plan_label)} |
${fmtDate(s.created_at)} | ${fmtDate(s.expires_at)} | ${statusBadge(s.active, s.days_left)} |
-
+
${s.active ? `` : ""}
|
`).join("") : '
Пока нет подписок |
';
}
let devicesTgId = null;
-async function openDevices(tgId, label) {
+async function openUserCard(tgId, label) {
devicesTgId = tgId;
- document.getElementById("devices-title").textContent = `Устройства: ${label}`;
- document.getElementById("devices-list").innerHTML = '
Загрузка…
';
+ document.getElementById("devices-title").textContent = `Карточка: ${label}`;
+ document.getElementById("uc-subs-list").innerHTML = '
Загрузка…
';
+ document.getElementById("devices-list").innerHTML = "";
+ switchUserTab("subs");
document.getElementById("devices-overlay").classList.add("show");
- const data = await api(`/admin/api/users/${tgId}/devices`);
- document.getElementById("devices-limit").value = data.limit || "";
- document.getElementById("devices-limit-hint").textContent = `По умолчанию (если пусто): ${data.fallback_limit}`;
+ if (!plansCache) plansCache = await api("/admin/api/plans");
+ if (!nodesCache) nodesCache = await api("/admin/api/nodes");
+ DD.ucGrantNode.setOptions(nodesCache.filter((n) => n.enabled).map((n) => ({ value: n.code, label: n.label })));
+ DD.ucGrantPlan.setOptions(plansCache.map((p) => ({ value: p.code, label: p.label })));
+ const data = await api(`/admin/api/users/${tgId}`);
+ document.getElementById("devices-limit").value = data.hwid_limit || "";
+ document.getElementById("devices-limit-hint").textContent = `По умолчанию (если пусто): ${data.hwid_fallback_limit}`;
+ renderUcSubs(data.subscriptions);
renderDevices(data.devices);
}
function closeDevices() {
document.getElementById("devices-overlay").classList.remove("show");
}
+function switchUserTab(tab) {
+ document.querySelectorAll("[data-uc-tab]").forEach((t) => t.classList.toggle("active", t.dataset.ucTab === tab));
+ document.getElementById("uc-tab-subs").style.display = tab === "subs" ? "block" : "none";
+ document.getElementById("uc-tab-devices").style.display = tab === "devices" ? "block" : "none";
+}
+function renderUcSubs(subs) {
+ const list = document.getElementById("uc-subs-list");
+ list.innerHTML = subs.length ? subs.map((s) => `
+
+
+
${esc(s.node_label)} — ${esc(s.plan_label)}
+
до ${fmtDate(s.expires_at)} · ${statusBadge(s.active, s.days_left)}
+
+ ${s.active ? `
` : ""}
+
+ `).join("") : '
Пока нет подписок
';
+}
+async function grantSubscription() {
+ const node = DD.ucGrantNode.getValue();
+ const plan = DD.ucGrantPlan.getValue();
+ if (!node || !plan) return;
+ await api(`/admin/api/users/${devicesTgId}/grant`, { method: "POST", body: JSON.stringify({ node, plan }) });
+ openUserCard(devicesTgId, document.getElementById("devices-title").textContent.replace("Карточка: ", ""));
+}
function renderDevices(devices) {
const list = document.getElementById("devices-list");
list.innerHTML = devices.length ? devices.map((d) => `
@@ -779,13 +834,12 @@ function renderDevices(devices) {
}
async function deleteDevice(deviceId) {
await api(`/admin/api/users/${devicesTgId}/devices/${deviceId}`, { method: "DELETE" });
- const data = await api(`/admin/api/users/${devicesTgId}/devices`);
+ const data = await api(`/admin/api/users/${devicesTgId}`);
renderDevices(data.devices);
}
async function saveHwidLimit() {
const val = document.getElementById("devices-limit").value.trim();
await api(`/admin/api/users/${devicesTgId}/hwid-limit`, { method: "POST", body: JSON.stringify({ limit: val || null }) });
- closeDevices();
}
async function revokeSub(uuid) {
diff --git a/api.py b/api.py
index 650e4cb..6c16d3b 100644
--- a/api.py
+++ b/api.py
@@ -492,6 +492,50 @@ def admin_reset_traffic(uuid: str, request: Request):
return {"ok": ok}
+@app.get("/admin/api/users/{tg_id}")
+def admin_user_card(tg_id: int, request: Request):
+ require_admin(request)
+ user = db.get_user(tg_id)
+ if not user:
+ raise HTTPException(404, "not found")
+ subs = db.list_subscriptions_for_user(tg_id)
+ out_subs = []
+ for s in subs:
+ node = db.get_node(s["node"])
+ plan = PLANS_BY_CODE.get(s["plan"])
+ out_subs.append({
+ **s,
+ "node_label": node["label"] if node else s["node"],
+ "plan_label": plan["label"] if plan else s["plan"],
+ "days_left": _days_left(s["expires_at"]),
+ })
+ return {
+ "tg_id": user["tg_id"],
+ "username": user["username"],
+ "created_at": user["created_at"],
+ "token": user["token"],
+ "subscriptions": out_subs,
+ "devices": db.list_devices(tg_id),
+ "hwid_limit": user.get("hwid_limit"),
+ "hwid_fallback_limit": HWID_FALLBACK_LIMIT,
+ }
+
+
+@app.post("/admin/api/users/{tg_id}/grant")
+def admin_grant_subscription(tg_id: int, request: Request, body: dict = Body(...)):
+ require_admin(request)
+ node_code = body.get("node")
+ plan_code = body.get("plan")
+ node = db.get_node(node_code)
+ plan = PLANS_BY_CODE.get(plan_code)
+ if not node or not plan:
+ raise HTTPException(400, "unknown node or plan")
+ db.get_or_create_user(tg_id, None)
+ sub = db.create_subscription(tg_id, node_code, plan["days"], plan_code, source="admin")
+ xray_manager.add_client_to_node(node, sub["uuid"], email=sub["uuid"])
+ return sub
+
+
@app.get("/admin/api/users/{tg_id}/devices")
def admin_list_devices(tg_id: int, request: Request):
require_admin(request)
diff --git a/db.py b/db.py
index d9c22d3..8951f25 100644
--- a/db.py
+++ b/db.py
@@ -351,6 +351,20 @@ def revoke_subscription(client_uuid: str):
conn.execute("UPDATE subscriptions SET active=0 WHERE uuid=?", (client_uuid,))
+def get_user(tg_id: int):
+ with get_conn() as conn:
+ row = conn.execute("SELECT * FROM users WHERE tg_id=?", (tg_id,)).fetchone()
+ return dict(row) if row else None
+
+
+def list_subscriptions_for_user(tg_id: int):
+ with get_conn() as conn:
+ rows = conn.execute(
+ "SELECT * FROM subscriptions WHERE tg_id=? ORDER BY created_at DESC", (tg_id,)
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+
def list_gift_codes(limit: int = 200):
with get_conn() as conn:
rows = conn.execute(