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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "sac-ui",
|
||||
"private": true,
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
+34
-7
@@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
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<T>;
|
||||
if (!text) {
|
||||
return undefined as T;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
+23
-3
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="error-page">
|
||||
<div class="error-card card">
|
||||
<img src="/sac-icon.png" alt="" class="error-logo" width="96" height="96" />
|
||||
<p class="error-code">{{ config.code }}</p>
|
||||
<h1 class="error-title">{{ config.title }}</h1>
|
||||
<p class="error-lead">{{ config.lead }}</p>
|
||||
<p class="error-hint">{{ config.hint }}</p>
|
||||
<div class="error-actions">
|
||||
<RouterLink :to="config.primaryAction.to" class="error-btn error-btn-primary">
|
||||
{{ config.primaryAction.label }}
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="showSecondary"
|
||||
:to="config.secondaryAction!.to"
|
||||
class="error-btn error-btn-secondary"
|
||||
>
|
||||
{{ config.secondaryAction!.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p class="error-brand muted">Security Alert Center</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { getErrorPageConfig } from "../errorPages";
|
||||
|
||||
const props = defineProps<{
|
||||
code: number;
|
||||
}>();
|
||||
|
||||
const config = computed(() => getErrorPageConfig(props.code));
|
||||
|
||||
const showSecondary = computed(() => Boolean(config.value.secondaryAction));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
padding: 2rem 1.75rem;
|
||||
}
|
||||
|
||||
.error-logo {
|
||||
border-radius: 16px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
margin: 0;
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: #7eb8ff;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.error-lead {
|
||||
margin: 1rem 0 0;
|
||||
color: #e8eaed;
|
||||
}
|
||||
|
||||
.error-hint {
|
||||
margin: 1rem 0 0;
|
||||
padding: 0.85rem 1rem;
|
||||
background: #0f1419;
|
||||
border: 1px solid #2a3441;
|
||||
border-radius: 8px;
|
||||
color: #9aa4b2;
|
||||
font-size: 0.92rem;
|
||||
text-align: left;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.error-btn {
|
||||
display: inline-block;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.error-btn-primary {
|
||||
background: #2563eb;
|
||||
border: 1px solid #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.error-btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid #3d4f63;
|
||||
color: #e8eaed;
|
||||
}
|
||||
|
||||
.error-brand {
|
||||
margin: 1.5rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user