Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72006aa4b1 | |||
| e171f49287 |
@@ -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.
|
- UI state should not be the source of truth for vault structure or search behavior.
|
||||||
- Domain packages must be test-driven where practical.
|
- Domain packages must be test-driven where practical.
|
||||||
- Prefer behavior-oriented tests that describe expected product behavior rather than implementation details.
|
- 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.
|
- 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.
|
- 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:
|
- Treat the vault model as local-first across all platforms:
|
||||||
|
|||||||
@@ -700,7 +700,34 @@ async function statusForPage(options = {}) {
|
|||||||
return refreshPageState(page.tabId, page.url, 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)) {
|
if (!Number.isInteger(tabId)) {
|
||||||
throw new Error("No active tab is available.");
|
throw new Error("No active tab is available.");
|
||||||
}
|
}
|
||||||
@@ -709,7 +736,10 @@ async function fillLogin(tabId, entryId) {
|
|||||||
if (!supportsPageStateURL(pageUrl)) {
|
if (!supportsPageStateURL(pageUrl)) {
|
||||||
throw new Error("This page cannot be filled.");
|
throw new Error("This page cannot be filled.");
|
||||||
}
|
}
|
||||||
|
return { url: pageUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillLoginOnPage(tabId, entryId, pageUrl, credentialRequest) {
|
||||||
let state = await getPageState(tabId, pageUrl);
|
let state = await getPageState(tabId, pageUrl);
|
||||||
state = await setPageState(tabId, {
|
state = await setPageState(tabId, {
|
||||||
...state,
|
...state,
|
||||||
@@ -729,12 +759,7 @@ async function fillLogin(tabId, entryId) {
|
|||||||
throw new Error("API token is not configured.");
|
throw new Error("API token is not configured.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await connectNative({
|
const response = await connectNative(credentialRequest(settings, entryId, pageUrl));
|
||||||
action: "get-login",
|
|
||||||
bearerToken: settings.bearerToken,
|
|
||||||
entryId,
|
|
||||||
url: pageUrl
|
|
||||||
});
|
|
||||||
if (!response?.success || !response.credential) {
|
if (!response?.success || !response.credential) {
|
||||||
throw new Error(response?.error || "KeePassGO did not return a credential.");
|
throw new Error(response?.error || "KeePassGO did not return a credential.");
|
||||||
}
|
}
|
||||||
@@ -846,6 +871,8 @@ const backgroundTestExports = {
|
|||||||
shouldContinueWatchingState,
|
shouldContinueWatchingState,
|
||||||
tokenPendingApprovalCount,
|
tokenPendingApprovalCount,
|
||||||
savePlanForObservedLogin,
|
savePlanForObservedLogin,
|
||||||
|
matchedLoginCredentialRequest,
|
||||||
|
selectedLoginCredentialRequest,
|
||||||
defaultSettings
|
defaultSettings
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -872,7 +899,14 @@ if (isNodeTestEnv) {
|
|||||||
focusTarget: cloneTarget(message.target)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
case "keepassgo-load-settings":
|
case "keepassgo-load-settings":
|
||||||
|
|||||||
@@ -149,3 +149,24 @@ test("applyBestMatchOnly preserves all matches when disabled", () => {
|
|||||||
|
|
||||||
assert.deepEqual(filtered.map((match) => match.id), ["livingston", "rusty"]);
|
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"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ function renderMatchList(root, matches, options = {}) {
|
|||||||
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
row.disabled = false;
|
row.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,20 @@ function renderSearchResults(results, query) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderMatchList(root, results, {
|
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");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 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 {
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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) {
|
func TestVaultServiceRejectsUnauthorizedBrowserCredentialAccess(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+1
-127
@@ -249,7 +249,6 @@ type ui struct {
|
|||||||
entryFields widget.Editor
|
entryFields widget.Editor
|
||||||
customFieldKeys []widget.Editor
|
customFieldKeys []widget.Editor
|
||||||
customFieldValues []widget.Editor
|
customFieldValues []widget.Editor
|
||||||
copyCustomFields []widget.Clickable
|
|
||||||
historyIndex widget.Editor
|
historyIndex widget.Editor
|
||||||
groupName widget.Editor
|
groupName widget.Editor
|
||||||
groupParentPath widget.Editor
|
groupParentPath widget.Editor
|
||||||
@@ -403,8 +402,6 @@ type ui struct {
|
|||||||
vaultRemoteCredentialClicks []widget.Clickable
|
vaultRemoteCredentialClicks []widget.Clickable
|
||||||
syncRemoteCredentialClicks []widget.Clickable
|
syncRemoteCredentialClicks []widget.Clickable
|
||||||
removeCustomFields []widget.Clickable
|
removeCustomFields []widget.Clickable
|
||||||
toggleCustomFields []widget.Clickable
|
|
||||||
revealedCustomFields map[string]bool
|
|
||||||
state appstate.State
|
state appstate.State
|
||||||
visible []entry
|
visible []entry
|
||||||
currentPath []string
|
currentPath []string
|
||||||
@@ -665,7 +662,6 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
pendingSharedLookupPath: paths.PendingSharedLookupPath,
|
pendingSharedLookupPath: paths.PendingSharedLookupPath,
|
||||||
recentVaultGroups: map[string][]string{},
|
recentVaultGroups: map[string][]string{},
|
||||||
recentVaultUsedAt: map[string]time.Time{},
|
recentVaultUsedAt: map[string]time.Time{},
|
||||||
revealedCustomFields: map[string]bool{},
|
|
||||||
lifecycleAdvancedHidden: true,
|
lifecycleAdvancedHidden: true,
|
||||||
historyHidden: true,
|
historyHidden: true,
|
||||||
statusBannerTTL: statusBannerDuration,
|
statusBannerTTL: statusBannerDuration,
|
||||||
@@ -944,7 +940,6 @@ func (u *ui) handlePhoneBack() bool {
|
|||||||
|
|
||||||
func (u *ui) resetPasswordPeek() {
|
func (u *ui) resetPasswordPeek() {
|
||||||
u.showPassword = false
|
u.showPassword = false
|
||||||
u.revealedCustomFields = map[string]bool{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) childGroups() []string {
|
func (u *ui) childGroups() []string {
|
||||||
@@ -2158,12 +2153,6 @@ type detailViewMetrics struct {
|
|||||||
cardGap unit.Dp
|
cardGap unit.Dp
|
||||||
}
|
}
|
||||||
|
|
||||||
type extraStringView struct {
|
|
||||||
Key string
|
|
||||||
Value string
|
|
||||||
Revealed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) detailViewContent(gtx layout.Context, item entry) layout.Dimensions {
|
func (u *ui) detailViewContent(gtx layout.Context, item entry) layout.Dimensions {
|
||||||
rows := u.detailViewRows(item)
|
rows := u.detailViewRows(item)
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
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,
|
layout.Spacer{Height: unit.Dp(8)}.Layout,
|
||||||
u.detailNotesCard(item),
|
u.detailNotesCard(item),
|
||||||
layout.Spacer{Height: metrics.cardGap}.Layout,
|
layout.Spacer{Height: metrics.cardGap}.Layout,
|
||||||
u.detailExtraStringsCard,
|
|
||||||
layout.Spacer{Height: metrics.cardGap}.Layout,
|
|
||||||
u.attachmentSummaryPanel,
|
u.attachmentSummaryPanel,
|
||||||
layout.Spacer{Height: metrics.cardGap}.Layout,
|
layout.Spacer{Height: metrics.cardGap}.Layout,
|
||||||
u.historyPanel,
|
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 {
|
func (u *ui) detailActionRow(gtx layout.Context) layout.Dimensions {
|
||||||
switch u.state.Section {
|
switch u.state.Section {
|
||||||
case appstate.SectionTemplates:
|
case appstate.SectionTemplates:
|
||||||
@@ -3118,11 +2996,7 @@ func (u *ui) detailPasswordValue() string {
|
|||||||
if u.showPassword {
|
if u.showPassword {
|
||||||
return item.Password
|
return item.Password
|
||||||
}
|
}
|
||||||
return maskedSecretValue(item.Password)
|
return strings.Repeat("•", max(8, len(item.Password)))
|
||||||
}
|
|
||||||
|
|
||||||
func maskedSecretValue(value string) string {
|
|
||||||
return strings.Repeat("•", max(8, len(value)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func card(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
func card(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
||||||
|
|||||||
@@ -82,8 +82,6 @@ func (u *ui) setCustomFieldRows(fields map[string]string) {
|
|||||||
u.customFieldKeys = nil
|
u.customFieldKeys = nil
|
||||||
u.customFieldValues = nil
|
u.customFieldValues = nil
|
||||||
u.removeCustomFields = nil
|
u.removeCustomFields = nil
|
||||||
u.copyCustomFields = nil
|
|
||||||
u.toggleCustomFields = nil
|
|
||||||
if len(fields) == 0 {
|
if len(fields) == 0 {
|
||||||
u.appendCustomFieldRow("", "")
|
u.appendCustomFieldRow("", "")
|
||||||
return
|
return
|
||||||
@@ -106,53 +104,21 @@ func (u *ui) appendCustomFieldRow(key, value string) {
|
|||||||
u.customFieldKeys = append(u.customFieldKeys, keyEditor)
|
u.customFieldKeys = append(u.customFieldKeys, keyEditor)
|
||||||
u.customFieldValues = append(u.customFieldValues, valueEditor)
|
u.customFieldValues = append(u.customFieldValues, valueEditor)
|
||||||
u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{})
|
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) {
|
func (u *ui) removeCustomFieldRow(index int) {
|
||||||
u.ensureCustomFieldRowControls()
|
|
||||||
if index < 0 || index >= len(u.customFieldKeys) {
|
if index < 0 || index >= len(u.customFieldKeys) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...)
|
u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...)
|
||||||
u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...)
|
u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...)
|
||||||
u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[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 {
|
if len(u.customFieldKeys) == 0 {
|
||||||
u.appendCustomFieldRow("", "")
|
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) {
|
func (u *ui) currentCustomFields() (map[string]string, error) {
|
||||||
u.ensureCustomFieldRowControls()
|
|
||||||
fields := map[string]string{}
|
fields := map[string]string{}
|
||||||
for i := range u.customFieldKeys {
|
for i := range u.customFieldKeys {
|
||||||
key := strings.TrimSpace(u.customFieldKeys[i].Text())
|
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)
|
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 {
|
func (u *ui) generatePasswordAction() error {
|
||||||
profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text())
|
profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -667,7 +667,6 @@ func (u *ui) customFieldEditorPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
if len(u.customFieldKeys) == 0 {
|
if len(u.customFieldKeys) == 0 {
|
||||||
u.setCustomFieldRows(nil)
|
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 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,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
|||||||
@@ -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) {
|
func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestUIPasswordTogglePresentationMatchesVisibility(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -45,22 +45,6 @@ func (s Service) Copy(model vault.Model, entryID string, target Target) error {
|
|||||||
return nil
|
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 {
|
func (s Service) writer() Writer {
|
||||||
if s.Writer != nil {
|
if s.Writer != nil {
|
||||||
return s.Writer
|
return s.Writer
|
||||||
|
|||||||
@@ -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) {
|
func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user