583682f330
TicketsPage.tsx — список open/closed тикетов + тред сообщений + ответ + закрытие (owner/moderator/support). ObservabilityPage.tsx — вкладки дашбордов Grafana через iframe за auth-гейтом Caddy, URL берётся из GET /api/v1/config (grafana_url), страница просто не показывает дашборды, пока GRAFANA_PUBLIC_URL не задан на сервере. StaffPage.tsx — форма создания support-аккаунта (только Owner). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
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<PublicConfig> | 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<PublicConfig> {
|
|
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<boolean> | null = null;
|
|
|
|
/// Один раз пробует тихо обновить сессию через `/auth/refresh` (ротация
|
|
/// refresh-cookie); параллельные вызовы схлопываются в один запрос.
|
|
function refreshSession(): Promise<boolean> {
|
|
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<string, string> = { ...(options.headers as Record<string, string>) };
|
|
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<Response> {
|
|
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<HackSessionStart> => {
|
|
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<boolean> => {
|
|
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<StaffRole> => {
|
|
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<StaffUser[]> => {
|
|
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<WithdrawalRequest[]> => {
|
|
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<MyEarnings> => {
|
|
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<SupportTicketSummary[]> => {
|
|
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 = "/";
|
|
};
|