diff --git a/main.go b/main.go index 21ac8c1..a2ade7d 100644 --- a/main.go +++ b/main.go @@ -1339,6 +1339,28 @@ func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) { return recentRemoteRecord{}, false, time.Time{} } +func (u *ui) currentRemoteRecord() recentRemoteRecord { + return recentRemoteRecord{ + BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), + Path: strings.TrimSpace(u.remotePath.Text()), + Username: strings.TrimSpace(u.remoteUsername.Text()), + Password: u.remotePassword.Text(), + } +} + +func (u *ui) selectedRecentRemoteRecord() (recentRemoteRecord, bool) { + record := u.currentRemoteRecord() + if record.BaseURL == "" || record.Path == "" { + return recentRemoteRecord{}, false + } + for _, existing := range u.recentRemotes { + if existing.BaseURL == record.BaseURL && existing.Path == record.Path { + return existing, true + } + } + return recentRemoteRecord{}, false +} + func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) { u.remoteBaseURL.SetText(record.BaseURL) u.remotePath.SetText(record.Path) @@ -1348,6 +1370,20 @@ func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) { u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != "" } +func (u *ui) remoteAuthStatusMessage() string { + selected, hasSelected := u.selectedRecentRemoteRecord() + switch { + case !u.rememberRemoteAuth.Value: + return "Only the location will be saved in Recent Connections." + case hasSelected && (strings.TrimSpace(selected.Username) != "" || selected.Password != ""): + return "Saved sign-in will be updated for this connection." + case strings.TrimSpace(u.remoteUsername.Text()) != "" || u.remotePassword.Text() != "": + return "This sign-in will be saved in Recent Connections after a successful open." + default: + return "Enter a username or password to save sign-in details for this connection." + } +} + func (u *ui) noteCurrentRemotePath() { status, ok := u.state.Session.(sessionStatus) if !ok || !status.IsRemote() || status.IsLocked() { @@ -1715,6 +1751,21 @@ func (u *ui) describeActionError(label string, err error) string { return err.Error() } +func (u *ui) remoteOpenRetryAvailable() bool { + return u.lifecycleMode == "remote" && strings.HasPrefix(strings.TrimSpace(u.state.ErrorMessage), "open remote vault failed:") +} + +func (u *ui) remoteOpenButtonLabel() string { + switch { + case u.lifecycleBusy(): + return "Opening Remote Vault..." + case u.remoteOpenRetryAvailable(): + return "Retry Remote Vault" + default: + return "Open Remote Vault" + } +} + func (u *ui) bannerSurface() uiBanner { switch { case strings.TrimSpace(u.loadingMessage) != "": @@ -2197,6 +2248,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.remotePath.SetText("") u.remoteUsername.SetText("") u.remotePassword.SetText("") + u.rememberRemoteAuth.Value = false u.state.ErrorMessage = "" u.state.StatusMessage = "" } diff --git a/main_test.go b/main_test.go index d796239..4062805 100644 --- a/main_test.go +++ b/main_test.go @@ -3017,12 +3017,115 @@ func TestUILoadingDetailMessageUsesSelectedRemote(t *testing.T) { u.loadingMessage = "Open remote vault..." got := u.loadingDetailMessage() - want := "Target: dav.example.com · vaults/home.kdbx (vaults/home.kdbx)" + want := "Target: home.kdbx · dav.example.com (vaults/home.kdbx)" if got != want { t.Fatalf("loadingDetailMessage() = %q, want %q", got, want) } } +func TestFriendlyRecentRemoteLabelUsesVaultNameBeforeHost(t *testing.T) { + t.Parallel() + + got := friendlyRecentRemoteLabel(recentRemoteRecord{ + BaseURL: "https://dav.example.com/remote.php/webdav/", + Path: "vaults/family/home.kdbx", + }) + want := "home.kdbx · dav.example.com" + if got != want { + t.Fatalf("friendlyRecentRemoteLabel() = %q, want %q", got, want) + } +} + +func TestRecentRemoteStoredAuthSummaryDescribesSavedCredentialState(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + record recentRemoteRecord + want string + }{ + { + name: "location_only", + record: recentRemoteRecord{}, + want: "location only", + }, + { + name: "username_only", + record: recentRemoteRecord{Username: "alice"}, + want: "saved username", + }, + { + name: "password_only", + record: recentRemoteRecord{Password: "token-1"}, + want: "saved password", + }, + { + name: "full_sign_in", + record: recentRemoteRecord{Username: "alice", Password: "token-1"}, + want: "saved username and password", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := recentRemoteStoredAuthSummary(tt.record); got != tt.want { + t.Fatalf("recentRemoteStoredAuthSummary(%+v) = %q, want %q", tt.record, got, tt.want) + } + }) + } +} + +func TestUIRemoteAuthStatusMessageExplainsWhatWillBeRemembered(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.remoteBaseURL.SetText("https://dav.example.com") + u.remotePath.SetText("vaults/home.kdbx") + + if got := u.remoteAuthStatusMessage(); got != "Only the location will be saved in Recent Connections." { + t.Fatalf("remoteAuthStatusMessage() = %q, want location-only guidance", got) + } + + u.rememberRemoteAuth.Value = true + if got := u.remoteAuthStatusMessage(); got != "Enter a username or password to save sign-in details for this connection." { + t.Fatalf("remoteAuthStatusMessage() = %q, want empty-sign-in guidance", got) + } + + u.remoteUsername.SetText("alice") + if got := u.remoteAuthStatusMessage(); got != "This sign-in will be saved in Recent Connections after a successful open." { + t.Fatalf("remoteAuthStatusMessage() = %q, want pending-save guidance", got) + } + + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.com", + Path: "vaults/home.kdbx", + Username: "alice", + Password: "secret-1", + }} + if got := u.remoteAuthStatusMessage(); got != "Saved sign-in will be updated for this connection." { + t.Fatalf("remoteAuthStatusMessage() = %q, want saved-sign-in guidance", got) + } +} + +func TestUIRemoteOpenButtonLabelOffersRetryAfterFailure(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + + if got := u.remoteOpenButtonLabel(); got != "Open Remote Vault" { + t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open Remote Vault") + } + + u.state.ErrorMessage = "open remote vault failed: dial tcp timeout" + if got := u.remoteOpenButtonLabel(); got != "Retry Remote Vault" { + t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Remote Vault") + } +} + func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) { t.Parallel() diff --git a/ui_forms.go b/ui_forms.go index 0832631..caf10c9 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -4,6 +4,7 @@ import ( "fmt" "image" "image/color" + "net/url" "path/filepath" "strings" @@ -66,28 +67,12 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)), - layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "AUTHENTICATION") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx) - }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if strings.TrimSpace(u.remoteBaseURL.Text()) == "" || strings.TrimSpace(u.remotePath.Text()) == "" { return layout.Dimensions{} } - record := recentRemoteRecord{ - BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()), - Path: strings.TrimSpace(u.remotePath.Text()), - } + record := u.currentRemoteRecord() lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path) return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { @@ -103,16 +88,22 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { lbl.Color = accentColor return lbl.Layout(gtx) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy { - return layout.Dimensions{} - } - return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Change...") - }), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), record.BaseURL) + lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), "Server: "+strings.TrimSpace(record.BaseURL)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(recentRemoteRecord{ + Username: strings.TrimSpace(u.remoteUsername.Text()), + Password: u.remotePassword.Text(), + })) lbl.Color = mutedColor return lbl.Layout(gtx) }), @@ -124,16 +115,17 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { lbl.Color = mutedColor return lbl.Layout(gtx) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy { + return layout.Dimensions{} + } + return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Change...") + }), ) }) }) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember username and password") - box.Color = accentColor - return box.Layout(gtx) - }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if busy { @@ -141,6 +133,31 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { } return u.recentRemoteList(gtx) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "AUTHENTICATION") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember sign-in on this device") + box.Color = accentColor + return box.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), u.remoteAuthStatusMessage()) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), ) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, @@ -232,10 +249,7 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.lifecycleMode == "remote" { - label := "Open Remote Vault" - if busy { - label = "Opening Remote Vault..." - } + label := u.remoteOpenButtonLabel() if busy { return passiveTonedButton(gtx, u.theme, label) } @@ -396,7 +410,17 @@ func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), strings.TrimSpace(record.BaseURL)) + lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), "Server: "+normalizedRemoteHost(record.BaseURL)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(record)) lbl.Color = mutedColor return lbl.Layout(gtx) }), @@ -469,15 +493,41 @@ func friendlyRecentRemoteLabel(record recentRemoteRecord) string { if baseURL == "" && path == "" { return "" } - host := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://")) - host = strings.TrimSuffix(host, "/") + host := normalizedRemoteHost(baseURL) + name := friendlyRecentVaultLabel(path) switch { - case host == "": - return path - case path == "": + case name != "" && host != "": + return name + " · " + host + case name != "": + return name + case host != "": return host default: - return host + " · " + path + return path + } +} + +func normalizedRemoteHost(baseURL string) string { + baseURL = strings.TrimSpace(baseURL) + if parsed, err := url.Parse(baseURL); err == nil && strings.TrimSpace(parsed.Host) != "" { + return strings.TrimSpace(parsed.Host) + } + host := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://")) + return strings.TrimSuffix(host, "/") +} + +func recentRemoteStoredAuthSummary(record recentRemoteRecord) string { + username := strings.TrimSpace(record.Username) + hasPassword := record.Password != "" + switch { + case username != "" && hasPassword: + return "saved username and password" + case username != "": + return "saved username" + case hasPassword: + return "saved password" + default: + return "location only" } }