feat(settings): add password meter (#1299)

FromSi and Andriy Chernov created

## 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 <andriy@floatpane.com>

Change summary

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(-)

Detailed changes

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

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=

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: حفظ"

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"

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"

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"

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"

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: 保存"

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"

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"

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: сохранить"

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: зберегти"

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: 保存"

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
+	}
+}

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
+}

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

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"))
+	}
+}