diff --git a/src/templates/dashboard.html b/src/templates/dashboard.html index 93f0de8..f7eb54b 100644 --- a/src/templates/dashboard.html +++ b/src/templates/dashboard.html @@ -164,6 +164,30 @@ .badge.fail { background: rgb(224 108 117 / 14%); color: var(--danger); } .badge.info { background: rgb(97 175 239 / 14%); color: var(--accent); } .message { min-height: 20px; color: var(--danger); } + .message.ok { color: var(--ok); } + .auth-panel { + max-width: 920px; + margin: 64px auto 0; + } + .auth-switch { + display: inline-flex; + gap: 6px; + padding: 4px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--field); + } + .auth-switch button { + min-height: 32px; + border-color: transparent; + background: transparent; + color: var(--muted); + } + .auth-switch button.active { + border-color: var(--accent); + background: rgb(97 175 239 / 12%); + color: var(--accent); + } .table { width: 100%; border-collapse: collapse; } .table th, .table td { @@ -701,8 +725,17 @@ - - Вход + + + + Авторизация + Вход выполняется по username. Регистрация открыта и сразу возвращает JWT. + + + Вход + Регистрация + + Логин @@ -710,9 +743,24 @@ Получить JWT - + POST /api/v1/users/login/ + + + Email + Логин + Имя + Фамилия + Пароль + Повтор пароля + + + Создать пользователя + POST /api/v1/users/register/ + + + @@ -1179,7 +1227,7 @@ logRequest(method, url, body, `${response.status} ${Math.round(performance.now() - started)}ms`, text); let data = {}; try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; } - if (!response.ok) throw data; + if (!response.ok) throw attachStatus(data, response.status); return data; } @@ -1197,7 +1245,7 @@ logRequest("POST", url, { file: file ? file.name : "" }, `${response.status} ${Math.round(performance.now() - started)}ms`, text); let data = {}; try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; } - if (!response.ok) throw data; + if (!response.ok) throw attachStatus(data, response.status); return data; } @@ -1230,7 +1278,7 @@ logRequest("POST", url, payload, `${response.status} ${Math.round(performance.now() - started)}ms`, text); let data = {}; try { data = text ? JSON.parse(text) : {}; } catch (_) { data = { raw: text }; } - if (!response.ok) throw data; + if (!response.ok) throw attachStatus(data, response.status); return data; } @@ -1490,6 +1538,61 @@ return []; } + function attachStatus(data, status) { + if (data && typeof data === "object" && !Array.isArray(data)) { + data.status = status; + return data; + } + return { status, raw: data }; + } + + function isAuthError(error) { + const codes = (error?.errors || []).map((item) => item.code); + return error?.status === 401 + || codes.includes("not_authenticated") + || codes.includes("authentication_failed") + || codes.includes("token_not_valid"); + } + + function errorMessage(error) { + if (!error) return "Неизвестная ошибка"; + if (Array.isArray(error.errors) && error.errors.length) { + return error.errors.map((item) => item.message || item.code).join("; "); + } + if (error.error) return error.error; + if (error.detail) return error.detail; + const fieldErrors = Object.entries(error) + .filter(([key]) => key !== "status") + .map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : JSON.stringify(value)}`); + if (fieldErrors.length) return fieldErrors.join("; "); + return JSON.stringify(error); + } + + function setLoginMessage(text, ok = false) { + $("loginMessage").textContent = text || ""; + $("loginMessage").classList.toggle("ok", ok); + } + + function extractTokens(response) { + return response?.tokens || response?.data?.tokens || response; + } + + function storeTokens(response) { + const tokens = extractTokens(response); + if (!tokens?.access || !tokens?.refresh) { + throw { error: "API не вернул JWT токены" }; + } + accessToken = tokens.access; + localStorage.setItem(tokenKey, tokens.access); + localStorage.setItem(refreshKey, tokens.refresh); + } + + function clearTokens() { + localStorage.removeItem(tokenKey); + localStorage.removeItem(refreshKey); + accessToken = ""; + } + function renderRegistryUploadPanel(registries) { const previousValue = $("registrySelect").value; const preferred = registries.find((registry) => registry.name === "Реестр предприятий ОПК") @@ -1537,6 +1640,14 @@ renderBackupPanel(registries); } + function renderRegistersUnavailable(error) { + $("registryCount").textContent = "недоступно"; + $("registrySelect").innerHTML = ""; + $("backupRegistrySelect").innerHTML = ""; + $("registrySummary").innerHTML = `Реестры недоступны: ${escapeHtml(errorMessage(error))}`; + $("backupStatus").textContent = `Выгрузка .bin недоступна: ${errorMessage(error)}`; + } + function setSelectOptions(selectId, tables) { $(selectId).innerHTML = `-${tables.map((item) => ( `${escapeHtml(item.title)} · ${escapeHtml(item.table)}` @@ -1747,39 +1858,58 @@ const data = await apiFetch("/api/v1/parsers/dashboard/"); setAuthenticated(true); renderDashboard(data); - await refreshRegisters(); + await refreshRegisters().catch(renderRegistersUnavailable); await refreshExchange(); await renderCurrentRoute(); + setLoginMessage(""); } catch (error) { - localStorage.removeItem(tokenKey); - localStorage.removeItem(refreshKey); - accessToken = ""; - setAuthenticated(false); - $("loginMessage").textContent = "JWT недействителен, войдите заново"; + if (isAuthError(error)) { + clearTokens(); + setAuthenticated(false); + setLoginMessage("JWT недействителен, войдите заново"); + return; + } + setAuthenticated(Boolean(accessToken)); + setLoginMessage(`Dashboard недоступен: ${errorMessage(error)}`); } } $("loginForm").addEventListener("submit", async (event) => { event.preventDefault(); - $("loginMessage").textContent = ""; + setLoginMessage(""); try { const response = await apiFetch("/api/v1/users/login/", { method: "POST", body: JSON.stringify(formPayload(event.target)), }); - accessToken = response.access; - localStorage.setItem(tokenKey, response.access); - localStorage.setItem(refreshKey, response.refresh); + storeTokens(response); + setLoginMessage("Вход выполнен", true); await refreshDashboard(); } catch (error) { - $("loginMessage").textContent = JSON.stringify(error); + setLoginMessage(errorMessage(error)); + } + }); + + $("registerForm").addEventListener("submit", async (event) => { + event.preventDefault(); + setLoginMessage(""); + try { + const response = await apiFetch("/api/v1/users/register/", { + method: "POST", + body: JSON.stringify(formPayload(event.target)), + }); + storeTokens(response); + setLoginMessage("Пользователь создан, JWT активен", true); + await refreshDashboard(); + } catch (error) { + setLoginMessage(errorMessage(error)); } }); async function refreshJwt() { const refreshToken = localStorage.getItem(refreshKey); if (!refreshToken) { - $("loginMessage").textContent = "Refresh token отсутствует, войдите заново"; + setLoginMessage("Refresh token отсутствует, войдите заново"); return; } try { @@ -1787,16 +1917,15 @@ method: "POST", body: JSON.stringify({ refresh: refreshToken }), }); - accessToken = response.access; - localStorage.setItem(tokenKey, response.access); - if (response.refresh) localStorage.setItem(refreshKey, response.refresh); + const tokens = extractTokens(response); + accessToken = tokens.access; + localStorage.setItem(tokenKey, tokens.access); + if (tokens.refresh) localStorage.setItem(refreshKey, tokens.refresh); await refreshDashboard(); } catch (error) { - localStorage.removeItem(tokenKey); - localStorage.removeItem(refreshKey); - accessToken = ""; + clearTokens(); setAuthenticated(false); - $("loginMessage").textContent = JSON.stringify(error); + setLoginMessage(errorMessage(error)); } } @@ -1902,14 +2031,21 @@ } if (target.id === "clearRequestLogButton") $("requestLog").innerHTML = ""; if (target.id === "logoutButton") { - localStorage.removeItem(tokenKey); - localStorage.removeItem(refreshKey); - accessToken = ""; + clearTokens(); navigateDashboard("/dashboard"); setAuthenticated(false); } if (target.id === "refreshTokenButton") await refreshJwt(); if (target.id === "refreshButton" || target.id === "drawerRefreshButton") await refreshDashboard(); + if (target.dataset.authMode) { + const mode = target.dataset.authMode; + document.querySelectorAll("[data-auth-mode]").forEach((button) => { + button.classList.toggle("active", button.dataset.authMode === mode); + }); + $("loginForm").classList.toggle("hidden", mode !== "login"); + $("registerForm").classList.toggle("hidden", mode !== "register"); + setLoginMessage(""); + } if (target.dataset.sourceDetail) openSourceDetail(target.dataset.sourceDetail); if (target.dataset.exchangeAction === "test") { const data = formPayload($("exchangeConnectionForm")); diff --git a/tests/apps/parsers/test_dashboard_page.py b/tests/apps/parsers/test_dashboard_page.py new file mode 100644 index 0000000..4fa87d0 --- /dev/null +++ b/tests/apps/parsers/test_dashboard_page.py @@ -0,0 +1,23 @@ +"""Regression tests for the standalone parser dashboard page.""" + +from django.test import TestCase + + +class ParserDashboardPageTest(TestCase): + def test_dashboard_exposes_login_and_registration_flows(self): + response = self.client.get("/dashboard") + + self.assertEqual(response.status_code, 200) + content = response.content.decode() + self.assertIn('id="loginForm"', content) + self.assertIn('id="registerForm"', content) + self.assertIn("/api/v1/users/login/", content) + self.assertIn("/api/v1/users/register/", content) + + def test_dashboard_does_not_drop_jwt_on_registers_panel_failure(self): + response = self.client.get("/dashboard") + + self.assertEqual(response.status_code, 200) + content = response.content.decode() + self.assertIn("refreshRegisters().catch(renderRegistersUnavailable)", content) + self.assertIn("isAuthError(error)", content)
Вход выполняется по username. Регистрация открыта и сразу возвращает JWT.
/api/v1/users/login/
/api/v1/users/register/