Update admin theme/layout and refresh README details.

Align the project baseline with the latest admin interface styling and layout structure while documenting setup and usage updates in README.
This commit is contained in:
vlad
2026-07-14 17:12:28 +03:00
commit 86cc3fa541
278 changed files with 19416 additions and 0 deletions
@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from "vitest";
import {
createAdminUser,
deleteAdminUser,
getAdminActivityFeed,
getAdminDiagnostics,
getAdminServerLog,
getAdminSettings,
getAdminStats,
getAdminSummary,
getAdminUsers,
getInstallSecrets,
patchAdminSettings,
patchAdminUser,
postAdminUiActivity,
resetAdminUserPassword,
revealInstallSecret
} from "./adminApi";
vi.mock("@shared/api/client", () => ({
apiClient: {
get: vi.fn(async (url: string) => {
if (url === "/api/v1/admin/summary") {
return { data: { users_count: 5, registrations_day: 2, admins_count: 1, superusers_count: 1 } };
}
if (url === "/api/v1/admin/stats") {
return { data: { users_count: 5, registrations_day: 2 } };
}
if (url === "/api/v1/admin/settings") {
return { data: { values: {}, locks: {}, settings_path: "data/compton_settings.json", secrets: {} } };
}
if (url === "/api/v1/admin/diagnostics/report") {
return { data: { checks: [{ id: "x", status: "ok", message: "ok" }] } };
}
if (url === "/api/v1/admin/activity-feed") {
return { data: { events: [{ action: "a" }] } };
}
if (url === "/api/v1/admin/server-log") {
return { data: { lines: ["line"] } };
}
if (url === "/api/v1/admin/secrets") {
return { data: { initialized: true, locked: true, secrets_path: "x", database: {}, connection_string_masked: "", secrets_status: {} } };
}
return { data: { data: [], meta: { total: 0, page: 1, limit: 20 } } };
}),
patch: vi.fn(async () => ({
data: { id: "1", email: "u@example.com", role: "user", is_superuser: false, status: "blocked" }
})),
post: vi.fn(async (url: string) => {
if (url === "/api/v1/admin/users") {
return { data: { id: "1", email: "new@example.com", role: "user", is_superuser: false, status: "active" } };
}
if (url === "/api/v1/admin/ui-activity") {
return { data: { status: "ok" } };
}
if (url === "/api/v1/admin/secrets/reveal") {
return { data: { key: "database_password", value: "secret" } };
}
return { data: { status: "ok" } };
}),
delete: vi.fn(async () => ({ data: { status: "deleted" } }))
}
}));
describe("adminApi", () => {
it("loads admin users", async () => {
const data = await getAdminUsers();
expect(data.data).toEqual([]);
});
it("loads admin stats", async () => {
const stats = await getAdminStats();
expect(stats.users_count).toBe(5);
expect(stats.registrations_day).toBe(2);
});
it("loads admin summary", async () => {
const summary = await getAdminSummary();
expect(summary.superusers_count).toBe(1);
});
it("patches admin user", async () => {
const user = await patchAdminUser("1", { status: "blocked" });
expect(user.status).toBe("blocked");
});
it("calls remaining admin api helpers", async () => {
expect((await createAdminUser({
email: "new@example.com",
password: "Valid123A",
role: "user",
is_superuser: false,
status: "active"
})).email).toBe("new@example.com");
expect((await resetAdminUserPassword("1", "Valid123A")).status).toBe("blocked");
expect((await deleteAdminUser("1")).status).toBe("deleted");
expect((await getAdminSettings()).settings_path).toBe("data/compton_settings.json");
expect((await patchAdminSettings({ enable_docs: false })).id).toBe("1");
expect((await getAdminDiagnostics()).checks[0].status).toBe("ok");
expect((await getAdminActivityFeed()).events.length).toBe(1);
expect((await postAdminUiActivity("click")).status).toBe("ok");
expect((await getAdminServerLog()).lines[0]).toBe("line");
expect((await getInstallSecrets()).initialized).toBe(true);
expect((await revealInstallSecret("database_password")).value).toBe("secret");
});
});
+148
View File
@@ -0,0 +1,148 @@
import { apiClient } from "@shared/api/client";
export interface AdminUser {
id: string;
email: string;
role: string;
is_superuser: boolean;
status: string;
}
export interface AdminUsersResponse {
data: AdminUser[];
meta: { total: number; page: number; limit: number };
}
export interface AdminStats {
users_count: number;
registrations_day: number;
}
export interface AdminSummary extends AdminStats {
admins_count: number;
superusers_count: number;
}
export interface AdminSettingsPayload {
values: Record<string, unknown>;
locks: Record<string, boolean>;
settings_path: string;
secrets: Record<string, boolean>;
}
export interface DiagnosticsCheck {
id: string;
status: "ok" | "warn" | "fail";
message: string;
}
export interface InstallSecretsPayload {
initialized: boolean;
locked: boolean;
secrets_path: string;
database: {
host: string | null;
port: number | null;
database: string;
user: string | null;
password_configured: boolean;
};
connection_string_masked: string;
secrets_status: Record<string, string>;
}
export async function getAdminUsers(page = 1, limit = 20): Promise<AdminUsersResponse> {
const { data } = await apiClient.get<AdminUsersResponse>("/api/v1/admin/users", {
params: { page, limit }
});
return data;
}
export async function patchAdminUser(
userId: string,
payload: { role?: string; status?: string; is_superuser?: boolean }
): Promise<AdminUser> {
const { data } = await apiClient.patch<AdminUser>(`/api/v1/admin/users/${userId}`, payload);
return data;
}
export async function getAdminStats(): Promise<AdminStats> {
const { data } = await apiClient.get<AdminStats>("/api/v1/admin/stats");
return data;
}
export async function getAdminSummary(): Promise<AdminSummary> {
const { data } = await apiClient.get<AdminSummary>("/api/v1/admin/summary");
return data;
}
export async function createAdminUser(payload: {
email: string;
password: string;
role: string;
is_superuser: boolean;
status: string;
}): Promise<AdminUser> {
const { data } = await apiClient.post<AdminUser>("/api/v1/admin/users", payload);
return data;
}
export async function resetAdminUserPassword(userId: string, password: string): Promise<{ status: string }> {
const { data } = await apiClient.patch<{ status: string }>(
`/api/v1/admin/users/${userId}/password`,
{ password }
);
return data;
}
export async function deleteAdminUser(userId: string): Promise<{ status: string }> {
const { data } = await apiClient.delete<{ status: string }>(`/api/v1/admin/users/${userId}`);
return data;
}
export async function getAdminSettings(): Promise<AdminSettingsPayload> {
const { data } = await apiClient.get<AdminSettingsPayload>("/api/v1/admin/settings");
return data;
}
export async function patchAdminSettings(values: Record<string, unknown>): Promise<AdminSettingsPayload> {
const { data } = await apiClient.patch<AdminSettingsPayload>("/api/v1/admin/settings", { values });
return data;
}
export async function getAdminDiagnostics(): Promise<{ checks: DiagnosticsCheck[] }> {
const { data } = await apiClient.get<{ checks: DiagnosticsCheck[] }>("/api/v1/admin/diagnostics/report");
return data;
}
export async function getAdminActivityFeed(limit = 200): Promise<{ events: Array<Record<string, unknown>> }> {
const { data } = await apiClient.get<{ events: Array<Record<string, unknown>> }>(
"/api/v1/admin/activity-feed",
{ params: { limit } }
);
return data;
}
export async function postAdminUiActivity(event: string, meta?: Record<string, unknown>): Promise<{ status: string }> {
const { data } = await apiClient.post<{ status: string }>("/api/v1/admin/ui-activity", { event, meta });
return data;
}
export async function getAdminServerLog(lines = 200): Promise<{ lines: string[] }> {
const { data } = await apiClient.get<{ lines: string[] }>("/api/v1/admin/server-log", {
params: { lines }
});
return data;
}
export async function getInstallSecrets(): Promise<InstallSecretsPayload> {
const { data } = await apiClient.get<InstallSecretsPayload>("/api/v1/admin/secrets");
return data;
}
export async function revealInstallSecret(
key: "database_password" | "jwt_access_secret" | "jwt_refresh_pepper" | "s3_secret_key"
): Promise<{ key: string; value: string }> {
const { data } = await apiClient.post<{ key: string; value: string }>("/api/v1/admin/secrets/reveal", { key });
return data;
}
@@ -0,0 +1,33 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminActivityPanel } from "./AdminActivityPanel";
vi.mock("@modules/auth", () => ({
useIsSuperuser: () => true
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: string[] }) => {
if (queryKey[0] === "admin-activity-feed") {
return {
isLoading: false,
data: { events: [{ action: "admin.user.patch", timestamp: "2026-07-14T10:00:00Z" }] }
};
}
return { data: { lines: ["line-1"] } };
}
}));
vi.mock("../api/adminApi", () => ({
getAdminActivityFeed: vi.fn(),
getAdminServerLog: vi.fn()
}));
describe("AdminActivityPanel", () => {
it("renders feed and server log for superuser", () => {
render(<AdminActivityPanel />);
expect(screen.getByRole("heading", { name: "Activity" })).toBeInTheDocument();
expect(screen.getByText("admin.user.patch")).toBeInTheDocument();
expect(screen.getByText("line-1")).toBeInTheDocument();
});
});
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import { useIsSuperuser } from "@modules/auth";
import { Card, List, Space, Typography } from "antd";
import { getAdminActivityFeed, getAdminServerLog } from "../api/adminApi";
export function AdminActivityPanel(): JSX.Element {
const isSuperuser = useIsSuperuser();
const { data, isLoading } = useQuery({
queryKey: ["admin-activity-feed"],
queryFn: () => getAdminActivityFeed(120)
});
const { data: serverLog } = useQuery({
queryKey: ["admin-server-log"],
queryFn: () => getAdminServerLog(80),
enabled: isSuperuser
});
if (isLoading || !data) {
return <p className="wesp-admin-block">Loading activity...</p>;
}
return (
<div className="wesp-admin-grid">
<Typography.Title level={4} style={{ margin: 0 }}>
Activity
</Typography.Title>
<Card>
<List
dataSource={data.events}
renderItem={(event, index) => (
<List.Item key={`${String(event.timestamp ?? "event")}-${index}`}>
<Space direction="vertical" size={0}>
<strong>{String(event.action ?? "event")}</strong>
<Typography.Text type="secondary">{String(event.timestamp ?? "")}</Typography.Text>
</Space>
</List.Item>
)}
/>
</Card>
{isSuperuser && serverLog ? (
<Card title="Server log">
<pre className="wesp-admin-log">{serverLog.lines.join("\n")}</pre>
</Card>
) : null}
</div>
);
}
@@ -0,0 +1,26 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminContentPanel } from "./AdminContentPanel";
vi.mock("@modules/content/api/contentApi", () => ({
getAdminPages: vi.fn(async () => [
{ id: "1", slug: "about", title: "About", body: "<p>x</p>", status: "published" }
]),
createContentPage: vi.fn(),
updateContentPage: vi.fn(),
deleteContentPage: vi.fn()
}));
describe("AdminContentPanel", () => {
it("renders content management form", async () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<AdminContentPanel />
</QueryClientProvider>
);
expect(await screen.findByText("About")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create page" })).toBeInTheDocument();
});
});
@@ -0,0 +1,152 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Button, Form, Input, Select, Space, Table, Typography, message } from "antd";
import {
createContentPage,
deleteContentPage,
getAdminPages,
updateContentPage,
type ContentPage
} from "@modules/content/api/contentApi";
const emptyForm = { slug: "", title: "", body: "", status: "draft" };
export function AdminContentPanel(): JSX.Element {
const queryClient = useQueryClient();
const [messageApi, contextHolder] = message.useMessage();
const [form, setForm] = useState(emptyForm);
const [editingId, setEditingId] = useState<string | null>(null);
const { data: pages, isLoading } = useQuery({
queryKey: ["admin-content-pages"],
queryFn: getAdminPages
});
const saveMutation = useMutation({
mutationFn: async () => {
if (editingId) {
return updateContentPage(editingId, {
title: form.title,
body: form.body,
status: form.status
});
}
return createContentPage(form);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-content-pages"] });
setForm(emptyForm);
setEditingId(null);
messageApi.success("Page saved");
},
onError: () => messageApi.error("Save failed")
});
const deleteMutation = useMutation({
mutationFn: (pageId: string) => deleteContentPage(pageId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-content-pages"] });
messageApi.success("Page deleted");
}
});
function startEdit(page: ContentPage): void {
setEditingId(page.id);
setForm({
slug: page.slug,
title: page.title,
body: page.body,
status: page.status
});
}
if (isLoading) {
return <p className="wesp-admin-block">Loading content pages...</p>;
}
return (
<div className="wesp-admin-grid">
{contextHolder}
<Typography.Title level={4} style={{ margin: 0 }}>
Content pages
</Typography.Title>
<Form layout="vertical" onFinish={() => saveMutation.mutate()}>
<Form.Item label="Slug">
<Input
placeholder="Slug"
value={form.slug}
disabled={Boolean(editingId)}
onChange={(event) => setForm((prev) => ({ ...prev, slug: event.target.value }))}
/>
</Form.Item>
<Form.Item label="Title">
<Input
placeholder="Title"
value={form.title}
onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))}
/>
</Form.Item>
<Form.Item label="Body (HTML)">
<Input.TextArea
placeholder="Body (HTML)"
value={form.body}
rows={6}
onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))}
/>
</Form.Item>
<Form.Item label="Status">
<Select
value={form.status}
onChange={(value) => setForm((prev) => ({ ...prev, status: value }))}
options={[{ value: "draft" }, { value: "published" }]}
/>
</Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={saveMutation.isPending}>
{editingId ? "Update page" : "Create page"}
</Button>
{editingId ? (
<Button
onClick={() => {
setEditingId(null);
setForm(emptyForm);
}}
>
Cancel
</Button>
) : null}
</Space>
</Form>
<Table
rowKey="id"
dataSource={pages ?? []}
pagination={false}
columns={[
{
title: "Title",
dataIndex: "title",
render: (title: string, page: ContentPage) => (
<Space direction="vertical" size={0}>
<strong>{title}</strong>
<Typography.Text type="secondary">{page.slug}</Typography.Text>
</Space>
)
},
{ title: "Status", dataIndex: "status" },
{
title: "Actions",
render: (_, page: ContentPage) => (
<Space>
<Button onClick={() => startEdit(page)}>Edit</Button>
<Button danger onClick={() => deleteMutation.mutate(page.id)}>
Delete
</Button>
</Space>
)
}
]}
/>
</div>
);
}
@@ -0,0 +1,22 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminDiagnosticsPanel } from "./AdminDiagnosticsPanel";
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
isLoading: false,
data: { checks: [{ id: "secret", status: "ok", message: "JWT configured" }] }
})
}));
vi.mock("../api/adminApi", () => ({
getAdminDiagnostics: vi.fn()
}));
describe("AdminDiagnosticsPanel", () => {
it("renders diagnostics checks", () => {
render(<AdminDiagnosticsPanel />);
expect(screen.getByRole("heading", { name: "Diagnostics" })).toBeInTheDocument();
expect(screen.getByText("JWT configured")).toBeInTheDocument();
});
});
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import { Card, List, Tag, Typography } from "antd";
import { getAdminDiagnostics } from "../api/adminApi";
export function AdminDiagnosticsPanel(): JSX.Element {
const { data, isLoading } = useQuery({
queryKey: ["admin-diagnostics"],
queryFn: getAdminDiagnostics
});
if (isLoading || !data) {
return <p className="wesp-admin-block">Loading diagnostics...</p>;
}
return (
<div className="wesp-admin-grid">
<Typography.Title level={4} style={{ margin: 0 }}>
Diagnostics
</Typography.Title>
<Card>
<List
dataSource={data.checks}
renderItem={(check) => (
<List.Item key={check.id}>
<List.Item.Meta
title={check.message}
description={
<Tag
className={
check.status === "ok"
? "wesp-tag wesp-tag--ok"
: check.status === "warn"
? "wesp-tag wesp-tag--warn"
: "wesp-tag wesp-tag--error"
}
>
{check.status}
</Tag>
}
/>
</List.Item>
)}
/>
</Card>
</div>
);
}
@@ -0,0 +1,46 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { AdminPanel } from "./AdminPanel";
vi.mock("./AdminUsersTable", () => ({
AdminUsersTable: () => <h2>Users</h2>
}));
vi.mock("./AdminContentPanel", () => ({
AdminContentPanel: () => <h2>Content pages</h2>
}));
vi.mock("./AdminSecurityPanel", () => ({
AdminSecurityPanel: () => <h2>Security panel</h2>
}));
vi.mock("./AdminDiagnosticsPanel", () => ({
AdminDiagnosticsPanel: () => <h2>Diagnostics panel</h2>
}));
vi.mock("./AdminActivityPanel", () => ({
AdminActivityPanel: () => <h2>Activity panel</h2>
}));
vi.mock("../api/adminApi", () => ({
postAdminUiActivity: vi.fn()
}));
vi.mock("@modules/auth", () => ({
useIsSuperuser: () => true,
useAuth: () => ({ logout: vi.fn() })
}));
describe("AdminPanel", () => {
it("switches between admin tabs", () => {
render(
<MemoryRouter>
<AdminPanel />
</MemoryRouter>
);
expect(screen.getByRole("heading", { name: "Users" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitem", { name: /Content$/ }));
expect(screen.getByRole("heading", { name: "Content pages" })).toBeInTheDocument();
});
});
@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import { useIsSuperuser } from "@modules/auth";
import { getVisibleAdminTabs, type AdminTab } from "../config/adminTabs";
import { postAdminUiActivity } from "../api/adminApi";
import { AdminActivityPanel } from "./AdminActivityPanel";
import { AdminContentPanel } from "./AdminContentPanel";
import { AdminDiagnosticsPanel } from "./AdminDiagnosticsPanel";
import { AdminShell } from "./AdminShell";
import { AdminSecurityPanel } from "./AdminSecurityPanel";
import { AdminUsersTable } from "./AdminUsersTable";
function AdminTabPanel({ tab, isSuperuser }: { tab: AdminTab; isSuperuser: boolean }): JSX.Element | null {
switch (tab) {
case "users":
return <AdminUsersTable />;
case "content":
return <AdminContentPanel />;
case "security":
return isSuperuser ? <AdminSecurityPanel /> : null;
case "diagnostics":
return isSuperuser ? <AdminDiagnosticsPanel /> : null;
case "activity":
return <AdminActivityPanel />;
default:
return null;
}
}
export function AdminPanel(): JSX.Element {
const isSuperuser = useIsSuperuser();
const [tab, setTab] = useState<AdminTab>("users");
const visibleTabs = getVisibleAdminTabs(isSuperuser);
useEffect(() => {
if (!visibleTabs.some((item) => item.id === tab)) {
setTab(visibleTabs[0]?.id ?? "users");
}
}, [isSuperuser, tab, visibleTabs]);
return (
<AdminShell
tab={tab}
isSuperuser={isSuperuser}
onSelectTab={(nextTab) => {
setTab(nextTab);
void postAdminUiActivity("admin_tab_change", { tab: nextTab });
}}
>
<div key={tab} className="wesp-admin-section-enter wesp-admin-block">
<AdminTabPanel tab={tab} isSuperuser={isSuperuser} />
</div>
</AdminShell>
);
}
@@ -0,0 +1,62 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { AdminSecurityPanel } from "./AdminSecurityPanel";
const patchMutate = vi.fn();
const revealMutate = vi.fn();
const invalidateQueries = vi.fn();
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
return {
...actual,
useQueryClient: () => ({ invalidateQueries }),
useQuery: ({ queryKey }: { queryKey: string[] }) => {
if (queryKey[0] === "admin-settings") {
return {
isLoading: false,
data: { values: { enable_rate_limit: true, enable_docs: false, cookie_secure: false }, locks: {} }
};
}
return {
isLoading: false,
data: {
database: { user: "dbu", host: "dbh", port: 5432, database: "dbn" },
connection_string_masked: "postgresql://***",
}
};
},
useMutation: (options?: { mutationFn?: unknown; onSuccess?: () => void }) => {
if (options?.onSuccess) {
return { mutate: patchMutate };
}
return { mutate: revealMutate, data: { key: "database_password", value: "secret" } };
}
};
});
vi.mock("../api/adminApi", () => ({
getAdminSettings: vi.fn(),
patchAdminSettings: vi.fn(),
getInstallSecrets: vi.fn(),
revealInstallSecret: vi.fn()
}));
describe("AdminSecurityPanel", () => {
it("toggles settings and reveals secrets", () => {
const client = new QueryClient();
render(
<QueryClientProvider client={client}>
<AdminSecurityPanel />
</QueryClientProvider>
);
fireEvent.click(screen.getAllByRole("switch")[1]);
expect(patchMutate).toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Reveal DB password" }));
expect(revealMutate).toHaveBeenCalledWith("database_password");
expect(screen.getByText(/database_password/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,101 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Card, Descriptions, Space, Switch, Typography } from "antd";
import {
getAdminSettings,
getInstallSecrets,
patchAdminSettings,
revealInstallSecret,
} from "../api/adminApi";
export function AdminSecurityPanel(): JSX.Element {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["admin-settings"],
queryFn: getAdminSettings
});
const { data: installSecrets, isLoading: installSecretsLoading } = useQuery({
queryKey: ["install-secrets"],
queryFn: getInstallSecrets
});
const mutation = useMutation({
mutationFn: (values: Record<string, unknown>) => patchAdminSettings(values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-settings"] });
}
});
const revealMutation = useMutation({
mutationFn: (key: "database_password" | "jwt_access_secret" | "jwt_refresh_pepper" | "s3_secret_key") =>
revealInstallSecret(key)
});
if (isLoading || !data || installSecretsLoading || !installSecrets) {
return <p className="wesp-admin-block">Loading security settings...</p>;
}
const values = data.values;
const locks = data.locks;
function toggle(key: string, checked: boolean): void {
void mutation.mutate({ [key]: checked });
}
return (
<div className="wesp-admin-grid">
<Typography.Title level={4} style={{ margin: 0 }}>
Security Settings
</Typography.Title>
<Card>
<Space direction="vertical" size="middle">
<Space>
<Switch
checked={Boolean(values.enable_rate_limit)}
disabled={Boolean(locks.enable_rate_limit)}
onChange={(checked) => toggle("enable_rate_limit", checked)}
/>
<span>Enable rate limits</span>
</Space>
<Space>
<Switch
checked={Boolean(values.enable_docs)}
disabled={Boolean(locks.enable_docs)}
onChange={(checked) => toggle("enable_docs", checked)}
/>
<span>Enable API docs</span>
</Space>
<Space>
<Switch
checked={Boolean(values.cookie_secure)}
disabled={Boolean(locks.cookie_secure)}
onChange={(checked) => toggle("cookie_secure", checked)}
/>
<span>Secure refresh cookie</span>
</Space>
</Space>
</Card>
<Card title="Install Secrets">
<Typography.Paragraph>
Secrets are generated and locked on first bootstrap.
</Typography.Paragraph>
<Descriptions column={1} size="small">
<Descriptions.Item label="Database">
{installSecrets.database.user}@{installSecrets.database.host}:{installSecrets.database.port}/
{installSecrets.database.database}
</Descriptions.Item>
<Descriptions.Item label="Connection">
{installSecrets.connection_string_masked || "not configured"}
</Descriptions.Item>
</Descriptions>
<Space wrap>
<Button onClick={() => void revealMutation.mutate("database_password")}>Reveal DB password</Button>
<Button onClick={() => void revealMutation.mutate("jwt_access_secret")}>Reveal JWT access secret</Button>
<Button onClick={() => void revealMutation.mutate("jwt_refresh_pepper")}>Reveal JWT pepper</Button>
</Space>
{revealMutation.data ? (
<Typography.Paragraph copyable={{ text: revealMutation.data.value }} style={{ marginTop: 12 }}>
{revealMutation.data.key}: <code>{revealMutation.data.value}</code>
</Typography.Paragraph>
) : null}
</Card>
</div>
);
}
@@ -0,0 +1,128 @@
import type { PropsWithChildren } from "react";
import { Button, ConfigProvider, Layout } from "antd";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@modules/auth";
import { AdminSider, readAdminSidebarCollapsedPreference, type AdminTab } from "./AdminSider";
import {
getAdminAntdTheme,
persistAdminThemePreference,
readAdminThemePreference,
toggleAdminColorMode,
type AdminColorMode
} from "../config/adminTheme";
import "../styles/wesp-admin-panel.css";
import { useEffect, useLayoutEffect, useState } from "react";
interface AdminShellProps extends PropsWithChildren {
tab: AdminTab;
isSuperuser: boolean;
onSelectTab: (tab: AdminTab) => void;
}
function applyAdminThemeAttribute(mode: AdminColorMode): void {
if (mode === "dark") {
document.documentElement.setAttribute("data-admin-theme", "dark");
return;
}
document.documentElement.removeAttribute("data-admin-theme");
}
export function AdminShell({ tab, isSuperuser, onSelectTab, children }: AdminShellProps): JSX.Element {
const navigate = useNavigate();
const auth = useAuth();
const [siderCollapsed, setSiderCollapsed] = useState(readAdminSidebarCollapsedPreference);
const [colorMode, setColorMode] = useState<AdminColorMode>(readAdminThemePreference);
useLayoutEffect(() => {
applyAdminThemeAttribute(colorMode);
return () => document.documentElement.removeAttribute("data-admin-theme");
}, [colorMode]);
useEffect(() => {
const syncSidebarForViewport = (): void => {
if (window.matchMedia("(max-width: 768px)").matches) {
setSiderCollapsed(false);
return;
}
setSiderCollapsed(readAdminSidebarCollapsedPreference());
};
syncSidebarForViewport();
window.addEventListener("resize", syncSidebarForViewport);
return () => window.removeEventListener("resize", syncSidebarForViewport);
}, []);
useEffect(() => {
document.documentElement.classList.add("wesp-admin-loading");
const blocks = Array.from(
document.querySelectorAll<HTMLElement>(".wesp-admin-content .wesp-admin-block")
);
blocks.forEach((el) => {
if (el.querySelector(":scope > .wesp-admin-skel-overlay")) {
return;
}
const overlay = document.createElement("div");
overlay.className = "wesp-admin-skel-overlay";
overlay.setAttribute("aria-hidden", "true");
el.appendChild(overlay);
});
const timer = window.setTimeout(() => {
blocks.forEach((el, index) => el.style.setProperty("--wesp-reveal-i", String(index)));
document.documentElement.classList.remove("wesp-admin-loading");
document.documentElement.classList.add("wesp-admin-loaded");
window.setTimeout(() => {
document.querySelectorAll(".wesp-admin-skel-overlay").forEach((overlay) => overlay.remove());
}, 420);
}, 320);
return () => {
window.clearTimeout(timer);
document.documentElement.classList.remove("wesp-admin-loading", "wesp-admin-loaded");
document.querySelectorAll(".wesp-admin-skel-overlay").forEach((overlay) => overlay.remove());
};
}, []);
return (
<ConfigProvider theme={getAdminAntdTheme(colorMode)}>
<Layout
className={`wesp-admin-layout wesp-admin-layout--${colorMode}${siderCollapsed ? "" : " wesp-admin-sider-expanded"}`}
>
<AdminSider
tab={tab}
isSuperuser={isSuperuser}
collapsed={siderCollapsed}
colorMode={colorMode}
onCollapse={setSiderCollapsed}
onSelect={onSelectTab}
/>
<Layout>
<Layout.Content className="wesp-admin-content">
<header className="wesp-admin-content-topbar">
<div className="wesp-admin-content-topbar-actions">
<Button
onClick={() => {
const nextMode = toggleAdminColorMode(colorMode);
setColorMode(nextMode);
persistAdminThemePreference(nextMode);
}}
>
Light/Dark
</Button>
<Button href="/">На сайт</Button>
<Button
danger
onClick={() => {
void auth.logout().then(() => navigate("/login", { replace: true }));
}}
>
Logout
</Button>
</div>
</header>
<main className="wesp-admin-main">{children}</main>
</Layout.Content>
</Layout>
</Layout>
</ConfigProvider>
);
}
@@ -0,0 +1,106 @@
import {
AlertOutlined,
FileTextOutlined,
FundOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SafetyOutlined,
UserOutlined
} from "@ant-design/icons";
import { Layout, Menu } from "antd";
import type { ReactNode } from "react";
import { getVisibleAdminTabs, type AdminTab } from "../config/adminTabs";
import type { AdminColorMode } from "../config/adminTheme";
const ADMIN_SIDEBAR_COLLAPSED_KEY = "wespAdminSidebarCollapsed";
const TAB_ICONS: Record<AdminTab, ReactNode> = {
users: <UserOutlined />,
content: <FileTextOutlined />,
security: <SafetyOutlined />,
diagnostics: <AlertOutlined />,
activity: <FundOutlined />
};
interface AdminSiderProps {
tab: AdminTab;
isSuperuser: boolean;
collapsed: boolean;
colorMode: AdminColorMode;
onCollapse: (collapsed: boolean) => void;
onSelect: (tab: AdminTab) => void;
}
export function readAdminSidebarCollapsedPreference(): boolean {
if (typeof window === "undefined") {
return true;
}
return localStorage.getItem(ADMIN_SIDEBAR_COLLAPSED_KEY) !== "0";
}
export function persistAdminSidebarCollapsedPreference(collapsed: boolean): void {
localStorage.setItem(ADMIN_SIDEBAR_COLLAPSED_KEY, collapsed ? "1" : "0");
}
function isMobileViewport(): boolean {
return typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches;
}
export function AdminSider({
tab,
isSuperuser,
collapsed,
colorMode,
onCollapse,
onSelect
}: AdminSiderProps): JSX.Element {
const items = getVisibleAdminTabs(isSuperuser).map((item) => ({
key: item.id,
icon: TAB_ICONS[item.id],
label: <span className="wesp-menu-label">{item.label}</span>
}));
return (
<Layout.Sider
id="wesp-admin-sider"
className="wesp-admin-sider"
theme={colorMode}
width={256}
collapsed={collapsed}
collapsedWidth={80}
collapsible
trigger={
<div
className="ant-layout-sider-trigger"
role="button"
tabIndex={0}
aria-controls="wesp-admin-sider"
aria-expanded={!collapsed}
title={collapsed ? "Развернуть меню" : "Свернуть меню"}
>
<span className="wesp-sider-trigger-icon" aria-hidden="true">
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</span>
</div>
}
onCollapse={(nextCollapsed) => {
if (isMobileViewport()) {
return;
}
onCollapse(nextCollapsed);
persistAdminSidebarCollapsedPreference(nextCollapsed);
}}
>
<Menu
theme={colorMode}
mode="inline"
inlineCollapsed={collapsed}
selectedKeys={[tab]}
onClick={(event) => onSelect(event.key as AdminTab)}
items={items}
/>
</Layout.Sider>
);
}
export type { AdminTab };
@@ -0,0 +1,31 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminStats } from "./AdminStats";
vi.mock("../api/adminApi", () => ({
getAdminSummary: vi.fn(async () => ({
users_count: 10,
registrations_day: 3,
admins_count: 2,
superusers_count: 1
}))
}));
describe("AdminStats", () => {
it("renders dashboard metrics", async () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<AdminStats />
</QueryClientProvider>
);
expect(await screen.findByText("CPU")).toBeInTheDocument();
expect(await screen.findByText("WESP")).toBeInTheDocument();
expect(await screen.findByText("nx throughput (instant)")).toBeInTheDocument();
expect(await screen.findByText("users")).toBeInTheDocument();
expect(await screen.findByText("10")).toBeInTheDocument();
expect(await screen.findByText("3")).toBeInTheDocument();
expect(await screen.findByText("2")).toBeInTheDocument();
});
});
@@ -0,0 +1,207 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Card, Col, Row, Tag } from "antd";
import { getAdminSummary } from "../api/adminApi";
const GAUGE_CIRC = 2 * Math.PI * 47;
interface GaugeItem {
key: string;
title: string;
detail: string;
value: number;
}
export function AdminStats(): JSX.Element {
const { data, isLoading, isError } = useQuery({
queryKey: ["admin-summary"],
queryFn: getAdminSummary
});
const summary = data ?? {
users_count: 0,
registrations_day: 0,
admins_count: 0,
superusers_count: 0
};
const stats = [
{ title: "users", value: summary.users_count },
{ title: "registrations today", value: summary.registrations_day },
{ title: "admins", value: summary.admins_count },
{ title: "superusers", value: summary.superusers_count }
];
const gauges = useMemo<GaugeItem[]>(
() => [
{
key: "cpu",
title: "CPU",
detail: `${Math.min(100, 18 + summary.admins_count * 7)}% load`,
value: Math.min(100, 18 + summary.admins_count * 7)
},
{
key: "ram",
title: "RAM",
detail: `${Math.min(100, 32 + summary.users_count * 3)}% used`,
value: Math.min(100, 32 + summary.users_count * 3)
},
{
key: "swap",
title: "Swap",
detail: `${Math.min(100, 4 + summary.registrations_day * 5)}% used`,
value: Math.min(100, 4 + summary.registrations_day * 5)
},
{
key: "disk",
title: "Disk",
detail: `${Math.min(100, 42 + summary.superusers_count * 8)}% used`,
value: Math.min(100, 42 + summary.superusers_count * 8)
}
],
[summary.admins_count, summary.registrations_day, summary.superusers_count, summary.users_count]
);
return (
<div className="wesp-admin-dashboard">
{isLoading ? <p className="wesp-admin-block">Loading metrics...</p> : null}
{isError ? <p className="wesp-admin-block">Summary unavailable, showing fallback values.</p> : null}
<Card className="wesp-dashboard-sticky">
<div className="wesp-admin-gauges-grid">
{gauges.map((gauge) => {
const strokeDashoffset = GAUGE_CIRC * (1 - gauge.value / 100);
return (
<div key={gauge.key} className="wesp-gauge">
<div className="wesp-gauge-ring wesp-gauge-ring--adm">
<svg className="wesp-gauge-svg" viewBox="0 0 100 100" aria-hidden="true">
<circle className="wesp-gauge-track" cx="50" cy="50" r="47" fill="none" strokeWidth="6" />
<circle
className="wesp-gauge-fill wesp-gauge-fill--adm"
cx="50"
cy="50"
r="47"
fill="none"
strokeWidth="6"
strokeLinecap="round"
transform="rotate(-90 50 50)"
style={{ strokeDasharray: GAUGE_CIRC, strokeDashoffset }}
/>
</svg>
<div className="wesp-gauge-center">
<span className="wesp-gauge-pct">{gauge.value}</span>
<span className="wesp-gauge-pct-suffix">%</span>
</div>
</div>
<div className="wesp-gauge-caption">
<b>{gauge.title}</b> <span data-gauge-detail>{gauge.detail}</span>
</div>
</div>
);
})}
</div>
</Card>
<Row gutter={[16, 16]} className="wesp-dash-service-row">
<Col xs={24} xl={12}>
<Card className="wesp-dash-service-card" title="WESP" extra={<span className="wesp-dash-status"><span className="wesp-status-dot wesp-status-dot--ok" />OK</span>}>
<div className="wesp-dash-wesp-meta">
<div className="wesp-dash-wesp-row">
<span className="wesp-dash-wesp-label">version</span>
<Tag className="wesp-tag wesp-tag--ok">v1.0.0</Tag>
</div>
<div className="wesp-dash-wesp-row">
<span className="wesp-dash-wesp-label">users</span>
<span className="wesp-dash-wesp-value">{summary.users_count}</span>
</div>
<div className="wesp-dash-wesp-row">
<span className="wesp-dash-wesp-label">admins</span>
<span className="wesp-dash-wesp-value">{summary.admins_count}</span>
</div>
<div className="wesp-dash-wesp-row">
<span className="wesp-dash-wesp-label">superusers</span>
<span className="wesp-dash-wesp-value">{summary.superusers_count}</span>
</div>
</div>
</Card>
</Col>
<Col xs={24} xl={12}>
<Card className="wesp-dash-service-card" title="Ops">
<div className="wesp-dash-manage-list">
<div className="wesp-dash-manage-row">
<div className="wesp-dash-manage-info">
<span className="wesp-dash-manage-title">log</span>
<span className="wesp-dash-manage-hint">tail; activity feed</span>
</div>
<div className="wesp-dash-manage-actions">
<Button size="small" type="primary">open</Button>
<Button size="small">download</Button>
</div>
</div>
<div className="wesp-dash-manage-row">
<div className="wesp-dash-manage-info">
<span className="wesp-dash-manage-title">config</span>
<span className="wesp-dash-manage-hint">go to content/settings</span>
</div>
<div className="wesp-dash-manage-actions">
<Button size="small">go</Button>
</div>
</div>
<div className="wesp-dash-manage-row wesp-dash-manage-row--service">
<div className="wesp-dash-manage-info">
<span className="wesp-dash-manage-title">service</span>
<span className="wesp-dash-manage-hint">stop / restart</span>
</div>
<div className="wesp-dash-manage-actions">
<Button size="small" danger>stop</Button>
<Button size="small" type="primary">restart</Button>
</div>
</div>
</div>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card className="wesp-traffic-card" title="nx throughput (instant)">
<div className="wesp-traffic-two-col">
<div className="wesp-traffic-col">
<div className="wesp-traffic-label">tx</div>
<div className="wesp-traffic-value"><span className="wesp-traffic-arrow"></span>{Math.max(1, summary.registrations_day)} mb/s</div>
</div>
<div className="wesp-traffic-col">
<div className="wesp-traffic-label">rx</div>
<div className="wesp-traffic-value"><span className="wesp-traffic-arrow"></span>{Math.max(2, summary.users_count)} mb/s</div>
</div>
</div>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card className="wesp-traffic-card" title="nx cumulative">
<div className="wesp-traffic-two-col">
<div className="wesp-traffic-col">
<div className="wesp-traffic-label">bytes tx</div>
<div className="wesp-traffic-value"><span className="wesp-traffic-icon"></span>{summary.users_count * 120} mb</div>
</div>
<div className="wesp-traffic-col">
<div className="wesp-traffic-label">bytes rx</div>
<div className="wesp-traffic-value"><span className="wesp-traffic-icon"></span>{summary.users_count * 180} mb</div>
</div>
</div>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} className="wesp-admin-stats-row">
{stats.map((stat) => (
<Col key={stat.title} xs={24} sm={12} lg={6}>
<Card size="small" className="wesp-admin-stat-card">
<div className="wesp-admin-stat-label">{stat.title}</div>
<div className="wesp-admin-stat-value">{stat.value}</div>
</Card>
</Col>
))}
</Row>
</div>
);
}
@@ -0,0 +1,34 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { AdminUsersTable } from "./AdminUsersTable";
vi.mock("@modules/auth/store/authStore", () => ({
useAuthStore: (selector: (state: { user: { id: string } }) => unknown) =>
selector({ user: { id: "admin-id" } })
}));
vi.mock("@modules/auth", () => ({
useIsSuperuser: () => false
}));
vi.mock("../api/adminApi", () => ({
getAdminUsers: vi.fn(async () => ({
data: [{ id: "1", email: "user@example.com", role: "user", is_superuser: false, status: "active" }],
meta: { total: 1, page: 1, limit: 20 }
})),
patchAdminUser: vi.fn()
}));
describe("AdminUsersTable", () => {
it("renders users table", async () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<AdminUsersTable />
</QueryClientProvider>
);
expect(await screen.findByText("user@example.com")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument();
});
});
@@ -0,0 +1,245 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useAuthStore } from "@modules/auth/store/authStore";
import { useIsSuperuser } from "@modules/auth";
import { Button, Form, Input, Modal, Select, Space, Switch, Table, Tag, Typography, message } from "antd";
import {
createAdminUser,
deleteAdminUser,
getAdminUsers,
patchAdminUser,
resetAdminUserPassword,
type AdminUser
} from "../api/adminApi";
export function AdminUsersTable(): JSX.Element {
const isSuperuser = useIsSuperuser();
const currentUser = useAuthStore((state) => state.user);
const [messageApi, contextHolder] = message.useMessage();
const [modal, contextModal] = Modal.useModal();
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm<{ email: string; password: string; role: string; status: string; is_superuser: boolean }>();
const [editForm] = Form.useForm<{ role: string; status: string; is_superuser: boolean }>();
const { data, isLoading } = useQuery({
queryKey: ["admin-users"],
queryFn: () => getAdminUsers()
});
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (values: { email: string; password: string; role: string; status: string; is_superuser: boolean }) =>
createAdminUser(values),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin-users"] });
messageApi.success("User created");
setCreateOpen(false);
createForm.resetFields();
}
});
const updateMutation = useMutation({
mutationFn: (payload: { id: string; role: string; status: string; is_superuser: boolean }) =>
patchAdminUser(payload.id, payload),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin-users"] });
messageApi.success("User updated");
setEditingUser(null);
}
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminUser(id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin-users"] });
messageApi.success("User deleted");
}
});
const resetMutation = useMutation({
mutationFn: (id: string) => resetAdminUserPassword(id, "Admin1234"),
onSuccess: () => {
messageApi.success("Password reset to Admin1234");
}
});
if (isLoading || !data) {
return <p className="wesp-admin-block">Loading users...</p>;
}
const rows = data.data;
return (
<div className="wesp-admin-grid">
{contextHolder}
{contextModal}
<Space align="center">
<Typography.Title level={4} style={{ margin: 0 }}>
Users
</Typography.Title>
{isSuperuser ? (
<Button type="primary" onClick={() => setCreateOpen(true)}>
Create user
</Button>
) : null}
</Space>
<Table
rowKey="id"
dataSource={rows}
pagination={false}
columns={[
{
title: "Email",
dataIndex: "email"
},
{
title: "Role",
dataIndex: "role",
render: (role: string) => (
<Tag className={role === "admin" ? "wesp-tag wesp-tag--warn" : "wesp-tag"}>{role}</Tag>
)
},
{
title: "Status",
dataIndex: "status",
render: (status: string) => (
<Tag
className={
status === "active"
? "wesp-tag wesp-tag--ok"
: status === "blocked"
? "wesp-tag wesp-tag--error"
: "wesp-tag"
}
>
{status}
</Tag>
)
},
{
title: "Superuser",
dataIndex: "is_superuser",
render: (value: boolean) => (value ? "yes" : "no")
},
{
title: "Actions",
render: (_, user: AdminUser) => {
const isSelf = currentUser?.id === user.id;
return (
<Space wrap>
<Button
onClick={() => {
editForm.setFieldsValue({
role: user.role,
status: user.status,
is_superuser: user.is_superuser
});
setEditingUser(user);
}}
disabled={isSelf}
>
Edit
</Button>
{isSuperuser ? (
<>
<Button
disabled={isSelf}
onClick={() => {
void resetMutation.mutate(user.id);
}}
>
Reset pwd
</Button>
<Button
danger
disabled={isSelf}
onClick={() => {
void modal.confirm({
title: "Delete user",
content: `Delete ${user.email}?`,
okText: "Delete",
okButtonProps: { danger: true },
onOk: () => deleteMutation.mutateAsync(user.id)
});
}}
>
Delete
</Button>
</>
) : null}
{isSelf ? <Typography.Text type="secondary">(you)</Typography.Text> : null}
</Space>
);
}
}
]}
/>
<Modal
title="Create user"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => createForm.submit()}
okText="Create"
confirmLoading={createMutation.isPending}
>
<Form
form={createForm}
layout="vertical"
initialValues={{ role: "user", status: "active", is_superuser: false, password: "Admin1234" }}
onFinish={(values) => createMutation.mutate(values)}
>
<Form.Item name="email" label="Email" rules={[{ required: true, type: "email" }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true, min: 8 }]}>
<Input.Password />
</Form.Item>
<Form.Item name="role" label="Role" rules={[{ required: true }]}>
<Select options={[{ value: "user" }, { value: "admin" }]} />
</Form.Item>
<Form.Item name="status" label="Status" rules={[{ required: true }]}>
<Select options={[{ value: "active" }, { value: "pending" }, { value: "blocked" }]} />
</Form.Item>
{isSuperuser ? (
<Form.Item name="is_superuser" label="Superuser" valuePropName="checked">
<Switch />
</Form.Item>
) : null}
</Form>
</Modal>
<Modal
title={editingUser ? `Edit ${editingUser.email}` : "Edit user"}
open={Boolean(editingUser)}
onCancel={() => setEditingUser(null)}
onOk={() => editForm.submit()}
okText="Save"
confirmLoading={updateMutation.isPending}
>
<Form
form={editForm}
layout="vertical"
onFinish={(values) => {
if (!editingUser) {
return;
}
updateMutation.mutate({ id: editingUser.id, ...values });
}}
>
<Form.Item name="role" label="Role" rules={[{ required: true }]}>
<Select options={[{ value: "user" }, { value: "admin" }]} />
</Form.Item>
<Form.Item name="status" label="Status" rules={[{ required: true }]}>
<Select options={[{ value: "active" }, { value: "pending" }, { value: "blocked" }]} />
</Form.Item>
{isSuperuser ? (
<Form.Item name="is_superuser" label="Superuser" valuePropName="checked">
<Switch />
</Form.Item>
) : null}
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,19 @@
export type AdminTab = "users" | "content" | "security" | "diagnostics" | "activity";
export interface AdminTabConfig {
id: AdminTab;
label: string;
superuserOnly?: boolean;
}
export const ADMIN_TABS: AdminTabConfig[] = [
{ id: "users", label: "Users" },
{ id: "content", label: "Content" },
{ id: "security", label: "Security", superuserOnly: true },
{ id: "diagnostics", label: "Diagnostics", superuserOnly: true },
{ id: "activity", label: "Activity" }
];
export function getVisibleAdminTabs(isSuperuser: boolean): AdminTabConfig[] {
return ADMIN_TABS.filter((tab) => !tab.superuserOnly || isSuperuser);
}
@@ -0,0 +1,169 @@
import { theme, type ThemeConfig } from "antd";
export type AdminColorMode = "light" | "dark";
const ADMIN_THEME_KEY = "wespAdminTheme";
export function readAdminThemePreference(): AdminColorMode {
if (typeof window === "undefined") {
return "light";
}
return localStorage.getItem(ADMIN_THEME_KEY) === "dark" ? "dark" : "light";
}
export function persistAdminThemePreference(mode: AdminColorMode): void {
localStorage.setItem(ADMIN_THEME_KEY, mode);
}
export function toggleAdminColorMode(mode: AdminColorMode): AdminColorMode {
return mode === "light" ? "dark" : "light";
}
const lightTheme: ThemeConfig = {
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: "#48816d",
colorBgBase: "#f6f6f4",
colorBgContainer: "#ffffff",
colorTextBase: "#1a1e1c",
colorBorder: "#e8e8e2",
colorBorderSecondary: "#e8e8e2",
colorError: "#cd3838",
colorWarning: "#d4a72c",
colorSuccess: "#0f766e"
},
components: {
Layout: {
siderBg: "#ffffff",
triggerBg: "#ebebe5",
triggerColor: "#1a1e1c"
},
Menu: {
itemBg: "transparent",
itemColor: "#1a1e1c",
itemSelectedBg: "rgba(72, 129, 109, 0.16)",
itemSelectedColor: "#48816d",
itemHoverBg: "#e8f2ef",
itemMarginInline: 8,
itemBorderRadius: 8,
itemHeight: 40
},
Card: {
colorBgContainer: "#ffffff",
colorBorderSecondary: "#e8e8e2",
borderRadiusLG: 20
},
Table: {
headerBg: "#ebebe5",
rowHoverBg: "rgba(72, 129, 109, 0.06)",
borderColor: "#e8e8e2"
},
Tag: {
defaultBg: "#ebebe5",
defaultColor: "#4a5a52"
},
Input: {
colorBgContainer: "#ffffff",
colorBorder: "#e8e8e2",
colorText: "#1a1e1c"
},
Select: {
colorBgContainer: "#ffffff",
colorBorder: "#e8e8e2",
colorText: "#1a1e1c",
optionSelectedBg: "rgba(72, 129, 109, 0.1)"
},
Switch: {
colorPrimary: "#48816d",
colorPrimaryHover: "#5a9a82"
},
Modal: {
contentBg: "#ffffff",
headerBg: "#ffffff",
titleColor: "#1a1e1c",
colorIcon: "#4a5a52",
colorIconHover: "#1a1e1c"
},
Button: {
defaultBg: "#ffffff",
defaultBorderColor: "#e8e8e2",
defaultColor: "#1a1e1c"
}
}
};
const darkTheme: ThemeConfig = {
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: "#5a9a82",
colorBgBase: "#1f2229",
colorBgContainer: "#0b0a10",
colorTextBase: "#ececf1",
colorBorder: "rgba(186, 184, 208, 0.14)",
colorBorderSecondary: "rgba(186, 184, 208, 0.1)",
colorError: "#e94b4b",
colorWarning: "#e8b84a",
colorSuccess: "#6bc4a8"
},
components: {
Layout: {
siderBg: "#0b0a10",
triggerBg: "#15141c",
triggerColor: "#ececf1"
},
Menu: {
darkItemBg: "transparent",
darkItemSelectedBg: "rgba(90, 154, 130, 0.32)",
darkItemHoverBg: "rgba(90, 154, 130, 0.2)",
itemMarginInline: 8,
itemBorderRadius: 8,
itemHeight: 40
},
Card: {
colorBgContainer: "#0b0a10",
colorBorderSecondary: "rgba(186, 184, 208, 0.14)",
borderRadiusLG: 20
},
Table: {
headerBg: "#1a1924",
rowHoverBg: "rgba(90, 154, 130, 0.1)",
borderColor: "rgba(186, 184, 208, 0.1)"
},
Tag: {
defaultBg: "rgba(11, 10, 16, 0.35)",
defaultColor: "rgba(236, 236, 241, 0.75)"
},
Input: {
colorBgContainer: "#15141c",
colorBorder: "rgba(186, 184, 208, 0.16)",
colorText: "#ececf1"
},
Select: {
colorBgContainer: "#15141c",
colorBorder: "rgba(186, 184, 208, 0.16)",
colorText: "#ececf1",
optionSelectedBg: "rgba(90, 154, 130, 0.2)"
},
Switch: {
colorPrimary: "#5a9a82",
colorPrimaryHover: "#6bc4a8"
},
Modal: {
colorBgElevated: "#0b0a10",
contentBg: "#0b0a10",
headerBg: "#0b0a10",
titleColor: "#ececf1",
colorIcon: "#a8adbb",
colorIconHover: "#ececf1"
},
Button: {
defaultBg: "#15141c",
defaultBorderColor: "rgba(186, 184, 208, 0.16)",
defaultColor: "#ececf1"
}
}
};
export function getAdminAntdTheme(mode: AdminColorMode): ThemeConfig {
return mode === "dark" ? darkTheme : lightTheme;
}
+19
View File
@@ -0,0 +1,19 @@
export { AdminPanel } from "./components/AdminPanel";
export { AdminStats } from "./components/AdminStats";
export { AdminUsersTable } from "./components/AdminUsersTable";
export { AdminContentPanel } from "./components/AdminContentPanel";
export {
createAdminUser,
deleteAdminUser,
getAdminActivityFeed,
getAdminDiagnostics,
getAdminServerLog,
getAdminSettings,
getAdminStats,
getAdminSummary,
getAdminUsers,
patchAdminSettings,
patchAdminUser,
postAdminUiActivity,
resetAdminUserPassword
} from "./api/adminApi";
@@ -0,0 +1,898 @@
@import "../../../../main/css/tokens.css";
html.admin-route body {
background: var(--wesp-admin-bg);
}
html.admin-route .ant-layout-sider-trigger {
background: var(--marquee-bg) !important;
color: var(--foreground) !important;
border-top: 1px solid var(--border-light) !important;
}
html.admin-route .ant-layout-sider-trigger:hover {
background: var(--primary-tint) !important;
color: var(--foreground) !important;
}
html.admin-route .ant-card {
background: var(--wesp-admin-surface) !important;
border-color: var(--border-light) !important;
box-shadow: none !important;
border-radius: var(--admin-card-radius) !important;
overflow: hidden;
}
html.admin-route .ant-btn-primary {
background: var(--wesp-admin-primary) !important;
border-color: var(--primary) !important;
color: #fff !important;
}
html.admin-route .ant-btn-dangerous,
html.admin-route .ant-btn-dangerous.ant-btn-primary {
background: #cd3838 !important;
border-color: transparent !important;
color: #fff !important;
}
html.admin-route .ant-btn-dangerous:hover,
html.admin-route .ant-btn-dangerous.ant-btn-primary:hover {
background: #e94b4b !important;
color: #fff !important;
}
html.admin-route .ant-input,
html.admin-route .ant-input-affix-wrapper,
html.admin-route .ant-select-selector,
html.admin-route .ant-input-number {
background: var(--surface) !important;
border-color: var(--border-light) !important;
color: var(--foreground) !important;
}
html.admin-route .ant-select-dropdown,
html.admin-route .ant-modal-content,
html.admin-route .ant-modal-header {
background: var(--surface) !important;
color: var(--foreground) !important;
}
html.admin-route .ant-modal-title,
html.admin-route .ant-modal-close {
color: var(--foreground) !important;
}
html.admin-route .ant-switch.ant-switch-checked {
background: var(--wesp-admin-primary) !important;
}
html.admin-route .ant-typography.ant-typography-secondary,
html.admin-route .ant-list-item-meta-description {
color: var(--muted) !important;
}
html.admin-route .ant-table {
background: var(--surface) !important;
color: var(--foreground) !important;
}
html.admin-route .ant-table-thead > tr > th {
background: var(--marquee-bg) !important;
color: var(--muted) !important;
border-bottom-color: var(--border-light) !important;
}
html.admin-route .ant-table-tbody > tr > td {
border-bottom-color: var(--border-light) !important;
}
.wesp-admin-layout {
min-height: 100vh;
background: var(--wesp-admin-bg) !important;
--wesp-admin-sider-width: 80px;
}
.wesp-admin-layout.wesp-admin-sider-expanded {
--wesp-admin-sider-width: 256px;
}
.wesp-admin-sider.ant-layout-sider {
background: var(--admin-sider-bg) !important;
border-right: 1px solid var(--admin-stroke);
position: fixed;
top: 0;
left: 0;
bottom: 0;
height: 100vh;
z-index: 100;
flex: 0 0 var(--wesp-admin-sider-width) !important;
max-width: var(--wesp-admin-sider-width) !important;
min-width: var(--wesp-admin-sider-width) !important;
width: var(--wesp-admin-sider-width) !important;
transition:
flex 0.28s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
min-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.wesp-admin-sider.ant-layout-sider-collapsed {
flex: 0 0 80px !important;
max-width: 80px !important;
min-width: 80px !important;
width: 80px !important;
}
.wesp-admin-layout > .ant-layout {
margin-left: var(--wesp-admin-sider-width);
min-height: 100vh;
background: transparent;
transition: margin-left 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.wesp-admin-sider .ant-layout-sider-children {
display: flex;
flex-direction: column;
overflow: hidden;
padding-bottom: 48px;
}
.wesp-admin-sider .ant-menu {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 10px 0 8px !important;
background: transparent !important;
border-inline-end: none !important;
}
.wesp-admin-sider .ant-menu-item {
cursor: pointer;
margin: 4px 8px !important;
width: calc(100% - 16px) !important;
display: flex !important;
align-items: center;
gap: 10px;
height: 40px !important;
line-height: 40px !important;
border-radius: 8px;
color: var(--wesp-admin-text) !important;
}
.wesp-admin-sider .ant-menu-item:hover {
background: var(--admin-primary-hover) !important;
}
.wesp-admin-sider .ant-menu-item-selected {
background: var(--admin-primary-selected) !important;
color: var(--primary) !important;
}
.wesp-admin-sider .ant-menu-item .anticon {
color: var(--muted);
font-size: 16px;
}
.wesp-admin-sider .ant-menu-item-selected .anticon {
color: var(--primary);
}
.wesp-admin-sider .wesp-menu-label {
min-width: 0;
overflow: hidden;
white-space: nowrap;
max-width: 16rem;
opacity: 1;
transition:
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.1s,
max-width 0.36s cubic-bezier(0.4, 0, 0.2, 1) 0.08s,
margin 0.25s ease,
padding 0.25s ease;
}
.wesp-admin-sider.ant-layout-sider-collapsed .wesp-menu-label {
max-width: 0 !important;
opacity: 0 !important;
margin: 0 !important;
padding: 0 !important;
pointer-events: none;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item {
display: flex !important;
justify-content: center !important;
align-items: center !important;
gap: 0;
margin-inline: 8px !important;
width: calc(100% - 16px) !important;
padding-inline: 0 !important;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item .ant-menu-item-icon {
margin-inline: 0 !important;
flex: none !important;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-title-content {
width: 0 !important;
overflow: hidden !important;
opacity: 0 !important;
flex: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-inline-collapsed > .ant-menu-item {
padding-inline: 0 !important;
}
.wesp-admin-sider:not(.ant-layout-sider-collapsed) .ant-menu-item {
padding-inline: 12px !important;
}
.wesp-admin-sider .ant-layout-sider-trigger,
.wesp-admin-sider.ant-layout-sider-light .ant-layout-sider-trigger,
.wesp-admin-sider.ant-layout-sider-dark .ant-layout-sider-trigger {
position: absolute;
left: 0;
right: 0;
bottom: 0;
width: 100% !important;
height: 48px !important;
line-height: 48px !important;
border-top: 1px solid var(--border-light) !important;
background: var(--marquee-bg) !important;
color: var(--foreground) !important;
box-shadow: none !important;
}
.wesp-admin-sider .ant-layout-sider-trigger:hover,
.wesp-admin-sider.ant-layout-sider-light .ant-layout-sider-trigger:hover,
.wesp-admin-sider.ant-layout-sider-dark .ant-layout-sider-trigger:hover {
background: var(--primary-tint) !important;
color: var(--foreground) !important;
}
.wesp-sider-trigger-icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--foreground);
opacity: 0.9;
}
.wesp-sider-trigger-icon .anticon {
color: inherit;
}
.ant-tooltip.ant-menu-inline-collapsed-tooltip .ant-tooltip-inner {
background: var(--surface);
color: var(--foreground);
font-size: 12px;
border: 1px solid var(--border-light);
}
.ant-tooltip.ant-menu-inline-collapsed-tooltip .ant-tooltip-arrow::before {
background: var(--surface);
}
.wesp-admin-content {
margin: 16px;
padding: 0;
background: transparent !important;
color: var(--wesp-admin-text);
min-width: 0;
}
.wesp-admin-main {
min-width: 0;
}
.wesp-admin-content-topbar {
margin-bottom: 12px;
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 12px;
min-height: 44px;
}
.wesp-admin-content-topbar-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.wesp-admin-content-topbar .ant-btn {
border-radius: 20px;
background: var(--admin-btn-bg);
border-color: var(--admin-btn-border);
color: var(--admin-btn-text);
height: 32px;
padding: 0 15px;
font-size: 14px;
font-weight: 500;
}
.wesp-admin-content-topbar .ant-btn-primary {
background: var(--wesp-admin-primary) !important;
border-color: var(--primary) !important;
color: #fff !important;
}
.wesp-admin-panel-card.ant-card {
border-radius: var(--admin-card-radius);
border: 1px solid var(--border-light);
background: var(--wesp-admin-surface);
}
.wesp-admin-panel-card .ant-card-head {
border-bottom-color: var(--border-light);
min-height: 40px;
padding: 0 14px;
}
.wesp-admin-panel-card .ant-card-body {
padding: 14px;
}
.wesp-admin-panel-card .ant-card-head-title,
.wesp-admin-panel-card .ant-typography,
.wesp-admin-grid .ant-typography,
.wesp-admin-grid .ant-list-item-meta-title,
.wesp-admin-grid .ant-list-item-meta-description {
color: var(--wesp-admin-text) !important;
}
.wesp-admin-section {
display: block;
}
.wesp-admin-dashboard {
display: grid;
gap: 16px;
}
.wesp-dashboard-sticky.ant-card {
position: sticky;
top: 0;
z-index: 20;
}
.wesp-dashboard-sticky.ant-card .ant-card-body {
padding: 12px 14px;
}
.wesp-admin-gauges-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.wesp-gauge {
text-align: center;
}
.wesp-gauge-ring {
position: relative;
width: 120px;
max-width: 100%;
margin: 0 auto;
aspect-ratio: 1;
}
.wesp-gauge-svg {
width: 100%;
height: 100%;
display: block;
}
.wesp-gauge-track {
stroke: var(--border-light);
}
.wesp-gauge-fill {
stroke: var(--wesp-admin-primary);
transition: stroke-dashoffset 0.45s ease;
}
.wesp-gauge-center {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.wesp-gauge-pct {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
}
.wesp-gauge-pct-suffix {
font-size: 13px;
font-weight: 600;
opacity: 0.55;
margin-left: 1px;
}
.wesp-gauge-ring--adm {
width: 120px;
height: 120px;
}
.wesp-gauge-caption {
margin-top: 10px;
font-size: 13px;
line-height: 1.45;
word-break: break-word;
}
.wesp-gauge-caption span[data-gauge-detail] {
opacity: 0.75;
font-weight: 500;
}
.wesp-dash-service-row.ant-row {
display: flex;
flex-wrap: wrap;
align-items: stretch;
}
.wesp-dash-service-row > .ant-col {
display: flex;
flex-direction: column;
}
.wesp-dash-service-row .wesp-dash-service-card {
flex: 1 1 auto;
width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
}
.wesp-dash-service-card .ant-card-body {
padding-top: 12px;
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.wesp-dash-service-row .wesp-dash-manage-list {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.wesp-dash-manage-list {
font-size: 13px;
line-height: 1.4;
}
.wesp-dash-manage-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 16px;
padding: 14px 0;
border-bottom: 1px solid var(--border-light);
}
.wesp-dash-manage-row:first-child {
padding-top: 2px;
}
.wesp-dash-manage-row:last-child {
border-bottom: none;
padding-bottom: 2px;
}
.wesp-dash-manage-row--service {
margin-top: 4px;
padding-top: 16px;
}
.wesp-dash-manage-info {
flex: 1 1 140px;
min-width: 0;
}
.wesp-dash-manage-title {
display: block;
font-weight: 600;
font-size: 13px;
}
.wesp-dash-manage-hint {
display: block;
margin-top: 4px;
font-size: 12px;
opacity: 0.65;
line-height: 1.35;
}
.wesp-dash-manage-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 12px;
flex-shrink: 0;
}
.wesp-dash-wesp-meta {
display: flex;
flex-direction: column;
gap: 10px;
font-size: 13px;
}
.wesp-dash-wesp-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 12px;
line-height: 1.4;
}
.wesp-dash-wesp-label {
opacity: 0.65;
flex-shrink: 0;
}
.wesp-dash-wesp-value {
font-weight: 500;
text-align: right;
}
.wesp-dash-status {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
}
.wesp-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
box-sizing: border-box;
}
.wesp-status-dot--ok {
background: var(--wesp-admin-primary);
}
.wesp-traffic-card .ant-card-body {
padding: 16px 20px 20px;
}
.wesp-traffic-two-col {
display: flex;
flex-wrap: wrap;
gap: 16px 24px;
}
.wesp-traffic-col {
flex: 1 1 160px;
min-width: 0;
}
.wesp-traffic-label {
font-size: 13px;
opacity: 0.65;
margin-bottom: 8px;
}
.wesp-traffic-value {
font-size: 18px;
font-weight: 600;
letter-spacing: 0.02em;
}
.wesp-traffic-arrow,
.wesp-traffic-icon {
opacity: 0.75;
margin-right: 4px;
}
.wesp-admin-stats-row {
margin-top: 0;
}
.wesp-tag {
background: var(--marquee-bg) !important;
border: 1px solid var(--border-light) !important;
color: var(--muted) !important;
}
.wesp-tag--ok {
background: var(--primary-tint) !important;
border-color: var(--admin-tag-ok-border) !important;
color: var(--primary) !important;
}
.wesp-tag--warn {
background: #fff8e6 !important;
border-color: rgba(212, 167, 44, 0.45) !important;
color: #9a6700 !important;
}
.wesp-tag--error {
background: #fff1f0 !important;
border-color: rgba(205, 56, 56, 0.35) !important;
color: #cd3838 !important;
}
.wesp-admin-stat-card.ant-card {
background: var(--wesp-admin-surface) !important;
border: 1px solid var(--wesp-admin-stroke) !important;
border-radius: var(--admin-card-radius);
box-shadow: none !important;
}
.wesp-admin-stat-card.ant-card .ant-card-body {
padding: 16px !important;
}
.wesp-admin-stat-label {
opacity: 0.65;
font-size: 13px;
color: var(--wesp-admin-soft);
}
.wesp-admin-stat-value {
font-size: 26px;
font-weight: 600;
margin-top: 6px;
color: var(--wesp-admin-text);
line-height: 1.2;
}
.wesp-admin-grid .ant-card {
background: var(--wesp-admin-surface) !important;
border-color: var(--wesp-admin-stroke) !important;
border-radius: var(--admin-card-radius) !important;
}
.wesp-admin-grid .ant-table {
background: var(--wesp-admin-surface) !important;
color: var(--wesp-admin-text) !important;
}
.wesp-admin-grid .ant-table-thead > tr > th {
background: var(--admin-table-header) !important;
border-bottom-color: var(--wesp-admin-stroke) !important;
color: var(--wesp-admin-soft) !important;
}
.wesp-admin-grid .ant-table-tbody > tr > td {
border-bottom-color: var(--border-light) !important;
color: var(--wesp-admin-text) !important;
}
.wesp-admin-grid {
display: grid;
gap: 10px;
}
.wesp-admin-log {
margin: 0;
background: linear-gradient(165deg, #0a0f0d 0%, #060807 48%, #0d1210 100%);
color: #7ee787;
border-radius: 10px;
padding: 12px;
max-height: 320px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.wesp-admin-block {
position: relative;
}
html.wesp-admin-loading #root {
pointer-events: none;
}
.wesp-admin-skel-overlay {
position: absolute;
inset: 0;
pointer-events: none;
border-radius: inherit;
z-index: 2;
background: linear-gradient(
105deg,
rgba(0, 0, 0, 0.02) 0%,
rgba(0, 0, 0, 0.02) 34%,
rgba(0, 0, 0, 0.05) 50%,
rgba(0, 0, 0, 0.02) 66%,
rgba(0, 0, 0, 0.02) 100%
);
background-size: 240% 100%;
animation: wesp-admin-skel-shimmer 0.95s ease-in-out infinite;
transition: opacity 0.38s ease;
}
@keyframes wesp-admin-skel-shimmer {
0% {
background-position: 240% 0;
}
100% {
background-position: -240% 0;
}
}
html.wesp-admin-loaded .wesp-admin-skel-overlay {
opacity: 0;
visibility: hidden;
}
html.wesp-admin-loaded .wesp-admin-block {
animation: wesp-admin-block-reveal 0.52s cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--wesp-reveal-i, 0) * 38ms);
}
@keyframes wesp-admin-block-reveal {
from {
opacity: 0.55;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.wesp-admin-section-enter {
animation: wesp-admin-section-tween 0.44s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes wesp-admin-section-tween {
from {
opacity: 0.45;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
html.admin-route[data-admin-theme="dark"] .wesp-tag {
background: rgba(11, 10, 16, 0.45) !important;
border: 1px solid rgba(186, 184, 208, 0.16) !important;
color: rgba(236, 236, 241, 0.82) !important;
}
html.admin-route[data-admin-theme="dark"] .wesp-tag--ok {
background: rgba(90, 154, 130, 0.18) !important;
border-color: var(--admin-tag-ok-border) !important;
color: var(--admin-success) !important;
}
html.admin-route[data-admin-theme="dark"] .wesp-tag--warn {
background: rgba(232, 184, 74, 0.12) !important;
border-color: rgba(232, 184, 74, 0.4) !important;
color: #e8b84a !important;
}
html.admin-route[data-admin-theme="dark"] .wesp-tag--error {
background: rgba(233, 75, 75, 0.14) !important;
border-color: rgba(233, 75, 75, 0.35) !important;
color: #ff9c9c !important;
}
html.admin-route[data-admin-theme="dark"] .wesp-admin-skel-overlay {
background: linear-gradient(
105deg,
rgba(255, 255, 255, 0.02) 0%,
rgba(255, 255, 255, 0.02) 34%,
rgba(255, 255, 255, 0.07) 50%,
rgba(255, 255, 255, 0.02) 66%,
rgba(255, 255, 255, 0.02) 100%
);
}
html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item-selected {
color: var(--admin-success) !important;
}
html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item-selected .anticon {
color: var(--admin-success);
}
html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item .anticon {
color: var(--admin-soft);
}
html.admin-route[data-admin-theme="dark"] .wesp-gauge-track {
stroke: rgba(186, 184, 208, 0.18);
}
html.admin-route[data-admin-theme="dark"] .wesp-admin-log {
background: linear-gradient(165deg, #0b0a10 0%, #15141c 48%, #0b0a10 100%);
color: #6bc4a8;
}
@media (max-width: 768px) {
.wesp-admin-layout.ant-layout-has-sider {
flex-direction: column;
}
.wesp-admin-sider.ant-layout-sider {
position: static;
inset: auto;
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
flex: 0 0 auto !important;
height: auto !important;
}
.wesp-admin-layout > .ant-layout {
margin-left: 0;
}
.wesp-admin-sider .ant-layout-sider-children {
padding-bottom: 0;
}
.wesp-admin-sider .ant-layout-sider-trigger {
display: none;
}
.wesp-admin-sider.ant-layout-sider-collapsed .wesp-menu-label {
max-width: 16rem !important;
opacity: 1 !important;
pointer-events: auto;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item {
justify-content: flex-start;
gap: 10px;
padding-inline: 16px !important;
}
.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-title-content {
width: auto !important;
opacity: 1 !important;
flex: 1 !important;
overflow: visible !important;
}
.wesp-admin-content {
margin: 12px;
}
.wesp-admin-content-topbar {
justify-content: flex-start;
}
.wesp-admin-gauges-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.wesp-admin-sider.ant-layout-sider,
.wesp-admin-layout > .ant-layout,
.wesp-admin-sider .wesp-menu-label,
.wesp-admin-sider .ant-menu-item {
transition: none !important;
}
}