899c54e1fc
Auth: drop localStorage session token entirely, talk to netrunner-backend via credentials:"include" HttpOnly cookies shared over the common parent domain, and redirect to the account subdomain (ЛК) after login instead of a dead local /profile route. Config: NEXT_PUBLIC_* values used by client components (API_URL, ACCOUNT_ORIGIN, BOT_USERNAME, PB_URL) get inlined into the JS bundle at image build time and can't be overridden by docker-compose environment at container start. Moved these reads into server components (page.tsx) and thread them down as props, so one built image can genuinely serve both prod and dev by config alone. CI/CD: split the old single auto-deploy-to-prod workflow into build.yml (build+push on every push to main) and deploy.yml with deploy-dev (auto, same VPS as netrunner-backend's dev environment) and deploy-prod (manual dispatch only) — mirrors netrunner-backend's pipeline shape. Added docker-compose.dev-remote.yml pointing at the dev backend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useRef } from "react";
|
||
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;
|
||
/**
|
||
* Origin ЛК (netrunner-backend) и база API — приходят как props из
|
||
* серверного `page.tsx`, а не читаются напрямую из
|
||
* `process.env.NEXT_PUBLIC_*` здесь: этот файл — `"use client"`, и
|
||
* Next.js инлайнит `NEXT_PUBLIC_*` в клиентский бандл на этапе `next
|
||
* build`, а не читает их заново при старте контейнера. Один и тот же
|
||
* собранный образ используется и для прода, и для дев-деплоя — если бы
|
||
* значение было зашито в бандл на сборке, dev-окружение стучалось бы в
|
||
* прод-бэкенд независимо от `environment:` в docker-compose. Читая их в
|
||
* серверном компоненте (`page.tsx`, исполняется заново на каждый запрос)
|
||
* и прокидывая пропсами, получаем настоящую runtime-конфигурируемость
|
||
* из одного и того же образа.
|
||
*/
|
||
accountOrigin: string;
|
||
apiBase: string;
|
||
botUsername: string;
|
||
}
|
||
|
||
/**
|
||
* Экран авторизации через Telegram Login Widget.
|
||
*
|
||
* Сессия — исключительно HttpOnly cookie, которую ставит netrunner-backend
|
||
* (сабдомен account.netrunner-vpn.com, общий родительский домен
|
||
* `.netrunner-vpn.com` — cookie видна и лендингу, и ЛК). Здесь ничего не
|
||
* хранится и не читается на стороне JS: ни localStorage, ни query/hash с
|
||
* токеном — раньше лендинг таскал токен в `localStorage["nrxp_token"]` и в
|
||
* URL, это ровно то, чего требовалось избежать.
|
||
*
|
||
* Раз ЛК физически находится на другом сабдомене, после успешного логина —
|
||
* полный переход браузера на `${accountOrigin}/profile`, а не
|
||
* клиентский роутинг (страницы `/profile` в самом лендинге нет и не будет).
|
||
*/
|
||
export function AuthClient({
|
||
dict,
|
||
lang,
|
||
accountOrigin,
|
||
apiBase,
|
||
botUsername,
|
||
}: AuthClientProps) {
|
||
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-режиме) — без этого флага мы бы дважды дернули /users/me и задвоили логи.
|
||
if (initialized.current) return;
|
||
initialized.current = true;
|
||
|
||
const authenticate = async () => {
|
||
setLogs((prev) => [...prev, `> ${dict.processing}`]);
|
||
setLogs((prev) => [...prev, `> ${dict.verifying}`]);
|
||
|
||
try {
|
||
const res = await fetch(`${apiBase}/users/me`, {
|
||
credentials: "include",
|
||
});
|
||
|
||
if (res.ok) {
|
||
setLogs((prev) => [...prev, dict.logs.sigValid]);
|
||
setStatus("granted");
|
||
setLogs((prev) => [...prev, `> ${dict.redirecting}`]);
|
||
setTimeout(() => {
|
||
window.location.href = `${accountOrigin}/profile`;
|
||
}, 1500);
|
||
} else {
|
||
setLogs((prev) => [...prev, dict.logs.waitInit]);
|
||
setStatus("awaiting");
|
||
}
|
||
} catch {
|
||
setLogs((prev) => [...prev, dict.logs.coreOffline]);
|
||
setStatus("denied");
|
||
}
|
||
};
|
||
|
||
authenticate();
|
||
}, [dict, apiBase, accountOrigin]);
|
||
|
||
// Регистрируем глобальный колбэк для 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 res = await fetch(`${apiBase}/auth/telegram`, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(user),
|
||
});
|
||
|
||
if (res.ok) {
|
||
setLogs((prev) => [
|
||
...prev,
|
||
dict.logs.sigValid,
|
||
`> ${dict.redirecting}`,
|
||
]);
|
||
setStatus("granted");
|
||
setTimeout(() => {
|
||
window.location.href = `${accountOrigin}/profile`;
|
||
}, 1500);
|
||
} else {
|
||
setLogs((prev) => [...prev, dict.logs.invalidSig]);
|
||
setStatus("denied");
|
||
}
|
||
} catch (e) {
|
||
setLogs((prev) => [...prev, dict.logs.networkError]);
|
||
setStatus("denied");
|
||
}
|
||
};
|
||
}, [dict, lang, apiBase, accountOrigin]);
|
||
|
||
// Инжектим официальный 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", botUsername);
|
||
script.setAttribute("data-size", "large");
|
||
script.setAttribute("data-onauth", "onTelegramAuth(user)");
|
||
script.setAttribute("data-request-access", "write");
|
||
tgContainerRef.current.appendChild(script);
|
||
}
|
||
}, [status, botUsername]);
|
||
|
||
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/${botUsername}`}
|
||
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>
|
||
);
|
||
}
|