Initial commit: site monorepo with API, web, and infra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
влад
2026-07-16 10:11:54 +03:00
co-authored by Cursor
commit 016910ffb7
447 changed files with 73972 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;
+45
View File
@@ -0,0 +1,45 @@
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";
const REACT_ONLY_PREFIXES = [
"/login",
"/register",
"/profile",
"/platform",
"/verify",
"/reset-password",
"/forgot-password",
"/pages",
"/enterprise"
];
function isReactShellRoute(pathname: string): boolean {
return REACT_ONLY_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
}
function AppShell(): JSX.Element {
const location = useLocation();
const hideHeader = isReactShellRoute(location.pathname);
useLayoutEffect(() => {
document.documentElement.classList.toggle("admin-route", location.pathname.startsWith("/platform"));
}, [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={["/platform"]}>
<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={["/platform"]}>
<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 | null {
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 | null {
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 | null {
const bootstrapped = useAuthStore((state) => state.bootstrapped);
const auth = useAuth();
if (!bootstrapped) {
return null;
}
if (auth.isAuthenticated) {
return <Navigate to={auth.user?.role === "admin" ? "/recipes" : "/profile"} replace />;
}
return <>{children}</>;
}
+76
View File
@@ -0,0 +1,76 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
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 WespLoginRedirect from "@pages/WespLoginRedirect";
import EnterpriseCabinetPage from "@pages/EnterpriseCabinetPage";
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>
<WespLoginRedirect />
</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="/platform"
element={
<AdminGuard>
<AdminPage />
</AdminGuard>
}
/>
<Route
path="/enterprise/:slug"
element={
<AuthGuard>
<EnterpriseCabinetPage />
</AuthGuard>
}
/>
<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,118 @@
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 (url: string) => {
if (url === "/api/v1/admin/settings") {
return {
data: {
values: { enable_docs: false },
locks: {},
settings_path: "data/compton_settings.json",
secrets: {}
}
};
}
return {
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 })).settings_path).toBe("data/compton_settings.json");
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,58 @@
import { useEffect, useState } from "react";
import { Alert } from "antd";
import { apiClient } from "@shared/api/client";
type EnterpriseRow = { id: string; name: string; slug: string; status: string };
export function AdminEnterprisesPanel(): JSX.Element {
const [rows, setRows] = useState<EnterpriseRow[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
void (async () => {
try {
const { data } = await apiClient.get<EnterpriseRow[]>("/api/v1/admin/enterprises");
setRows(Array.isArray(data) ? data : []);
setError(null);
} catch (loadError) {
const message = loadError instanceof Error ? loadError.message : "Failed to load enterprises";
setError(message);
} finally {
setLoading(false);
}
})();
}, []);
if (loading) {
return <p className="wesp-admin-block">Loading enterprises...</p>;
}
if (error) {
return <Alert type="error" message={error} />;
}
return (
<div>
<h2>Enterprises</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td>{r.name}</td>
<td>{r.slug}</td>
<td>{r.status}</td>
</tr>
))}
</tbody>
</table>
</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,60 @@
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";
import { AdminEnterprisesPanel } from "./AdminEnterprisesPanel";
import { AdminSyncPanel } from "./AdminSyncPanel";
function AdminTabPanel({ tab, isSuperuser }: { tab: AdminTab; isSuperuser: boolean }): JSX.Element | null {
switch (tab) {
case "users":
return <AdminUsersTable />;
case "content":
return <AdminContentPanel />;
case "enterprises":
return isSuperuser ? <AdminEnterprisesPanel /> : null;
case "sync":
return isSuperuser ? <AdminSyncPanel /> : null;
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,110 @@
import {
AlertOutlined,
ApartmentOutlined,
FileTextOutlined,
FundOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SafetyOutlined,
SyncOutlined,
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 />,
enterprises: <ApartmentOutlined />,
sync: <SyncOutlined />,
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,36 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, within } 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("registrations today")).toBeInTheDocument();
expect((await screen.findAllByText("users")).length).toBeGreaterThanOrEqual(1);
const statCards = document.querySelectorAll(".wesp-admin-stat-card");
expect(statCards).toHaveLength(4);
expect(within(statCards[0] as HTMLElement).getByText("10")).toBeInTheDocument();
expect(within(statCards[1] as HTMLElement).getByText("3")).toBeInTheDocument();
expect(within(statCards[2] as HTMLElement).getByText("2")).toBeInTheDocument();
expect(within(statCards[3] as HTMLElement).getByText("1")).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,63 @@
import { useEffect, useState } from "react";
import { Alert } from "antd";
import { apiClient } from "@shared/api/client";
type SyncRow = {
enterprise_id: string;
name: string;
slug: string;
outbox_pending: number;
conflicts_pending: number;
max_event_seq: number;
hubs: Array<{ name: string; sync_lag: number; last_seen: string | null }>;
};
export function AdminSyncPanel(): JSX.Element {
const [rows, setRows] = useState<SyncRow[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
void (async () => {
try {
const { data } = await apiClient.get<SyncRow[]>("/api/v1/admin/sync-metrics");
setRows(Array.isArray(data) ? data : []);
setError(null);
} catch (loadError) {
const message = loadError instanceof Error ? loadError.message : "Failed to load sync metrics";
setError(message);
} finally {
setLoading(false);
}
})();
}, []);
if (loading) {
return <p className="wesp-admin-block">Loading sync metrics...</p>;
}
if (error) {
return <Alert type="error" message={error} />;
}
return (
<div>
<h2>Sync metrics</h2>
{rows.length === 0 ? <p className="text-muted">No enterprises yet.</p> : null}
{rows.map((r) => (
<div key={r.enterprise_id} style={{ marginBottom: 16 }}>
<strong>{r.name}</strong> outbox {r.outbox_pending}, conflicts {r.conflicts_pending}, seq{" "}
{r.max_event_seq}
<ul>
{r.hubs.map((h) => (
<li key={h.name}>
{h.name}: lag {h.sync_lag}
{h.last_seen ? ` · ${h.last_seen}` : " · offline"}
</li>
))}
</ul>
</div>
))}
</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,21 @@
export type AdminTab = "users" | "content" | "security" | "diagnostics" | "activity" | "enterprises" | "sync";
export interface AdminTabConfig {
id: AdminTab;
label: string;
superuserOnly?: boolean;
}
export const ADMIN_TABS: AdminTabConfig[] = [
{ id: "users", label: "Users" },
{ id: "content", label: "Content" },
{ id: "enterprises", label: "Enterprises", superuserOnly: true },
{ id: "sync", label: "Sync metrics", superuserOnly: true },
{ 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");
});
});
+85
View File
@@ -0,0 +1,85 @@
import { authClient, applyAuthHeader } 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(accessToken?: string | null) {
const headers = accessToken ? applyAuthHeader({}) : {};
await authClient.post("/api/v1/auth/logout", undefined, { headers });
}
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,17 @@
export async function fetchDefaultEnterpriseId(accessToken: string): Promise<string | null> {
try {
const resp = await fetch("/api/v1/enterprise/enterprises", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!resp.ok) {
return null;
}
const data = (await resp.json()) as Array<{ id: string }>;
if (!Array.isArray(data) || !data.length) {
return null;
}
return data[0]?.id ?? null;
} catch {
return null;
}
}
@@ -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,107 @@
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);
if (user.role === "admin") {
window.location.assign("/recipes");
return;
}
navigate("/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,36 @@
import { useAuthStore } from "../store/authStore";
import type { AuthUser } from "../store/authStore";
import { login as apiLogin, logout as apiLogout, refresh as apiRefresh } from "../api/authApi";
import { fetchDefaultEnterpriseId } from "../api/enterpriseContext";
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);
async function bindSession(accessToken: string, authUser: AuthUser) {
const enterpriseId = await fetchDefaultEnterpriseId(accessToken);
setSession(accessToken, authUser, enterpriseId);
return authUser;
}
return {
user,
isAuthenticated: Boolean(user && accessToken),
bootstrapped,
async login(email: string, password: string) {
const data = await apiLogin({ email, password });
return bindSession(data.access_token, data.user as AuthUser);
},
async logout() {
await apiLogout(accessToken);
clearSession();
},
async refreshSession() {
const data = await apiRefresh();
await bindSession(data.access_token, data.user as AuthUser);
}
};
}
@@ -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("/platform")).toBe(true);
expect(isProtectedAppPath("/platform/users")).toBe(true);
expect(isProtectedAppPath("/profile")).toBe(true);
expect(isProtectedAppPath("/login")).toBe(false);
});
it("attempts refresh on protected paths even without hint", () => {
expect(shouldAttemptAuthRefresh("/platform")).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 = ["/platform", "/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,68 @@
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;
}
const TOKEN_KEY = "compton.access_token";
const USER_KEY = "compton.user";
const ENTERPRISE_KEY = "compton.enterprise_id";
function persistWespSession(accessToken: string, user: AuthUser, enterpriseId?: string | null): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(TOKEN_KEY, accessToken);
window.localStorage.setItem(USER_KEY, JSON.stringify(user));
const resolvedEnterprise = enterpriseId ?? window.localStorage.getItem(ENTERPRISE_KEY) ?? "";
if (resolvedEnterprise) {
window.localStorage.setItem(ENTERPRISE_KEY, resolvedEnterprise);
}
const w = window as Window & { __WESP_ORCH__?: Record<string, string> };
w.__WESP_ORCH__ = Object.assign({}, w.__WESP_ORCH__ || {}, {
accessToken,
enterpriseId: resolvedEnterprise,
userEmail: user.email,
});
}
function clearWespSession(): void {
if (typeof window === "undefined") return;
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(USER_KEY);
window.localStorage.removeItem(ENTERPRISE_KEY);
}
interface AuthState {
accessToken: string | null;
user: AuthUser | null;
bootstrapped: boolean;
setSession: (accessToken: string, user: AuthUser, enterpriseId?: string | null) => void;
clearSession: () => void;
setBootstrapped: (bootstrapped: boolean) => void;
}
export const useAuthStore = create<AuthState>()((set) => ({
accessToken: null,
user: null,
bootstrapped: false,
setSession: (accessToken, user, enterpriseId) => {
markAuthSessionHint();
persistWespSession(accessToken, user, enterpriseId);
set({ accessToken, user });
},
clearSession: () => {
clearAuthSessionHint();
clearWespSession();
set({ accessToken: null, user: null });
},
setBootstrapped: (bootstrapped) => set({ bootstrapped })
}));
export { TOKEN_KEY, USER_KEY, ENTERPRISE_KEY };
@@ -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>
);
}
@@ -0,0 +1,163 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { apiClient } from "@shared/api/client";
type Farm = {
id: string;
name: string;
hub_site_id: string;
status: string;
last_seen: string | null;
};
type Member = {
user_id: string;
role: string;
email: string | null;
farm_ids: string[];
};
type SyncStatus = {
outbox_pending: number;
conflicts_pending: number;
hubs: Array<Farm & { sync_lag?: number }>;
};
export default function EnterpriseCabinetPage(): JSX.Element {
const { slug } = useParams();
const [farms, setFarms] = useState<Farm[]>([]);
const [members, setMembers] = useState<Member[]>([]);
const [enterpriseId, setEnterpriseId] = useState<string>("");
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [pairCode, setPairCode] = useState<string>("");
const [pairingBusy, setPairingBusy] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState("viewer");
useEffect(() => {
void (async () => {
const ents = await apiClient.get<Array<{ id: string; slug: string }>>("/api/v1/enterprise/enterprises");
const ent = ents.find((e) => e.slug === slug);
if (!ent) return;
setEnterpriseId(ent.id);
const [list, status, mems] = await Promise.all([
apiClient.get<Farm[]>(`/api/v1/enterprise/enterprises/${ent.id}/farms`),
apiClient.get<SyncStatus>(`/api/v1/enterprise/enterprises/${ent.id}/sync-status`),
apiClient.get<Member[]>(`/api/v1/enterprise/enterprises/${ent.id}/members`),
]);
setFarms(list);
setSyncStatus(status);
setMembers(mems);
})();
}, [slug]);
async function startPairing(): Promise<void> {
if (!enterpriseId) return;
setPairingBusy(true);
try {
const resp = await apiClient.post<{ code: string }>(
`/api/v1/enterprise/pair/start`,
{ enterprise_id: enterpriseId, farm_name: "New hub" },
);
setPairCode(resp.code);
} finally {
setPairingBusy(false);
}
}
async function inviteMember(): Promise<void> {
if (!enterpriseId || !inviteEmail.trim()) return;
await apiClient.post(`/api/v1/enterprise/enterprises/${enterpriseId}/members`, {
email: inviteEmail.trim(),
role: inviteRole,
});
const mems = await apiClient.get<Member[]>(`/api/v1/enterprise/enterprises/${enterpriseId}/members`);
setMembers(mems);
setInviteEmail("");
}
return (
<div style={{ padding: 24, maxWidth: 960 }}>
<h1>Enterprise: {slug}</h1>
<p>Pairing, members, farms, sync status. Conflicts в K-hub «Мультисервер».</p>
<section style={{ marginTop: 24 }}>
<h2>Pair hub</h2>
<button type="button" onClick={() => void startPairing()} disabled={pairingBusy || !enterpriseId}>
{pairingBusy ? "Generating…" : "Generate pairing code"}
</button>
{pairCode ? (
<p>
Pairing code: <strong>{pairCode}</strong> (enter on hub admin within 15 min)
</p>
) : null}
</section>
<section style={{ marginTop: 24 }}>
<h2>Members</h2>
<ul>
{members.map((m) => (
<li key={m.user_id}>
{m.email ?? m.user_id} {m.role}
{m.farm_ids.length ? ` · farms: ${m.farm_ids.length}` : ""}
</li>
))}
</ul>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<input
type="email"
placeholder="user@example.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
/>
<select value={inviteRole} onChange={(e) => setInviteRole(e.target.value)}>
<option value="viewer">viewer</option>
<option value="zootech">zootech</option>
<option value="admin">admin</option>
</select>
<button type="button" onClick={() => void inviteMember()} disabled={!inviteEmail.trim()}>
Add member
</button>
</div>
</section>
<section style={{ marginTop: 24 }}>
<h2>Sync status</h2>
{syncStatus ? (
<ul>
<li>Outbox pending: {syncStatus.outbox_pending}</li>
<li>Conflicts pending: {syncStatus.conflicts_pending}</li>
{syncStatus.hubs.map((h) => (
<li key={h.id}>
{h.name}: lag {h.sync_lag ?? 0}
{h.last_seen ? ` · ${h.last_seen}` : " · offline"}
</li>
))}
</ul>
) : (
<p>Loading</p>
)}
</section>
<section style={{ marginTop: 24 }}>
<h2>Farms</h2>
<ul>
{farms.map((f) => (
<li key={f.id}>
{f.name} ({f.hub_site_id}) {f.status}
{f.last_seen ? ` · last seen ${f.last_seen}` : " · offline"}
</li>
))}
</ul>
</section>
{enterpriseId ? (
<p style={{ marginTop: 24 }}>
<a href={`/recipes?enterprise_id=${encodeURIComponent(enterpriseId)}`}>
Zootech UI (WESP copy)
</a>
</p>
) : null}
</div>
);
}
+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>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { useEffect } from "react";
/** Full-page redirect to WESP static login (orchestrator 1:1). */
export default function WespLoginRedirect(): null {
useEffect(() => {
window.location.replace("/login");
}, []);
return null;
}
+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" });
});
});
+98
View File
@@ -0,0 +1,98 @@
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);
}
if (error.response?.status === 401 && detail === "TOKEN_REVOKED") {
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,53 @@
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>
<a href="/recipes">Zootech</a>
{auth.user?.role === "admin" ? (
<>
<a href="/admin">Admin</a>
{auth.user.is_superuser ? <Link to="/platform">Platform</Link> : null}
</>
) : 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>
);
}

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