Админка: кнопки в шапке сайта, красный выход и путь, который нигде не утекает
Интерфейс: - после входа на главной появляются «Админка» и «Выйти» — в шапке, перед переключателем темы и после него соответственно (порядок: Админка, тема, Выйти); - «Выйти» теперь красная и заметно реагирует на наведение (темнеет, приподнимается, тень) — и на сайте, и в админке (одна кнопка в шапке, из карточки убрана); - в админке убраны «осталось попыток», путь к файлу настроек и карточка «Данные расписания» (дублировала шапку); блокировка показывается отдельной плашкой только когда действительно активна. Безопасность пути: - путь к панели не отдаётся ни в HTML, ни в JSON (из /api/admin/state убран), ни в JS: на главной для вошедшего только флаг, а кнопка «Админка» ведёт на служебный /api/admin/goto, который отвечает 302 на секретный путь; - без сессии /api/admin/goto и /api/admin/state отвечают 401, неизвестные пути дают 404 — перебором путь не находится; - страница панели: noindex/nofollow/noarchive, Referrer-Policy: no-referrer, X-Frame-Options: DENY, Cache-Control: no-store; - robots.txt без пути (только Disallow: /api/), внешние ссылки в панели — rel=noopener noreferrer. Тесты: 161 (состояние без пути, /goto: 401 без входа и 302 с ним).
This commit is contained in:
@@ -28,6 +28,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from schedule import ScheduleCache, ScheduleError
|
from schedule import ScheduleCache, ScheduleError
|
||||||
@@ -245,6 +246,8 @@ def create_admin_router(
|
|||||||
ip = _client_ip(request)
|
ip = _client_ip(request)
|
||||||
until = throttle.locked_until(ip)
|
until = throttle.locked_until(ip)
|
||||||
payload = cache.state()
|
payload = cache.state()
|
||||||
|
# путь к панели здесь НЕ отдаём: он не должен попадать ни в JSON, ни в
|
||||||
|
# HTML — переход в панель идёт через /api/admin/goto (редирект)
|
||||||
payload["throttle"] = {
|
payload["throttle"] = {
|
||||||
"max_attempts": config.max_attempts,
|
"max_attempts": config.max_attempts,
|
||||||
"lockout_minutes": config.lockout_minutes,
|
"lockout_minutes": config.lockout_minutes,
|
||||||
@@ -253,6 +256,20 @@ def create_admin_router(
|
|||||||
}
|
}
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
@router.get("/goto", dependencies=[Depends(require_auth)])
|
||||||
|
def goto() -> Response:
|
||||||
|
"""Переход в панель.
|
||||||
|
|
||||||
|
Так секретный путь не хранится в разметке главной страницы: кнопка
|
||||||
|
«Админка» дёргает этот адрес, и браузер уже получает редирект.
|
||||||
|
Без валидной сессии ответ — 401, то есть ботам тут ничего не видно.
|
||||||
|
"""
|
||||||
|
if not config.path:
|
||||||
|
raise HTTPException(status_code=500, detail="ADMIN_PATH не задан")
|
||||||
|
return RedirectResponse(
|
||||||
|
url=config.path, status_code=302, headers={"Cache-Control": "no-store"}
|
||||||
|
)
|
||||||
|
|
||||||
@router.post("/refresh", dependencies=[Depends(require_auth)])
|
@router.post("/refresh", dependencies=[Depends(require_auth)])
|
||||||
def refresh() -> dict:
|
def refresh() -> dict:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -85,7 +85,6 @@
|
|||||||
}
|
}
|
||||||
.admin-btn:hover { filter: brightness(1.06); }
|
.admin-btn:hover { filter: brightness(1.06); }
|
||||||
.admin-btn.secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-2); }
|
.admin-btn.secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-2); }
|
||||||
.admin-btn.danger { background: transparent; color: var(--accent-red-bright); border-color: var(--accent-red-bright); }
|
|
||||||
.admin-btn-lg { font-size: 15px; padding: 11px 18px; }
|
.admin-btn-lg { font-size: 15px; padding: 11px 18px; }
|
||||||
.admin-btn[disabled] { opacity: .55; cursor: default; filter: none; }
|
.admin-btn[disabled] { opacity: .55; cursor: default; filter: none; }
|
||||||
|
|
||||||
@@ -163,6 +162,7 @@
|
|||||||
<svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
<svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||||
<svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
<svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="topbar-logout" id="logout" type="button">Выйти</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -197,10 +197,8 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="admin-card-actions">
|
<div class="admin-card-actions">
|
||||||
<button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button>
|
<button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button>
|
||||||
<a class="admin-link" id="link-open" href="#" target="_blank" rel="noopener">открыть таблицу</a>
|
<a class="admin-link" id="link-open" href="#" target="_blank" rel="noopener noreferrer">открыть таблицу</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="admin-hint">Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.
|
|
||||||
Значение хранится в файле настроек и <b>имеет приоритет над .env</b>.</p>
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="admin-card">
|
<article class="admin-card">
|
||||||
@@ -216,8 +214,6 @@
|
|||||||
<div><dt>Дата файла на Диске</dt><dd id="fact-modified">—</dd></div>
|
<div><dt>Дата файла на Диске</dt><dd id="fact-modified">—</dd></div>
|
||||||
<div><dt>Отпечаток файла</dt><dd id="fact-hash" class="admin-mono">—</dd></div>
|
<div><dt>Отпечаток файла</dt><dd id="fact-hash" class="admin-mono">—</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<p class="admin-hint">По интервалу таблица перечитывается, только если на Диске изменилась дата файла.
|
|
||||||
Кнопка «Обновить сейчас» скачивает таблицу всегда — её и нажимайте сразу после правок.</p>
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="admin-card">
|
<article class="admin-card">
|
||||||
@@ -225,21 +221,8 @@
|
|||||||
<dl class="admin-facts">
|
<dl class="admin-facts">
|
||||||
<div><dt>Последняя попытка</dt><dd id="auth-attempt">—</dd></div>
|
<div><dt>Последняя попытка</dt><dd id="auth-attempt">—</dd></div>
|
||||||
<div><dt>Последний успешный вход</dt><dd id="auth-success">—</dd></div>
|
<div><dt>Последний успешный вход</dt><dd id="auth-success">—</dd></div>
|
||||||
<div><dt>Осталось попыток</dt><dd id="auth-throttle">—</dd></div>
|
|
||||||
</dl>
|
|
||||||
<div class="admin-card-actions" style="margin-top:auto">
|
|
||||||
<button class="admin-btn danger" id="logout" type="button">Выйти</button>
|
|
||||||
</div>
|
|
||||||
<p class="admin-hint">Каждая попытка запоминается: откуда, когда и чем закончилась. Порог и время блокировки задаются в .env.</p>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="admin-card">
|
|
||||||
<h2>Данные расписания</h2>
|
|
||||||
<dl class="admin-facts">
|
|
||||||
<div><dt>Групп</dt><dd id="fact-groups">—</dd></div>
|
|
||||||
<div><dt>Обновлено</dt><dd id="fact-updated">—</dd></div>
|
|
||||||
<div><dt>Файл настроек</dt><dd id="fact-settings" class="admin-mono">—</dd></div>
|
|
||||||
</dl>
|
</dl>
|
||||||
|
<div id="auth-lock" class="admin-alert warn hidden" style="margin:10px 0 0"></div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -373,16 +356,13 @@
|
|||||||
setText("auth-attempt", loginText(attempt), attempt ? (attempt.ok ? "good" : "bad") : null);
|
setText("auth-attempt", loginText(attempt), attempt ? (attempt.ok ? "good" : "bad") : null);
|
||||||
setText("auth-success", loginText(state.last_success));
|
setText("auth-success", loginText(state.last_success));
|
||||||
const throttle = state.throttle || {};
|
const throttle = state.throttle || {};
|
||||||
|
const lock = $("auth-lock");
|
||||||
if (throttle.locked_until) {
|
if (throttle.locked_until) {
|
||||||
setText("auth-throttle", `заблокировано до ${fmt(throttle.locked_until)}`, "bad");
|
lock.textContent = `Вход заблокирован до ${fmt(throttle.locked_until)} — слишком много неудачных попыток.`;
|
||||||
} else if (throttle.max_attempts) {
|
lock.classList.remove("hidden");
|
||||||
setText("auth-throttle", `${throttle.attempts_left} из ${throttle.max_attempts}`);
|
} else {
|
||||||
|
lock.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
// данные
|
|
||||||
setText("fact-groups", String(state.groups));
|
|
||||||
setText("fact-updated", state.last_updated ? `${fmt(state.last_updated)} (${ago(state.last_updated)})` : "—");
|
|
||||||
setText("fact-settings", state.settings_file || "—");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLogin(show) {
|
function showLogin(show) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import uvicorn
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -48,6 +48,32 @@ ADMIN_PATH = (os.getenv("ADMIN_PATH") or "").strip().rstrip("/")
|
|||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
# страница админки вне static: прямой доступ по /admin.html закрыт
|
# страница админки вне static: прямой доступ по /admin.html закрыт
|
||||||
ADMIN_PAGE = Path(__file__).parent / "admin.html"
|
ADMIN_PAGE = Path(__file__).parent / "admin.html"
|
||||||
|
INDEX_PAGE = STATIC_DIR / "index.html"
|
||||||
|
# проверка «это админ?» при отдаче главной — свой короткий таймаут
|
||||||
|
ADMIN_CHECK_TIMEOUT = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def admin_session_active(request: Request) -> bool:
|
||||||
|
"""Есть ли валидная админская сессия у запроса.
|
||||||
|
|
||||||
|
Сам путь к панели при этом не читаем и никому не отдаём: главная страница
|
||||||
|
получает только булев флаг, по которому показывает кнопку «Админка».
|
||||||
|
"""
|
||||||
|
if not ADMIN_PATH:
|
||||||
|
return False
|
||||||
|
cookie = request.headers.get("cookie")
|
||||||
|
if not cookie:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{SCHEDULE_API}/api/admin/state",
|
||||||
|
headers={"Cookie": cookie},
|
||||||
|
timeout=ADMIN_CHECK_TIMEOUT,
|
||||||
|
)
|
||||||
|
except requests.RequestException:
|
||||||
|
return False
|
||||||
|
return resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Расписание — фронт")
|
app = FastAPI(title="Расписание — фронт")
|
||||||
|
|
||||||
@@ -82,6 +108,9 @@ def _proxy_admin(method: str, path: str, body: bytes, headers: dict) -> Response
|
|||||||
data=body or None,
|
data=body or None,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=PROXY_TIMEOUT,
|
timeout=PROXY_TIMEOUT,
|
||||||
|
# редирект /api/admin/goto отдаём браузеру как есть: он и уводит
|
||||||
|
# на страницу панели, чтобы путь не появлялся в разметке
|
||||||
|
allow_redirects=False,
|
||||||
)
|
)
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}")
|
raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}")
|
||||||
@@ -91,6 +120,9 @@ def _proxy_admin(method: str, path: str, body: bytes, headers: dict) -> Response
|
|||||||
media_type=resp.headers.get("content-type"),
|
media_type=resp.headers.get("content-type"),
|
||||||
headers={"Cache-Control": "no-store"},
|
headers={"Cache-Control": "no-store"},
|
||||||
)
|
)
|
||||||
|
location = resp.headers.get("location")
|
||||||
|
if location:
|
||||||
|
out.headers["location"] = location
|
||||||
# Set-Cookie (вход/выход админки) передаём как есть
|
# Set-Cookie (вход/выход админки) передаём как есть
|
||||||
raw_headers = getattr(resp.raw, "headers", None)
|
raw_headers = getattr(resp.raw, "headers", None)
|
||||||
for cookie in raw_headers.getlist("Set-Cookie") if raw_headers is not None else []:
|
for cookie in raw_headers.getlist("Set-Cookie") if raw_headers is not None else []:
|
||||||
@@ -177,8 +209,21 @@ async def api_admin(rest: str, request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def index():
|
def index(request: Request):
|
||||||
return FileResponse(STATIC_DIR / "index.html")
|
"""Главная страница.
|
||||||
|
|
||||||
|
Вошедшему админу добавляем только флаг (без пути!): по нему страница
|
||||||
|
покажет кнопку «Админка», которая ведёт на /api/admin/goto — сервер сам
|
||||||
|
ответит редиректом на секретный путь. Так путь не попадает ни в разметку,
|
||||||
|
ни в данные, и неавторизованный пользователь его нигде не увидит.
|
||||||
|
"""
|
||||||
|
if admin_session_active(request):
|
||||||
|
html = INDEX_PAGE.read_text(encoding="utf-8")
|
||||||
|
html = html.replace(
|
||||||
|
"<body>", "<body>\n <script>window.__SCHEDULE_ADMIN__ = true;</script>", 1
|
||||||
|
)
|
||||||
|
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
|
||||||
|
return FileResponse(INDEX_PAGE, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
if ADMIN_PATH:
|
if ADMIN_PATH:
|
||||||
@@ -187,7 +232,13 @@ if ADMIN_PATH:
|
|||||||
"""Страница админки: путь задаётся в ADMIN_PATH, входа с сайта нет."""
|
"""Страница админки: путь задаётся в ADMIN_PATH, входа с сайта нет."""
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
ADMIN_PAGE,
|
ADMIN_PAGE,
|
||||||
headers={"X-Robots-Tag": "noindex, nofollow", "Cache-Control": "no-store"},
|
headers={
|
||||||
|
"X-Robots-Tag": "noindex, nofollow, noarchive",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
# путь не должен утекать во внешние ссылки через Referer
|
||||||
|
"Referrer-Policy": "no-referrer",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
log.info("Страница админки включена по секретному пути")
|
log.info("Страница админки включена по секретному пути")
|
||||||
|
|||||||
@@ -787,6 +787,24 @@ themeToggle.addEventListener("click", () => {
|
|||||||
|
|
||||||
initTheme();
|
initTheme();
|
||||||
|
|
||||||
|
/* Кнопки админки в шапке: сервер подставляет путь, только если сессия валидна */
|
||||||
|
const adminLink = document.getElementById("admin-link");
|
||||||
|
const adminLogout = document.getElementById("admin-logout");
|
||||||
|
|
||||||
|
if (adminLink && adminLogout && window.__SCHEDULE_ADMIN__) {
|
||||||
|
// путь к панели в разметке не храним: сервер сам ответит редиректом
|
||||||
|
adminLink.classList.remove("hidden");
|
||||||
|
adminLogout.classList.remove("hidden");
|
||||||
|
adminLink.addEventListener("click", () => { window.location.href = "/api/admin/goto"; });
|
||||||
|
adminLogout.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/admin/logout", { method: "POST", credentials: "same-origin" });
|
||||||
|
} catch (_) {}
|
||||||
|
adminLink.classList.add("hidden");
|
||||||
|
adminLogout.classList.add("hidden");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll(".side-menu a").forEach((link) => {
|
document.querySelectorAll(".side-menu a").forEach((link) => {
|
||||||
link.addEventListener("click", (e) => {
|
link.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -28,10 +28,14 @@
|
|||||||
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-faculty">Факультет информатики и вычислительной техники</a>
|
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-faculty">Факультет информатики и вычислительной техники</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="theme-toggle" class="theme-toggle" title="Переключить тему">
|
<div class="topbar-actions">
|
||||||
|
<button id="admin-link" class="topbar-admin hidden" type="button">Админка</button>
|
||||||
|
<button id="theme-toggle" class="theme-toggle" title="Переключить тему">
|
||||||
<svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
<svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||||
<svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
<svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="admin-logout" class="topbar-logout hidden" type="button">Выйти</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
|
|||||||
2
src/frontend/static/robots.txt
Normal file
2
src/frontend/static/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /api/
|
||||||
@@ -151,6 +151,47 @@ a:hover { color: var(--accent-red); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
|
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---- кнопки в шапке: админка и выход ---- */
|
||||||
|
.topbar-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.topbar-admin {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: filter .15s ease, transform .15s ease;
|
||||||
|
}
|
||||||
|
.topbar-admin:hover { filter: brightness(1.1); transform: translateY(-1px); }
|
||||||
|
.topbar-admin:active { transform: none; }
|
||||||
|
|
||||||
|
/* красная кнопка выхода: при наведении темнеет, приподнимается и даёт тень */
|
||||||
|
.topbar-logout {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid var(--accent-red-bright);
|
||||||
|
background: var(--accent-red-bright);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s ease, border-color .15s ease, box-shadow .15s ease, transform .15s ease;
|
||||||
|
}
|
||||||
|
.topbar-logout:hover {
|
||||||
|
background: var(--accent-red);
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
box-shadow: 0 4px 12px color-mix(in srgb, var(--accent-red-bright) 35%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.topbar-logout:active { transform: none; box-shadow: none; }
|
||||||
.theme-toggle .icon { width: 18px; height: 18px; }
|
.theme-toggle .icon { width: 18px; height: 18px; }
|
||||||
|
|
||||||
.icon-sun { display: none; }
|
.icon-sun { display: none; }
|
||||||
|
|||||||
@@ -75,6 +75,18 @@ def test_login_and_state(client, cache):
|
|||||||
assert state["groups"] == 2
|
assert state["groups"] == 2
|
||||||
assert state["error"] is None
|
assert state["error"] is None
|
||||||
assert state["throttle"]["max_attempts"] == ADMIN.max_attempts
|
assert state["throttle"]["max_attempts"] == ADMIN.max_attempts
|
||||||
|
# путь к панели не должен утекать в JSON: переход идёт через /goto
|
||||||
|
assert "admin_path" not in state
|
||||||
|
assert ADMIN.path not in str(state)
|
||||||
|
|
||||||
|
|
||||||
|
def test_goto_redirects_to_panel_only_for_authorized(client):
|
||||||
|
"""Переход в панель: без сессии 401, с сессией — редирект на секретный путь."""
|
||||||
|
assert client.get("/api/admin/goto", follow_redirects=False).status_code == 401
|
||||||
|
do_login(client)
|
||||||
|
resp = client.get("/api/admin/goto", follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert resp.headers["location"] == ADMIN.path
|
||||||
|
|
||||||
|
|
||||||
def test_logout(client):
|
def test_logout(client):
|
||||||
|
|||||||
Reference in New Issue
Block a user