From 141fc4409961b9423ef5322a23a6783696a46b1c Mon Sep 17 00:00:00 2001 From: PTah Date: Mon, 1 Jun 2026 11:06:21 +1000 Subject: [PATCH] feat: SAC 0.7.3 custom error pages 401/403/404/429 Centered logo pages with event explanation and allowed actions; router and apiFetch redirect to them on auth, permission, not-found, and rate-limit errors. Co-authored-by: Cursor --- backend/app/version.py | 2 +- backend/tests/test_health.py | 4 +- frontend/package.json | 2 +- frontend/src/App.vue | 3 +- frontend/src/api.ts | 41 +++++++-- frontend/src/errorPages.ts | 53 ++++++++++++ frontend/src/router.ts | 26 +++++- frontend/src/version.ts | 2 +- frontend/src/views/ErrorPageView.vue | 124 +++++++++++++++++++++++++++ 9 files changed, 241 insertions(+), 16 deletions(-) create mode 100644 frontend/src/errorPages.ts create mode 100644 frontend/src/views/ErrorPageView.vue diff --git a/backend/app/version.py b/backend/app/version.py index 9aafedc..ac5e340 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.7.2" +APP_VERSION = "0.7.3" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 8567206..60d1d43 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL def test_version_constants(): - assert APP_VERSION == "0.7.2" + assert APP_VERSION == "0.7.3" assert APP_NAME == "Security Alert Center" - assert APP_VERSION_LABEL == "Security Alert Center v.0.7.2" + assert APP_VERSION_LABEL == "Security Alert Center v.0.7.3" diff --git a/frontend/package.json b/frontend/package.json index 8c40411..287d3e5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "sac-ui", "private": true, - "version": "0.7.2", + "version": "0.7.3", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c99a3bb..1b926c9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -15,7 +15,8 @@ import AppSidebar from "./components/AppSidebar.vue"; import { refreshSessionRole } from "./router"; const route = useRoute(); -const showShell = computed(() => route.path !== "/login" && !!getToken()); +const publicLayout = computed(() => route.path === "/login" || route.meta.standalone === true); +const showShell = computed(() => !publicLayout.value && !!getToken()); onMounted(() => { if (getToken()) { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 557e563..c9f497b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -31,29 +31,56 @@ export function clearToken(): void { localStorage.removeItem(ROLE_KEY); } +export class ApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +export function redirectToErrorPage(status: 401 | 403 | 429): void { + window.location.href = `/${status}`; +} + export async function apiFetch(path: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers); if (!headers.has("Content-Type") && init.body) { headers.set("Content-Type", "application/json"); } const token = getToken(); + const hadToken = !!token; if (token) { headers.set("Authorization", `Bearer ${token}`); } const res = await fetch(path, { ...init, headers }); + const text = await res.text(); if (res.status === 401) { - clearToken(); - window.location.href = "/login"; - throw new Error("Unauthorized"); + if (hadToken) { + clearToken(); + redirectToErrorPage(401); + } + throw new ApiError(parseApiError(text) || "Unauthorized", 401); } if (res.status === 403) { - throw new Error("Forbidden"); + if (hadToken) { + redirectToErrorPage(403); + } + throw new ApiError(parseApiError(text) || "Forbidden", 403); + } + if (res.status === 429) { + redirectToErrorPage(429); + throw new ApiError(parseApiError(text) || "Too many requests", 429); } if (!res.ok) { - const text = await res.text(); - throw new Error(parseApiError(text) || res.statusText); + throw new ApiError(parseApiError(text) || res.statusText, res.status); } - return res.json() as Promise; + if (!text) { + return undefined as T; + } + return JSON.parse(text) as T; } export interface TokenResponse { diff --git a/frontend/src/errorPages.ts b/frontend/src/errorPages.ts new file mode 100644 index 0000000..44e108a --- /dev/null +++ b/frontend/src/errorPages.ts @@ -0,0 +1,53 @@ +export interface ErrorPageAction { + label: string; + to: string; +} + +export interface ErrorPageConfig { + code: number; + title: string; + lead: string; + hint: string; + primaryAction: ErrorPageAction; + secondaryAction?: ErrorPageAction; +} + +export const ERROR_PAGES: Record<401 | 403 | 404 | 429, ErrorPageConfig> = { + 401: { + code: 401, + title: "Требуется авторизация", + lead: "Сессия истекла, токен недействителен или вы ещё не вошли в SAC.", + hint: "Можно: войти снова под своим логином и паролем. Нельзя: просматривать события, хосты и настройки без входа.", + primaryAction: { label: "Войти", to: "/login" }, + }, + 403: { + code: 403, + title: "Доступ запрещён", + lead: "У вашей учётной записи нет прав на этот раздел или действие.", + hint: "Можно: работать с событиями, проблемами, хостами и отчётами (роль monitor). Нельзя: настройки уведомлений, пользователи и удаление хостов — только для admin.", + primaryAction: { label: "На обзор", to: "/dashboard" }, + secondaryAction: { label: "К событиям", to: "/events" }, + }, + 404: { + code: 404, + title: "Страница не найдена", + lead: "Такого адреса в интерфейсе SAC нет — ссылка устарела или опечатка в URL.", + hint: "Можно: перейти на обзор или воспользоваться меню слева. Нельзя: открыть несуществующий маршрут — сервер отдаёт только известные страницы SPA.", + primaryAction: { label: "На обзор", to: "/dashboard" }, + secondaryAction: { label: "К событиям", to: "/events" }, + }, + 429: { + code: 429, + title: "Слишком много попыток входа", + lead: "С вашего IP зафиксировано несколько неудачных попыток входа подряд.", + hint: "Можно: подождать около 15 минут и войти с правильным паролем. Нельзя: продолжать подбор пароля — IP временно блокируется, администратор может получить алерт в Telegram.", + primaryAction: { label: "Попробовать позже", to: "/login" }, + }, +}; + +export function getErrorPageConfig(code: number): ErrorPageConfig { + if (code === 401 || code === 403 || code === 404 || code === 429) { + return ERROR_PAGES[code]; + } + return ERROR_PAGES[404]; +} diff --git a/frontend/src/router.ts b/frontend/src/router.ts index e45d274..f64dc1a 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -4,6 +4,7 @@ import EventsView from "./views/EventsView.vue"; import EventDetailView from "./views/EventDetailView.vue"; import HostsView from "./views/HostsView.vue"; import LoginView from "./views/LoginView.vue"; +import ErrorPageView from "./views/ErrorPageView.vue"; import ProblemsView from "./views/ProblemsView.vue"; import ProblemDetailView from "./views/ProblemDetailView.vue"; import DashboardView from "./views/DashboardView.vue"; @@ -11,12 +12,25 @@ import ReportsView from "./views/ReportsView.vue"; import SettingsView from "./views/SettingsView.vue"; import UsersView from "./views/UsersView.vue"; +const PUBLIC_PATHS = new Set(["/login", "/401", "/403", "/404", "/429"]); + +const errorRoute = (code: 401 | 403 | 404 | 429) => ({ + path: `/${code}`, + component: ErrorPageView, + props: { code }, + meta: { standalone: true }, +}); + const router = createRouter({ history: createWebHistory(), routes: [ { path: "/", redirect: "/dashboard" }, { path: "/dashboard", component: DashboardView }, { path: "/login", component: LoginView }, + errorRoute(401), + errorRoute(403), + errorRoute(404), + errorRoute(429), { path: "/events", component: EventsView }, { path: "/events/:id", component: EventDetailView, props: true }, { path: "/reports", component: ReportsView }, @@ -25,14 +39,20 @@ const router = createRouter({ { path: "/problems/:id", component: ProblemDetailView, props: true }, { path: "/settings", component: SettingsView, meta: { adminOnly: true } }, { path: "/users", component: UsersView, meta: { adminOnly: true } }, + { + path: "/:pathMatch(.*)*", + component: ErrorPageView, + props: { code: 404 }, + meta: { standalone: true }, + }, ], }); router.beforeEach(async (to) => { - if (to.path === "/login") return true; - if (!getToken()) return { path: "/login" }; + if (PUBLIC_PATHS.has(to.path)) return true; + if (!getToken()) return { path: "/login", query: { redirect: to.fullPath } }; if (to.meta.adminOnly && !isAdmin()) { - return { path: "/dashboard" }; + return { path: "/403" }; } return true; }); diff --git a/frontend/src/version.ts b/frontend/src/version.ts index e07f152..c6d61a7 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,3 +1,3 @@ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.7.2"; +export const APP_VERSION = "0.7.3"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/ErrorPageView.vue b/frontend/src/views/ErrorPageView.vue new file mode 100644 index 0000000..cfbbc44 --- /dev/null +++ b/frontend/src/views/ErrorPageView.vue @@ -0,0 +1,124 @@ + + + + +