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
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:
@@ -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"));
|
||||
|
||||
23
tests/apps/parsers/test_dashboard_page.py
Normal file
23
tests/apps/parsers/test_dashboard_page.py
Normal 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)
|
||||
Reference in New Issue
Block a user