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