diff --git a/src/api/subscription.ts b/src/api/subscription.ts
index 2af14bf..832954d 100644
--- a/src/api/subscription.ts
+++ b/src/api/subscription.ts
@@ -540,6 +540,17 @@ export const subscriptionApi = {
return response.data;
},
+ // ── Revoke (reissue) ────────────────────────────────────────────────
+
+ revokeSubscription: async (subscriptionId?: number) => {
+ const response = await apiClient.post(
+ '/cabinet/subscription/revoke',
+ undefined,
+ withSubId(subscriptionId),
+ );
+ return response.data;
+ },
+
// ── Daily subscription ──────────────────────────────────────────────
togglePause: async (
diff --git a/src/locales/en.json b/src/locales/en.json
index 37af017..09e9cf5 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -571,6 +571,14 @@
"selectServersHint": "Select servers to connect or disconnect",
"noServersAvailable": "No servers available to manage"
},
+ "revoke": {
+ "title": "Reissue Subscription",
+ "button": "Reissue Subscription",
+ "description": "New link, device reset",
+ "warning": "This will generate a new connection link, reset all connected devices, and invalidate the old link. Continue?",
+ "confirmBtn": "Reissue",
+ "cooldown": "Available in {{minutes}} min {{seconds}} sec"
+ },
"legacy": {
"selectTariffTitle": "Select a tariff to renew",
"selectTariffDescription": "Your current subscription was created before tariffs were introduced. To renew, please select one of the available tariffs.",
diff --git a/src/locales/fa.json b/src/locales/fa.json
index bba6774..0b50443 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -402,6 +402,14 @@
"selectServersHint": "سرورها را برای اتصال یا قطع انتخاب کنید",
"noServersAvailable": "سرور قابل مدیریتی وجود ندارد"
},
+ "revoke": {
+ "title": "صدور مجدد اشتراک",
+ "button": "صدور مجدد اشتراک",
+ "description": "لینک جدید، بازنشانی دستگاهها",
+ "warning": "این عمل لینک اتصال جدیدی تولید میکند، همه دستگاههای متصل را بازنشانی میکند و لینک قدیمی را نامعتبر میکند. ادامه؟",
+ "confirmBtn": "صدور مجدد",
+ "cooldown": "در {{minutes}} دقیقه {{seconds}} ثانیه دیگر"
+ },
"legacy": {
"selectTariffTitle": "یک تعرفه برای تمدید انتخاب کنید",
"selectTariffDescription": "اشتراک فعلی شما قبل از معرفی تعرفهها ایجاد شده بود. برای تمدید، لطفاً یکی از تعرفههای موجود را انتخاب کنید.",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 61e906f..f108714 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -599,6 +599,14 @@
"selectServersHint": "Выберите серверы для подключения или отключения",
"noServersAvailable": "Нет доступных серверов для управления"
},
+ "revoke": {
+ "title": "Перевыпуск подписки",
+ "button": "Перевыпустить подписку",
+ "description": "Новая ссылка, сброс устройств",
+ "warning": "Это действие сгенерирует новую ссылку подключения, сбросит все подключённые устройства и сделает старую ссылку недействительной. Продолжить?",
+ "confirmBtn": "Перевыпустить",
+ "cooldown": "Доступно через {{minutes}} мин. {{seconds}} сек."
+ },
"legacy": {
"selectTariffTitle": "Выберите тариф для продления",
"selectTariffDescription": "Ваша текущая подписка была создана до введения тарифов. Для продления необходимо выбрать один из доступных тарифов.",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 83fd5ab..852dda9 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -402,6 +402,14 @@
"selectServersHint": "选择要连接或断开的服务器",
"noServersAvailable": "没有可管理的服务器"
},
+ "revoke": {
+ "title": "重新签发订阅",
+ "button": "重新签发订阅",
+ "description": "新链接,设备重置",
+ "warning": "此操作将生成新的连接链接,重置所有已连接设备,并使旧链接失效。继续?",
+ "confirmBtn": "重新签发",
+ "cooldown": "{{minutes}}分{{seconds}}秒后可用"
+ },
"legacy": {
"selectTariffTitle": "选择续费套餐",
"selectTariffDescription": "您的当前订阅是在引入套餐之前创建的。要续费,请选择一个可用的套餐。",
diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx
index 4b42ae6..2c4c22a 100644
--- a/src/pages/Subscription.tsx
+++ b/src/pages/Subscription.tsx
@@ -202,6 +202,9 @@ export default function Subscription() {
// Traffic refresh state
const [trafficRefreshCooldown, setTrafficRefreshCooldown] = useState(0);
+
+ // Revoke (reissue) cooldown state
+ const [revokeCooldown, setRevokeCooldown] = useState(0);
const [trafficData, setTrafficData] = useState<{
traffic_used_gb: number;
traffic_used_percent: number;
@@ -464,6 +467,41 @@ export default function Subscription() {
return () => clearInterval(timer);
}, [trafficRefreshCooldown]);
+ // Initialize revoke cooldown from localStorage on mount
+ useEffect(() => {
+ const ts = localStorage.getItem(`revoke_ts_${subscriptionId ?? 'default'}`);
+ if (ts) {
+ const elapsed = Math.floor((Date.now() - parseInt(ts, 10)) / 1000);
+ const remaining = Math.max(0, 900 - elapsed);
+ setRevokeCooldown(remaining);
+ }
+ }, [subscriptionId]);
+
+ // Countdown timer for revoke cooldown
+ useEffect(() => {
+ if (revokeCooldown <= 0) return;
+ const timer = setInterval(() => {
+ setRevokeCooldown((prev) => Math.max(0, prev - 1));
+ }, 1000);
+ return () => clearInterval(timer);
+ }, [revokeCooldown]);
+
+ // Revoke (reissue) subscription mutation
+ const revokeMutation = useMutation({
+ mutationFn: () => subscriptionApi.revokeSubscription(subscriptionId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['subscription'] });
+ queryClient.invalidateQueries({ queryKey: ['connection-link', subscriptionId] });
+ queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
+ haptic.notification('success');
+ localStorage.setItem(`revoke_ts_${subscriptionId ?? 'default'}`, Date.now().toString());
+ setRevokeCooldown(900);
+ },
+ onError: () => {
+ haptic.notification('error');
+ },
+ });
+
// Auto-refresh traffic on mount (with 30s caching)
useEffect(() => {
if (!subscription) return;
@@ -494,6 +532,16 @@ export default function Subscription() {
}
};
+ const handleRevoke = async () => {
+ const confirmed = await destructiveConfirm(
+ t('subscription.revoke.warning'),
+ t('subscription.revoke.confirmBtn'),
+ t('subscription.revoke.title'),
+ );
+ if (!confirmed) return;
+ revokeMutation.mutate();
+ };
+
// In multi-tariff mode without a specific subscription ID, redirect to list
if (isMultiTariff && !subscriptionId && !isLoading) {
return
{getErrorMessage(revokeMutation.error)}
+ )} +