export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1"; interface PublicConfig { bot_username: string; /// URL Grafana за auth-гейтом Caddy — `null`, пока GRAFANA_PUBLIC_URL не /// задан на сервере (см. ObservabilityPage.tsx). grafana_url: string | null; } let configPromise: Promise | null = null; /// Юзернейм бота — намеренно не `import.meta.env.VITE_BOT_USERNAME`: этот SPA /// собирается один раз в CI, и один и тот же статический бандл раздаётся и /// прод-, и dev-стендом — build-time значение зашилось бы в файлы намертво и /// не различалось бы между стендами (ровно так и вышло на практике: dev видел /// прод-бота, у которого в BotFather другой домен — виджет логина отвечал /// "Bot domain invalid"). `/api/v1/config` читает env на сервере заново на /// каждый запрос, поэтому корректно отдаёт "свой" бот (и Grafana URL) для /// каждого стенда. export function getPublicConfig(): Promise { if (!configPromise) { configPromise = fetch(`${API_BASE}/config`) .then((res) => res.json()) .catch(() => ({ bot_username: "ntrnr_vpn_bot", grafana_url: null })); } return configPromise; } // Сессия живёт только в HttpOnly cookie, которую ставит сервер — JS её не видит // и не должен: раньше здесь читался `document.cookie` в расчёте на токен, // который сервер ставит как HttpOnly, поэтому чтение всегда возвращало null, а // все "авторизованные" запросы ниже реально уходили без токена вообще. let refreshInFlight: Promise | null = null; /// Один раз пробует тихо обновить сессию через `/auth/refresh` (ротация /// refresh-cookie); параллельные вызовы схлопываются в один запрос. function refreshSession(): Promise { if (!refreshInFlight) { refreshInFlight = fetch(`${API_BASE}/auth/refresh`, { method: "POST", credentials: "same-origin", }) .then((res) => res.ok) .catch(() => false) .finally(() => { refreshInFlight = null; }); } return refreshInFlight; } function toRequestInit(options: RequestInit): RequestInit { const headers: Record = { ...(options.headers as Record) }; if (options.body && !headers["Content-Type"]) { headers["Content-Type"] = "application/json"; } return { ...options, credentials: "same-origin", headers }; } /// Единая обёртка над fetch для авторизованных запросов: всегда с cookie, /// при первом 401 пробует silent-refresh и повторяет запрос один раз, при /// провале — чистит сторону клиента и уводит на экран логина. /// /// Редирект пропускается, если мы и так уже на `/` (экран логина): иначе /// `window.location.href = "/"` там же, где мы уже находимся, всё равно /// вызывает полную перезагрузку страницы — React-приложение перемонтируется, /// Auth.tsx заново дёргает тот же `/users/me`, получает тот же 401 (для /// анонимного визита это нормальное, ожидаемое состояние, а не сбой) — и /// уходит в бесконечный цикл перезагрузок. export async function apiFetch( path: string, options: RequestInit = {}, ): Promise { const init = toRequestInit(options); const res = await fetch(`${API_BASE}${path}`, init); if (res.status !== 401) return res; const refreshed = await refreshSession(); if (!refreshed) { // Админка (Owner/Moderator/Partner) логинится отдельно от обычных // Ghost/Telegram-юзеров — редирект на "/" был бы неверным экраном для неё. const isAdminPath = window.location.pathname.startsWith("/admin"); const loginPath = isAdminPath ? "/admin/login" : "/"; if (window.location.pathname !== loginPath) { window.location.href = loginPath; } return res; } return fetch(`${API_BASE}${path}`, init); } async function parseOrThrow(res: Response, fallbackMessage: string) { if (!res.ok) { const errData = await res.json().catch(() => ({}) as { error?: string }); throw new Error(errData.error || fallbackMessage); } return res.json(); } // --- API Запросы --- export const fetchProfile = async () => { const res = await apiFetch("/users/me"); return parseOrThrow(res, "Failed to fetch profile"); }; export interface HackSessionStart { session_id: string; matrix: string[][]; targets: string[][]; base_value: number; time_limit: number; } /// Списывает билет и получает от сервера сгенерированную им доску — сервер /// хранит эту же доску и позже сверит присланный путь, не доверяя очкам, /// которые мог бы подсчитать сам клиент. export const startHackSession = async (): Promise => { const res = await apiFetch("/users/hack/start", { method: "POST" }); return parseOrThrow(res, "Failed to start breach sequence"); }; export const submitHackResult = async ( sessionId: string, path: [number, number][], ) => { const res = await apiFetch("/users/hack/result", { method: "POST", body: JSON.stringify({ session_id: sessionId, path }), }); return parseOrThrow(res, "Failed to sync breach data"); }; export const activateTrial = async () => { const res = await apiFetch("/billing/trial", { method: "POST" }); return parseOrThrow(res, "Activation failed"); }; export const initiatePayment = async ( plan: string, provider: string, currency: string, ) => { const res = await apiFetch("/billing/pay/initiate", { method: "POST", body: JSON.stringify({ plan, provider, currency }), }); return parseOrThrow(res, "Initiate payment failed"); }; export const confirmPayment = async ( invoice_id: string, external_tx_id: string, ) => { const res = await apiFetch("/billing/pay/confirm", { method: "POST", body: JSON.stringify({ invoice_id, external_tx_id }), }); return parseOrThrow(res, "Payment verification failed"); }; /// Обменивает короткоживущий bootstrap-токен из URL (мост из бота, см. /// `App.tsx`) на полноценную cookie-сессию. export const exchangeBootstrapToken = async (token: string): Promise => { const res = await fetch(`${API_BASE}/auth/exchange`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), }); return res.ok; }; // --- Ролевая админка (Owner/Moderator/Partner) --- export const adminLogin = async (username: string, password: string) => { const res = await fetch(`${API_BASE}/auth/admin/login`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); return parseOrThrow(res, "Login failed"); }; export interface StaffRole { role: "owner" | "moderator" | "partner" | "support" | "user" | "admin"; can_manage_nodes: boolean; username: string | null; partner_code: string | null; commission_percent: string | null; } export const fetchMyRole = async (): Promise => { const res = await apiFetch("/admin/staff/me"); return parseOrThrow(res, "Failed to fetch role"); }; export const createModerator = async ( username: string, password: string, canManageNodes: boolean, ) => { const res = await apiFetch("/admin/staff/moderators", { method: "POST", body: JSON.stringify({ username, password, can_manage_nodes: canManageNodes, }), }); return parseOrThrow(res, "Failed to create moderator"); }; export const createSupport = async (username: string, password: string) => { const res = await apiFetch("/admin/staff/support", { method: "POST", body: JSON.stringify({ username, password }), }); return parseOrThrow(res, "Failed to create support account"); }; export const createPartner = async ( username: string, password: string, commissionPercent: number, ) => { const res = await apiFetch("/admin/staff/partners", { method: "POST", body: JSON.stringify({ username, password, commission_percent: commissionPercent, }), }); return parseOrThrow(res, "Failed to create partner"); }; export interface StaffUser { id: string; username: string | null; role: string; can_manage_nodes: boolean; commission_percent: string | null; partner_code: string | null; } export const fetchStaffUsers = async (): Promise => { const res = await apiFetch("/admin/staff/staff-users"); return parseOrThrow(res, "Failed to fetch staff users"); }; export const setNodePermission = async (userId: string, canManageNodes: boolean) => { const res = await apiFetch(`/admin/staff/users/${userId}/permissions`, { method: "PATCH", body: JSON.stringify({ can_manage_nodes: canManageNodes }), }); return parseOrThrow(res, "Failed to update permissions"); }; export interface WithdrawalRequest { id: string; partner_id: string; currency: string; amount: number; status: "pending" | "approved" | "rejected" | "paid"; note: string | null; created_at: string; processed_at: string | null; processed_by: string | null; } export const fetchWithdrawals = async (): Promise => { const res = await apiFetch("/admin/staff/withdrawals"); return parseOrThrow(res, "Failed to fetch withdrawals"); }; export const updateWithdrawal = async ( id: string, status: "approved" | "rejected" | "paid", ) => { const res = await apiFetch(`/admin/staff/withdrawals/${id}`, { method: "PATCH", body: JSON.stringify({ status }), }); return parseOrThrow(res, "Failed to update withdrawal"); }; export interface PartnerEarnings { currency: string; total_earned: number; total_withdrawn: number; pending_withdrawal: number; available: number; } export interface MyEarnings { earnings: PartnerEarnings[]; withdrawals: WithdrawalRequest[]; partner_code: string | null; } export const fetchMyEarnings = async (): Promise => { const res = await apiFetch("/admin/partners/earnings"); return parseOrThrow(res, "Failed to fetch earnings"); }; export const requestWithdrawal = async ( currency: string, amount: number, note?: string, ) => { const res = await apiFetch("/admin/partners/withdrawals", { method: "POST", body: JSON.stringify({ currency, amount, note }), }); return parseOrThrow(res, "Failed to submit withdrawal request"); }; // --- Тикеты техподдержки (Owner/Moderator/Support) --- export interface SupportTicketSummary { id: string; user_id: string; tg_id: number | null; tg_username: string | null; status: "open" | "closed"; message_count: number; last_message_preview: string | null; created_at: string; updated_at: string; } export interface SupportTicket { id: string; user_id: string; status: "open" | "closed"; assigned_to: string | null; created_at: string; updated_at: string; } export interface SupportMessage { id: string; ticket_id: string; sender_role: "user" | "staff"; sender_user_id: string | null; body: string; created_at: string; } export const fetchTickets = async ( status: "open" | "closed" | "all" = "open", ): Promise => { const res = await apiFetch(`/admin/support/tickets?status=${status}`); return parseOrThrow(res, "Failed to fetch tickets"); }; export const fetchTicket = async ( id: string, ): Promise<{ ticket: SupportTicket; messages: SupportMessage[] }> => { const res = await apiFetch(`/admin/support/tickets/${id}`); return parseOrThrow(res, "Failed to fetch ticket"); }; export const replyToTicket = async (id: string, text: string) => { const res = await apiFetch(`/admin/support/tickets/${id}/reply`, { method: "POST", body: JSON.stringify({ text }), }); return parseOrThrow(res, "Failed to send reply"); }; export const closeTicket = async (id: string) => { const res = await apiFetch(`/admin/support/tickets/${id}/close`, { method: "PATCH", }); return parseOrThrow(res, "Failed to close ticket"); }; export const logout = async () => { await fetch(`${API_BASE}/auth/logout`, { method: "POST", credentials: "same-origin", }).catch(() => {}); window.location.href = "/"; };