From 820ea0197f46c9144bd851a10fbec827560880d9 Mon Sep 17 00:00:00 2001 From: FromSi Date: Mon, 18 May 2026 14:00:36 +0500 Subject: [PATCH] feat(settings): add password meter (#1299) ## What? Adds real-time password feedback to the encryption settings screen. - Shows whether the password and confirmation fields match while typing. - Shows an informational password strength level: `weak`, `medium`, or `strong`. - Adds `internal/passwordstrength` with a `Meter` interface and a `LibMeter` implementation backed by `github.com/wagslane/go-password-validator`. ## Why? Closes #628 Users previously only learned that password confirmation failed after submitting the form. Inline feedback makes the encryption setup flow clearer and helps users catch mistakes before attempting to enable encryption. The strength meter is informational only: weak passwords are not rejected. --------- Co-authored-by: Andriy Chernov --- go.mod | 1 + go.sum | 2 ++ i18n/locales/ar.json | 6 ++++ i18n/locales/de.json | 6 ++++ i18n/locales/en.json | 6 ++++ i18n/locales/es.json | 6 ++++ i18n/locales/fr.json | 6 ++++ i18n/locales/ja.json | 6 ++++ i18n/locales/pl.json | 6 ++++ i18n/locales/pt.json | 6 ++++ i18n/locales/ru.json | 6 ++++ i18n/locales/uk.json | 6 ++++ i18n/locales/zh.json | 6 ++++ internal/passwordstrength/lib_meter.go | 21 ++++++++++++ internal/passwordstrength/meter.go | 18 ++++++++++ tui/settings.go | 18 ++++++---- tui/settings_encryption.go | 47 +++++++++++++++++++++++++- 17 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 internal/passwordstrength/lib_meter.go create mode 100644 internal/passwordstrength/meter.go diff --git a/go.mod b/go.mod index b4ce7af8c2ca6f033cf255b398f9d9f3da6d8914..97bf1421d4b1b5e8d54b6f030e5c902757ef3963 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/knadh/go-pop3 v1.0.2 github.com/mattn/go-sixel v0.0.9 + github.com/wagslane/go-password-validator v0.3.0 github.com/yuin/goldmark v1.8.2 github.com/yuin/gopher-lua v1.1.2 github.com/zalando/go-keyring v0.2.8 diff --git a/go.sum b/go.sum index eeae001762255f3187250f11f44f77fddab14209..e4373b51e64d8e01d8fd9572605b107168842772 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,8 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I= +github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/i18n/locales/ar.json b/i18n/locales/ar.json index ac7ac44fb9420e52610ea6cfa2e62a0786a59213..d6818784de9d3e53f33ae06e4ac77ac39b9bdc1a 100644 --- a/i18n/locales/ar.json +++ b/i18n/locales/ar.json @@ -174,6 +174,12 @@ "disable_confirm": "تعطيل التشفير؟", "disable_warning": "سيتم تخزين جميع البيانات بدون تشفير.", "encrypting": "جاري تشفير البيانات...", + "passwords_match": "✓ كلمات المرور متطابقة", + "passwords_do_not_match": "✗ كلمات المرور غير متطابقة", + "strength_label": "القوة:", + "strength_weak": "ضعيف", + "strength_medium": "متوسط", + "strength_strong": "قوي", "error_empty": "لا يمكن أن تكون كلمة المرور فارغة", "error_mismatch": "كلمات المرور غير متطابقة", "help": "tab: التالي • enter: حفظ" diff --git a/i18n/locales/de.json b/i18n/locales/de.json index 6a96076c8904d5baab3f7014ea989bc156c38e34..8fc87d3a20822bd59429164b5bc13f019339dc55 100644 --- a/i18n/locales/de.json +++ b/i18n/locales/de.json @@ -170,6 +170,12 @@ "disable_confirm": "Verschlüsselung deaktivieren?", "disable_warning": "Alle Daten werden unverschlüsselt gespeichert.", "encrypting": "Daten werden verschlüsselt...", + "passwords_match": "✓ Passwörter stimmen überein", + "passwords_do_not_match": "✗ Passwörter stimmen nicht überein", + "strength_label": "Stärke:", + "strength_weak": "schwach", + "strength_medium": "mittel", + "strength_strong": "stark", "error_empty": "Passwort darf nicht leer sein", "error_mismatch": "Passwörter stimmen nicht überein", "help": "tab: nächstes • enter: speichern" diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 4fcf69b26d5e7f22d393aae094f43906c0944221..8d0c465eb051b81e0cf8af9c2a65f092cde301bd 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -170,6 +170,12 @@ "disable_confirm": "Disable encryption?", "disable_warning": "All data will be stored unencrypted.", "encrypting": "Encrypting data...", + "passwords_match": "✓ Passwords match", + "passwords_do_not_match": "✗ Passwords do not match", + "strength_label": "Strength:", + "strength_weak": "weak", + "strength_medium": "medium", + "strength_strong": "strong", "error_empty": "Password cannot be empty", "error_mismatch": "Passwords do not match", "help": "tab: next • enter: save" diff --git a/i18n/locales/es.json b/i18n/locales/es.json index 88a07ef18b7d5dc5bbff14a4c69adb40efee781c..1bc563a750d852a017d7cec81481daf612cd054b 100644 --- a/i18n/locales/es.json +++ b/i18n/locales/es.json @@ -170,6 +170,12 @@ "disable_confirm": "¿Deshabilitar cifrado?", "disable_warning": "Todos los datos se almacenarán sin cifrar.", "encrypting": "Cifrando datos...", + "passwords_match": "✓ Las contraseñas coinciden", + "passwords_do_not_match": "✗ Las contraseñas no coinciden", + "strength_label": "Fortaleza:", + "strength_weak": "débil", + "strength_medium": "media", + "strength_strong": "fuerte", "error_empty": "La contraseña no puede estar vacía", "error_mismatch": "Las contraseñas no coinciden", "help": "tab: siguiente • enter: guardar" diff --git a/i18n/locales/fr.json b/i18n/locales/fr.json index 6ad0a8fd33cb9849ae956ec33e7fe96d35f676e0..62220e5b7e0217f36f0f52a1913a5a8ae76ef9eb 100644 --- a/i18n/locales/fr.json +++ b/i18n/locales/fr.json @@ -170,6 +170,12 @@ "disable_confirm": "Désactiver le chiffrement ?", "disable_warning": "Toutes les données seront stockées non chiffrées.", "encrypting": "Chiffrement des données...", + "passwords_match": "✓ Les mots de passe correspondent", + "passwords_do_not_match": "✗ Les mots de passe ne correspondent pas", + "strength_label": "Force :", + "strength_weak": "faible", + "strength_medium": "moyen", + "strength_strong": "fort", "error_empty": "Le mot de passe ne peut pas être vide", "error_mismatch": "Les mots de passe ne correspondent pas", "help": "tab: suivant • entrée: enregistrer" diff --git a/i18n/locales/ja.json b/i18n/locales/ja.json index 9556af911d8f7ae0fffa9077b567bb5d0a6a00c1..f5892f73e40facb20ea2152ccf66a318df998033 100644 --- a/i18n/locales/ja.json +++ b/i18n/locales/ja.json @@ -168,6 +168,12 @@ "disable_confirm": "暗号化を無効にしますか?", "disable_warning": "すべてのデータは暗号化されずに保存されます。", "encrypting": "データを暗号化中...", + "passwords_match": "✓ パスワードが一致しました", + "passwords_do_not_match": "✗ パスワードが一致しません", + "strength_label": "強度:", + "strength_weak": "弱い", + "strength_medium": "普通", + "strength_strong": "強い", "error_empty": "パスワードを空にすることはできません", "error_mismatch": "パスワードが一致しません", "help": "tab: 次へ • enter: 保存" diff --git a/i18n/locales/pl.json b/i18n/locales/pl.json index d4ea5a6ddc82104a5bff5d0f3df4ef5481705c45..2c8a82a8aa6d7d89293e062ebc6ec27c2f8f213c 100644 --- a/i18n/locales/pl.json +++ b/i18n/locales/pl.json @@ -174,6 +174,12 @@ "disable_confirm": "Wyłączyć szyfrowanie?", "disable_warning": "Wszystkie dane będą przechowywane bez szyfrowania.", "encrypting": "Szyfrowanie danych...", + "passwords_match": "✓ Hasła pasują do siebie", + "passwords_do_not_match": "✗ Hasła nie pasują do siebie", + "strength_label": "Siła:", + "strength_weak": "słabe", + "strength_medium": "średnie", + "strength_strong": "silne", "error_empty": "Hasło nie może być puste", "error_mismatch": "Hasła nie pasują do siebie", "help": "tab: następny • enter: zapisz" diff --git a/i18n/locales/pt.json b/i18n/locales/pt.json index a9a8663210255b2706bb52811ce312083d457fde..3d6562713940e76cac7696d3d311785e01b2a214 100644 --- a/i18n/locales/pt.json +++ b/i18n/locales/pt.json @@ -170,6 +170,12 @@ "disable_confirm": "Desativar criptografia?", "disable_warning": "Todos os dados serão armazenados sem criptografia.", "encrypting": "Criptografando dados...", + "passwords_match": "✓ As senhas coincidem", + "passwords_do_not_match": "✗ As senhas não coincidem", + "strength_label": "Força:", + "strength_weak": "fraca", + "strength_medium": "média", + "strength_strong": "forte", "error_empty": "A senha não pode estar vazia", "error_mismatch": "As senhas não coincidem", "help": "tab: próximo • enter: salvar" diff --git a/i18n/locales/ru.json b/i18n/locales/ru.json index ddaf0e0d8bd975390f5461dcfbc9228bee6510b0..f18e8c666089cbbc1930c078e78f0dbc65e1114b 100644 --- a/i18n/locales/ru.json +++ b/i18n/locales/ru.json @@ -174,6 +174,12 @@ "disable_confirm": "Отключить шифрование?", "disable_warning": "Все данные будут храниться незашифрованными.", "encrypting": "Шифрование данных...", + "passwords_match": "✓ Пароли совпадают", + "passwords_do_not_match": "✗ Пароли не совпадают", + "strength_label": "Надёжность:", + "strength_weak": "слабый", + "strength_medium": "средний", + "strength_strong": "сильный", "error_empty": "Пароль не может быть пустым", "error_mismatch": "Пароли не совпадают", "help": "tab: следующий • enter: сохранить" diff --git a/i18n/locales/uk.json b/i18n/locales/uk.json index cf6c1f4b831bf0745c5cdf631266423f1cfcc776..37a597edb62fd8939df0a6600fbd8a2cc7a85d3c 100644 --- a/i18n/locales/uk.json +++ b/i18n/locales/uk.json @@ -172,6 +172,12 @@ "disable_confirm": "Вимкнути шифрування?", "disable_warning": "Всі дані будуть зберігатися без шифрування.", "encrypting": "Шифрування даних...", + "passwords_match": "✓ Паролі співпадають", + "passwords_do_not_match": "✗ Паролі не співпадають", + "strength_label": "Надійність:", + "strength_weak": "слабкий", + "strength_medium": "середній", + "strength_strong": "сильний", "error_empty": "Пароль не може бути порожнім", "error_mismatch": "Паролі не співпадають", "help": "tab: далі • enter: зберегти" diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index 730d46f282e7785610e319a69f1d0fa13e5dfb51..276af2f81f1e7878a0e9251c5ec9f6ee1ff40c96 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -168,6 +168,12 @@ "disable_confirm": "禁用加密?", "disable_warning": "所有数据将以未加密方式存储。", "encrypting": "正在加密数据...", + "passwords_match": "✓ 密码匹配", + "passwords_do_not_match": "✗ 密码不匹配", + "strength_label": "强度:", + "strength_weak": "弱", + "strength_medium": "中等", + "strength_strong": "强", "error_empty": "密码不能为空", "error_mismatch": "密码不匹配", "help": "tab: 下一项 • enter: 保存" diff --git a/internal/passwordstrength/lib_meter.go b/internal/passwordstrength/lib_meter.go new file mode 100644 index 0000000000000000000000000000000000000000..141504c3e1862b9dd1d71616caaa336f3cb80e02 --- /dev/null +++ b/internal/passwordstrength/lib_meter.go @@ -0,0 +1,21 @@ +package passwordstrength + +import passwordvalidator "github.com/wagslane/go-password-validator" + +type LibMeter struct{} + +func NewLibMeter() LibMeter { + return LibMeter{} +} + +func (m LibMeter) Strength(password string) Strength { + entropy := passwordvalidator.GetEntropy(password) + switch { + case entropy >= strongEntropyBits: + return Strong + case entropy >= mediumEntropyBits: + return Medium + default: + return Weak + } +} diff --git a/internal/passwordstrength/meter.go b/internal/passwordstrength/meter.go new file mode 100644 index 0000000000000000000000000000000000000000..994ac3d8ca4802cbaff4c53d1466c16681934c76 --- /dev/null +++ b/internal/passwordstrength/meter.go @@ -0,0 +1,18 @@ +package passwordstrength + +type Strength string + +const ( + Weak Strength = "weak" + Medium Strength = "medium" + Strong Strength = "strong" +) + +const ( + mediumEntropyBits = 50 + strongEntropyBits = 70 +) + +type Meter interface { + Strength(password string) Strength +} diff --git a/tui/settings.go b/tui/settings.go index f0d4e8c64a10bbecc050e694641bb70c917ad23d..affa34a6dbaee78341aeacdf0a3f108d1eaf1973 100644 --- a/tui/settings.go +++ b/tui/settings.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/internal/passwordstrength" "github.com/floatpane/matcha/plugin" "github.com/floatpane/matcha/theme" ) @@ -16,6 +17,7 @@ var ( selectedAccountItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42")).Bold(true) accountEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) settingsFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) settingsBlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) @@ -69,12 +71,14 @@ type Settings struct { pgpPINInput textinput.Model // Encryption fields - encPasswordInput textinput.Model - encConfirmInput textinput.Model - encFocusIndex int - encError string - encEnabling bool - confirmingDisable bool + encPasswordInput textinput.Model + encConfirmInput textinput.Model + encFocusIndex int + encError string + encEnabling bool + confirmingDisable bool + passwordMeter passwordstrength.Meter + encPasswordStrength passwordstrength.Strength // Plugin settings state plugins *plugin.Manager @@ -130,6 +134,7 @@ func NewSettings(cfg *config.Config) *Settings { pgpKeySource: "file", encPasswordInput: newInput("Password", "> ", true), encConfirmInput: newInput("Confirm Password", "> ", true), + passwordMeter: passwordstrength.NewLibMeter(), pluginInput: newInput("", "> ", false), } } @@ -292,6 +297,7 @@ func (m *Settings) updateMenu(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.encError = "" m.encPasswordInput.SetValue("") m.encConfirmInput.SetValue("") + m.encPasswordStrength = "" m.encFocusIndex = 0 m.confirmingDisable = false m.encEnabling = false diff --git a/tui/settings_encryption.go b/tui/settings_encryption.go index 63e1e51416fce3a03a45536e7b3eb467c64c9270..3d0a0410a30ba592b5d3de80aefef33cea43c0f0 100644 --- a/tui/settings_encryption.go +++ b/tui/settings_encryption.go @@ -6,6 +6,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/internal/passwordstrength" ) func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { @@ -38,6 +39,7 @@ func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // Clear inputs and return to menu m.encPasswordInput.SetValue("") m.encConfirmInput.SetValue("") + m.encPasswordStrength = "" m.encPasswordInput.Blur() m.encConfirmInput.Blur() m.encError = "" @@ -98,7 +100,11 @@ func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // Forward input to focused textinput var cmd tea.Cmd if m.encFocusIndex == 0 { + before := m.encPasswordInput.Value() m.encPasswordInput, cmd = m.encPasswordInput.Update(msg) + if m.encPasswordInput.Value() != before { + m.handlePasswordChanged() + } } else if m.encFocusIndex == 1 { m.encConfirmInput, cmd = m.encConfirmInput.Update(msg) } @@ -137,13 +143,20 @@ func (m *Settings) viewEncryption() string { b.WriteString(settingsBlurredStyle.Render(t("settings_encryption.password_label") + "\n")) } b.WriteString(m.encPasswordInput.View() + "\n\n") + if m.encPasswordStrength != "" { + b.WriteString(" " + m.renderPasswordStrength() + "\n\n") + } if m.encFocusIndex == 1 { b.WriteString(settingsFocusedStyle.Render(t("settings_encryption.confirm_label") + "\n")) } else { b.WriteString(settingsBlurredStyle.Render(t("settings_encryption.confirm_label") + "\n")) } - b.WriteString(m.encConfirmInput.View() + "\n\n") + b.WriteString(m.encConfirmInput.View() + "\n") + if status := m.renderPasswordMatch(); status != "" { + b.WriteString(" " + status + "\n") + } + b.WriteString("\n") saveBtn := "[ " + t("settings_encryption.enable_button") + " ]" if m.encFocusIndex == 2 { @@ -165,3 +178,35 @@ func (m *Settings) viewEncryption() string { return b.String() } + +func (m *Settings) renderPasswordMatch() string { + password := m.encPasswordInput.Value() + confirm := m.encConfirmInput.Value() + if confirm == "" { + return "" + } + if password == confirm { + return successStyle.Render(t("settings_encryption.passwords_match")) + } + return dangerStyle.Render(t("settings_encryption.passwords_do_not_match")) +} + +func (m *Settings) handlePasswordChanged() { + password := m.encPasswordInput.Value() + if password == "" { + m.encPasswordStrength = "" + return + } + m.encPasswordStrength = m.passwordMeter.Strength(password) +} + +func (m *Settings) renderPasswordStrength() string { + switch m.encPasswordStrength { + case passwordstrength.Strong: + return successStyle.Render(t("settings_encryption.strength_label") + " " + t("settings_encryption.strength_strong")) + case passwordstrength.Medium: + return settingsFocusedStyle.Render(t("settings_encryption.strength_label") + " " + t("settings_encryption.strength_medium")) + default: + return dangerStyle.Render(t("settings_encryption.strength_label") + " " + t("settings_encryption.strength_weak")) + } +}