Files
netrunner-landing/app/[lang]/auth/auth-client.tsx
T
2026-07-02 23:13:24 +07:00

312 lines
13 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.
"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>
);
}