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:
parent
e90f6331a2
commit
de7c7132ae
1 changed files with 101 additions and 0 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue