diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts
index 2c35665..2ff043b 100644
--- a/src/api/adminUsers.ts
+++ b/src/api/adminUsers.ts
@@ -476,4 +476,22 @@ export const adminUsersApi = {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data);
return response.data;
},
+
+ // Reset trial
+ resetTrial: async (userId: number): Promise<{ success: boolean; message: string }> => {
+ const response = await apiClient.post(`/cabinet/admin/users/${userId}/reset-trial`);
+ return response.data;
+ },
+
+ // Reset subscription
+ resetSubscription: async (userId: number): Promise<{ success: boolean; message: string }> => {
+ const response = await apiClient.post(`/cabinet/admin/users/${userId}/reset-subscription`);
+ return response.data;
+ },
+
+ // Disable user
+ disableUser: async (userId: number): Promise<{ success: boolean; message: string }> => {
+ const response = await apiClient.post(`/cabinet/admin/users/${userId}/disable`);
+ return response.data;
+ },
};
diff --git a/src/locales/en.json b/src/locales/en.json
index 0767006..bca0572 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -322,6 +322,10 @@
"title": "Choose a plan to continue",
"description": "Your trial period is ending soon. Select a plan to continue using VPN without restrictions."
},
+ "expired": {
+ "title": "Subscription expired",
+ "selectTariff": "Your subscription has expired. Choose a plan below to restore access to VPN."
+ },
"connection": {
"title": "Connect VPN",
"selectDevice": "Select your device",
@@ -1764,6 +1768,35 @@
"confirm": {
"block": "Block this user?"
},
+ "userActions": {
+ "delete": "Delete user",
+ "resetTrial": "Reset trial",
+ "resetSubscription": "Reset subscription",
+ "disable": "Disable user",
+ "error": "Failed to perform action",
+ "success": {
+ "delete": "User successfully deleted",
+ "resetTrial": "Trial successfully reset",
+ "resetSubscription": "Subscription successfully reset",
+ "disable": "User successfully disabled"
+ },
+ "confirmDelete": {
+ "title": "Delete user?",
+ "message": "Are you sure? This action is irreversible. All user data will be deleted."
+ },
+ "confirmResetTrial": {
+ "title": "Reset trial?",
+ "message": "The user will be able to use the trial period again."
+ },
+ "confirmResetSubscription": {
+ "title": "Reset subscription?",
+ "message": "The user's current subscription will be cancelled."
+ },
+ "confirmDisable": {
+ "title": "Disable user?",
+ "message": "The user will not be able to use the service until reactivated."
+ }
+ },
"detail": {
"tabs": {
"info": "Information",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 3e4b9ad..658d18c 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -344,6 +344,10 @@
"title": "Выберите тариф для продолжения",
"description": "Ваш пробный период скоро закончится. Выберите подходящий тариф, чтобы продолжить пользоваться VPN без ограничений."
},
+ "expired": {
+ "title": "Подписка истекла",
+ "selectTariff": "Ваша подписка истекла. Выберите тариф ниже, чтобы возобновить доступ к VPN."
+ },
"connection": {
"title": "Подключить VPN",
"selectDevice": "Выберите устройство",
@@ -2278,6 +2282,35 @@
"confirm": {
"block": "Заблокировать пользователя?"
},
+ "userActions": {
+ "delete": "Удалить пользователя",
+ "resetTrial": "Сбросить триал",
+ "resetSubscription": "Сбросить подписку",
+ "disable": "Отключить пользователя",
+ "error": "Не удалось выполнить действие",
+ "success": {
+ "delete": "Пользователь успешно удалён",
+ "resetTrial": "Триал успешно сброшен",
+ "resetSubscription": "Подписка успешно сброшена",
+ "disable": "Пользователь успешно отключён"
+ },
+ "confirmDelete": {
+ "title": "Удалить пользователя?",
+ "message": "Вы уверены? Это действие необратимо. Все данные пользователя будут удалены."
+ },
+ "confirmResetTrial": {
+ "title": "Сбросить триал?",
+ "message": "Пользователь сможет снова воспользоваться пробным периодом."
+ },
+ "confirmResetSubscription": {
+ "title": "Сбросить подписку?",
+ "message": "Текущая подписка пользователя будет отменена."
+ },
+ "confirmDisable": {
+ "title": "Отключить пользователя?",
+ "message": "Пользователь не сможет пользоваться сервисом до повторной активации."
+ }
+ },
"detail": {
"tabs": {
"info": "Информация",
diff --git a/src/pages/AdminUsers.tsx b/src/pages/AdminUsers.tsx
index 5a71c2d..a66f06d 100644
--- a/src/pages/AdminUsers.tsx
+++ b/src/pages/AdminUsers.tsx
@@ -1,7 +1,8 @@
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useCurrency } from '../hooks/useCurrency';
+import { useToast } from '../components/Toast';
import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers';
import { AdminBackButton } from '../components/admin';
@@ -45,6 +46,204 @@ const TelegramIcon = () => (
);
+const DotsVerticalIcon = () => (
+
+);
+
+const TrashIcon = () => (
+
+);
+
+const ArrowPathIcon = () => (
+
+);
+
+const NoSymbolIcon = () => (
+
+);
+
+const ExclamationTriangleIcon = () => (
+
+);
+
+// ============ Confirmation Modal ============
+
+interface ConfirmationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ title: string;
+ message: string;
+ confirmText: string;
+ confirmButtonClass?: string;
+ isLoading?: boolean;
+}
+
+function ConfirmationModal({
+ isOpen,
+ onClose,
+ onConfirm,
+ title,
+ message,
+ confirmText,
+ confirmButtonClass = 'bg-rose-500 hover:bg-rose-600',
+ isLoading = false,
+}: ConfirmationModalProps) {
+ const { t } = useTranslation();
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+
{message}
+
+
+
+
+
+
+ );
+}
+
+// ============ User Actions Menu ============
+
+type UserAction = 'delete' | 'resetTrial' | 'resetSubscription' | 'disable';
+
+interface UserActionsMenuProps {
+ user: UserListItem;
+ onAction: (action: UserAction, user: UserListItem) => void;
+}
+
+function UserActionsMenu({ user, onAction }: UserActionsMenuProps) {
+ const { t } = useTranslation();
+ const [isOpen, setIsOpen] = useState(false);
+ const menuRef = useRef(null);
+
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const handleAction = (action: UserAction) => {
+ setIsOpen(false);
+ onAction(action, user);
+ };
+
+ const actions = [
+ {
+ key: 'resetTrial' as const,
+ label: t('admin.users.userActions.resetTrial'),
+ icon: ,
+ className: 'text-blue-400 hover:bg-blue-500/10',
+ },
+ {
+ key: 'resetSubscription' as const,
+ label: t('admin.users.userActions.resetSubscription'),
+ icon: ,
+ className: 'text-amber-400 hover:bg-amber-500/10',
+ },
+ {
+ key: 'disable' as const,
+ label: t('admin.users.userActions.disable'),
+ icon: ,
+ className: 'text-dark-400 hover:bg-dark-700',
+ },
+ {
+ key: 'delete' as const,
+ label: t('admin.users.userActions.delete'),
+ icon: ,
+ className: 'text-rose-400 hover:bg-rose-500/10',
+ },
+ ];
+
+ return (
+
+
+
+ {isOpen && (
+
+ {actions.map((action) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
// ============ Components ============
interface StatCardProps {
@@ -94,10 +293,11 @@ function StatusBadge({ status }: { status: string }) {
interface UserRowProps {
user: UserListItem;
onClick: () => void;
+ onAction: (action: UserAction, user: UserListItem) => void;
formatAmount: (rubAmount: number) => string;
}
-function UserRow({ user, onClick, formatAmount }: UserRowProps) {
+function UserRow({ user, onClick, onAction, formatAmount }: UserRowProps) {
const { t } = useTranslation();
return (
+ {/* Actions Menu */}
+
+
);
@@ -158,10 +361,17 @@ function UserRow({ user, onClick, formatAmount }: UserRowProps) {
// ============ Main Page ============
+interface ConfirmModalState {
+ isOpen: boolean;
+ action: UserAction | null;
+ user: UserListItem | null;
+}
+
export default function AdminUsers() {
const { t } = useTranslation();
const { formatWithCurrency } = useCurrency();
const navigate = useNavigate();
+ const { showToast } = useToast();
const [users, setUsers] = useState([]);
const [stats, setStats] = useState(null);
@@ -172,6 +382,12 @@ export default function AdminUsers() {
const [sortBy, setSortBy] = useState('created_at');
const [offset, setOffset] = useState(0);
const [total, setTotal] = useState(0);
+ const [confirmModal, setConfirmModal] = useState({
+ isOpen: false,
+ action: null,
+ user: null,
+ });
+ const [actionLoading, setActionLoading] = useState(false);
const limit = 20;
@@ -218,6 +434,104 @@ export default function AdminUsers() {
loadUsers();
};
+ const handleUserAction = (action: UserAction, user: UserListItem) => {
+ setConfirmModal({ isOpen: true, action, user });
+ };
+
+ const closeConfirmModal = () => {
+ setConfirmModal({ isOpen: false, action: null, user: null });
+ };
+
+ const executeAction = async () => {
+ const { action, user } = confirmModal;
+ if (!action || !user) return;
+
+ setActionLoading(true);
+ try {
+ let result: { success: boolean; message: string };
+
+ switch (action) {
+ case 'delete':
+ result = await adminUsersApi.deleteUser(user.id);
+ break;
+ case 'resetTrial':
+ result = await adminUsersApi.resetTrial(user.id);
+ break;
+ case 'resetSubscription':
+ result = await adminUsersApi.resetSubscription(user.id);
+ break;
+ case 'disable':
+ result = await adminUsersApi.disableUser(user.id);
+ break;
+ default:
+ throw new Error('Unknown action');
+ }
+
+ if (result.success) {
+ showToast({
+ type: 'success',
+ title: t('common.success'),
+ message: t(`admin.users.userActions.success.${action}`),
+ });
+ loadUsers();
+ loadStats();
+ } else {
+ showToast({
+ type: 'error',
+ title: t('common.error'),
+ message: result.message || t('admin.users.userActions.error'),
+ });
+ }
+ } catch (error) {
+ console.error('Action failed:', error);
+ showToast({
+ type: 'error',
+ title: t('common.error'),
+ message: t('admin.users.userActions.error'),
+ });
+ } finally {
+ setActionLoading(false);
+ closeConfirmModal();
+ }
+ };
+
+ const getConfirmModalContent = () => {
+ const { action } = confirmModal;
+ if (!action) return { title: '', message: '', confirmText: '', confirmButtonClass: '' };
+
+ const configs: Record<
+ UserAction,
+ { title: string; message: string; confirmText: string; confirmButtonClass: string }
+ > = {
+ delete: {
+ title: t('admin.users.userActions.confirmDelete.title'),
+ message: t('admin.users.userActions.confirmDelete.message'),
+ confirmText: t('admin.users.userActions.delete'),
+ confirmButtonClass: 'bg-rose-500 hover:bg-rose-600',
+ },
+ resetTrial: {
+ title: t('admin.users.userActions.confirmResetTrial.title'),
+ message: t('admin.users.userActions.confirmResetTrial.message'),
+ confirmText: t('admin.users.userActions.resetTrial'),
+ confirmButtonClass: 'bg-blue-500 hover:bg-blue-600',
+ },
+ resetSubscription: {
+ title: t('admin.users.userActions.confirmResetSubscription.title'),
+ message: t('admin.users.userActions.confirmResetSubscription.message'),
+ confirmText: t('admin.users.userActions.resetSubscription'),
+ confirmButtonClass: 'bg-amber-500 hover:bg-amber-600',
+ },
+ disable: {
+ title: t('admin.users.userActions.confirmDisable.title'),
+ message: t('admin.users.userActions.confirmDisable.message'),
+ confirmText: t('admin.users.userActions.disable'),
+ confirmButtonClass: 'bg-dark-600 hover:bg-dark-500',
+ },
+ };
+
+ return configs[action];
+ };
+
const totalPages = Math.ceil(total / limit);
const currentPage = Math.floor(offset / limit) + 1;
@@ -348,6 +662,7 @@ export default function AdminUsers() {
key={user.id}
user={user}
onClick={() => navigate(`/admin/users/${user.id}`)}
+ onAction={handleUserAction}
formatAmount={(amount) => formatWithCurrency(amount)}
/>
))
@@ -385,6 +700,15 @@ export default function AdminUsers() {
)}
+
+ {/* User action confirmation modal */}
+
);
}
diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx
index 0baf7c9..bc0bcb9 100644
--- a/src/pages/Subscription.tsx
+++ b/src/pages/Subscription.tsx
@@ -312,6 +312,28 @@ export default function Subscription() {
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
setSwitchTariffId(null);
},
+ onError: (error: unknown) => {
+ // Handle subscription_expired error - redirect to purchase flow
+ if (error instanceof AxiosError) {
+ const detail = error.response?.data?.detail;
+ if (
+ typeof detail === 'object' &&
+ detail?.error_code === 'subscription_expired' &&
+ detail?.use_purchase_flow === true
+ ) {
+ // Find the tariff user was trying to switch to and open purchase form
+ const targetTariff = tariffs.find((t) => t.id === switchTariffId);
+ if (targetTariff) {
+ setSwitchTariffId(null);
+ setSelectedTariff(targetTariff);
+ setSelectedTariffPeriod(targetTariff.periods[0] || null);
+ setShowTariffPurchase(true);
+ // Refetch purchase-options to get updated expired status
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
+ }
+ }
+ }
+ },
});
// Tariff purchase mutation
@@ -1956,6 +1978,40 @@ export default function Subscription() {
)}
+ {/* Expired subscription notice - prompt to purchase new tariff */}
+ {isTariffsMode &&
+ purchaseOptions &&
+ 'subscription_is_expired' in purchaseOptions &&
+ purchaseOptions.subscription_is_expired && (
+
+
+
+
+
+ {t('subscription.expired.title')}
+
+
+ {t('subscription.expired.selectTariff')}
+
+
+
+
+ )}
+
{/* Legacy subscription notice - if user has subscription without tariff */}
{subscription && !subscription.is_trial && !subscription.tariff_id && (
@@ -2067,6 +2123,27 @@ export default function Subscription() {
t('subscription.switchTariff.switch')
)}
+
+ {/* Show error (except subscription_expired which redirects to purchase) */}
+ {switchTariffMutation.isError &&
+ (() => {
+ const detail =
+ switchTariffMutation.error instanceof AxiosError
+ ? switchTariffMutation.error.response?.data?.detail
+ : null;
+ // Skip displaying if it's subscription_expired (handled by redirect)
+ if (
+ typeof detail === 'object' &&
+ detail?.error_code === 'subscription_expired'
+ ) {
+ return null;
+ }
+ return (
+
+ {getErrorMessage(switchTariffMutation.error)}
+
+ );
+ })()}
>
);
})()
@@ -2126,11 +2203,20 @@ export default function Subscription() {
.map((tariff) => {
const isCurrentTariff =
tariff.is_current || tariff.id === subscription?.tariff_id;
+ // Check if subscription is expired from purchaseOptions
+ const isSubscriptionExpired =
+ isTariffsMode &&
+ purchaseOptions &&
+ 'subscription_is_expired' in purchaseOptions &&
+ purchaseOptions.subscription_is_expired === true;
+ // canSwitch only if subscription is active (not expired, not trial)
const canSwitch =
subscription &&
subscription.tariff_id &&
!isCurrentTariff &&
- !subscription.is_trial;
+ !subscription.is_trial &&
+ !isSubscriptionExpired &&
+ subscription.is_active;
// Если есть подписка БЕЗ tariff_id (классическая) - разрешить выбрать тариф
const isLegacySubscription =
subscription && !subscription.is_trial && !subscription.tariff_id;
diff --git a/src/types/index.ts b/src/types/index.ts
index 0259362..0d9e1ff 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -300,6 +300,10 @@ export interface TariffsPurchaseOptions {
current_tariff_id: number | null;
balance_kopeks: number;
balance_label: string;
+ // New fields for expired subscription handling
+ subscription_status?: string;
+ subscription_is_expired?: boolean;
+ has_subscription?: boolean;
}
export interface ClassicPurchaseOptions {