diff --git a/go.mod b/go.mod index 2646bce..40c3f04 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/rebelice/lazypg go 1.24.0 require ( + github.com/99designs/keyring v1.2.2 + github.com/alecthomas/chroma/v2 v2.20.0 github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 @@ -14,29 +16,30 @@ require ( github.com/mattn/go-runewidth v0.0.16 github.com/mattn/go-sqlite3 v1.14.32 github.com/spf13/viper v1.21.0 - github.com/zalando/go-keyring v0.2.6 gopkg.in/yaml.v3 v3.0.1 ) require ( - al.essio.dev/pkg/shellescape v1.5.1 // indirect - github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dvsekhvalnov/jose2go v1.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect + github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mtibben/percent v0.2.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect @@ -53,5 +56,6 @@ require ( golang.org/x/crypto v0.31.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.36.0 // indirect + golang.org/x/term v0.27.0 // indirect golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 12b662b..2259eb7 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,13 @@ -al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= -al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= +github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= +github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -27,6 +33,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM= +github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -35,14 +43,16 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -53,6 +63,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= @@ -67,12 +79,15 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= +github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -105,8 +120,6 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 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/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= -github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= @@ -119,9 +132,12 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/app.go b/internal/app/app.go index bff741b..10802bb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -98,6 +98,11 @@ type App struct { // Connection history connectionHistory *connection_history.Manager + // Password dialog for missing passwords + showPasswordDialog bool + passwordDialog *components.PasswordDialog + pendingConnectionInfo *models.ConnectionHistoryEntry + // Search input showSearch bool searchInput *components.SearchInput @@ -330,6 +335,7 @@ func New(cfg *config.Config) *App { favoritesManager: favoritesManager, favoritesDialog: favoritesDialog, connectionHistory: connectionHistory, + passwordDialog: components.NewPasswordDialog(th), showSearch: false, searchInput: searchInput, executeSpinner: s, @@ -588,6 +594,34 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.showError = false return a, nil + case components.PasswordSubmitMsg: + // User submitted password from password dialog + a.showPasswordDialog = false + if a.pendingConnectionInfo != nil { + // Create connection config with the entered password + config := a.pendingConnectionInfo.ToConnectionConfig() + config.Password = msg.Password + + // Try to save the password for future use + if a.connectionHistory != nil { + if err := a.connectionHistory.SavePassword(config.Host, config.Port, config.Database, config.User, config.Password); err != nil { + log.Printf("Warning: Failed to save password: %v", err) + } + } + + a.pendingConnectionInfo = nil + return a.performConnection(config) + } + return a, nil + + case components.PasswordCancelMsg: + // User cancelled password dialog + a.showPasswordDialog = false + a.pendingConnectionInfo = nil + // Re-show connection dialog + a.showConnectionDialog = true + return a, nil + case components.CloseCommandPaletteMsg: a.showCommandPalette = false return a, nil @@ -785,6 +819,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a.handleConnectionDialog(msg) } + // Handle password dialog if visible + if a.showPasswordDialog { + var cmd tea.Cmd + a.passwordDialog, cmd = a.passwordDialog.Update(msg) + return a, cmd + } + // Handle command palette if visible if a.showCommandPalette { return a.handleCommandPalette(msg) @@ -1726,6 +1767,11 @@ func (a *App) View() string { return zone.Scan(a.renderConnectionDialog()) } + // If password dialog is showing, render it + if a.showPasswordDialog { + return zone.Scan(a.renderPasswordDialog()) + } + // If in help mode, show help overlay if a.state.ViewMode == models.HelpMode { return help.Render(a.state.Width, a.state.Height, lipgloss.NewStyle()) @@ -2749,7 +2795,18 @@ func (a *App) connectToHistoryEntry(entry models.ConnectionHistoryEntry) (tea.Mo // Convert history entry to connection config WITH password from keyring if a.connectionHistory != nil { - config = a.connectionHistory.GetConnectionConfigWithPassword(&entry) + result := a.connectionHistory.GetConnectionConfigWithPassword(&entry) + config = result.Config + + // If password is missing, show password dialog + if result.PasswordMissing { + entryCopy := entry + a.pendingConnectionInfo = &entryCopy + a.passwordDialog.SetConnectionInfo(entry.Host, entry.Port, entry.Database, entry.User) + a.showPasswordDialog = true + a.showConnectionDialog = false + return a, a.passwordDialog.Init() + } } else { config = entry.ToConnectionConfig() } @@ -2799,9 +2856,13 @@ func (a *App) performConnection(config models.ConnectionConfig) (tea.Model, tea. // Save to connection history (ignore errors) if a.connectionHistory != nil { - if err := a.connectionHistory.Add(config); err != nil { + result, err := a.connectionHistory.Add(config) + if err != nil { log.Printf("Warning: Failed to save connection to history: %v", err) } else { + if result != nil && result.PasswordSaveError != nil { + log.Printf("Warning: Failed to save password: %v", result.PasswordSaveError) + } // Reload history in dialog history := a.connectionHistory.GetRecent(10) a.connectionDialog.SetHistoryEntries(history) @@ -2965,9 +3026,13 @@ func (a *App) handleConnectionDialog(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Save to connection history (ignore errors) if a.connectionHistory != nil { - if err := a.connectionHistory.Add(config); err != nil { + result, err := a.connectionHistory.Add(config) + if err != nil { log.Printf("Warning: Failed to save connection to history: %v", err) } else { + if result != nil && result.PasswordSaveError != nil { + log.Printf("Warning: Failed to save password: %v", result.PasswordSaveError) + } // Reload history in dialog history := a.connectionHistory.GetRecent(10) a.connectionDialog.SetHistoryEntries(history) @@ -2993,7 +3058,18 @@ func (a *App) handleConnectionDialog(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Convert history entry to connection config WITH password from keyring if a.connectionHistory != nil { - config = a.connectionHistory.GetConnectionConfigWithPassword(historyEntry) + result := a.connectionHistory.GetConnectionConfigWithPassword(historyEntry) + config = result.Config + + // If password is missing, show password dialog + if result.PasswordMissing { + entryCopy := *historyEntry + a.pendingConnectionInfo = &entryCopy + a.passwordDialog.SetConnectionInfo(historyEntry.Host, historyEntry.Port, historyEntry.Database, historyEntry.User) + a.showPasswordDialog = true + a.showConnectionDialog = false + return a, a.passwordDialog.Init() + } } else { config = historyEntry.ToConnectionConfig() } @@ -3045,9 +3121,13 @@ func (a *App) handleConnectionDialog(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Save to connection history (ignore errors) if a.connectionHistory != nil { - if err := a.connectionHistory.Add(config); err != nil { + result, err := a.connectionHistory.Add(config) + if err != nil { log.Printf("Warning: Failed to save connection to history: %v", err) } else { + if result != nil && result.PasswordSaveError != nil { + log.Printf("Warning: Failed to save password: %v", result.PasswordSaveError) + } // Reload history in dialog history := a.connectionHistory.GetRecent(10) a.connectionDialog.SetHistoryEntries(history) @@ -3323,6 +3403,33 @@ func (a *App) renderConnectionDialog() string { return style.Render(dialog) } +func (a *App) renderPasswordDialog() string { + // Center the dialog + dialogWidth := 50 + dialogHeight := 12 + + a.passwordDialog.Width = dialogWidth + a.passwordDialog.Height = dialogHeight + + dialog := a.passwordDialog.View() + + // Center it + verticalPadding := (a.state.Height - dialogHeight) / 2 + horizontalPadding := (a.state.Width - dialogWidth) / 2 + + if verticalPadding < 0 { + verticalPadding = 0 + } + if horizontalPadding < 0 { + horizontalPadding = 0 + } + + style := lipgloss.NewStyle(). + Padding(verticalPadding, 0, 0, horizontalPadding) + + return style.Render(dialog) +} + // triggerDiscovery runs discovery in the background and returns a command func (a *App) triggerDiscovery() tea.Cmd { return func() tea.Msg { diff --git a/internal/connection_history/errors.go b/internal/connection_history/errors.go new file mode 100644 index 0000000..4952185 --- /dev/null +++ b/internal/connection_history/errors.go @@ -0,0 +1,36 @@ +package connection_history + +import "errors" + +// ErrPasswordNotFound is returned when a password is not found in the keyring +var ErrPasswordNotFound = errors.New("password not found in keyring") + +// PasswordSaveError represents an error that occurred while saving a password +type PasswordSaveError struct { + Err error + Message string +} + +func (e *PasswordSaveError) Error() string { + if e.Message != "" { + return e.Message + ": " + e.Err.Error() + } + return e.Err.Error() +} + +func (e *PasswordSaveError) Unwrap() error { + return e.Err +} + +// PasswordReadError represents an error that occurred while reading a password +type PasswordReadError struct { + Err error +} + +func (e *PasswordReadError) Error() string { + return "failed to read password from keyring: " + e.Err.Error() +} + +func (e *PasswordReadError) Unwrap() error { + return e.Err +} diff --git a/internal/connection_history/machine_id.go b/internal/connection_history/machine_id.go new file mode 100644 index 0000000..cdc752b --- /dev/null +++ b/internal/connection_history/machine_id.go @@ -0,0 +1,115 @@ +package connection_history + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "os" + "os/exec" + "runtime" + "strings" +) + +const passwordSalt = "lazypg-keyring-salt-v1" + +// deriveFilePassword generates a machine-specific password for the file backend. +// This password is derived from machine ID and username, so it's consistent +// across app restarts but different on each machine. +func deriveFilePassword() (string, error) { + machineID, err := getMachineID() + if err != nil { + // Fallback to hostname if machine ID is unavailable + machineID, _ = os.Hostname() + } + + username := os.Getenv("USER") + if username == "" { + username = os.Getenv("USERNAME") // Windows + } + if username == "" { + // Fallback for containers/service accounts without USER env + username = fmt.Sprintf("uid-%d", os.Getuid()) + } + + // Combine machine ID, username, and salt to create a unique password + data := machineID + username + passwordSalt + hash := sha256.Sum256([]byte(data)) + + return base64.StdEncoding.EncodeToString(hash[:]), nil +} + +// getMachineID returns a unique identifier for the current machine. +func getMachineID() (string, error) { + switch runtime.GOOS { + case "linux": + return getLinuxMachineID() + case "darwin": + return getDarwinMachineID() + case "windows": + return getWindowsMachineID() + default: + hostname, err := os.Hostname() + return hostname, err + } +} + +// getLinuxMachineID reads the machine ID from /etc/machine-id or /var/lib/dbus/machine-id +func getLinuxMachineID() (string, error) { + paths := []string{ + "/etc/machine-id", + "/var/lib/dbus/machine-id", + } + + for _, path := range paths { + data, err := os.ReadFile(path) + if err == nil { + return strings.TrimSpace(string(data)), nil + } + } + + // Fallback to hostname + return os.Hostname() +} + +// getDarwinMachineID gets the hardware UUID on macOS +func getDarwinMachineID() (string, error) { + cmd := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice") + output, err := cmd.Output() + if err != nil { + return os.Hostname() + } + + // Parse IOPlatformUUID from output + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, "IOPlatformUUID") { + parts := strings.Split(line, "=") + if len(parts) == 2 { + uuid := strings.TrimSpace(parts[1]) + uuid = strings.Trim(uuid, "\"") + return uuid, nil + } + } + } + + return os.Hostname() +} + +// getWindowsMachineID gets the machine GUID on Windows +func getWindowsMachineID() (string, error) { + cmd := exec.Command("wmic", "csproduct", "get", "UUID") + output, err := cmd.Output() + if err != nil { + return os.Hostname() + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && line != "UUID" { + return line, nil + } + } + + return os.Hostname() +} diff --git a/internal/connection_history/manager.go b/internal/connection_history/manager.go index c249a4c..465cd12 100644 --- a/internal/connection_history/manager.go +++ b/internal/connection_history/manager.go @@ -1,7 +1,9 @@ package connection_history import ( + "errors" "fmt" + "log" "os" "path/filepath" "sort" @@ -12,9 +14,17 @@ import ( "gopkg.in/yaml.v3" ) +// ConnectionConfigResult contains the result of getting a connection config with password +type ConnectionConfigResult struct { + Config models.ConnectionConfig + PasswordMissing bool + Error error +} + // Manager manages connection history type Manager struct { path string + configDir string history []models.ConnectionHistoryEntry passwordStore *PasswordStore } @@ -24,9 +34,21 @@ func NewManager(configDir string) (*Manager, error) { path := filepath.Join(configDir, "connection_history.yaml") m := &Manager{ - path: path, - history: []models.ConnectionHistoryEntry{}, - passwordStore: NewPasswordStore(), + path: path, + configDir: configDir, + history: []models.ConnectionHistoryEntry{}, + } + + // Initialize password store + passwordStore, err := NewPasswordStore(configDir) + if err != nil { + // Log warning but continue without password storage + log.Printf("Warning: Failed to initialize password store: %v", err) + } else { + m.passwordStore = passwordStore + if passwordStore.IsUsingFallback() { + log.Printf("Info: Using encrypted file for password storage (system keyring unavailable)") + } } // Load existing history if file exists @@ -39,6 +61,15 @@ func NewManager(configDir string) (*Manager, error) { return m, nil } +// IsUsingFallbackStorage returns true if passwords are stored in encrypted files +// instead of the native OS keyring +func (m *Manager) IsUsingFallbackStorage() bool { + if m.passwordStore == nil { + return false + } + return m.passwordStore.IsUsingFallback() +} + // Load loads connection history from YAML file func (m *Manager) Load() error { data, err := os.ReadFile(m.path) @@ -73,13 +104,20 @@ func (m *Manager) Save() error { return nil } +// AddResult contains the result of adding a connection +type AddResult struct { + PasswordSaveError error +} + // Add adds or updates a connection in history -func (m *Manager) Add(config models.ConnectionConfig) error { +func (m *Manager) Add(config models.ConnectionConfig) (*AddResult, error) { + result := &AddResult{} + // Save password to secure keyring (if provided) if config.Password != "" && m.passwordStore != nil { if err := m.passwordStore.Save(config.Host, config.Port, config.Database, config.User, config.Password); err != nil { - // Log error but don't fail - password storage is optional - fmt.Printf("Warning: Failed to save password to keyring: %v\n", err) + // Store the error but don't fail - caller can decide how to handle + result.PasswordSaveError = err } } @@ -97,7 +135,7 @@ func (m *Manager) Add(config models.ConnectionConfig) error { if config.Name != "" { m.history[i].Name = config.Name } - return m.Save() + return result, m.Save() } } @@ -122,7 +160,7 @@ func (m *Manager) Add(config models.ConnectionConfig) error { m.history = append(m.history, entry) - return m.Save() + return result, m.Save() } // GetAll returns all connection history entries @@ -177,17 +215,50 @@ func (m *Manager) Delete(id string) error { return fmt.Errorf("connection history entry with ID '%s' not found", id) } -// GetConnectionConfigWithPassword returns a ConnectionConfig with password retrieved from keyring -func (m *Manager) GetConnectionConfigWithPassword(entry *models.ConnectionHistoryEntry) models.ConnectionConfig { +// GetConnectionConfigWithPassword returns a ConnectionConfig with password retrieved from keyring. +// If password retrieval fails, PasswordMissing will be true and the caller should prompt for password. +func (m *Manager) GetConnectionConfigWithPassword(entry *models.ConnectionHistoryEntry) ConnectionConfigResult { config := entry.ToConnectionConfig() - // Try to get password from keyring - if m.passwordStore != nil { - password, err := m.passwordStore.Get(entry.Host, entry.Port, entry.Database, entry.User) - if err == nil { - config.Password = password + if m.passwordStore == nil { + return ConnectionConfigResult{ + Config: config, + PasswordMissing: true, + Error: fmt.Errorf("password store not initialized"), } } - return config + password, err := m.passwordStore.Get(entry.Host, entry.Port, entry.Database, entry.User) + if err != nil { + if errors.Is(err, ErrPasswordNotFound) { + return ConnectionConfigResult{ + Config: config, + PasswordMissing: true, + } + } + return ConnectionConfigResult{ + Config: config, + PasswordMissing: true, + Error: err, + } + } + + // Empty password also means missing (user might need to enter it) + if password == "" { + return ConnectionConfigResult{ + Config: config, + PasswordMissing: true, + } + } + + config.Password = password + return ConnectionConfigResult{Config: config} +} + +// SavePassword saves a password for an existing connection +func (m *Manager) SavePassword(host string, port int, database, user, password string) error { + if m.passwordStore == nil { + return fmt.Errorf("password store not initialized") + } + return m.passwordStore.Save(host, port, database, user, password) } diff --git a/internal/connection_history/password_store.go b/internal/connection_history/password_store.go index a1d4c26..540d2c6 100644 --- a/internal/connection_history/password_store.go +++ b/internal/connection_history/password_store.go @@ -1,28 +1,101 @@ package connection_history import ( + "errors" "fmt" + "path/filepath" + "runtime" - "github.com/zalando/go-keyring" + "github.com/99designs/keyring" ) -const ( - serviceName = "lazypg" -) +const serviceName = "lazypg" -// PasswordStore handles secure password storage using OS keyring +// PasswordStore handles secure password storage using OS keyring with file fallback type PasswordStore struct { - service string + ring keyring.Keyring + usingFallback bool } -// NewPasswordStore creates a new password store -func NewPasswordStore() *PasswordStore { +// NewPasswordStore creates a new password store with platform-appropriate backends +func NewPasswordStore(configDir string) (*PasswordStore, error) { + backends := getBackendsForPlatform() + fileDir := filepath.Join(configDir, "keyring") + + ring, err := keyring.Open(keyring.Config{ + ServiceName: serviceName, + AllowedBackends: backends, + // File backend configuration + FileDir: fileDir, + FilePasswordFunc: func(_ string) (string, error) { + return deriveFilePassword() + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to open keyring: %w", err) + } + + // Check if we're using the file backend (fallback) + usingFallback := isUsingFallback(backends, ring) + return &PasswordStore{ - service: serviceName, + ring: ring, + usingFallback: usingFallback, + }, nil +} + +// getBackendsForPlatform returns the appropriate backend priority for the current OS +func getBackendsForPlatform() []keyring.BackendType { + switch runtime.GOOS { + case "darwin": + return []keyring.BackendType{ + keyring.KeychainBackend, + keyring.FileBackend, + } + case "linux": + return []keyring.BackendType{ + keyring.SecretServiceBackend, + keyring.KWalletBackend, + keyring.FileBackend, + } + case "windows": + return []keyring.BackendType{ + keyring.WinCredBackend, + keyring.FileBackend, + } + default: + return []keyring.BackendType{ + keyring.FileBackend, + } } } -// Save stores a password securely in the OS keyring +// isUsingFallback checks if the opened keyring is using the file backend +func isUsingFallback(requestedBackends []keyring.BackendType, ring keyring.Keyring) bool { + // If file backend is the only option, we're using fallback + if len(requestedBackends) == 1 && requestedBackends[0] == keyring.FileBackend { + return true + } + + // Try to detect by checking available backends + availableBackends := keyring.AvailableBackends() + for _, b := range availableBackends { + if b != keyring.FileBackend { + // A native backend is available, likely not using fallback + return false + } + } + + return true +} + +// IsUsingFallback returns true if the password store is using the file backend +// instead of the native OS keyring +func (ps *PasswordStore) IsUsingFallback() bool { + return ps.usingFallback +} + +// Save stores a password securely in the keyring // key format: "host:port:database:user" for uniqueness func (ps *PasswordStore) Save(host string, port int, database, user, password string) error { if password == "" { @@ -31,28 +104,39 @@ func (ps *PasswordStore) Save(host string, port int, database, user, password st } key := makeKey(host, port, database, user) - return keyring.Set(ps.service, key, password) + err := ps.ring.Set(keyring.Item{ + Key: key, + Data: []byte(password), + Label: fmt.Sprintf("lazypg: %s@%s:%d/%s", user, host, port, database), + Description: "PostgreSQL connection password for lazypg", + }) + if err != nil { + return &PasswordSaveError{ + Err: err, + Message: "failed to save password to keyring", + } + } + return nil } -// Get retrieves a password from the OS keyring +// Get retrieves a password from the keyring func (ps *PasswordStore) Get(host string, port int, database, user string) (string, error) { key := makeKey(host, port, database, user) - password, err := keyring.Get(ps.service, key) + item, err := ps.ring.Get(key) if err != nil { - // Password not found is not an error, just return empty - if err == keyring.ErrNotFound { - return "", nil + if errors.Is(err, keyring.ErrKeyNotFound) { + return "", ErrPasswordNotFound } - return "", fmt.Errorf("failed to get password from keyring: %w", err) + return "", &PasswordReadError{Err: err} } - return password, nil + return string(item.Data), nil } -// Delete removes a password from the OS keyring +// Delete removes a password from the keyring func (ps *PasswordStore) Delete(host string, port int, database, user string) error { key := makeKey(host, port, database, user) - err := keyring.Delete(ps.service, key) - if err != nil && err != keyring.ErrNotFound { + err := ps.ring.Remove(key) + if err != nil && !errors.Is(err, keyring.ErrKeyNotFound) { return fmt.Errorf("failed to delete password from keyring: %w", err) } return nil diff --git a/internal/ui/components/password_dialog.go b/internal/ui/components/password_dialog.go new file mode 100644 index 0000000..cacb2e6 --- /dev/null +++ b/internal/ui/components/password_dialog.go @@ -0,0 +1,197 @@ +package components + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + zone "github.com/lrstanley/bubblezone" + "github.com/rebelice/lazypg/internal/ui/theme" +) + +// Zone IDs for password dialog +const ( + ZonePasswordSubmit = "password-submit" + ZonePasswordCancel = "password-cancel" +) + +// PasswordSubmitMsg is sent when password is submitted +type PasswordSubmitMsg struct { + Password string +} + +// PasswordCancelMsg is sent when password dialog is cancelled +type PasswordCancelMsg struct{} + +// PasswordDialog represents a password input dialog +type PasswordDialog struct { + Title string + Description string + Width int + Height int + Theme theme.Theme + + input textinput.Model + host string + port int + database string + user string +} + +// NewPasswordDialog creates a new password dialog +func NewPasswordDialog(th theme.Theme) *PasswordDialog { + input := textinput.New() + input.Placeholder = "Enter password" + input.EchoMode = textinput.EchoPassword + input.EchoCharacter = '•' + input.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#cba6f7")) + input.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#cdd6f4")) + input.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")) + input.CharLimit = 256 + input.Width = 40 + input.Focus() + + return &PasswordDialog{ + Theme: th, + Width: 50, + Height: 12, + input: input, + } +} + +// SetConnectionInfo sets the connection info to display +func (p *PasswordDialog) SetConnectionInfo(host string, port int, database, user string) { + p.host = host + p.port = port + p.database = database + p.user = user + p.Title = "Password Required" + p.Description = fmt.Sprintf("Enter password for %s@%s:%d/%s", user, host, port, database) + p.input.SetValue("") + p.input.Focus() +} + +// Init initializes the password dialog +func (p *PasswordDialog) Init() tea.Cmd { + return textinput.Blink +} + +// Update handles messages +func (p *PasswordDialog) Update(msg tea.Msg) (*PasswordDialog, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + return p, func() tea.Msg { + return PasswordSubmitMsg{Password: p.input.Value()} + } + case "esc": + return p, func() tea.Msg { + return PasswordCancelMsg{} + } + } + } + + p.input, cmd = p.input.Update(msg) + return p, cmd +} + +// View renders the password dialog +func (p *PasswordDialog) View() string { + if p.Width <= 0 || p.Height <= 0 { + return "" + } + + // Title style + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(p.Theme.Info). + Padding(0, 1) + + // Description style + descStyle := lipgloss.NewStyle(). + Foreground(p.Theme.Foreground). + Faint(true). + Padding(0, 1) + + // Label style + labelStyle := lipgloss.NewStyle(). + Foreground(p.Theme.Info). + Padding(0, 1) + + // Footer style + footerStyle := lipgloss.NewStyle(). + Faint(true). + Foreground(p.Theme.Foreground). + Padding(0, 1) + + // Build content + var content strings.Builder + + // Title + content.WriteString(titleStyle.Render(p.Title)) + content.WriteString("\n\n") + + // Description + content.WriteString(descStyle.Render(p.Description)) + content.WriteString("\n\n") + + // Password input + content.WriteString(labelStyle.Render("Password:")) + content.WriteString("\n") + content.WriteString(" ") + content.WriteString(p.input.View()) + content.WriteString("\n\n") + + // Footer with buttons + submitBtn := zone.Mark(ZonePasswordSubmit, footerStyle.Render("[Enter] Submit")) + cancelBtn := zone.Mark(ZonePasswordCancel, footerStyle.Render("[Esc] Cancel")) + content.WriteString(submitBtn) + content.WriteString(" ") + content.WriteString(cancelBtn) + + // Box style + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(p.Theme.Info). + Padding(1, 2). + MaxWidth(p.Width). + Background(p.Theme.Background) + + return boxStyle.Render(content.String()) +} + +// HandleMouseClick handles mouse click events +func (p *PasswordDialog) HandleMouseClick(msg tea.MouseMsg) (handled bool, cmd tea.Cmd) { + if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionPress { + return false, nil + } + + if zone.Get(ZonePasswordSubmit).InBounds(msg) { + return true, func() tea.Msg { + return PasswordSubmitMsg{Password: p.input.Value()} + } + } + + if zone.Get(ZonePasswordCancel).InBounds(msg) { + return true, func() tea.Msg { + return PasswordCancelMsg{} + } + } + + return false, nil +} + +// GetPassword returns the entered password +func (p *PasswordDialog) GetPassword() string { + return p.input.Value() +} + +// GetConnectionInfo returns the connection info +func (p *PasswordDialog) GetConnectionInfo() (host string, port int, database, user string) { + return p.host, p.port, p.database, p.user +}