diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js
index ea5b56d..e0d02c6 100644
--- a/src/antigravity_provider/router/web/static/app.js
+++ b/src/antigravity_provider/router/web/static/app.js
@@ -184,6 +184,10 @@ async function fetchSnapshot() {
if (authToken) headers['X-Hub-Token'] = authToken;
const res = await fetch('/api/snapshot', { headers });
+ if (res.status === 401) {
+ handleUnauthorized();
+ return;
+ }
if (!res.ok) {
if (res.status === 404 || res.status === 502 || res.status === 503) {
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 = `
+
+ Этот хаб открыт по сети, поэтому запросы к нему требуют токен.
+ Он был показан при выполнении enable_lan_access.py и хранится
+ на сервере в ~/.hermes/hub_settings.json.
+
+
+
+
+ Токен сохранится в этом браузере и больше спрашиваться не будет.
+
+ `;
+ elements.modalFooter.innerHTML = `
+
+ `;
+ 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 = 'Поле пустое.';
+ return;
+ }
+
+ // Заголовки HTTP переносят только ASCII. Без этой проверки fetch бросает
+ // TypeError и обработчик умирает целиком, не показав ничего: так бывает,
+ // если вместе с токеном скопировали русский текст или лишний символ.
+ if (!/^[!-~]+$/.test(value)) {
+ feedback.innerHTML = 'В токене посторонние символы. Скопируйте только сам токен, без кавычек, пробелов и текста вокруг.';
+ return;
+ }
+
+ feedback.textContent = 'Проверяем…';
+ let res;
+ try {
+ res = await fetch('/api/snapshot', { headers: { 'X-Hub-Token': value } });
+ } catch (err) {
+ feedback.innerHTML = `Не удалось обратиться к серверу: ${escapeHtml(err.message)}`;
+ return;
+ }
+ if (res.status === 401) {
+ feedback.innerHTML = 'Токен не подошёл. Проверьте, что скопирован целиком.';
+ return;
+ }
+ if (!res.ok) {
+ feedback.innerHTML = `Сервер ответил ${res.status}.`;
+ 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) ──
async function executeAction(actionName, actionData = {}) {
showToast(`Выполняется «${actionName}»...`, 'info');
@@ -299,6 +395,11 @@ async function executeAction(actionName, 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})` }));
if (result.ok) {