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.
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.git
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
@@ -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=
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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/");
|
||||
});
|
||||
});
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/*" }
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -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">© 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>
|
||||
@@ -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);
|
||||
}
|
||||
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 786 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 484 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -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">© 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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" } }]
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: /sitemap.xml
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { JSX as ReactJSX } from "react";
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
type Element = ReactJSX.Element;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function CatalogPlaceholder(): string {
|
||||
return "catalog-v1";
|
||||
}
|
||||