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)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.7.2"
|
APP_VERSION = "0.7.3"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
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():
|
def test_version_constants():
|
||||||
assert APP_VERSION == "0.7.2"
|
assert APP_VERSION == "0.7.3"
|
||||||
assert APP_NAME == "Security Alert Center"
|
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",
|
"name": "sac-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.2",
|
"version": "0.7.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import AppSidebar from "./components/AppSidebar.vue";
|
|||||||
import { refreshSessionRole } from "./router";
|
import { refreshSessionRole } from "./router";
|
||||||
|
|
||||||
const route = useRoute();
|
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(() => {
|
onMounted(() => {
|
||||||
if (getToken()) {
|
if (getToken()) {
|
||||||
|
|||||||
+33
-6
@@ -31,29 +31,56 @@ export function clearToken(): void {
|
|||||||
localStorage.removeItem(ROLE_KEY);
|
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> {
|
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
const headers = new Headers(init.headers);
|
const headers = new Headers(init.headers);
|
||||||
if (!headers.has("Content-Type") && init.body) {
|
if (!headers.has("Content-Type") && init.body) {
|
||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
}
|
}
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
const hadToken = !!token;
|
||||||
if (token) {
|
if (token) {
|
||||||
headers.set("Authorization", `Bearer ${token}`);
|
headers.set("Authorization", `Bearer ${token}`);
|
||||||
}
|
}
|
||||||
const res = await fetch(path, { ...init, headers });
|
const res = await fetch(path, { ...init, headers });
|
||||||
|
const text = await res.text();
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
|
if (hadToken) {
|
||||||
clearToken();
|
clearToken();
|
||||||
window.location.href = "/login";
|
redirectToErrorPage(401);
|
||||||
throw new Error("Unauthorized");
|
}
|
||||||
|
throw new ApiError(parseApiError(text) || "Unauthorized", 401);
|
||||||
}
|
}
|
||||||
if (res.status === 403) {
|
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) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
throw new ApiError(parseApiError(text) || res.statusText, res.status);
|
||||||
throw new Error(parseApiError(text) || res.statusText);
|
|
||||||
}
|
}
|
||||||
return res.json() as Promise<T>;
|
if (!text) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
return JSON.parse(text) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenResponse {
|
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 EventDetailView from "./views/EventDetailView.vue";
|
||||||
import HostsView from "./views/HostsView.vue";
|
import HostsView from "./views/HostsView.vue";
|
||||||
import LoginView from "./views/LoginView.vue";
|
import LoginView from "./views/LoginView.vue";
|
||||||
|
import ErrorPageView from "./views/ErrorPageView.vue";
|
||||||
import ProblemsView from "./views/ProblemsView.vue";
|
import ProblemsView from "./views/ProblemsView.vue";
|
||||||
import ProblemDetailView from "./views/ProblemDetailView.vue";
|
import ProblemDetailView from "./views/ProblemDetailView.vue";
|
||||||
import DashboardView from "./views/DashboardView.vue";
|
import DashboardView from "./views/DashboardView.vue";
|
||||||
@@ -11,12 +12,25 @@ import ReportsView from "./views/ReportsView.vue";
|
|||||||
import SettingsView from "./views/SettingsView.vue";
|
import SettingsView from "./views/SettingsView.vue";
|
||||||
import UsersView from "./views/UsersView.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({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/", redirect: "/dashboard" },
|
{ path: "/", redirect: "/dashboard" },
|
||||||
{ path: "/dashboard", component: DashboardView },
|
{ path: "/dashboard", component: DashboardView },
|
||||||
{ path: "/login", component: LoginView },
|
{ path: "/login", component: LoginView },
|
||||||
|
errorRoute(401),
|
||||||
|
errorRoute(403),
|
||||||
|
errorRoute(404),
|
||||||
|
errorRoute(429),
|
||||||
{ path: "/events", component: EventsView },
|
{ path: "/events", component: EventsView },
|
||||||
{ path: "/events/:id", component: EventDetailView, props: true },
|
{ path: "/events/:id", component: EventDetailView, props: true },
|
||||||
{ path: "/reports", component: ReportsView },
|
{ path: "/reports", component: ReportsView },
|
||||||
@@ -25,14 +39,20 @@ const router = createRouter({
|
|||||||
{ path: "/problems/:id", component: ProblemDetailView, props: true },
|
{ path: "/problems/:id", component: ProblemDetailView, props: true },
|
||||||
{ path: "/settings", component: SettingsView, meta: { adminOnly: true } },
|
{ path: "/settings", component: SettingsView, meta: { adminOnly: true } },
|
||||||
{ path: "/users", component: UsersView, meta: { adminOnly: true } },
|
{ path: "/users", component: UsersView, meta: { adminOnly: true } },
|
||||||
|
{
|
||||||
|
path: "/:pathMatch(.*)*",
|
||||||
|
component: ErrorPageView,
|
||||||
|
props: { code: 404 },
|
||||||
|
meta: { standalone: true },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
if (to.path === "/login") return true;
|
if (PUBLIC_PATHS.has(to.path)) return true;
|
||||||
if (!getToken()) return { path: "/login" };
|
if (!getToken()) return { path: "/login", query: { redirect: to.fullPath } };
|
||||||
if (to.meta.adminOnly && !isAdmin()) {
|
if (to.meta.adminOnly && !isAdmin()) {
|
||||||
return { path: "/dashboard" };
|
return { path: "/403" };
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export const APP_NAME = "Security Alert Center";
|
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}`;
|
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