web2-client/src/pages/admin/index.tsx

357 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 />;
};