Авторизация: форма входа, cookie-сессия, защита /api/* (кроме health/login/me/logout)
This commit is contained in:
123
public/app.js
123
public/app.js
@@ -36,6 +36,14 @@ const jobStatusText = document.querySelector("#job-status-text");
|
||||
const downloadLink = document.querySelector("#download-link");
|
||||
const downloadCurrent = document.querySelector("#download-current");
|
||||
|
||||
const authOverlay = document.querySelector("#auth-overlay");
|
||||
const loginForm = document.querySelector("#login-form");
|
||||
const loginUsername = document.querySelector("#login-username");
|
||||
const loginPassword = document.querySelector("#login-password");
|
||||
const loginError = document.querySelector("#login-error");
|
||||
const loginSubmit = document.querySelector("#login-submit");
|
||||
const logoutButton = document.querySelector("#logout-button");
|
||||
|
||||
let currentVersionId = null;
|
||||
let historyVersions = [];
|
||||
let sourceObjectUrl = null;
|
||||
@@ -134,6 +142,15 @@ function resetResult() {
|
||||
downloadLink.removeAttribute("download");
|
||||
}
|
||||
|
||||
function clearEditorState() {
|
||||
historyVersions = [];
|
||||
currentVersionId = null;
|
||||
clearSourcePreview();
|
||||
resetResult();
|
||||
renderHistory();
|
||||
updateDownloadCurrent(null);
|
||||
}
|
||||
|
||||
function clearSourcePreview() {
|
||||
if (sourceObjectUrl) {
|
||||
URL.revokeObjectURL(sourceObjectUrl);
|
||||
@@ -196,7 +213,7 @@ async function selectFile(file) {
|
||||
form.append("photo", file, file.name);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/history/import", {
|
||||
const response = await apiFetch("/api/history/import", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
cache: "no-store"
|
||||
@@ -274,6 +291,21 @@ async function readJsonResponse(response) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function apiFetch(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
credentials: "same-origin"
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
clearEditorState();
|
||||
showAuthOverlay();
|
||||
throw new Error("Требуется авторизация.");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function runJob(kind) {
|
||||
if (isBusy) {
|
||||
return;
|
||||
@@ -315,7 +347,7 @@ async function runJob(kind) {
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
cache: "no-store"
|
||||
@@ -476,7 +508,7 @@ function selectHistoryVersion(version) {
|
||||
|
||||
async function loadHistoryOnStart() {
|
||||
try {
|
||||
const response = await fetch("/api/history", {
|
||||
const response = await apiFetch("/api/history", {
|
||||
cache: "no-store"
|
||||
});
|
||||
|
||||
@@ -507,12 +539,90 @@ async function loadHistoryOnStart() {
|
||||
}
|
||||
}
|
||||
|
||||
function showAuthOverlay() {
|
||||
authOverlay.hidden = false;
|
||||
logoutButton.hidden = true;
|
||||
loginError.hidden = true;
|
||||
loginPassword.value = "";
|
||||
loginUsername.focus();
|
||||
}
|
||||
|
||||
function hideAuthOverlay() {
|
||||
authOverlay.hidden = true;
|
||||
logoutButton.hidden = false;
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const response = await apiFetch("/api/me", { cache: "no-store" });
|
||||
const data = await readJsonResponse(response);
|
||||
|
||||
if (data.ok) {
|
||||
hideAuthOverlay();
|
||||
loadHistoryOnStart();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Сеть недоступна или 401 — считаем пользователя не вошедшим.
|
||||
}
|
||||
|
||||
showAuthOverlay();
|
||||
}
|
||||
|
||||
async function submitLogin(event) {
|
||||
event.preventDefault();
|
||||
|
||||
loginError.hidden = true;
|
||||
loginSubmit.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: loginUsername.value.trim(),
|
||||
password: loginPassword.value
|
||||
})
|
||||
});
|
||||
|
||||
const data = await readJsonResponse(response);
|
||||
|
||||
if (!response.ok || !data.ok) {
|
||||
throw new Error(data.error || "Неверный логин или пароль.");
|
||||
}
|
||||
|
||||
loginPassword.value = "";
|
||||
hideAuthOverlay();
|
||||
loadHistoryOnStart();
|
||||
} catch (error) {
|
||||
loginError.textContent =
|
||||
error instanceof Error ? error.message : "Неверный логин или пароль.";
|
||||
loginError.hidden = false;
|
||||
loginPassword.focus();
|
||||
} finally {
|
||||
loginSubmit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await fetch("/api/logout", { method: "POST", credentials: "same-origin" });
|
||||
} catch {
|
||||
// Выходим локально даже при ошибке сети.
|
||||
}
|
||||
|
||||
clearEditorState();
|
||||
showAuthOverlay();
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
setHealthState("loading", "Проверяем сервер обработки…");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/health", {
|
||||
cache: "no-store"
|
||||
cache: "no-store",
|
||||
credentials: "same-origin"
|
||||
});
|
||||
const data = await readJsonResponse(response);
|
||||
|
||||
@@ -601,6 +711,9 @@ promptInput.addEventListener("input", () => {
|
||||
editButton.addEventListener("click", () => runJob("edit"));
|
||||
upscaleButton.addEventListener("click", () => runJob("upscale"));
|
||||
|
||||
loginForm.addEventListener("submit", submitLogin);
|
||||
logoutButton.addEventListener("click", logout);
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
if (sourceObjectUrl) {
|
||||
URL.revokeObjectURL(sourceObjectUrl);
|
||||
@@ -608,4 +721,4 @@ window.addEventListener("beforeunload", () => {
|
||||
});
|
||||
|
||||
checkHealth();
|
||||
loadHistoryOnStart();
|
||||
checkAuth();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23202321'/%3E%3Ctext x='32' y='45' font-size='38' font-weight='bold' text-anchor='middle' fill='%23ffffff' font-family='sans-serif'%3E%D0%9A%3C/text%3E%3C/svg%3E"
|
||||
>
|
||||
<link rel="stylesheet" href="/style.css?v=8">
|
||||
<link rel="stylesheet" href="/style.css?v=9">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -24,14 +24,19 @@
|
||||
<span class="brand-name">Кадр</span>
|
||||
</a>
|
||||
|
||||
<div
|
||||
id="health-status"
|
||||
class="health-status health-loading"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="health-dot" aria-hidden="true"></span>
|
||||
<span id="health-text">Проверяем сервер обработки…</span>
|
||||
<div class="topbar-right">
|
||||
<div
|
||||
id="health-status"
|
||||
class="health-status health-loading"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="health-dot" aria-hidden="true"></span>
|
||||
<span id="health-text">Проверяем сервер обработки…</span>
|
||||
</div>
|
||||
<button id="logout-button" class="logout-button" type="button" hidden>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -230,12 +235,32 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="auth-overlay" class="auth-overlay" role="dialog" aria-modal="true" aria-labelledby="auth-title">
|
||||
<form id="login-form" class="auth-card" novalidate>
|
||||
<p class="auth-eyebrow">Закрытый доступ</p>
|
||||
<h1 id="auth-title">Вход в редактор</h1>
|
||||
<p class="auth-lead">Редактор доступен только владельцу. Войдите, чтобы продолжить.</p>
|
||||
<div class="auth-field">
|
||||
<label class="field-label" for="login-username">Логин</label>
|
||||
<input id="login-username" name="username" type="text" autocomplete="username" autofocus required>
|
||||
</div>
|
||||
<div class="auth-field">
|
||||
<label class="field-label" for="login-password">Пароль</label>
|
||||
<input id="login-password" name="password" type="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<p id="login-error" class="auth-error" hidden></p>
|
||||
<button id="login-submit" class="button button-primary auth-submit" type="submit">
|
||||
<span>Войти</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
<div class="noscript">
|
||||
Для работы редактора необходимо включить JavaScript.
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<script src="/app.js?v=8" defer></script>
|
||||
<script src="/app.js?v=9" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
120
public/style.css
120
public/style.css
@@ -988,3 +988,123 @@ textarea:disabled {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* -- Авторизация -------------------------------------------------- */
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 13px;
|
||||
border: 1px solid var(--dark);
|
||||
border-radius: 999px;
|
||||
color: var(--dark);
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 740;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
background 150ms ease,
|
||||
color 150ms ease;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: var(--dark);
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.logout-button:focus-visible {
|
||||
outline: 3px solid rgba(108, 92, 231, 0.28);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.auth-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
background: rgba(243, 240, 232, 0.82);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(400px, 100%);
|
||||
padding: 34px 32px;
|
||||
border: 1px solid rgba(23, 24, 23, 0.08);
|
||||
border-radius: var(--radius-large);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.auth-eyebrow {
|
||||
margin: 0;
|
||||
color: var(--accent-dark);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(26px, 4vw, 34px);
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.auth-lead {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.auth-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auth-field input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-medium);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.auth-field input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(108, 92, 231, 0.1);
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-small);
|
||||
color: var(--danger);
|
||||
background: rgba(184, 67, 67, 0.07);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user