Add factory reset functionality and update identity service
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s

- Introduced a new DELETE endpoint `/api/admin/maintenance/factory-reset` for a complete reset of the admin panel, removing all users except the current admin and clearing various data.
- Implemented the `FactoryReset` method in `AdminMaintenanceEndpoints` to handle the reset logic.
- Added a new method `ListAllUserIdsExceptAsync` in `IIdentityService` to retrieve user IDs excluding a specified user, aiding in the factory reset process.
- Updated the frontend to include a confirmation dialog for the factory reset action, enhancing user experience and safety.
- Enhanced localization support for the new factory reset feature in both Russian and English, ensuring clarity for all users.
This commit is contained in:
Leonid Pershin
2026-07-14 18:58:51 +03:00
parent 94ba514b8e
commit 701c3a1d51
16 changed files with 564 additions and 64 deletions
@@ -0,0 +1,80 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
import { factoryReset } from './api'
const CONFIRM_PHRASE_KEY = 'admin.maintenance.factoryReset.confirmPhrase'
export function FactoryResetDialog() {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [confirmText, setConfirmText] = useState('')
const confirmPhrase = t(CONFIRM_PHRASE_KEY)
const mutation = useMutation({
mutationFn: factoryReset,
onSuccess: () => {
toast.success(t('admin.maintenance.factoryReset.done'))
// Почти всё состояние приложения (пользователи/роли/ноды/кэши списков) больше не валидно —
// проще перезагрузить всю панель, чем точечно инвалидировать десяток query-ключей.
window.location.href = '/admin'
},
onError: () => toast.error(t('auth.genericError')),
})
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setConfirmText('')
}}
>
<DialogTrigger asChild>
<Button variant="destructive">{t('admin.maintenance.factoryReset.action')}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-red-400">{t('admin.maintenance.factoryReset.dialogTitle')}</DialogTitle>
<DialogDescription asChild>
<div className="flex flex-col gap-2 text-sm">
<p>{t('admin.maintenance.factoryReset.warningIntro')}</p>
<ul className="list-disc pl-5">
{(t('admin.maintenance.factoryReset.warningItems', { returnObjects: true }) as string[]).map(
(item) => (
<li key={item}>{item}</li>
),
)}
</ul>
<p className="font-semibold text-red-400">{t('admin.maintenance.factoryReset.irreversible')}</p>
</div>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<label className="text-sm text-muted-foreground">
{t('admin.maintenance.factoryReset.confirmInputLabel', { phrase: confirmPhrase })}
</label>
<Input value={confirmText} onChange={(e) => setConfirmText(e.target.value)} autoComplete="off" />
</div>
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setOpen(false)}>
{t('settings.cancel')}
</Button>
<Button
variant="destructive"
disabled={confirmText !== confirmPhrase || mutation.isPending}
onClick={() => mutation.mutate()}
>
{t('admin.maintenance.factoryReset.confirmButton')}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
@@ -14,3 +14,7 @@ export function deleteOldAuditLogs(olderThanDays: number) {
export function deleteDisabledApps() {
return apiRequest<MaintenanceCleanupResponseDto>('/admin/maintenance/apps/disabled', { method: 'DELETE' })
}
export function factoryReset() {
return apiRequest<void>('/admin/maintenance/factory-reset', { method: 'DELETE' })
}
+20
View File
@@ -7,6 +7,7 @@ import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { deleteClosedTickets, deleteDisabledApps, deleteOldAuditLogs } from '@/features/admin/maintenance/api'
import { FactoryResetDialog } from '@/features/admin/maintenance/FactoryResetDialog'
export const Route = createFileRoute('/admin/maintenance')({ component: AdminMaintenancePage })
@@ -118,6 +119,25 @@ function AdminMaintenancePage() {
</div>
</CardContent>
</Card>
<details className="group rounded-md border border-red-900/50">
<summary className="cursor-pointer select-none px-4 py-3 text-sm font-medium text-red-400">
{t('admin.maintenance.factoryReset.dangerZone')}
</summary>
<div className="border-t border-red-900/50 px-4 py-4">
<Card className="border-red-900/50">
<CardHeader>
<CardTitle className="text-base text-red-400">{t('admin.maintenance.factoryReset.title')}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">{t('admin.maintenance.factoryReset.description')}</p>
<div>
<FactoryResetDialog />
</div>
</CardContent>
</Card>
</div>
</details>
</div>
)
}
+46
View File
@@ -370,6 +370,29 @@ const resources = {
confirm: 'Удалить все отключённые приложения? Действие необратимо.',
deleted: 'Удалено приложений: {{count}}.',
},
factoryReset: {
dangerZone: 'Опасная зона',
title: 'Полный сброс панели',
description: 'Вернуть панель в состояние только что развёрнутой — удалить всех пользователей, конфиги, ноды и все данные. Ваш текущий аккаунт сохранится.',
action: 'Сбросить панель',
dialogTitle: 'Полный сброс панели',
warningIntro: 'Будет удалено безвозвратно:',
warningItems: [
'Все пользователи, кроме вашего текущего аккаунта',
'Все VPN-конфиги (сначала будут отозваны на нодах 3x-ui)',
'Все ноды и инбаунды — регистрация серверов 3x-ui и сохранённые креды',
'Все обращения в поддержку вместе с перепиской и вложениями',
'Все новости',
'Весь журнал аудита',
'Все роли, кроме системных admin/user',
'Каталог приложений (будет пересеян из стартового набора)',
],
irreversible: 'Действие необратимо и не может быть отменено.',
confirmPhrase: 'СБРОС',
confirmInputLabel: 'Введите «{{phrase}}» для подтверждения',
confirmButton: 'Сбросить панель безвозвратно',
done: 'Панель сброшена до начального состояния.',
},
},
stats: {
totalUsers: 'Всего пользователей',
@@ -752,6 +775,29 @@ const resources = {
confirm: 'Delete all disabled apps? This cannot be undone.',
deleted: 'Deleted apps: {{count}}.',
},
factoryReset: {
dangerZone: 'Danger zone',
title: 'Full panel reset',
description: 'Return the panel to a freshly-deployed state — deletes all users, configs, nodes and data. Your current account is kept.',
action: 'Reset panel',
dialogTitle: 'Full panel reset',
warningIntro: 'This will permanently delete:',
warningItems: [
'All users except your current account',
'All VPN configs (revoked on the 3x-ui nodes first)',
'All nodes and inbounds — 3x-ui server registrations and stored credentials',
'All support tickets with their comments and attachments',
'All news posts',
'The entire audit log',
'All roles except the system admin/user roles',
'The app catalog (will be re-seeded from the default set)',
],
irreversible: 'This cannot be undone.',
confirmPhrase: 'RESET',
confirmInputLabel: 'Type "{{phrase}}" to confirm',
confirmButton: 'Reset the panel permanently',
done: 'The panel has been reset to its initial state.',
},
},
stats: {
totalUsers: 'Total users',