fix(web): при 401 интерфейс молчал вместо того, чтобы спросить токен

Владелец открыл хаб по сети и получил пустую панель с повторяющимся тостом
«Ошибка сервера: 401». Кода 401 клиент не знал вовсе: ответ падал в общую
ветку ошибок, тост повторялся на каждом опросе, и ни одной подсказки о том,
что нужен токен и где его взять, не было.

Попытка ввести токен в «Настройках» тоже провалилась: рядом с полем токена
стоит кнопка сохранения настроек СЕРВЕРА, а она сама шлёт save_settings и
требует токен — получался замкнутый круг, 401 на попытке ввести токен от 401.

Теперь 401 перехватывается и в fetchSnapshot, и в executeAction: опрос
останавливается, открывается окно с полем ввода и объяснением, откуда взять
значение. Токен проверяется настоящим запросом до сохранения, поэтому
неверный не попадает в localStorage. После принятия окно закрывается, поле в
настройках заполняется, опрос возобновляется.

Дефект, найденный при проверке в браузере: непустой токен с не-ASCII
символами роняет fetch на TypeError, и обработчик умирает молча, ничего не
показав. Заголовки HTTP переносят только ASCII. Добавлена проверка до
отправки и перехват сетевой ошибки — так бывает, когда вместе с токеном
скопирован текст вокруг.

Проверено в браузере на изолированном экземпляре: окно появляется само,
кириллица даёт внятное сообщение, неверный токен отвергается и не
сохраняется, верный принимается — панель оживает, индикатор Live API,
опрос возобновлён. 432 passed, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-25 10:55:41 +07:00
parent e90f6331a2
commit de7c7132ae

View file

@ -184,6 +184,10 @@ async function fetchSnapshot() {
if (authToken) headers['X-Hub-Token'] = authToken; if (authToken) headers['X-Hub-Token'] = authToken;
const res = await fetch('/api/snapshot', { headers }); const res = await fetch('/api/snapshot', { headers });
if (res.status === 401) {
handleUnauthorized();
return;
}
if (!res.ok) { if (!res.ok) {
if (res.status === 404 || res.status === 502 || res.status === 503) { if (res.status === 404 || res.status === 502 || res.status === 503) {
throw new Error(`Server returned ${res.status}`); throw new Error(`Server returned ${res.status}`);
@ -286,6 +290,98 @@ function startPolling() {
} }
} }
// ── ЗАПРОС ТОКЕНА ПРИ 401 ──
//
// Сервер, привязанный не к localhost, требует X-Hub-Token. Раньше клиент этого
// случая не знал вовсе: 401 попадал в общую ветку ошибок и превращался в
// красный тост «Ошибка сервера: 401», который повторялся на каждом опросе.
// Владелец видел пустую панель и не имел ни одной подсказки, что нужен токен
// и где его взять. Теперь спрашиваем прямо, один раз.
let _tokenPromptOpen = false;
function handleUnauthorized() {
if (_tokenPromptOpen) return;
_tokenPromptOpen = true;
// Опрос останавливаем, иначе окно ввода будет перекрываться новыми 401
// каждые несколько секунд. Отдельной stopPolling в клиенте нет — таймер
// гасится напрямую, как это делает startPolling перед перезапуском.
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
setSourceIndicator(false, 'Требуется токен доступа');
elements.modalTitle.textContent = 'Требуется токен доступа';
elements.modalBody.innerHTML = `
<div class="modal-feedback info" style="margin-bottom:14px;">
Этот хаб открыт по сети, поэтому запросы к нему требуют токен.
Он был показан при выполнении <code>enable_lan_access.py</code> и хранится
на сервере в <code>~/.hermes/hub_settings.json</code>.
</div>
<label style="display:block; font-weight:600; margin-bottom:4px;">Токен доступа:</label>
<input type="password" class="input-text" style="width:100%;" id="auth-token-input"
placeholder="Вставьте токен" autocomplete="off">
<div id="auth-token-feedback" style="font-size:12px; color:var(--text-muted); margin-top:8px;">
Токен сохранится в этом браузере и больше спрашиваться не будет.
</div>
`;
elements.modalFooter.innerHTML = `
<button class="btn btn-primary" onclick="saveAuthTokenFromPrompt()">Сохранить и продолжить</button>
`;
showModal();
setTimeout(() => {
const el = document.getElementById('auth-token-input');
if (el) el.focus();
}, 50);
}
async function saveAuthTokenFromPrompt() {
const input = document.getElementById('auth-token-input');
const feedback = document.getElementById('auth-token-feedback');
if (!input) return;
const value = (input.value || '').trim();
if (!value) {
feedback.innerHTML = '<span style="color:var(--status-warning);">Поле пустое.</span>';
return;
}
// Заголовки HTTP переносят только ASCII. Без этой проверки fetch бросает
// TypeError и обработчик умирает целиком, не показав ничего: так бывает,
// если вместе с токеном скопировали русский текст или лишний символ.
if (!/^[!-~]+$/.test(value)) {
feedback.innerHTML = '<span style="color:var(--status-error);">В токене посторонние символы. Скопируйте только сам токен, без кавычек, пробелов и текста вокруг.</span>';
return;
}
feedback.textContent = 'Проверяем…';
let res;
try {
res = await fetch('/api/snapshot', { headers: { 'X-Hub-Token': value } });
} catch (err) {
feedback.innerHTML = `<span style="color:var(--status-error);">Не удалось обратиться к серверу: ${escapeHtml(err.message)}</span>`;
return;
}
if (res.status === 401) {
feedback.innerHTML = '<span style="color:var(--status-error);">Токен не подошёл. Проверьте, что скопирован целиком.</span>';
return;
}
if (!res.ok) {
feedback.innerHTML = `<span style="color:var(--status-error);">Сервер ответил ${res.status}.</span>`;
return;
}
authToken = value;
localStorage.setItem('hermes_hub_token', authToken);
const tokenInput = document.getElementById('setting-client-token-input');
if (tokenInput) tokenInput.value = authToken;
_tokenPromptOpen = false;
closeModal();
showToast('Токен принят', 'success');
startPolling();
fetchSnapshot();
}
// ── ACTIONS EXECUTION (POST /api/action) ── // ── ACTIONS EXECUTION (POST /api/action) ──
async function executeAction(actionName, actionData = {}) { async function executeAction(actionName, actionData = {}) {
showToast(`Выполняется «${actionName}»...`, 'info'); showToast(`Выполняется «${actionName}»...`, 'info');
@ -299,6 +395,11 @@ async function executeAction(actionName, actionData = {}) {
body: JSON.stringify({ action: actionName, data: actionData }), body: JSON.stringify({ action: actionName, data: actionData }),
}); });
if (res.status === 401) {
handleUnauthorized();
return { ok: false, message: 'Требуется токен доступа' };
}
const result = await res.json().catch(() => ({ ok: false, message: `Ошибка парсинга ответа (${res.status})` })); const result = await res.json().catch(() => ({ ok: false, message: `Ошибка парсинга ответа (${res.status})` }));
if (result.ok) { if (result.ok) {