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:
Fringg
2026-04-29 11:48:59 +03:00
parent ae55a18fc9
commit afffab17d3
5 changed files with 150 additions and 65 deletions

View File

@@ -32,6 +32,7 @@ export interface BulkActionParams {
promo_group_id?: number | null;
device_limit?: number;
delete_from_panel?: boolean;
force_delete_active_paid?: boolean;
}
export interface BulkActionErrorItem {

View File

@@ -38,6 +38,7 @@ export interface UserListItemSubscription {
tariff_id: number | null;
tariff_name: string | null;
status: string;
is_trial: boolean;
end_date: string | null;
days_remaining: number;
traffic_used_gb: number;

View File

@@ -2050,7 +2050,9 @@
},
"deleteSubscription": {
"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": {
"warning": "Selected users will be permanently deleted from the bot! All their subscriptions, balance, and data will be lost!",

View File

@@ -3812,7 +3812,9 @@
},
"deleteSubscription": {
"warning": "Выбранные подписки будут безвозвратно удалены из бота и панели RemnaWave!",
"hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить."
"hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить.",
"activePaidWarning": "{{count}} из {{total}} выбранных подписок — активные платные",
"forceDeleteConfirm": "Подтверждаю удаление активных платных подписок"
},
"deleteUser": {
"warning": "Выбранные пользователи будут безвозвратно удалены из бота! Все их подписки, баланс и данные будут потеряны!",

View File

@@ -741,6 +741,8 @@ interface ActionModalProps {
selectedCount: number;
tariffs: TariffListItem[];
promoGroups: PromoGroup[];
users: UserListItem[];
selectedSubscriptionIds: number[];
onClose: () => void;
onExecute: (params: BulkActionParams) => void;
}
@@ -750,6 +752,8 @@ function ActionModal({
selectedCount,
tariffs,
promoGroups,
users,
selectedSubscriptionIds,
onClose,
onExecute,
}: ActionModalProps) {
@@ -763,6 +767,7 @@ function ActionModal({
const [grantDays, setGrantDays] = useState(30);
const [deviceLimit, setDeviceLimit] = useState(1);
const [deleteFromPanel, setDeleteFromPanel] = useState(true);
const [forceDeleteActivePaid, setForceDeleteActivePaid] = useState(false);
useEffect(() => {
if (tariffs.length > 0 && tariffId === 0) {
@@ -779,10 +784,15 @@ function ActionModal({
}
}, [promoGroups, promoGroupId]);
// Reset deleteFromPanel to default when modal opens
// Reset modal-specific state when modal opens
useEffect(() => {
if (modal.open && modal.action === 'delete_user') {
setDeleteFromPanel(true);
if (modal.open) {
if (modal.action === 'delete_user') {
setDeleteFromPanel(true);
}
if (modal.action === 'delete_subscription') {
setForceDeleteActivePaid(false);
}
}
}, [modal.open, modal.action]);
@@ -798,6 +808,25 @@ function ActionModal({
return () => document.removeEventListener('keydown', handleKeyDown);
}, [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;
const actionLabelKeys: Record<BulkActionType, string> = {
@@ -840,6 +869,9 @@ function ActionModal({
case 'set_devices':
params.device_limit = deviceLimit;
break;
case 'delete_subscription':
params.force_delete_active_paid = forceDeleteActivePaid;
break;
case 'delete_user':
params.delete_from_panel = deleteFromPanel;
break;
@@ -982,17 +1014,56 @@ function ActionModal({
</div>
</div>
);
case 'delete_subscription':
case 'delete_subscription': {
const totalSelected = selectedSubscriptionIds.length;
return (
<div className="rounded-xl border border-error-500/20 bg-error-500/5 px-3 py-2.5">
<p className="text-sm font-medium text-error-400">
{t('admin.bulkActions.deleteSubscription.warning')}
</p>
<p className="mt-1 text-xs text-error-300/70">
{t('admin.bulkActions.deleteSubscription.hint')}
</p>
<div className="space-y-4">
<div className="rounded-xl border border-error-500/20 bg-error-500/5 px-3 py-2.5">
<p className="text-sm font-medium text-error-400">
{t('admin.bulkActions.deleteSubscription.warning')}
</p>
<p className="mt-1 text-xs text-error-300/70">
{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>
);
}
case 'delete_user':
return (
<div className="space-y-4">
@@ -1158,7 +1229,7 @@ function ActionModal({
</button>
<button
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"
>
{t('admin.bulkActions.confirm')}
@@ -1670,60 +1741,18 @@ export default function AdminBulkActions() {
const getFilteredSubs = useCallback(
(subs: UserListItemSubscription[]): UserListItemSubscription[] => {
if (tariffFilter.length === 0) return subs;
return subs.filter((s) => s.tariff_id !== null && tariffFilter.includes(s.tariff_id));
let result = subs;
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) => {
setModal({ open: true, action: type, loading: false, result: null, progress: null });
};
@@ -2089,6 +2118,54 @@ export default function AdminBulkActions() {
return result;
}, [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({
data: filteredUsers,
columns,
@@ -2417,6 +2494,8 @@ export default function AdminBulkActions() {
}
tariffs={tariffs}
promoGroups={promoGroups}
users={users}
selectedSubscriptionIds={selectedSubscriptionIds}
onClose={handleCloseModal}
onExecute={handleExecuteAction}
/>