fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing

Found by systematically walking every async function in admin.html and
checking whether it wraps its api() call in try/catch — 24 didn't. Two
of them (createManualNode, generateGuide) are the exact forms whose
backend validation this session added over the last several commits:
type a duplicate node code, a bad port, anything the new checks reject,
and the button just... does nothing. No error, no success message, the
click looks like it didn't register. The backend was correctly
rejecting bad input with a clear message (and, since two commits ago,
that message even displays cleanly instead of as raw JSON) — none of
it reached the screen because the calling function never caught the
exception to display it.

Triaged the other 22 by actual risk instead of fixing all of them:
- 12 mutating actions where a silent failure leaves the admin unsure
  whether their click did anything — grant/revoke/hold/resume a
  subscription, delete a device, set an HWID limit, create/toggle/
  delete a node, create a gift code, start 2FA setup, plus the two
  above. Fixed all 12.
- The remaining ~14 are view-population loads (loadNodes, loadGifts,
  loadDashboard, etc.) and logout. Deferred, deliberately: their most
  likely real failure mode is an expired session, which api()'s own
  401 handling already resolves by redirecting to the login screen
  before the exception even reaches the caller — the confusing "did
  it work" ambiguity that motivates this fix doesn't really apply to
  a read-only load the way it does to a deliberate action.

Two feedback shapes depending on what's nearby: functions with an
existing dedicated result <div> (createManualNode, generateGuide,
createGift) route the error there, matching how every other form in
the panel already shows its errors. Functions with no natural home for
inline text (grant/revoke/hold/resume, node toggle/delete, device
delete, HWID limit, 2FA setup) use a plain alert() — these are
infrequent, deliberate single-action clicks, not something a blocking
dialog would be disruptive for. All of them still run their normal
refresh after a failure, not just after success, so the view never
goes stale relative to what the backend actually did.

Verification: pure client-side JS, no backend involved, so tested
directly under Node with a mocked api()/alert()/refresh — representative
cases from both feedback shapes: holdSub and toggleNode (alert-based,
confirmed the real backend message reaches the alert and the refresh
still fires on both success and failure), createManualNode (result-div-
based, confirmed the error text renders and loadNodes is correctly
NOT called when creation genuinely failed), and startEnableTotp
(confirmed the early return after a failed setup call avoids a second,
more confusing crash from reading .secret off an undefined response).
Re-ran the full function-by-function try/catch audit afterward to
confirm exactly the intended 12 were fixed and list what's still
deferred, rather than assuming the diff did what I meant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Savsis? 2026-09-14 07:35:44 +05:00
parent 02ff43095c
commit 2b34b11391

View file

@ -1335,7 +1335,13 @@ async function loadTotpStatus() {
}
async function startEnableTotp() {
const res = await api("/admin/api/2fa/setup", { method: "POST" });
let res;
try {
res = await api("/admin/api/2fa/setup", { method: "POST" });
} catch (e) {
alert("Не получилось: " + e.message);
return;
}
document.getElementById("totp-secret-display").textContent = res.secret;
document.getElementById("totp-setup-box").dataset.secret = res.secret;
document.getElementById("totp-setup-box").style.display = "block";
@ -1525,7 +1531,11 @@ 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 }) });
try {
await api(`/admin/api/users/${devicesTgId}/grant`, { method: "POST", body: JSON.stringify({ node, plan }) });
} catch (e) {
alert("Не получилось: " + e.message);
}
openUserCard(devicesTgId, document.getElementById("devices-title").textContent.replace("Карточка: ", ""));
}
function renderDevices(devices) {
@ -1541,28 +1551,48 @@ function renderDevices(devices) {
`).join("") : '<div class="empty">Нет привязанных устройств</div>';
}
async function deleteDevice(deviceId) {
await api(`/admin/api/users/${devicesTgId}/devices/${deviceId}`, { method: "DELETE" });
try {
await api(`/admin/api/users/${devicesTgId}/devices/${deviceId}`, { method: "DELETE" });
} catch (e) {
alert("Не получилось: " + e.message);
}
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 }) });
try {
await api(`/admin/api/users/${devicesTgId}/hwid-limit`, { method: "POST", body: JSON.stringify({ limit: val || null }) });
} catch (e) {
alert("Не получилось: " + e.message);
}
}
async function revokeSub(uuid) {
if (!confirm("Отозвать подписку?")) return;
await api(`/admin/api/subscriptions/${uuid}/revoke`, { method: "POST" });
try {
await api(`/admin/api/subscriptions/${uuid}/revoke`, { method: "POST" });
} catch (e) {
alert("Не получилось: " + e.message);
}
loadSubscriptions();
}
async function holdSub(uuid) {
await api(`/admin/api/subscriptions/${uuid}/hold`, { method: "POST" });
try {
await api(`/admin/api/subscriptions/${uuid}/hold`, { method: "POST" });
} catch (e) {
alert("Не получилось: " + e.message);
}
loadSubscriptions();
}
async function resumeSub(uuid) {
await api(`/admin/api/subscriptions/${uuid}/resume`, { method: "POST" });
try {
await api(`/admin/api/subscriptions/${uuid}/resume`, { method: "POST" });
} catch (e) {
alert("Не получилось: " + e.message);
}
loadSubscriptions();
}
@ -1590,9 +1620,14 @@ async function loadGifts() {
async function createGift() {
const node = DD.giftNode.getValue();
const plan = DD.giftPlan.getValue();
const res = await api("/admin/api/gift-codes", { method: "POST", body: JSON.stringify({ node, plan }) });
document.getElementById("gift-result").innerHTML = `<div class="code-box" style="margin-top:12px">${res.link}<button class="copy-btn" onclick="copyText('${res.link}')">Копировать</button></div>`;
loadGifts();
const result = document.getElementById("gift-result");
try {
const res = await api("/admin/api/gift-codes", { method: "POST", body: JSON.stringify({ node, plan }) });
result.innerHTML = `<div class="code-box" style="margin-top:12px">${res.link}<button class="copy-btn" onclick="copyText('${res.link}')">Копировать</button></div>`;
loadGifts();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:12px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
function copyText(t) { navigator.clipboard.writeText(t); }
@ -1720,12 +1755,20 @@ async function saveEditNode() {
}
async function toggleNode(code, enabled) {
await api(`/admin/api/nodes/${code}`, { method: "PATCH", body: JSON.stringify({ enabled }) });
try {
await api(`/admin/api/nodes/${code}`, { method: "PATCH", body: JSON.stringify({ enabled }) });
} catch (e) {
alert("Не получилось: " + e.message);
}
loadNodes();
}
async function deleteNode(code) {
if (!confirm("Удалить ноду?")) return;
await api(`/admin/api/nodes/${code}`, { method: "DELETE" });
try {
await api(`/admin/api/nodes/${code}`, { method: "DELETE" });
} catch (e) {
alert("Не получилось: " + e.message);
}
loadNodes();
}
@ -1745,8 +1788,15 @@ async function generateGuide() {
const include_hysteria2 = document.getElementById("ng-hy").checked;
const hysteria_port = parseInt(document.getElementById("ng-hy-port").value || "443");
if (!label || !address) return;
const res = await api("/admin/api/nodes/provision-guide", { method: "POST", body: JSON.stringify({ label, address, port, sni, include_ws, include_hysteria2, hysteria_port }) });
document.getElementById("guide-result").innerHTML = `
const guideResult = document.getElementById("guide-result");
let res;
try {
res = await api("/admin/api/nodes/provision-guide", { method: "POST", body: JSON.stringify({ label, address, port, sni, include_ws, include_hysteria2, hysteria_port }) });
} catch (e) {
guideResult.innerHTML = '<p class="page-sub" style="margin-top:12px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
return;
}
guideResult.innerHTML = `
<p class="page-sub" style="margin:16px 0 8px">Выполни на новом сервере:</p>
<div class="code-box">${res.command}<button class="copy-btn" onclick="copyText('${res.command}')">Копировать</button></div>
<p class="page-sub" style="margin-top:12px" id="guide-status">Ожидаю установки…</p>
@ -1774,9 +1824,14 @@ async function createManualNode() {
shared_uuid: document.getElementById("nm-uuid").value.trim() || null,
kind: document.getElementById("nm-uuid").value.trim() ? "external" : "managed",
};
await api("/admin/api/nodes", { method: "POST", body: JSON.stringify(body) });
document.getElementById("manual-result").innerHTML = '<p class="page-sub" style="margin-top:12px">Нода добавлена.</p>';
loadNodes();
const result = document.getElementById("manual-result");
try {
await api("/admin/api/nodes", { method: "POST", body: JSON.stringify(body) });
result.innerHTML = '<p class="page-sub" style="margin-top:12px">Нода добавлена.</p>';
loadNodes();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:12px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
function applyBrandName(name) {