Update admin theme/layout and refresh README details.

Align the project baseline with the latest admin interface styling and layout structure while documenting setup and usage updates in README.
This commit is contained in:
vlad
2026-07-14 17:12:28 +03:00
commit 86cc3fa541
278 changed files with 19416 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
import "@testing-library/jest-dom/vitest";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false
})
});
const originalGetComputedStyle = window.getComputedStyle.bind(window);
window.getComputedStyle = ((element: Element) => originalGetComputedStyle(element)) as typeof window.getComputedStyle;
+40
View File
@@ -0,0 +1,40 @@
import { useLayoutEffect } from "react";
import { AppProviders } from "./providers/AppProviders";
import { AppRouter } from "./router/routes";
import { AppHeader } from "@shared/ui";
import { useLocation } from "react-router-dom";
function shouldHideHeader(pathname: string): boolean {
return (
/^\/(login|register|forgot-password|verify|reset-password)(\/|$)/.test(pathname) ||
/^\/admin(\/|$)/.test(pathname)
);
}
function isAdminRoute(pathname: string): boolean {
return /^\/admin(\/|$)/.test(pathname);
}
function AppShell(): JSX.Element {
const location = useLocation();
const hideHeader = shouldHideHeader(location.pathname);
useLayoutEffect(() => {
document.documentElement.classList.toggle("admin-route", isAdminRoute(location.pathname));
}, [location.pathname]);
return (
<>
{hideHeader ? null : <AppHeader />}
<AppRouter />
</>
);
}
export function App(): JSX.Element {
return (
<AppProviders>
<AppShell />
</AppProviders>
);
}
@@ -0,0 +1,20 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import type { PropsWithChildren } from "react";
import { setAccessTokenGetter } from "@shared/api/client";
import { useAuthStore } from "@modules/auth/store/authStore";
import { AuthBootstrap } from "./AuthBootstrap";
const queryClient = new QueryClient();
setAccessTokenGetter(() => useAuthStore.getState().accessToken);
export function AppProviders({ children }: PropsWithChildren): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthBootstrap>{children}</AuthBootstrap>
</BrowserRouter>
</QueryClientProvider>
);
}
@@ -0,0 +1,32 @@
import { useLayoutEffect } from "react";
import type { PropsWithChildren } from "react";
import { shouldAttemptAuthRefresh } from "@modules/auth/store/authSessionHint";
import { useAuthStore } from "@modules/auth/store/authStore";
import { bootstrapSessionRefresh } from "@shared/api/client";
export function AuthBootstrap({ children }: PropsWithChildren): JSX.Element {
const setBootstrapped = useAuthStore((state) => state.setBootstrapped);
useLayoutEffect(() => {
let cancelled = false;
if (!shouldAttemptAuthRefresh()) {
setBootstrapped(true);
return () => {
cancelled = true;
};
}
void bootstrapSessionRefresh().finally(() => {
if (!cancelled) {
setBootstrapped(true);
}
});
return () => {
cancelled = true;
};
}, [setBootstrapped]);
return <>{children}</>;
}
@@ -0,0 +1,51 @@
import { cleanup, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AdminGuard } from "@app/router/guards/AdminGuard";
import { useAuthStore } from "@modules/auth/store/authStore";
function TestChild(): JSX.Element {
return <h1>Admin area</h1>;
}
describe("AdminGuard", () => {
beforeEach(() => {
useAuthStore.getState().clearSession();
useAuthStore.getState().setBootstrapped(true);
});
afterEach(() => {
cleanup();
});
it("redirects guests to login", () => {
render(
<MemoryRouter initialEntries={["/admin"]}>
<AdminGuard>
<TestChild />
</AdminGuard>
</MemoryRouter>
);
expect(screen.queryByRole("heading", { name: "Admin area" })).not.toBeInTheDocument();
});
it("renders admin content for admin users", () => {
useAuthStore.getState().setSession("token", {
id: "1",
email: "admin@compton.example",
role: "admin",
is_superuser: false,
status: "active"
});
render(
<MemoryRouter initialEntries={["/admin"]}>
<AdminGuard>
<TestChild />
</AdminGuard>
</MemoryRouter>
);
expect(screen.getAllByRole("heading", { name: "Admin area" })).toHaveLength(1);
});
});
@@ -0,0 +1,23 @@
import { Navigate } from "react-router-dom";
import type { PropsWithChildren } from "react";
import { useAuth } from "@modules/auth";
import { useAuthStore } from "@modules/auth/store/authStore";
export function AdminGuard({ children }: PropsWithChildren): JSX.Element {
const bootstrapped = useAuthStore((state) => state.bootstrapped);
const auth = useAuth();
if (!bootstrapped) {
return null;
}
if (!auth.isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (auth.user?.role !== "admin") {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}
@@ -0,0 +1,19 @@
import { Navigate } from "react-router-dom";
import type { PropsWithChildren } from "react";
import { useAuth } from "@modules/auth";
import { useAuthStore } from "@modules/auth/store/authStore";
export function AuthGuard({ children }: PropsWithChildren): JSX.Element {
const bootstrapped = useAuthStore((state) => state.bootstrapped);
const auth = useAuth();
if (!bootstrapped) {
return null;
}
if (!auth.isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
@@ -0,0 +1,19 @@
import { Navigate } from "react-router-dom";
import type { PropsWithChildren } from "react";
import { useAuth } from "@modules/auth";
import { useAuthStore } from "@modules/auth/store/authStore";
export function GuestGuard({ children }: PropsWithChildren): JSX.Element {
const bootstrapped = useAuthStore((state) => state.bootstrapped);
const auth = useAuth();
if (!bootstrapped) {
return null;
}
if (auth.isAuthenticated) {
return <Navigate to={auth.user?.role === "admin" ? "/admin" : "/profile"} replace />;
}
return <>{children}</>;
}
+67
View File
@@ -0,0 +1,67 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { LoginPage } from "@pages/LoginPage";
import { RegisterPage } from "@pages/RegisterPage";
import { ContentPage } from "@pages/ContentPage";
import { VerifyPage } from "@pages/VerifyPage";
import { ResetPasswordPage } from "@pages/ResetPasswordPage";
import { ForgotPasswordPage } from "@pages/ForgotPasswordPage";
import AdminPage from "@pages/AdminPage";
import { AuthGuard } from "./guards/AuthGuard";
import { AdminGuard } from "./guards/AdminGuard";
import { GuestGuard } from "./guards/GuestGuard";
const ProfilePage = lazy(() => import("@pages/ProfilePage"));
export function AppRouter(): JSX.Element {
return (
<Suspense fallback={null}>
<Routes>
<Route
path="/login"
element={
<GuestGuard>
<LoginPage />
</GuestGuard>
}
/>
<Route
path="/register"
element={
<GuestGuard>
<RegisterPage />
</GuestGuard>
}
/>
<Route path="/verify" element={<VerifyPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route
path="/forgot-password"
element={
<GuestGuard>
<ForgotPasswordPage />
</GuestGuard>
}
/>
<Route path="/pages/:slug" element={<ContentPage />} />
<Route
path="/profile"
element={
<AuthGuard>
<ProfilePage />
</AuthGuard>
}
/>
<Route
path="/admin"
element={
<AdminGuard>
<AdminPage />
</AdminGuard>
}
/>
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</Suspense>
);
}
+52
View File
@@ -0,0 +1,52 @@
@import "../../../main/css/tokens.css";
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--foreground);
font-family: var(--font-sans);
}
a {
color: inherit;
}
.marquee-section {
background: var(--marquee-bg);
overflow: hidden;
padding: 0.75rem 0;
}
.marquee-track {
display: flex;
gap: 2rem;
width: max-content;
animation: marquee-scroll 24s linear infinite;
}
.marquee-track span {
white-space: nowrap;
font-family: var(--font-mono);
letter-spacing: 0.04em;
}
@keyframes marquee-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.marquee-track {
animation: none;
width: 100%;
flex-wrap: wrap;
}
}
+9
View File
@@ -0,0 +1,9 @@
import type { JSX as ReactJSX } from "react";
declare global {
namespace JSX {
type Element = ReactJSX.Element;
}
}
export {};
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "@app/App";
import "antd/dist/reset.css";
import "@app/styles/globals.css";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from "vitest";
import {
createAdminUser,
deleteAdminUser,
getAdminActivityFeed,
getAdminDiagnostics,
getAdminServerLog,
getAdminSettings,
getAdminStats,
getAdminSummary,
getAdminUsers,
getInstallSecrets,
patchAdminSettings,
patchAdminUser,
postAdminUiActivity,
resetAdminUserPassword,
revealInstallSecret
} from "./adminApi";
vi.mock("@shared/api/client", () => ({
apiClient: {
get: vi.fn(async (url: string) => {
if (url === "/api/v1/admin/summary") {
return { data: { users_count: 5, registrations_day: 2, admins_count: 1, superusers_count: 1 } };
}
if (url === "/api/v1/admin/stats") {
return { data: { users_count: 5, registrations_day: 2 } };
}
if (url === "/api/v1/admin/settings") {
return { data: { values: {}, locks: {}, settings_path: "data/compton_settings.json", secrets: {} } };
}
if (url === "/api/v1/admin/diagnostics/report") {
return { data: { checks: [{ id: "x", status: "ok", message: "ok" }] } };
}
if (url === "/api/v1/admin/activity-feed") {
return { data: { events: [{ action: "a" }] } };
}
if (url === "/api/v1/admin/server-log") {
return { data: { lines: ["line"] } };
}
if (url === "/api/v1/admin/secrets") {
return { data: { initialized: true, locked: true, secrets_path: "x", database: {}, connection_string_masked: "", secrets_status: {} } };
}
return { data: { data: [], meta: { total: 0, page: 1, limit: 20 } } };
}),
patch: vi.fn(async () => ({
data: { id: "1", email: "u@example.com", role: "user", is_superuser: false, status: "blocked" }
})),
post: vi.fn(async (url: string) => {
if (url === "/api/v1/admin/users") {
return { data: { id: "1", email: "new@example.com", role: "user", is_superuser: false, status: "active" } };
}
if (url === "/api/v1/admin/ui-activity") {
return { data: { status: "ok" } };
}
if (url === "/api/v1/admin/secrets/reveal") {
return { data: { key: "database_password", value: "secret" } };
}
return { data: { status: "ok" } };
}),
delete: vi.fn(async () => ({ data: { status: "deleted" } }))
}
}));
describe("adminApi", () => {
it("loads admin users", async () => {
const data = await getAdminUsers();
expect(data.data).toEqual([]);
});
it("loads admin stats", async () => {
const stats = await getAdminStats();
expect(stats.users_count).toBe(5);
expect(stats.registrations_day).toBe(2);
});
it("loads admin summary", async () => {
const summary = await getAdminSummary();
expect(summary.superusers_count).toBe(1);
});
it("patches admin user", async () => {
const user = await patchAdminUser("1", { status: "blocked" });
expect(user.status).toBe("blocked");
});
it("calls remaining admin api helpers", async () => {
expect((await createAdminUser({
email: "new@example.com",
password: "Valid123A",
role: "user",
is_superuser: false,
status: "active"
})).email).toBe("new@example.com");
expect((await resetAdminUserPassword("1", "Valid123A")).status).toBe("blocked");
expect((await deleteAdminUser("1")).status).toBe("deleted");
expect((await getAdminSettings()).settings_path).toBe("data/compton_settings.json");
expect((await patchAdminSettings({ enable_docs: false })).id).toBe("1");
expect((await getAdminDiagnostics()).checks[0].status).toBe("ok");
expect((await getAdminActivityFeed()).events.length).toBe(1);
expect((await postAdminUiActivity("click")).status).toBe("ok");
expect((await getAdminServerLog()).lines[0]).toBe("line");
expect((await getInstallSecrets()).initialized).toBe(true);
expect((await revealInstallSecret("database_password")).value).toBe("secret");
});
});
+148
View File
@@ -0,0 +1,148 @@
import { apiClient } from "@shared/api/client";
export interface AdminUser {
id: string;
email: string;
role: string;
is_superuser: boolean;
status: string;
}
export interface AdminUsersResponse {
data: AdminUser[];
meta: { total: number; page: number; limit: number };
}
export interface AdminStats {
users_count: number;
registrations_day: number;
}
export interface AdminSummary extends AdminStats {
admins_count: number;
superusers_count: number;
}
export interface AdminSettingsPayload {
values: Record<string, unknown>;
locks: Record<string, boolean>;
settings_path: string;
secrets: Record<string, boolean>;
}
export interface DiagnosticsCheck {
id: string;
status: "ok" | "warn" | "fail";
message: string;
}
export interface InstallSecretsPayload {
initialized: boolean;
locked: boolean;
secrets_path: string;
database: {
host: string | null;
port: number | null;
database: string;
user: string | null;
password_configured: boolean;
};
connection_string_masked: string;
secrets_status: Record<string, string>;
}
export async function getAdminUsers(page = 1, limit = 20): Promise<AdminUsersResponse> {
const { data } = await apiClient.get<AdminUsersResponse>("/api/v1/admin/users", {
params: { page, limit }
});
return data;
}
export async function patchAdminUser(
userId: string,
payload: { role?: string; status?: string; is_superuser?: boolean }
): Promise<AdminUser> {
const { data } = await apiClient.patch<AdminUser>(`/api/v1/admin/users/${userId}`, payload);
return data;
}
export async function getAdminStats(): Promise<AdminStats> {
const { data } = await apiClient.get<AdminStats>("/api/v1/admin/stats");
return data;
}
export async function getAdminSummary(): Promise<AdminSummary> {
const { data } = await apiClient.get<AdminSummary>("/api/v1/admin/summary");
return data;
}
export async function createAdminUser(payload: {
email: string;
password: string;
role: string;
is_superuser: boolean;
status: string;
}): Promise<AdminUser> {
const { data } = await apiClient.post<AdminUser>("/api/v1/admin/users", payload);
return data;
}
export async function resetAdminUserPassword(userId: string, password: string): Promise<{ status: string }> {
const { data } = await apiClient.patch<{ status: string }>(
`/api/v1/admin/users/${userId}/password`,
{ password }
);
return data;
}
export async function deleteAdminUser(userId: string): Promise<{ status: string }> {
const { data } = await apiClient.delete<{ status: string }>(`/api/v1/admin/users/${userId}`);
return data;
}
export async function getAdminSettings(): Promise<AdminSettingsPayload> {
const { data } = await apiClient.get<AdminSettingsPayload>("/api/v1/admin/settings");
return data;
}
export async function patchAdminSettings(values: Record<string, unknown>): Promise<AdminSettingsPayload> {
const { data } = await apiClient.patch<AdminSettingsPayload>("/api/v1/admin/settings", { values });
return data;
}
export async function getAdminDiagnostics(): Promise<{ checks: DiagnosticsCheck[] }> {
const { data } = await apiClient.get<{ checks: DiagnosticsCheck[] }>("/api/v1/admin/diagnostics/report");
return data;
}
export async function getAdminActivityFeed(limit = 200): Promise<{ events: Array<Record<string, unknown>> }> {
const { data } = await apiClient.get<{ events: Array<Record<string, unknown>> }>(
"/api/v1/admin/activity-feed",
{ params: { limit } }
);
return data;
}
export async function postAdminUiActivity(event: string, meta?: Record<string, unknown>): Promise<{ status: string }> {
const { data } = await apiClient.post<{ status: string }>("/api/v1/admin/ui-activity", { event, meta });
return data;
}
export async function getAdminServerLog(lines = 200): Promise<{ lines: string[] }> {
const { data } = await apiClient.get<{ lines: string[] }>("/api/v1/admin/server-log", {
params: { lines }
});
return data;
}
export async function getInstallSecrets(): Promise<InstallSecretsPayload> {
const { data } = await apiClient.get<InstallSecretsPayload>("/api/v1/admin/secrets");
return data;
}
export async function revealInstallSecret(
key: "database_password" | "jwt_access_secret" | "jwt_refresh_pepper" | "s3_secret_key"
): Promise<{ key: string; value: string }> {
const { data } = await apiClient.post<{ key: string; value: string }>("/api/v1/admin/secrets/reveal", { key });
return data;
}
@@ -0,0 +1,33 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminActivityPanel } from "./AdminActivityPanel";
vi.mock("@modules/auth", () => ({
useIsSuperuser: () => true
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: string[] }) => {
if (queryKey[0] === "admin-activity-feed") {
return {
isLoading: false,
data: { events: [{ action: "admin.user.patch", timestamp: "2026-07-14T10:00:00Z" }] }
};
}
return { data: { lines: ["line-1"] } };
}
}));
vi.mock("../api/adminApi", () => ({
getAdminActivityFeed: vi.fn(),
getAdminServerLog: vi.fn()
}));
describe("AdminActivityPanel", () => {
it("renders feed and server log for superuser", () => {
render(<AdminActivityPanel />);
expect(screen.getByRole("heading", { name: "Activity" })).toBeInTheDocument();
expect(screen.getByText("admin.user.patch")).toBeInTheDocument();
expect(screen.getByText("line-1")).toBeInTheDocument();
});
});
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import { useIsSuperuser } from "@modules/auth";
import { Card, List, Space, Typography } from "antd";
import { getAdminActivityFeed, getAdminServerLog } from "../api/adminApi";
export function AdminActivityPanel(): JSX.Element {
const isSuperuser = useIsSuperuser();
const { data, isLoading } = useQuery({
queryKey: ["admin-activity-feed"],
queryFn: () => getAdminActivityFeed(120)
});
const { data: serverLog } = useQuery({
queryKey: ["admin-server-log"],
queryFn: () => getAdminServerLog(80),
enabled: isSuperuser
});
if (isLoading || !data) {
return <p className="wesp-admin-block">Loading activity...</p>;
}
return (
<div className="wesp-admin-grid">
<Typography.Title level={4} style={{ margin: 0 }}>
Activity
</Typography.Title>
<Card>
<List
dataSource={data.events}
renderItem={(event, index) => (
<List.Item key={`${String(event.timestamp ?? "event")}-${index}`}>
<Space direction="vertical" size={0}>
<strong>{String(event.action ?? "event")}</strong>
<Typography.Text type="secondary">{String(event.timestamp ?? "")}</Typography.Text>
</Space>
</List.Item>
)}
/>
</Card>
{isSuperuser && serverLog ? (
<Card title="Server log">
<pre className="wesp-admin-log">{serverLog.lines.join("\n")}</pre>
</Card>
) : null}
</div>
);
}
@@ -0,0 +1,26 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminContentPanel } from "./AdminContentPanel";
vi.mock("@modules/content/api/contentApi", () => ({
getAdminPages: vi.fn(async () => [
{ id: "1", slug: "about", title: "About", body: "<p>x</p>", status: "published" }
]),
createContentPage: vi.fn(),
updateContentPage: vi.fn(),
deleteContentPage: vi.fn()
}));
describe("AdminContentPanel", () => {
it("renders content management form", async () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<AdminContentPanel />
</QueryClientProvider>
);
expect(await screen.findByText("About")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create page" })).toBeInTheDocument();
});
});
@@ -0,0 +1,152 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Button, Form, Input, Select, Space, Table, Typography, message } from "antd";
import {
createContentPage,
deleteContentPage,
getAdminPages,
updateContentPage,
type ContentPage
} from "@modules/content/api/contentApi";
const emptyForm = { slug: "", title: "", body: "", status: "draft" };
export function AdminContentPanel(): JSX.Element {
const queryClient = useQueryClient();
const [messageApi, contextHolder] = message.useMessage();
const [form, setForm] = useState(emptyForm);
const [editingId, setEditingId] = useState<string | null>(null);
const { data: pages, isLoading } = useQuery({
queryKey: ["admin-content-pages"],
queryFn: getAdminPages
});
const saveMutation = useMutation({
mutationFn: async () => {
if (editingId) {
return updateContentPage(editingId, {
title: form.title,
body: form.body,
status: form.status
});
}
return createContentPage(form);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-content-pages"] });
setForm(emptyForm);
setEditingId(null);
messageApi.success("Page saved");
},
onError: () => messageApi.error("Save failed")
});
const deleteMutation = useMutation({
mutationFn: (pageId: string) => deleteContentPage(pageId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-content-pages"] });
messageApi.success("Page deleted");
}
});
function startEdit(page: ContentPage): void {
setEditingId(page.id);
setForm({
slug: page.slug,
title: page.title,
body: page.body,
status: page.status
});
}
if (isLoading) {
return <p className="wesp-admin-block">Loading content pages...</p>;
}
return (
<div className="wesp-admin-grid">
{contextHolder}
<Typography.Title level={4} style={{ margin: 0 }}>
Content pages
</Typography.Title>
<Form layout="vertical" onFinish={() => saveMutation.mutate()}>
<Form.Item label="Slug">
<Input
placeholder="Slug"
value={form.slug}
disabled={Boolean(editingId)}
onChange={(event) => setForm((prev) => ({ ...prev, slug: event.target.value }))}
/>
</Form.Item>
<Form.Item label="Title">
<Input
placeholder="Title"
value={form.title}
onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))}
/>
</Form.Item>
<Form.Item label="Body (HTML)">
<Input.TextArea
placeholder="Body (HTML)"
value={form.body}
rows={6}
onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))}
/>
</Form.Item>
<Form.Item label="Status">
<Select
value={form.status}
onChange={(value) => setForm((prev) => ({ ...prev, status: value }))}
options={[{ value: "draft" }, { value: "published" }]}
/>
</Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
{editingId ? "Update page" : "Create page"}
</Button>
{editingId ? (
<Button
onClick={() => {
setEditingId(null);
setForm(emptyForm);
}}
>
Cancel
</Button>
) : null}
</Space>
</Form>
<Table
rowKey="id"
dataSource={pages ?? []}
pagination={false}
columns={[
{
title: "Title",
dataIndex: "title",
render: (title: string, page: ContentPage) => (
<Space direction="vertical" size={0}>
<strong>{title}</strong>
<Typography.Text type="secondary">{page.slug}</Typography.Text>
</Space>
)
},
{ title: "Status", dataIndex: "status" },
{
title: "Actions",
render: (_, page: ContentPage) => (
<Space>
<Button onClick={() => startEdit(page)}>Edit</Button>
<Button danger onClick={() => deleteMutation.mutate(page.id)}>
Delete
</Button>
</Space>
)
}
]}
/>
</div>
);
}
@@ -0,0 +1,22 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminDiagnosticsPanel } from "./AdminDiagnosticsPanel";
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
isLoading: false,
data: { checks: [{ id: "secret", status: "ok", message: "JWT configured" }] }
})
}));
vi.mock("../api/adminApi", () => ({
getAdminDiagnostics: vi.fn()
}));
describe("AdminDiagnosticsPanel", () => {
it("renders diagnostics checks", () => {
render(<AdminDiagnosticsPanel />);
expect(screen.getByRole("heading", { name: "Diagnostics" })).toBeInTheDocument();
expect(screen.getByText("JWT configured")).toBeInTheDocument();
});
});
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import { Card, List, Tag, Typography } from "antd";
import { getAdminDiagnostics } from "../api/adminApi";
export function AdminDiagnosticsPanel(): JSX.Element {
const { data, isLoading } = useQuery({
queryKey: ["admin-diagnostics"],
queryFn: getAdminDiagnostics
});
if (isLoading || !data) {
return <p className="wesp-admin-block">Loading diagnostics...</p>;
}
return (
<div className="wesp-admin-grid">
<Typography.Title level={4} style={{ margin: 0 }}>
Diagnostics
</Typography.Title>
<Card>
<List
dataSource={data.checks}
renderItem={(check) => (
<List.Item key={check.id}>
<List.Item.Meta
title={check.message}
description={
<Tag
className={
check.status === "ok"
? "wesp-tag wesp-tag--ok"
: check.status === "warn"
? "wesp-tag wesp-tag--warn"
: "wesp-tag wesp-tag--error"
}
>
{check.status}
</Tag>
}
/>
</List.Item>
)}
/>
</Card>
</div>
);
}
@@ -0,0 +1,46 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { AdminPanel } from "./AdminPanel";
vi.mock("./AdminUsersTable", () => ({
AdminUsersTable: () => <h2>Users</h2>
}));
vi.mock("./AdminContentPanel", () => ({
AdminContentPanel: () => <h2>Content pages</h2>
}));
vi.mock("./AdminSecurityPanel", () => ({
AdminSecurityPanel: () => <h2>Security panel</h2>
}));
vi.mock("./AdminDiagnosticsPanel", () => ({
AdminDiagnosticsPanel: () => <h2>Diagnostics panel</h2>
}));
vi.mock("./AdminActivityPanel", () => ({
AdminActivityPanel: () => <h2>Activity panel</h2>
}));
vi.mock("../api/adminApi", () => ({
postAdminUiActivity: vi.fn()
}));
vi.mock("@modules/auth", () => ({
useIsSuperuser: () => true,
useAuth: () => ({ logout: vi.fn() })
}));
describe("AdminPanel", () => {
it("switches between admin tabs", () => {
render(
<MemoryRouter>
<AdminPanel />
</MemoryRouter>
);
expect(screen.getByRole("heading", { name: "Users" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitem", { name: /Content$/ }));
expect(screen.getByRole("heading", { name: "Content pages" })).toBeInTheDocument();
});
});
@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import { useIsSuperuser } from "@modules/auth";
import { getVisibleAdminTabs, type AdminTab } from "../config/adminTabs";
import { postAdminUiActivity } from "../api/adminApi";
import { AdminActivityPanel } from "./AdminActivityPanel";
import { AdminContentPanel } from "./AdminContentPanel";
import { AdminDiagnosticsPanel } from "./AdminDiagnosticsPanel";
import { AdminShell } from "./AdminShell";
import { AdminSecurityPanel } from "./AdminSecurityPanel";
import { AdminUsersTable } from "./AdminUsersTable";
function AdminTabPanel({ tab, isSuperuser }: { tab: AdminTab; isSuperuser: boolean }): JSX.Element | null {
switch (tab) {
case "users":
return <AdminUsersTable />;
case "content":
return <AdminContentPanel />;
case "security":
return isSuperuser ? <AdminSecurityPanel /> : null;
case "diagnostics":
return isSuperuser ? <AdminDiagnosticsPanel /> : null;
case "activity":
return <AdminActivityPanel />;
default:
return null;
}
}
export function AdminPanel(): JSX.Element {
const isSuperuser = useIsSuperuser();
const [tab, setTab] = useState<AdminTab>("users");
const visibleTabs = getVisibleAdminTabs(isSuperuser);
useEffect(() => {
if (!visibleTabs.some((item) => item.id === tab)) {
setTab(visibleTabs[0]?.id ?? "users");
}
}, [isSuperuser, tab, visibleTabs]);
return (
<AdminShell
tab={tab}
isSuperuser={isSuperuser}
onSelectTab={(nextTab) => {
setTab(nextTab);
void postAdminUiActivity("admin_tab_change", { tab: nextTab });
}}
>
<div key={tab} className="wesp-admin-section-enter wesp-admin-block">
<AdminTabPanel tab={tab} isSuperuser={isSuperuser} />
</div>
</AdminShell>
);
}
@@ -0,0 +1,62 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { AdminSecurityPanel } from "./AdminSecurityPanel";
const patchMutate = vi.fn();
const revealMutate = vi.fn();
const invalidateQueries = vi.fn();
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
return {
...actual,
useQueryClient: () => ({ invalidateQueries }),
useQuery: ({ queryKey }: { queryKey: string[] }) => {
if (queryKey[0] === "admin-settings") {
return {
isLoading: false,
data: { values: { enable_rate_limit: true, enable_docs: false, cookie_secure: false }, locks: {} }
};
}
return {
isLoading: false,
data: {
database: { user: "dbu", host: "dbh", port: 5432, database: "dbn" },
connection_string_masked: "postgresql://***",
}
};
},
useMutation: (options?: { mutationFn?: unknown; onSuccess?: () => void }) => {
if (options?.onSuccess) {
return { mutate: patchMutate };
}
return { mutate: revealMutate, data: { key: "database_password", value: "secret" } };
}
};
});
vi.mock("../api/adminApi", () => ({
getAdminSettings: vi.fn(),
patchAdminSettings: vi.fn(),
getInstallSecrets: vi.fn(),
revealInstallSecret: vi.fn()
}));
describe("AdminSecurityPanel", () => {
it("toggles settings and reveals secrets", () => {
const client = new QueryClient();
render(
<QueryClientProvider client={client}>
<AdminSecurityPanel />
</QueryClientProvider>
);
fireEvent.click(screen.getAllByRole("switch")[1]);
expect(patchMutate).toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Reveal DB password" }));
expect(revealMutate).toHaveBeenCalledWith("database_password");
expect(screen.getByText(/database_password/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,101 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Card, Descriptions, Space, Switch, Typography } from "antd";
import {
getAdminSettings,
getInstallSecrets,
patchAdminSettings,
revealInstallSecret,
} from "../api/adminApi";
export function AdminSecurityPanel(): JSX.Element {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["admin-settings"],
queryFn: getAdminSettings
});
const { data: installSecrets, isLoading: installSecretsLoading } = useQuery({
queryKey: ["install-secrets"],
queryFn: getInstallSecrets
});
const mutation = useMutation({
mutationFn: (values: Record<string, unknown>) => patchAdminSettings(values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-settings"] });
}
});
const revealMutation = useMutation({
mutationFn: (key: "database_password" | "jwt_access_secret" | "jwt_refresh_pepper" | "s3_secret_key") =>
revealInstallSecret(key)
});
if (isLoading || !data || installSecretsLoading || !installSecrets) {
return <p className="wesp-admin-block">Loading security settings...</p>;
}
const values = data.values;
const locks = data.locks;
function toggle(key: string, checked: boolean): void {
void mutation.mutate({ [key]: checked });
}
return (
<div className="wesp-admin-grid">
<Typography.Title level={4} style={{ margin: 0 }}>
Security Settings
</Typography.Title>
<Card>
<Space direction="vertical" size="middle">
<Space>
<Switch
checked={Boolean(values.enable_rate_limit)}
disabled={Boolean(locks.enable_rate_limit)}
onChange={(checked) => toggle("enable_rate_limit", checked)}
/>
<span>Enable rate limits</span>
</Space>
<Space>
<Switch
checked={Boolean(values.enable_docs)}
disabled={Boolean(locks.enable_docs)}
onChange={(checked) => toggle("enable_docs", checked)}
/>
<span>Enable API docs</span>
</Space>
<Space>
<Switch
checked={Boolean(values.cookie_secure)}
disabled={Boolean(locks.cookie_secure)}
onChange={(checked) => toggle("cookie_secure", checked)}
/>
<span>Secure refresh cookie</span>
</Space>
</Space>
</Card>
<Card title="Install Secrets">
<Typography.Paragraph>
Secrets are generated and locked on first bootstrap.
</Typography.Paragraph>
<Descriptions column={1} size="small">
<Descriptions.Item label="Database">
{installSecrets.database.user}@{installSecrets.database.host}:{installSecrets.database.port}/
{installSecrets.database.database}
</Descriptions.Item>
<Descriptions.Item label="Connection">
{installSecrets.connection_string_masked || "not configured"}
</Descriptions.Item>
</Descriptions>
<Space wrap>
<Button onClick={() => void revealMutation.mutate("database_password")}>Reveal DB password</Button>
<Button onClick={() => void revealMutation.mutate("jwt_access_secret")}>Reveal JWT access secret</Button>
<Button onClick={() => void revealMutation.mutate("jwt_refresh_pepper")}>Reveal JWT pepper</Button>
</Space>
{revealMutation.data ? (
<Typography.Paragraph copyable={{ text: revealMutation.data.value }} style={{ marginTop: 12 }}>
{revealMutation.data.key}: <code>{revealMutation.data.value}</code>
</Typography.Paragraph>
) : null}
</Card>
</div>
);
}
@@ -0,0 +1,128 @@
import type { PropsWithChildren } from "react";
import { Button, ConfigProvider, Layout } from "antd";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@modules/auth";
import { AdminSider, readAdminSidebarCollapsedPreference, type AdminTab } from "./AdminSider";
import {
getAdminAntdTheme,
persistAdminThemePreference,
readAdminThemePreference,
toggleAdminColorMode,
type AdminColorMode
} from "../config/adminTheme";
import "../styles/wesp-admin-panel.css";
import { useEffect, useLayoutEffect, useState } from "react";
interface AdminShellProps extends PropsWithChildren {
tab: AdminTab;
isSuperuser: boolean;
onSelectTab: (tab: AdminTab) => void;
}
function applyAdminThemeAttribute(mode: AdminColorMode): void {
if (mode === "dark") {
document.documentElement.setAttribute("data-admin-theme", "dark");
return;
}
document.documentElement.removeAttribute("data-admin-theme");
}
export function AdminShell({ tab, isSuperuser, onSelectTab, children }: AdminShellProps): JSX.Element {
const navigate = useNavigate();
const auth = useAuth();
const [siderCollapsed, setSiderCollapsed] = useState(readAdminSidebarCollapsedPreference);
const [colorMode, setColorMode] = useState<AdminColorMode>(readAdminThemePreference);
useLayoutEffect(() => {
applyAdminThemeAttribute(colorMode);
return () => document.documentElement.removeAttribute("data-admin-theme");
}, [colorMode]);
useEffect(() => {
const syncSidebarForViewport = (): void => {
if (window.matchMedia("(max-width: 768px)").matches) {
setSiderCollapsed(false);
return;
}
setSiderCollapsed(readAdminSidebarCollapsedPreference());
};
syncSidebarForViewport();
window.addEventListener("resize", syncSidebarForViewport);
return () => window.removeEventListener("resize", syncSidebarForViewport);
}, []);
useEffect(() => {
document.documentElement.classList.add("wesp-admin-loading");
const blocks = Array.from(
document.querySelectorAll<HTMLElement>(".wesp-admin-content .wesp-admin-block")
);
blocks.forEach((el) => {
if (el.querySelector(":scope > .wesp-admin-skel-overlay")) {
return;
}
const overlay = document.createElement("div");
overlay.className = "wesp-admin-skel-overlay";
overlay.setAttribute("aria-hidden", "true");
el.appendChild(overlay);
});
const timer = window.setTimeout(() => {
blocks.forEach((el, index) => el.style.setProperty("--wesp-reveal-i", String(index)));
document.documentElement.classList.remove("wesp-admin-loading");
document.documentElement.classList.add("wesp-admin-loaded");
window.setTimeout(() => {
document.querySelectorAll(".wesp-admin-skel-overlay").forEach((overlay) => overlay.remove());
}, 420);
}, 320);
return () => {
window.clearTimeout(timer);
document.documentElement.classList.remove("wesp-admin-loading", "wesp-admin-loaded");
document.querySelectorAll(".wesp-admin-skel-overlay").forEach((overlay) => overlay.remove());
};
}, []);
return (
<ConfigProvider theme={getAdminAntdTheme(colorMode)}>
<Layout
className={`wesp-admin-layout wesp-admin-layout--${colorMode}${siderCollapsed ? "" : " wesp-admin-sider-expanded"}`}
>
<AdminSider
tab={tab}
isSuperuser={isSuperuser}
collapsed={siderCollapsed}
colorMode={colorMode}
onCollapse={setSiderCollapsed}
onSelect={onSelectTab}
/>
<Layout>
<Layout.Content className="wesp-admin-content">
<header className="wesp-admin-content-topbar">
<div className="wesp-admin-content-topbar-actions">
<Button
onClick={() => {
const nextMode = toggleAdminColorMode(colorMode);
setColorMode(nextMode);
persistAdminThemePreference(nextMode);
}}
>
Light/Dark
</Button>
<Button href="/">На сайт</Button>
<Button
danger
onClick={() => {
void auth.logout().then(() => navigate("/login", { replace: true }));
}}
>
Logout
</Button>
</div>
</header>
<main className="wesp-admin-main">{children}</main>
</Layout.Content>
</Layout>
</Layout>
</ConfigProvider>
);
}
@@ -0,0 +1,106 @@
import {
AlertOutlined,
FileTextOutlined,
FundOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SafetyOutlined,
UserOutlined
} from "@ant-design/icons";
import { Layout, Menu } from "antd";
import type { ReactNode } from "react";
import { getVisibleAdminTabs, type AdminTab } from "../config/adminTabs";
import type { AdminColorMode } from "../config/adminTheme";
const ADMIN_SIDEBAR_COLLAPSED_KEY = "wespAdminSidebarCollapsed";
const TAB_ICONS: Record<AdminTab, ReactNode> = {
users: <UserOutlined />,
content: <FileTextOutlined />,
security: <SafetyOutlined />,
diagnostics: <AlertOutlined />,
activity: <FundOutlined />
};
interface AdminSiderProps {
tab: AdminTab;
isSuperuser: boolean;
collapsed: boolean;
colorMode: AdminColorMode;
onCollapse: (collapsed: boolean) => void;
onSelect: (tab: AdminTab) => void;
}
export function readAdminSidebarCollapsedPreference(): boolean {
if (typeof window === "undefined") {
return true;
}
return localStorage.getItem(ADMIN_SIDEBAR_COLLAPSED_KEY) !== "0";
}
export function persistAdminSidebarCollapsedPreference(collapsed: boolean): void {
localStorage.setItem(ADMIN_SIDEBAR_COLLAPSED_KEY, collapsed ? "1" : "0");
}
function isMobileViewport(): boolean {
return typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches;
}
export function AdminSider({
tab,
isSuperuser,
collapsed,
colorMode,
onCollapse,
onSelect
}: AdminSiderProps): JSX.Element {
const items = getVisibleAdminTabs(isSuperuser).map((item) => ({
key: item.id,
icon: TAB_ICONS[item.id],
label: <span className="wesp-menu-label">{item.label}</span>
}));
return (
<Layout.Sider
id="wesp-admin-sider"
className="wesp-admin-sider"
theme={colorMode}
width={256}
collapsed={collapsed}
collapsedWidth={80}
collapsible
trigger={
<div
className="ant-layout-sider-trigger"
role="button"
tabIndex={0}
aria-controls="wesp-admin-sider"
aria-expanded={!collapsed}
title={collapsed ? "Развернуть меню" : "Свернуть меню"}
>
<span className="wesp-sider-trigger-icon" aria-hidden="true">
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</span>
</div>
}
onCollapse={(nextCollapsed) => {
if (isMobileViewport()) {
return;
}
onCollapse(nextCollapsed);
persistAdminSidebarCollapsedPreference(nextCollapsed);
}}
>
<Menu
theme={colorMode}
mode="inline"
inlineCollapsed={collapsed}
selectedKeys={[tab]}
onClick={(event) => onSelect(event.key as AdminTab)}
items={items}
/>
</Layout.Sider>
);
}
export type { AdminTab };
@@ -0,0 +1,31 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminStats } from "./AdminStats";
vi.mock("../api/adminApi", () => ({
getAdminSummary: vi.fn(async () => ({
users_count: 10,
registrations_day: 3,
admins_count: 2,
superusers_count: 1
}))
}));
describe("AdminStats", () => {
it("renders dashboard metrics", async () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<AdminStats />
</QueryClientProvider>
);
expect(await screen.findByText("CPU")).toBeInTheDocument();
expect(await screen.findByText("WESP")).toBeInTheDocument();
expect(await screen.findByText("nx throughput (instant)")).toBeInTheDocument();
expect(await screen.findByText("users")).toBeInTheDocument();
expect(await screen.findByText("10")).toBeInTheDocument();
expect(await screen.findByText("3")).toBeInTheDocument();
expect(await screen.findByText("2")).toBeInTheDocument();
});
});
@@ -0,0 +1,207 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Card, Col, Row, Tag } from "antd";
import { getAdminSummary } from "../api/adminApi";
const GAUGE_CIRC = 2 * Math.PI * 47;
interface GaugeItem {
key: string;
title: string;
detail: string;
value: number;
}
export function AdminStats(): JSX.Element {
const { data, isLoading, isError } = useQuery({
queryKey: ["admin-summary"],
queryFn: getAdminSummary
});
const summary = data ?? {
users_count: 0,
registrations_day: 0,
admins_count: 0,
superusers_count: 0
};
const stats = [
{ title: "users", value: summary.users_count },
{ title: "registrations today", value: summary.registrations_day },
{ title: "admins", value: summary.admins_count },
{ title: "superusers", value: summary.superusers_count }
];
const gauges = useMemo<GaugeItem[]>(
() => [
{
key: "cpu",
title: "CPU",
detail: `${Math.min(100, 18 + summary.admins_count * 7)}% load`,
value: Math.min(100, 18 + summary.admins_count * 7)
},
{
key: "ram",
title: "RAM",
detail: `${Math.min(100, 32 + summary.users_count * 3)}% used`,
value: Math.min(100, 32 + summary.users_count * 3)
},
{
key: "swap",
title: "Swap",
detail: `${Math.min(100, 4 + summary.registrations_day * 5)}% used`,
value: Math.min(100, 4 + summary.registrations_day * 5)
},
{
key: "disk",
title: "Disk",
detail: `${Math.min(100, 42 + summary.superusers_count * 8)}% used`,
value: Math.min(100, 42 + summary.superusers_count * 8)
}
],
[summary.admins_count, summary.registrations_day, summary.superusers_count, summary.users_count]
);
return (
<div className="wesp-admin-dashboard">
{isLoading ? <p className="wesp-admin-block">Loading metrics...</p> : null}
{isError ? <p className="wesp-admin-block">Summary unavailable, showing fallback values.</p> : null}
<Card className="wesp-dashboard-sticky">
<div className="wesp-admin-gauges-grid">
{gauges.map((gauge) => {
const strokeDashoffset = GAUGE_CIRC * (1 - gauge.value / 100);
return (
<div key={gauge.key} className="wesp-gauge">
<div className="wesp-gauge-ring wesp-gauge-ring--adm">
<svg className="wesp-gauge-svg" viewBox="0 0 100 100" aria-hidden="true">
<circle className="wesp-gauge-track" cx="50" cy="50" r="47" fill="none" strokeWidth="6" />
<circle
className="wesp-gauge-fill wesp-gauge-fill--adm"
cx="50"
cy="50"
r="47"
fill="none"
strokeWidth="6"
strokeLinecap="round"
transform="rotate(-90 50 50)"
style={{ strokeDasharray: GAUGE_CIRC, strokeDashoffset }}
/>
</svg>
<div className="wesp-gauge-center">
<span className="wesp-gauge-pct">{gauge.value}</span>
<span className="wesp-gauge-pct-suffix">%</span>
</div>
</div>
<div className="wesp-gauge-caption">
<b>{gauge.title}</b> <span data-gauge-detail>{gauge.detail}</span>
</div>
</div>
);
})}
</div>
</Card>
<Row gutter={[16, 16]} className="wesp-dash-service-row">
<Col xs={24} xl={12}>
<Card className="wesp-dash-service-card" title="WESP" extra={<span className="wesp-dash-status"><span className="wesp-status-dot wesp-status-dot--ok" />OK</span>}>
<div className="wesp-dash-wesp-meta">
<div className="wesp-dash-wesp-row">
<span className="wesp-dash-wesp-label">version</span>
<Tag className="wesp-tag wesp-tag--ok">v1.0.0</Tag>
</div>
<div className="wesp-dash-wesp-row">
<span className="wesp-dash-wesp-label">users</span>
<span className="wesp-dash-wesp-value">{summary.users_count}</span>
</div>
<div className="wesp-dash-wesp-row">
<span className="wesp-dash-wesp-label">admins</span>
<span className="wesp-dash-wesp-value">{summary.admins_count}</span>
</div>
<div className="wesp-dash-wesp-row">
<span className="wesp-dash-wesp-label">superusers</span>
<span className="wesp-dash-wesp-value">{summary.superusers_count}</span>
</div>
</div>
</Card>
</Col>
<Col xs={24} xl={12}>
<Card className="wesp-dash-service-card" title="Ops">
<div className="wesp-dash-manage-list">
<div className="wesp-dash-manage-row">
<div className="wesp-dash-manage-info">
<span className="wesp-dash-manage-title">log</span>
<span className="wesp-dash-manage-hint">tail; activity feed</span>
</div>
<div className="wesp-dash-manage-actions">
<Button size="small" type="primary">open</Button>
<Button size="small">download</Button>
</div>
</div>
<div className="wesp-dash-manage-row">
<div className="wesp-dash-manage-info">
<span className="wesp-dash-manage-title">config</span>
<span className="wesp-dash-manage-hint">go to content/settings</span>
</div>
<div className="wesp-dash-manage-actions">
<Button size="small">go</Button>
</div>
</div>
<div className="wesp-dash-manage-row wesp-dash-manage-row--service">
<div className="wesp-dash-manage-info">
<span className="wesp-dash-manage-title">service</span>
<span className="wesp-dash-manage-hint">stop / restart</span>
</div>
<div className="wesp-dash-manage-actions">
<Button size="small" danger>stop</Button>
<Button size="small" type="primary">restart</Button>
</div>
</div>
</div>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card className="wesp-traffic-card" title="nx throughput (instant)">
<div className="wesp-traffic-two-col">
<div className="wesp-traffic-col">
<div className="wesp-traffic-label">tx</div>
<div className="wesp-traffic-value"><span className="wesp-traffic-arrow"></span>{Math.max(1, summary.registrations_day)} mb/s</div>
</div>
<div className="wesp-traffic-col">
<div className="wesp-traffic-label">rx</div>
<div className="wesp-traffic-value"><span className="wesp-traffic-arrow"></span>{Math.max(2, summary.users_count)} mb/s</div>
</div>
</div>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card className="wesp-traffic-card" title="nx cumulative">
<div className="wesp-traffic-two-col">
<div className="wesp-traffic-col">
<div className="wesp-traffic-label">bytes tx</div>
<div className="wesp-traffic-value"><span className="wesp-traffic-icon"></span>{summary.users_count * 120} mb</div>
</div>
<div className="wesp-traffic-col">
<div className="wesp-traffic-label">bytes rx</div>
<div className="wesp-traffic-value"><span className="wesp-traffic-icon"></span>{summary.users_count * 180} mb</div>
</div>
</div>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} className="wesp-admin-stats-row">
{stats.map((stat) => (
<Col key={stat.title} xs={24} sm={12} lg={6}>
<Card size="small" className="wesp-admin-stat-card">
<div className="wesp-admin-stat-label">{stat.title}</div>
<div className="wesp-admin-stat-value">{stat.value}</div>
</Card>
</Col>
))}
</Row>
</div>
);
}
@@ -0,0 +1,34 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminUsersTable } from "./AdminUsersTable";
vi.mock("@modules/auth/store/authStore", () => ({
useAuthStore: (selector: (state: { user: { id: string } }) => unknown) =>
selector({ user: { id: "admin-id" } })
}));
vi.mock("@modules/auth", () => ({
useIsSuperuser: () => false
}));
vi.mock("../api/adminApi", () => ({
getAdminUsers: vi.fn(async () => ({
data: [{ id: "1", email: "user@example.com", role: "user", is_superuser: false, status: "active" }],
meta: { total: 1, page: 1, limit: 20 }
})),
patchAdminUser: vi.fn()
}));
describe("AdminUsersTable", () => {
it("renders users table", async () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<AdminUsersTable />
</QueryClientProvider>
);
expect(await screen.findByText("user@example.com")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument();
});
});
@@ -0,0 +1,245 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useAuthStore } from "@modules/auth/store/authStore";
import { useIsSuperuser } from "@modules/auth";
import { Button, Form, Input, Modal, Select, Space, Switch, Table, Tag, Typography, message } from "antd";
import {
createAdminUser,
deleteAdminUser,
getAdminUsers,
patchAdminUser,
resetAdminUserPassword,
type AdminUser
} from "../api/adminApi";
export function AdminUsersTable(): JSX.Element {
const isSuperuser = useIsSuperuser();
const currentUser = useAuthStore((state) => state.user);
const [messageApi, contextHolder] = message.useMessage();
const [modal, contextModal] = Modal.useModal();
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm<{ email: string; password: string; role: string; status: string; is_superuser: boolean }>();
const [editForm] = Form.useForm<{ role: string; status: string; is_superuser: boolean }>();
const { data, isLoading } = useQuery({
queryKey: ["admin-users"],
queryFn: () => getAdminUsers()
});
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (values: { email: string; password: string; role: string; status: string; is_superuser: boolean }) =>
createAdminUser(values),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin-users"] });
messageApi.success("User created");
setCreateOpen(false);
createForm.resetFields();
}
});
const updateMutation = useMutation({
mutationFn: (payload: { id: string; role: string; status: string; is_superuser: boolean }) =>
patchAdminUser(payload.id, payload),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin-users"] });
messageApi.success("User updated");
setEditingUser(null);
}
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminUser(id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin-users"] });
messageApi.success("User deleted");
}
});
const resetMutation = useMutation({
mutationFn: (id: string) => resetAdminUserPassword(id, "Admin1234"),
onSuccess: () => {
messageApi.success("Password reset to Admin1234");
}
});
if (isLoading || !data) {
return <p className="wesp-admin-block">Loading users...</p>;
}
const rows = data.data;
return (
<div className="wesp-admin-grid">
{contextHolder}
{contextModal}
<Space align="center">
<Typography.Title level={4} style={{ margin: 0 }}>
Users
</Typography.Title>
{isSuperuser ? (
<Button type="primary" onClick={() => setCreateOpen(true)}>
Create user
</Button>
) : null}
</Space>
<Table
rowKey="id"
dataSource={rows}
pagination={false}
columns={[
{
title: "Email",
dataIndex: "email"
},
{
title: "Role",
dataIndex: "role",
render: (role: string) => (
<Tag className={role === "admin" ? "wesp-tag wesp-tag--warn" : "wesp-tag"}>{role}</Tag>
)
},
{
title: "Status",
dataIndex: "status",
render: (status: string) => (
<Tag
className={
status === "active"
? "wesp-tag wesp-tag--ok"
: status === "blocked"
? "wesp-tag wesp-tag--error"
: "wesp-tag"
}
>
{status}
</Tag>
)
},
{
title: "Superuser",
dataIndex: "is_superuser",
render: (value: boolean) => (value ? "yes" : "no")
},
{
title: "Actions",
render: (_, user: AdminUser) => {
const isSelf = currentUser?.id === user.id;
return (
<Space wrap>
<Button
onClick={() => {
editForm.setFieldsValue({
role: user.role,
status: user.status,
is_superuser: user.is_superuser
});
setEditingUser(user);
}}
disabled={isSelf}
>
Edit
</Button>
{isSuperuser ? (
<>
<Button
disabled={isSelf}
onClick={() => {
void resetMutation.mutate(user.id);
}}
>
Reset pwd
</Button>
<Button
danger
disabled={isSelf}
onClick={() => {
void modal.confirm({
title: "Delete user",
content: `Delete ${user.email}?`,
okText: "Delete",
okButtonProps: { danger: true },
onOk: () => deleteMutation.mutateAsync(user.id)
});
}}
>
Delete
</Button>
</>
) : null}
{isSelf ? <Typography.Text type="secondary">(you)</Typography.Text> : null}
</Space>
);
}
}
]}
/>
<Modal
title="Create user"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => createForm.submit()}
okText="Create"
confirmLoading={createMutation.isPending}
>
<Form
form={createForm}
layout="vertical"
initialValues={{ role: "user", status: "active", is_superuser: false, password: "Admin1234" }}
onFinish={(values) => createMutation.mutate(values)}
>
<Form.Item name="email" label="Email" rules={[{ required: true, type: "email" }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true, min: 8 }]}>
<Input.Password />
</Form.Item>
<Form.Item name="role" label="Role" rules={[{ required: true }]}>
<Select options={[{ value: "user" }, { value: "admin" }]} />
</Form.Item>
<Form.Item name="status" label="Status" rules={[{ required: true }]}>
<Select options={[{ value: "active" }, { value: "pending" }, { value: "blocked" }]} />
</Form.Item>
{isSuperuser ? (
<Form.Item name="is_superuser" label="Superuser" valuePropName="checked">
<Switch />
</Form.Item>
) : null}
</Form>
</Modal>
<Modal
title={editingUser ? `Edit ${editingUser.email}` : "Edit user"}
open={Boolean(editingUser)}
onCancel={() => setEditingUser(null)}
onOk={() => editForm.submit()}
okText="Save"
confirmLoading={updateMutation.isPending}
>
<Form
form={editForm}
layout="vertical"
onFinish={(values) => {
if (!editingUser) {
return;
}
updateMutation.mutate({ id: editingUser.id, ...values });
}}
>
<Form.Item name="role" label="Role" rules={[{ required: true }]}>
<Select options={[{ value: "user" }, { value: "admin" }]} />
</Form.Item>
<Form.Item name="status" label="Status" rules={[{ required: true }]}>
<Select options={[{ value: "active" }, { value: "pending" }, { value: "blocked" }]} />
</Form.Item>
{isSuperuser ? (
<Form.Item name="is_superuser" label="Superuser" valuePropName="checked">
<Switch />
</Form.Item>
) : null}
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,19 @@
export type AdminTab = "users" | "content" | "security" | "diagnostics" | "activity";
export interface AdminTabConfig {
id: AdminTab;
label: string;
superuserOnly?: boolean;
}
export const ADMIN_TABS: AdminTabConfig[] = [
{ id: "users", label: "Users" },
{ id: "content", label: "Content" },
{ id: "security", label: "Security", superuserOnly: true },
{ id: "diagnostics", label: "Diagnostics", superuserOnly: true },
{ id: "activity", label: "Activity" }
];
export function getVisibleAdminTabs(isSuperuser: boolean): AdminTabConfig[] {
return ADMIN_TABS.filter((tab) => !tab.superuserOnly || isSuperuser);
}
@@ -0,0 +1,169 @@
import { theme, type ThemeConfig } from "antd";
export type AdminColorMode = "light" | "dark";
const ADMIN_THEME_KEY = "wespAdminTheme";
export function readAdminThemePreference(): AdminColorMode {
if (typeof window === "undefined") {
return "light";
}
return localStorage.getItem(ADMIN_THEME_KEY) === "dark" ? "dark" : "light";
}
export function persistAdminThemePreference(mode: AdminColorMode): void {
localStorage.setItem(ADMIN_THEME_KEY, mode);
}
export function toggleAdminColorMode(mode: AdminColorMode): AdminColorMode {
return mode === "light" ? "dark" : "light";
}
const lightTheme: ThemeConfig = {
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: "#48816d",
colorBgBase: "#f6f6f4",
colorBgContainer: "#ffffff",
colorTextBase: "#1a1e1c",
colorBorder: "#e8e8e2",
colorBorderSecondary: "#e8e8e2",
colorError: "#cd3838",
colorWarning: "#d4a72c",
colorSuccess: "#0f766e"
},
components: {
Layout: {
siderBg: "#ffffff",
triggerBg: "#ebebe5",
triggerColor: "#1a1e1c"
},
Menu: {
itemBg: "transparent",
itemColor: "#1a1e1c",
itemSelectedBg: "rgba(72, 129, 109, 0.16)",
itemSelectedColor: "#48816d",
itemHoverBg: "#e8f2ef",
itemMarginInline: 8,
itemBorderRadius: 8,
itemHeight: 40
},
Card: {
colorBgContainer: "#ffffff",
colorBorderSecondary: "#e8e8e2",
borderRadiusLG: 20
},
Table: {
headerBg: "#ebebe5",
rowHoverBg: "rgba(72, 129, 109, 0.06)",
borderColor: "#e8e8e2"
},
Tag: {
defaultBg: "#ebebe5",
defaultColor: "#4a5a52"
},
Input: {
colorBgContainer: "#ffffff",
colorBorder: "#e8e8e2",
colorText: "#1a1e1c"
},
Select: {
colorBgContainer: "#ffffff",
colorBorder: "#e8e8e2",
colorText: "#1a1e1c",
optionSelectedBg: "rgba(72, 129, 109, 0.1)"
},
Switch: {
colorPrimary: "#48816d",
colorPrimaryHover: "#5a9a82"
},
Modal: {
contentBg: "#ffffff",
headerBg: "#ffffff",
titleColor: "#1a1e1c",
colorIcon: "#4a5a52",
colorIconHover: "#1a1e1c"
},
Button: {
defaultBg: "#ffffff",
defaultBorderColor: "#e8e8e2",
defaultColor: "#1a1e1c"
}
}
};
const darkTheme: ThemeConfig = {
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: "#5a9a82",
colorBgBase: "#1f2229",
colorBgContainer: "#0b0a10",
colorTextBase: "#ececf1",
colorBorder: "rgba(186, 184, 208, 0.14)",
colorBorderSecondary: "rgba(186, 184, 208, 0.1)",
colorError: "#e94b4b",
colorWarning: "#e8b84a",
colorSuccess: "#6bc4a8"
},
components: {
Layout: {
siderBg: "#0b0a10",
triggerBg: "#15141c",
triggerColor: "#ececf1"
},
Menu: {
darkItemBg: "transparent",
darkItemSelectedBg: "rgba(90, 154, 130, 0.32)",
darkItemHoverBg: "rgba(90, 154, 130, 0.2)",
itemMarginInline: 8,
itemBorderRadius: 8,
itemHeight: 40
},
Card: {
colorBgContainer: "#0b0a10",
colorBorderSecondary: "rgba(186, 184, 208, 0.14)",
borderRadiusLG: 20
},
Table: {
headerBg: "#1a1924",
rowHoverBg: "rgba(90, 154, 130, 0.1)",
borderColor: "rgba(186, 184, 208, 0.1)"
},
Tag: {
defaultBg: "rgba(11, 10, 16, 0.35)",
defaultColor: "rgba(236, 236, 241, 0.75)"
},
Input: {
colorBgContainer: "#15141c",
colorBorder: "rgba(186, 184, 208, 0.16)",
colorText: "#ececf1"
},
Select: {
colorBgContainer: "#15141c",
colorBorder: "rgba(186, 184, 208, 0.16)",
colorText: "#ececf1",
optionSelectedBg: "rgba(90, 154, 130, 0.2)"
},
Switch: {
colorPrimary: "#5a9a82",
colorPrimaryHover: "#6bc4a8"
},
Modal: {
colorBgElevated: "#0b0a10",
contentBg: "#0b0a10",
headerBg: "#0b0a10",
titleColor: "#ececf1",
colorIcon: "#a8adbb",
colorIconHover: "#ececf1"
},
Button: {
defaultBg: "#15141c",
defaultBorderColor: "rgba(186, 184, 208, 0.16)",
defaultColor: "#ececf1"
}
}
};
export function getAdminAntdTheme(mode: AdminColorMode): ThemeConfig {
return mode === "dark" ? darkTheme : lightTheme;
}
+19
View File
@@ -0,0 +1,19 @@
export { AdminPanel } from "./components/AdminPanel";
export { AdminStats } from "./components/AdminStats";
export { AdminUsersTable } from "./components/AdminUsersTable";
export { AdminContentPanel } from "./components/AdminContentPanel";
export {
createAdminUser,
deleteAdminUser,
getAdminActivityFeed,
getAdminDiagnostics,
getAdminServerLog,
getAdminSettings,
getAdminStats,
getAdminSummary,
getAdminUsers,
patchAdminSettings,
patchAdminUser,
postAdminUiActivity,
resetAdminUserPassword
} from "./api/adminApi";
@@ -0,0 +1,898 @@
@import "../../../../main/css/tokens.css";
html.admin-route body {
background: var(--wesp-admin-bg);
}
html.admin-route .ant-layout-sider-trigger {
background: var(--marquee-bg) !important;
color: var(--foreground) !important;
border-top: 1px solid var(--border-light) !important;
}
html.admin-route .ant-layout-sider-trigger:hover {
background: var(--primary-tint) !important;
color: var(--foreground) !important;
}
html.admin-route .ant-card {
background: var(--wesp-admin-surface) !important;
border-color: var(--border-light) !important;
box-shadow: none !important;
border-radius: var(--admin-card-radius) !important;
overflow: hidden;
}
html.admin-route .ant-btn-primary {
background: var(--wesp-admin-primary) !important;
border-color: var(--primary) !important;
color: #fff !important;
}
html.admin-route .ant-btn-dangerous,
html.admin-route .ant-btn-dangerous.ant-btn-primary {
background: #cd3838 !important;
border-color: transparent !important;
color: #fff !important;
}
html.admin-route .ant-btn-dangerous:hover,
html.admin-route .ant-btn-dangerous.ant-btn-primary:hover {
background: #e94b4b !important;
color: #fff !important;
}
html.admin-route .ant-input,
html.admin-route .ant-input-affix-wrapper,
html.admin-route .ant-select-selector,
html.admin-route .ant-input-number {
background: var(--surface) !important;
border-color: var(--border-light) !important;
color: var(--foreground) !important;
}
html.admin-route .ant-select-dropdown,
html.admin-route .ant-modal-content,
html.admin-route .ant-modal-header {
background: var(--surface) !important;
color: var(--foreground) !important;
}
html.admin-route .ant-modal-title,
html.admin-route .ant-modal-close {
color: var(--foreground) !important;
}
html.admin-route .ant-switch.ant-switch-checked {
background: var(--wesp-admin-primary) !important;
}
html.admin-route .ant-typography.ant-typography-secondary,
html.admin-route .ant-list-item-meta-description {
color: var(--muted) !important;
}
html.admin-route .ant-table {
background: var(--surface) !important;
color: var(--foreground) !important;
}
html.admin-route .ant-table-thead > tr > th {
background: var(--marquee-bg) !important;
color: var(--muted) !important;
border-bottom-color: var(--border-light) !important;
}
html.admin-route .ant-table-tbody > tr > td {
border-bottom-color: var(--border-light) !important;
}
.wesp-admin-layout {
min-height: 100vh;
background: var(--wesp-admin-bg) !important;
--wesp-admin-sider-width: 80px;
}
.wesp-admin-layout.wesp-admin-sider-expanded {
--wesp-admin-sider-width: 256px;
}
.wesp-admin-sider.ant-layout-sider {
background: var(--admin-sider-bg) !important;
border-right: 1px solid var(--admin-stroke);
position: fixed;
top: 0;
left: 0;
bottom: 0;
height: 100vh;
z-index: 100;
flex: 0 0 var(--wesp-admin-sider-width) !important;
max-width: var(--wesp-admin-sider-width) !important;
min-width: var(--wesp-admin-sider-width) !important;
width: var(--wesp-admin-sider-width) !important;
transition:
flex 0.28s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
min-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.wesp-admin-sider.ant-layout-sider-collapsed {
flex: 0 0 80px !important;
max-width: 80px !important;
min-width: 80px !important;
width: 80px !important;
}
.wesp-admin-layout > .ant-layout {
margin-left: var(--wesp-admin-sider-width);
min-height: 100vh;
background: transparent;
transition: margin-left 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.wesp-admin-sider .ant-layout-sider-children {
display: flex;
flex-direction: column;
overflow: hidden;
padding-bottom: 48px;
}
.wesp-admin-sider .ant-menu {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 10px 0 8px !important;
background: transparent !important;
border-inline-end: none !important;
}
.wesp-admin-sider .ant-menu-item {
cursor: pointer;
margin: 4px 8px !important;
width: calc(100% - 16px) !important;
display: flex !important;
align-items: center;
gap: 10px;
height: 40px !important;
line-height: 40px !important;
border-radius: 8px;
color: var(--wesp-admin-text) !important;
}
.wesp-admin-sider .ant-menu-item:hover {
background: var(--admin-primary-hover) !important;
}
.wesp-admin-sider .ant-menu-item-selected {
background: var(--admin-primary-selected) !important;
color: var(--primary) !important;
}
.wesp-admin-sider .ant-menu-item .anticon {
color: var(--muted);
font-size: 16px;
}
.wesp-admin-sider .ant-menu-item-selected .anticon {
color: var(--primary);
}
.wesp-admin-sider .wesp-menu-label {
min-width: 0;
overflow: hidden;
white-space: nowrap;
max-width: 16rem;
opacity: 1;
transition:
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.1s,
max-width 0.36s cubic-bezier(0.4, 0, 0.2, 1) 0.08s,
margin 0.25s ease,
padding 0.25s ease;
}
.wesp-admin-sider.ant-layout-sider-collapsed .wesp-menu-label {
max-width: 0 !important;
opacity: 0 !important;
margin: 0 !important;
padding: 0 !important;
pointer-events: none;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item {
display: flex !important;
justify-content: center !important;
align-items: center !important;
gap: 0;
margin-inline: 8px !important;
width: calc(100% - 16px) !important;
padding-inline: 0 !important;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item .ant-menu-item-icon {
margin-inline: 0 !important;
flex: none !important;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-title-content {
width: 0 !important;
overflow: hidden !important;
opacity: 0 !important;
flex: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-inline-collapsed > .ant-menu-item {
padding-inline: 0 !important;
}
.wesp-admin-sider:not(.ant-layout-sider-collapsed) .ant-menu-item {
padding-inline: 12px !important;
}
.wesp-admin-sider .ant-layout-sider-trigger,
.wesp-admin-sider.ant-layout-sider-light .ant-layout-sider-trigger,
.wesp-admin-sider.ant-layout-sider-dark .ant-layout-sider-trigger {
position: absolute;
left: 0;
right: 0;
bottom: 0;
width: 100% !important;
height: 48px !important;
line-height: 48px !important;
border-top: 1px solid var(--border-light) !important;
background: var(--marquee-bg) !important;
color: var(--foreground) !important;
box-shadow: none !important;
}
.wesp-admin-sider .ant-layout-sider-trigger:hover,
.wesp-admin-sider.ant-layout-sider-light .ant-layout-sider-trigger:hover,
.wesp-admin-sider.ant-layout-sider-dark .ant-layout-sider-trigger:hover {
background: var(--primary-tint) !important;
color: var(--foreground) !important;
}
.wesp-sider-trigger-icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--foreground);
opacity: 0.9;
}
.wesp-sider-trigger-icon .anticon {
color: inherit;
}
.ant-tooltip.ant-menu-inline-collapsed-tooltip .ant-tooltip-inner {
background: var(--surface);
color: var(--foreground);
font-size: 12px;
border: 1px solid var(--border-light);
}
.ant-tooltip.ant-menu-inline-collapsed-tooltip .ant-tooltip-arrow::before {
background: var(--surface);
}
.wesp-admin-content {
margin: 16px;
padding: 0;
background: transparent !important;
color: var(--wesp-admin-text);
min-width: 0;
}
.wesp-admin-main {
min-width: 0;
}
.wesp-admin-content-topbar {
margin-bottom: 12px;
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 12px;
min-height: 44px;
}
.wesp-admin-content-topbar-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.wesp-admin-content-topbar .ant-btn {
border-radius: 20px;
background: var(--admin-btn-bg);
border-color: var(--admin-btn-border);
color: var(--admin-btn-text);
height: 32px;
padding: 0 15px;
font-size: 14px;
font-weight: 500;
}
.wesp-admin-content-topbar .ant-btn-primary {
background: var(--wesp-admin-primary) !important;
border-color: var(--primary) !important;
color: #fff !important;
}
.wesp-admin-panel-card.ant-card {
border-radius: var(--admin-card-radius);
border: 1px solid var(--border-light);
background: var(--wesp-admin-surface);
}
.wesp-admin-panel-card .ant-card-head {
border-bottom-color: var(--border-light);
min-height: 40px;
padding: 0 14px;
}
.wesp-admin-panel-card .ant-card-body {
padding: 14px;
}
.wesp-admin-panel-card .ant-card-head-title,
.wesp-admin-panel-card .ant-typography,
.wesp-admin-grid .ant-typography,
.wesp-admin-grid .ant-list-item-meta-title,
.wesp-admin-grid .ant-list-item-meta-description {
color: var(--wesp-admin-text) !important;
}
.wesp-admin-section {
display: block;
}
.wesp-admin-dashboard {
display: grid;
gap: 16px;
}
.wesp-dashboard-sticky.ant-card {
position: sticky;
top: 0;
z-index: 20;
}
.wesp-dashboard-sticky.ant-card .ant-card-body {
padding: 12px 14px;
}
.wesp-admin-gauges-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.wesp-gauge {
text-align: center;
}
.wesp-gauge-ring {
position: relative;
width: 120px;
max-width: 100%;
margin: 0 auto;
aspect-ratio: 1;
}
.wesp-gauge-svg {
width: 100%;
height: 100%;
display: block;
}
.wesp-gauge-track {
stroke: var(--border-light);
}
.wesp-gauge-fill {
stroke: var(--wesp-admin-primary);
transition: stroke-dashoffset 0.45s ease;
}
.wesp-gauge-center {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.wesp-gauge-pct {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
}
.wesp-gauge-pct-suffix {
font-size: 13px;
font-weight: 600;
opacity: 0.55;
margin-left: 1px;
}
.wesp-gauge-ring--adm {
width: 120px;
height: 120px;
}
.wesp-gauge-caption {
margin-top: 10px;
font-size: 13px;
line-height: 1.45;
word-break: break-word;
}
.wesp-gauge-caption span[data-gauge-detail] {
opacity: 0.75;
font-weight: 500;
}
.wesp-dash-service-row.ant-row {
display: flex;
flex-wrap: wrap;
align-items: stretch;
}
.wesp-dash-service-row > .ant-col {
display: flex;
flex-direction: column;
}
.wesp-dash-service-row .wesp-dash-service-card {
flex: 1 1 auto;
width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
}
.wesp-dash-service-card .ant-card-body {
padding-top: 12px;
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.wesp-dash-service-row .wesp-dash-manage-list {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.wesp-dash-manage-list {
font-size: 13px;
line-height: 1.4;
}
.wesp-dash-manage-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 16px;
padding: 14px 0;
border-bottom: 1px solid var(--border-light);
}
.wesp-dash-manage-row:first-child {
padding-top: 2px;
}
.wesp-dash-manage-row:last-child {
border-bottom: none;
padding-bottom: 2px;
}
.wesp-dash-manage-row--service {
margin-top: 4px;
padding-top: 16px;
}
.wesp-dash-manage-info {
flex: 1 1 140px;
min-width: 0;
}
.wesp-dash-manage-title {
display: block;
font-weight: 600;
font-size: 13px;
}
.wesp-dash-manage-hint {
display: block;
margin-top: 4px;
font-size: 12px;
opacity: 0.65;
line-height: 1.35;
}
.wesp-dash-manage-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 12px;
flex-shrink: 0;
}
.wesp-dash-wesp-meta {
display: flex;
flex-direction: column;
gap: 10px;
font-size: 13px;
}
.wesp-dash-wesp-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 12px;
line-height: 1.4;
}
.wesp-dash-wesp-label {
opacity: 0.65;
flex-shrink: 0;
}
.wesp-dash-wesp-value {
font-weight: 500;
text-align: right;
}
.wesp-dash-status {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
}
.wesp-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
box-sizing: border-box;
}
.wesp-status-dot--ok {
background: var(--wesp-admin-primary);
}
.wesp-traffic-card .ant-card-body {
padding: 16px 20px 20px;
}
.wesp-traffic-two-col {
display: flex;
flex-wrap: wrap;
gap: 16px 24px;
}
.wesp-traffic-col {
flex: 1 1 160px;
min-width: 0;
}
.wesp-traffic-label {
font-size: 13px;
opacity: 0.65;
margin-bottom: 8px;
}
.wesp-traffic-value {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.02em;
}
.wesp-traffic-arrow,
.wesp-traffic-icon {
opacity: 0.75;
margin-right: 4px;
}
.wesp-admin-stats-row {
margin-top: 0;
}
.wesp-tag {
background: var(--marquee-bg) !important;
border: 1px solid var(--border-light) !important;
color: var(--muted) !important;
}
.wesp-tag--ok {
background: var(--primary-tint) !important;
border-color: var(--admin-tag-ok-border) !important;
color: var(--primary) !important;
}
.wesp-tag--warn {
background: #fff8e6 !important;
border-color: rgba(212, 167, 44, 0.45) !important;
color: #9a6700 !important;
}
.wesp-tag--error {
background: #fff1f0 !important;
border-color: rgba(205, 56, 56, 0.35) !important;
color: #cd3838 !important;
}
.wesp-admin-stat-card.ant-card {
background: var(--wesp-admin-surface) !important;
border: 1px solid var(--wesp-admin-stroke) !important;
border-radius: var(--admin-card-radius);
box-shadow: none !important;
}
.wesp-admin-stat-card.ant-card .ant-card-body {
padding: 16px !important;
}
.wesp-admin-stat-label {
opacity: 0.65;
font-size: 13px;
color: var(--wesp-admin-soft);
}
.wesp-admin-stat-value {
font-size: 26px;
font-weight: 600;
margin-top: 6px;
color: var(--wesp-admin-text);
line-height: 1.2;
}
.wesp-admin-grid .ant-card {
background: var(--wesp-admin-surface) !important;
border-color: var(--wesp-admin-stroke) !important;
border-radius: var(--admin-card-radius) !important;
}
.wesp-admin-grid .ant-table {
background: var(--wesp-admin-surface) !important;
color: var(--wesp-admin-text) !important;
}
.wesp-admin-grid .ant-table-thead > tr > th {
background: var(--admin-table-header) !important;
border-bottom-color: var(--wesp-admin-stroke) !important;
color: var(--wesp-admin-soft) !important;
}
.wesp-admin-grid .ant-table-tbody > tr > td {
border-bottom-color: var(--border-light) !important;
color: var(--wesp-admin-text) !important;
}
.wesp-admin-grid {
display: grid;
gap: 10px;
}
.wesp-admin-log {
margin: 0;
background: linear-gradient(165deg, #0a0f0d 0%, #060807 48%, #0d1210 100%);
color: #7ee787;
border-radius: 10px;
padding: 12px;
max-height: 320px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.wesp-admin-block {
position: relative;
}
html.wesp-admin-loading #root {
pointer-events: none;
}
.wesp-admin-skel-overlay {
position: absolute;
inset: 0;
pointer-events: none;
border-radius: inherit;
z-index: 2;
background: linear-gradient(
105deg,
rgba(0, 0, 0, 0.02) 0%,
rgba(0, 0, 0, 0.02) 34%,
rgba(0, 0, 0, 0.05) 50%,
rgba(0, 0, 0, 0.02) 66%,
rgba(0, 0, 0, 0.02) 100%
);
background-size: 240% 100%;
animation: wesp-admin-skel-shimmer 0.95s ease-in-out infinite;
transition: opacity 0.38s ease;
}
@keyframes wesp-admin-skel-shimmer {
0% {
background-position: 240% 0;
}
100% {
background-position: -240% 0;
}
}
html.wesp-admin-loaded .wesp-admin-skel-overlay {
opacity: 0;
visibility: hidden;
}
html.wesp-admin-loaded .wesp-admin-block {
animation: wesp-admin-block-reveal 0.52s cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--wesp-reveal-i, 0) * 38ms);
}
@keyframes wesp-admin-block-reveal {
from {
opacity: 0.55;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.wesp-admin-section-enter {
animation: wesp-admin-section-tween 0.44s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes wesp-admin-section-tween {
from {
opacity: 0.45;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
html.admin-route[data-admin-theme="dark"] .wesp-tag {
background: rgba(11, 10, 16, 0.45) !important;
border: 1px solid rgba(186, 184, 208, 0.16) !important;
color: rgba(236, 236, 241, 0.82) !important;
}
html.admin-route[data-admin-theme="dark"] .wesp-tag--ok {
background: rgba(90, 154, 130, 0.18) !important;
border-color: var(--admin-tag-ok-border) !important;
color: var(--admin-success) !important;
}
html.admin-route[data-admin-theme="dark"] .wesp-tag--warn {
background: rgba(232, 184, 74, 0.12) !important;
border-color: rgba(232, 184, 74, 0.4) !important;
color: #e8b84a !important;
}
html.admin-route[data-admin-theme="dark"] .wesp-tag--error {
background: rgba(233, 75, 75, 0.14) !important;
border-color: rgba(233, 75, 75, 0.35) !important;
color: #ff9c9c !important;
}
html.admin-route[data-admin-theme="dark"] .wesp-admin-skel-overlay {
background: linear-gradient(
105deg,
rgba(255, 255, 255, 0.02) 0%,
rgba(255, 255, 255, 0.02) 34%,
rgba(255, 255, 255, 0.07) 50%,
rgba(255, 255, 255, 0.02) 66%,
rgba(255, 255, 255, 0.02) 100%
);
}
html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item-selected {
color: var(--admin-success) !important;
}
html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item-selected .anticon {
color: var(--admin-success);
}
html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item .anticon {
color: var(--admin-soft);
}
html.admin-route[data-admin-theme="dark"] .wesp-gauge-track {
stroke: rgba(186, 184, 208, 0.18);
}
html.admin-route[data-admin-theme="dark"] .wesp-admin-log {
background: linear-gradient(165deg, #0b0a10 0%, #15141c 48%, #0b0a10 100%);
color: #6bc4a8;
}
@media (max-width: 768px) {
.wesp-admin-layout.ant-layout-has-sider {
flex-direction: column;
}
.wesp-admin-sider.ant-layout-sider {
position: static;
inset: auto;
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
flex: 0 0 auto !important;
height: auto !important;
}
.wesp-admin-layout > .ant-layout {
margin-left: 0;
}
.wesp-admin-sider .ant-layout-sider-children {
padding-bottom: 0;
}
.wesp-admin-sider .ant-layout-sider-trigger {
display: none;
}
.wesp-admin-sider.ant-layout-sider-collapsed .wesp-menu-label {
max-width: 16rem !important;
opacity: 1 !important;
pointer-events: auto;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item {
justify-content: flex-start;
gap: 10px;
padding-inline: 16px !important;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-title-content {
width: auto !important;
opacity: 1 !important;
flex: 1 !important;
overflow: visible !important;
}
.wesp-admin-content {
margin: 12px;
}
.wesp-admin-content-topbar {
justify-content: flex-start;
}
.wesp-admin-gauges-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.wesp-admin-sider.ant-layout-sider,
.wesp-admin-layout > .ant-layout,
.wesp-admin-sider .wesp-menu-label,
.wesp-admin-sider .ant-menu-item {
transition: none !important;
}
}
+3
View File
@@ -0,0 +1,3 @@
export function AnalyticsPlaceholder(): string {
return "analytics-v1";
}
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from "vitest";
import { login, register } from "./authApi";
vi.mock("@shared/api/client", () => ({
authClient: {
post: vi.fn(async (url: string) => {
if (url.endsWith("/login")) {
return { data: { access_token: "token", user: { id: "1" } } };
}
return { data: { message: "ok" } };
})
}
}));
describe("authApi", () => {
it("login returns access token payload", async () => {
const data = await login({ email: "u@example.com", password: "Valid123" });
expect(data.access_token).toBe("token");
});
it("register returns success payload", async () => {
const data = await register({ email: "u@example.com", password: "Valid123" });
expect(data.message).toBe("ok");
});
});
+84
View File
@@ -0,0 +1,84 @@
import { authClient } from "@shared/api/client";
export interface LoginPayload {
email: string;
password: string;
}
export interface RegisterPayload {
email: string;
password: string;
}
export async function login(payload: LoginPayload) {
const { data } = await authClient.post(
"/api/v1/auth/login",
{
email: payload.email.trim(),
password: payload.password
},
{
headers: { "Content-Type": "application/json" }
}
);
return data;
}
export async function register(payload: RegisterPayload) {
const { data } = await authClient.post(
"/api/v1/auth/register",
{
email: payload.email.trim(),
password: payload.password
},
{
headers: { "Content-Type": "application/json" }
}
);
return data;
}
export async function logout() {
await authClient.post("/api/v1/auth/logout");
}
export async function refresh() {
const { data } = await authClient.post("/api/v1/auth/refresh");
return data;
}
export async function verifyEmail(token: string) {
const { data } = await authClient.post(
"/api/v1/auth/verify-email",
{ token },
{ headers: { "Content-Type": "application/json" } }
);
return data;
}
export async function forgotPassword(email: string) {
const { data } = await authClient.post(
"/api/v1/auth/forgot-password",
{ email: email.trim() },
{ headers: { "Content-Type": "application/json" } }
);
return data;
}
export async function resetPassword(token: string, newPassword: string) {
const { data } = await authClient.post(
"/api/v1/auth/reset-password",
{ token, new_password: newPassword },
{ headers: { "Content-Type": "application/json" } }
);
return data;
}
export async function resendVerification(email: string) {
const { data } = await authClient.post(
"/api/v1/auth/resend-verification",
{ email: email.trim() },
{ headers: { "Content-Type": "application/json" } }
);
return data;
}
@@ -0,0 +1,16 @@
import type { InputHTMLAttributes, ReactNode } from "react";
interface AuthFieldProps extends InputHTMLAttributes<HTMLInputElement> {
icon: ReactNode;
}
export function AuthField({ icon, ...props }: AuthFieldProps): JSX.Element {
return (
<label className="z-login-field">
<span className="z-login-field-icon" aria-hidden="true">
{icon}
</span>
<input className="z-login-input" {...props} />
</label>
);
}
@@ -0,0 +1,22 @@
import type { PropsWithChildren } from "react";
import { ZootechThemeProvider } from "@shared/theme/zootech/ThemeProvider";
import "@shared/theme/zootech/auth-login.css";
interface AuthLayoutProps extends PropsWithChildren {
title: string;
}
export function AuthLayout({ title, children }: AuthLayoutProps): JSX.Element {
return (
<ZootechThemeProvider>
<main className="z-login-shell">
<section className="z-login-page">
<article className="z-login-card">
<h1 className="z-login-title">{title}</h1>
{children}
</article>
</section>
</main>
</ZootechThemeProvider>
);
}
@@ -0,0 +1,12 @@
interface AuthMessageProps {
type: "error" | "success";
text: string;
}
export function AuthMessage({ type, text }: AuthMessageProps): JSX.Element {
return (
<p className={`z-login-msg z-login-msg--${type}`} role={type === "error" ? "alert" : undefined}>
{text}
</p>
);
}
@@ -0,0 +1,19 @@
interface AuthSubmitProps {
loading: boolean;
idleText: string;
loadingText: string;
disabled?: boolean;
}
export function AuthSubmit({
loading,
idleText,
loadingText,
disabled = false
}: AuthSubmitProps): JSX.Element {
return (
<button className="z-login-submit" type="submit" disabled={disabled || loading}>
{loading ? loadingText : idleText}
</button>
);
}
@@ -0,0 +1,12 @@
import type { InputHTMLAttributes, PropsWithChildren } from "react";
type AuthSwitchProps = InputHTMLAttributes<HTMLInputElement> & PropsWithChildren;
export function AuthSwitch({ children, ...props }: AuthSwitchProps): JSX.Element {
return (
<label className="z-login-remember">
<input type="checkbox" {...props} />
<span>{children}</span>
</label>
);
}
@@ -0,0 +1,17 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { LoginForm } from "./LoginForm";
describe("LoginForm", () => {
it("renders login form controls", () => {
render(
<MemoryRouter>
<LoginForm />
</MemoryRouter>
);
expect(screen.getByPlaceholderText("Email")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Password")).toBeInTheDocument();
expect(screen.getByText(/Forgot password/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,103 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { formatAuthError } from "../utils/formatAuthError";
import { AuthField } from "./AuthField";
import { AuthMessage } from "./AuthMessage";
import { AuthSubmit } from "./AuthSubmit";
function UserIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function LockIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M7 11V7a5 5 0 0 1 10 0v4M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function LoginForm(): JSX.Element {
const auth = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
return (
<form
className="z-login-form"
onSubmit={async (event) => {
event.preventDefault();
setError("");
setIsSubmitting(true);
try {
const user = await auth.login(email.trim(), password);
navigate(user.role === "admin" ? "/admin" : "/profile");
} catch (loginError) {
const fallback =
loginError && typeof loginError === "object" && "response" in loginError
? (loginError as { response?: { status?: number } }).response?.status === 401
? "Invalid credentials"
: (loginError as { response?: { status?: number } }).response?.status === 403
? "Email not verified or account blocked"
: "Login failed"
: "Login failed";
setError(formatAuthError(loginError, fallback));
} finally {
setIsSubmitting(false);
}
}}
>
<AuthField
icon={<UserIcon />}
placeholder="Email"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
<AuthField
icon={<LockIcon />}
placeholder="Password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
<AuthSubmit loading={isSubmitting} loadingText="Logging in..." idleText="Login" />
<p className="z-login-meta">
<Link className="z-login-link" to="/forgot-password">
Forgot password?
</Link>
</p>
<p className="z-login-meta">
No account yet?{" "}
<Link className="z-login-link" to="/register">
Register
</Link>
</p>
{error ? <AuthMessage type="error" text={error} /> : null}
</form>
);
}
@@ -0,0 +1,72 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { AxiosError, type AxiosResponse } from "axios";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RegisterForm } from "./RegisterForm";
const registerMock = vi.fn();
vi.mock("../api/authApi", () => ({
register: (...args: unknown[]) => registerMock(...args)
}));
afterEach(() => {
cleanup();
registerMock.mockReset();
});
describe("RegisterForm", () => {
it("renders register form controls", () => {
render(
<MemoryRouter>
<RegisterForm />
</MemoryRouter>
);
expect(screen.getByPlaceholderText("Email")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Password")).toBeInTheDocument();
expect(screen.getByText(/uppercase, lowercase, and a digit/i)).toBeInTheDocument();
expect(screen.getByText(/privacy policy/i)).toBeInTheDocument();
});
it("shows validation error from API", async () => {
registerMock.mockRejectedValueOnce(
new AxiosError(
"Validation failed",
"422",
undefined,
undefined,
{
status: 422,
data: {
detail: [
{
loc: ["body", "password"],
msg: "Value error, Password must include at least one uppercase letter"
}
]
}
} as AxiosResponse
)
);
render(
<MemoryRouter>
<RegisterForm />
</MemoryRouter>
);
fireEvent.change(screen.getByPlaceholderText("Email"), {
target: { value: "user@example.com" }
});
fireEvent.change(screen.getByPlaceholderText("Password"), {
target: { value: "valid123" }
});
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(screen.getByRole("button", { name: "Register" }));
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(
"Password must include at least one uppercase letter"
);
});
});
});
@@ -0,0 +1,108 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { register } from "../api/authApi";
import { formatAuthError } from "../utils/formatAuthError";
import { AuthField } from "./AuthField";
import { AuthMessage } from "./AuthMessage";
import { AuthSubmit } from "./AuthSubmit";
import { AuthSwitch } from "./AuthSwitch";
function UserIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function LockIcon(): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M7 11V7a5 5 0 0 1 10 0v4M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function RegisterForm(): JSX.Element {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [consent, setConsent] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
return (
<form
className="z-login-form"
onSubmit={async (event) => {
event.preventDefault();
if (!consent) {
setError("Please accept the privacy policy to register.");
return;
}
setMessage("");
setError("");
setIsSubmitting(true);
try {
await register({ email, password });
setMessage("Registration submitted. Check your email.");
} catch (registerError) {
setError(formatAuthError(registerError, "Registration failed"));
} finally {
setIsSubmitting(false);
}
}}
>
<AuthField
icon={<UserIcon />}
placeholder="Email"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
<AuthField
icon={<LockIcon />}
placeholder="Password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
minLength={8}
/>
<p className="z-login-meta">
Password: at least 8 characters with uppercase, lowercase, and a digit.
</p>
<AuthSwitch checked={consent} onChange={(event) => setConsent(event.target.checked)}>
I agree to the{" "}
<Link className="z-login-link" to="/pages/privacy">
privacy policy
</Link>
.
</AuthSwitch>
<AuthSubmit loading={isSubmitting} loadingText="Registering..." idleText="Register" />
{error ? <AuthMessage type="error" text={error} /> : null}
{message ? <AuthMessage type="success" text={message} /> : null}
<p className="z-login-meta">
Already registered?{" "}
<Link className="z-login-link" to="/login">
Login
</Link>
</p>
</form>
);
}
@@ -0,0 +1,30 @@
import { useAuthStore } from "../store/authStore";
import type { AuthUser } from "../store/authStore";
import { login as apiLogin, logout as apiLogout, refresh as apiRefresh } from "../api/authApi";
export function useAuth() {
const user = useAuthStore((state) => state.user);
const accessToken = useAuthStore((state) => state.accessToken);
const bootstrapped = useAuthStore((state) => state.bootstrapped);
const setSession = useAuthStore((state) => state.setSession);
const clearSession = useAuthStore((state) => state.clearSession);
return {
user,
isAuthenticated: Boolean(user && accessToken),
bootstrapped,
async login(email: string, password: string) {
const data = await apiLogin({ email, password });
setSession(data.access_token, data.user);
return data.user as AuthUser;
},
async logout() {
await apiLogout();
clearSession();
},
async refreshSession() {
const data = await apiRefresh();
setSession(data.access_token, data.user);
}
};
}
@@ -0,0 +1,6 @@
import { useAuthStore } from "../store/authStore";
export function useIsSuperuser(): boolean {
const user = useAuthStore((state) => state.user);
return Boolean(user?.role === "admin" && user?.is_superuser);
}
+4
View File
@@ -0,0 +1,4 @@
export { LoginForm } from "./components/LoginForm";
export { RegisterForm } from "./components/RegisterForm";
export { useAuth } from "./hooks/useAuth";
export { useIsSuperuser } from "./hooks/useIsSuperuser";
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearAuthSessionHint,
hasAuthSessionHint,
isProtectedAppPath,
markAuthSessionHint,
shouldAttemptAuthRefresh
} from "./authSessionHint";
describe("authSessionHint", () => {
afterEach(() => {
sessionStorage.clear();
});
it("tracks session hint in sessionStorage", () => {
expect(hasAuthSessionHint()).toBe(false);
markAuthSessionHint();
expect(hasAuthSessionHint()).toBe(true);
clearAuthSessionHint();
expect(hasAuthSessionHint()).toBe(false);
});
it("detects protected app paths", () => {
expect(isProtectedAppPath("/admin")).toBe(true);
expect(isProtectedAppPath("/admin/users")).toBe(true);
expect(isProtectedAppPath("/profile")).toBe(true);
expect(isProtectedAppPath("/login")).toBe(false);
});
it("attempts refresh on protected paths even without hint", () => {
expect(shouldAttemptAuthRefresh("/admin")).toBe(true);
expect(shouldAttemptAuthRefresh("/login")).toBe(false);
});
it("attempts refresh when hint is present", () => {
markAuthSessionHint();
expect(shouldAttemptAuthRefresh("/login")).toBe(true);
});
});
@@ -0,0 +1,24 @@
const AUTH_HINT_KEY = "compton-auth-hint";
const PROTECTED_PATH_PREFIXES = ["/admin", "/profile"] as const;
export function markAuthSessionHint(): void {
sessionStorage.setItem(AUTH_HINT_KEY, "1");
}
export function clearAuthSessionHint(): void {
sessionStorage.removeItem(AUTH_HINT_KEY);
}
export function hasAuthSessionHint(): boolean {
return sessionStorage.getItem(AUTH_HINT_KEY) === "1";
}
export function isProtectedAppPath(pathname: string): boolean {
return PROTECTED_PATH_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
);
}
export function shouldAttemptAuthRefresh(pathname = window.location.pathname): boolean {
return hasAuthSessionHint() || isProtectedAppPath(pathname);
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { useAuthStore } from "./authStore";
describe("authStore", () => {
it("stores and clears session in memory", () => {
useAuthStore.getState().setBootstrapped(true);
useAuthStore.getState().setSession("token", {
id: "1",
email: "u@e.com",
role: "user",
is_superuser: false,
status: "active"
});
expect(useAuthStore.getState().accessToken).toBe("token");
useAuthStore.getState().clearSession();
expect(useAuthStore.getState().accessToken).toBeNull();
});
});
@@ -0,0 +1,37 @@
import { create } from "zustand";
import { clearAuthSessionHint, markAuthSessionHint } from "@modules/auth/store/authSessionHint";
export type UserRole = "user" | "admin";
export type UserStatus = "active" | "pending" | "blocked";
export interface AuthUser {
id: string;
email: string;
role: UserRole;
is_superuser: boolean;
status: UserStatus;
}
interface AuthState {
accessToken: string | null;
user: AuthUser | null;
bootstrapped: boolean;
setSession: (accessToken: string, user: AuthUser) => void;
clearSession: () => void;
setBootstrapped: (bootstrapped: boolean) => void;
}
export const useAuthStore = create<AuthState>()((set) => ({
accessToken: null,
user: null,
bootstrapped: false,
setSession: (accessToken, user) => {
markAuthSessionHint();
set({ accessToken, user });
},
clearSession: () => {
clearAuthSessionHint();
set({ accessToken: null, user: null });
},
setBootstrapped: (bootstrapped) => set({ bootstrapped })
}));
@@ -0,0 +1,37 @@
import { AxiosError, type AxiosResponse } from "axios";
import { describe, expect, it } from "vitest";
import { formatAuthError } from "./formatAuthError";
describe("formatAuthError", () => {
it("shows network message when API is unreachable", () => {
const message = formatAuthError(new AxiosError("Network Error", "ERR_NETWORK"), "Login failed");
expect(message).toBe(
"Cannot reach the API. Make sure the backend is running and reload the page."
);
});
it("extracts password validation message", () => {
const message = formatAuthError(
new AxiosError(
"Validation failed",
"422",
undefined,
undefined,
{
status: 422,
data: {
detail: [
{
loc: ["body", "password"],
msg: "Value error, Password must include at least one digit"
}
]
}
} as AxiosResponse
),
"Registration failed"
);
expect(message).toBe("Password must include at least one digit");
});
});
@@ -0,0 +1,35 @@
import { isAxiosError } from "axios";
type ValidationDetail = {
loc?: (string | number)[];
msg?: string;
};
function formatValidationDetail(detail: ValidationDetail[]): string {
const passwordError = detail.find((item) => item.loc?.includes("password"));
if (passwordError?.msg) {
return passwordError.msg.replace(/^Value error, /, "");
}
const emailError = detail.find((item) => item.loc?.includes("email"));
if (emailError?.msg) {
return "Enter a valid email address";
}
return "Invalid email or password format";
}
export function formatAuthError(error: unknown, fallback: string): string {
if (!isAxiosError(error)) {
return fallback;
}
if (!error.response) {
return "Cannot reach the API. Make sure the backend is running and reload the page.";
}
const detail = error.response?.data?.detail;
if (typeof detail === "string") {
return detail;
}
if (Array.isArray(detail) && detail.length > 0) {
return formatValidationDetail(detail as ValidationDetail[]);
}
return fallback;
}
@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
import { CatalogPlaceholder } from "./index";
describe("CatalogPlaceholder", () => {
it("returns stable marker", () => {
expect(CatalogPlaceholder()).toBe("catalog-v1");
});
});
+3
View File
@@ -0,0 +1,3 @@
export function CatalogPlaceholder(): string {
return "catalog-v1";
}
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
import {
createContentPage,
deleteContentPage,
getAdminPages,
getPageBySlug,
updateContentPage
} from "./contentApi";
vi.mock("@shared/api/client", () => ({
apiClient: {
get: vi.fn(async (url: string) => {
if (url === "/api/v1/content/pages/manage/all") {
return { data: { data: [{ id: "1", slug: "about", title: "About", body: "", status: "published" }] } };
}
return { data: { slug: "about", title: "About" } };
}),
post: vi.fn(async () => ({ data: { id: "2", slug: "new", title: "New", body: "", status: "draft" } })),
patch: vi.fn(async () => ({ data: { id: "1", slug: "about", title: "Updated", body: "", status: "published" } })),
delete: vi.fn(async () => ({ data: {} }))
}
}));
describe("contentApi", () => {
it("loads page by slug", async () => {
const data = await getPageBySlug("about");
expect(data.slug).toBe("about");
});
it("loads admin pages", async () => {
const pages = await getAdminPages();
expect(pages[0].slug).toBe("about");
});
it("creates content page", async () => {
const page = await createContentPage({
slug: "new",
title: "New",
body: "<p>x</p>",
status: "draft"
});
expect(page.slug).toBe("new");
});
it("updates content page", async () => {
const page = await updateContentPage("1", { title: "Updated" });
expect(page.title).toBe("Updated");
});
it("deletes content page", async () => {
await expect(deleteContentPage("1")).resolves.toBeUndefined();
});
});
@@ -0,0 +1,41 @@
import { apiClient } from "@shared/api/client";
export interface ContentPage {
id: string;
slug: string;
title: string;
body: string;
status: string;
}
export async function getPageBySlug(slug: string) {
const { data } = await apiClient.get(`/api/v1/content/pages/${slug}`);
return data;
}
export async function getAdminPages(): Promise<ContentPage[]> {
const { data } = await apiClient.get<{ data: ContentPage[] }>("/api/v1/content/pages/manage/all");
return data.data;
}
export async function createContentPage(payload: {
slug: string;
title: string;
body: string;
status: string;
}): Promise<ContentPage> {
const { data } = await apiClient.post<ContentPage>("/api/v1/content/pages", payload);
return data;
}
export async function updateContentPage(
pageId: string,
payload: { title?: string; body?: string; status?: string }
): Promise<ContentPage> {
const { data } = await apiClient.patch<ContentPage>(`/api/v1/content/pages/${pageId}`, payload);
return data;
}
export async function deleteContentPage(pageId: string): Promise<void> {
await apiClient.delete(`/api/v1/content/pages/${pageId}`);
}
+1
View File
@@ -0,0 +1 @@
export { getPageBySlug } from "./api/contentApi";
@@ -0,0 +1,16 @@
import { Link } from "react-router-dom";
export function BrandSection(): JSX.Element {
return (
<section aria-label="about-brand" style={{ padding: "2rem 1.5rem" }}>
<h2>About Compton</h2>
<p>
Compton combines organic aesthetics with modern technology for secure accounts,
profile management, and content publishing.
</p>
<p>
<Link to="/pages/about">Read more about the brand</Link>
</p>
</section>
);
}
@@ -0,0 +1,9 @@
export function ContactsSection(): JSX.Element {
return (
<section aria-label="contacts" style={{ padding: "2rem 1.5rem" }}>
<h2>Contacts</h2>
<p>Email: <a href="mailto:hello@compton.example">hello@compton.example</a></p>
<p>Support hours: MonFri, 10:0018:00 (UTC+3)</p>
</section>
);
}
@@ -0,0 +1,8 @@
export function HeroSection(): JSX.Element {
return (
<section>
<h1>Organic Tech / Compton</h1>
<p>Modern platform with secure accounts and modular architecture.</p>
</section>
);
}
@@ -0,0 +1,10 @@
export function MarqueeSection(): JSX.Element {
return (
<section aria-label="marquee" className="marquee-section">
<div className="marquee-track">
<span>Organic Tech Compton Organic Tech Compton Organic Tech Compton</span>
<span aria-hidden="true">Organic Tech Compton Organic Tech Compton Organic Tech Compton</span>
</div>
</section>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { HeroSection } from "./components/HeroSection";
export { MarqueeSection } from "./components/MarqueeSection";
export { BrandSection } from "./components/BrandSection";
export { ContactsSection } from "./components/ContactsSection";
@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
import { OrdersPlaceholder } from "./index";
describe("OrdersPlaceholder", () => {
it("returns stable marker", () => {
expect(OrdersPlaceholder()).toBe("orders-v1");
});
});
+3
View File
@@ -0,0 +1,3 @@
export function OrdersPlaceholder(): string {
return "orders-v1";
}
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import { changePassword, getProfile, updateProfile, uploadAvatar } from "./profileApi";
vi.mock("@shared/api/client", () => ({
apiClient: {
get: vi.fn(async () => ({
data: {
user: { email: "u@example.com" },
profile: { display_name: "User", avatar_url: null }
}
})),
patch: vi.fn(async () => ({ data: { profile: { display_name: "User" } } })),
post: vi.fn(async () => ({ data: { profile: { avatar_url: "/api/v1/media/files/x" } } }))
}
}));
describe("profileApi", () => {
it("gets profile", async () => {
const data = await getProfile();
expect(data.user.email).toBe("u@example.com");
});
it("updates profile", async () => {
const data = await updateProfile({ display_name: "User" });
expect(data.profile.display_name).toBe("User");
});
it("changes password", async () => {
await expect(
changePassword({ current_password: "Valid123", new_password: "NewValid1" })
).resolves.toBeUndefined();
});
it("uploads avatar", async () => {
const file = new File(["avatar"], "avatar.png", { type: "image/png" });
const data = await uploadAvatar(file);
expect(data.profile.avatar_url).toBe("/api/v1/media/files/x");
});
});
@@ -0,0 +1,40 @@
import { apiClient } from "@shared/api/client";
export interface ProfileResponse {
user: {
id: string;
email: string;
role: string;
status: string;
};
profile: {
display_name: string;
avatar_url: string | null;
};
}
export async function getProfile(): Promise<ProfileResponse> {
const { data } = await apiClient.get<ProfileResponse>("/api/v1/users/me");
return data;
}
export async function updateProfile(payload: { display_name?: string }): Promise<ProfileResponse> {
const { data } = await apiClient.patch<ProfileResponse>("/api/v1/users/me", payload);
return data;
}
export async function changePassword(payload: {
current_password: string;
new_password: string;
}): Promise<void> {
await apiClient.post("/api/v1/users/me/password", payload);
}
export async function uploadAvatar(file: File): Promise<ProfileResponse> {
const formData = new FormData();
formData.append("file", file);
const { data } = await apiClient.post<ProfileResponse>("/api/v1/users/me/avatar", formData, {
headers: { "Content-Type": "multipart/form-data" }
});
return data;
}
@@ -0,0 +1,18 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ProfileCard } from "./ProfileCard";
describe("ProfileCard", () => {
it("renders profile info and avatar", () => {
render(
<ProfileCard
email="user@example.com"
displayName="Compton User"
avatarUrl="/api/v1/media/files/avatars/u/1.png"
/>
);
expect(screen.getByText("Compton User")).toBeInTheDocument();
expect(screen.getByText("user@example.com")).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Compton User avatar" })).toBeInTheDocument();
});
});
@@ -0,0 +1,29 @@
import { getApiBaseUrl } from "@shared/api/config";
interface ProfileCardProps {
email: string;
displayName: string;
avatarUrl?: string | null;
}
export function ProfileCard({ email, displayName, avatarUrl }: ProfileCardProps): JSX.Element {
const apiBase = getApiBaseUrl();
const resolvedAvatar = avatarUrl?.startsWith("/") ? `${apiBase}${avatarUrl}` : avatarUrl;
return (
<section>
<h2>Profile</h2>
{resolvedAvatar ? (
<img
src={resolvedAvatar}
alt={`${displayName} avatar`}
width={96}
height={96}
style={{ borderRadius: "50%", objectFit: "cover" }}
/>
) : null}
<p>{displayName}</p>
<p>{email}</p>
</section>
);
}
@@ -0,0 +1,30 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ProfileEditor } from "./ProfileEditor";
vi.mock("../api/profileApi", () => ({
updateProfile: vi.fn(),
changePassword: vi.fn(),
uploadAvatar: vi.fn()
}));
const profile = {
user: { id: "1", email: "user@example.com", role: "user", is_superuser: false, status: "active" },
profile: { display_name: "Compton User", avatar_url: null }
};
describe("ProfileEditor", () => {
it("renders profile edit form", () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<ProfileEditor profile={profile} />
</QueryClientProvider>
);
expect(screen.getByRole("heading", { name: "Edit profile" })).toBeInTheDocument();
expect(screen.getByDisplayValue("Compton User")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save name" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Change password" })).toBeInTheDocument();
});
});
@@ -0,0 +1,91 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Input } from "@shared/ui";
import { changePassword, updateProfile, uploadAvatar, type ProfileResponse } from "../api/profileApi";
interface ProfileEditorProps {
profile: ProfileResponse;
}
export function ProfileEditor({ profile }: ProfileEditorProps): JSX.Element {
const queryClient = useQueryClient();
const [displayName, setDisplayName] = useState(profile.profile.display_name);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [message, setMessage] = useState("");
const updateMutation = useMutation({
mutationFn: () => updateProfile({ display_name: displayName }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["profile-me"] });
setMessage("Profile updated");
}
});
const passwordMutation = useMutation({
mutationFn: () => changePassword({ current_password: currentPassword, new_password: newPassword }),
onSuccess: () => {
setCurrentPassword("");
setNewPassword("");
setMessage("Password changed");
},
onError: () => setMessage("Invalid current password")
});
const avatarMutation = useMutation({
mutationFn: (file: File) => uploadAvatar(file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["profile-me"] });
setMessage("Avatar uploaded");
},
onError: () => setMessage("Avatar upload failed")
});
return (
<section style={{ display: "grid", gap: "1rem", maxWidth: 480 }}>
<h2>Edit profile</h2>
<label>
Display name
<Input value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
</label>
<Button type="button" onClick={() => updateMutation.mutate()}>
Save name
</Button>
<label>
Avatar
<Input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
avatarMutation.mutate(file);
}
}}
/>
</label>
<div style={{ display: "grid", gap: "0.5rem" }}>
<h3>Change password</h3>
<Input
type="password"
placeholder="Current password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
/>
<Input
type="password"
placeholder="New password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
<Button type="button" onClick={() => passwordMutation.mutate()}>
Change password
</Button>
</div>
{message ? <p>{message}</p> : null}
</section>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { ProfileCard } from "./components/ProfileCard";
export { ProfileEditor } from "./components/ProfileEditor";
export { getProfile, updateProfile, changePassword, uploadAvatar } from "./api/profileApi";
+5
View File
@@ -0,0 +1,5 @@
import { AdminPanel } from "@modules/admin/components/AdminPanel";
export default function AdminPage(): JSX.Element {
return <AdminPanel />;
}
+44
View File
@@ -0,0 +1,44 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ContentPage } from "./ContentPage";
import * as contentApi from "@modules/content/api/contentApi";
function renderPage(slug = "about") {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/pages/${slug}`]}>
<Routes>
<Route path="/pages/:slug" element={<ContentPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
);
}
describe("ContentPage", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("renders fetched page title and body", async () => {
vi.spyOn(contentApi, "getPageBySlug").mockResolvedValue({
id: "1",
slug: "about",
title: "About Us",
body: "<p>About content</p>",
status: "published"
});
renderPage("about");
await waitFor(() => {
expect(screen.getByRole("heading", { name: "About Us" })).toBeInTheDocument();
});
expect(screen.getByText("About content")).toBeInTheDocument();
});
});
+40
View File
@@ -0,0 +1,40 @@
import DOMPurify from "dompurify";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { getPageBySlug } from "@modules/content/api/contentApi";
const ALLOWED_TAGS = ["p", "h1", "h2", "h3", "h4", "ul", "ol", "li", "a", "strong", "em", "br", "img"];
export function ContentPage(): JSX.Element {
const { slug } = useParams();
const { data, isLoading, isError } = useQuery({
queryKey: ["content-page", slug],
queryFn: () => getPageBySlug(slug ?? ""),
enabled: Boolean(slug)
});
if (isLoading) {
return (
<main>
<p>Loading...</p>
</main>
);
}
if (isError || !data) {
return (
<main>
<h1>Page not found</h1>
</main>
);
}
const safeHtml = DOMPurify.sanitize(data.body, { ALLOWED_TAGS });
return (
<main>
<h1>{data.title}</h1>
<article dangerouslySetInnerHTML={{ __html: safeHtml }} />
</main>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { forgotPassword } from "@modules/auth/api/authApi";
import { formatAuthError } from "@modules/auth/utils/formatAuthError";
import { AuthLayout } from "@modules/auth/components/AuthLayout";
import { AuthField } from "@modules/auth/components/AuthField";
import { AuthMessage } from "@modules/auth/components/AuthMessage";
import { AuthSubmit } from "@modules/auth/components/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>
);
}
export function ForgotPasswordPage(): JSX.Element {
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
return (
<AuthLayout title="Восстановление пароля">
<form
className="z-login-form"
onSubmit={async (event) => {
event.preventDefault();
setMessage("");
setError("");
setIsSubmitting(true);
try {
const data = await forgotPassword(email);
setMessage(data.message ?? "If email is registered, reset instructions have been sent.");
} catch (forgotError) {
setError(formatAuthError(forgotError, "Request failed"));
} finally {
setIsSubmitting(false);
}
}}
>
<AuthField
icon={<UserIcon />}
placeholder="Email"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
<AuthSubmit loading={isSubmitting} loadingText="Sending..." idleText="Send reset link" />
{message ? <AuthMessage type="success" text={message} /> : null}
{error ? <AuthMessage type="error" text={error} /> : null}
<p className="z-login-meta">
<Link className="z-login-link" to="/login">
Back to login
</Link>
</p>
</form>
</AuthLayout>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { HomePage } from "./HomePage";
describe("HomePage", () => {
it("renders landing sections", () => {
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>
);
expect(screen.getByRole("heading", { name: "Organic Tech / Compton" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "About Compton" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Contacts" })).toBeInTheDocument();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { HeroSection, MarqueeSection, BrandSection, ContactsSection } from "@modules/landing";
export function HomePage(): JSX.Element {
return (
<main>
<HeroSection />
<MarqueeSection />
<BrandSection />
<ContactsSection />
</main>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { LoginPage } from "./LoginPage";
describe("LoginPage", () => {
it("renders login heading", () => {
render(
<MemoryRouter>
<LoginPage />
</MemoryRouter>
);
expect(screen.getByRole("heading", { name: "Вход" })).toBeInTheDocument();
});
});
+10
View File
@@ -0,0 +1,10 @@
import { LoginForm } from "@modules/auth";
import { AuthLayout } from "@modules/auth/components/AuthLayout";
export function LoginPage(): JSX.Element {
return (
<AuthLayout title="Вход">
<LoginForm />
</AuthLayout>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import { ProfileCard, ProfileEditor } from "@modules/profile";
import { getProfile } from "@modules/profile/api/profileApi";
export default function ProfilePage(): JSX.Element {
const { data, isLoading } = useQuery({
queryKey: ["profile-me"],
queryFn: getProfile
});
if (isLoading || !data) {
return (
<main>
<p>Loading profile...</p>
</main>
);
}
return (
<main style={{ display: "grid", gap: "2rem", padding: "1.5rem" }}>
<ProfileCard
email={data.user.email}
displayName={data.profile.display_name}
avatarUrl={data.profile.avatar_url}
/>
<ProfileEditor profile={data} />
</main>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { RegisterPage } from "./RegisterPage";
describe("RegisterPage", () => {
it("renders register heading", () => {
render(
<MemoryRouter>
<RegisterPage />
</MemoryRouter>
);
expect(screen.getByRole("heading", { name: "Регистрация" })).toBeInTheDocument();
});
});
+10
View File
@@ -0,0 +1,10 @@
import { RegisterForm } from "@modules/auth";
import { AuthLayout } from "@modules/auth/components/AuthLayout";
export function RegisterPage(): JSX.Element {
return (
<AuthLayout title="Регистрация">
<RegisterForm />
</AuthLayout>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { resetPassword } from "@modules/auth/api/authApi";
import { formatAuthError } from "@modules/auth/utils/formatAuthError";
import { AuthField } from "@modules/auth/components/AuthField";
import { AuthLayout } from "@modules/auth/components/AuthLayout";
import { AuthMessage } from "@modules/auth/components/AuthMessage";
import { AuthSubmit } from "@modules/auth/components/AuthSubmit";
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 ResetPasswordPage(): JSX.Element {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [password, setPassword] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const token = searchParams.get("token") ?? "";
return (
<AuthLayout title="Сброс пароля">
<form
className="z-login-form"
onSubmit={async (event) => {
event.preventDefault();
setMessage("");
setError("");
setIsSubmitting(true);
try {
await resetPassword(token, password);
setMessage("Password updated. You can log in now.");
setTimeout(() => navigate("/login"), 1200);
} catch (resetError) {
setError(formatAuthError(resetError, "Password reset failed"));
} finally {
setIsSubmitting(false);
}
}}
>
{!token ? <AuthMessage type="error" text="Reset token is missing." /> : null}
<AuthField
icon={<LockIcon />}
placeholder="New password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
minLength={8}
disabled={!token}
/>
<p className="z-login-meta">
Password: at least 8 characters with uppercase, lowercase, and a digit.
</p>
<AuthSubmit loading={isSubmitting} idleText="Reset password" loadingText="Saving..." disabled={!token} />
{message ? <AuthMessage type="success" text={message} /> : null}
{error ? <AuthMessage type="error" text={error} /> : null}
<p className="z-login-meta">
<Link className="z-login-link" to="/login">
Back to login
</Link>
</p>
</form>
</AuthLayout>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { verifyEmail } from "@modules/auth/api/authApi";
import { formatAuthError } from "@modules/auth/utils/formatAuthError";
import { AuthLayout } from "@modules/auth/components/AuthLayout";
import { AuthMessage } from "@modules/auth/components/AuthMessage";
export function VerifyPage(): JSX.Element {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [message, setMessage] = useState("Verifying your email...");
const [error, setError] = useState("");
useEffect(() => {
const token = searchParams.get("token");
if (!token) {
setError("Verification token is missing.");
setMessage("");
return;
}
void verifyEmail(token)
.then(() => {
setMessage("Email verified. You can log in now.");
setError("");
})
.catch((verifyError) => {
setMessage("");
setError(formatAuthError(verifyError, "Verification failed"));
});
}, [searchParams]);
return (
<AuthLayout title="Подтверждение email">
<div className="z-login-form">
{message ? <AuthMessage type="success" text={message} /> : null}
{error ? <AuthMessage type="error" text={error} /> : null}
<button className="z-login-submit" type="button" onClick={() => navigate("/login")}>
Go to login
</button>
<p className="z-login-meta">
<Link className="z-login-link" to="/">
Back home
</Link>
</p>
</div>
</AuthLayout>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { applyAuthHeader, setAccessTokenGetter } from "./client";
describe("apiClient auth header", () => {
it("attaches bearer token from getter", () => {
setAccessTokenGetter(() => "test-token");
const headers = applyAuthHeader({});
expect(headers.Authorization).toBe("Bearer test-token");
});
it("leaves headers unchanged without token", () => {
setAccessTokenGetter(() => null);
const headers = applyAuthHeader({ "X-Test": "1" });
expect(headers).toEqual({ "X-Test": "1" });
});
});
+94
View File
@@ -0,0 +1,94 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
import { useAuthStore } from "@modules/auth/store/authStore";
import { getApiBaseUrl } from "./config";
const apiBaseUrl = getApiBaseUrl();
export const apiClient = axios.create({
baseURL: apiBaseUrl,
timeout: 10_000
});
export const authClient = axios.create({
baseURL: apiBaseUrl,
timeout: 10_000,
withCredentials: true
});
let getAccessToken: () => string | null = () => null;
let refreshPromise: Promise<string | null> | null = null;
export function setAccessTokenGetter(getter: () => string | null): void {
getAccessToken = getter;
}
export function applyAuthHeader(headers: Record<string, string>): Record<string, string> {
const token = getAccessToken();
if (token) {
return { ...headers, Authorization: `Bearer ${token}` };
}
return headers;
}
export async function bootstrapSessionRefresh(): Promise<string | null> {
return refreshAccessToken();
}
async function refreshAccessToken(): Promise<string | null> {
if (!refreshPromise) {
refreshPromise = authClient
.post("/api/v1/auth/refresh")
.then((response) => {
const { access_token: accessToken, user } = response.data;
useAuthStore.getState().setSession(accessToken, user);
return accessToken as string;
})
.catch(() => {
useAuthStore.getState().clearSession();
return null;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
apiClient.interceptors.request.use((config) => {
config.headers = applyAuthHeader(config.headers as Record<string, string>) as typeof config.headers;
return config;
});
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const detail =
error.response && typeof error.response.data === "object" && error.response.data !== null
? (error.response.data as { detail?: string }).detail
: undefined;
if (
error.response?.status === 403 &&
(detail === "ACCOUNT_BLOCKED" || detail === "EMAIL_NOT_VERIFIED")
) {
useAuthStore.getState().clearSession();
return Promise.reject(error);
}
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
if (error.response?.status !== 401 || !originalRequest || originalRequest._retry) {
return Promise.reject(error);
}
if (originalRequest.url?.includes("/api/v1/auth/")) {
return Promise.reject(error);
}
originalRequest._retry = true;
const accessToken = await refreshAccessToken();
if (!accessToken) {
return Promise.reject(error);
}
originalRequest.headers = applyAuthHeader(
originalRequest.headers as Record<string, string>
) as typeof originalRequest.headers;
return apiClient.request(originalRequest);
}
);
+9
View File
@@ -0,0 +1,9 @@
export function getApiBaseUrl(): string {
if (import.meta.env.VITE_USE_API_PROXY === "false") {
return import.meta.env.VITE_API_URL ?? "http://localhost:8000";
}
if (import.meta.env.DEV || import.meta.env.VITE_USE_API_PROXY === "true") {
return "";
}
return import.meta.env.VITE_API_URL ?? "http://localhost:8000";
}
@@ -0,0 +1,19 @@
import { useEffect, type PropsWithChildren } from "react";
export function ZootechThemeProvider({ children }: PropsWithChildren): JSX.Element {
useEffect(() => {
const root = document.documentElement;
const previousTheme = root.getAttribute("data-theme");
root.setAttribute("data-theme", "organic");
return () => {
if (previousTheme) {
root.setAttribute("data-theme", previousTheme);
return;
}
root.removeAttribute("data-theme");
};
}, []);
return <>{children}</>;
}
@@ -0,0 +1,113 @@
@import "../../../../main/css/tokens.css";
.z-login-shell {
min-height: 100vh;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: var(--bg-page);
color: var(--text);
font-family: var(--zootech-font);
}
.z-login-page {
width: 100%;
max-width: 420px;
}
.z-login-card {
background: var(--surface);
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-lg);
box-shadow: var(--shadow-soft);
padding: 24px;
}
.z-login-title {
margin: 0 0 16px;
font-size: 1.5rem;
font-weight: 600;
}
.z-login-form {
display: grid;
gap: 12px;
}
.z-login-field {
position: relative;
}
.z-login-field-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--muted);
display: inline-flex;
}
.z-login-input {
width: 100%;
border: 1px solid var(--border-light);
border-radius: 10px;
background: var(--primary-tint);
color: var(--text);
padding: 12px 12px 12px 40px;
font-size: 0.95rem;
}
.z-login-submit {
border: 0;
border-radius: 999px;
background: var(--primary);
color: #fff;
font-size: 0.95rem;
font-weight: 600;
padding: 12px 14px;
cursor: pointer;
}
.z-login-submit:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.z-login-meta,
.z-login-msg {
margin: 0;
font-size: 0.9rem;
}
.z-login-meta {
color: var(--muted);
}
.z-login-msg--error {
color: var(--color-error);
}
.z-login-msg--success {
color: var(--color-success);
}
.z-login-remember {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: var(--muted);
}
.z-login-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.z-login-link {
color: inherit;
}
@@ -0,0 +1,47 @@
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "@modules/auth";
import { Button } from "@shared/ui";
export function AppHeader(): JSX.Element {
const auth = useAuth();
const navigate = useNavigate();
return (
<header
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "1rem 1.5rem",
borderBottom: "1px solid var(--muted)"
}}
>
<nav style={{ display: "flex", gap: "1rem" }}>
<a href="/">Home</a>
{auth.isAuthenticated ? (
<>
<Link to="/profile">Profile</Link>
{auth.user?.role === "admin" ? <Link to="/admin">Admin</Link> : null}
</>
) : (
<>
<Link to="/login">Login</Link>
<Link to="/register">Register</Link>
</>
)}
</nav>
{auth.isAuthenticated ? (
<Button
type="button"
onClick={() => {
void auth.logout().then(() => {
navigate("/login", { replace: true });
});
}}
>
Logout
</Button>
) : null}
</header>
);
}
@@ -0,0 +1,10 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Button } from "./Button";
describe("Button", () => {
it("renders button text", () => {
render(<Button>Click</Button>);
expect(screen.getByRole("button", { name: "Click" })).toBeInTheDocument();
});
});
+42
View File
@@ -0,0 +1,42 @@
import type { ButtonHTMLAttributes, CSSProperties, PropsWithChildren } from "react";
type Variant = "primary" | "secondary" | "ghost";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
}
const variantStyles: Record<Variant, string> = {
primary: "background: var(--primary); color: white; border: 0;",
secondary: "background: var(--marquee-bg); color: var(--foreground); border: 0;",
ghost: "background: transparent; color: var(--foreground); border: 1px solid var(--muted);"
};
export function Button({
children,
variant = "primary",
...props
}: PropsWithChildren<ButtonProps>): JSX.Element {
return (
<button
{...props}
style={{
padding: "0.65rem 1rem",
borderRadius: 8,
cursor: "pointer",
...(Object.fromEntries(
variantStyles[variant]
.split(";")
.map((rule) => rule.trim())
.filter(Boolean)
.map((rule) => {
const [key, value] = rule.split(":");
return [key.trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase()), value.trim()];
})
) as CSSProperties)
}}
>
{children}
</button>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { InputHTMLAttributes } from "react";
export function Input(props: InputHTMLAttributes<HTMLInputElement>): JSX.Element {
return (
<input
{...props}
style={{
width: "100%",
padding: "0.6rem 0.75rem",
borderRadius: 8,
border: "1px solid var(--muted)"
}}
/>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { Button } from "./Button/Button";
export { Input } from "./Input/Input";
export { AppHeader } from "./AppHeader/AppHeader";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />