Ролевая админка (Owner/Moderator/Partner) + оплата через @CryptoBot
Ролевая система вместо привязки к Telegram ---------------------------------------- Раньше единственный способ получить админ-доступ — admin_guard, пускавший по ADMIN_TG_ID или role=="admin" (без сеттера для роли). Заменяет на Owner/Moderator/Partner: - migrations/0005_roles_and_partners.sql — username/password_hash на users (NULL у обычных Ghost/Telegram-юзеров, тот же паттерн, что и у seed_hash/ tg_id), can_manage_nodes, commission_percent, partner_code, referred_by_partner_id; новые таблицы partner_commissions и withdrawal_requests. - auth/guard.rs — admin_guard заменён на owner_guard/staff_guard/ node_manage_guard/partner_guard; ADMIN_TG_ID убран из ApiState и main.rs. - auth/service.rs — hash_password/login_staff со случайной Argon2-солью на каждого юзера (не общий фиксированный ARGON_SALT, который годится для высокоэнтропийных seed-логинов, но не для человеческих паролей). - POST /api/v1/auth/admin/login — вход модератора/партнёра/овнера паролем, переиспользует существующий issue_session(). - --create-owner CLI-флаг (main.rs) со скрытым вводом пароля (rpassword) — вместо ручного SQL для создания первого владельца с VPS-консоли. - src/modules/staff/ — эндпоинты создания модераторов/партнёров, переключения can_manage_nodes, списка заявок на вывод и их approve/reject/paid. - Комиссия партнёра начисляется в BillingService::confirm_payment отдельным шагом после существующей рефералки, пересчитывается через get_plan_price(invoice.plan_name, invoice.currency) — НЕ через invoice.amount напрямую, потому что у провайдеров разная внутренняя единица (TON — nanoTON+2% буфер, CryptoBot — фиатные центы напрямую). - bot.rs — /start p_XXXXXX привязывает покупателя к партнёру по коду (first-wins, до существующих веток numeric-tg_id-рефералки и кода входа). - frontend/ — AdminLogin/AdminLayout/StaffPage/WithdrawalsPage/EarningsPage поверх существующего Vite-админ-фронта (без отдельного проекта). Оплата через @CryptoBot ------------------------ - modules/billing/providers/cryptobot.rs — новый PaymentProvider (Crypto Pay API), плюс попутно исправлены два реальных бага в существующем коде: confirm_payment был жёстко завязан на TON-провайдера при любом инвойсе, и InvoiceRecord не хранил currency, из-за чего TON-курс молча применялся ко всем провайдерам вместо родной валюты инвойса. - bot.rs — экран выбора способа оплаты для CryptoBot-инвойсов. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+144
-2
@@ -71,8 +71,12 @@ export async function apiFetch(
|
||||
|
||||
const refreshed = await refreshSession();
|
||||
if (!refreshed) {
|
||||
if (window.location.pathname !== "/") {
|
||||
window.location.href = "/";
|
||||
// Админка (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;
|
||||
}
|
||||
@@ -161,6 +165,144 @@ export const exchangeBootstrapToken = async (token: string): Promise<boolean> =>
|
||||
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" | "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 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");
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
await fetch(`${API_BASE}/auth/logout`, {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user