Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false
|
||||
})
|
||||
});
|
||||
|
||||
const originalGetComputedStyle = window.getComputedStyle.bind(window);
|
||||
window.getComputedStyle = ((element: Element) => originalGetComputedStyle(element)) as typeof window.getComputedStyle;
|
||||
@@ -0,0 +1,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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import type { JSX as ReactJSX } from "react";
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
type Element = ReactJSX.Element;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "@app/App";
|
||||
import "antd/dist/reset.css";
|
||||
import "@app/styles/globals.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export { AdminPanel } from "./components/AdminPanel";
|
||||
export { AdminStats } from "./components/AdminStats";
|
||||
export { AdminUsersTable } from "./components/AdminUsersTable";
|
||||
export { AdminContentPanel } from "./components/AdminContentPanel";
|
||||
export {
|
||||
createAdminUser,
|
||||
deleteAdminUser,
|
||||
getAdminActivityFeed,
|
||||
getAdminDiagnostics,
|
||||
getAdminServerLog,
|
||||
getAdminSettings,
|
||||
getAdminStats,
|
||||
getAdminSummary,
|
||||
getAdminUsers,
|
||||
patchAdminSettings,
|
||||
patchAdminUser,
|
||||
postAdminUiActivity,
|
||||
resetAdminUserPassword
|
||||
} from "./api/adminApi";
|
||||
@@ -0,0 +1,898 @@
|
||||
@import "../../../../main/css/tokens.css";
|
||||
|
||||
html.admin-route body {
|
||||
background: var(--wesp-admin-bg);
|
||||
}
|
||||
|
||||
html.admin-route .ant-layout-sider-trigger {
|
||||
background: var(--marquee-bg) !important;
|
||||
color: var(--foreground) !important;
|
||||
border-top: 1px solid var(--border-light) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-layout-sider-trigger:hover {
|
||||
background: var(--primary-tint) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-card {
|
||||
background: var(--wesp-admin-surface) !important;
|
||||
border-color: var(--border-light) !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: var(--admin-card-radius) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.admin-route .ant-btn-primary {
|
||||
background: var(--wesp-admin-primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-btn-dangerous,
|
||||
html.admin-route .ant-btn-dangerous.ant-btn-primary {
|
||||
background: #cd3838 !important;
|
||||
border-color: transparent !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-btn-dangerous:hover,
|
||||
html.admin-route .ant-btn-dangerous.ant-btn-primary:hover {
|
||||
background: #e94b4b !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-input,
|
||||
html.admin-route .ant-input-affix-wrapper,
|
||||
html.admin-route .ant-select-selector,
|
||||
html.admin-route .ant-input-number {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--border-light) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-select-dropdown,
|
||||
html.admin-route .ant-modal-content,
|
||||
html.admin-route .ant-modal-header {
|
||||
background: var(--surface) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-modal-title,
|
||||
html.admin-route .ant-modal-close {
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-switch.ant-switch-checked {
|
||||
background: var(--wesp-admin-primary) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-typography.ant-typography-secondary,
|
||||
html.admin-route .ant-list-item-meta-description {
|
||||
color: var(--muted) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-table {
|
||||
background: var(--surface) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-table-thead > tr > th {
|
||||
background: var(--marquee-bg) !important;
|
||||
color: var(--muted) !important;
|
||||
border-bottom-color: var(--border-light) !important;
|
||||
}
|
||||
|
||||
html.admin-route .ant-table-tbody > tr > td {
|
||||
border-bottom-color: var(--border-light) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--wesp-admin-bg) !important;
|
||||
--wesp-admin-sider-width: 80px;
|
||||
}
|
||||
|
||||
.wesp-admin-layout.wesp-admin-sider-expanded {
|
||||
--wesp-admin-sider-width: 256px;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider {
|
||||
background: var(--admin-sider-bg) !important;
|
||||
border-right: 1px solid var(--admin-stroke);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
flex: 0 0 var(--wesp-admin-sider-width) !important;
|
||||
max-width: var(--wesp-admin-sider-width) !important;
|
||||
min-width: var(--wesp-admin-sider-width) !important;
|
||||
width: var(--wesp-admin-sider-width) !important;
|
||||
transition:
|
||||
flex 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
max-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
min-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed {
|
||||
flex: 0 0 80px !important;
|
||||
max-width: 80px !important;
|
||||
min-width: 80px !important;
|
||||
width: 80px !important;
|
||||
}
|
||||
|
||||
.wesp-admin-layout > .ant-layout {
|
||||
margin-left: var(--wesp-admin-sider-width);
|
||||
min-height: 100vh;
|
||||
background: transparent;
|
||||
transition: margin-left 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-layout-sider-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-menu {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 10px 0 8px !important;
|
||||
background: transparent !important;
|
||||
border-inline-end: none !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-menu-item {
|
||||
cursor: pointer;
|
||||
margin: 4px 8px !important;
|
||||
width: calc(100% - 16px) !important;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 40px !important;
|
||||
line-height: 40px !important;
|
||||
border-radius: 8px;
|
||||
color: var(--wesp-admin-text) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-menu-item:hover {
|
||||
background: var(--admin-primary-hover) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-menu-item-selected {
|
||||
background: var(--admin-primary-selected) !important;
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-menu-item .anticon {
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-menu-item-selected .anticon {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.wesp-admin-sider .wesp-menu-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
max-width: 16rem;
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.1s,
|
||||
max-width 0.36s cubic-bezier(0.4, 0, 0.2, 1) 0.08s,
|
||||
margin 0.25s ease,
|
||||
padding 0.25s ease;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .wesp-menu-label {
|
||||
max-width: 0 !important;
|
||||
opacity: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
gap: 0;
|
||||
margin-inline: 8px !important;
|
||||
width: calc(100% - 16px) !important;
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item .ant-menu-item-icon {
|
||||
margin-inline: 0 !important;
|
||||
flex: none !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-title-content {
|
||||
width: 0 !important;
|
||||
overflow: hidden !important;
|
||||
opacity: 0 !important;
|
||||
flex: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-inline-collapsed > .ant-menu-item {
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider:not(.ant-layout-sider-collapsed) .ant-menu-item {
|
||||
padding-inline: 12px !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-layout-sider-trigger,
|
||||
.wesp-admin-sider.ant-layout-sider-light .ant-layout-sider-trigger,
|
||||
.wesp-admin-sider.ant-layout-sider-dark .ant-layout-sider-trigger {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100% !important;
|
||||
height: 48px !important;
|
||||
line-height: 48px !important;
|
||||
border-top: 1px solid var(--border-light) !important;
|
||||
background: var(--marquee-bg) !important;
|
||||
color: var(--foreground) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-layout-sider-trigger:hover,
|
||||
.wesp-admin-sider.ant-layout-sider-light .ant-layout-sider-trigger:hover,
|
||||
.wesp-admin-sider.ant-layout-sider-dark .ant-layout-sider-trigger:hover {
|
||||
background: var(--primary-tint) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.wesp-sider-trigger-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: var(--foreground);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.wesp-sider-trigger-icon .anticon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ant-tooltip.ant-menu-inline-collapsed-tooltip .ant-tooltip-inner {
|
||||
background: var(--surface);
|
||||
color: var(--foreground);
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.ant-tooltip.ant-menu-inline-collapsed-tooltip .ant-tooltip-arrow::before {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.wesp-admin-content {
|
||||
margin: 16px;
|
||||
padding: 0;
|
||||
background: transparent !important;
|
||||
color: var(--wesp-admin-text);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wesp-admin-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wesp-admin-content-topbar {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.wesp-admin-content-topbar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wesp-admin-content-topbar .ant-btn {
|
||||
border-radius: 20px;
|
||||
background: var(--admin-btn-bg);
|
||||
border-color: var(--admin-btn-border);
|
||||
color: var(--admin-btn-text);
|
||||
height: 32px;
|
||||
padding: 0 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wesp-admin-content-topbar .ant-btn-primary {
|
||||
background: var(--wesp-admin-primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.wesp-admin-panel-card.ant-card {
|
||||
border-radius: var(--admin-card-radius);
|
||||
border: 1px solid var(--border-light);
|
||||
background: var(--wesp-admin-surface);
|
||||
}
|
||||
|
||||
.wesp-admin-panel-card .ant-card-head {
|
||||
border-bottom-color: var(--border-light);
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.wesp-admin-panel-card .ant-card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.wesp-admin-panel-card .ant-card-head-title,
|
||||
.wesp-admin-panel-card .ant-typography,
|
||||
.wesp-admin-grid .ant-typography,
|
||||
.wesp-admin-grid .ant-list-item-meta-title,
|
||||
.wesp-admin-grid .ant-list-item-meta-description {
|
||||
color: var(--wesp-admin-text) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-section {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wesp-admin-dashboard {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.wesp-dashboard-sticky.ant-card {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.wesp-dashboard-sticky.ant-card .ant-card-body {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.wesp-admin-gauges-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wesp-gauge {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wesp-gauge-ring {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.wesp-gauge-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wesp-gauge-track {
|
||||
stroke: var(--border-light);
|
||||
}
|
||||
|
||||
.wesp-gauge-fill {
|
||||
stroke: var(--wesp-admin-primary);
|
||||
transition: stroke-dashoffset 0.45s ease;
|
||||
}
|
||||
|
||||
.wesp-gauge-center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wesp-gauge-pct {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.wesp-gauge-pct-suffix {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
opacity: 0.55;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.wesp-gauge-ring--adm {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.wesp-gauge-caption {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.wesp-gauge-caption span[data-gauge-detail] {
|
||||
opacity: 0.75;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wesp-dash-service-row.ant-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.wesp-dash-service-row > .ant-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wesp-dash-service-row .wesp-dash-service-card {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wesp-dash-service-card .ant-card-body {
|
||||
padding-top: 12px;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wesp-dash-service-row .wesp-dash-manage-list {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-list {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px 16px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.wesp-dash-manage-row:first-child {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-row--service {
|
||||
margin-top: 4px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-info {
|
||||
flex: 1 1 140px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-title {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.wesp-dash-manage-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wesp-dash-wesp-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wesp-dash-wesp-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wesp-dash-wesp-label {
|
||||
opacity: 0.65;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wesp-dash-wesp-value {
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.wesp-dash-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wesp-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wesp-status-dot--ok {
|
||||
background: var(--wesp-admin-primary);
|
||||
}
|
||||
|
||||
.wesp-traffic-card .ant-card-body {
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
|
||||
.wesp-traffic-two-col {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 24px;
|
||||
}
|
||||
|
||||
.wesp-traffic-col {
|
||||
flex: 1 1 160px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wesp-traffic-label {
|
||||
font-size: 13px;
|
||||
opacity: 0.65;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.wesp-traffic-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.wesp-traffic-arrow,
|
||||
.wesp-traffic-icon {
|
||||
opacity: 0.75;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.wesp-admin-stats-row {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.wesp-tag {
|
||||
background: var(--marquee-bg) !important;
|
||||
border: 1px solid var(--border-light) !important;
|
||||
color: var(--muted) !important;
|
||||
}
|
||||
|
||||
.wesp-tag--ok {
|
||||
background: var(--primary-tint) !important;
|
||||
border-color: var(--admin-tag-ok-border) !important;
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.wesp-tag--warn {
|
||||
background: #fff8e6 !important;
|
||||
border-color: rgba(212, 167, 44, 0.45) !important;
|
||||
color: #9a6700 !important;
|
||||
}
|
||||
|
||||
.wesp-tag--error {
|
||||
background: #fff1f0 !important;
|
||||
border-color: rgba(205, 56, 56, 0.35) !important;
|
||||
color: #cd3838 !important;
|
||||
}
|
||||
|
||||
.wesp-admin-stat-card.ant-card {
|
||||
background: var(--wesp-admin-surface) !important;
|
||||
border: 1px solid var(--wesp-admin-stroke) !important;
|
||||
border-radius: var(--admin-card-radius);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.wesp-admin-stat-card.ant-card .ant-card-body {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.wesp-admin-stat-label {
|
||||
opacity: 0.65;
|
||||
font-size: 13px;
|
||||
color: var(--wesp-admin-soft);
|
||||
}
|
||||
|
||||
.wesp-admin-stat-value {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
margin-top: 6px;
|
||||
color: var(--wesp-admin-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.wesp-admin-grid .ant-card {
|
||||
background: var(--wesp-admin-surface) !important;
|
||||
border-color: var(--wesp-admin-stroke) !important;
|
||||
border-radius: var(--admin-card-radius) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-grid .ant-table {
|
||||
background: var(--wesp-admin-surface) !important;
|
||||
color: var(--wesp-admin-text) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-grid .ant-table-thead > tr > th {
|
||||
background: var(--admin-table-header) !important;
|
||||
border-bottom-color: var(--wesp-admin-stroke) !important;
|
||||
color: var(--wesp-admin-soft) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-grid .ant-table-tbody > tr > td {
|
||||
border-bottom-color: var(--border-light) !important;
|
||||
color: var(--wesp-admin-text) !important;
|
||||
}
|
||||
|
||||
.wesp-admin-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wesp-admin-log {
|
||||
margin: 0;
|
||||
background: linear-gradient(165deg, #0a0f0d 0%, #060807 48%, #0d1210 100%);
|
||||
color: #7ee787;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wesp-admin-block {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
html.wesp-admin-loading #root {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wesp-admin-skel-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
z-index: 2;
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
rgba(0, 0, 0, 0.02) 0%,
|
||||
rgba(0, 0, 0, 0.02) 34%,
|
||||
rgba(0, 0, 0, 0.05) 50%,
|
||||
rgba(0, 0, 0, 0.02) 66%,
|
||||
rgba(0, 0, 0, 0.02) 100%
|
||||
);
|
||||
background-size: 240% 100%;
|
||||
animation: wesp-admin-skel-shimmer 0.95s ease-in-out infinite;
|
||||
transition: opacity 0.38s ease;
|
||||
}
|
||||
|
||||
@keyframes wesp-admin-skel-shimmer {
|
||||
0% {
|
||||
background-position: 240% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -240% 0;
|
||||
}
|
||||
}
|
||||
|
||||
html.wesp-admin-loaded .wesp-admin-skel-overlay {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
html.wesp-admin-loaded .wesp-admin-block {
|
||||
animation: wesp-admin-block-reveal 0.52s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: calc(var(--wesp-reveal-i, 0) * 38ms);
|
||||
}
|
||||
|
||||
@keyframes wesp-admin-block-reveal {
|
||||
from {
|
||||
opacity: 0.55;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.wesp-admin-section-enter {
|
||||
animation: wesp-admin-section-tween 0.44s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes wesp-admin-section-tween {
|
||||
from {
|
||||
opacity: 0.45;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-tag {
|
||||
background: rgba(11, 10, 16, 0.45) !important;
|
||||
border: 1px solid rgba(186, 184, 208, 0.16) !important;
|
||||
color: rgba(236, 236, 241, 0.82) !important;
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-tag--ok {
|
||||
background: rgba(90, 154, 130, 0.18) !important;
|
||||
border-color: var(--admin-tag-ok-border) !important;
|
||||
color: var(--admin-success) !important;
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-tag--warn {
|
||||
background: rgba(232, 184, 74, 0.12) !important;
|
||||
border-color: rgba(232, 184, 74, 0.4) !important;
|
||||
color: #e8b84a !important;
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-tag--error {
|
||||
background: rgba(233, 75, 75, 0.14) !important;
|
||||
border-color: rgba(233, 75, 75, 0.35) !important;
|
||||
color: #ff9c9c !important;
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-admin-skel-overlay {
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
rgba(255, 255, 255, 0.02) 0%,
|
||||
rgba(255, 255, 255, 0.02) 34%,
|
||||
rgba(255, 255, 255, 0.07) 50%,
|
||||
rgba(255, 255, 255, 0.02) 66%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item-selected {
|
||||
color: var(--admin-success) !important;
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item-selected .anticon {
|
||||
color: var(--admin-success);
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item .anticon {
|
||||
color: var(--admin-soft);
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-gauge-track {
|
||||
stroke: rgba(186, 184, 208, 0.18);
|
||||
}
|
||||
|
||||
html.admin-route[data-admin-theme="dark"] .wesp-admin-log {
|
||||
background: linear-gradient(165deg, #0b0a10 0%, #15141c 48%, #0b0a10 100%);
|
||||
color: #6bc4a8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wesp-admin-layout.ant-layout-has-sider {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider {
|
||||
position: static;
|
||||
inset: auto;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
flex: 0 0 auto !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.wesp-admin-layout > .ant-layout {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-layout-sider-children {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.wesp-admin-sider .ant-layout-sider-trigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .wesp-menu-label {
|
||||
max-width: 16rem !important;
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item {
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding-inline: 16px !important;
|
||||
}
|
||||
|
||||
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-title-content {
|
||||
width: auto !important;
|
||||
opacity: 1 !important;
|
||||
flex: 1 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.wesp-admin-content {
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.wesp-admin-content-topbar {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.wesp-admin-gauges-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wesp-admin-sider.ant-layout-sider,
|
||||
.wesp-admin-layout > .ant-layout,
|
||||
.wesp-admin-sider .wesp-menu-label,
|
||||
.wesp-admin-sider .ant-menu-item {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function AnalyticsPlaceholder(): string {
|
||||
return "analytics-v1";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { login, register } from "./authApi";
|
||||
|
||||
vi.mock("@shared/api/client", () => ({
|
||||
authClient: {
|
||||
post: vi.fn(async (url: string) => {
|
||||
if (url.endsWith("/login")) {
|
||||
return { data: { access_token: "token", user: { id: "1" } } };
|
||||
}
|
||||
return { data: { message: "ok" } };
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
describe("authApi", () => {
|
||||
it("login returns access token payload", async () => {
|
||||
const data = await login({ email: "u@example.com", password: "Valid123" });
|
||||
expect(data.access_token).toBe("token");
|
||||
});
|
||||
|
||||
it("register returns success payload", async () => {
|
||||
const data = await register({ email: "u@example.com", password: "Valid123" });
|
||||
expect(data.message).toBe("ok");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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: Mon–Fri, 10:00–18: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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ProfileCard } from "./components/ProfileCard";
|
||||
export { ProfileEditor } from "./components/ProfileEditor";
|
||||
export { getProfile, updateProfile, changePassword, uploadAvatar } from "./api/profileApi";
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AdminPanel } from "@modules/admin/components/AdminPanel";
|
||||
|
||||
export default function AdminPage(): JSX.Element {
|
||||
return <AdminPanel />;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { HeroSection, MarqueeSection, BrandSection, ContactsSection } from "@modules/landing";
|
||||
|
||||
export function HomePage(): JSX.Element {
|
||||
return (
|
||||
<main>
|
||||
<HeroSection />
|
||||
<MarqueeSection />
|
||||
<BrandSection />
|
||||
<ContactsSection />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user