357 lines
12 KiB
TypeScript
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 />;
|
|
};
|