Compare commits

..

2 Commits

Author SHA1 Message Date
Joe Julian 72006aa4b1 Allow explicit browser search fill
ci / lint-test (pull_request) Successful in 6m13s
ci / build (pull_request) Successful in 6m8s
2026-04-28 21:15:15 -07:00
Joe Julian e171f49287 Document simplifying refactor preference 2026-04-28 21:10:44 -07:00
12 changed files with 121 additions and 307 deletions
+4
View File
@@ -116,6 +116,10 @@ These features are product requirements, not “nice to have” ideas.
- UI state should not be the source of truth for vault structure or search behavior.
- Domain packages must be test-driven where practical.
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
- Prefer simplifying refactors that extract shared behavior into smaller named
functions. When a new path needs most of an existing function, factor the
common behavior out and let the specific functions call it instead of adding
flags or branches that make the original function larger.
- Provide a secure gRPC API as a first-class programmatic surface, not as a thin wrapper around UI state.
- Design browser-extension and automation integrations against the gRPC API, not against ad hoc local protocols.
- Treat the vault model as local-first across all platforms:
+42 -8
View File
@@ -700,7 +700,34 @@ async function statusForPage(options = {}) {
return refreshPageState(page.tabId, page.url, options);
}
async function fillLogin(tabId, entryId) {
function matchedLoginCredentialRequest(settings, entryId, pageUrl) {
return {
action: "get-login",
bearerToken: settings.bearerToken,
entryId,
url: pageUrl
};
}
function selectedLoginCredentialRequest(settings, entryId) {
return {
action: "get-login",
bearerToken: settings.bearerToken,
entryId
};
}
async function fillMatchedLogin(tabId, entryId) {
const page = await loginFillPage(tabId);
return fillLoginOnPage(tabId, entryId, page.url, matchedLoginCredentialRequest);
}
async function fillSelectedLogin(tabId, entryId) {
const page = await loginFillPage(tabId);
return fillLoginOnPage(tabId, entryId, page.url, selectedLoginCredentialRequest);
}
async function loginFillPage(tabId) {
if (!Number.isInteger(tabId)) {
throw new Error("No active tab is available.");
}
@@ -709,7 +736,10 @@ async function fillLogin(tabId, entryId) {
if (!supportsPageStateURL(pageUrl)) {
throw new Error("This page cannot be filled.");
}
return { url: pageUrl };
}
async function fillLoginOnPage(tabId, entryId, pageUrl, credentialRequest) {
let state = await getPageState(tabId, pageUrl);
state = await setPageState(tabId, {
...state,
@@ -729,12 +759,7 @@ async function fillLogin(tabId, entryId) {
throw new Error("API token is not configured.");
}
const response = await connectNative({
action: "get-login",
bearerToken: settings.bearerToken,
entryId,
url: pageUrl
});
const response = await connectNative(credentialRequest(settings, entryId, pageUrl));
if (!response?.success || !response.credential) {
throw new Error(response?.error || "KeePassGO did not return a credential.");
}
@@ -846,6 +871,8 @@ const backgroundTestExports = {
shouldContinueWatchingState,
tokenPendingApprovalCount,
savePlanForObservedLogin,
matchedLoginCredentialRequest,
selectedLoginCredentialRequest,
defaultSettings
};
@@ -872,7 +899,14 @@ if (isNodeTestEnv) {
focusTarget: cloneTarget(message.target)
});
}
sendResponse({ success: true, ...(await fillLogin(targetTabID, message.entryId)) });
sendResponse({ success: true, ...(await fillMatchedLogin(targetTabID, message.entryId)) });
return;
}
case "keepassgo-fill-selected-entry": {
const targetTabID = Number.isInteger(message?.tabId)
? message.tabId
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
sendResponse({ success: true, ...(await fillSelectedLogin(targetTabID, message.entryId)) });
return;
}
case "keepassgo-load-settings":
+21
View File
@@ -149,3 +149,24 @@ test("applyBestMatchOnly preserves all matches when disabled", () => {
assert.deepEqual(filtered.map((match) => match.id), ["livingston", "rusty"]);
});
test("matched login credential requests include the page URL for URL validation", () => {
assert.deepEqual(background.matchedLoginCredentialRequest({
bearerToken: "token-1"
}, "vault-console", "https://bellagio.example.invalid/login"), {
action: "get-login",
bearerToken: "token-1",
entryId: "vault-console",
url: "https://bellagio.example.invalid/login"
});
});
test("explicit selected credential requests omit the page URL", () => {
assert.deepEqual(background.selectedLoginCredentialRequest({
bearerToken: "token-1"
}, "no-url-entry"), {
action: "get-login",
bearerToken: "token-1",
entryId: "no-url-entry"
});
});
+15 -2
View File
@@ -97,7 +97,7 @@ function renderMatchList(root, matches, options = {}) {
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
}
} catch (error) {
setStatus(options.onSelect ? "Save failed" : "Fill failed", error instanceof Error ? error.message : String(error), "error");
setStatus(options.errorTitle || (options.onSelect ? "Save failed" : "Fill failed"), error instanceof Error ? error.message : String(error), "error");
} finally {
row.disabled = false;
}
@@ -147,7 +147,20 @@ function renderSearchResults(results, query) {
return;
}
renderMatchList(root, results, {
emptyMessage: `No entries matched "${query}".`
emptyMessage: `No entries matched "${query}".`,
errorTitle: "Fill failed",
onSelect: async (match, targetTabID) => {
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
const result = await runtimeSend({
type: "keepassgo-fill-selected-entry",
entryId: match.id,
tabId: targetTabID
});
if (!result?.success) {
throw new Error(result?.error || "Fill failed.");
}
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
}
});
}
+4
View File
@@ -394,6 +394,10 @@ func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetB
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
}
}
return s.browserCredential(ctx, token, entry)
}
func (s *Server) browserCredential(ctx context.Context, token apitokens.Token, entry vault.Entry) (*keepassgov1.GetBrowserCredentialResponse, error) {
if strings.TrimSpace(entry.Username) != "" {
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
return nil, err
+34
View File
@@ -693,6 +693,40 @@ func TestVaultServiceGetsBrowserCredentialForAuthorizedClients(t *testing.T) {
}
}
func TestVaultServiceGetsExplicitBrowserCredentialWithoutURLMatch(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "no-url-entry",
Title: "Livingston Console",
Username: "livingstondell",
Password: "demo-loop",
Path: []string{"Root", "Heist Crew"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "no-url-entry", Path: []string{"Root", "Heist Crew"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "no-url-entry", Path: []string{"Root", "Heist Crew"}}},
),
},
})
defer cleanup()
resp, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
Id: "no-url-entry",
})
if err != nil {
t.Fatalf("GetBrowserCredential(no-url-entry without page URL) error = %v", err)
}
if resp.GetId() != "no-url-entry" {
t.Fatalf("GetBrowserCredential(no-url-entry without page URL).Id = %q, want no-url-entry", resp.GetId())
}
if resp.GetPassword() != "demo-loop" {
t.Fatalf("GetBrowserCredential(no-url-entry without page URL).Password = %q, want demo-loop", resp.GetPassword())
}
}
func TestVaultServiceRejectsUnauthorizedBrowserCredentialAccess(t *testing.T) {
t.Parallel()
+1 -127
View File
@@ -249,7 +249,6 @@ type ui struct {
entryFields widget.Editor
customFieldKeys []widget.Editor
customFieldValues []widget.Editor
copyCustomFields []widget.Clickable
historyIndex widget.Editor
groupName widget.Editor
groupParentPath widget.Editor
@@ -403,8 +402,6 @@ type ui struct {
vaultRemoteCredentialClicks []widget.Clickable
syncRemoteCredentialClicks []widget.Clickable
removeCustomFields []widget.Clickable
toggleCustomFields []widget.Clickable
revealedCustomFields map[string]bool
state appstate.State
visible []entry
currentPath []string
@@ -665,7 +662,6 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
pendingSharedLookupPath: paths.PendingSharedLookupPath,
recentVaultGroups: map[string][]string{},
recentVaultUsedAt: map[string]time.Time{},
revealedCustomFields: map[string]bool{},
lifecycleAdvancedHidden: true,
historyHidden: true,
statusBannerTTL: statusBannerDuration,
@@ -944,7 +940,6 @@ func (u *ui) handlePhoneBack() bool {
func (u *ui) resetPasswordPeek() {
u.showPassword = false
u.revealedCustomFields = map[string]bool{}
}
func (u *ui) childGroups() []string {
@@ -2158,12 +2153,6 @@ type detailViewMetrics struct {
cardGap unit.Dp
}
type extraStringView struct {
Key string
Value string
Revealed bool
}
func (u *ui) detailViewContent(gtx layout.Context, item entry) layout.Dimensions {
rows := u.detailViewRows(item)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
@@ -2191,8 +2180,6 @@ func (u *ui) detailViewRows(item entry) []layout.Widget {
layout.Spacer{Height: unit.Dp(8)}.Layout,
u.detailNotesCard(item),
layout.Spacer{Height: metrics.cardGap}.Layout,
u.detailExtraStringsCard,
layout.Spacer{Height: metrics.cardGap}.Layout,
u.attachmentSummaryPanel,
layout.Spacer{Height: metrics.cardGap}.Layout,
u.historyPanel,
@@ -2352,115 +2339,6 @@ func (u *ui) detailNotesCard(item entry) layout.Widget {
}
}
func (u *ui) detailExtraStringsCard(gtx layout.Context) layout.Dimensions {
fields := u.detailExtraStrings()
u.ensureExtraStringClickables(len(fields))
if len(fields) == 0 {
return layout.Dimensions{}
}
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
children := []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "EXTRA STRINGS")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
}
for i, field := range fields {
index := i
item := field
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.detailExtraStringRow(gtx, index, item)
}))
if i < len(fields)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
}
func (u *ui) detailExtraStringRow(gtx layout.Context, index int, field extraStringView) layout.Dimensions {
for u.toggleCustomFields[index].Clicked(gtx) {
u.toggleExtraStringReveal(field.Key)
}
for u.copyCustomFields[index].Clicked(gtx) {
key := field.Key
u.runAction("copy extra string", func() error { return u.copySelectedCustomFieldAction(key) })
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(field.Key))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(15), field.Value)
return lbl.Layout(gtx)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.inlinePasswordToggle(gtx, &u.toggleCustomFields[index], field.Revealed)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.copyCustomFields[index], u.copyIcon, "Copy extra string")
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
btn.Color = accentColor
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
return btn.Layout(gtx)
}),
)
}
func (u *ui) detailExtraStrings() []extraStringView {
item, ok := u.selectedEntry()
if !ok || len(item.Fields) == 0 {
return nil
}
keys := make([]string, 0, len(item.Fields))
for key := range item.Fields {
keys = append(keys, key)
}
slices.Sort(keys)
out := make([]extraStringView, 0, len(keys))
for _, key := range keys {
value := item.Fields[key]
revealed := u.revealedCustomFields[key]
if !revealed {
value = maskedSecretValue(value)
}
out = append(out, extraStringView{Key: key, Value: value, Revealed: revealed})
}
return out
}
func (u *ui) toggleExtraStringReveal(key string) {
if u.revealedCustomFields == nil {
u.revealedCustomFields = map[string]bool{}
}
u.revealedCustomFields[key] = !u.revealedCustomFields[key]
}
func (u *ui) ensureExtraStringClickables(count int) {
if len(u.copyCustomFields) < count {
clicks := make([]widget.Clickable, count)
copy(clicks, u.copyCustomFields)
u.copyCustomFields = clicks
}
if len(u.toggleCustomFields) < count {
clicks := make([]widget.Clickable, count)
copy(clicks, u.toggleCustomFields)
u.toggleCustomFields = clicks
}
}
func (u *ui) detailActionRow(gtx layout.Context) layout.Dimensions {
switch u.state.Section {
case appstate.SectionTemplates:
@@ -3118,11 +2996,7 @@ func (u *ui) detailPasswordValue() string {
if u.showPassword {
return item.Password
}
return maskedSecretValue(item.Password)
}
func maskedSecretValue(value string) string {
return strings.Repeat("•", max(8, len(value)))
return strings.Repeat("•", max(8, len(item.Password)))
}
func card(gtx layout.Context, w layout.Widget) layout.Dimensions {
-44
View File
@@ -82,8 +82,6 @@ func (u *ui) setCustomFieldRows(fields map[string]string) {
u.customFieldKeys = nil
u.customFieldValues = nil
u.removeCustomFields = nil
u.copyCustomFields = nil
u.toggleCustomFields = nil
if len(fields) == 0 {
u.appendCustomFieldRow("", "")
return
@@ -106,53 +104,21 @@ func (u *ui) appendCustomFieldRow(key, value string) {
u.customFieldKeys = append(u.customFieldKeys, keyEditor)
u.customFieldValues = append(u.customFieldValues, valueEditor)
u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{})
u.copyCustomFields = append(u.copyCustomFields, widget.Clickable{})
u.toggleCustomFields = append(u.toggleCustomFields, widget.Clickable{})
}
func (u *ui) removeCustomFieldRow(index int) {
u.ensureCustomFieldRowControls()
if index < 0 || index >= len(u.customFieldKeys) {
return
}
u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...)
u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...)
u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[index+1:]...)
u.copyCustomFields = append(u.copyCustomFields[:index], u.copyCustomFields[index+1:]...)
u.toggleCustomFields = append(u.toggleCustomFields[:index], u.toggleCustomFields[index+1:]...)
if len(u.customFieldKeys) == 0 {
u.appendCustomFieldRow("", "")
}
}
func (u *ui) ensureCustomFieldRowControls() {
if len(u.customFieldValues) < len(u.customFieldKeys) {
values := make([]widget.Editor, len(u.customFieldKeys))
copy(values, u.customFieldValues)
for i := len(u.customFieldValues); i < len(values); i++ {
values[i] = widget.Editor{SingleLine: true, Submit: false}
}
u.customFieldValues = values
}
if len(u.removeCustomFields) < len(u.customFieldKeys) {
clicks := make([]widget.Clickable, len(u.customFieldKeys))
copy(clicks, u.removeCustomFields)
u.removeCustomFields = clicks
}
if len(u.copyCustomFields) < len(u.customFieldKeys) {
clicks := make([]widget.Clickable, len(u.customFieldKeys))
copy(clicks, u.copyCustomFields)
u.copyCustomFields = clicks
}
if len(u.toggleCustomFields) < len(u.customFieldKeys) {
clicks := make([]widget.Clickable, len(u.customFieldKeys))
copy(clicks, u.toggleCustomFields)
u.toggleCustomFields = clicks
}
}
func (u *ui) currentCustomFields() (map[string]string, error) {
u.ensureCustomFieldRowControls()
fields := map[string]string{}
for i := range u.customFieldKeys {
key := strings.TrimSpace(u.customFieldKeys[i].Text())
@@ -433,16 +399,6 @@ func (u *ui) copySelectedFieldAction(target clipboard.Target) error {
return service.Copy(model, u.state.SelectedEntryID, target)
}
func (u *ui) copySelectedCustomFieldAction(key string) error {
model, err := u.state.Session.Current()
if err != nil {
return err
}
service := clipboard.Service{Writer: u.clipboardWriter}
return service.CopyCustomField(model, u.state.SelectedEntryID, key)
}
func (u *ui) generatePasswordAction() error {
profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text())
if err != nil {
-1
View File
@@ -667,7 +667,6 @@ func (u *ui) customFieldEditorPanel(gtx layout.Context) layout.Dimensions {
if len(u.customFieldKeys) == 0 {
u.setCustomFieldRows(nil)
}
u.ensureCustomFieldRowControls()
return sectionCard(gtx, u.theme, "CUSTOM FIELDS", "Add key/value pairs. Changes are only saved when you save the entry.", func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
-85
View File
@@ -3830,21 +3830,6 @@ func TestUILoadSelectedEntryIntoEditorPopulatesStructuredCustomFields(t *testing
}
}
func TestUIRemoveCustomFieldRowToleratesMissingClickables(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
u.customFieldKeys = []widget.Editor{{SingleLine: true}}
u.customFieldValues = []widget.Editor{{SingleLine: true}}
u.removeCustomFields = nil
u.removeCustomFieldRow(0)
if len(u.customFieldKeys) != 1 || len(u.customFieldValues) != 1 || len(u.removeCustomFields) != 1 {
t.Fatalf("custom field rows after remove with missing clickables = %d/%d/%d, want one blank row", len(u.customFieldKeys), len(u.customFieldValues), len(u.removeCustomFields))
}
}
func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) {
t.Parallel()
@@ -9141,76 +9126,6 @@ func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) {
}
}
func TestUIExtraStringValuesAreMaskedUntilRevealed(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Path: []string{"Root", "Internet"},
Fields: map[string]string{
"OTPSeed": "green-light",
},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
fields := u.detailExtraStrings()
if len(fields) != 1 {
t.Fatalf("len(detailExtraStrings()) = %d, want 1", len(fields))
}
if fields[0].Value != strings.Repeat("•", len("green-light")) {
t.Fatalf("detailExtraStrings()[0].Value hidden = %q, want masked value", fields[0].Value)
}
u.toggleExtraStringReveal("OTPSeed")
fields = u.detailExtraStrings()
if fields[0].Value != "green-light" {
t.Fatalf("detailExtraStrings()[0].Value revealed = %q, want green-light", fields[0].Value)
}
}
func TestUICopyExtraStringWritesClipboardWithoutLeakingStatus(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Path: []string{"Root", "Internet"},
Fields: map[string]string{
"OTPSeed": "green-light",
},
},
},
})
writer := &memoryClipboardWriter{}
u.clipboardWriter = writer
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.runAction("copy extra string", func() error { return u.copySelectedCustomFieldAction("OTPSeed") })
if writer.content != "green-light" {
t.Fatalf("clipboard content = %q, want green-light", writer.content)
}
if u.state.StatusMessage != "copy extra string complete" {
t.Fatalf("state.StatusMessage = %q, want copy extra string complete", u.state.StatusMessage)
}
if strings.Contains(u.state.StatusMessage, "green-light") {
t.Fatalf("state.StatusMessage = %q, must not contain copied extra string value", u.state.StatusMessage)
}
}
func TestUIPasswordTogglePresentationMatchesVisibility(t *testing.T) {
t.Parallel()
-16
View File
@@ -45,22 +45,6 @@ func (s Service) Copy(model vault.Model, entryID string, target Target) error {
return nil
}
func (s Service) CopyCustomField(model vault.Model, entryID, key string) error {
entry, err := findEntry(model, entryID)
if err != nil {
return err
}
content, ok := entry.Fields[key]
if !ok {
return ErrUnsupportedTarget
}
if err := s.writer().WriteText(content); err != nil {
return writeError{err: err}
}
return nil
}
func (s Service) writer() Writer {
if s.Writer != nil {
return s.Writer
-24
View File
@@ -48,30 +48,6 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
}
}
func TestServiceCopiesCustomField(t *testing.T) {
t.Parallel()
var writer memoryWriter
service := Service{Writer: &writer}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Fields: map[string]string{
"OTPSeed": "green-light",
},
},
},
}
if err := service.CopyCustomField(model, "vault-console", "OTPSeed"); err != nil {
t.Fatalf("CopyCustomField(vault-console, OTPSeed) error = %v", err)
}
if writer.content != "green-light" {
t.Fatalf("clipboard content = %q, want green-light", writer.content)
}
}
func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
t.Parallel()