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 配置",