Files
web2-client/src/pages/admin/index.tsx
T
2025-10-11 21:42:47 +00:00

357 lines
12 KiB
TypeScript

import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { NavLink, Navigate, Outlet, useLocation } from "react-router-dom";
import clsx from "clsx";
import { useQueryClient } from "react-query";
import { Routes } from "~/app/router/constants";
import {
useAdminLogin,
useAdminLogout,
useAdminOverview,
} from "~/shared/services/admin";
import { clearAdminAuth, getAdminAuthSnapshot } from "~/shared/libs/admin-auth";
import { isUnauthorizedError } from "~/shared/services/admin";
import { ADMIN_SECTIONS, DEFAULT_ADMIN_SECTION, type AdminSection } from "./config";
import { AdminContext } from "./context";
import type { AuthState, FlashMessage } from "./types";
const initialAuthState: AuthState = getAdminAuthSnapshot().token ? "checking" : "unauthorized";
const resolveErrorMessage = (error: unknown) => {
if (error && typeof error === "object" && "message" in error && typeof (error as any).message === "string") {
return (error as any).message as string;
}
return "Неизвестная ошибка";
};
const AdminFlash = ({ flash, onClose }: { flash: FlashMessage | null; onClose: () => void }) => {
if (!flash) {
return null;
}
const tone =
flash.type === "success"
? "border-emerald-500/60 bg-emerald-500/15 text-emerald-50"
: flash.type === "error"
? "border-rose-500/60 bg-rose-500/15 text-rose-50"
: "border-sky-500/60 bg-sky-500/15 text-sky-50";
return (
<div className="pointer-events-none fixed inset-x-4 top-4 z-50 flex justify-center sm:inset-x-auto sm:left-auto sm:right-6">
<div
className={clsx(
"pointer-events-auto flex w-full max-w-md items-start gap-4 rounded-2xl border px-5 py-4 text-sm shadow-2xl shadow-black/40 backdrop-blur-md",
tone,
)}
>
<span className="flex-1 break-words">{flash.message}</span>
<button
type="button"
className="text-xs font-semibold uppercase tracking-wide text-slate-200 transition hover:text-white"
onClick={onClose}
>
Закрыть
</button>
</div>
</div>
);
};
const AdminLoginForm = ({
token,
onTokenChange,
onSubmit,
isSubmitting,
}: {
token: string;
onTokenChange: (value: string) => void;
onSubmit: () => void;
isSubmitting: boolean;
}) => {
return (
<div className="mx-auto max-w-xl rounded-2xl border border-slate-800 bg-slate-900/60 p-8 shadow-xl shadow-black/50">
<h1 className="text-3xl font-semibold text-slate-100">Админ-панель узла</h1>
<p className="mt-2 text-sm text-slate-400">
Введите секретный токен ADMIN_API_TOKEN, чтобы получить доступ к мониторингу и управлению.
</p>
<form
className="mt-6 space-y-4"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<label className="block text-sm font-medium text-slate-200">
Админ-токен
<input
type="password"
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-base text-slate-100 shadow-inner focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/40"
value={token}
onChange={(event) => onTokenChange(event.target.value)}
placeholder="••••••"
autoComplete="off"
/>
</label>
<div className="flex items-center justify-between">
<button
type="submit"
className="inline-flex items-center justify-center rounded-lg bg-sky-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/60 disabled:cursor-not-allowed disabled:bg-slate-700"
disabled={isSubmitting}
>
{isSubmitting ? "Проверяем…" : "Войти"}
</button>
<span className="text-xs text-slate-500">
Cookie max-age {Math.round(172800 / 3600)} ч
</span>
</div>
</form>
</div>
);
};
const MobileNav = ({ sections, currentPath }: { sections: AdminSection[]; currentPath: string }) => (
<select
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-200 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 md:hidden"
value={currentPath}
onChange={(event) => {
const next = event.target.value;
if (next !== currentPath) {
window.location.assign(next);
}
}}
>
{sections.map((section) => {
const to = `${Routes.Admin}/${section.path}`;
return (
<option key={section.id} value={to}>
{section.label}
</option>
);
})}
</select>
);
const DesktopNav = ({
sections,
currentPath,
}: {
sections: AdminSection[];
currentPath: string;
}) => {
return (
<nav className="hidden w-60 shrink-0 flex-col gap-1 md:flex">
{sections.map((section) => {
const to = `${Routes.Admin}/${section.path}`;
const isActive = currentPath.startsWith(to);
return (
<NavLink
key={section.id}
to={to}
className={clsx(
"rounded-xl px-4 py-3 text-sm font-medium transition",
isActive
? "bg-sky-500/10 text-sky-200 ring-1 ring-sky-500/40"
: "text-slate-300 hover:bg-slate-900/60 hover:text-white",
)}
end
>
<div className="flex flex-col">
<span>{section.label}</span>
<span className="mt-1 text-xs text-slate-500">{section.description}</span>
</div>
</NavLink>
);
})}
</nav>
);
};
const AdminLayoutFrame = ({ children }: { children: ReactNode }) => {
const location = useLocation();
const currentPath = location.pathname;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 text-slate-100">
<header className="border-b border-slate-800 bg-slate-950/70 backdrop-blur">
<div className="flex w-full items-center justify-between gap-4 px-4 py-4 sm:px-6 lg:px-8">
<div>
<h1 className="text-xl font-semibold text-white">MY Admin</h1>
<p className="text-xs text-slate-500">Мониторинг и управление узлом</p>
</div>
<div className="flex items-center gap-3 md:hidden">
<MobileNav sections={ADMIN_SECTIONS} currentPath={currentPath} />
</div>
</div>
</header>
<main className="flex w-full flex-1 flex-col gap-6 px-4 py-6 sm:px-6 lg:px-8 md:flex-row">
<DesktopNav sections={ADMIN_SECTIONS} currentPath={currentPath} />
<div className="w-full max-w-full md:flex-1">
<MobileNav sections={ADMIN_SECTIONS} currentPath={currentPath} />
<div className="mt-4 space-y-6 md:mt-0">
{children}
</div>
</div>
</main>
</div>
);
};
export const AdminShell = () => {
const queryClient = useQueryClient();
const [authState, setAuthState] = useState<AuthState>(initialAuthState);
const [token, setToken] = useState("");
const [flash, setFlash] = useState<FlashMessage | null>(null);
useEffect(() => {
if (!flash) {
return;
}
const timeout = setTimeout(() => setFlash(null), 6000);
return () => clearTimeout(timeout);
}, [flash]);
useEffect(() => {
if (authState === "unauthorized") {
queryClient.removeQueries({
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "admin",
});
}
}, [authState, queryClient]);
const pushFlash = useCallback((message: FlashMessage) => {
setFlash(message);
}, []);
const clearFlash = useCallback(() => {
setFlash(null);
}, []);
const handleRequestError = useCallback(
(error: unknown, fallbackMessage: string) => {
if (isUnauthorizedError(error)) {
clearAdminAuth();
setAuthState("unauthorized");
pushFlash({ type: "info", message: "Сессия истекла. Введите админ-токен заново." });
return;
}
const tail = resolveErrorMessage(error);
pushFlash({
type: "error",
message: `${fallbackMessage}: ${tail}`,
});
},
[pushFlash],
);
const invalidateAll = useCallback(async () => {
await queryClient.invalidateQueries({
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "admin",
});
}, [queryClient]);
useAdminOverview({
enabled: authState === "checking",
retry: false,
onSuccess: () => {
setAuthState("authorized");
},
onError: (error) => {
handleRequestError(error, "Не удалось проверить админ-сессию");
},
});
const loginMutation = useAdminLogin({
onSuccess: async () => {
setAuthState("authorized");
setToken("");
pushFlash({ type: "success", message: "Админ-сессия активирована" });
await invalidateAll();
},
onError: (error) => {
if (isUnauthorizedError(error)) {
clearAdminAuth();
pushFlash({ type: "error", message: "Неверный токен" });
return;
}
handleRequestError(error, "Ошибка входа");
},
});
const logoutMutation = useAdminLogout({
onSuccess: async () => {
setAuthState("unauthorized");
pushFlash({ type: "info", message: "Сессия завершена" });
await invalidateAll();
},
onError: (error) => {
handleRequestError(error, "Ошибка выхода");
},
});
const contextValue = useMemo(
() => ({
authState,
isAuthorized: authState === "authorized",
pushFlash,
clearFlash,
handleRequestError,
invalidateAll,
}),
[authState, pushFlash, clearFlash, handleRequestError, invalidateAll],
);
return (
<AdminContext.Provider value={contextValue}>
{authState !== "authorized" ? (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 px-6 py-10">
<div className="w-full max-w-xl space-y-6">
<AdminLoginForm
token={token}
onTokenChange={setToken}
onSubmit={() => {
if (!token) {
pushFlash({ type: "info", message: "Введите токен" });
return;
}
loginMutation.mutate({ secret: token });
}}
isSubmitting={loginMutation.isLoading}
/>
</div>
</div>
) : (
<AdminLayoutFrame>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-200 ring-1 ring-emerald-500/40">
Авторизован
</span>
<button
type="button"
className="rounded-lg border border-slate-700 px-3 py-1 text-xs font-semibold text-slate-300 transition hover:border-sky-500 hover:text-white"
onClick={() => invalidateAll()}
>
Обновить все данные
</button>
</div>
<button
type="button"
className="rounded-lg bg-rose-600 px-3 py-1 text-xs font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:bg-rose-900/50"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isLoading}
>
Выйти
</button>
</div>
<Outlet />
</AdminLayoutFrame>
)}
<AdminFlash flash={flash} onClose={clearFlash} />
</AdminContext.Provider>
);
};
export const AdminPage = AdminShell;
export const AdminIndexRedirect = () => {
return <Navigate to={`${Routes.Admin}/${DEFAULT_ADMIN_SECTION.path}`} replace />;
};