deploy change, update token system, fix abuses and add admin verify
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Shield, Server, Activity, Plus, Power, RefreshCw } from "lucide-react";
|
||||
import { TOKEN_KEY, API_BASE } from "./api";
|
||||
import { getTokenCookie, API_BASE } from "./api";
|
||||
|
||||
interface VpnNode {
|
||||
id: string;
|
||||
@@ -16,16 +16,14 @@ export default function AdminDashboard() {
|
||||
const [nodes, setNodes] = useState<VpnNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Стейт формы
|
||||
const [name, setName] = useState("");
|
||||
const [ip, setIp] = useState("");
|
||||
const [code, setCode] = useState("de");
|
||||
const [sshPassword, setSshPassword] = useState(""); // <-- Добавлен стейт пароля
|
||||
const [sshPassword, setSshPassword] = useState("");
|
||||
|
||||
const fetchNodes = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
// Путь: /api/v1/nodes
|
||||
const token = getTokenCookie();
|
||||
const res = await fetch(`${API_BASE}/nodes`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
@@ -45,7 +43,6 @@ export default function AdminDashboard() {
|
||||
let isMounted = true;
|
||||
|
||||
const loadData = async () => {
|
||||
// Это предотвратит вызов setState если компонент успел размонтироваться
|
||||
if (isMounted) {
|
||||
await fetchNodes();
|
||||
}
|
||||
@@ -58,11 +55,10 @@ export default function AdminDashboard() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAddNode = async (e: React.SubmitEvent) => {
|
||||
const handleAddNode = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
// Путь: /api/v1/admin/nodes
|
||||
const token = getTokenCookie();
|
||||
const res = await fetch(`${API_BASE}/admin/nodes`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -74,16 +70,16 @@ export default function AdminDashboard() {
|
||||
country_code: code,
|
||||
ip_address: ip,
|
||||
port: 443,
|
||||
ssh_password: sshPassword, // <-- Пароль теперь передается на бэкенд
|
||||
ssh_password: sshPassword,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setName("");
|
||||
setIp("");
|
||||
setSshPassword(""); // <-- Очищаем пароль после успеха
|
||||
setSshPassword("");
|
||||
fetchNodes();
|
||||
} else {
|
||||
console.error("Failed to add node:", await res.text());
|
||||
console.error("Failed to add node:", await res.text());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -113,10 +109,9 @@ export default function AdminDashboard() {
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Панель добавления сервера */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-black/40 border border-white/10 rounded-2xl p-6 relative overflow-hidden group">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-linear-to-r from-secondary to-primary opacity-50" />
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-secondary to-primary opacity-50" />
|
||||
<h2 className="text-sm font-bold uppercase tracking-widest mb-6 flex items-center gap-2">
|
||||
<Plus className="w-4 h-4 text-secondary" />
|
||||
Deploy New Node
|
||||
@@ -161,8 +156,7 @@ export default function AdminDashboard() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Поле для ввода временного SSH-пароля */}
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||
Root SSH Password (Temporary)
|
||||
@@ -187,7 +181,6 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Активные узлы */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<h2 className="text-sm font-bold uppercase tracking-widest text-muted-foreground mb-4">
|
||||
Active Grid
|
||||
@@ -244,4 +237,4 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-61
@@ -1,76 +1,24 @@
|
||||
import { useEffect, useState, Suspense, lazy } from "react";
|
||||
import { Suspense, lazy, useState } from "react";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { TonConnectUIProvider } from "@tonconnect/ui-react";
|
||||
import { TOKEN_KEY } from "./api";
|
||||
|
||||
const Profile = lazy(() => import("./Profile"));
|
||||
const HackGame = lazy(() => import("./HackGame"));
|
||||
const AdminDashboard = lazy(() => import("./AdminDashboard"));
|
||||
const AuthClient = lazy(() => import("./Auth"));
|
||||
|
||||
const DICT = {
|
||||
en: {
|
||||
unauthorized: "[ERROR] UNAUTHORIZED ACCESS",
|
||||
loading: "Loading protocols...",
|
||||
},
|
||||
ru: {
|
||||
unauthorized: "[ОШИБКА] ДОСТУП ЗАПРЕЩЕН",
|
||||
loading: "Загрузка протоколов...",
|
||||
},
|
||||
en: { loading: "Loading protocols..." },
|
||||
ru: { loading: "Загрузка протоколов..." },
|
||||
};
|
||||
|
||||
const getLang = (): "en" | "ru" => {
|
||||
const l = localStorage.getItem("lang");
|
||||
return l === "ru" ? "ru" : "en";
|
||||
};
|
||||
const getLang = (): "en" | "ru" =>
|
||||
localStorage.getItem("lang") === "ru" ? "ru" : "en";
|
||||
|
||||
function AuthHandler({ children }: { children: React.ReactNode }) {
|
||||
const [lang] = useState<"en" | "ru">(getLang);
|
||||
const t = DICT[lang];
|
||||
|
||||
// 1. Вычисляем статус авторизации синхронно ДО первого рендера
|
||||
const [isAuthenticated] = useState(() => {
|
||||
let token = localStorage.getItem(TOKEN_KEY);
|
||||
const hash = window.location.hash;
|
||||
|
||||
// Если токен есть в URL, он приоритетнее
|
||||
if (hash.includes("token=")) {
|
||||
const params = new URLSearchParams(hash.replace("#", "?"));
|
||||
const urlToken = params.get("token");
|
||||
if (urlToken) {
|
||||
token = urlToken;
|
||||
}
|
||||
}
|
||||
|
||||
return !!token || import.meta.env.DEV;
|
||||
});
|
||||
|
||||
// 2. В эффекте делаем только "сайд-эффекты" браузера без вызова setState!
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash;
|
||||
|
||||
if (hash.includes("token=")) {
|
||||
const params = new URLSearchParams(hash.replace("#", "?"));
|
||||
const token = params.get("token");
|
||||
|
||||
if (token) {
|
||||
// Сохраняем токен и чистим URL (это сайд-эффекты, им самое место тут)
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Если не авторизован и не в DEV режиме — показываем лаконичную ошибку
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center bg-[#05050A] font-mono p-6 text-center">
|
||||
<h1 className="text-[#FF003C] text-lg tracking-widest uppercase animate-pulse">
|
||||
{t.unauthorized}
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full overflow-x-hidden bg-black">
|
||||
<Suspense
|
||||
@@ -87,7 +35,6 @@ function AuthHandler({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// Динамически получаем базовый URL, чтобы работало и на localhost, и на реальном домене
|
||||
const manifestUrl = `${window.location.origin}/tonconnect-manifest.json`;
|
||||
|
||||
return (
|
||||
@@ -95,7 +42,7 @@ export default function App() {
|
||||
<BrowserRouter>
|
||||
<AuthHandler>
|
||||
<Routes>
|
||||
<Route path="/" element={<Profile />} />
|
||||
<Route path="/" element={<AuthClient />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/hack" element={<HackGame />} />
|
||||
<Route path="/admin" element={<AdminDashboard />} />
|
||||
@@ -104,4 +51,4 @@ export default function App() {
|
||||
</BrowserRouter>
|
||||
</TonConnectUIProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-98
@@ -9,6 +9,7 @@ import {
|
||||
Terminal,
|
||||
Key,
|
||||
} from "lucide-react";
|
||||
import { removeTokenCookie } from "./api";
|
||||
|
||||
export interface TelegramAuthUser {
|
||||
id: number;
|
||||
@@ -57,10 +58,8 @@ const DICT = {
|
||||
},
|
||||
};
|
||||
|
||||
const getLang = (): "en" | "ru" => {
|
||||
const l = localStorage.getItem("lang");
|
||||
return l === "ru" ? "ru" : "en";
|
||||
};
|
||||
const getLang = (): "en" | "ru" =>
|
||||
localStorage.getItem("lang") === "ru" ? "ru" : "en";
|
||||
|
||||
export default function AuthClient() {
|
||||
const navigate = useNavigate();
|
||||
@@ -85,66 +84,24 @@ export default function AuthClient() {
|
||||
|
||||
const authenticate = async () => {
|
||||
setLogs((prev) => [...prev, `> ${t.processing}`]);
|
||||
|
||||
// Эмулируем задержку для "хакерского" эффекта консоли
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
|
||||
const hash = window.location.hash;
|
||||
let token = localStorage.getItem("nrxp_token");
|
||||
|
||||
// Логика получения токена из query parameters или хеша
|
||||
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;
|
||||
// Безопасно очищаем URL от токена, сохраняя search params
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname + window.location.search,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
"> [WAITING] INITIALIZE SECURE LOGIN...",
|
||||
]);
|
||||
setStatus("awaiting");
|
||||
return;
|
||||
}
|
||||
|
||||
if (status !== "processing") setStatus("processing");
|
||||
setLogs((prev) => [...prev, `> ${t.verifying}`]);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/users/me`);
|
||||
|
||||
if (res.ok) {
|
||||
localStorage.setItem("nrxp_token", token);
|
||||
setLogs((prev) => [...prev, "> [OK] SIGNATURE VALID"]);
|
||||
setStatus("granted");
|
||||
setLogs((prev) => [...prev, `> ${t.redirecting}`]);
|
||||
setTimeout(() => navigate("/profile"), 1500);
|
||||
} else {
|
||||
localStorage.removeItem("nrxp_token");
|
||||
setLogs((prev) => [...prev, "> [FAIL] API REJECTED TOKEN"]);
|
||||
removeTokenCookie();
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
"> [WAITING] INITIALIZE SECURE LOGIN...",
|
||||
]);
|
||||
setStatus("awaiting");
|
||||
}
|
||||
} catch {
|
||||
@@ -173,8 +130,6 @@ export default function AuthClient() {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
localStorage.setItem("nrxp_token", data.token);
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
"> [OK] SIGNATURE VALID",
|
||||
@@ -224,7 +179,6 @@ export default function AuthClient() {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
localStorage.setItem("nrxp_token", data.token);
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
`> [CRITICAL] YOUR SEED: ${data.seed}`,
|
||||
@@ -232,7 +186,6 @@ export default function AuthClient() {
|
||||
"> ROUTING TO UPLINK...",
|
||||
]);
|
||||
setStatus("granted");
|
||||
// Задержка больше, чтобы юзер успел скопировать сид
|
||||
setTimeout(() => navigate("/profile"), 6000);
|
||||
} else throw new Error();
|
||||
} catch {
|
||||
@@ -251,9 +204,7 @@ export default function AuthClient() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ seed: seedInput }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
localStorage.setItem("nrxp_token", data.token);
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
"> [OK] SEED ACCEPTED",
|
||||
@@ -273,40 +224,25 @@ export default function AuthClient() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pt-16 pb-24 px-4 container mx-auto max-w-2xl relative flex flex-col items-center justify-center font-mono bg-[#05050A]">
|
||||
{/* Мягкое фоновое свечение */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[80vw] h-[80vw] max-w-[600px] max-h-[600px] 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-white/[0.02] backdrop-blur-xl border border-white/10 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>
|
||||
<Shield className="w-20 h-20 text-primary animate-pulse" />
|
||||
)}
|
||||
{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>
|
||||
<ShieldCheck className="w-20 h-20 text-secondary" />
|
||||
)}
|
||||
{status === "denied" && (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-red-500/20 blur-xl rounded-full" />
|
||||
<ShieldAlert className="w-20 h-20 text-red-500 drop-shadow-[0_0_15px_rgba(255,0,0,0.8)] relative z-10" />
|
||||
</div>
|
||||
<ShieldAlert className="w-20 h-20 text-red-500" />
|
||||
)}
|
||||
{(status === "awaiting" || status === "ghost_login") && (
|
||||
<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>
|
||||
<Terminal className="w-20 h-20 text-secondary" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-black tracking-widest text-white/90 uppercase">
|
||||
@@ -320,7 +256,6 @@ export default function AuthClient() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Терминал с логами процесса авторизации */}
|
||||
<div className="bg-black/60 border border-white/10 rounded-2xl p-6 font-mono text-sm text-muted-foreground/80 min-h-[140px] flex flex-col justify-end space-y-3 mb-8 shadow-inner overflow-y-auto">
|
||||
<AnimatePresence>
|
||||
{logs.map((log, i) => (
|
||||
@@ -330,9 +265,9 @@ export default function AuthClient() {
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={
|
||||
log.includes("[OK]")
|
||||
? "text-secondary font-bold drop-shadow-[0_0_5px_rgba(0,229,242,0.5)]"
|
||||
? "text-secondary font-bold"
|
||||
: log.includes("[FAIL]")
|
||||
? "text-red-500 font-bold drop-shadow-[0_0_5px_rgba(255,0,0,0.5)]"
|
||||
? "text-red-500 font-bold"
|
||||
: log.includes("[CRITICAL]")
|
||||
? "text-primary font-bold animate-pulse"
|
||||
: ""
|
||||
@@ -342,8 +277,6 @@ export default function AuthClient() {
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Мигающий курсор загрузки */}
|
||||
{status === "processing" && (
|
||||
<div className="flex items-center gap-2 text-primary mt-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
@@ -354,7 +287,6 @@ export default function AuthClient() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Блок логина виджетом Telegram */}
|
||||
{status === "awaiting" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -368,17 +300,16 @@ export default function AuthClient() {
|
||||
ref={tgContainerRef}
|
||||
className="min-h-[40px] flex items-center justify-center bg-white/5 rounded-xl p-4 w-full"
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 w-full mt-4">
|
||||
<button
|
||||
onClick={() => setStatus("ghost_login")}
|
||||
className="flex-1 bg-white/5 hover:bg-white/10 border border-white/10 text-muted-foreground hover:text-white p-3 rounded-xl transition-colors text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-2"
|
||||
className="flex-1 bg-white/5 p-3 rounded-xl text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-2"
|
||||
>
|
||||
<Key className="w-4 h-4" /> Seed Login
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGhostGenerate}
|
||||
className="flex-1 bg-primary/10 hover:bg-primary/20 border border-primary/30 text-primary p-3 rounded-xl transition-colors text-xs font-bold uppercase tracking-widest"
|
||||
className="flex-1 bg-primary/10 text-primary p-3 rounded-xl text-xs font-bold uppercase tracking-widest"
|
||||
>
|
||||
Create Seed
|
||||
</button>
|
||||
@@ -386,7 +317,6 @@ export default function AuthClient() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Форма входа по Seed (Ghost Protocol) */}
|
||||
{status === "ghost_login" && (
|
||||
<motion.form
|
||||
onSubmit={handleGhostLogin}
|
||||
@@ -400,20 +330,20 @@ export default function AuthClient() {
|
||||
value={seedInput}
|
||||
onChange={(e) => setSeedInput(e.target.value)}
|
||||
placeholder="Enter 16-digit Seed"
|
||||
className="w-full bg-black/50 border border-white/10 rounded-xl p-4 text-center font-mono text-lg text-white focus:border-secondary outline-none transition-colors tracking-[0.2em]"
|
||||
className="w-full bg-black/50 border border-white/10 rounded-xl p-4 text-center font-mono text-lg text-white outline-none tracking-[0.2em]"
|
||||
maxLength={16}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus("awaiting")}
|
||||
className="flex-1 bg-white/5 text-muted-foreground p-3 rounded-xl text-xs uppercase tracking-widest hover:text-white transition-colors"
|
||||
className="flex-1 bg-white/5 text-muted-foreground p-3 rounded-xl text-xs uppercase tracking-widest"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-[2] bg-secondary/10 border border-secondary/30 text-secondary font-bold p-3 rounded-xl text-xs uppercase tracking-widest hover:bg-secondary/20 transition-colors"
|
||||
className="flex-[2] bg-secondary/10 text-secondary font-bold p-3 rounded-xl text-xs uppercase tracking-widest"
|
||||
>
|
||||
Authenticate
|
||||
</button>
|
||||
@@ -421,7 +351,6 @@ export default function AuthClient() {
|
||||
</motion.form>
|
||||
)}
|
||||
|
||||
{/* Сообщение об ошибке и кнопка в бот (если доступ запрещен) */}
|
||||
{status === "denied" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -435,17 +364,13 @@ export default function AuthClient() {
|
||||
onClick={() =>
|
||||
(window.location.href = `https://t.me/${BOT_USERNAME}`)
|
||||
}
|
||||
className="w-full relative bg-primary/10 hover:bg-primary/20 border border-primary/40 text-primary font-bold tracking-widest uppercase p-4 rounded-xl transition-all flex justify-center items-center gap-3 shadow-[0_0_15px_rgba(139,61,255,0.1)] hover:shadow-[0_0_25px_rgba(139,61,255,0.3)] group overflow-hidden"
|
||||
className="w-full bg-primary/10 text-primary font-bold tracking-widest uppercase p-4 rounded-xl flex justify-center items-center gap-3"
|
||||
>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(90deg,transparent_0%,rgba(139,61,255,0.1)_50%,transparent_100%)] translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000" />
|
||||
<Terminal className="w-5 h-5 group-hover:text-white transition-colors" />
|
||||
<span className="group-hover:text-white transition-colors">
|
||||
[ {t.toBot} ]
|
||||
</span>
|
||||
<Terminal className="w-5 h-5" /> [ {t.toBot} ]
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+24
-22
@@ -20,8 +20,12 @@ import {
|
||||
useTonConnectUI,
|
||||
useTonAddress,
|
||||
} from "@tonconnect/ui-react";
|
||||
import { fetchProfile, activateTrial, initiatePayment, confirmPayment } from "./api";
|
||||
import { beginCell } from "@ton/core";
|
||||
import {
|
||||
fetchProfile,
|
||||
activateTrial,
|
||||
initiatePayment,
|
||||
confirmPayment,
|
||||
} from "./api";
|
||||
|
||||
// --- ТИПИЗАЦИЯ ---
|
||||
|
||||
@@ -253,7 +257,7 @@ export default function Profile() {
|
||||
const user = data.user || data;
|
||||
const subscription = data.subscription;
|
||||
const hasSub = subscription && subscription.plan_name;
|
||||
const recruits = data.referrals ? data.referrals.length : (data.recruits || 0);
|
||||
const recruits = data.referrals ? data.referrals.length : data.recruits || 0;
|
||||
const earned = data.earned || 0;
|
||||
|
||||
const copyRef = () => {
|
||||
@@ -280,7 +284,7 @@ export default function Profile() {
|
||||
}
|
||||
};
|
||||
|
||||
const executeTonPayment = async (planName: string) => {
|
||||
const executeTonPayment = async (planName: string) => {
|
||||
if (!userFriendlyAddress) {
|
||||
tonConnectUI.openModal();
|
||||
return;
|
||||
@@ -288,15 +292,12 @@ const executeTonPayment = async (planName: string) => {
|
||||
|
||||
try {
|
||||
const targetCurrency = lang === "ru" ? "RUB" : "USD";
|
||||
const { checkout_data } = await initiatePayment(planName, "TON", targetCurrency);
|
||||
|
||||
// Упаковываем комментарий (ID инвойса) в формат Cell для TON блокчейна
|
||||
const body = beginCell()
|
||||
.storeUint(0, 32) // 32 нуля означают, что дальше идет обычный текстовый комментарий
|
||||
.storeStringTail(checkout_data.comment)
|
||||
.endCell();
|
||||
|
||||
const payload = body.toBoc().toString("base64");
|
||||
// Бэкенд возвращает destination, amount и comment
|
||||
const { checkout_data } = await initiatePayment(
|
||||
planName,
|
||||
"TON",
|
||||
targetCurrency,
|
||||
);
|
||||
|
||||
const transaction = {
|
||||
validUntil: getTonTxValidUntil(),
|
||||
@@ -304,20 +305,21 @@ const executeTonPayment = async (planName: string) => {
|
||||
{
|
||||
address: checkout_data.destination,
|
||||
amount: checkout_data.amount.toString(),
|
||||
payload: payload, // <-- Теперь блокчейн запишет ID инвойса в транзакцию!
|
||||
}
|
||||
]
|
||||
// Кошелек сам упакует комментарий, если его передать в payload как text:
|
||||
payload: `text:${checkout_data.comment}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
const result = await tonConnectUI.sendTransaction(transaction);
|
||||
// Отправляем результат (boc) на бэкенд для верификации
|
||||
await confirmPayment(checkout_data.comment, result.boc);
|
||||
|
||||
|
||||
const newData = await fetchProfile();
|
||||
setData(newData);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
console.error(e);
|
||||
alert(`[SYS.ERR] TRANSACTION ABORTED: ${e.message || "Network error"}`);
|
||||
console.error(err);
|
||||
alert(`[SYS.ERR] TRANSACTION ABORTED`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -594,4 +596,4 @@ const executeTonPayment = async (planName: string) => {
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+63
-20
@@ -1,25 +1,34 @@
|
||||
// Базовый путь теперь смотрит на /api/v1
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
|
||||
export const TOKEN_KEY = "nrxp_token";
|
||||
|
||||
export const fetchProfile = async () => {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
if (!token) throw new Error("No token found");
|
||||
export const removeTokenCookie = () => {
|
||||
document.cookie = `${TOKEN_KEY}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;`;
|
||||
};
|
||||
|
||||
// Путь: /api/v1/users/me
|
||||
const res = await fetch(`${API_BASE}/users/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
export const getTokenCookie = (): string | null => {
|
||||
const match = document.cookie.match(
|
||||
new RegExp("(^| )" + TOKEN_KEY + "=([^;]+)"),
|
||||
);
|
||||
return match ? match[2] : null;
|
||||
};
|
||||
|
||||
// --- API Запросы ---
|
||||
export const fetchProfile = async () => {
|
||||
const res = await fetch(`${API_BASE}/users/me`);
|
||||
|
||||
if (res.status === 401) {
|
||||
window.location.href = "/"; // Сервер сам "выкинул" куку или она протухла
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error("Failed to fetch profile");
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const submitHackResult = async (score: number) => {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
const token = getTokenCookie();
|
||||
if (!token) throw new Error("No token found");
|
||||
|
||||
// Путь: /api/v1/users/hack/result
|
||||
const res = await fetch(`${API_BASE}/users/hack/result`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -29,17 +38,31 @@ export const submitHackResult = async (score: number) => {
|
||||
body: JSON.stringify({ score }),
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
removeTokenCookie();
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error("Failed to sync breach data");
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const activateTrial = async () => {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
// Путь: /api/v1/billing/trial
|
||||
const token = getTokenCookie();
|
||||
if (!token) throw new Error("No token found");
|
||||
|
||||
const res = await fetch(`${API_BASE}/billing/trial`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
removeTokenCookie();
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || res.statusText || "Activation failed");
|
||||
@@ -47,9 +70,12 @@ export const activateTrial = async () => {
|
||||
return res.json();
|
||||
};
|
||||
|
||||
|
||||
export const initiatePayment = async (plan: string, provider: string, currency: string) => {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
export const initiatePayment = async (
|
||||
plan: string,
|
||||
provider: string,
|
||||
currency: string,
|
||||
) => {
|
||||
const token = getTokenCookie();
|
||||
if (!token) throw new Error("No token found");
|
||||
|
||||
const res = await fetch(`${API_BASE}/billing/pay/initiate`, {
|
||||
@@ -60,7 +86,13 @@ export const initiatePayment = async (plan: string, provider: string, currency:
|
||||
},
|
||||
body: JSON.stringify({ plan, provider, currency }),
|
||||
});
|
||||
|
||||
|
||||
if (res.status === 401) {
|
||||
removeTokenCookie();
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || "Initiate payment failed");
|
||||
@@ -68,8 +100,13 @@ export const initiatePayment = async (plan: string, provider: string, currency:
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const confirmPayment = async (invoice_id: string, external_tx_id: string) => {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
export const confirmPayment = async (
|
||||
invoice_id: string,
|
||||
external_tx_id: string,
|
||||
) => {
|
||||
const token = getTokenCookie();
|
||||
if (!token) throw new Error("No token found");
|
||||
|
||||
const res = await fetch(`${API_BASE}/billing/pay/confirm`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -78,10 +115,16 @@ export const confirmPayment = async (invoice_id: string, external_tx_id: string)
|
||||
},
|
||||
body: JSON.stringify({ invoice_id, external_tx_id }),
|
||||
});
|
||||
|
||||
|
||||
if (res.status === 401) {
|
||||
removeTokenCookie();
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || "Payment verification failed");
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user