312 lines
13 KiB
TypeScript
312 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useRef } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import {
|
||
Shield,
|
||
ShieldAlert,
|
||
ShieldCheck,
|
||
Loader2,
|
||
Terminal,
|
||
} from "lucide-react";
|
||
import { CyberButton } from "@/components/ui/cyber-button";
|
||
import { Dictionary } from "@/lib/IDict";
|
||
|
||
// Telegram Login Widget вызывает этот колбэк из глобального window после успешной
|
||
// авторизации в самом Telegram — вешаем на него типизацию, т.к. виджет грузится
|
||
// сторонним <script> и не завязан на React-модель.
|
||
declare global {
|
||
interface Window {
|
||
onTelegramAuth: (user: any) => void;
|
||
}
|
||
}
|
||
|
||
interface AuthClientProps {
|
||
dict: Dictionary["auth"];
|
||
lang: string;
|
||
}
|
||
|
||
/**
|
||
* Экран авторизации через Telegram Login Widget.
|
||
*
|
||
* Логика в двух независимых ветках:
|
||
* 1. Если в URL уже есть токен (боту редиректит юзера обратно с `?token=` или `#token=`,
|
||
* либо токен уже лежит в localStorage) — сразу проверяем его на `/api/me`.
|
||
* 2. Если токена нет — показываем статус "awaiting" и инжектим Telegram Widget script;
|
||
* после успешного логина в Telegram виджет дергает `window.onTelegramAuth`, мы шлём
|
||
* данные пользователя на `/api/auth/telegram`, получаем токен и сохраняем его.
|
||
*
|
||
* Токен читается и из query, и из hash, потому что Telegram Login Widget в разных
|
||
* сценариях редиректа (виджет на этой же странице vs. редирект из бота) кладёт токен
|
||
* то в query-параметр, то в хэш — приходится поддерживать оба варианта.
|
||
*
|
||
* Статусы: processing (идёт проверка) / granted (успех, редирект в профиль) /
|
||
* denied (сеть недоступна или подпись невалидна) / awaiting (ждём логина в Telegram).
|
||
*/
|
||
export function AuthClient({ dict, lang }: AuthClientProps) {
|
||
const router = useRouter();
|
||
const [status, setStatus] = useState<
|
||
"processing" | "granted" | "denied" | "awaiting"
|
||
>("processing");
|
||
const [logs, setLogs] = useState<string[]>([]);
|
||
const tgContainerRef = useRef<HTMLDivElement>(null);
|
||
const initialized = useRef(false);
|
||
const widgetInjected = useRef(false);
|
||
|
||
useEffect(() => {
|
||
// Защита от двойного запуска в React 18 Strict Mode (эффект монтируется дважды
|
||
// в dev-режиме) — без этого флага мы бы дважды дернули /api/me и задвоили логи.
|
||
if (initialized.current) return;
|
||
initialized.current = true;
|
||
|
||
const authenticate = async () => {
|
||
setLogs((prev) => [...prev, `> ${dict.processing}`]);
|
||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||
|
||
const hash = window.location.hash;
|
||
let token = localStorage.getItem("nrxp_token");
|
||
|
||
// Токен может прийти двумя путями: как query-параметр (?token=...) или
|
||
// в hash-фрагменте (#token=...) — зависит от того, как именно бот/виджет
|
||
// сформировал редирект. Проверяем оба варианта и в любом случае вычищаем
|
||
// токен из URL через replaceState, чтобы он не остался в истории браузера.
|
||
const searchParams = new URLSearchParams(window.location.search);
|
||
const queryToken = searchParams.get("token");
|
||
|
||
if (queryToken) {
|
||
token = queryToken;
|
||
searchParams.delete("token");
|
||
const newSearch = searchParams.toString();
|
||
const newUrl =
|
||
window.location.pathname +
|
||
(newSearch ? `?${newSearch}` : "") +
|
||
window.location.hash;
|
||
window.history.replaceState(null, "", newUrl);
|
||
} else if (hash.includes("token=")) {
|
||
const params = new URLSearchParams(hash.replace("#", "?"));
|
||
const urlToken = params.get("token");
|
||
if (urlToken) {
|
||
token = urlToken;
|
||
window.history.replaceState(
|
||
null,
|
||
"",
|
||
window.location.pathname + window.location.search,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!token) {
|
||
setLogs((prev) => [...prev, dict.logs.waitInit]);
|
||
setStatus("awaiting");
|
||
return;
|
||
}
|
||
|
||
if (status !== "processing") setStatus("processing");
|
||
setLogs((prev) => [...prev, `> ${dict.verifying}`]);
|
||
|
||
try {
|
||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "/api";
|
||
const res = await fetch(`${API_BASE}/me`, {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
});
|
||
|
||
if (res.ok) {
|
||
localStorage.setItem("nrxp_token", token);
|
||
setLogs((prev) => [...prev, dict.logs.sigValid]);
|
||
setStatus("granted");
|
||
setLogs((prev) => [...prev, `> ${dict.redirecting}`]);
|
||
setTimeout(() => router.push(`/${lang}/profile`), 1500);
|
||
} else {
|
||
localStorage.removeItem("nrxp_token");
|
||
setLogs((prev) => [...prev, dict.logs.apiRejected]);
|
||
setStatus("awaiting");
|
||
}
|
||
} catch (err) {
|
||
setLogs((prev) => [...prev, dict.logs.coreOffline]);
|
||
setStatus("denied");
|
||
}
|
||
};
|
||
|
||
authenticate();
|
||
}, [dict, lang, router]);
|
||
|
||
// Регистрируем глобальный колбэк для Telegram Login Widget: сам виджет
|
||
// подключается сторонним script-тегом ниже (см. следующий useEffect) и после
|
||
// логина в Telegram вызывает window.onTelegramAuth с данными пользователя.
|
||
useEffect(() => {
|
||
window.onTelegramAuth = async (user: any) => {
|
||
setStatus("processing");
|
||
setLogs((prev) => [...prev, dict.logs.authPayload, dict.logs.verifyHmac]);
|
||
try {
|
||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "/api";
|
||
const res = await fetch(`${API_BASE}/auth/telegram`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(user),
|
||
});
|
||
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
localStorage.setItem("nrxp_token", data.token);
|
||
setLogs((prev) => [
|
||
...prev,
|
||
dict.logs.sigValid,
|
||
`> ${dict.redirecting}`,
|
||
]);
|
||
setStatus("granted");
|
||
setTimeout(() => router.push(`/${lang}/profile`), 1500);
|
||
} else {
|
||
setLogs((prev) => [...prev, dict.logs.invalidSig]);
|
||
setStatus("denied");
|
||
}
|
||
} catch (e) {
|
||
setLogs((prev) => [...prev, dict.logs.networkError]);
|
||
setStatus("denied");
|
||
}
|
||
};
|
||
}, [dict, lang, router]);
|
||
|
||
// Инжектим официальный Telegram Widget script только когда реально дошли до
|
||
// экрана "awaiting" (нет токена) — виджет сам рисует кнопку логина в контейнере
|
||
// и после успеха вызывает window.onTelegramAuth. widgetInjected защищает от
|
||
// повторной вставки script-тега при повторных ререндерах.
|
||
useEffect(() => {
|
||
if (
|
||
status === "awaiting" &&
|
||
tgContainerRef.current &&
|
||
!widgetInjected.current
|
||
) {
|
||
widgetInjected.current = true;
|
||
const script = document.createElement("script");
|
||
script.src = "https://telegram.org/js/telegram-widget.js?22";
|
||
script.async = true;
|
||
script.setAttribute(
|
||
"data-telegram-login",
|
||
process.env.NEXT_PUBLIC_BOT_USERNAME || "ntrnr_vpn_bot",
|
||
);
|
||
script.setAttribute("data-size", "large");
|
||
script.setAttribute("data-onauth", "onTelegramAuth(user)");
|
||
script.setAttribute("data-request-access", "write");
|
||
tgContainerRef.current.appendChild(script);
|
||
}
|
||
}, [status]);
|
||
|
||
return (
|
||
<div className="min-h-screen pt-32 pb-24 px-4 container mx-auto max-w-2xl relative flex flex-col items-center justify-center font-mono">
|
||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[80vw] h-[80vw] max-w-150 max-h-150 bg-primary/5 blur-[120px] rounded-full -z-10 pointer-events-none" />
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="w-full bg-card/40 backdrop-blur-xl border border-border/50 rounded-2xl p-8 md:p-12 shadow-2xl relative overflow-hidden"
|
||
>
|
||
<div className="flex flex-col items-center mb-10 text-center">
|
||
<div className="mb-8 relative">
|
||
{status === "processing" && (
|
||
<div className="relative">
|
||
<div className="absolute inset-0 bg-primary/20 blur-xl rounded-full" />
|
||
<Shield className="w-20 h-20 text-primary animate-pulse relative z-10" />
|
||
</div>
|
||
)}
|
||
{status === "granted" && (
|
||
<div className="relative">
|
||
<div className="absolute inset-0 bg-secondary/20 blur-xl rounded-full" />
|
||
<ShieldCheck className="w-20 h-20 text-secondary drop-shadow-[0_0_15px_rgba(0,229,242,0.8)] relative z-10" />
|
||
</div>
|
||
)}
|
||
{status === "denied" && (
|
||
<div className="relative">
|
||
<div className="absolute inset-0 bg-destructive/20 blur-xl rounded-full" />
|
||
<ShieldAlert className="w-20 h-20 text-destructive drop-shadow-[0_0_15px_rgba(255,0,0,0.8)] relative z-10" />
|
||
</div>
|
||
)}
|
||
{status === "awaiting" && (
|
||
<div className="relative">
|
||
<div className="absolute inset-0 bg-secondary/20 blur-xl rounded-full" />
|
||
<Terminal className="w-20 h-20 text-secondary drop-shadow-[0_0_15px_rgba(0,229,242,0.8)] relative z-10" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<h1 className="text-2xl md:text-3xl font-black tracking-widest text-foreground uppercase">
|
||
{status === "processing"
|
||
? dict.title
|
||
: status === "granted"
|
||
? dict.granted
|
||
: status === "awaiting"
|
||
? dict.awaiting
|
||
: dict.denied}
|
||
</h1>
|
||
</div>
|
||
|
||
<div className="bg-background/80 border border-border/50 rounded-2xl p-6 font-mono text-sm text-muted-foreground/80 min-h-35 flex flex-col justify-end space-y-3 mb-8 shadow-inner">
|
||
<AnimatePresence>
|
||
{logs.map((log, i) => (
|
||
<motion.div
|
||
key={i}
|
||
initial={{ opacity: 0, x: -10 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
className={
|
||
log.includes("[OK]") || log.includes("[OK]")
|
||
? "text-secondary font-bold drop-shadow-[0_0_5px_rgba(0,229,242,0.5)]"
|
||
: log.includes("[FAIL]") || log.includes("[ОШИБКА]")
|
||
? "text-destructive font-bold drop-shadow-[0_0_5px_rgba(255,0,0,0.5)]"
|
||
: ""
|
||
}
|
||
>
|
||
{log}
|
||
</motion.div>
|
||
))}
|
||
</AnimatePresence>
|
||
|
||
{status === "processing" && (
|
||
<div className="flex items-center gap-2 text-primary mt-2">
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
<span className="animate-pulse font-bold text-lg leading-none">
|
||
_
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{status === "denied" && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="flex flex-col items-center text-center gap-8"
|
||
>
|
||
<p className="text-sm text-muted-foreground leading-relaxed max-w-md">
|
||
{dict.noToken}
|
||
</p>
|
||
<CyberButton
|
||
href={`https://t.me/${process.env.NEXT_PUBLIC_BOT_USERNAME || "ntrnr_vpn_bot"}`}
|
||
variant="primary"
|
||
className="w-full sm:w-auto"
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
<Terminal className="w-4 h-4" />
|
||
{dict.toBot}
|
||
</span>
|
||
</CyberButton>
|
||
</motion.div>
|
||
)}
|
||
|
||
{status === "awaiting" && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="flex flex-col items-center text-center gap-6 mt-4"
|
||
>
|
||
<p className="text-sm text-muted-foreground leading-relaxed max-w-md">
|
||
{dict.authPrompt}
|
||
</p>
|
||
<div
|
||
ref={tgContainerRef}
|
||
className="min-h-[40px] flex items-center justify-center"
|
||
/>
|
||
</motion.div>
|
||
)}
|
||
</motion.div>
|
||
</div>
|
||
);
|
||
}
|