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