mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
feat: bulk delete subscription protection for active paid subs
Add force_delete_active_paid guard to prevent accidental deletion of active paid subscriptions. Shows warning with count and requires explicit checkbox confirmation. Also fixes allVisibleSubscriptionIds to use filteredUsers and getFilteredSubs to respect trialOnly filter on subscription sub-rows.
This commit is contained in:
@@ -32,6 +32,7 @@ export interface BulkActionParams {
|
|||||||
promo_group_id?: number | null;
|
promo_group_id?: number | null;
|
||||||
device_limit?: number;
|
device_limit?: number;
|
||||||
delete_from_panel?: boolean;
|
delete_from_panel?: boolean;
|
||||||
|
force_delete_active_paid?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BulkActionErrorItem {
|
export interface BulkActionErrorItem {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface UserListItemSubscription {
|
|||||||
tariff_id: number | null;
|
tariff_id: number | null;
|
||||||
tariff_name: string | null;
|
tariff_name: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
is_trial: boolean;
|
||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
days_remaining: number;
|
days_remaining: number;
|
||||||
traffic_used_gb: number;
|
traffic_used_gb: number;
|
||||||
|
|||||||
@@ -2050,7 +2050,9 @@
|
|||||||
},
|
},
|
||||||
"deleteSubscription": {
|
"deleteSubscription": {
|
||||||
"warning": "Selected subscriptions will be permanently deleted from the bot and RemnaWave panel!",
|
"warning": "Selected subscriptions will be permanently deleted from the bot and RemnaWave panel!",
|
||||||
"hint": "Users will lose VPN access for deleted subscriptions. This action cannot be undone."
|
"hint": "Users will lose VPN access for deleted subscriptions. This action cannot be undone.",
|
||||||
|
"activePaidWarning": "{{count}} of {{total}} selected subscriptions are active paid",
|
||||||
|
"forceDeleteConfirm": "I confirm deletion of active paid subscriptions"
|
||||||
},
|
},
|
||||||
"deleteUser": {
|
"deleteUser": {
|
||||||
"warning": "Selected users will be permanently deleted from the bot! All their subscriptions, balance, and data will be lost!",
|
"warning": "Selected users will be permanently deleted from the bot! All their subscriptions, balance, and data will be lost!",
|
||||||
|
|||||||
@@ -3812,7 +3812,9 @@
|
|||||||
},
|
},
|
||||||
"deleteSubscription": {
|
"deleteSubscription": {
|
||||||
"warning": "Выбранные подписки будут безвозвратно удалены из бота и панели RemnaWave!",
|
"warning": "Выбранные подписки будут безвозвратно удалены из бота и панели RemnaWave!",
|
||||||
"hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить."
|
"hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить.",
|
||||||
|
"activePaidWarning": "{{count}} из {{total}} выбранных подписок — активные платные",
|
||||||
|
"forceDeleteConfirm": "Подтверждаю удаление активных платных подписок"
|
||||||
},
|
},
|
||||||
"deleteUser": {
|
"deleteUser": {
|
||||||
"warning": "Выбранные пользователи будут безвозвратно удалены из бота! Все их подписки, баланс и данные будут потеряны!",
|
"warning": "Выбранные пользователи будут безвозвратно удалены из бота! Все их подписки, баланс и данные будут потеряны!",
|
||||||
|
|||||||
@@ -741,6 +741,8 @@ interface ActionModalProps {
|
|||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
tariffs: TariffListItem[];
|
tariffs: TariffListItem[];
|
||||||
promoGroups: PromoGroup[];
|
promoGroups: PromoGroup[];
|
||||||
|
users: UserListItem[];
|
||||||
|
selectedSubscriptionIds: number[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onExecute: (params: BulkActionParams) => void;
|
onExecute: (params: BulkActionParams) => void;
|
||||||
}
|
}
|
||||||
@@ -750,6 +752,8 @@ function ActionModal({
|
|||||||
selectedCount,
|
selectedCount,
|
||||||
tariffs,
|
tariffs,
|
||||||
promoGroups,
|
promoGroups,
|
||||||
|
users,
|
||||||
|
selectedSubscriptionIds,
|
||||||
onClose,
|
onClose,
|
||||||
onExecute,
|
onExecute,
|
||||||
}: ActionModalProps) {
|
}: ActionModalProps) {
|
||||||
@@ -763,6 +767,7 @@ function ActionModal({
|
|||||||
const [grantDays, setGrantDays] = useState(30);
|
const [grantDays, setGrantDays] = useState(30);
|
||||||
const [deviceLimit, setDeviceLimit] = useState(1);
|
const [deviceLimit, setDeviceLimit] = useState(1);
|
||||||
const [deleteFromPanel, setDeleteFromPanel] = useState(true);
|
const [deleteFromPanel, setDeleteFromPanel] = useState(true);
|
||||||
|
const [forceDeleteActivePaid, setForceDeleteActivePaid] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tariffs.length > 0 && tariffId === 0) {
|
if (tariffs.length > 0 && tariffId === 0) {
|
||||||
@@ -779,10 +784,15 @@ function ActionModal({
|
|||||||
}
|
}
|
||||||
}, [promoGroups, promoGroupId]);
|
}, [promoGroups, promoGroupId]);
|
||||||
|
|
||||||
// Reset deleteFromPanel to default when modal opens
|
// Reset modal-specific state when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modal.open && modal.action === 'delete_user') {
|
if (modal.open) {
|
||||||
setDeleteFromPanel(true);
|
if (modal.action === 'delete_user') {
|
||||||
|
setDeleteFromPanel(true);
|
||||||
|
}
|
||||||
|
if (modal.action === 'delete_subscription') {
|
||||||
|
setForceDeleteActivePaid(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [modal.open, modal.action]);
|
}, [modal.open, modal.action]);
|
||||||
|
|
||||||
@@ -798,6 +808,25 @@ function ActionModal({
|
|||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [modal.open, modal.loading, onClose]);
|
}, [modal.open, modal.loading, onClose]);
|
||||||
|
|
||||||
|
// Count active paid subscriptions among selected ones
|
||||||
|
const activePaidCount = useMemo(() => {
|
||||||
|
if (modal.action !== 'delete_subscription') return 0;
|
||||||
|
const selectedSubIdSet = new Set(selectedSubscriptionIds);
|
||||||
|
let count = 0;
|
||||||
|
for (const user of users) {
|
||||||
|
for (const sub of user.subscriptions ?? []) {
|
||||||
|
if (selectedSubIdSet.has(sub.id) && sub.status === 'active' && !sub.is_trial) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}, [modal.action, selectedSubscriptionIds, users]);
|
||||||
|
|
||||||
|
const isConfirmDisabled =
|
||||||
|
modal.loading ||
|
||||||
|
(modal.action === 'delete_subscription' && activePaidCount > 0 && !forceDeleteActivePaid);
|
||||||
|
|
||||||
if (!modal.open || !modal.action) return null;
|
if (!modal.open || !modal.action) return null;
|
||||||
|
|
||||||
const actionLabelKeys: Record<BulkActionType, string> = {
|
const actionLabelKeys: Record<BulkActionType, string> = {
|
||||||
@@ -840,6 +869,9 @@ function ActionModal({
|
|||||||
case 'set_devices':
|
case 'set_devices':
|
||||||
params.device_limit = deviceLimit;
|
params.device_limit = deviceLimit;
|
||||||
break;
|
break;
|
||||||
|
case 'delete_subscription':
|
||||||
|
params.force_delete_active_paid = forceDeleteActivePaid;
|
||||||
|
break;
|
||||||
case 'delete_user':
|
case 'delete_user':
|
||||||
params.delete_from_panel = deleteFromPanel;
|
params.delete_from_panel = deleteFromPanel;
|
||||||
break;
|
break;
|
||||||
@@ -982,17 +1014,56 @@ function ActionModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'delete_subscription':
|
case 'delete_subscription': {
|
||||||
|
const totalSelected = selectedSubscriptionIds.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-error-500/20 bg-error-500/5 px-3 py-2.5">
|
<div className="space-y-4">
|
||||||
<p className="text-sm font-medium text-error-400">
|
<div className="rounded-xl border border-error-500/20 bg-error-500/5 px-3 py-2.5">
|
||||||
{t('admin.bulkActions.deleteSubscription.warning')}
|
<p className="text-sm font-medium text-error-400">
|
||||||
</p>
|
{t('admin.bulkActions.deleteSubscription.warning')}
|
||||||
<p className="mt-1 text-xs text-error-300/70">
|
</p>
|
||||||
{t('admin.bulkActions.deleteSubscription.hint')}
|
<p className="mt-1 text-xs text-error-300/70">
|
||||||
</p>
|
{t('admin.bulkActions.deleteSubscription.hint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{activePaidCount > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-3 py-2.5">
|
||||||
|
<p className="text-sm font-medium text-amber-400">
|
||||||
|
{t('admin.bulkActions.deleteSubscription.activePaidWarning', {
|
||||||
|
count: activePaidCount,
|
||||||
|
total: totalSelected,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="inline-flex min-h-[44px] cursor-pointer items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setForceDeleteActivePaid((prev) => !prev)}
|
||||||
|
className={cn(
|
||||||
|
'flex h-6 w-6 items-center justify-center rounded-md border-2 transition-all duration-150',
|
||||||
|
forceDeleteActivePaid
|
||||||
|
? 'border-error-500 bg-error-500 shadow-[0_0_8px_rgba(239,68,68,0.4)]'
|
||||||
|
: 'border-dark-500 bg-dark-700/60 hover:border-error-500/50 hover:bg-dark-600/60',
|
||||||
|
)}
|
||||||
|
aria-pressed={forceDeleteActivePaid}
|
||||||
|
>
|
||||||
|
{forceDeleteActivePaid && <CheckIcon />}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm',
|
||||||
|
forceDeleteActivePaid ? 'font-medium text-error-400' : 'text-dark-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('admin.bulkActions.deleteSubscription.forceDeleteConfirm')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case 'delete_user':
|
case 'delete_user':
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -1158,7 +1229,7 @@ function ActionModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={modal.loading}
|
disabled={isConfirmDisabled}
|
||||||
className="flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-accent-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
className="flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-accent-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t('admin.bulkActions.confirm')}
|
{t('admin.bulkActions.confirm')}
|
||||||
@@ -1670,60 +1741,18 @@ export default function AdminBulkActions() {
|
|||||||
|
|
||||||
const getFilteredSubs = useCallback(
|
const getFilteredSubs = useCallback(
|
||||||
(subs: UserListItemSubscription[]): UserListItemSubscription[] => {
|
(subs: UserListItemSubscription[]): UserListItemSubscription[] => {
|
||||||
if (tariffFilter.length === 0) return subs;
|
let result = subs;
|
||||||
return subs.filter((s) => s.tariff_id !== null && tariffFilter.includes(s.tariff_id));
|
if (tariffFilter.length > 0) {
|
||||||
|
result = result.filter((s) => s.tariff_id !== null && tariffFilter.includes(s.tariff_id));
|
||||||
|
}
|
||||||
|
if (trialOnly) {
|
||||||
|
result = result.filter((s) => s.is_trial);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
[tariffFilter],
|
[tariffFilter, trialOnly],
|
||||||
);
|
);
|
||||||
|
|
||||||
const allVisibleSubscriptionIds = useMemo(() => {
|
|
||||||
const ids: number[] = [];
|
|
||||||
for (const user of users) {
|
|
||||||
const subs = user.subscriptions ?? [];
|
|
||||||
const filtered = getFilteredSubs(subs);
|
|
||||||
for (const sub of filtered) {
|
|
||||||
ids.push(sub.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}, [users, getFilteredSubs]);
|
|
||||||
|
|
||||||
const toggleAllSubscriptions = useCallback(() => {
|
|
||||||
const allSelected =
|
|
||||||
allVisibleSubscriptionIds.length > 0 &&
|
|
||||||
allVisibleSubscriptionIds.every((id) => subscriptionSelection[id]);
|
|
||||||
|
|
||||||
if (allSelected) {
|
|
||||||
// Deselect all
|
|
||||||
const next: Record<number, boolean> = {};
|
|
||||||
for (const key of Object.keys(subscriptionSelection)) {
|
|
||||||
const id = Number(key);
|
|
||||||
if (!allVisibleSubscriptionIds.includes(id)) {
|
|
||||||
next[id] = subscriptionSelection[id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSubscriptionSelection(next);
|
|
||||||
} else {
|
|
||||||
// Select all visible
|
|
||||||
const next = { ...subscriptionSelection };
|
|
||||||
for (const id of allVisibleSubscriptionIds) {
|
|
||||||
next[id] = true;
|
|
||||||
}
|
|
||||||
setSubscriptionSelection(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-expand rows that have filtered subs
|
|
||||||
const expanded: Record<number, boolean> = { ...expandedRows };
|
|
||||||
for (const user of users) {
|
|
||||||
const subs = user.subscriptions ?? [];
|
|
||||||
const filtered = getFilteredSubs(subs);
|
|
||||||
if (filtered.length > 1) {
|
|
||||||
expanded[user.id] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setExpandedRows(expanded);
|
|
||||||
}, [allVisibleSubscriptionIds, subscriptionSelection, expandedRows, users, getFilteredSubs]);
|
|
||||||
|
|
||||||
const handleOpenAction = (type: BulkActionType) => {
|
const handleOpenAction = (type: BulkActionType) => {
|
||||||
setModal({ open: true, action: type, loading: false, result: null, progress: null });
|
setModal({ open: true, action: type, loading: false, result: null, progress: null });
|
||||||
};
|
};
|
||||||
@@ -2089,6 +2118,54 @@ export default function AdminBulkActions() {
|
|||||||
return result;
|
return result;
|
||||||
}, [users, trialOnly]);
|
}, [users, trialOnly]);
|
||||||
|
|
||||||
|
const allVisibleSubscriptionIds = useMemo(() => {
|
||||||
|
const ids: number[] = [];
|
||||||
|
for (const user of filteredUsers) {
|
||||||
|
const subs = user.subscriptions ?? [];
|
||||||
|
const filtered = getFilteredSubs(subs);
|
||||||
|
for (const sub of filtered) {
|
||||||
|
ids.push(sub.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [filteredUsers, getFilteredSubs]);
|
||||||
|
|
||||||
|
const toggleAllSubscriptions = useCallback(() => {
|
||||||
|
const allSelected =
|
||||||
|
allVisibleSubscriptionIds.length > 0 &&
|
||||||
|
allVisibleSubscriptionIds.every((id) => subscriptionSelection[id]);
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
// Deselect all
|
||||||
|
const next: Record<number, boolean> = {};
|
||||||
|
for (const key of Object.keys(subscriptionSelection)) {
|
||||||
|
const id = Number(key);
|
||||||
|
if (!allVisibleSubscriptionIds.includes(id)) {
|
||||||
|
next[id] = subscriptionSelection[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSubscriptionSelection(next);
|
||||||
|
} else {
|
||||||
|
// Select all visible
|
||||||
|
const next = { ...subscriptionSelection };
|
||||||
|
for (const id of allVisibleSubscriptionIds) {
|
||||||
|
next[id] = true;
|
||||||
|
}
|
||||||
|
setSubscriptionSelection(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-expand rows that have filtered subs
|
||||||
|
const expanded: Record<number, boolean> = { ...expandedRows };
|
||||||
|
for (const user of users) {
|
||||||
|
const subs = user.subscriptions ?? [];
|
||||||
|
const filtered = getFilteredSubs(subs);
|
||||||
|
if (filtered.length > 1) {
|
||||||
|
expanded[user.id] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExpandedRows(expanded);
|
||||||
|
}, [allVisibleSubscriptionIds, subscriptionSelection, expandedRows, users, getFilteredSubs]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: filteredUsers,
|
data: filteredUsers,
|
||||||
columns,
|
columns,
|
||||||
@@ -2417,6 +2494,8 @@ export default function AdminBulkActions() {
|
|||||||
}
|
}
|
||||||
tariffs={tariffs}
|
tariffs={tariffs}
|
||||||
promoGroups={promoGroups}
|
promoGroups={promoGroups}
|
||||||
|
users={users}
|
||||||
|
selectedSubscriptionIds={selectedSubscriptionIds}
|
||||||
onClose={handleCloseModal}
|
onClose={handleCloseModal}
|
||||||
onExecute={handleExecuteAction}
|
onExecute={handleExecuteAction}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user