From 1795c223edb8fbae141f8435158a1c87294d8c35 Mon Sep 17 00:00:00 2001 From: Arthur Koba Date: Mon, 11 May 2026 11:53:38 +0300 Subject: [PATCH 1/2] feat(core-config): add unsaved changes confirmation dialog - Implement change tracking for core configuration editor - Block modal close when unsaved changes exist - Show confirmation dialog with options to keep editing or discard changes - Add translations for all supported languages (en, ru, fa, zh) - Reset dirty state after successful save operation --- dashboard/public/statics/locales/en.json | 6 +- dashboard/public/statics/locales/fa.json | 6 +- dashboard/public/statics/locales/ru.json | 6 +- dashboard/public/statics/locales/zh.json | 6 +- .../components/dialogs/core-config-modal.tsx | 71 ++++++++++++++++++- 5 files changed, 88 insertions(+), 7 deletions(-) diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 25a715b5d..3b586764e 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -1873,7 +1873,11 @@ "shadowsocksPasswordGenerationFailed": "Failed to generate password", "realityTab": "Reality", "vlessTab": "VLESS", - "shadowsocksTab": "ShadowSocks" + "shadowsocksTab": "ShadowSocks", + "unsavedChanges": "Unsaved Changes", + "unsavedChangesMessage": "You have unsaved changes. Are you sure you want to exit without saving?", + "keepEditing": "Keep Editing", + "discardChanges": "Discard Changes" }, "settings.cores.title": "Cores", "settings.cores.description": "Manage Your Cores", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index b3fa7c6ac..93093523f 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -1788,7 +1788,11 @@ "shadowsocksPasswordGenerationFailed": "تولید رمز عبور ناموفق بود", "realityTab": "Reality", "vlessTab": "VLESS", - "shadowsocksTab": "ShadowSocks" + "shadowsocksTab": "ShadowSocks", + "unsavedChanges": "تغییرات ذخیره نشده", + "unsavedChangesMessage": "شما تغییرات ذخیره نشده‌ای دارید. آیا مطمئن هستید که می‌خواهید بدون ذخیره خارج شوید؟", + "keepEditing": "ادامه ویرایش", + "discardChanges": "لغو تغییرات" }, "settings.cores.title": "هسته‌ها", "settings.cores.description": "مدیریت هسته‌های شما", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index b3975890a..2150a1816 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -1762,7 +1762,11 @@ "shadowsocksPasswordGenerationFailed": "Не удалось сгенерировать пароль", "realityTab": "Reality", "vlessTab": "VLESS", - "shadowsocksTab": "ShadowSocks" + "shadowsocksTab": "ShadowSocks", + "unsavedChanges": "Несохраненные изменения", + "unsavedChangesMessage": "У вас есть несохраненные изменения. Вы уверены, что хотите выйти без сохранения?", + "keepEditing": "Продолжить редактирование", + "discardChanges": "Отменить изменения" }, "settings.cores.title": "Ядра", "settings.cores.description": "Управление вашими ядрами", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index d9459bb42..a6d2a6d64 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -1835,7 +1835,11 @@ "shadowsocksPasswordGenerationFailed": "密码生成失败", "realityTab": "Reality", "vlessTab": "VLESS", - "shadowsocksTab": "ShadowSocks" + "shadowsocksTab": "ShadowSocks", + "unsavedChanges": "未保存的更改", + "unsavedChangesMessage": "您有未保存的更改。您确定要在不保存的情况下退出吗?", + "keepEditing": "继续编辑", + "discardChanges": "放弃更改" }, "settings.cores.title": "核心", "settings.cores.description": "管理您的核心", diff --git a/dashboard/src/components/dialogs/core-config-modal.tsx b/dashboard/src/components/dialogs/core-config-modal.tsx index 06f9d3ef1..6377bb9db 100644 --- a/dashboard/src/components/dialogs/core-config-modal.tsx +++ b/dashboard/src/components/dialogs/core-config-modal.tsx @@ -2,6 +2,7 @@ import { CopyButton } from '@/components/common/copy-button' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -20,7 +21,7 @@ import { generateWireGuardKeyPair } from '@/utils/wireguard' import { encodeURLSafe } from '@stablelib/base64' import { generateKeyPair } from '@stablelib/x25519' import { debounce } from 'es-toolkit' -import { Info, Key, Maximize2, Minimize2, Sparkles, Shield, Pencil, Cpu } from 'lucide-react' +import { Info, Key, Maximize2, Minimize2, Sparkles, Shield, Pencil, Cpu, AlertTriangle } from 'lucide-react' import { MlKem768 } from 'mlkem' import { Suspense, lazy, useCallback, useEffect, useState } from 'react' import { UseFormReturn } from 'react-hook-form' @@ -185,6 +186,10 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit const [generatedShadowsocksPassword, setGeneratedShadowsocksPassword] = useState<{ password: string; encryptionMethod: string } | null>(null) const [generatedMldsa65, setGeneratedMldsa65] = useState<{ seed: string; verify: string } | null>(null) const [generatedVLESS, setGeneratedVLESS] = useState(null) + + // Unsaved changes tracking + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [isConfirmExitDialogOpen, setIsConfirmExitDialogOpen] = useState(false) const handleVlessVariantChange = useCallback( (value: string) => { if (value === 'x25519' || value === 'mlkem768') { @@ -194,6 +199,14 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit [setSelectedVlessVariant], ) + // Track unsaved changes + useEffect(() => { + const subscription = form.watch(() => { + setHasUnsavedChanges(form.formState.isDirty) + }) + return () => subscription.unsubscribe() + }, [form]) + // Helper function to show results in dialog const showResultDialog = useCallback((type: string, data: any) => { setResultType(type) @@ -201,6 +214,30 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit setIsResultsDialogOpen(true) }, []) + // Handle modal close with unsaved changes check + const handleModalCloseWithCheck = useCallback((open: boolean) => { + if (!open && hasUnsavedChanges) { + setIsConfirmExitDialogOpen(true) + } else { + onOpenChange(open) + if (!open) { + setHasUnsavedChanges(false) + } + } + }, [hasUnsavedChanges, onOpenChange]) + + // Confirm exit without saving + const handleConfirmExit = useCallback(() => { + setIsConfirmExitDialogOpen(false) + setHasUnsavedChanges(false) + onOpenChange(false) + }, [onOpenChange]) + + // Cancel exit + const handleCancelExit = useCallback(() => { + setIsConfirmExitDialogOpen(false) + }, []) + const relayoutEditor = useCallback( (editor = editorInstance) => { if (!editor) return @@ -601,6 +638,7 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit // Invalidate core config queries after successful action queryClient.invalidateQueries({ queryKey: ['/api/cores'] }) queryClient.invalidateQueries({ queryKey: ['/api/cores/simple'] }) + setHasUnsavedChanges(false) onOpenChange(false) form.reset() } catch (error: any) { @@ -736,6 +774,8 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit setValidation({ isValid: true }) setEditorInstance(null) setIsEditorReady(false) + setHasUnsavedChanges(false) + setIsConfirmExitDialogOpen(false) // Don't clear generated values - keep them for reuse } }, [isDialogOpen]) @@ -1325,7 +1365,7 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit <> {renderVlessAdvancedModal()} {renderResultDialog()} - + @@ -1809,7 +1849,7 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit )}
-
+ + {/* Unsaved Changes Confirmation Dialog */} + + + + + + {t('coreConfigModal.unsavedChanges', { defaultValue: 'Unsaved Changes' })} + + + {t('coreConfigModal.unsavedChangesMessage', { + defaultValue: 'You have unsaved changes. Are you sure you want to exit without saving?' + })} + + + + + {t('coreConfigModal.keepEditing', { defaultValue: 'Keep Editing' })} + + + + + ) } From 48eb86bf48fce3b430b55f7ddec19a99bfdc2954 Mon Sep 17 00:00:00 2001 From: Arthur Koba Date: Mon, 11 May 2026 12:23:03 +0300 Subject: [PATCH 2/2] fix(core-config): prevent race condition in unsaved changes check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use form.formState.isDirty directly instead of mirrored state to prevent race condition where fast edit→close sequence could bypass confirmation dialog. Remove unnecessary state synchronization and simplify logic by reading form state at decision time. --- .../components/dialogs/core-config-modal.tsx | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/dashboard/src/components/dialogs/core-config-modal.tsx b/dashboard/src/components/dialogs/core-config-modal.tsx index 6377bb9db..b27997fea 100644 --- a/dashboard/src/components/dialogs/core-config-modal.tsx +++ b/dashboard/src/components/dialogs/core-config-modal.tsx @@ -187,9 +187,9 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit const [generatedMldsa65, setGeneratedMldsa65] = useState<{ seed: string; verify: string } | null>(null) const [generatedVLESS, setGeneratedVLESS] = useState(null) - // Unsaved changes tracking - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + // Unsaved changes confirmation dialog state const [isConfirmExitDialogOpen, setIsConfirmExitDialogOpen] = useState(false) + const handleVlessVariantChange = useCallback( (value: string) => { if (value === 'x25519' || value === 'mlkem768') { @@ -199,14 +199,6 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit [setSelectedVlessVariant], ) - // Track unsaved changes - useEffect(() => { - const subscription = form.watch(() => { - setHasUnsavedChanges(form.formState.isDirty) - }) - return () => subscription.unsubscribe() - }, [form]) - // Helper function to show results in dialog const showResultDialog = useCallback((type: string, data: any) => { setResultType(type) @@ -216,22 +208,22 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit // Handle modal close with unsaved changes check const handleModalCloseWithCheck = useCallback((open: boolean) => { - if (!open && hasUnsavedChanges) { + if (!open && form.formState.isDirty) { setIsConfirmExitDialogOpen(true) } else { onOpenChange(open) if (!open) { - setHasUnsavedChanges(false) + form.reset() } } - }, [hasUnsavedChanges, onOpenChange]) + }, [form, onOpenChange]) // Confirm exit without saving const handleConfirmExit = useCallback(() => { setIsConfirmExitDialogOpen(false) - setHasUnsavedChanges(false) + form.reset() onOpenChange(false) - }, [onOpenChange]) + }, [form, onOpenChange]) // Cancel exit const handleCancelExit = useCallback(() => { @@ -638,7 +630,6 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit // Invalidate core config queries after successful action queryClient.invalidateQueries({ queryKey: ['/api/cores'] }) queryClient.invalidateQueries({ queryKey: ['/api/cores/simple'] }) - setHasUnsavedChanges(false) onOpenChange(false) form.reset() } catch (error: any) { @@ -774,7 +765,6 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit setValidation({ isValid: true }) setEditorInstance(null) setIsEditorReady(false) - setHasUnsavedChanges(false) setIsConfirmExitDialogOpen(false) // Don't clear generated values - keep them for reuse }