Преглед на файлове

feat(settings): let users clear stored secrets from the UI

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
MHSanaei преди 1 ден
родител
ревизия
92303094fd

+ 3 - 0
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) {

+ 8 - 4
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) {
             </SettingListItem>
 
             <SettingListItem paddings="small" title={t('pages.settings.smtpPassword')}
-              description={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
-              <Input.Password value={allSetting.smtpPassword}
-                placeholder={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordPlaceholder') : ''}
-                onChange={(e) => updateSetting({ smtpPassword: e.target.value })} />
+              description={allSetting.hasSmtpPassword && !allSetting.clearSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
+              <SecretInput value={allSetting.smtpPassword}
+                configured={allSetting.hasSmtpPassword}
+                clearArmed={allSetting.clearSmtpPassword}
+                placeholder={t('pages.settings.smtpPasswordPlaceholder')}
+                onChange={(v) => updateSetting({ smtpPassword: v })}
+                onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })} />
             </SettingListItem>
 
             <SettingListItem paddings="small" title={t('pages.settings.smtpTo')} description={t('pages.settings.smtpToDesc')}>

+ 8 - 4
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<T = unknown> {
   success?: boolean;
@@ -329,12 +330,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
             <SettingListItem
               paddings="small"
               title={t('password')}
-              description={allSetting.hasLdapPassword ? t('pages.settings.ldap.passwordConfigured') : t('pages.settings.ldap.passwordUnconfigured')}
+              description={allSetting.hasLdapPassword && !allSetting.clearLdapPassword ? t('pages.settings.ldap.passwordConfigured') : t('pages.settings.ldap.passwordUnconfigured')}
             >
-              <Input.Password
+              <SecretInput
                 value={allSetting.ldapPassword}
-                placeholder={allSetting.hasLdapPassword ? t('pages.settings.ldap.passwordPlaceholder') : ''}
-                onChange={(e) => 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 })}
               />
             </SettingListItem>
             <SettingListItem paddings="small" title={t('pages.settings.ldap.baseDn')}>

+ 45 - 0
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 (
+    <Space.Compact style={{ width: '100%' }}>
+      <Input.Password
+        value={value}
+        placeholder={configured && !clearArmed ? placeholder : ''}
+        onChange={(e) => {
+          onChange(e.target.value);
+          if (clearArmed) onClearArmedChange(false);
+        }}
+      />
+      {configured && (
+        <Button
+          danger={clearArmed}
+          onClick={() => {
+            onChange('');
+            onClearArmedChange(!clearArmed);
+          }}
+        >
+          {clearArmed ? t('pages.settings.secretClearUndo') : t('pages.settings.secretClear')}
+        </Button>
+      )}
+    </Space.Compact>
+  );
+}

+ 8 - 4
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
             <SettingListItem
               paddings="small"
               title={t('pages.settings.telegramToken')}
-              description={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenConfigured') : t('pages.settings.telegramTokenDesc')}
+              description={allSetting.hasTgBotToken && !allSetting.clearTgBotToken ? t('pages.settings.telegramTokenConfigured') : t('pages.settings.telegramTokenDesc')}
             >
-              <Input.Password
+              <SecretInput
                 value={allSetting.tgBotToken}
-                placeholder={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenPlaceholder') : ''}
-                onChange={(e) => 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 })}
               />
             </SettingListItem>
 

+ 13 - 2
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

+ 15 - 6
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

+ 49 - 1
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")

+ 3 - 1
internal/web/translation/ar-EG.json

@@ -1399,7 +1399,9 @@
       "remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.",
       "validation": {
         "pathLeadingSlash": "يجب أن يبدأ المسار بالرمز /"
-      }
+      },
+      "secretClear": "مسح",
+      "secretClearUndo": "تراجع عن المسح"
     },
     "xray": {
       "title": "إعدادات Xray",

+ 3 - 1
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",

+ 3 - 1
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",

+ 3 - 1
internal/web/translation/fa-IR.json

@@ -1399,7 +1399,9 @@
       "eventMemoryHigh": "مصرف حافظه بالا (%)",
       "validation": {
         "pathLeadingSlash": "مسیر باید با / شروع شود"
-      }
+      },
+      "secretClear": "پاک کردن",
+      "secretClearUndo": "لغو پاک کردن"
     },
     "xray": {
       "title": "پیکربندی ایکس‌ری",

+ 3 - 1
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",

+ 3 - 1
internal/web/translation/ja-JP.json

@@ -1399,7 +1399,9 @@
       "remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。",
       "validation": {
         "pathLeadingSlash": "パスは / で始まる必要があります"
-      }
+      },
+      "secretClear": "クリア",
+      "secretClearUndo": "クリアを取り消す"
     },
     "xray": {
       "title": "Xray 設定",

+ 3 - 1
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",

+ 3 - 1
internal/web/translation/ru-RU.json

@@ -1399,7 +1399,9 @@
       "remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше.",
       "validation": {
         "pathLeadingSlash": "Путь должен начинаться с /"
-      }
+      },
+      "secretClear": "Очистить",
+      "secretClearUndo": "Отменить очистку"
     },
     "xray": {
       "importRules": "Импорт правил",

+ 3 - 1
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ı",

+ 3 - 1
internal/web/translation/uk-UA.json

@@ -1399,7 +1399,9 @@
       "remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище.",
       "validation": {
         "pathLeadingSlash": "Шлях має починатися з /"
-      }
+      },
+      "secretClear": "Очистити",
+      "secretClearUndo": "Скасувати очищення"
     },
     "xray": {
       "title": "Xray конфігурації",

+ 3 - 1
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",

+ 3 - 1
internal/web/translation/zh-CN.json

@@ -1399,7 +1399,9 @@
       "remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。",
       "validation": {
         "pathLeadingSlash": "路径必须以 / 开头"
-      }
+      },
+      "secretClear": "清除",
+      "secretClearUndo": "撤销清除"
     },
     "xray": {
       "importRules": "导入规则",

+ 3 - 1
internal/web/translation/zh-TW.json

@@ -1399,7 +1399,9 @@
       "remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。",
       "validation": {
         "pathLeadingSlash": "路徑必須以 / 開頭"
-      }
+      },
+      "secretClear": "清除",
+      "secretClearUndo": "復原清除"
     },
     "xray": {
       "title": "Xray 配置",