Update admin theme/layout and refresh README details.

Align the project baseline with the latest admin interface styling and layout structure while documenting setup and usage updates in README.
This commit is contained in:
vlad
2026-07-14 17:12:28 +03:00
commit 86cc3fa541
278 changed files with 19416 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
coverage
.git
*.log
.env
.env.*
+6
View File
@@ -0,0 +1,6 @@
# Copy to .env for local dev: cp .env.example .env
# In dev, API proxy is enabled by default (requests go through Vite, not cross-origin).
VITE_USE_API_PROXY=true
VITE_API_URL=http://localhost:8000
VITE_APP_NAME=Compton
VITE_SENTRY_DSN=
+31
View File
@@ -0,0 +1,31 @@
# Monorepo dev image: build context must be the repository root (see docker-compose.yml).
FROM node:22-alpine
# pnpm 9+ via Corepack (bundled with Node 22)
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
WORKDIR /app
# --- dependency layer: copy only manifests so `pnpm install` can be cached ---
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json ./apps/web/
COPY packages/shared-types/package.json ./packages/shared-types/
COPY packages/eslint-config/package.json ./packages/eslint-config/
# Workspace packages referenced by the lockfile (minimal source for install/link)
COPY packages/shared-types ./packages/shared-types/
COPY packages/eslint-config ./packages/eslint-config/
RUN pnpm install --frozen-lockfile
# App source baked into the image; at runtime bind-mount overrides for live-reload
COPY apps/web ./apps/web/
EXPOSE 5173
# Chokidar polling helps file watching on Docker Desktop (Windows/macOS bind mounts)
ENV CHOKIDAR_USEPOLLING=true
ENV WATCHPACK_POLLING=true
# `pnpm exec` runs vite directly in the web workspace (avoids `--` arg forwarding issues)
CMD ["pnpm", "--filter", "web", "exec", "vite", "--host", "0.0.0.0"]
+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Compton platform by Organic Tech" />
<link rel="stylesheet" href="/main/css/tokens.css" />
<style>
html.admin-route,
html.admin-route body {
background: #f6f6f4;
margin: 0;
}
html.admin-route[data-admin-theme="dark"],
html.admin-route[data-admin-theme="dark"] body {
background: #1f2229;
}
</style>
<script>
if (/^\/admin(\/|$)/.test(location.pathname)) {
document.documentElement.classList.add("admin-route");
if (localStorage.getItem("wespAdminTheme") === "dark") {
document.documentElement.setAttribute("data-admin-theme", "dark");
}
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>Compton</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from "@playwright/test";
import { API_URL, adminLogin, loginViaUi, registerVerifyLogin, uniqueEmail } from "../helpers/api";
test.describe("§15.7 scenarios 5 & 9: Admin users", () => {
test("admin blocks user → blocked user cannot login", async ({ page, request }) => {
const email = uniqueEmail("e2e-block");
const session = await registerVerifyLogin(request, email);
const admin = await adminLogin(request);
const adminToken = (await admin.json()).access_token;
const patch = await request.patch(`${API_URL}/api/v1/admin/users/${session.user.id}`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: { status: "blocked" }
});
expect(patch.ok()).toBeTruthy();
await loginViaUi(page, email, "Valid1234");
await expect(page).toHaveURL(/\/login$/);
});
test("admin can open admin users page", async ({ page }) => {
await loginViaUi(page, "admin@compton.example", "Admin1234");
await expect(page).toHaveURL(/\/admin$/);
await expect(page.getByRole("heading", { name: "Users" })).toBeVisible();
});
test("admin session survives reload on /admin", async ({ page }) => {
await loginViaUi(page, "admin@compton.example", "Admin1234");
await expect(page).toHaveURL(/\/admin$/);
await page.goto("/admin");
await expect(page.getByRole("heading", { name: "Users" })).toBeVisible();
});
test("non-admin cannot open admin page", async ({ page, request }) => {
const email = uniqueEmail("e2e-nonadmin");
await registerVerifyLogin(request, email);
await loginViaUi(page, email, "Valid1234");
await expect(page).toHaveURL(/\/profile$/);
await page.goto("/admin");
await expect(page).toHaveURL(/\/$/);
});
test("admin cannot block self", async ({ request }) => {
const admin = await adminLogin(request);
const body = await admin.json();
const response = await request.patch(`${API_URL}/api/v1/admin/users/${body.user.id}`, {
headers: { Authorization: `Bearer ${body.access_token}` },
data: { status: "blocked" }
});
expect(response.status()).toBe(400);
expect((await response.json()).detail).toBe("SELF_BLOCK_FORBIDDEN");
});
test("admin cannot demote self", async ({ request }) => {
const admin = await adminLogin(request);
const body = await admin.json();
const response = await request.patch(`${API_URL}/api/v1/admin/users/${body.user.id}`, {
headers: { Authorization: `Bearer ${body.access_token}` },
data: { role: "user" }
});
expect(response.status()).toBe(400);
expect((await response.json()).detail).toBe("SELF_DEMOTION_FORBIDDEN");
});
});
+6
View File
@@ -0,0 +1,6 @@
import { expect, test } from "@playwright/test";
test("login page renders", async ({ page }) => {
await page.goto("/login");
await expect(page.getByRole("heading", { name: "Вход" })).toBeVisible();
});
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from "@playwright/test";
import { API_URL, fetchLatestToken, registerVerifyLogin, uniqueEmail } from "../helpers/api";
test.describe("§15.7 scenario 3: Password reset", () => {
test("forgot password → reset → login with new password", async ({ request }) => {
const email = uniqueEmail("e2e-reset");
const oldPassword = "Valid1234";
const newPassword = "ResetValid1";
await registerVerifyLogin(request, email, oldPassword);
const forgot = await request.post(`${API_URL}/api/v1/auth/forgot-password`, {
data: { email }
});
expect(forgot.ok()).toBeTruthy();
const token = await fetchLatestToken(request, email, "reset_password");
const reset = await request.post(`${API_URL}/api/v1/auth/reset-password`, {
data: { token, new_password: newPassword }
});
expect(reset.ok()).toBeTruthy();
const oldLogin = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { email, password: oldPassword }
});
expect(oldLogin.status()).toBe(401);
const newLogin = await request.post(`${API_URL}/api/v1/auth/login`, {
data: { email, password: newPassword }
});
expect(newLogin.ok()).toBeTruthy();
});
});
@@ -0,0 +1,37 @@
import { expect, test } from "@playwright/test";
import { API_URL, registerVerifyLogin, uniqueEmail } from "../helpers/api";
function extractRefreshCookie(headers: Record<string, string>): string {
const setCookie = headers["set-cookie"] ?? headers["Set-Cookie"] ?? "";
const match = setCookie.match(/refresh_token=([^;]+)/);
if (!match) {
throw new Error("refresh_token cookie missing");
}
return match[1];
}
test.describe("§15.7 scenario 7: Refresh rotation", () => {
test("old refresh token rejected after rotation; reuse revokes family", async ({ request }) => {
const email = uniqueEmail("e2e-refresh");
await registerVerifyLogin(request, email);
const login = await request.post(`${API_URL}/api/v1/auth/login`, { data: { email, password: "Valid1234" } });
const oldRefresh = extractRefreshCookie(login.headers());
const rotated = await request.post(`${API_URL}/api/v1/auth/refresh`, {
headers: { Cookie: `refresh_token=${oldRefresh}` }
});
expect(rotated.ok()).toBeTruthy();
const newRefresh = extractRefreshCookie(rotated.headers());
const oldReuse = await request.post(`${API_URL}/api/v1/auth/refresh`, {
headers: { Cookie: `refresh_token=${oldRefresh}` }
});
expect(oldReuse.status()).toBe(401);
const familyReuse = await request.post(`${API_URL}/api/v1/auth/refresh`, {
headers: { Cookie: `refresh_token=${newRefresh}` }
});
expect(familyReuse.status()).toBe(401);
});
});
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test";
import {
loginViaUi,
registerUser,
uniqueEmail,
verifyEmail
} from "../helpers/api";
test.describe("§15.7 scenario 2: Auth full journey", () => {
test("register → verify → login → profile edit → change password → logout", async ({
page,
request
}) => {
const email = uniqueEmail("e2e-flow");
const password = "Valid1234";
const newPassword = "NewValid1";
await registerUser(request, email, password);
await verifyEmail(request, email);
await loginViaUi(page, email, password);
await expect(page).toHaveURL(/\/profile$/);
await expect(page.getByText(email)).toBeVisible();
await page.getByLabel("Display name").fill("E2E User");
await page.getByRole("button", { name: "Save name" }).click();
await expect(page.getByText("Profile updated")).toBeVisible();
await page.getByPlaceholder("Current password").fill(password);
await page.getByPlaceholder("New password").fill(newPassword);
await page.getByRole("button", { name: "Change password" }).click();
await expect(page.getByText("Password changed")).toBeVisible();
await page.getByRole("button", { name: "Logout" }).click();
await expect(page).toHaveURL(/\/login$/);
await loginViaUi(page, email, newPassword);
await expect(page).toHaveURL(/\/profile$/);
});
});
@@ -0,0 +1,25 @@
import { expect, test } from "@playwright/test";
import { API_URL, adminLogin } from "../helpers/api";
test.describe("§15.7 scenario 4: Admin content publish", () => {
test("admin creates content → publish → visible on /pages/:slug", async ({ page, request }) => {
const admin = await adminLogin(request);
const headers = { Authorization: `Bearer ${(await admin.json()).access_token}` };
const slug = `e2e-page-${Date.now()}`;
const created = await request.post(`${API_URL}/api/v1/content/pages`, {
headers,
data: {
slug,
title: "E2E Published Page",
body: "<p>Published by admin</p>",
status: "published"
}
});
expect(created.ok()).toBeTruthy();
await page.goto(`/pages/${slug}`);
await expect(page.getByRole("heading", { name: "E2E Published Page" })).toBeVisible();
await expect(page.getByText("Published by admin")).toBeVisible();
});
});
+64
View File
@@ -0,0 +1,64 @@
import { APIRequestContext } from "@playwright/test";
export const API_URL = process.env.E2E_API_URL ?? "http://127.0.0.1:8001";
export function uniqueEmail(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
}
export async function registerUser(request: APIRequestContext, email: string, password = "Valid1234") {
const response = await request.post(`${API_URL}/api/v1/auth/register`, {
data: { email, password }
});
return response;
}
export async function fetchLatestToken(
request: APIRequestContext,
email: string,
template: "verify_email" | "reset_password"
): Promise<string> {
const response = await request.get(`${API_URL}/api/v1/test/emails/latest-token`, {
params: { to: email, template }
});
if (!response.ok()) {
throw new Error(`Token not found for ${email} (${template})`);
}
const body = await response.json();
return body.token as string;
}
export async function verifyEmail(request: APIRequestContext, email: string) {
const token = await fetchLatestToken(request, email, "verify_email");
return request.post(`${API_URL}/api/v1/auth/verify-email`, { data: { token } });
}
export async function loginApi(request: APIRequestContext, email: string, password = "Valid1234") {
return request.post(`${API_URL}/api/v1/auth/login`, { data: { email, password } });
}
export async function adminLogin(request: APIRequestContext) {
return loginApi(request, "admin@compton.example", "Admin1234");
}
export async function registerVerifyLogin(
request: APIRequestContext,
email: string,
password = "Valid1234"
) {
await registerUser(request, email, password);
await verifyEmail(request, email);
const login = await loginApi(request, email, password);
const body = await login.json();
return {
accessToken: body.access_token as string,
user: body.user as { id: string; email: string; role: string; status: string }
};
}
export async function loginViaUi(page: import("@playwright/test").Page, email: string, password: string) {
await page.goto("/login");
await page.getByPlaceholder("Email").fill(email);
await page.getByPlaceholder("Password").fill(password);
await page.getByRole("button", { name: "Login" }).click();
}
+15
View File
@@ -0,0 +1,15 @@
import { expect, test } from "@playwright/test";
test.describe("§15.7 scenario 1: Landing", () => {
test("hero and marquee are visible", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: /Технологии будущего на вашей ферме/i })).toBeVisible();
await expect(page.locator("#integrations .marquee-track")).toBeVisible();
});
test("respects prefers-reduced-motion", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/");
await expect(page.getByRole("heading", { name: /Технологии будущего на вашей ферме/i })).toBeVisible();
});
});
@@ -0,0 +1,52 @@
import { expect, test } from "@playwright/test";
import { API_URL, registerVerifyLogin, uniqueEmail } from "../helpers/api";
const tinyPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64"
);
test.describe("§15.7 scenario 10: Avatar validation", () => {
test("rejects svg, oversize and accepts valid png", async ({ request }) => {
const session = await registerVerifyLogin(request, uniqueEmail("e2e-avatar"));
const headers = { Authorization: `Bearer ${session.accessToken}` };
const svg = await request.post(`${API_URL}/api/v1/users/me/avatar`, {
headers,
multipart: {
file: {
name: "avatar.svg",
mimeType: "image/svg+xml",
buffer: Buffer.from("<svg xmlns='http://www.w3.org/2000/svg'></svg>")
}
}
});
expect(svg.status()).toBe(400);
const oversize = await request.post(`${API_URL}/api/v1/users/me/avatar`, {
headers,
multipart: {
file: {
name: "big.png",
mimeType: "image/png",
buffer: Buffer.concat([tinyPng, Buffer.alloc(2 * 1024 * 1024 + 1)])
}
}
});
expect(oversize.status()).toBe(400);
expect((await oversize.json()).detail).toBe("FILE_TOO_LARGE");
const valid = await request.post(`${API_URL}/api/v1/users/me/avatar`, {
headers,
multipart: {
file: {
name: "avatar.png",
mimeType: "image/png",
buffer: tinyPng
}
}
});
expect(valid.ok()).toBeTruthy();
expect((await valid.json()).profile.avatar_url).toContain("/api/v1/media/files/avatars/");
});
});
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
import { registerUser, uniqueEmail } from "../helpers/api";
test.describe("§15.7 scenario 6: Pending user", () => {
test("pending user cannot access /profile", async ({ page, request }) => {
const email = uniqueEmail("e2e-pending");
await registerUser(request, email, "Valid1234");
await page.goto("/profile");
await expect(page).toHaveURL(/\/login$/);
await page.goto("/login");
await page.getByPlaceholder("Email").fill(email);
await page.getByPlaceholder("Password").fill("Valid1234");
await page.getByRole("button", { name: "Login" }).click();
await expect(page).toHaveURL(/\/login$/);
});
});
+15
View File
@@ -0,0 +1,15 @@
import { expect, test } from "@playwright/test";
import { API_URL, registerVerifyLogin, uniqueEmail } from "../helpers/api";
test.describe("§15.7 scenario 8: IDOR", () => {
test("user A cannot patch user B via admin route", async ({ request }) => {
const userA = await registerVerifyLogin(request, uniqueEmail("e2e-a"));
const userB = await registerVerifyLogin(request, uniqueEmail("e2e-b"));
const forbidden = await request.patch(`${API_URL}/api/v1/admin/users/${userB.user.id}`, {
headers: { Authorization: `Bearer ${userA.accessToken}` },
data: { status: "blocked" }
});
expect(forbidden.status()).toBe(403);
});
});
+39
View File
@@ -0,0 +1,39 @@
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import boundaries from "eslint-plugin-boundaries";
export default [
{
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
parser: tsParser,
parserOptions: { project: "./tsconfig.json" }
},
plugins: {
"@typescript-eslint": tsPlugin,
boundaries
},
rules: {
"boundaries/element-types": [
"error",
{
default: "disallow",
rules: [
{ from: "app", allow: ["app", "pages", "modules", "shared"] },
{ from: "pages", allow: ["pages", "modules", "shared"] },
{ from: "modules", allow: ["modules", "shared"] },
{ from: "shared", allow: ["shared"] }
]
}
]
},
settings: {
"boundaries/elements": [
{ type: "app", pattern: "src/app/*" },
{ type: "pages", pattern: "src/pages/*" },
{ type: "modules", pattern: "src/modules/*" },
{ type: "shared", pattern: "src/shared/*" }
]
}
}
];
+283
View File
@@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Комптон</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/main/css/style.css" />
</head>
<body>
<!-- ====== НАВБАР ====== -->
<header class="navbar" role="navigation">
<div class="container">
<a href="#" class="navbar-logo">
<img src="/main/img/logo3.png" alt="FeedControl" />
</a>
<input type="checkbox" id="burgerToggle" class="burger-checkbox" />
<label for="burgerToggle" class="burger-icon" aria-label="Открыть меню">
<span></span><span></span><span></span>
</label>
<ul class="navbar-menu">
<li><a href="#solutions">Система</a></li>
<li><a href="#integrations">Технологии</a></li>
<li><a href="#security">Безопасность</a></li>
<li class="navbar-auth">
<a href="/login" class="btn btn-primary btn-sm">Вход</a>
<a href="/register" class="btn btn-primary btn-sm">Регистрация</a>
</li>
</ul>
</div>
</header>
<!-- ====== HERO С ВИДЕО ====== -->
<section class="hero section-padding" id="hero">
<video class="hero-video" autoplay muted loop playsinline>
<source src="/main/video/Тестовое видео.mp4" type="video/mp4" />
Ваш браузер не поддерживает видео.
</video>
<div class="container">
<h1 class="scramble-target" id="heroTitle">Технологии будущего на вашей ферме</h1>
<p class="subtitle">
Интеллектуальная система контроля кормления КРС на основе искусственного интеллекта.
Снижаем затраты на корма, повышаем продуктивность стада и даём полный контроль над каждым этапом.
</p>
<div class="btn-group">
<a href="#cta" class="btn btn-primary">Запросить демо</a>
<a href="#solutions" class="btn btn-outline">Узнать больше</a>
</div>
</div>
</section>
<!-- ====== БЛОК: КАК ЭТО УСТРОЕНО ====== -->
<section class="steps-section section-padding" id="how-it-works">
<div class="container">
<div class="section-intro">
<span class="text-mono section-label">Процесс автоматизации</span>
<h2>Как устроена наша система</h2>
</div>
<div class="steps-grid">
<!-- Шаг 1 с изображением zoo.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/zoo.png" alt="План рационов" />
</div>
<h3>План рационов</h3>
<p>Зоотехник составляет и корректирует рацион для каждой группы КРС в удобной программе на офисном ПК, ноутбуке или смартфоне.</p>
</div>
<!-- Шаг 2 с изображением wifi.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/wifi.png" alt="Синхронизация данных" />
</div>
<h3>Синхронизация данных</h3>
<p>Созданное задание мгновенно и без проводов передается на весовой терминал кормосмесителя.</p>
</div>
<!-- Шаг 3 с изображением load.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/load.png" alt="Точная загрузка" />
</div>
<h3>Точная загрузка</h3>
<p>Тракторист на дисплее видит подсказки и точный вес каждого компонента на лету, что исключает ошибки перегруза.</p>
</div>
<!-- Шаг 4 с изображением control.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/control.png" alt="Контроль руководителя" />
</div>
<h3>Контроль руководителя</h3>
<p>Собственник или управляющий получает автоматический отчет о всех отклонениях и реальном расходе прямо на смартфон.</p>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- ====== БЛОК: ОТ ФОРМУЛЫ ДО КОРМУШКИ (ТОЛЬКО ЗАГОЛОВОК + СЛОГАН) ====== -->
<!-- ============================================================ -->
<section class="funnel-section" id="funnel">
<div class="container">
<!-- Заголовок (без подзаголовка и шагов) -->
<div class="funnel-header">
<h2>От формулы до кормушки</h2>
</div>
<!-- Финальный слоган -->
<div class="funnel-final-slogan">
<p>«Каждый килограмм корма — под контролем.<br>Каждая копейка — на счету. Каждая минута — сэкономлена»</p>
</div>
</div>
</section>
<!-- ====== БЛОК С ЗАГОЛОВКОМ, ИЗОБРАЖЕНИЕМ И ВЫЕЗЖАЮЩИМ ТЕКСТОМ ====== -->
<section class="expand-block section-padding" id="about">
<div class="container">
<h2 class="section-title">Работа на любом устройстве</h2>
<p class="section-subtitle">
Управляйте системой кормления с телефона, планшета или компьютера — интерфейс адаптируется под любой экран.
</p>
<div class="expand-content" id="expandContent">
<div class="side-panel left">
<p><strong>Всегда на связи с фермой</strong> Абсолютный контроль рационов и остатков в любом месте, в любое время и с любого гаджета.</p>
</div>
<div class="image-wrapper">
<img src="/main/img/Комптон всегда под рукой на белом фоне.jpg" alt="Комптон всегда под рукой" />
</div>
<div class="side-panel right">
<p><strong>Прозрачность и контроль</strong> Вы всегда видите, сколько корма съедено, как меняется продуктивность и где можно сэкономить без потери качества.</p>
</div>
</div>
</div>
</section>
<!-- ====== КАРТОЧКИ РЕШЕНИЙ ====== -->
<section class="section-padding" id="solutions">
<div class="container">
<h2 class="section-intro section-intro--mb">Начни кормить по новому!</h2>
<p class="section-subtitle-centered">
Всё, что нужно для точного, экономного и эффективного кормления КРС.
</p>
<div class="cards-grid">
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Индивидуальный рацион" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v8M8 12h8"/></svg>
</div>
<h3>Автоматический расчет рационов</h3>
<p>Программа сама подбирает оптимальный состав корма для каждой группы животных с учётом возраста, веса и продуктивности.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/рецепты.gif" alt="Удобное редактирование рационов" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
</div>
<h3>Удобное редактирование рационов</h3>
<p>Редактируйте рацион в один клик: меняйте рецепты местами, добавляйте новые компоненты и мгновенно корректируйте сухое вещество.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/учет потребления.gif" alt="Прогноз продуктивности" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
</div>
<h3>Учет кормов</h3>
<p>Автоматический учет остатков: программа точно рассчитывает остатки кормов на складе и прогнозирует дату следующей закупки на основе текущего расхода, защищая от внезапного дефицита.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Визуализация экономии кормов" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="M12 22V12"/><path d="M3.3 7L12 12l8.7-5"/></svg>
</div>
<h3>Гибкая система уведомлений</h3>
<p>Умная система уведомлений — ваш новый помощник, который следит за процессом кормления КРС и мгновенно предупреждает команду фермы о любых сбоях. Она заменяет ручной контроль автоматическим мониторингом.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Анализ микроклимата" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
</div>
<h3>Анализ микроклимата</h3>
<p>Интеграция с системами климат-контроля: учитываем температуру и влажность для коррекции потребности в питании.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Единая платформа" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/><path d="M12 7v10"/></svg>
</div>
<h3>Единая платформа</h3>
<p>Все данные о кормлении, здоровье и продуктивности в одном окне – для быстрого принятия решений.</p>
</div>
</div>
</div>
</div>
</section>
<!-- ====== БЕГУЩАЯ СТРОКА ====== -->
<section class="marquee-section" id="integrations">
<div class="marquee-track" id="marqueeTrack">
<span class="logo-item">✦ АГРО-ХОЛДИНГ</span>
<span class="logo-item">✦ МОЛОЧНЫЙ КОМБИНАТ</span>
<span class="logo-item">✦ ФЕРМА №1</span>
<span class="logo-item">✦ ЗЕРНО-ТРЕЙД</span>
<span class="logo-item">✦ ВЕТЕРИНАРНАЯ СЛУЖБА</span>
<span class="logo-item">✦ КОРМОВОЙ ЦЕНТР</span>
<span class="logo-item">✦ ПЛЕМЗАВОД</span>
<span class="logo-item">✦ АГРО-ИНТЕЛЛЕКТ</span>
<span class="logo-item">✦ АГРО-ХОЛДИНГ</span>
<span class="logo-item">✦ МОЛОЧНЫЙ КОМБИНАТ</span>
<span class="logo-item">✦ ФЕРМА №1</span>
<span class="logo-item">✦ ЗЕРНО-ТРЕЙД</span>
<span class="logo-item">✦ ВЕТЕРИНАРНАЯ СЛУЖБА</span>
<span class="logo-item">✦ КОРМОВОЙ ЦЕНТР</span>
<span class="logo-item">✦ ПЛЕМЗАВОД</span>
<span class="logo-item">✦ АГРО-ИНТЕЛЛЕКТ</span>
</div>
</section>
<!-- ====== CTA ====== -->
<section class="section-padding cta-section" id="cta">
<div class="container">
<h2>Внедряйте технологии будущего</h2>
<p>
Получите консультацию по настройке системы для вашего хозяйства.
Первые 2 месяца – полная поддержка и мониторинг.
</p>
<a href="#" class="btn btn-primary btn-lg">Оставить заявку</a>
</div>
</section>
<!-- ====== ФУТЕР ====== -->
<footer class="footer" id="security">
<div class="container">
<a href="#" class="footer-logo">
<img src="/main/img/logo3.png" alt="FeedControl" />
</a>
<span class="footer-copy">&copy; 2026 — FeedControl</span>
<div class="footer-links">
<a href="#">Политика</a>
<a href="#">Поддержка</a>
<a href="#">Контакты</a>
</div>
</div>
</footer>
<script src="/main/js/main.js" defer></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
/* Compton design tokens — single source of truth */
:root {
/* Brand */
--primary: #48816d;
--primary-dark: #3a6b58;
--primary-light: #5a9a82;
--primary-accent: #5a9a82;
--primary-tint: #e8f2ef;
--primary-color: var(--primary);
/* Light surfaces */
--bg: #f6f6f4;
--foreground: #1a1e1c;
--muted: #4a5a52;
--marquee-bg: #ebebe5;
--surface: #ffffff;
--border-light: #e8e8e2;
--card: #ffffff;
--card-border: #e8e8e2;
--footer-bg: #1a1e1c;
--bg-page: var(--bg);
--text: var(--foreground);
/* Typography */
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", "SF Mono", monospace;
--zootech-font: var(--font-sans);
/* Radius */
--radius-card: 18px;
--radius-btn: 40px;
--radius-icon: 16px;
--zt-radius-lg: 16px;
/* Effects */
--shadow-soft: 0 16px 48px rgba(26, 30, 28, 0.08), 0 0 0 1px rgba(26, 30, 28, 0.04);
/* Semantic */
--color-error: #b42318;
--color-success: #0f766e;
--color-danger: #cd3838;
--color-danger-hover: #e94b4b;
--color-warning: #d4a72c;
/* Admin (light — matches site) */
--admin-bg: var(--bg);
--admin-surface: var(--surface);
--admin-surface-2: var(--marquee-bg);
--admin-stroke: var(--border-light);
--admin-soft: var(--muted);
--admin-text: var(--foreground);
--admin-primary: var(--primary);
--admin-primary-hover: var(--primary-tint);
--admin-primary-selected: rgba(72, 129, 109, 0.16);
--admin-primary-option: rgba(72, 129, 109, 0.1);
--admin-btn-bg: var(--surface);
--admin-btn-border: var(--border-light);
--admin-btn-text: var(--foreground);
--admin-success: var(--color-success);
--admin-tag-ok-border: rgba(72, 129, 109, 0.35);
--admin-sider-bg: var(--surface);
--admin-table-header: var(--marquee-bg);
--admin-card-radius: 20px;
/* WESP admin aliases */
--wesp-admin-bg: var(--admin-bg);
--wesp-admin-surface: var(--admin-surface);
--wesp-admin-surface-2: var(--admin-surface-2);
--wesp-admin-stroke: var(--admin-stroke);
--wesp-admin-soft: var(--admin-soft);
--wesp-admin-text: var(--admin-text);
--wesp-admin-primary: var(--admin-primary);
}
html[data-theme="organic"] {
color-scheme: light;
}
html.admin-route,
html.admin-route body {
background: var(--admin-bg);
}
/* Admin dark theme — isolated from public site */
html.admin-route[data-admin-theme="dark"] {
color-scheme: dark;
--admin-deep: #0b0a10;
--admin-bg: #1f2229;
--admin-surface: #0b0a10;
--admin-surface-2: #15141c;
--admin-stroke: rgba(186, 184, 208, 0.14);
--admin-soft: #a8adbb;
--admin-text: #ececf1;
--admin-primary: #5a9a82;
--admin-primary-hover: rgba(90, 154, 130, 0.2);
--admin-primary-selected: rgba(90, 154, 130, 0.32);
--admin-primary-option: rgba(90, 154, 130, 0.16);
--admin-btn-bg: #15141c;
--admin-btn-border: rgba(186, 184, 208, 0.16);
--admin-btn-text: #ececf1;
--admin-success: #6bc4a8;
--admin-tag-ok-border: rgba(90, 154, 130, 0.5);
--admin-sider-bg: #0b0a10;
--admin-table-header: #1a1924;
--admin-card-radius: 20px;
--surface: #0b0a10;
--foreground: #ececf1;
--muted: #a8adbb;
--border-light: rgba(186, 184, 208, 0.14);
--marquee-bg: #1a1924;
--primary-tint: rgba(90, 154, 130, 0.16);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+111
View File
@@ -0,0 +1,111 @@
(function() {
'use strict';
// ===== SCRAMBLE HERO =====
const heroTitleEl = document.getElementById('heroTitle');
if (heroTitleEl) {
const originalText = heroTitleEl.textContent.trim() || 'Технологии будущего на вашей ферме';
const chars = '!@#$%^&*()_+{}[]|;:,.<>?';
let iteration = 0;
let isScrambling = true;
let frameId = null;
const maxIterations = 30;
function scramble() {
if (!isScrambling) return;
const result = originalText
.split('')
.map((char, index) => {
if (index < iteration) return originalText[index];
if (char === ' ') return ' ';
return chars[Math.floor(Math.random() * chars.length)];
})
.join('');
heroTitleEl.textContent = result;
iteration += 1;
if (iteration <= maxIterations) {
frameId = requestAnimationFrame(scramble);
} else {
heroTitleEl.textContent = originalText;
isScrambling = false;
if (frameId) {
cancelAnimationFrame(frameId);
frameId = null;
}
}
}
setTimeout(() => {
scramble();
}, 300);
}
// ===== FADE-UP =====
const fadeElements = document.querySelectorAll('.fade-up');
if (fadeElements.length > 0 && 'IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.15, rootMargin: '0px 0px -30px 0px' });
fadeElements.forEach(el => observer.observe(el));
} else {
fadeElements.forEach(el => el.classList.add('visible'));
}
// ===== EXPAND BLOCK =====
const expandContent = document.getElementById('expandContent');
if (expandContent && 'IntersectionObserver' in window) {
let isExpanded = false;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
if (!isExpanded) {
expandContent.classList.add('expanded');
isExpanded = true;
}
} else {
if (isExpanded) {
expandContent.classList.remove('expanded');
isExpanded = false;
}
}
});
}, { threshold: 0.25, rootMargin: '0px 0px -30px 0px' });
observer.observe(expandContent);
} else if (expandContent) {
expandContent.classList.add('expanded');
}
// ===== MARQUEE =====
const marqueeTrack = document.getElementById('marqueeTrack');
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
if (prefersReducedMotion.matches && marqueeTrack) {
marqueeTrack.style.animation = 'none';
marqueeTrack.style.transform = 'translateX(0)';
}
prefersReducedMotion.addEventListener('change', (e) => {
if (marqueeTrack) {
if (e.matches) {
marqueeTrack.style.animation = 'none';
marqueeTrack.style.transform = 'translateX(0)';
} else {
marqueeTrack.style.animation = 'marqueeScroll 30s linear infinite';
marqueeTrack.style.transform = '';
}
}
});
// ===== HERO VIDEO =====
const heroVideo = document.querySelector('.hero-video');
if (heroVideo) {
heroVideo.play().catch(() => {
document.addEventListener('click', () => {
heroVideo.play();
}, { once: true });
});
}
})();
View File
+283
View File
@@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Комптон</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/main/css/style.css" />
</head>
<body>
<!-- ====== НАВБАР ====== -->
<header class="navbar" role="navigation">
<div class="container">
<a href="#" class="navbar-logo">
<img src="/main/img/logo3.png" alt="FeedControl" />
</a>
<input type="checkbox" id="burgerToggle" class="burger-checkbox" />
<label for="burgerToggle" class="burger-icon" aria-label="Открыть меню">
<span></span><span></span><span></span>
</label>
<ul class="navbar-menu">
<li><a href="#solutions">Система</a></li>
<li><a href="#integrations">Технологии</a></li>
<li><a href="#security">Безопасность</a></li>
<li class="navbar-auth">
<a href="/login" class="btn btn-primary btn-sm">Вход</a>
<a href="/register" class="btn btn-primary btn-sm">Регистрация</a>
</li>
</ul>
</div>
</header>
<!-- ====== HERO С ВИДЕО ====== -->
<section class="hero section-padding" id="hero">
<video class="hero-video" autoplay muted loop playsinline>
<source src="/main/video/Тестовое видео.mp4" type="video/mp4" />
Ваш браузер не поддерживает видео.
</video>
<div class="container">
<h1 class="scramble-target" id="heroTitle">Технологии будущего на вашей ферме</h1>
<p class="subtitle">
Интеллектуальная система контроля кормления КРС на основе искусственного интеллекта.
Снижаем затраты на корма, повышаем продуктивность стада и даём полный контроль над каждым этапом.
</p>
<div class="btn-group">
<a href="#cta" class="btn btn-primary">Запросить демо</a>
<a href="#solutions" class="btn btn-outline">Узнать больше</a>
</div>
</div>
</section>
<!-- ====== БЛОК: КАК ЭТО УСТРОЕНО ====== -->
<section class="steps-section section-padding" id="how-it-works">
<div class="container">
<div class="section-intro">
<span class="text-mono section-label">Процесс автоматизации</span>
<h2>Как устроена наша система</h2>
</div>
<div class="steps-grid">
<!-- Шаг 1 с изображением zoo.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/zoo.png" alt="План рационов" />
</div>
<h3>План рационов</h3>
<p>Зоотехник составляет и корректирует рацион для каждой группы КРС в удобной программе на офисном ПК, ноутбуке или смартфоне.</p>
</div>
<!-- Шаг 2 с изображением wifi.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/wifi.png" alt="Синхронизация данных" />
</div>
<h3>Синхронизация данных</h3>
<p>Созданное задание мгновенно и без проводов передается на весовой терминал кормосмесителя.</p>
</div>
<!-- Шаг 3 с изображением load.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/load.png" alt="Точная загрузка" />
</div>
<h3>Точная загрузка</h3>
<p>Тракторист на дисплее видит подсказки и точный вес каждого компонента на лету, что исключает ошибки перегруза.</p>
</div>
<!-- Шаг 4 с изображением control.png -->
<div class="step-card">
<div class="step-image-wrapper">
<img src="/main/img/control.png" alt="Контроль руководителя" />
</div>
<h3>Контроль руководителя</h3>
<p>Собственник или управляющий получает автоматический отчет о всех отклонениях и реальном расходе прямо на смартфон.</p>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- ====== БЛОК: ОТ ФОРМУЛЫ ДО КОРМУШКИ (ТОЛЬКО ЗАГОЛОВОК + СЛОГАН) ====== -->
<!-- ============================================================ -->
<section class="funnel-section" id="funnel">
<div class="container">
<!-- Заголовок (без подзаголовка и шагов) -->
<div class="funnel-header">
<h2>От формулы до кормушки</h2>
</div>
<!-- Финальный слоган -->
<div class="funnel-final-slogan">
<p>«Каждый килограмм корма — под контролем.<br>Каждая копейка — на счету. Каждая минута — сэкономлена»</p>
</div>
</div>
</section>
<!-- ====== БЛОК С ЗАГОЛОВКОМ, ИЗОБРАЖЕНИЕМ И ВЫЕЗЖАЮЩИМ ТЕКСТОМ ====== -->
<section class="expand-block section-padding" id="about">
<div class="container">
<h2 class="section-title">Работа на любом устройстве</h2>
<p class="section-subtitle">
Управляйте системой кормления с телефона, планшета или компьютера — интерфейс адаптируется под любой экран.
</p>
<div class="expand-content" id="expandContent">
<div class="side-panel left">
<p><strong>Всегда на связи с фермой</strong> Абсолютный контроль рационов и остатков в любом месте, в любое время и с любого гаджета.</p>
</div>
<div class="image-wrapper">
<img src="/main/img/Комптон всегда под рукой на белом фоне.jpg" alt="Комптон всегда под рукой" />
</div>
<div class="side-panel right">
<p><strong>Прозрачность и контроль</strong> Вы всегда видите, сколько корма съедено, как меняется продуктивность и где можно сэкономить без потери качества.</p>
</div>
</div>
</div>
</section>
<!-- ====== КАРТОЧКИ РЕШЕНИЙ ====== -->
<section class="section-padding" id="solutions">
<div class="container">
<h2 class="section-intro section-intro--mb">Начни кормить по новому!</h2>
<p class="section-subtitle-centered">
Всё, что нужно для точного, экономного и эффективного кормления КРС.
</p>
<div class="cards-grid">
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Индивидуальный рацион" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v8M8 12h8"/></svg>
</div>
<h3>Автоматический расчет рационов</h3>
<p>Программа сама подбирает оптимальный состав корма для каждой группы животных с учётом возраста, веса и продуктивности.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/рецепты.gif" alt="Удобное редактирование рационов" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
</div>
<h3>Удобное редактирование рационов</h3>
<p>Редактируйте рацион в один клик: меняйте рецепты местами, добавляйте новые компоненты и мгновенно корректируйте сухое вещество.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/учет потребления.gif" alt="Прогноз продуктивности" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
</div>
<h3>Учет кормов</h3>
<p>Автоматический учет остатков: программа точно рассчитывает остатки кормов на складе и прогнозирует дату следующей закупки на основе текущего расхода, защищая от внезапного дефицита.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Визуализация экономии кормов" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="M12 22V12"/><path d="M3.3 7L12 12l8.7-5"/></svg>
</div>
<h3>Гибкая система уведомлений</h3>
<p>Умная система уведомлений — ваш новый помощник, который следит за процессом кормления КРС и мгновенно предупреждает команду фермы о любых сбоях. Она заменяет ручной контроль автоматическим мониторингом.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Анализ микроклимата" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
</div>
<h3>Анализ микроклимата</h3>
<p>Интеграция с системами климат-контроля: учитываем температуру и влажность для коррекции потребности в питании.</p>
</div>
</div>
<div class="card fade-up card-with-gif">
<div class="card-gif-wrapper">
<img src="/main/gif/16-24-58.gif" alt="Единая платформа" loading="lazy" />
</div>
<div class="card-body">
<div class="card-icon">
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/><path d="M12 7v10"/></svg>
</div>
<h3>Единая платформа</h3>
<p>Все данные о кормлении, здоровье и продуктивности в одном окне – для быстрого принятия решений.</p>
</div>
</div>
</div>
</div>
</section>
<!-- ====== БЕГУЩАЯ СТРОКА ====== -->
<section class="marquee-section" id="integrations">
<div class="marquee-track" id="marqueeTrack">
<span class="logo-item">✦ АГРО-ХОЛДИНГ</span>
<span class="logo-item">✦ МОЛОЧНЫЙ КОМБИНАТ</span>
<span class="logo-item">✦ ФЕРМА №1</span>
<span class="logo-item">✦ ЗЕРНО-ТРЕЙД</span>
<span class="logo-item">✦ ВЕТЕРИНАРНАЯ СЛУЖБА</span>
<span class="logo-item">✦ КОРМОВОЙ ЦЕНТР</span>
<span class="logo-item">✦ ПЛЕМЗАВОД</span>
<span class="logo-item">✦ АГРО-ИНТЕЛЛЕКТ</span>
<span class="logo-item">✦ АГРО-ХОЛДИНГ</span>
<span class="logo-item">✦ МОЛОЧНЫЙ КОМБИНАТ</span>
<span class="logo-item">✦ ФЕРМА №1</span>
<span class="logo-item">✦ ЗЕРНО-ТРЕЙД</span>
<span class="logo-item">✦ ВЕТЕРИНАРНАЯ СЛУЖБА</span>
<span class="logo-item">✦ КОРМОВОЙ ЦЕНТР</span>
<span class="logo-item">✦ ПЛЕМЗАВОД</span>
<span class="logo-item">✦ АГРО-ИНТЕЛЛЕКТ</span>
</div>
</section>
<!-- ====== CTA ====== -->
<section class="section-padding cta-section" id="cta">
<div class="container">
<h2>Внедряйте технологии будущего</h2>
<p>
Получите консультацию по настройке системы для вашего хозяйства.
Первые 2 месяца – полная поддержка и мониторинг.
</p>
<a href="#" class="btn btn-primary btn-lg">Оставить заявку</a>
</div>
</section>
<!-- ====== ФУТЕР ====== -->
<footer class="footer" id="security">
<div class="container">
<a href="#" class="footer-logo">
<img src="/main/img/logo3.png" alt="FeedControl" />
</a>
<span class="footer-copy">&copy; 2026 — FeedControl</span>
<div class="footer-links">
<a href="#">Политика</a>
<a href="#">Поддержка</a>
<a href="#">Контакты</a>
</div>
</div>
</footer>
<script src="/main/js/main.js" defer></script>
</body>
</html>
Binary file not shown.
View File
+50
View File
@@ -0,0 +1,50 @@
{
"name": "web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src --max-warnings=0",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:ci": "vitest run --coverage",
"e2e": "playwright test"
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"@hookform/resolvers": "^3.9.1",
"@tanstack/react-query": "^5.59.0",
"antd": "^5.29.3",
"axios": "^1.7.7",
"dompurify": "^3.2.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.53.0",
"react-router-dom": "^7.0.0",
"zod": "^3.23.8",
"zustand": "^5.0.0"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@types/dompurify": "^3.2.0",
"@types/node": "^22.8.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^8.10.0",
"@typescript-eslint/parser": "^8.10.0",
"@vitejs/plugin-react": "^4.3.2",
"@vitest/coverage-v8": "^2.1.9",
"eslint": "^9.12.0",
"eslint-plugin-boundaries": "^4.2.0",
"eslint-plugin-react-hooks": "^5.1.0",
"jsdom": "^25.0.1",
"typescript": "^5.6.3",
"vite": "^5.4.21",
"vitest": "^2.1.9"
}
}
+48
View File
@@ -0,0 +1,48 @@
import { defineConfig } from "@playwright/test";
const webPort = process.env.E2E_WEB_PORT ?? "5175";
const webUrl = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${webPort}`;
const apiPort = process.env.E2E_API_PORT ?? "8001";
const apiUrl = process.env.E2E_API_URL ?? `http://127.0.0.1:${apiPort}`;
const startApi = process.env.E2E_START_API !== "false";
export default defineConfig({
testDir: "./e2e",
retries: process.env.CI ? 1 : 0,
workers: 1,
webServer: startApi
? [
{
command: "python scripts/start_e2e_api.py",
cwd: "../api",
url: `${apiUrl}/api/v1/health`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
E2E_API_PORT: apiPort,
E2E_WEB_PORT: webPort
}
},
{
command: `npx vite --host 127.0.0.1 --port ${webPort}`,
url: webUrl,
reuseExistingServer: !process.env.CI,
env: {
VITE_USE_API_PROXY: "true",
VITE_API_URL: apiUrl
}
}
]
: {
command: `npx vite --host 127.0.0.1 --port ${webPort}`,
url: webUrl,
reuseExistingServer: !process.env.CI
},
use: {
baseURL: webUrl,
trace: "on-first-retry",
video: "on-first-retry"
},
projects: [{ name: "chromium", use: { browserName: "chromium" } }]
});
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: /sitemap.xml
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>http://localhost:5173/</loc></url>
<url><loc>http://localhost:5173/pages/about</loc></url>
<url><loc>http://localhost:5173/pages/privacy</loc></url>
<url><loc>http://localhost:5173/pages/terms</loc></url>
</urlset>
+18
View File
@@ -0,0 +1,18 @@
import "@testing-library/jest-dom/vitest";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false
})
});
const originalGetComputedStyle = window.getComputedStyle.bind(window);
window.getComputedStyle = ((element: Element) => originalGetComputedStyle(element)) as typeof window.getComputedStyle;
+40
View File
@@ -0,0 +1,40 @@
import { useLayoutEffect } from "react";
import { AppProviders } from "./providers/AppProviders";
import { AppRouter } from "./router/routes";
import { AppHeader } from "@shared/ui";
import { useLocation } from "react-router-dom";
function shouldHideHeader(pathname: string): boolean {
return (
/^\/(login|register|forgot-password|verify|reset-password)(\/|$)/.test(pathname) ||
/^\/admin(\/|$)/.test(pathname)
);
}
function isAdminRoute(pathname: string): boolean {
return /^\/admin(\/|$)/.test(pathname);
}
function AppShell(): JSX.Element {
const location = useLocation();
const hideHeader = shouldHideHeader(location.pathname);
useLayoutEffect(() => {
document.documentElement.classList.toggle("admin-route", isAdminRoute(location.pathname));
}, [location.pathname]);
return (
<>
{hideHeader ? null : <AppHeader />}
<AppRouter />
</>
);
}
export function App(): JSX.Element {
return (
<AppProviders>
<AppShell />
</AppProviders>
);
}
@@ -0,0 +1,20 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import type { PropsWithChildren } from "react";
import { setAccessTokenGetter } from "@shared/api/client";
import { useAuthStore } from "@modules/auth/store/authStore";
import { AuthBootstrap } from "./AuthBootstrap";
const queryClient = new QueryClient();
setAccessTokenGetter(() => useAuthStore.getState().accessToken);
export function AppProviders({ children }: PropsWithChildren): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthBootstrap>{children}</AuthBootstrap>
</BrowserRouter>
</QueryClientProvider>
);
}
@@ -0,0 +1,32 @@
import { useLayoutEffect } from "react";
import type { PropsWithChildren } from "react";
import { shouldAttemptAuthRefresh } from "@modules/auth/store/authSessionHint";
import { useAuthStore } from "@modules/auth/store/authStore";
import { bootstrapSessionRefresh } from "@shared/api/client";
export function AuthBootstrap({ children }: PropsWithChildren): JSX.Element {
const setBootstrapped = useAuthStore((state) => state.setBootstrapped);
useLayoutEffect(() => {
let cancelled = false;
if (!shouldAttemptAuthRefresh()) {
setBootstrapped(true);
return () => {
cancelled = true;
};
}
void bootstrapSessionRefresh().finally(() => {
if (!cancelled) {
setBootstrapped(true);
}
});
return () => {
cancelled = true;
};
}, [setBootstrapped]);
return <>{children}</>;
}
@@ -0,0 +1,51 @@
import { cleanup, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AdminGuard } from "@app/router/guards/AdminGuard";
import { useAuthStore } from "@modules/auth/store/authStore";
function TestChild(): JSX.Element {
return <h1>Admin area</h1>;
}
describe("AdminGuard", () => {
beforeEach(() => {
useAuthStore.getState().clearSession();
useAuthStore.getState().setBootstrapped(true);
});
afterEach(() => {
cleanup();
});
it("redirects guests to login", () => {
render(
<MemoryRouter initialEntries={["/admin"]}>
<AdminGuard>
<TestChild />
</AdminGuard>
</MemoryRouter>
);
expect(screen.queryByRole("heading", { name: "Admin area" })).not.toBeInTheDocument();
});
it("renders admin content for admin users", () => {
useAuthStore.getState().setSession("token", {
id: "1",
email: "admin@compton.example",
role: "admin",
is_superuser: false,
status: "active"
});
render(
<MemoryRouter initialEntries={["/admin"]}>
<AdminGuard>
<TestChild />
</AdminGuard>
</MemoryRouter>
);
expect(screen.getAllByRole("heading", { name: "Admin area" })).toHaveLength(1);
});
});
@@ -0,0 +1,23 @@
import { Navigate } from "react-router-dom";
import type { PropsWithChildren } from "react";
import { useAuth } from "@modules/auth";
import { useAuthStore } from "@modules/auth/store/authStore";
export function AdminGuard({ children }: PropsWithChildren): JSX.Element {
const bootstrapped = useAuthStore((state) => state.bootstrapped);
const auth = useAuth();
if (!bootstrapped) {
return null;
}
if (!auth.isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (auth.user?.role !== "admin") {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}
@@ -0,0 +1,19 @@
import { Navigate } from "react-router-dom";
import type { PropsWithChildren } from "react";
import { useAuth } from "@modules/auth";
import { useAuthStore } from "@modules/auth/store/authStore";
export function AuthGuard({ children }: PropsWithChildren): JSX.Element {
const bootstrapped = useAuthStore((state) => state.bootstrapped);
const auth = useAuth();
if (!bootstrapped) {
return null;
}
if (!auth.isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
@@ -0,0 +1,19 @@
import { Navigate } from "react-router-dom";
import type { PropsWithChildren } from "react";
import { useAuth } from "@modules/auth";
import { useAuthStore } from "@modules/auth/store/authStore";
export function GuestGuard({ children }: PropsWithChildren): JSX.Element {
const bootstrapped = useAuthStore((state) => state.bootstrapped);
const auth = useAuth();
if (!bootstrapped) {
return null;
}
if (auth.isAuthenticated) {
return <Navigate to={auth.user?.role === "admin" ? "/admin" : "/profile"} replace />;
}
return <>{children}</>;
}
+67
View File
@@ -0,0 +1,67 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { LoginPage } from "@pages/LoginPage";
import { RegisterPage } from "@pages/RegisterPage";
import { ContentPage } from "@pages/ContentPage";
import { VerifyPage } from "@pages/VerifyPage";
import { ResetPasswordPage } from "@pages/ResetPasswordPage";
import { ForgotPasswordPage } from "@pages/ForgotPasswordPage";
import AdminPage from "@pages/AdminPage";
import { AuthGuard } from "./guards/AuthGuard";
import { AdminGuard } from "./guards/AdminGuard";
import { GuestGuard } from "./guards/GuestGuard";
const ProfilePage = lazy(() => import("@pages/ProfilePage"));
export function AppRouter(): JSX.Element {
return (
<Suspense fallback={null}>
<Routes>
<Route
path="/login"
element={
<GuestGuard>
<LoginPage />
</GuestGuard>
}
/>
<Route
path="/register"
element={
<GuestGuard>
<RegisterPage />
</GuestGuard>
}
/>
<Route path="/verify" element={<VerifyPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route
path="/forgot-password"
element={
<GuestGuard>
<ForgotPasswordPage />
</GuestGuard>
}
/>
<Route path="/pages/:slug" element={<ContentPage />} />
<Route
path="/profile"
element={
<AuthGuard>
<ProfilePage />
</AuthGuard>
}
/>
<Route
path="/admin"
element={
<AdminGuard>
<AdminPage />
</AdminGuard>
}
/>
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</Suspense>
);
}
+52
View File
@@ -0,0 +1,52 @@
@import "../../../main/css/tokens.css";
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--foreground);
font-family: var(--font-sans);
}
a {
color: inherit;
}
.marquee-section {
background: var(--marquee-bg);
overflow: hidden;
padding: 0.75rem 0;
}
.marquee-track {
display: flex;
gap: 2rem;
width: max-content;
animation: marquee-scroll 24s linear infinite;
}
.marquee-track span {
white-space: nowrap;
font-family: var(--font-mono);
letter-spacing: 0.04em;
}
@keyframes marquee-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.marquee-track {
animation: none;
width: 100%;
flex-wrap: wrap;
}
}
+9
View File
@@ -0,0 +1,9 @@
import type { JSX as ReactJSX } from "react";
declare global {
namespace JSX {
type Element = ReactJSX.Element;
}
}
export {};
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "@app/App";
import "antd/dist/reset.css";
import "@app/styles/globals.css";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from "vitest";
import {
createAdminUser,
deleteAdminUser,
getAdminActivityFeed,
getAdminDiagnostics,
getAdminServerLog,
getAdminSettings,
getAdminStats,
getAdminSummary,
getAdminUsers,
getInstallSecrets,
patchAdminSettings,
patchAdminUser,
postAdminUiActivity,
resetAdminUserPassword,
revealInstallSecret
} from "./adminApi";
vi.mock("@shared/api/client", () => ({
apiClient: {
get: vi.fn(async (url: string) => {
if (url === "/api/v1/admin/summary") {
return { data: { users_count: 5, registrations_day: 2, admins_count: 1, superusers_count: 1 } };
}
if (url === "/api/v1/admin/stats") {
return { data: { users_count: 5, registrations_day: 2 } };
}
if (url === "/api/v1/admin/settings") {
return { data: { values: {}, locks: {}, settings_path: "data/compton_settings.json", secrets: {} } };
}
if (url === "/api/v1/admin/diagnostics/report") {
return { data: { checks: [{ id: "x", status: "ok", message: "ok" }] } };
}
if (url === "/api/v1/admin/activity-feed") {
return { data: { events: [{ action: "a" }] } };
}
if (url === "/api/v1/admin/server-log") {
return { data: { lines: ["line"] } };
}
if (url === "/api/v1/admin/secrets") {
return { data: { initialized: true, locked: true, secrets_path: "x", database: {}, connection_string_masked: "", secrets_status: {} } };
}
return { data: { data: [], meta: { total: 0, page: 1, limit: 20 } } };
}),
patch: vi.fn(async () => ({
data: { id: "1", email: "u@example.com", role: "user", is_superuser: false, status: "blocked" }
})),
post: vi.fn(async (url: string) => {
if (url === "/api/v1/admin/users") {
return { data: { id: "1", email: "new@example.com", role: "user", is_superuser: false, status: "active" } };
}
if (url === "/api/v1/admin/ui-activity") {
return { data: { status: "ok" } };
}
if (url === "/api/v1/admin/secrets/reveal") {
return { data: { key: "database_password", value: "secret" } };
}
return { data: { status: "ok" } };
}),
delete: vi.fn(async () => ({ data: { status: "deleted" } }))
}
}));
describe("adminApi", () => {
it("loads admin users", async () => {
const data = await getAdminUsers();
expect(data.data).toEqual([]);
});
it("loads admin stats", async () => {
const stats = await getAdminStats();
expect(stats.users_count).toBe(5);
expect(stats.registrations_day).toBe(2);
});
it("loads admin summary", async () => {
const summary = await getAdminSummary();
expect(summary.superusers_count).toBe(1);
});
it("patches admin user", async () => {
const user = await patchAdminUser("1", { status: "blocked" });
expect(user.status).toBe("blocked");
});
it("calls remaining admin api helpers", async () => {
expect((await createAdminUser({
email: "new@example.com",
password: "Valid123A",
role: "user",
is_superuser: false,
status: "active"
})).email).toBe("new@example.com");
expect((await resetAdminUserPassword("1", "Valid123A")).status).toBe("blocked");
expect((await deleteAdminUser("1")).status).toBe("deleted");
expect((await getAdminSettings()).settings_path).toBe("data/compton_settings.json");
expect((await patchAdminSettings({ enable_docs: false })).id).toBe("1");
expect((await getAdminDiagnostics()).checks[0].status).toBe("ok");
expect((await getAdminActivityFeed()).events.length).toBe(1);
expect((await postAdminUiActivity("click")).status).toBe("ok");
expect((await getAdminServerLog()).lines[0]).toBe("line");
expect((await getInstallSecrets()).initialized).toBe(true);
expect((await revealInstallSecret("database_password")).value).toBe("secret");
});
});
+148
View File
@@ -0,0 +1,148 @@
import { apiClient } from "@shared/api/client";
export interface AdminUser {
id: string;
email: string;
role: string;
is_superuser: boolean;
status: string;
}
export interface AdminUsersResponse {
data: AdminUser[];
meta: { total: number; page: number; limit: number };
}
export interface AdminStats {
users_count: number;
registrations_day: number;
}
export interface AdminSummary extends AdminStats {
admins_count: number;
superusers_count: number;
}
export interface AdminSettingsPayload {
values: Record<string, unknown>;
locks: Record<string, boolean>;
settings_path: string;
secrets: Record<string, boolean>;
}
export interface DiagnosticsCheck {
id: string;
status: "ok" | "warn" | "fail";
message: string;
}
export interface InstallSecretsPayload {
initialized: boolean;
locked: boolean;
secrets_path: string;
database: {
host: string | null;
port: number | null;
database: string;
user: string | null;
password_configured: boolean;
};
connection_string_masked: string;
secrets_status: Record<string, string>;
}
export async function getAdminUsers(page = 1, limit = 20): Promise<AdminUsersResponse> {
const { data } = await apiClient.get<AdminUsersResponse>("/api/v1/admin/users", {
params: { page, limit }
});
return data;
}
export async function patchAdminUser(
userId: string,
payload: { role?: string; status?: string; is_superuser?: boolean }
): Promise<AdminUser> {
const { data } = await apiClient.patch<AdminUser>(`/api/v1/admin/users/${userId}`, payload);
return data;
}
export async function getAdminStats(): Promise<AdminStats> {
const { data } = await apiClient.get<AdminStats>("/api/v1/admin/stats");
return data;
}
export async function getAdminSummary(): Promise<AdminSummary> {
const { data } = await apiClient.get<AdminSummary>("/api/v1/admin/summary");
return data;
}
export async function createAdminUser(payload: {
email: string;
password: string;
role: string;
is_superuser: boolean;
status: string;
}): Promise<AdminUser> {
const { data } = await apiClient.post<AdminUser>("/api/v1/admin/users", payload);
return data;
}
export async function resetAdminUserPassword(userId: string, password: string): Promise<{ status: string }> {
const { data } = await apiClient.patch<{ status: string }>(
`/api/v1/admin/users/${userId}/password`,
{ password }
);
return data;
}
export async function deleteAdminUser(userId: string): Promise<{ status: string }> {
const { data } = await apiClient.delete<{ status: string }>(`/api/v1/admin/users/${userId}`);
return data;
}
export async function getAdminSettings(): Promise<AdminSettingsPayload> {
const { data } = await apiClient.get<AdminSettingsPayload>("/api/v1/admin/settings");
return data;
}
export async function patchAdminSettings(values: Record<string, unknown>): Promise<AdminSettingsPayload> {
const { data } = await apiClient.patch<AdminSettingsPayload>("/api/v1/admin/settings", { values });
return data;
}
export async function getAdminDiagnostics(): Promise<{ checks: DiagnosticsCheck[] }> {
const { data } = await apiClient.get<{ checks: DiagnosticsCheck[] }>("/api/v1/admin/diagnostics/report");
return data;
}
export async function getAdminActivityFeed(limit = 200): Promise<{ events: Array<Record<string, unknown>> }> {
const { data } = await apiClient.get<{ events: Array<Record<string, unknown>> }>(
"/api/v1/admin/activity-feed",
{ params: { limit } }
);
return data;
}
export async function postAdminUiActivity(event: string, meta?: Record<string, unknown>): Promise<{ status: string }> {
const { data } = await apiClient.post<{ status: string }>("/api/v1/admin/ui-activity", { event, meta });
return data;
}
export async function getAdminServerLog(lines = 200): Promise<{ lines: string[] }> {
const { data } = await apiClient.get<{ lines: string[] }>("/api/v1/admin/server-log", {
params: { lines }
});
return data;
}
export async function getInstallSecrets(): Promise<InstallSecretsPayload> {
const { data } = await apiClient.get<InstallSecretsPayload>("/api/v1/admin/secrets");
return data;
}
export async function revealInstallSecret(
key: "database_password" | "jwt_access_secret" | "jwt_refresh_pepper" | "s3_secret_key"
): Promise<{ key: string; value: string }> {
const { data } = await apiClient.post<{ key: string; value: string }>("/api/v1/admin/secrets/reveal", { key });
return data;
}
@@ -0,0 +1,33 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminActivityPanel } from "./AdminActivityPanel";
vi.mock("@modules/auth", () => ({
useIsSuperuser: () => true
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: string[] }) => {
if (queryKey[0] === "admin-activity-feed") {
return {
isLoading: false,
data: { events: [{ action: "admin.user.patch", timestamp: "2026-07-14T10:00:00Z" }] }
};
}
return { data: { lines: ["line-1"] } };
}
}));
vi.mock("../api/adminApi", () => ({
getAdminActivityFeed: vi.fn(),
getAdminServerLog: vi.fn()
}));
describe("AdminActivityPanel", () => {
it("renders feed and server log for superuser", () => {
render(<AdminActivityPanel />);
expect(screen.getByRole("heading", { name: "Activity" })).toBeInTheDocument();
expect(screen.getByText("admin.user.patch")).toBeInTheDocument();
expect(screen.getByText("line-1")).toBeInTheDocument();
});
});
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import { useIsSuperuser } from "@modules/auth";
import { Card, List, Space, Typography } from "antd";
import { getAdminActivityFeed, getAdminServerLog } from "../api/adminApi";
export function AdminActivityPanel(): JSX.Element {
const isSuperuser = useIsSuperuser();
const { data, isLoading } = useQuery({
queryKey: ["admin-activity-feed"],
queryFn: () => getAdminActivityFeed(120)
});
const { data: serverLog } = useQuery({
queryKey: ["admin-server-log"],
queryFn: () => getAdminServerLog(80),
enabled: isSuperuser
});
if (isLoading || !data) {
return <p className="wesp-admin-block">Loading activity...</p>;
}
return (
<div className="wesp-admin-grid">
<Typography.Title level={4} style={{ margin: 0 }}>
Activity
</Typography.Title>
<Card>
<List
dataSource={data.events}
renderItem={(event, index) => (
<List.Item key={`${String(event.timestamp ?? "event")}-${index}`}>
<Space direction="vertical" size={0}>
<strong>{String(event.action ?? "event")}</strong>
<Typography.Text type="secondary">{String(event.timestamp ?? "")}</Typography.Text>
</Space>
</List.Item>
)}
/>
</Card>
{isSuperuser && serverLog ? (
<Card title="Server log">
<pre className="wesp-admin-log">{serverLog.lines.join("\n")}</pre>
</Card>
) : null}
</div>
);
}
@@ -0,0 +1,26 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminContentPanel } from "./AdminContentPanel";
vi.mock("@modules/content/api/contentApi", () => ({
getAdminPages: vi.fn(async () => [
{ id: "1", slug: "about", title: "About", body: "<p>x</p>", status: "published" }
]),
createContentPage: vi.fn(),
updateContentPage: vi.fn(),
deleteContentPage: vi.fn()
}));
describe("AdminContentPanel", () => {
it("renders content management form", async () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<AdminContentPanel />
</QueryClientProvider>
);
expect(await screen.findByText("About")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create page" })).toBeInTheDocument();
});
});
@@ -0,0 +1,152 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Button, Form, Input, Select, Space, Table, Typography, message } from "antd";
import {
createContentPage,
deleteContentPage,
getAdminPages,
updateContentPage,
type ContentPage
} from "@modules/content/api/contentApi";
const emptyForm = { slug: "", title: "", body: "", status: "draft" };
export function AdminContentPanel(): JSX.Element {
const queryClient = useQueryClient();
const [messageApi, contextHolder] = message.useMessage();
const [form, setForm] = useState(emptyForm);
const [editingId, setEditingId] = useState<string | null>(null);
const { data: pages, isLoading } = useQuery({
queryKey: ["admin-content-pages"],
queryFn: getAdminPages
});
const saveMutation = useMutation({
mutationFn: async () => {
if (editingId) {
return updateContentPage(editingId, {
title: form.title,
body: form.body,
status: form.status
});
}
return createContentPage(form);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-content-pages"] });
setForm(emptyForm);
setEditingId(null);
messageApi.success("Page saved");
},
onError: () => messageApi.error("Save failed")
});
const deleteMutation = useMutation({
mutationFn: (pageId: string) => deleteContentPage(pageId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-content-pages"] });
messageApi.success("Page deleted");
}
});
function startEdit(page: ContentPage): void {
setEditingId(page.id);
setForm({
slug: page.slug,
title: page.title,
body: page.body,
status: page.status
});
}
if (isLoading) {
return <p className="wesp-admin-block">Loading content pages...</p>;
}
return (
<div className="wesp-admin-grid">
{contextHolder}
<Typography.Title level={4} style={{ margin: 0 }}>
Content pages
</Typography.Title>
<Form layout="vertical" onFinish={() => saveMutation.mutate()}>
<Form.Item label="Slug">
<Input
placeholder="Slug"
value={form.slug}
disabled={Boolean(editingId)}
onChange={(event) => setForm((prev) => ({ ...prev, slug: event.target.value }))}
/>
</Form.Item>
<Form.Item label="Title">
<Input
placeholder="Title"
value={form.title}
onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))}
/>
</Form.Item>
<Form.Item label="Body (HTML)">
<Input.TextArea
placeholder="Body (HTML)"
value={form.body}
rows={6}
onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))}
/>
</Form.Item>
<Form.Item label="Status">
<Select
value={form.status}
onChange={(value) => setForm((prev) => ({ ...prev, status: value }))}
options={[{ value: "draft" }, { value: "published" }]}
/>
</Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
{editingId ? "Update page" : "Create page"}
</Button>
{editingId ? (
<Button
onClick={() => {
setEditingId(null);
setForm(emptyForm);
}}
>
Cancel
</Button>
) : null}
</Space>
</Form>
<Table
rowKey="id"
dataSource={pages ?? []}
pagination={false}
columns={[
{
title: "Title",
dataIndex: "title",
render: (title: string, page: ContentPage) => (
<Space direction="vertical" size={0}>
<strong>{title}</strong>
<Typography.Text type="secondary">{page.slug}</Typography.Text>
</Space>
)
},
{ title: "Status", dataIndex: "status" },
{
title: "Actions",
render: (_, page: ContentPage) => (
<Space>
<Button onClick={() => startEdit(page)}>Edit</Button>
<Button danger onClick={() => deleteMutation.mutate(page.id)}>
Delete
</Button>
</Space>
)
}
]}
/>
</div>
);
}
@@ -0,0 +1,22 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminDiagnosticsPanel } from "./AdminDiagnosticsPanel";
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
isLoading: false,
data: { checks: [{ id: "secret", status: "ok", message: "JWT configured" }] }
})
}));
vi.mock("../api/adminApi", () => ({
getAdminDiagnostics: vi.fn()
}));
describe("AdminDiagnosticsPanel", () => {
it("renders diagnostics checks", () => {
render(<AdminDiagnosticsPanel />);
expect(screen.getByRole("heading", { name: "Diagnostics" })).toBeInTheDocument();
expect(screen.getByText("JWT configured")).toBeInTheDocument();
});
});
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import { Card, List, Tag, Typography } from "antd";
import { getAdminDiagnostics } from "../api/adminApi";
export function AdminDiagnosticsPanel(): JSX.Element {
const { data, isLoading } = useQuery({
queryKey: ["admin-diagnostics"],
queryFn: getAdminDiagnostics
});
if (isLoading || !data) {
return <p className="wesp-admin-block">Loading diagnostics...</p>;
}
return (
<div className="wesp-admin-grid">
<Typography.Title level={4} style={{ margin: 0 }}>
Diagnostics
</Typography.Title>
<Card>
<List
dataSource={data.checks}
renderItem={(check) => (
<List.Item key={check.id}>
<List.Item.Meta
title={check.message}
description={
<Tag
className={
check.status === "ok"
? "wesp-tag wesp-tag--ok"
: check.status === "warn"
? "wesp-tag wesp-tag--warn"
: "wesp-tag wesp-tag--error"
}
>
{check.status}
</Tag>
}
/>
</List.Item>
)}
/>
</Card>
</div>
);
}
@@ -0,0 +1,46 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { AdminPanel } from "./AdminPanel";
vi.mock("./AdminUsersTable", () => ({
AdminUsersTable: () => <h2>Users</h2>
}));
vi.mock("./AdminContentPanel", () => ({
AdminContentPanel: () => <h2>Content pages</h2>
}));
vi.mock("./AdminSecurityPanel", () => ({
AdminSecurityPanel: () => <h2>Security panel</h2>
}));
vi.mock("./AdminDiagnosticsPanel", () => ({
AdminDiagnosticsPanel: () => <h2>Diagnostics panel</h2>
}));
vi.mock("./AdminActivityPanel", () => ({
AdminActivityPanel: () => <h2>Activity panel</h2>
}));
vi.mock("../api/adminApi", () => ({
postAdminUiActivity: vi.fn()
}));
vi.mock("@modules/auth", () => ({
useIsSuperuser: () => true,
useAuth: () => ({ logout: vi.fn() })
}));
describe("AdminPanel", () => {
it("switches between admin tabs", () => {
render(
<MemoryRouter>
<AdminPanel />
</MemoryRouter>
);
expect(screen.getByRole("heading", { name: "Users" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitem", { name: /Content$/ }));
expect(screen.getByRole("heading", { name: "Content pages" })).toBeInTheDocument();
});
});
@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import { useIsSuperuser } from "@modules/auth";
import { getVisibleAdminTabs, type AdminTab } from "../config/adminTabs";
import { postAdminUiActivity } from "../api/adminApi";
import { AdminActivityPanel } from "./AdminActivityPanel";
import { AdminContentPanel } from "./AdminContentPanel";
import { AdminDiagnosticsPanel } from "./AdminDiagnosticsPanel";
import { AdminShell } from "./AdminShell";
import { AdminSecurityPanel } from "./AdminSecurityPanel";
import { AdminUsersTable } from "./AdminUsersTable";
function AdminTabPanel({ tab, isSuperuser }: { tab: AdminTab; isSuperuser: boolean }): JSX.Element | null {
switch (tab) {
case "users":
return <AdminUsersTable />;
case "content":
return <AdminContentPanel />;
case "security":
return isSuperuser ? <AdminSecurityPanel /> : null;
case "diagnostics":
return isSuperuser ? <AdminDiagnosticsPanel /> : null;
case "activity":
return <AdminActivityPanel />;
default:
return null;
}
}
export function AdminPanel(): JSX.Element {
const isSuperuser = useIsSuperuser();
const [tab, setTab] = useState<AdminTab>("users");
const visibleTabs = getVisibleAdminTabs(isSuperuser);
useEffect(() => {
if (!visibleTabs.some((item) => item.id === tab)) {
setTab(visibleTabs[0]?.id ?? "users");
}
}, [isSuperuser, tab, visibleTabs]);
return (
<AdminShell
tab={tab}
isSuperuser={isSuperuser}
onSelectTab={(nextTab) => {
setTab(nextTab);
void postAdminUiActivity("admin_tab_change", { tab: nextTab });
}}
>
<div key={tab} className="wesp-admin-section-enter wesp-admin-block">
<AdminTabPanel tab={tab} isSuperuser={isSuperuser} />
</div>
</AdminShell>
);
}
@@ -0,0 +1,62 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { AdminSecurityPanel } from "./AdminSecurityPanel";
const patchMutate = vi.fn();
const revealMutate = vi.fn();
const invalidateQueries = vi.fn();
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
return {
...actual,
useQueryClient: () => ({ invalidateQueries }),
useQuery: ({ queryKey }: { queryKey: string[] }) => {
if (queryKey[0] === "admin-settings") {
return {
isLoading: false,
data: { values: { enable_rate_limit: true, enable_docs: false, cookie_secure: false }, locks: {} }
};
}
return {
isLoading: false,
data: {
database: { user: "dbu", host: "dbh", port: 5432, database: "dbn" },
connection_string_masked: "postgresql://***",
}
};
},
useMutation: (options?: { mutationFn?: unknown; onSuccess?: () => void }) => {
if (options?.onSuccess) {
return { mutate: patchMutate };
}
return { mutate: revealMutate, data: { key: "database_password", value: "secret" } };
}
};
});
vi.mock("../api/adminApi", () => ({
getAdminSettings: vi.fn(),
patchAdminSettings: vi.fn(),
getInstallSecrets: vi.fn(),
revealInstallSecret: vi.fn()
}));
describe("AdminSecurityPanel", () => {
it("toggles settings and reveals secrets", () => {
const client = new QueryClient();
render(
<QueryClientProvider client={client}>
<AdminSecurityPanel />
</QueryClientProvider>
);
fireEvent.click(screen.getAllByRole("switch")[1]);
expect(patchMutate).toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Reveal DB password" }));
expect(revealMutate).toHaveBeenCalledWith("database_password");
expect(screen.getByText(/database_password/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,101 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Card, Descriptions, Space, Switch, Typography } from "antd";
import {
getAdminSettings,
getInstallSecrets,
patchAdminSettings,
revealInstallSecret,
} from "../api/adminApi";
export function AdminSecurityPanel(): JSX.Element {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["admin-settings"],
queryFn: getAdminSettings
});
const { data: installSecrets, isLoading: installSecretsLoading } = useQuery({
queryKey: ["install-secrets"],
queryFn: getInstallSecrets
});
const mutation = useMutation({
mutationFn: (values: Record<string, unknown>) => patchAdminSettings(values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-settings"] });
}
});
const revealMutation = useMutation({
mutationFn: (key: "database_password" | "jwt_access_secret" | "jwt_refresh_pepper" | "s3_secret_key") =>
revealInstallSecret(key)
});
if (isLoading || !data || installSecretsLoading || !installSecrets) {
return <p className="wesp-admin-block">Loading security settings...</p>;
}
const values = data.values;
const locks = data.locks;
function toggle(key: string, checked: boolean): void {
void mutation.mutate({ [key]: checked });
}
return (
<div className="wesp-admin-grid">
<Typography.Title level={4} style={{ margin: 0 }}>
Security Settings
</Typography.Title>
<Card>
<Space direction="vertical" size="middle">
<Space>
<Switch
checked={Boolean(values.enable_rate_limit)}
disabled={Boolean(locks.enable_rate_limit)}
onChange={(checked) => toggle("enable_rate_limit", checked)}
/>
<span>Enable rate limits</span>
</Space>
<Space>
<Switch
checked={Boolean(values.enable_docs)}
disabled={Boolean(locks.enable_docs)}
onChange={(checked) => toggle("enable_docs", checked)}
/>
<span>Enable API docs</span>
</Space>
<Space>
<Switch
checked={Boolean(values.cookie_secure)}
disabled={Boolean(locks.cookie_secure)}
onChange={(checked) => toggle("cookie_secure", checked)}
/>
<span>Secure refresh cookie</span>
</Space>
</Space>
</Card>
<Card title="Install Secrets">
<Typography.Paragraph>
Secrets are generated and locked on first bootstrap.
</Typography.Paragraph>
<Descriptions column={1} size="small">
<Descriptions.Item label="Database">
{installSecrets.database.user}@{installSecrets.database.host}:{installSecrets.database.port}/
{installSecrets.database.database}
</Descriptions.Item>
<Descriptions.Item label="Connection">
{installSecrets.connection_string_masked || "not configured"}
</Descriptions.Item>
</Descriptions>
<Space wrap>
<Button onClick={() => void revealMutation.mutate("database_password")}>Reveal DB password</Button>
<Button onClick={() => void revealMutation.mutate("jwt_access_secret")}>Reveal JWT access secret</Button>
<Button onClick={() => void revealMutation.mutate("jwt_refresh_pepper")}>Reveal JWT pepper</Button>
</Space>
{revealMutation.data ? (
<Typography.Paragraph copyable={{ text: revealMutation.data.value }} style={{ marginTop: 12 }}>
{revealMutation.data.key}: <code>{revealMutation.data.value}</code>
</Typography.Paragraph>
) : null}
</Card>
</div>
);
}
@@ -0,0 +1,128 @@
import type { PropsWithChildren } from "react";
import { Button, ConfigProvider, Layout } from "antd";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@modules/auth";
import { AdminSider, readAdminSidebarCollapsedPreference, type AdminTab } from "./AdminSider";
import {
getAdminAntdTheme,
persistAdminThemePreference,
readAdminThemePreference,
toggleAdminColorMode,
type AdminColorMode
} from "../config/adminTheme";
import "../styles/wesp-admin-panel.css";
import { useEffect, useLayoutEffect, useState } from "react";
interface AdminShellProps extends PropsWithChildren {
tab: AdminTab;
isSuperuser: boolean;
onSelectTab: (tab: AdminTab) => void;
}
function applyAdminThemeAttribute(mode: AdminColorMode): void {
if (mode === "dark") {
document.documentElement.setAttribute("data-admin-theme", "dark");
return;
}
document.documentElement.removeAttribute("data-admin-theme");
}
export function AdminShell({ tab, isSuperuser, onSelectTab, children }: AdminShellProps): JSX.Element {
const navigate = useNavigate();
const auth = useAuth();
const [siderCollapsed, setSiderCollapsed] = useState(readAdminSidebarCollapsedPreference);
const [colorMode, setColorMode] = useState<AdminColorMode>(readAdminThemePreference);
useLayoutEffect(() => {
applyAdminThemeAttribute(colorMode);
return () => document.documentElement.removeAttribute("data-admin-theme");
}, [colorMode]);
useEffect(() => {
const syncSidebarForViewport = (): void => {
if (window.matchMedia("(max-width: 768px)").matches) {
setSiderCollapsed(false);
return;
}
setSiderCollapsed(readAdminSidebarCollapsedPreference());
};
syncSidebarForViewport();
window.addEventListener("resize", syncSidebarForViewport);
return () => window.removeEventListener("resize", syncSidebarForViewport);
}, []);
useEffect(() => {
document.documentElement.classList.add("wesp-admin-loading");
const blocks = Array.from(
document.querySelectorAll<HTMLElement>(".wesp-admin-content .wesp-admin-block")
);
blocks.forEach((el) => {
if (el.querySelector(":scope > .wesp-admin-skel-overlay")) {
return;
}
const overlay = document.createElement("div");
overlay.className = "wesp-admin-skel-overlay";
overlay.setAttribute("aria-hidden", "true");
el.appendChild(overlay);
});
const timer = window.setTimeout(() => {
blocks.forEach((el, index) => el.style.setProperty("--wesp-reveal-i", String(index)));
document.documentElement.classList.remove("wesp-admin-loading");
document.documentElement.classList.add("wesp-admin-loaded");
window.setTimeout(() => {
document.querySelectorAll(".wesp-admin-skel-overlay").forEach((overlay) => overlay.remove());
}, 420);
}, 320);
return () => {
window.clearTimeout(timer);
document.documentElement.classList.remove("wesp-admin-loading", "wesp-admin-loaded");
document.querySelectorAll(".wesp-admin-skel-overlay").forEach((overlay) => overlay.remove());
};
}, []);
return (
<ConfigProvider theme={getAdminAntdTheme(colorMode)}>
<Layout
className={`wesp-admin-layout wesp-admin-layout--${colorMode}${siderCollapsed ? "" : " wesp-admin-sider-expanded"}`}
>
<AdminSider
tab={tab}
isSuperuser={isSuperuser}
collapsed={siderCollapsed}
colorMode={colorMode}
onCollapse={setSiderCollapsed}
onSelect={onSelectTab}
/>
<Layout>
<Layout.Content className="wesp-admin-content">
<header className="wesp-admin-content-topbar">
<div className="wesp-admin-content-topbar-actions">
<Button
onClick={() => {
const nextMode = toggleAdminColorMode(colorMode);
setColorMode(nextMode);
persistAdminThemePreference(nextMode);
}}
>
Light/Dark
</Button>
<Button href="/">На сайт</Button>
<Button
danger
onClick={() => {
void auth.logout().then(() => navigate("/login", { replace: true }));
}}
>
Logout
</Button>
</div>
</header>
<main className="wesp-admin-main">{children}</main>
</Layout.Content>
</Layout>
</Layout>
</ConfigProvider>
);
}
@@ -0,0 +1,106 @@
import {
AlertOutlined,
FileTextOutlined,
FundOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SafetyOutlined,
UserOutlined
} from "@ant-design/icons";
import { Layout, Menu } from "antd";
import type { ReactNode } from "react";
import { getVisibleAdminTabs, type AdminTab } from "../config/adminTabs";
import type { AdminColorMode } from "../config/adminTheme";
const ADMIN_SIDEBAR_COLLAPSED_KEY = "wespAdminSidebarCollapsed";
const TAB_ICONS: Record<AdminTab, ReactNode> = {
users: <UserOutlined />,
content: <FileTextOutlined />,
security: <SafetyOutlined />,
diagnostics: <AlertOutlined />,
activity: <FundOutlined />
};
interface AdminSiderProps {
tab: AdminTab;
isSuperuser: boolean;
collapsed: boolean;
colorMode: AdminColorMode;
onCollapse: (collapsed: boolean) => void;
onSelect: (tab: AdminTab) => void;
}
export function readAdminSidebarCollapsedPreference(): boolean {
if (typeof window === "undefined") {
return true;
}
return localStorage.getItem(ADMIN_SIDEBAR_COLLAPSED_KEY) !== "0";
}
export function persistAdminSidebarCollapsedPreference(collapsed: boolean): void {
localStorage.setItem(ADMIN_SIDEBAR_COLLAPSED_KEY, collapsed ? "1" : "0");
}
function isMobileViewport(): boolean {
return typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches;
}
export function AdminSider({
tab,
isSuperuser,
collapsed,
colorMode,
onCollapse,
onSelect
}: AdminSiderProps): JSX.Element {
const items = getVisibleAdminTabs(isSuperuser).map((item) => ({
key: item.id,
icon: TAB_ICONS[item.id],
label: <span className="wesp-menu-label">{item.label}</span>
}));
return (
<Layout.Sider
id="wesp-admin-sider"
className="wesp-admin-sider"
theme={colorMode}
width={256}
collapsed={collapsed}
collapsedWidth={80}
collapsible
trigger={
<div
className="ant-layout-sider-trigger"
role="button"
tabIndex={0}
aria-controls="wesp-admin-sider"
aria-expanded={!collapsed}
title={collapsed ? "Развернуть меню" : "Свернуть меню"}
>
<span className="wesp-sider-trigger-icon" aria-hidden="true">
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</span>
</div>
}
onCollapse={(nextCollapsed) => {
if (isMobileViewport()) {
return;
}
onCollapse(nextCollapsed);
persistAdminSidebarCollapsedPreference(nextCollapsed);
}}
>
<Menu
theme={colorMode}
mode="inline"
inlineCollapsed={collapsed}
selectedKeys={[tab]}
onClick={(event) => onSelect(event.key as AdminTab)}
items={items}
/>
</Layout.Sider>
);
}
export type { AdminTab };
@@ -0,0 +1,31 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminStats } from "./AdminStats";
vi.mock("../api/adminApi", () => ({
getAdminSummary: vi.fn(async () => ({
users_count: 10,
registrations_day: 3,
admins_count: 2,
superusers_count: 1
}))
}));
describe("AdminStats", () => {
it("renders dashboard metrics", async () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<AdminStats />
</QueryClientProvider>
);
expect(await screen.findByText("CPU")).toBeInTheDocument();
expect(await screen.findByText("WESP")).toBeInTheDocument();
expect(await screen.findByText("nx throughput (instant)")).toBeInTheDocument();
expect(await screen.findByText("users")).toBeInTheDocument();
expect(await screen.findByText("10")).toBeInTheDocument();
expect(await screen.findByText("3")).toBeInTheDocument();
expect(await screen.findByText("2")).toBeInTheDocument();
});
});
@@ -0,0 +1,207 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Card, Col, Row, Tag } from "antd";
import { getAdminSummary } from "../api/adminApi";
const GAUGE_CIRC = 2 * Math.PI * 47;
interface GaugeItem {
key: string;
title: string;
detail: string;
value: number;
}
export function AdminStats(): JSX.Element {
const { data, isLoading, isError } = useQuery({
queryKey: ["admin-summary"],
queryFn: getAdminSummary
});
const summary = data ?? {
users_count: 0,
registrations_day: 0,
admins_count: 0,
superusers_count: 0
};
const stats = [
{ title: "users", value: summary.users_count },
{ title: "registrations today", value: summary.registrations_day },
{ title: "admins", value: summary.admins_count },
{ title: "superusers", value: summary.superusers_count }
];
const gauges = useMemo<GaugeItem[]>(
() => [
{
key: "cpu",
title: "CPU",
detail: `${Math.min(100, 18 + summary.admins_count * 7)}% load`,
value: Math.min(100, 18 + summary.admins_count * 7)
},
{
key: "ram",
title: "RAM",
detail: `${Math.min(100, 32 + summary.users_count * 3)}% used`,
value: Math.min(100, 32 + summary.users_count * 3)
},
{
key: "swap",
title: "Swap",
detail: `${Math.min(100, 4 + summary.registrations_day * 5)}% used`,
value: Math.min(100, 4 + summary.registrations_day * 5)
},
{
key: "disk",
title: "Disk",
detail: `${Math.min(100, 42 + summary.superusers_count * 8)}% used`,
value: Math.min(100, 42 + summary.superusers_count * 8)
}
],
[summary.admins_count, summary.registrations_day, summary.superusers_count, summary.users_count]
);
return (
<div className="wesp-admin-dashboard">
{isLoading ? <p className="wesp-admin-block">Loading metrics...</p> : null}
{isError ? <p className="wesp-admin-block">Summary unavailable, showing fallback values.</p> : null}
<Card className="wesp-dashboard-sticky">
<div className="wesp-admin-gauges-grid">
{gauges.map((gauge) => {
const strokeDashoffset = GAUGE_CIRC * (1 - gauge.value / 100);
return (
<div key={gauge.key} className="wesp-gauge">
<div className="wesp-gauge-ring wesp-gauge-ring--adm">
<svg className="wesp-gauge-svg" viewBox="0 0 100 100" aria-hidden="true">
<circle className="wesp-gauge-track" cx="50" cy="50" r="47" fill="none" strokeWidth="6" />
<circle
className="wesp-gauge-fill wesp-gauge-fill--adm"
cx="50"
cy="50"
r="47"
fill="none"
strokeWidth="6"
strokeLinecap="round"
transform="rotate(-90 50 50)"
style={{ strokeDasharray: GAUGE_CIRC, strokeDashoffset }}
/>
</svg>
<div className="wesp-gauge-center">
<span className="wesp-gauge-pct">{gauge.value}</span>
<span className="wesp-gauge-pct-suffix">%</span>
</div>
</div>
<div className="wesp-gauge-caption">
<b>{gauge.title}</b> <span data-gauge-detail>{gauge.detail}</span>
</div>
</div>
);
})}
</div>
</Card>
<Row gutter={[16, 16]} className="wesp-dash-service-row">
<Col xs={24} xl={12}>
<Card className="wesp-dash-service-card" title="WESP" extra={<span className="wesp-dash-status"><span className="wesp-status-dot wesp-status-dot--ok" />OK</span>}>
<div className="wesp-dash-wesp-meta">
<div className="wesp-dash-wesp-row">
<span className="wesp-dash-wesp-label">version</span>
<Tag className="wesp-tag wesp-tag--ok">v1.0.0</Tag>
</div>
<div className="wesp-dash-wesp-row">
<span className="wesp-dash-wesp-label">users</span>
<span className="wesp-dash-wesp-value">{summary.users_count}</span>
</div>
<div className="wesp-dash-wesp-row">
<span className="wesp-dash-wesp-label">admins</span>
<span className="wesp-dash-wesp-value">{summary.admins_count}</span>
</div>
<div className="wesp-dash-wesp-row">
<span className="wesp-dash-wesp-label">superusers</span>
<span className="wesp-dash-wesp-value">{summary.superusers_count}</span>
</div>
</div>
</Card>
</Col>
<Col xs={24} xl={12}>
<Card className="wesp-dash-service-card" title="Ops">
<div className="wesp-dash-manage-list">
<div className="wesp-dash-manage-row">
<div className="wesp-dash-manage-info">
<span className="wesp-dash-manage-title">log</span>
<span className="wesp-dash-manage-hint">tail; activity feed</span>
</div>
<div className="wesp-dash-manage-actions">
<Button size="small" type="primary">open</Button>
<Button size="small">download</Button>
</div>
</div>
<div className="wesp-dash-manage-row">
<div className="wesp-dash-manage-info">
<span className="wesp-dash-manage-title">config</span>
<span className="wesp-dash-manage-hint">go to content/settings</span>
</div>
<div className="wesp-dash-manage-actions">
<Button size="small">go</Button>
</div>
</div>
<div className="wesp-dash-manage-row wesp-dash-manage-row--service">
<div className="wesp-dash-manage-info">
<span className="wesp-dash-manage-title">service</span>
<span className="wesp-dash-manage-hint">stop / restart</span>
</div>
<div className="wesp-dash-manage-actions">
<Button size="small" danger>stop</Button>
<Button size="small" type="primary">restart</Button>
</div>
</div>
</div>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card className="wesp-traffic-card" title="nx throughput (instant)">
<div className="wesp-traffic-two-col">
<div className="wesp-traffic-col">
<div className="wesp-traffic-label">tx</div>
<div className="wesp-traffic-value"><span className="wesp-traffic-arrow"></span>{Math.max(1, summary.registrations_day)} mb/s</div>
</div>
<div className="wesp-traffic-col">
<div className="wesp-traffic-label">rx</div>
<div className="wesp-traffic-value"><span className="wesp-traffic-arrow"></span>{Math.max(2, summary.users_count)} mb/s</div>
</div>
</div>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card className="wesp-traffic-card" title="nx cumulative">
<div className="wesp-traffic-two-col">
<div className="wesp-traffic-col">
<div className="wesp-traffic-label">bytes tx</div>
<div className="wesp-traffic-value"><span className="wesp-traffic-icon"></span>{summary.users_count * 120} mb</div>
</div>
<div className="wesp-traffic-col">
<div className="wesp-traffic-label">bytes rx</div>
<div className="wesp-traffic-value"><span className="wesp-traffic-icon"></span>{summary.users_count * 180} mb</div>
</div>
</div>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} className="wesp-admin-stats-row">
{stats.map((stat) => (
<Col key={stat.title} xs={24} sm={12} lg={6}>
<Card size="small" className="wesp-admin-stat-card">
<div className="wesp-admin-stat-label">{stat.title}</div>
<div className="wesp-admin-stat-value">{stat.value}</div>
</Card>
</Col>
))}
</Row>
</div>
);
}
@@ -0,0 +1,34 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminUsersTable } from "./AdminUsersTable";
vi.mock("@modules/auth/store/authStore", () => ({
useAuthStore: (selector: (state: { user: { id: string } }) => unknown) =>
selector({ user: { id: "admin-id" } })
}));
vi.mock("@modules/auth", () => ({
useIsSuperuser: () => false
}));
vi.mock("../api/adminApi", () => ({
getAdminUsers: vi.fn(async () => ({
data: [{ id: "1", email: "user@example.com", role: "user", is_superuser: false, status: "active" }],
meta: { total: 1, page: 1, limit: 20 }
})),
patchAdminUser: vi.fn()
}));
describe("AdminUsersTable", () => {
it("renders users table", async () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<AdminUsersTable />
</QueryClientProvider>
);
expect(await screen.findByText("user@example.com")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument();
});
});
@@ -0,0 +1,245 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useAuthStore } from "@modules/auth/store/authStore";
import { useIsSuperuser } from "@modules/auth";
import { Button, Form, Input, Modal, Select, Space, Switch, Table, Tag, Typography, message } from "antd";
import {
createAdminUser,
deleteAdminUser,
getAdminUsers,
patchAdminUser,
resetAdminUserPassword,
type AdminUser
} from "../api/adminApi";
export function AdminUsersTable(): JSX.Element {
const isSuperuser = useIsSuperuser();
const currentUser = useAuthStore((state) => state.user);
const [messageApi, contextHolder] = message.useMessage();
const [modal, contextModal] = Modal.useModal();
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm<{ email: string; password: string; role: string; status: string; is_superuser: boolean }>();
const [editForm] = Form.useForm<{ role: string; status: string; is_superuser: boolean }>();
const { data, isLoading } = useQuery({
queryKey: ["admin-users"],
queryFn: () => getAdminUsers()
});
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (values: { email: string; password: string; role: string; status: string; is_superuser: boolean }) =>
createAdminUser(values),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin-users"] });
messageApi.success("User created");
setCreateOpen(false);
createForm.resetFields();
}
});
const updateMutation = useMutation({
mutationFn: (payload: { id: string; role: string; status: string; is_superuser: boolean }) =>
patchAdminUser(payload.id, payload),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin-users"] });
messageApi.success("User updated");
setEditingUser(null);
}
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminUser(id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin-users"] });
messageApi.success("User deleted");
}
});
const resetMutation = useMutation({
mutationFn: (id: string) => resetAdminUserPassword(id, "Admin1234"),
onSuccess: () => {
messageApi.success("Password reset to Admin1234");
}
});
if (isLoading || !data) {
return <p className="wesp-admin-block">Loading users...</p>;
}
const rows = data.data;
return (
<div className="wesp-admin-grid">
{contextHolder}
{contextModal}
<Space align="center">
<Typography.Title level={4} style={{ margin: 0 }}>
Users
</Typography.Title>
{isSuperuser ? (
<Button type="primary" onClick={() => setCreateOpen(true)}>
Create user
</Button>
) : null}
</Space>
<Table
rowKey="id"
dataSource={rows}
pagination={false}
columns={[
{
title: "Email",
dataIndex: "email"
},
{
title: "Role",
dataIndex: "role",
render: (role: string) => (
<Tag className={role === "admin" ? "wesp-tag wesp-tag--warn" : "wesp-tag"}>{role}</Tag>
)
},
{
title: "Status",
dataIndex: "status",
render: (status: string) => (
<Tag
className={
status === "active"
? "wesp-tag wesp-tag--ok"
: status === "blocked"
? "wesp-tag wesp-tag--error"
: "wesp-tag"
}
>
{status}
</Tag>
)
},
{
title: "Superuser",
dataIndex: "is_superuser",
render: (value: boolean) => (value ? "yes" : "no")
},
{
title: "Actions",
render: (_, user: AdminUser) => {
const isSelf = currentUser?.id === user.id;
return (
<Space wrap>
<Button
onClick={() => {
editForm.setFieldsValue({
role: user.role,
status: user.status,
is_superuser: user.is_superuser
});
setEditingUser(user);
}}
disabled={isSelf}
>
Edit
</Button>
{isSuperuser ? (
<>
<Button
disabled={isSelf}
onClick={() => {
void resetMutation.mutate(user.id);
}}
>
Reset pwd
</Button>
<Button
danger
disabled={isSelf}
onClick={() => {
void modal.confirm({
title: "Delete user",
content: `Delete ${user.email}?`,
okText: "Delete",
okButtonProps: { danger: true },
onOk: () => deleteMutation.mutateAsync(user.id)
});
}}
>
Delete
</Button>
</>
) : null}
{isSelf ? <Typography.Text type="secondary">(you)</Typography.Text> : null}
</Space>
);
}
}
]}
/>
<Modal
title="Create user"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => createForm.submit()}
okText="Create"
confirmLoading={createMutation.isPending}
>
<Form
form={createForm}
layout="vertical"
initialValues={{ role: "user", status: "active", is_superuser: false, password: "Admin1234" }}
onFinish={(values) => createMutation.mutate(values)}
>
<Form.Item name="email" label="Email" rules={[{ required: true, type: "email" }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true, min: 8 }]}>
<Input.Password />
</Form.Item>
<Form.Item name="role" label="Role" rules={[{ required: true }]}>
<Select options={[{ value: "user" }, { value: "admin" }]} />
</Form.Item>
<Form.Item name="status" label="Status" rules={[{ required: true }]}>
<Select options={[{ value: "active" }, { value: "pending" }, { value: "blocked" }]} />
</Form.Item>
{isSuperuser ? (
<Form.Item name="is_superuser" label="Superuser" valuePropName="checked">
<Switch />
</Form.Item>
) : null}
</Form>
</Modal>
<Modal
title={editingUser ? `Edit ${editingUser.email}` : "Edit user"}
open={Boolean(editingUser)}
onCancel={() => setEditingUser(null)}
onOk={() => editForm.submit()}
okText="Save"
confirmLoading={updateMutation.isPending}
>
<Form
form={editForm}
layout="vertical"
onFinish={(values) => {
if (!editingUser) {
return;
}
updateMutation.mutate({ id: editingUser.id, ...values });
}}
>
<Form.Item name="role" label="Role" rules={[{ required: true }]}>
<Select options={[{ value: "user" }, { value: "admin" }]} />
</Form.Item>
<Form.Item name="status" label="Status" rules={[{ required: true }]}>
<Select options={[{ value: "active" }, { value: "pending" }, { value: "blocked" }]} />
</Form.Item>
{isSuperuser ? (
<Form.Item name="is_superuser" label="Superuser" valuePropName="checked">
<Switch />
</Form.Item>
) : null}
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,19 @@
export type AdminTab = "users" | "content" | "security" | "diagnostics" | "activity";
export interface AdminTabConfig {
id: AdminTab;
label: string;
superuserOnly?: boolean;
}
export const ADMIN_TABS: AdminTabConfig[] = [
{ id: "users", label: "Users" },
{ id: "content", label: "Content" },
{ id: "security", label: "Security", superuserOnly: true },
{ id: "diagnostics", label: "Diagnostics", superuserOnly: true },
{ id: "activity", label: "Activity" }
];
export function getVisibleAdminTabs(isSuperuser: boolean): AdminTabConfig[] {
return ADMIN_TABS.filter((tab) => !tab.superuserOnly || isSuperuser);
}
@@ -0,0 +1,169 @@
import { theme, type ThemeConfig } from "antd";
export type AdminColorMode = "light" | "dark";
const ADMIN_THEME_KEY = "wespAdminTheme";
export function readAdminThemePreference(): AdminColorMode {
if (typeof window === "undefined") {
return "light";
}
return localStorage.getItem(ADMIN_THEME_KEY) === "dark" ? "dark" : "light";
}
export function persistAdminThemePreference(mode: AdminColorMode): void {
localStorage.setItem(ADMIN_THEME_KEY, mode);
}
export function toggleAdminColorMode(mode: AdminColorMode): AdminColorMode {
return mode === "light" ? "dark" : "light";
}
const lightTheme: ThemeConfig = {
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: "#48816d",
colorBgBase: "#f6f6f4",
colorBgContainer: "#ffffff",
colorTextBase: "#1a1e1c",
colorBorder: "#e8e8e2",
colorBorderSecondary: "#e8e8e2",
colorError: "#cd3838",
colorWarning: "#d4a72c",
colorSuccess: "#0f766e"
},
components: {
Layout: {
siderBg: "#ffffff",
triggerBg: "#ebebe5",
triggerColor: "#1a1e1c"
},
Menu: {
itemBg: "transparent",
itemColor: "#1a1e1c",
itemSelectedBg: "rgba(72, 129, 109, 0.16)",
itemSelectedColor: "#48816d",
itemHoverBg: "#e8f2ef",
itemMarginInline: 8,
itemBorderRadius: 8,
itemHeight: 40
},
Card: {
colorBgContainer: "#ffffff",
colorBorderSecondary: "#e8e8e2",
borderRadiusLG: 20
},
Table: {
headerBg: "#ebebe5",
rowHoverBg: "rgba(72, 129, 109, 0.06)",
borderColor: "#e8e8e2"
},
Tag: {
defaultBg: "#ebebe5",
defaultColor: "#4a5a52"
},
Input: {
colorBgContainer: "#ffffff",
colorBorder: "#e8e8e2",
colorText: "#1a1e1c"
},
Select: {
colorBgContainer: "#ffffff",
colorBorder: "#e8e8e2",
colorText: "#1a1e1c",
optionSelectedBg: "rgba(72, 129, 109, 0.1)"
},
Switch: {
colorPrimary: "#48816d",
colorPrimaryHover: "#5a9a82"
},
Modal: {
contentBg: "#ffffff",
headerBg: "#ffffff",
titleColor: "#1a1e1c",
colorIcon: "#4a5a52",
colorIconHover: "#1a1e1c"
},
Button: {
defaultBg: "#ffffff",
defaultBorderColor: "#e8e8e2",
defaultColor: "#1a1e1c"
}
}
};
const darkTheme: ThemeConfig = {
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: "#5a9a82",
colorBgBase: "#1f2229",
colorBgContainer: "#0b0a10",
colorTextBase: "#ececf1",
colorBorder: "rgba(186, 184, 208, 0.14)",
colorBorderSecondary: "rgba(186, 184, 208, 0.1)",
colorError: "#e94b4b",
colorWarning: "#e8b84a",
colorSuccess: "#6bc4a8"
},
components: {
Layout: {
siderBg: "#0b0a10",
triggerBg: "#15141c",
triggerColor: "#ececf1"
},
Menu: {
darkItemBg: "transparent",
darkItemSelectedBg: "rgba(90, 154, 130, 0.32)",
darkItemHoverBg: "rgba(90, 154, 130, 0.2)",
itemMarginInline: 8,
itemBorderRadius: 8,
itemHeight: 40
},
Card: {
colorBgContainer: "#0b0a10",
colorBorderSecondary: "rgba(186, 184, 208, 0.14)",
borderRadiusLG: 20
},
Table: {
headerBg: "#1a1924",
rowHoverBg: "rgba(90, 154, 130, 0.1)",
borderColor: "rgba(186, 184, 208, 0.1)"
},
Tag: {
defaultBg: "rgba(11, 10, 16, 0.35)",
defaultColor: "rgba(236, 236, 241, 0.75)"
},
Input: {
colorBgContainer: "#15141c",
colorBorder: "rgba(186, 184, 208, 0.16)",
colorText: "#ececf1"
},
Select: {
colorBgContainer: "#15141c",
colorBorder: "rgba(186, 184, 208, 0.16)",
colorText: "#ececf1",
optionSelectedBg: "rgba(90, 154, 130, 0.2)"
},
Switch: {
colorPrimary: "#5a9a82",
colorPrimaryHover: "#6bc4a8"
},
Modal: {
colorBgElevated: "#0b0a10",
contentBg: "#0b0a10",
headerBg: "#0b0a10",
titleColor: "#ececf1",
colorIcon: "#a8adbb",
colorIconHover: "#ececf1"
},
Button: {
defaultBg: "#15141c",
defaultBorderColor: "rgba(186, 184, 208, 0.16)",
defaultColor: "#ececf1"
}
}
};
export function getAdminAntdTheme(mode: AdminColorMode): ThemeConfig {
return mode === "dark" ? darkTheme : lightTheme;
}
+19
View File
@@ -0,0 +1,19 @@
export { AdminPanel } from "./components/AdminPanel";
export { AdminStats } from "./components/AdminStats";
export { AdminUsersTable } from "./components/AdminUsersTable";
export { AdminContentPanel } from "./components/AdminContentPanel";
export {
createAdminUser,
deleteAdminUser,
getAdminActivityFeed,
getAdminDiagnostics,
getAdminServerLog,
getAdminSettings,
getAdminStats,
getAdminSummary,
getAdminUsers,
patchAdminSettings,
patchAdminUser,
postAdminUiActivity,
resetAdminUserPassword
} from "./api/adminApi";
@@ -0,0 +1,898 @@
@import "../../../../main/css/tokens.css";
html.admin-route body {
background: var(--wesp-admin-bg);
}
html.admin-route .ant-layout-sider-trigger {
background: var(--marquee-bg) !important;
color: var(--foreground) !important;
border-top: 1px solid var(--border-light) !important;
}
html.admin-route .ant-layout-sider-trigger:hover {
background: var(--primary-tint) !important;
color: var(--foreground) !important;
}
html.admin-route .ant-card {
background: var(--wesp-admin-surface) !important;
border-color: var(--border-light) !important;
box-shadow: none !important;
border-radius: var(--admin-card-radius) !important;
overflow: hidden;
}
html.admin-route .ant-btn-primary {
background: var(--wesp-admin-primary) !important;
border-color: var(--primary) !important;
color: #fff !important;
}
html.admin-route .ant-btn-dangerous,
html.admin-route .ant-btn-dangerous.ant-btn-primary {
background: #cd3838 !important;
border-color: transparent !important;
color: #fff !important;
}
html.admin-route .ant-btn-dangerous:hover,
html.admin-route .ant-btn-dangerous.ant-btn-primary:hover {
background: #e94b4b !important;
color: #fff !important;
}
html.admin-route .ant-input,
html.admin-route .ant-input-affix-wrapper,
html.admin-route .ant-select-selector,
html.admin-route .ant-input-number {
background: var(--surface) !important;
border-color: var(--border-light) !important;
color: var(--foreground) !important;
}
html.admin-route .ant-select-dropdown,
html.admin-route .ant-modal-content,
html.admin-route .ant-modal-header {
background: var(--surface) !important;
color: var(--foreground) !important;
}
html.admin-route .ant-modal-title,
html.admin-route .ant-modal-close {
color: var(--foreground) !important;
}
html.admin-route .ant-switch.ant-switch-checked {
background: var(--wesp-admin-primary) !important;
}
html.admin-route .ant-typography.ant-typography-secondary,
html.admin-route .ant-list-item-meta-description {
color: var(--muted) !important;
}
html.admin-route .ant-table {
background: var(--surface) !important;
color: var(--foreground) !important;
}
html.admin-route .ant-table-thead > tr > th {
background: var(--marquee-bg) !important;
color: var(--muted) !important;
border-bottom-color: var(--border-light) !important;
}
html.admin-route .ant-table-tbody > tr > td {
border-bottom-color: var(--border-light) !important;
}
.wesp-admin-layout {
min-height: 100vh;
background: var(--wesp-admin-bg) !important;
--wesp-admin-sider-width: 80px;
}
.wesp-admin-layout.wesp-admin-sider-expanded {
--wesp-admin-sider-width: 256px;
}
.wesp-admin-sider.ant-layout-sider {
background: var(--admin-sider-bg) !important;
border-right: 1px solid var(--admin-stroke);
position: fixed;
top: 0;
left: 0;
bottom: 0;
height: 100vh;
z-index: 100;
flex: 0 0 var(--wesp-admin-sider-width) !important;
max-width: var(--wesp-admin-sider-width) !important;
min-width: var(--wesp-admin-sider-width) !important;
width: var(--wesp-admin-sider-width) !important;
transition:
flex 0.28s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
min-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.wesp-admin-sider.ant-layout-sider-collapsed {
flex: 0 0 80px !important;
max-width: 80px !important;
min-width: 80px !important;
width: 80px !important;
}
.wesp-admin-layout > .ant-layout {
margin-left: var(--wesp-admin-sider-width);
min-height: 100vh;
background: transparent;
transition: margin-left 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.wesp-admin-sider .ant-layout-sider-children {
display: flex;
flex-direction: column;
overflow: hidden;
padding-bottom: 48px;
}
.wesp-admin-sider .ant-menu {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 10px 0 8px !important;
background: transparent !important;
border-inline-end: none !important;
}
.wesp-admin-sider .ant-menu-item {
cursor: pointer;
margin: 4px 8px !important;
width: calc(100% - 16px) !important;
display: flex !important;
align-items: center;
gap: 10px;
height: 40px !important;
line-height: 40px !important;
border-radius: 8px;
color: var(--wesp-admin-text) !important;
}
.wesp-admin-sider .ant-menu-item:hover {
background: var(--admin-primary-hover) !important;
}
.wesp-admin-sider .ant-menu-item-selected {
background: var(--admin-primary-selected) !important;
color: var(--primary) !important;
}
.wesp-admin-sider .ant-menu-item .anticon {
color: var(--muted);
font-size: 16px;
}
.wesp-admin-sider .ant-menu-item-selected .anticon {
color: var(--primary);
}
.wesp-admin-sider .wesp-menu-label {
min-width: 0;
overflow: hidden;
white-space: nowrap;
max-width: 16rem;
opacity: 1;
transition:
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.1s,
max-width 0.36s cubic-bezier(0.4, 0, 0.2, 1) 0.08s,
margin 0.25s ease,
padding 0.25s ease;
}
.wesp-admin-sider.ant-layout-sider-collapsed .wesp-menu-label {
max-width: 0 !important;
opacity: 0 !important;
margin: 0 !important;
padding: 0 !important;
pointer-events: none;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item {
display: flex !important;
justify-content: center !important;
align-items: center !important;
gap: 0;
margin-inline: 8px !important;
width: calc(100% - 16px) !important;
padding-inline: 0 !important;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item .ant-menu-item-icon {
margin-inline: 0 !important;
flex: none !important;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-title-content {
width: 0 !important;
overflow: hidden !important;
opacity: 0 !important;
flex: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-inline-collapsed > .ant-menu-item {
padding-inline: 0 !important;
}
.wesp-admin-sider:not(.ant-layout-sider-collapsed) .ant-menu-item {
padding-inline: 12px !important;
}
.wesp-admin-sider .ant-layout-sider-trigger,
.wesp-admin-sider.ant-layout-sider-light .ant-layout-sider-trigger,
.wesp-admin-sider.ant-layout-sider-dark .ant-layout-sider-trigger {
position: absolute;
left: 0;
right: 0;
bottom: 0;
width: 100% !important;
height: 48px !important;
line-height: 48px !important;
border-top: 1px solid var(--border-light) !important;
background: var(--marquee-bg) !important;
color: var(--foreground) !important;
box-shadow: none !important;
}
.wesp-admin-sider .ant-layout-sider-trigger:hover,
.wesp-admin-sider.ant-layout-sider-light .ant-layout-sider-trigger:hover,
.wesp-admin-sider.ant-layout-sider-dark .ant-layout-sider-trigger:hover {
background: var(--primary-tint) !important;
color: var(--foreground) !important;
}
.wesp-sider-trigger-icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--foreground);
opacity: 0.9;
}
.wesp-sider-trigger-icon .anticon {
color: inherit;
}
.ant-tooltip.ant-menu-inline-collapsed-tooltip .ant-tooltip-inner {
background: var(--surface);
color: var(--foreground);
font-size: 12px;
border: 1px solid var(--border-light);
}
.ant-tooltip.ant-menu-inline-collapsed-tooltip .ant-tooltip-arrow::before {
background: var(--surface);
}
.wesp-admin-content {
margin: 16px;
padding: 0;
background: transparent !important;
color: var(--wesp-admin-text);
min-width: 0;
}
.wesp-admin-main {
min-width: 0;
}
.wesp-admin-content-topbar {
margin-bottom: 12px;
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 12px;
min-height: 44px;
}
.wesp-admin-content-topbar-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.wesp-admin-content-topbar .ant-btn {
border-radius: 20px;
background: var(--admin-btn-bg);
border-color: var(--admin-btn-border);
color: var(--admin-btn-text);
height: 32px;
padding: 0 15px;
font-size: 14px;
font-weight: 500;
}
.wesp-admin-content-topbar .ant-btn-primary {
background: var(--wesp-admin-primary) !important;
border-color: var(--primary) !important;
color: #fff !important;
}
.wesp-admin-panel-card.ant-card {
border-radius: var(--admin-card-radius);
border: 1px solid var(--border-light);
background: var(--wesp-admin-surface);
}
.wesp-admin-panel-card .ant-card-head {
border-bottom-color: var(--border-light);
min-height: 40px;
padding: 0 14px;
}
.wesp-admin-panel-card .ant-card-body {
padding: 14px;
}
.wesp-admin-panel-card .ant-card-head-title,
.wesp-admin-panel-card .ant-typography,
.wesp-admin-grid .ant-typography,
.wesp-admin-grid .ant-list-item-meta-title,
.wesp-admin-grid .ant-list-item-meta-description {
color: var(--wesp-admin-text) !important;
}
.wesp-admin-section {
display: block;
}
.wesp-admin-dashboard {
display: grid;
gap: 16px;
}
.wesp-dashboard-sticky.ant-card {
position: sticky;
top: 0;
z-index: 20;
}
.wesp-dashboard-sticky.ant-card .ant-card-body {
padding: 12px 14px;
}
.wesp-admin-gauges-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.wesp-gauge {
text-align: center;
}
.wesp-gauge-ring {
position: relative;
width: 120px;
max-width: 100%;
margin: 0 auto;
aspect-ratio: 1;
}
.wesp-gauge-svg {
width: 100%;
height: 100%;
display: block;
}
.wesp-gauge-track {
stroke: var(--border-light);
}
.wesp-gauge-fill {
stroke: var(--wesp-admin-primary);
transition: stroke-dashoffset 0.45s ease;
}
.wesp-gauge-center {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.wesp-gauge-pct {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
}
.wesp-gauge-pct-suffix {
font-size: 13px;
font-weight: 600;
opacity: 0.55;
margin-left: 1px;
}
.wesp-gauge-ring--adm {
width: 120px;
height: 120px;
}
.wesp-gauge-caption {
margin-top: 10px;
font-size: 13px;
line-height: 1.45;
word-break: break-word;
}
.wesp-gauge-caption span[data-gauge-detail] {
opacity: 0.75;
font-weight: 500;
}
.wesp-dash-service-row.ant-row {
display: flex;
flex-wrap: wrap;
align-items: stretch;
}
.wesp-dash-service-row > .ant-col {
display: flex;
flex-direction: column;
}
.wesp-dash-service-row .wesp-dash-service-card {
flex: 1 1 auto;
width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
}
.wesp-dash-service-card .ant-card-body {
padding-top: 12px;
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.wesp-dash-service-row .wesp-dash-manage-list {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.wesp-dash-manage-list {
font-size: 13px;
line-height: 1.4;
}
.wesp-dash-manage-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 16px;
padding: 14px 0;
border-bottom: 1px solid var(--border-light);
}
.wesp-dash-manage-row:first-child {
padding-top: 2px;
}
.wesp-dash-manage-row:last-child {
border-bottom: none;
padding-bottom: 2px;
}
.wesp-dash-manage-row--service {
margin-top: 4px;
padding-top: 16px;
}
.wesp-dash-manage-info {
flex: 1 1 140px;
min-width: 0;
}
.wesp-dash-manage-title {
display: block;
font-weight: 600;
font-size: 13px;
}
.wesp-dash-manage-hint {
display: block;
margin-top: 4px;
font-size: 12px;
opacity: 0.65;
line-height: 1.35;
}
.wesp-dash-manage-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 12px;
flex-shrink: 0;
}
.wesp-dash-wesp-meta {
display: flex;
flex-direction: column;
gap: 10px;
font-size: 13px;
}
.wesp-dash-wesp-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 12px;
line-height: 1.4;
}
.wesp-dash-wesp-label {
opacity: 0.65;
flex-shrink: 0;
}
.wesp-dash-wesp-value {
font-weight: 500;
text-align: right;
}
.wesp-dash-status {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
}
.wesp-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
box-sizing: border-box;
}
.wesp-status-dot--ok {
background: var(--wesp-admin-primary);
}
.wesp-traffic-card .ant-card-body {
padding: 16px 20px 20px;
}
.wesp-traffic-two-col {
display: flex;
flex-wrap: wrap;
gap: 16px 24px;
}
.wesp-traffic-col {
flex: 1 1 160px;
min-width: 0;
}
.wesp-traffic-label {
font-size: 13px;
opacity: 0.65;
margin-bottom: 8px;
}
.wesp-traffic-value {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.02em;
}
.wesp-traffic-arrow,
.wesp-traffic-icon {
opacity: 0.75;
margin-right: 4px;
}
.wesp-admin-stats-row {
margin-top: 0;
}
.wesp-tag {
background: var(--marquee-bg) !important;
border: 1px solid var(--border-light) !important;
color: var(--muted) !important;
}
.wesp-tag--ok {
background: var(--primary-tint) !important;
border-color: var(--admin-tag-ok-border) !important;
color: var(--primary) !important;
}
.wesp-tag--warn {
background: #fff8e6 !important;
border-color: rgba(212, 167, 44, 0.45) !important;
color: #9a6700 !important;
}
.wesp-tag--error {
background: #fff1f0 !important;
border-color: rgba(205, 56, 56, 0.35) !important;
color: #cd3838 !important;
}
.wesp-admin-stat-card.ant-card {
background: var(--wesp-admin-surface) !important;
border: 1px solid var(--wesp-admin-stroke) !important;
border-radius: var(--admin-card-radius);
box-shadow: none !important;
}
.wesp-admin-stat-card.ant-card .ant-card-body {
padding: 16px !important;
}
.wesp-admin-stat-label {
opacity: 0.65;
font-size: 13px;
color: var(--wesp-admin-soft);
}
.wesp-admin-stat-value {
font-size: 26px;
font-weight: 600;
margin-top: 6px;
color: var(--wesp-admin-text);
line-height: 1.2;
}
.wesp-admin-grid .ant-card {
background: var(--wesp-admin-surface) !important;
border-color: var(--wesp-admin-stroke) !important;
border-radius: var(--admin-card-radius) !important;
}
.wesp-admin-grid .ant-table {
background: var(--wesp-admin-surface) !important;
color: var(--wesp-admin-text) !important;
}
.wesp-admin-grid .ant-table-thead > tr > th {
background: var(--admin-table-header) !important;
border-bottom-color: var(--wesp-admin-stroke) !important;
color: var(--wesp-admin-soft) !important;
}
.wesp-admin-grid .ant-table-tbody > tr > td {
border-bottom-color: var(--border-light) !important;
color: var(--wesp-admin-text) !important;
}
.wesp-admin-grid {
display: grid;
gap: 10px;
}
.wesp-admin-log {
margin: 0;
background: linear-gradient(165deg, #0a0f0d 0%, #060807 48%, #0d1210 100%);
color: #7ee787;
border-radius: 10px;
padding: 12px;
max-height: 320px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.wesp-admin-block {
position: relative;
}
html.wesp-admin-loading #root {
pointer-events: none;
}
.wesp-admin-skel-overlay {
position: absolute;
inset: 0;
pointer-events: none;
border-radius: inherit;
z-index: 2;
background: linear-gradient(
105deg,
rgba(0, 0, 0, 0.02) 0%,
rgba(0, 0, 0, 0.02) 34%,
rgba(0, 0, 0, 0.05) 50%,
rgba(0, 0, 0, 0.02) 66%,
rgba(0, 0, 0, 0.02) 100%
);
background-size: 240% 100%;
animation: wesp-admin-skel-shimmer 0.95s ease-in-out infinite;
transition: opacity 0.38s ease;
}
@keyframes wesp-admin-skel-shimmer {
0% {
background-position: 240% 0;
}
100% {
background-position: -240% 0;
}
}
html.wesp-admin-loaded .wesp-admin-skel-overlay {
opacity: 0;
visibility: hidden;
}
html.wesp-admin-loaded .wesp-admin-block {
animation: wesp-admin-block-reveal 0.52s cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--wesp-reveal-i, 0) * 38ms);
}
@keyframes wesp-admin-block-reveal {
from {
opacity: 0.55;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.wesp-admin-section-enter {
animation: wesp-admin-section-tween 0.44s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes wesp-admin-section-tween {
from {
opacity: 0.45;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
html.admin-route[data-admin-theme="dark"] .wesp-tag {
background: rgba(11, 10, 16, 0.45) !important;
border: 1px solid rgba(186, 184, 208, 0.16) !important;
color: rgba(236, 236, 241, 0.82) !important;
}
html.admin-route[data-admin-theme="dark"] .wesp-tag--ok {
background: rgba(90, 154, 130, 0.18) !important;
border-color: var(--admin-tag-ok-border) !important;
color: var(--admin-success) !important;
}
html.admin-route[data-admin-theme="dark"] .wesp-tag--warn {
background: rgba(232, 184, 74, 0.12) !important;
border-color: rgba(232, 184, 74, 0.4) !important;
color: #e8b84a !important;
}
html.admin-route[data-admin-theme="dark"] .wesp-tag--error {
background: rgba(233, 75, 75, 0.14) !important;
border-color: rgba(233, 75, 75, 0.35) !important;
color: #ff9c9c !important;
}
html.admin-route[data-admin-theme="dark"] .wesp-admin-skel-overlay {
background: linear-gradient(
105deg,
rgba(255, 255, 255, 0.02) 0%,
rgba(255, 255, 255, 0.02) 34%,
rgba(255, 255, 255, 0.07) 50%,
rgba(255, 255, 255, 0.02) 66%,
rgba(255, 255, 255, 0.02) 100%
);
}
html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item-selected {
color: var(--admin-success) !important;
}
html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item-selected .anticon {
color: var(--admin-success);
}
html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item .anticon {
color: var(--admin-soft);
}
html.admin-route[data-admin-theme="dark"] .wesp-gauge-track {
stroke: rgba(186, 184, 208, 0.18);
}
html.admin-route[data-admin-theme="dark"] .wesp-admin-log {
background: linear-gradient(165deg, #0b0a10 0%, #15141c 48%, #0b0a10 100%);
color: #6bc4a8;
}
@media (max-width: 768px) {
.wesp-admin-layout.ant-layout-has-sider {
flex-direction: column;
}
.wesp-admin-sider.ant-layout-sider {
position: static;
inset: auto;
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
flex: 0 0 auto !important;
height: auto !important;
}
.wesp-admin-layout > .ant-layout {
margin-left: 0;
}
.wesp-admin-sider .ant-layout-sider-children {
padding-bottom: 0;
}
.wesp-admin-sider .ant-layout-sider-trigger {
display: none;
}
.wesp-admin-sider.ant-layout-sider-collapsed .wesp-menu-label {
max-width: 16rem !important;
opacity: 1 !important;
pointer-events: auto;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item {
justify-content: flex-start;
gap: 10px;
padding-inline: 16px !important;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-title-content {
width: auto !important;
opacity: 1 !important;
flex: 1 !important;
overflow: visible !important;
}
.wesp-admin-content {
margin: 12px;
}
.wesp-admin-content-topbar {
justify-content: flex-start;
}
.wesp-admin-gauges-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.wesp-admin-sider.ant-layout-sider,
.wesp-admin-layout > .ant-layout,
.wesp-admin-sider .wesp-menu-label,
.wesp-admin-sider .ant-menu-item {
transition: none !important;
}
}
+3
View File
@@ -0,0 +1,3 @@
export function AnalyticsPlaceholder(): string {
return "analytics-v1";
}
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from "vitest";
import { login, register } from "./authApi";
vi.mock("@shared/api/client", () => ({
authClient: {
post: vi.fn(async (url: string) => {
if (url.endsWith("/login")) {
return { data: { access_token: "token", user: { id: "1" } } };
}
return { data: { message: "ok" } };
})
}
}));
describe("authApi", () => {
it("login returns access token payload", async () => {
const data = await login({ email: "u@example.com", password: "Valid123" });
expect(data.access_token).toBe("token");
});
it("register returns success payload", async () => {
const data = await register({ email: "u@example.com", password: "Valid123" });
expect(data.message).toBe("ok");
});
});
+84
View File
@@ -0,0 +1,84 @@
import { authClient } from "@shared/api/client";
export interface LoginPayload {
email: string;
password: string;
}
export interface RegisterPayload {
email: string;
password: string;
}
export async function login(payload: LoginPayload) {
const { data } = await authClient.post(
"/api/v1/auth/login",
{
email: payload.email.trim(),
password: payload.password
},
{
headers: { "Content-Type": "application/json" }
}
);
return data;
}
export async function register(payload: RegisterPayload) {
const { data } = await authClient.post(
"/api/v1/auth/register",
{
email: payload.email.trim(),
password: payload.password
},
{
headers: { "Content-Type": "application/json" }
}
);
return data;
}
export async function logout() {
await authClient.post("/api/v1/auth/logout");
}
export async function refresh() {
const { data } = await authClient.post("/api/v1/auth/refresh");
return data;
}
export async function verifyEmail(token: string) {
const { data } = await authClient.post(
"/api/v1/auth/verify-email",
{ token },
{ headers: { "Content-Type": "application/json" } }
);
return data;
}
export async function forgotPassword(email: string) {
const { data } = await authClient.post(
"/api/v1/auth/forgot-password",
{ email: email.trim() },
{ headers: { "Content-Type": "application/json" } }
);
return data;
}
export async function resetPassword(token: string, newPassword: string) {
const { data } = await authClient.post(
"/api/v1/auth/reset-password",
{ token, new_password: newPassword },
{ headers: { "Content-Type": "application/json" } }
);
return data;
}
export async function resendVerification(email: string) {
const { data } = await authClient.post(
"/api/v1/auth/resend-verification",
{ email: email.trim() },
{ headers: { "Content-Type": "application/json" } }
);
return data;
}
@@ -0,0 +1,16 @@
import type { InputHTMLAttributes, ReactNode } from "react";
interface AuthFieldProps extends InputHTMLAttributes<HTMLInputElement> {
icon: ReactNode;
}
export function AuthField({ icon, ...props }: AuthFieldProps): JSX.Element {
return (
<label className="z-login-field">
<span className="z-login-field-icon" aria-hidden="true">
{icon}
</span>
<input className="z-login-input" {...props} />
</label>
);
}
@@ -0,0 +1,22 @@
import type { PropsWithChildren } from "react";
import { ZootechThemeProvider } from "@shared/theme/zootech/ThemeProvider";
import "@shared/theme/zootech/auth-login.css";
interface AuthLayoutProps extends PropsWithChildren {
title: string;
}
export function AuthLayout({ title, children }: AuthLayoutProps): JSX.Element {
return (
<ZootechThemeProvider>
<main className="z-login-shell">
<section className="z-login-page">
<article className="z-login-card">
<h1 className="z-login-title">{title}</h1>
{children}
</article>
</section>
</main>
</ZootechThemeProvider>
);
}
@@ -0,0 +1,12 @@
interface AuthMessageProps {
type: "error" | "success";
text: string;
}
export function AuthMessage({ type, text }: AuthMessageProps): JSX.Element {
return (
<p className={`z-login-msg z-login-msg--${type}`} role={type === "error" ? "alert" : undefined}>
{text}
</p>
);
}
@@ -0,0 +1,19 @@
interface AuthSubmitProps {
loading: boolean;
idleText: string;
loadingText: string;
disabled?: boolean;
}
export function AuthSubmit({
loading,
idleText,
loadingText,
disabled = false
}: AuthSubmitProps): JSX.Element {
return (
<button className="z-login-submit" type="submit" disabled={disabled || loading}>
{loading ? loadingText : idleText}
</button>
);
}
@@ -0,0 +1,12 @@
import type { InputHTMLAttributes, PropsWithChildren } from "react";
type AuthSwitchProps = InputHTMLAttributes<HTMLInputElement> & PropsWithChildren;
export function AuthSwitch({ children, ...props }: AuthSwitchProps): JSX.Element {
return (
<label className="z-login-remember">
<input type="checkbox" {...props} />
<span>{children}</span>
</label>
);
}
@@ -0,0 +1,17 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { LoginForm } from "./LoginForm";
describe("LoginForm", () => {
it("renders login form controls", () => {
render(
<MemoryRouter>
<LoginForm />
</MemoryRouter>
);
expect(screen.getByPlaceholderText("Email")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Password")).toBeInTheDocument();
expect(screen.getByText(/Forgot password/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,103 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { formatAuthError } from "../utils/formatAuthError";
import { AuthField } from "./AuthField";
import { AuthMessage } from "./AuthMessage";
import { AuthSubmit } from "./AuthSubmit";
function UserIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function LockIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M7 11V7a5 5 0 0 1 10 0v4M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function LoginForm(): JSX.Element {
const auth = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
return (
<form
className="z-login-form"
onSubmit={async (event) => {
event.preventDefault();
setError("");
setIsSubmitting(true);
try {
const user = await auth.login(email.trim(), password);
navigate(user.role === "admin" ? "/admin" : "/profile");
} catch (loginError) {
const fallback =
loginError && typeof loginError === "object" && "response" in loginError
? (loginError as { response?: { status?: number } }).response?.status === 401
? "Invalid credentials"
: (loginError as { response?: { status?: number } }).response?.status === 403
? "Email not verified or account blocked"
: "Login failed"
: "Login failed";
setError(formatAuthError(loginError, fallback));
} finally {
setIsSubmitting(false);
}
}}
>
<AuthField
icon={<UserIcon />}
placeholder="Email"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
<AuthField
icon={<LockIcon />}
placeholder="Password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
<AuthSubmit loading={isSubmitting} loadingText="Logging in..." idleText="Login" />
<p className="z-login-meta">
<Link className="z-login-link" to="/forgot-password">
Forgot password?
</Link>
</p>
<p className="z-login-meta">
No account yet?{" "}
<Link className="z-login-link" to="/register">
Register
</Link>
</p>
{error ? <AuthMessage type="error" text={error} /> : null}
</form>
);
}
@@ -0,0 +1,72 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { AxiosError, type AxiosResponse } from "axios";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RegisterForm } from "./RegisterForm";
const registerMock = vi.fn();
vi.mock("../api/authApi", () => ({
register: (...args: unknown[]) => registerMock(...args)
}));
afterEach(() => {
cleanup();
registerMock.mockReset();
});
describe("RegisterForm", () => {
it("renders register form controls", () => {
render(
<MemoryRouter>
<RegisterForm />
</MemoryRouter>
);
expect(screen.getByPlaceholderText("Email")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Password")).toBeInTheDocument();
expect(screen.getByText(/uppercase, lowercase, and a digit/i)).toBeInTheDocument();
expect(screen.getByText(/privacy policy/i)).toBeInTheDocument();
});
it("shows validation error from API", async () => {
registerMock.mockRejectedValueOnce(
new AxiosError(
"Validation failed",
"422",
undefined,
undefined,
{
status: 422,
data: {
detail: [
{
loc: ["body", "password"],
msg: "Value error, Password must include at least one uppercase letter"
}
]
}
} as AxiosResponse
)
);
render(
<MemoryRouter>
<RegisterForm />
</MemoryRouter>
);
fireEvent.change(screen.getByPlaceholderText("Email"), {
target: { value: "user@example.com" }
});
fireEvent.change(screen.getByPlaceholderText("Password"), {
target: { value: "valid123" }
});
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(screen.getByRole("button", { name: "Register" }));
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(
"Password must include at least one uppercase letter"
);
});
});
});
@@ -0,0 +1,108 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { register } from "../api/authApi";
import { formatAuthError } from "../utils/formatAuthError";
import { AuthField } from "./AuthField";
import { AuthMessage } from "./AuthMessage";
import { AuthSubmit } from "./AuthSubmit";
import { AuthSwitch } from "./AuthSwitch";
function UserIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function LockIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M7 11V7a5 5 0 0 1 10 0v4M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function RegisterForm(): JSX.Element {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [consent, setConsent] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
return (
<form
className="z-login-form"
onSubmit={async (event) => {
event.preventDefault();
if (!consent) {
setError("Please accept the privacy policy to register.");
return;
}
setMessage("");
setError("");
setIsSubmitting(true);
try {
await register({ email, password });
setMessage("Registration submitted. Check your email.");
} catch (registerError) {
setError(formatAuthError(registerError, "Registration failed"));
} finally {
setIsSubmitting(false);
}
}}
>
<AuthField
icon={<UserIcon />}
placeholder="Email"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
<AuthField
icon={<LockIcon />}
placeholder="Password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
minLength={8}
/>
<p className="z-login-meta">
Password: at least 8 characters with uppercase, lowercase, and a digit.
</p>
<AuthSwitch checked={consent} onChange={(event) => setConsent(event.target.checked)}>
I agree to the{" "}
<Link className="z-login-link" to="/pages/privacy">
privacy policy
</Link>
.
</AuthSwitch>
<AuthSubmit loading={isSubmitting} loadingText="Registering..." idleText="Register" />
{error ? <AuthMessage type="error" text={error} /> : null}
{message ? <AuthMessage type="success" text={message} /> : null}
<p className="z-login-meta">
Already registered?{" "}
<Link className="z-login-link" to="/login">
Login
</Link>
</p>
</form>
);
}
@@ -0,0 +1,30 @@
import { useAuthStore } from "../store/authStore";
import type { AuthUser } from "../store/authStore";
import { login as apiLogin, logout as apiLogout, refresh as apiRefresh } from "../api/authApi";
export function useAuth() {
const user = useAuthStore((state) => state.user);
const accessToken = useAuthStore((state) => state.accessToken);
const bootstrapped = useAuthStore((state) => state.bootstrapped);
const setSession = useAuthStore((state) => state.setSession);
const clearSession = useAuthStore((state) => state.clearSession);
return {
user,
isAuthenticated: Boolean(user && accessToken),
bootstrapped,
async login(email: string, password: string) {
const data = await apiLogin({ email, password });
setSession(data.access_token, data.user);
return data.user as AuthUser;
},
async logout() {
await apiLogout();
clearSession();
},
async refreshSession() {
const data = await apiRefresh();
setSession(data.access_token, data.user);
}
};
}
@@ -0,0 +1,6 @@
import { useAuthStore } from "../store/authStore";
export function useIsSuperuser(): boolean {
const user = useAuthStore((state) => state.user);
return Boolean(user?.role === "admin" && user?.is_superuser);
}
+4
View File
@@ -0,0 +1,4 @@
export { LoginForm } from "./components/LoginForm";
export { RegisterForm } from "./components/RegisterForm";
export { useAuth } from "./hooks/useAuth";
export { useIsSuperuser } from "./hooks/useIsSuperuser";
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearAuthSessionHint,
hasAuthSessionHint,
isProtectedAppPath,
markAuthSessionHint,
shouldAttemptAuthRefresh
} from "./authSessionHint";
describe("authSessionHint", () => {
afterEach(() => {
sessionStorage.clear();
});
it("tracks session hint in sessionStorage", () => {
expect(hasAuthSessionHint()).toBe(false);
markAuthSessionHint();
expect(hasAuthSessionHint()).toBe(true);
clearAuthSessionHint();
expect(hasAuthSessionHint()).toBe(false);
});
it("detects protected app paths", () => {
expect(isProtectedAppPath("/admin")).toBe(true);
expect(isProtectedAppPath("/admin/users")).toBe(true);
expect(isProtectedAppPath("/profile")).toBe(true);
expect(isProtectedAppPath("/login")).toBe(false);
});
it("attempts refresh on protected paths even without hint", () => {
expect(shouldAttemptAuthRefresh("/admin")).toBe(true);
expect(shouldAttemptAuthRefresh("/login")).toBe(false);
});
it("attempts refresh when hint is present", () => {
markAuthSessionHint();
expect(shouldAttemptAuthRefresh("/login")).toBe(true);
});
});
@@ -0,0 +1,24 @@
const AUTH_HINT_KEY = "compton-auth-hint";
const PROTECTED_PATH_PREFIXES = ["/admin", "/profile"] as const;
export function markAuthSessionHint(): void {
sessionStorage.setItem(AUTH_HINT_KEY, "1");
}
export function clearAuthSessionHint(): void {
sessionStorage.removeItem(AUTH_HINT_KEY);
}
export function hasAuthSessionHint(): boolean {
return sessionStorage.getItem(AUTH_HINT_KEY) === "1";
}
export function isProtectedAppPath(pathname: string): boolean {
return PROTECTED_PATH_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
);
}
export function shouldAttemptAuthRefresh(pathname = window.location.pathname): boolean {
return hasAuthSessionHint() || isProtectedAppPath(pathname);
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { useAuthStore } from "./authStore";
describe("authStore", () => {
it("stores and clears session in memory", () => {
useAuthStore.getState().setBootstrapped(true);
useAuthStore.getState().setSession("token", {
id: "1",
email: "u@e.com",
role: "user",
is_superuser: false,
status: "active"
});
expect(useAuthStore.getState().accessToken).toBe("token");
useAuthStore.getState().clearSession();
expect(useAuthStore.getState().accessToken).toBeNull();
});
});
@@ -0,0 +1,37 @@
import { create } from "zustand";
import { clearAuthSessionHint, markAuthSessionHint } from "@modules/auth/store/authSessionHint";
export type UserRole = "user" | "admin";
export type UserStatus = "active" | "pending" | "blocked";
export interface AuthUser {
id: string;
email: string;
role: UserRole;
is_superuser: boolean;
status: UserStatus;
}
interface AuthState {
accessToken: string | null;
user: AuthUser | null;
bootstrapped: boolean;
setSession: (accessToken: string, user: AuthUser) => void;
clearSession: () => void;
setBootstrapped: (bootstrapped: boolean) => void;
}
export const useAuthStore = create<AuthState>()((set) => ({
accessToken: null,
user: null,
bootstrapped: false,
setSession: (accessToken, user) => {
markAuthSessionHint();
set({ accessToken, user });
},
clearSession: () => {
clearAuthSessionHint();
set({ accessToken: null, user: null });
},
setBootstrapped: (bootstrapped) => set({ bootstrapped })
}));
@@ -0,0 +1,37 @@
import { AxiosError, type AxiosResponse } from "axios";
import { describe, expect, it } from "vitest";
import { formatAuthError } from "./formatAuthError";
describe("formatAuthError", () => {
it("shows network message when API is unreachable", () => {
const message = formatAuthError(new AxiosError("Network Error", "ERR_NETWORK"), "Login failed");
expect(message).toBe(
"Cannot reach the API. Make sure the backend is running and reload the page."
);
});
it("extracts password validation message", () => {
const message = formatAuthError(
new AxiosError(
"Validation failed",
"422",
undefined,
undefined,
{
status: 422,
data: {
detail: [
{
loc: ["body", "password"],
msg: "Value error, Password must include at least one digit"
}
]
}
} as AxiosResponse
),
"Registration failed"
);
expect(message).toBe("Password must include at least one digit");
});
});
@@ -0,0 +1,35 @@
import { isAxiosError } from "axios";
type ValidationDetail = {
loc?: (string | number)[];
msg?: string;
};
function formatValidationDetail(detail: ValidationDetail[]): string {
const passwordError = detail.find((item) => item.loc?.includes("password"));
if (passwordError?.msg) {
return passwordError.msg.replace(/^Value error, /, "");
}
const emailError = detail.find((item) => item.loc?.includes("email"));
if (emailError?.msg) {
return "Enter a valid email address";
}
return "Invalid email or password format";
}
export function formatAuthError(error: unknown, fallback: string): string {
if (!isAxiosError(error)) {
return fallback;
}
if (!error.response) {
return "Cannot reach the API. Make sure the backend is running and reload the page.";
}
const detail = error.response?.data?.detail;
if (typeof detail === "string") {
return detail;
}
if (Array.isArray(detail) && detail.length > 0) {
return formatValidationDetail(detail as ValidationDetail[]);
}
return fallback;
}
@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
import { CatalogPlaceholder } from "./index";
describe("CatalogPlaceholder", () => {
it("returns stable marker", () => {
expect(CatalogPlaceholder()).toBe("catalog-v1");
});
});
+3
View File
@@ -0,0 +1,3 @@
export function CatalogPlaceholder(): string {
return "catalog-v1";
}

Some files were not shown because too many files have changed in this diff Show More