fix dashboard auth flow
All checks were successful
CI/CD Pipeline / Manual Action Help (push) Has been skipped
CI/CD Pipeline / Start Dev Containers in Dokploy (push) Has been skipped
CI/CD Pipeline / Cleanup Dev Database (push) Has been skipped
CI/CD Pipeline / Quality Gate (push) Successful in 2m21s
CI/CD Pipeline / Build and Push Images (push) Successful in 2m24s
CI/CD Pipeline / Internal Notify (push) Successful in 1s

This commit is contained in:
2026-04-28 12:42:39 +02:00
parent 1edcfc4be8
commit afa7845fef
2 changed files with 187 additions and 28 deletions

View File

@@ -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 @@
</header>
<main>
<section id="loginPanel">
<h2>Вход</h2>
<section id="loginPanel" class="auth-panel">
<div class="section-head">
<div>
<h2>Авторизация</h2>
<p>Вход выполняется по username. Регистрация открыта и сразу возвращает JWT.</p>
</div>
<div class="auth-switch" role="tablist" aria-label="Авторизация">
<button class="active" data-auth-mode="login" type="button">Вход</button>
<button data-auth-mode="register" type="button">Регистрация</button>
</div>
</div>
<form id="loginForm">
<div class="grid">
<label>Логин<input name="username" type="text" autocomplete="username" required></label>
@@ -710,9 +743,24 @@
</div>
<div class="row">
<button type="submit">Получить JWT</button>
<div id="loginMessage" class="message"></div>
<span class="muted">POST <code>/api/v1/users/login/</code></span>
</div>
</form>
<form id="registerForm" class="hidden">
<div class="grid">
<label>Email<input name="email" type="email" autocomplete="email" required></label>
<label>Логин<input name="username" type="text" autocomplete="username" required></label>
<label>Имя<input name="first_name" type="text" autocomplete="given-name" required></label>
<label>Фамилия<input name="last_name" type="text" autocomplete="family-name" required></label>
<label>Пароль<input name="password" type="password" autocomplete="new-password" minlength="8" required></label>
<label>Повтор пароля<input name="password_confirm" type="password" autocomplete="new-password" minlength="8" required></label>
</div>
<div class="row">
<button type="submit">Создать пользователя</button>
<span class="muted">POST <code>/api/v1/users/register/</code></span>
</div>
</form>
<div id="loginMessage" class="message"></div>
</section>
<section id="sourceRoutePanel" class="hidden">
@@ -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 = `<div class="empty-state">Реестры недоступны: ${escapeHtml(errorMessage(error))}</div>`;
$("backupStatus").textContent = `Выгрузка .bin недоступна: ${errorMessage(error)}`;
}
function setSelectOptions(selectId, tables) {
$(selectId).innerHTML = `<option value="">-</option>${tables.map((item) => (
`<option value="${escapeHtml(item.table)}">${escapeHtml(item.title)} · ${escapeHtml(item.table)}</option>`
@@ -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"));

View File

@@ -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)