From 92303094fdaee2c5e7a03aecf16047433c796084 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 2 Jul 2026 13:57:34 +0200 Subject: [PATCH] feat(settings): let users clear stored secrets from the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redacted secrets (SMTP password, Telegram bot token, LDAP password) are always served blank to the browser, so the update path treats a blank submission as "unchanged" and silently restores the stored value. That made a once-set secret impossible to remove without editing the database — e.g. switching to a passwordless localhost SMTP relay kept sending the old credentials forever. Blank stays "unchanged"; clearing is now its own signal. The update request carries explicit clear flags (request-scoped fields on the controller form, so they are never persisted as settings rows), and preserveRedactedSecrets skips the restore for a flagged secret. Each secret field gets a Clear/Undo button that arms the flag; typing a new value disarms it. The 2FA token keeps its existing behavior: it is already clearable by disabling 2FA. Closes #5724 --- frontend/src/models/setting.ts | 3 ++ frontend/src/pages/settings/EmailTab.tsx | 12 +++-- frontend/src/pages/settings/GeneralTab.tsx | 12 +++-- frontend/src/pages/settings/SecretInput.tsx | 45 +++++++++++++++++ frontend/src/pages/settings/TelegramTab.tsx | 12 +++-- internal/web/controller/setting.go | 15 +++++- internal/web/service/setting.go | 21 +++++--- internal/web/service/setting_security_test.go | 50 ++++++++++++++++++- internal/web/translation/ar-EG.json | 4 +- internal/web/translation/en-US.json | 4 +- internal/web/translation/es-ES.json | 4 +- internal/web/translation/fa-IR.json | 4 +- internal/web/translation/id-ID.json | 4 +- internal/web/translation/ja-JP.json | 4 +- internal/web/translation/pt-BR.json | 4 +- internal/web/translation/ru-RU.json | 4 +- internal/web/translation/tr-TR.json | 4 +- internal/web/translation/uk-UA.json | 4 +- internal/web/translation/vi-VN.json | 4 +- internal/web/translation/zh-CN.json | 4 +- internal/web/translation/zh-TW.json | 4 +- 21 files changed, 188 insertions(+), 34 deletions(-) create mode 100644 frontend/src/pages/settings/SecretInput.tsx diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index 739fb78c5..c2eca193b 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -103,6 +103,9 @@ export class AllSetting { hasWarpSecret = false; hasNordSecret = false; hasSmtpPassword = false; + clearTgBotToken = false; + clearLdapPassword = false; + clearSmtpPassword = false; constructor(data?: unknown) { if (data != null) { diff --git a/frontend/src/pages/settings/EmailTab.tsx b/frontend/src/pages/settings/EmailTab.tsx index 2f830fc5b..eee583070 100644 --- a/frontend/src/pages/settings/EmailTab.tsx +++ b/frontend/src/pages/settings/EmailTab.tsx @@ -8,6 +8,7 @@ import { SettingListItem } from '@/components/ui'; import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { catTabLabel } from './catTabLabel'; +import SecretInput from './SecretInput'; interface EmailTabProps { allSetting: AllSetting; @@ -72,10 +73,13 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) { - updateSetting({ smtpPassword: e.target.value })} /> + description={allSetting.hasSmtpPassword && !allSetting.clearSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}> + updateSetting({ smtpPassword: v })} + onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })} /> diff --git a/frontend/src/pages/settings/GeneralTab.tsx b/frontend/src/pages/settings/GeneralTab.tsx index b40ab935f..df7a3f395 100644 --- a/frontend/src/pages/settings/GeneralTab.tsx +++ b/frontend/src/pages/settings/GeneralTab.tsx @@ -21,6 +21,7 @@ import { SettingListItem } from '@/components/ui'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { catTabLabel } from './catTabLabel'; import { sanitizePath } from './uriPath'; +import SecretInput from './SecretInput'; interface ApiMsg { success?: boolean; @@ -329,12 +330,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp - updateSetting({ ldapPassword: e.target.value })} + configured={allSetting.hasLdapPassword} + clearArmed={allSetting.clearLdapPassword} + placeholder={t('pages.settings.ldap.passwordPlaceholder')} + onChange={(v) => updateSetting({ ldapPassword: v })} + onClearArmedChange={(armed) => updateSetting({ clearLdapPassword: armed })} /> diff --git a/frontend/src/pages/settings/SecretInput.tsx b/frontend/src/pages/settings/SecretInput.tsx new file mode 100644 index 000000000..2b5a626af --- /dev/null +++ b/frontend/src/pages/settings/SecretInput.tsx @@ -0,0 +1,45 @@ +import { Button, Input, Space } from 'antd'; +import { useTranslation } from 'react-i18next'; + +interface SecretInputProps { + value: string; + configured: boolean; + clearArmed: boolean; + placeholder: string; + onChange: (value: string) => void; + onClearArmedChange: (armed: boolean) => void; +} + +export default function SecretInput({ + value, + configured, + clearArmed, + placeholder, + onChange, + onClearArmedChange, +}: SecretInputProps) { + const { t } = useTranslation(); + return ( + + { + onChange(e.target.value); + if (clearArmed) onClearArmedChange(false); + }} + /> + {configured && ( + + )} + + ); +} diff --git a/frontend/src/pages/settings/TelegramTab.tsx b/frontend/src/pages/settings/TelegramTab.tsx index 6d5cb95a4..967a34557 100644 --- a/frontend/src/pages/settings/TelegramTab.tsx +++ b/frontend/src/pages/settings/TelegramTab.tsx @@ -9,6 +9,7 @@ import { SettingListItem } from '@/components/ui'; import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { catTabLabel } from './catTabLabel'; +import SecretInput from './SecretInput'; interface TelegramTabProps { allSetting: AllSetting; @@ -193,12 +194,15 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr - updateSetting({ tgBotToken: e.target.value })} + configured={allSetting.hasTgBotToken} + clearArmed={allSetting.clearTgBotToken} + placeholder={t('pages.settings.telegramTokenPlaceholder')} + onChange={(v) => updateSetting({ tgBotToken: v })} + onClearArmedChange={(armed) => updateSetting({ clearTgBotToken: armed })} /> diff --git a/internal/web/controller/setting.go b/internal/web/controller/setting.go index 4705939ae..35601c7c3 100644 --- a/internal/web/controller/setting.go +++ b/internal/web/controller/setting.go @@ -26,9 +26,16 @@ type updateUserForm struct { TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"` } +// updateSettingForm carries the persisted settings plus request-scoped fields +// that must never land in the settings table: the 2FA confirmation code and +// the explicit clear flags for redacted secrets (a blank secret alone means +// "unchanged", so clearing needs its own signal — see #5724). type updateSettingForm struct { entity.AllSetting - TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"` + TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"` + ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"` + ClearLdapPassword bool `json:"clearLdapPassword" form:"clearLdapPassword"` + ClearSmtpPassword bool `json:"clearSmtpPassword" form:"clearSmtpPassword"` } // SettingController handles settings and user management operations. @@ -105,7 +112,11 @@ func (a *SettingController) updateSetting(c *gin.Context) { return } } - err := a.settingService.UpdateAllSetting(allSetting) + err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{ + TgBotToken: form.ClearTgBotToken, + LdapPassword: form.ClearLdapPassword, + SmtpPassword: form.ClearSmtpPassword, + }) if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable { if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil { err = bumpErr diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index 2e3ff0626..00ab86545 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -1085,8 +1085,17 @@ func (s *SettingService) SetSmtpMemory(value int) error { return s.setInt("smtpMemory", value) } -func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { - if err := s.preserveRedactedSecrets(allSetting); err != nil { +// SecretClears marks redacted secrets the user explicitly emptied. Without a +// flag, a blank submitted secret means "unchanged" (the field is always served +// blank to the browser) and the stored value is preserved. +type SecretClears struct { + TgBotToken bool + LdapPassword bool + SmtpPassword bool +} + +func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error { + if err := s.preserveRedactedSecrets(allSetting, clears); err != nil { return err } if err := validateSettingsURLs(allSetting); err != nil { @@ -1132,15 +1141,15 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { }) } -func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting) error { - if strings.TrimSpace(allSetting.TgBotToken) == "" { +func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, clears SecretClears) error { + if !clears.TgBotToken && strings.TrimSpace(allSetting.TgBotToken) == "" { value, err := s.GetTgBotToken() if err != nil { return err } allSetting.TgBotToken = value } - if strings.TrimSpace(allSetting.LdapPassword) == "" { + if !clears.LdapPassword && strings.TrimSpace(allSetting.LdapPassword) == "" { value, err := s.GetLdapPassword() if err != nil { return err @@ -1154,7 +1163,7 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting) } allSetting.TwoFactorToken = value } - if strings.TrimSpace(allSetting.SmtpPassword) == "" { + if !clears.SmtpPassword && strings.TrimSpace(allSetting.SmtpPassword) == "" { value, err := s.GetSmtpPassword() if err != nil { return err diff --git a/internal/web/service/setting_security_test.go b/internal/web/service/setting_security_test.go index 4acef1a6d..9f7b1aaa6 100644 --- a/internal/web/service/setting_security_test.go +++ b/internal/web/service/setting_security_test.go @@ -77,7 +77,7 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) { t.Fatal(err) } settings := &view.AllSetting - if err := s.UpdateAllSetting(settings); err != nil { + if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil { t.Fatal(err) } if got, _ := s.GetTgBotToken(); got != "telegram-secret" { @@ -94,6 +94,54 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) { } } +func TestUpdateAllSettingClearsFlaggedSecrets(t *testing.T) { + setupSettingTestDB(t) + s := &SettingService{} + if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil { + t.Fatal(err) + } + if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil { + t.Fatal(err) + } + if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil { + t.Fatal(err) + } + + view, err := s.GetAllSettingView() + if err != nil { + t.Fatal(err) + } + if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{SmtpPassword: true}); err != nil { + t.Fatal(err) + } + if got, _ := s.GetSmtpPassword(); got != "" { + t.Fatalf("smtp password = %q, want cleared", got) + } + if got, _ := s.GetTgBotToken(); got != "telegram-secret" { + t.Fatalf("tg token = %q, unflagged secret must stay preserved", got) + } + if got, _ := s.GetLdapPassword(); got != "ldap-secret" { + t.Fatalf("ldap password = %q, unflagged secret must stay preserved", got) + } + + view, err = s.GetAllSettingView() + if err != nil { + t.Fatal(err) + } + if view.HasSmtpPassword { + t.Fatal("hasSmtpPassword must report false after clearing") + } + if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{TgBotToken: true, LdapPassword: true}); err != nil { + t.Fatal(err) + } + if got, _ := s.GetTgBotToken(); got != "" { + t.Fatalf("tg token = %q, want cleared", got) + } + if got, _ := s.GetLdapPassword(); got != "" { + t.Fatalf("ldap password = %q, want cleared", got) + } +} + func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) { if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil { t.Fatal("expected localhost URL to be blocked") diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 5e9e7e6ee..7275fa56b 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1399,7 +1399,9 @@ "remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.", "validation": { "pathLeadingSlash": "يجب أن يبدأ المسار بالرمز /" - } + }, + "secretClear": "مسح", + "secretClearUndo": "تراجع عن المسح" }, "xray": { "title": "إعدادات Xray", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 2c65f1719..25ef671b9 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1515,7 +1515,9 @@ "eventMemoryHigh": "Memory high (%)", "validation": { "pathLeadingSlash": "Path must start with /" - } + }, + "secretClear": "Clear", + "secretClearUndo": "Undo clear" }, "xray": { "title": "Xray Configs", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index dacf31c95..9c20198b9 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -1399,7 +1399,9 @@ "remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior.", "validation": { "pathLeadingSlash": "La ruta debe comenzar con /" - } + }, + "secretClear": "Borrar", + "secretClearUndo": "Deshacer borrado" }, "xray": { "title": "Xray Configuración", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index bc0718cd4..4268da570 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1399,7 +1399,9 @@ "eventMemoryHigh": "مصرف حافظه بالا (%)", "validation": { "pathLeadingSlash": "مسیر باید با / شروع شود" - } + }, + "secretClear": "پاک کردن", + "secretClearUndo": "لغو پاک کردن" }, "xray": { "title": "پیکربندی ایکس‌ری", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 2b9a8e92c..913b7b854 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -1399,7 +1399,9 @@ "remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas.", "validation": { "pathLeadingSlash": "Path harus diawali dengan /" - } + }, + "secretClear": "Hapus", + "secretClearUndo": "Batalkan hapus" }, "xray": { "title": "Konfigurasi Xray", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index d30b9f2ba..adce49f67 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1399,7 +1399,9 @@ "remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。", "validation": { "pathLeadingSlash": "パスは / で始まる必要があります" - } + }, + "secretClear": "クリア", + "secretClearUndo": "クリアを取り消す" }, "xray": { "title": "Xray 設定", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index a7aebcae7..2943423de 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -1399,7 +1399,9 @@ "remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima.", "validation": { "pathLeadingSlash": "O caminho deve começar com /" - } + }, + "secretClear": "Limpar", + "secretClearUndo": "Desfazer limpeza" }, "xray": { "title": "Configurações Xray", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 1ce3a9b03..d86822288 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1399,7 +1399,9 @@ "remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше.", "validation": { "pathLeadingSlash": "Путь должен начинаться с /" - } + }, + "secretClear": "Очистить", + "secretClearUndo": "Отменить очистку" }, "xray": { "importRules": "Импорт правил", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 7ccdd1ba6..c59a0db5e 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -1399,7 +1399,9 @@ "remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın.", "validation": { "pathLeadingSlash": "Yol / ile başlamalıdır" - } + }, + "secretClear": "Temizle", + "secretClearUndo": "Temizlemeyi geri al" }, "xray": { "title": "Xray Yapılandırmaları", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index d66259e77..aa7e785af 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1399,7 +1399,9 @@ "remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище.", "validation": { "pathLeadingSlash": "Шлях має починатися з /" - } + }, + "secretClear": "Очистити", + "secretClearUndo": "Скасувати очищення" }, "xray": { "title": "Xray конфігурації", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 5f037e294..b99e2d874 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -1399,7 +1399,9 @@ "remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên.", "validation": { "pathLeadingSlash": "Đường dẫn phải bắt đầu bằng /" - } + }, + "secretClear": "Xóa", + "secretClearUndo": "Hoàn tác xóa" }, "xray": { "title": "Cài đặt Xray", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index b6fc73d0e..e0ee5cb7d 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1399,7 +1399,9 @@ "remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。", "validation": { "pathLeadingSlash": "路径必须以 / 开头" - } + }, + "secretClear": "清除", + "secretClearUndo": "撤销清除" }, "xray": { "importRules": "导入规则", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index ac61e969f..5e5e83efa 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1399,7 +1399,9 @@ "remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。", "validation": { "pathLeadingSlash": "路徑必須以 / 開頭" - } + }, + "secretClear": "清除", + "secretClearUndo": "復原清除" }, "xray": { "title": "Xray 配置",