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 ( +
+
+
+
+
+ +
+

{title}

+
+

{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 {