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:
PTah
2026-06-01 11:06:21 +10:00
parent d3a337992c
commit 141fc44099
9 changed files with 241 additions and 16 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sac-ui",
"private": true,
"version": "0.7.2",
"version": "0.7.3",
"type": "module",
"scripts": {
"dev": "vite",
+2 -1
View File
@@ -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
View File
@@ -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 {
+53
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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}`;
+124
View File
@@ -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>