diff --git a/handlers/admin/users/keyboard.py b/handlers/admin/users/keyboard.py
index 207a6e1e..31c002ec 100644
--- a/handlers/admin/users/keyboard.py
+++ b/handlers/admin/users/keyboard.py
@@ -260,6 +260,10 @@ def build_key_edit_kb(key_details: dict, email: str, is_configurable: bool = Fal
text="🔄 Перевыпустить",
callback_data=AdminUserEditorCallback(action="users_update_key", data=email, tg_id=key_details["tg_id"]).pack(),
)
+ builder.button(
+ text="🔁 Пересоздать",
+ callback_data=AdminUserEditorCallback(action="users_recreate_key", data=email, tg_id=key_details["tg_id"]).pack(),
+ )
builder.button(
text="📦 Тариф",
callback_data=AdminUserEditorCallback(action="users_renew", data=email, tg_id=key_details["tg_id"]).pack(),
diff --git a/handlers/admin/users/users_keys.py b/handlers/admin/users/users_keys.py
index ad849315..d8b4e480 100644
--- a/handlers/admin/users/users_keys.py
+++ b/handlers/admin/users/users_keys.py
@@ -640,6 +640,195 @@ async def admin_reissue_country(callback_query: CallbackQuery, session: AsyncSes
await callback_query.message.answer(f"❗ Ошибка: {e}")
+@router.callback_query(
+ AdminUserEditorCallback.filter(F.action == "users_recreate_key"),
+ IsAdminFilter(),
+)
+async def handle_recreate_key_start(
+ callback_query: CallbackQuery,
+ callback_data: AdminUserEditorCallback,
+ session: AsyncSession,
+):
+ tg_id = callback_data.tg_id
+ email = callback_data.data
+
+ result = await session.execute(select(Key).where(Key.email == email))
+ key_obj: Key | None = result.scalar_one_or_none()
+
+ if not key_obj:
+ await callback_query.message.edit_text(
+ text="🚫 Ключ не найден.",
+ reply_markup=build_editor_kb(tg_id),
+ )
+ return
+
+ tariff_name = "—"
+ if key_obj.tariff_id:
+ tariff = await get_tariff_by_id(session, key_obj.tariff_id)
+ if tariff:
+ tariff_name = tariff.get("name", "—")
+
+ text = (
+ "🔁 Пересоздание ссылки подписки\n\n"
+ f"📦 Тариф: {tariff_name}\n\n"
+ "⚠️ Будет сгенерирована новая ссылка подписки.\n"
+ "Старая ссылка перестанет работать.\n\n"
+ "✅ Все данные подписки сохранятся."
+ )
+
+ builder = InlineKeyboardBuilder()
+ builder.row(
+ InlineKeyboardButton(
+ text="✅ Пересоздать",
+ callback_data=f"confirm_recreate|{tg_id}|{email}",
+ )
+ )
+ builder.row(
+ InlineKeyboardButton(
+ text="🔙 Назад",
+ callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
+ )
+ )
+
+ await callback_query.message.edit_text(text=text, reply_markup=builder.as_markup())
+
+
+@router.callback_query(F.data.startswith("confirm_recreate|"), IsAdminFilter())
+async def handle_recreate_key_confirm(
+ callback_query: CallbackQuery,
+ session: AsyncSession,
+):
+ _, tg_id, old_email = callback_query.data.split("|")
+ tg_id = int(tg_id)
+
+ try:
+ result = await session.execute(select(Key).where(Key.email == old_email))
+ key_obj: Key | None = result.scalar_one_or_none()
+
+ if not key_obj:
+ await callback_query.message.edit_text(
+ text="🚫 Ключ не найден.",
+ reply_markup=build_editor_kb(tg_id),
+ )
+ return
+
+ await callback_query.message.edit_text("⏳ Пересоздание ссылки подписки...")
+
+ client_id = key_obj.client_id
+ cluster_id = key_obj.server_id
+ old_link = key_obj.remnawave_link or key_obj.key
+
+ servers = await get_servers(session)
+ cluster = servers.get(cluster_id)
+
+ if not cluster:
+ for _, server_list in servers.items():
+ for server_info in server_list:
+ if server_info.get("server_name", "").lower() == cluster_id.lower():
+ cluster = [server_info]
+ break
+ if cluster:
+ break
+
+ if not cluster:
+ await callback_query.message.edit_text(
+ text=f"❗ Кластер {cluster_id} не найден.",
+ reply_markup=build_editor_kb(tg_id),
+ )
+ return
+
+ remnawave_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "remnawave"]
+
+ if not remnawave_servers:
+ await callback_query.message.edit_text(
+ text="❗ Revoke доступен только для Remnawave. Для 3x-ui используйте перевыпуск.",
+ reply_markup=build_editor_kb(tg_id),
+ )
+ return
+
+ api_url = remnawave_servers[0].get("api_url")
+
+ from panels.remnawave_ext import revoke_user_subscription
+
+ user_data = await revoke_user_subscription(api_url, client_id)
+
+ if not user_data:
+ await callback_query.message.edit_text(
+ text="❗ Не удалось выполнить revoke. Проверьте логи.",
+ reply_markup=build_editor_kb(tg_id),
+ )
+ return
+
+ new_link = user_data.get("subscriptionUrl")
+
+ if not new_link:
+ await callback_query.message.edit_text(
+ text="❗ Revoke выполнен, но новая ссылка не получена.",
+ reply_markup=build_editor_kb(tg_id),
+ )
+ return
+
+ await session.execute(
+ update(Key)
+ .where(Key.email == old_email)
+ .values(
+ key=new_link,
+ remnawave_link=new_link,
+ )
+ )
+ await session.commit()
+
+ try:
+ user_text = (
+ "🔄 Ваша подписка была перевыпущена\n\n"
+ f"🔗 Новая ссылка подписки:\n{new_link}\n\n"
+ "Старая ссылка больше не работает."
+ )
+ user_kb = InlineKeyboardBuilder()
+ user_kb.row(InlineKeyboardButton(text="📱 Мои подписки", callback_data="view_keys"))
+ user_kb.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
+
+ await callback_query.bot.send_message(
+ chat_id=tg_id,
+ text=user_text,
+ reply_markup=user_kb.as_markup(),
+ )
+ notification_sent = True
+ except Exception as e:
+ logger.warning(f"Не удалось отправить уведомление клиенту {tg_id}: {e}")
+ notification_sent = False
+
+ text = (
+ "✅ Ссылка подписки пересоздана\n\n"
+ f"🔗 Старая ссылка:\n{old_link}\n\n"
+ f"🔗 Новая ссылка:\n{new_link}\n\n"
+ )
+ if notification_sent:
+ text += "📨 Клиент уведомлён о новой ссылке."
+ else:
+ text += "⚠️ Не удалось уведомить клиента."
+
+ builder = InlineKeyboardBuilder()
+ builder.row(
+ InlineKeyboardButton(
+ text="🔙 Назад",
+ callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=old_email).pack(),
+ )
+ )
+
+ await callback_query.message.edit_text(
+ text=text,
+ reply_markup=builder.as_markup(),
+ )
+
+ except Exception as e:
+ logger.error(f"Ошибка при revoke ключа {old_email}: {e}")
+ await callback_query.message.edit_text(
+ text=f"❗ Ошибка при пересоздании: {e}",
+ reply_markup=build_editor_kb(tg_id),
+ )
+
+
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_delete_key"),
IsAdminFilter(),
diff --git a/panels/remnawave_ext.py b/panels/remnawave_ext.py
new file mode 100644
index 00000000..852a9d96
--- /dev/null
+++ b/panels/remnawave_ext.py
@@ -0,0 +1,68 @@
+import aiohttp
+
+from config import (
+ REMNAWAVE_ACCESS_TOKEN,
+ REMNAWAVE_LOGIN,
+ REMNAWAVE_PASSWORD,
+ REMNAWAVE_TOKEN_LOGIN_ENABLED,
+)
+from logger import logger
+
+PANEL_REMNA = "[Remnawave]"
+
+
+async def revoke_user_subscription(
+ api_url: str,
+ user_uuid: str,
+ short_uuid: str | None = None,
+) -> dict | None:
+ base_url = api_url.rstrip("/")
+
+ async with aiohttp.ClientSession() as session:
+ if REMNAWAVE_TOKEN_LOGIN_ENABLED and REMNAWAVE_ACCESS_TOKEN:
+ token = REMNAWAVE_ACCESS_TOKEN
+ else:
+ login_url = f"{base_url}/auth/login"
+ login_data = {"username": REMNAWAVE_LOGIN, "password": REMNAWAVE_PASSWORD}
+
+ try:
+ async with session.post(login_url, json=login_data) as resp:
+ if resp.status != 200 and resp.status != 201:
+ logger.error(f"{PANEL_REMNA} Авторизация не удалась: {resp.status}")
+ return None
+
+ auth_response = await resp.json()
+ token = auth_response.get("response", {}).get("accessToken")
+ if not token:
+ logger.error(f"{PANEL_REMNA} Не получен accessToken")
+ return None
+ except Exception as e:
+ logger.error(f"{PANEL_REMNA} Ошибка авторизации: {e}")
+ return None
+
+ revoke_url = f"{base_url}/users/{user_uuid}/actions/revoke"
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ }
+
+ revoke_data = {}
+ if short_uuid:
+ revoke_data["shortUuid"] = short_uuid
+
+ try:
+ async with session.post(revoke_url, json=revoke_data, headers=headers) as resp:
+ if resp.status != 200 and resp.status != 201:
+ error_text = await resp.text()
+ logger.error(f"{PANEL_REMNA} Revoke не удался: {resp.status} - {error_text}")
+ return None
+
+ result = await resp.json()
+ user_data = result.get("response", {})
+
+ logger.info(f"{PANEL_REMNA} Подписка {user_uuid} успешно отозвана")
+ return user_data
+
+ except Exception as e:
+ logger.error(f"{PANEL_REMNA} Ошибка revoke: {e}")
+ return None