diff --git a/appstate/state.go b/appstate/state.go index 85e56b4..963fab9 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -6,6 +6,8 @@ import ( "slices" "strings" + "git.julianfamily.org/keepassgo/apiapproval" + "git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/webdav" ) @@ -81,8 +83,14 @@ type RemoteOpenableSession interface { OpenRemote(webdav.Client, string, vault.MasterKey) error } +type ApprovalManager interface { + Pending() []apiapproval.Request + Resolve(string, apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) +} + type State struct { Session CurrentSession + Approvals ApprovalManager Section Section CurrentPath []string SearchQuery string @@ -92,6 +100,21 @@ type State struct { ErrorMessage string } +func (s *State) PendingApprovals() []apiapproval.Request { + if s.Approvals == nil { + return nil + } + return s.Approvals.Pending() +} + +func (s *State) ResolveApproval(id string, outcome apiapproval.Outcome) error { + if s.Approvals == nil { + return fmt.Errorf("approval manager is not configured") + } + _, _, err := s.Approvals.Resolve(id, outcome) + return err +} + func (s *State) ShowSection(section Section) { s.Section = section s.CurrentPath = nil diff --git a/appstate/state_test.go b/appstate/state_test.go index 625da48..c115063 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -5,6 +5,8 @@ import ( "slices" "testing" + "git.julianfamily.org/keepassgo/apiapproval" + "git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/session" "git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/webdav" @@ -41,6 +43,36 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) { } } +func TestPendingApprovalsReturnsManagerRequests(t *testing.T) { + t.Parallel() + + state := State{ + Approvals: &stubApprovalManager{ + pending: []apiapproval.Request{ + {ID: "approval-1", TokenName: "CLI", Operation: apitokens.OperationListEntries}, + }, + }, + } + + got := state.PendingApprovals() + if len(got) != 1 || got[0].ID != "approval-1" { + t.Fatalf("PendingApprovals() = %#v, want approval-1", got) + } +} + +func TestResolveApprovalDelegatesToManager(t *testing.T) { + t.Parallel() + + manager := &stubApprovalManager{} + state := State{Approvals: manager} + if err := state.ResolveApproval("approval-1", apiapproval.OutcomeAllowPermanent); err != nil { + t.Fatalf("ResolveApproval() error = %v", err) + } + if manager.lastID != "approval-1" || manager.lastOutcome != apiapproval.OutcomeAllowPermanent { + t.Fatalf("ResolveApproval() delegated (%q, %q), want (approval-1, allow-permanent)", manager.lastID, manager.lastOutcome) + } +} + func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) { t.Parallel() @@ -1378,3 +1410,19 @@ func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error { s.changedKey = key return nil } + +type stubApprovalManager struct { + pending []apiapproval.Request + lastID string + lastOutcome apiapproval.Outcome +} + +func (s stubApprovalManager) Pending() []apiapproval.Request { + return append([]apiapproval.Request(nil), s.pending...) +} + +func (s *stubApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) { + s.lastID = id + s.lastOutcome = outcome + return apiapproval.Request{ID: id}, nil, nil +} diff --git a/main.go b/main.go index d779e00..d99a1f6 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,8 @@ import ( "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" + "git.julianfamily.org/keepassgo/apiapproval" + "git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/appstate" "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/passwords" @@ -198,6 +200,10 @@ type ui struct { showSyncRemote widget.Clickable showSyncPull widget.Clickable showSyncPush widget.Clickable + allowApproval widget.Clickable + denyApproval widget.Clickable + cancelApproval widget.Clickable + approvalPermanent widget.Bool rememberRemoteAuth widget.Bool entryClicks []widget.Clickable historyClicks []widget.Clickable @@ -1480,6 +1486,35 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.showSyncPush.Clicked(gtx) { u.syncDirection = syncDirectionPush } + for u.allowApproval.Clicked(gtx) { + u.runAction("allow API request", func() error { + outcome := apiapproval.OutcomeAllowOnce + if u.approvalPermanent.Value { + outcome = apiapproval.OutcomeAllowPermanent + } + err := u.resolvePendingApproval(outcome) + u.approvalPermanent.Value = false + return err + }) + } + for u.denyApproval.Clicked(gtx) { + u.runAction("deny API request", func() error { + outcome := apiapproval.OutcomeDenyOnce + if u.approvalPermanent.Value { + outcome = apiapproval.OutcomeDenyPermanent + } + err := u.resolvePendingApproval(outcome) + u.approvalPermanent.Value = false + return err + }) + } + for u.cancelApproval.Clicked(gtx) { + u.runAction("cancel API request", func() error { + err := u.resolvePendingApproval(apiapproval.OutcomeCancel) + u.approvalPermanent.Value = false + return err + }) + } for u.lockVault.Clicked(gtx) { u.runAction("lock vault", u.lockAction) } @@ -1681,6 +1716,12 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } return u.syncDialog(gtx) }), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + if _, ok := u.pendingApproval(); !ok { + return layout.Dimensions{} + } + return u.approvalDialog(gtx) + }), ) } @@ -1722,6 +1763,81 @@ func (u *ui) syncDialog(gtx layout.Context) layout.Dimensions { ) } +func (u *ui) approvalDialog(gtx layout.Context) layout.Dimensions { + return layout.Stack{}.Layout(gtx, + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + paint.FillShape(gtx.Ops, color.NRGBA{A: 110}, clip.Rect{Max: gtx.Constraints.Max}.Op()) + return layout.Dimensions{Size: gtx.Constraints.Max} + }), + layout.Stacked(func(gtx layout.Context) layout.Dimensions { + return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + width := gtx.Dp(unit.Dp(640)) + if width > gtx.Constraints.Max.X { + width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24)) + } + if width < 1 { + width = gtx.Constraints.Max.X + } + gtx.Constraints.Min.X = width + gtx.Constraints.Max.X = width + return card(gtx, u.approvalDialogContent) + }) + }), + ) +} + +func (u *ui) approvalDialogContent(gtx layout.Context) layout.Dimensions { + request, ok := u.pendingApproval() + if !ok { + return layout.Dimensions{} + } + + resourceText := approvalResourceText(request) + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(20), "Approve API Request") + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(14), "An external tool requested vault access that is not explicitly allowed or denied.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return approvalFact(u.theme, "Client", strings.TrimSpace(request.ClientName), strings.TrimSpace(request.TokenName))(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return approvalFact(u.theme, "Operation", string(request.Operation), resourceText)(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + check := material.CheckBox(u.theme, &u.approvalPermanent, "Make this decision permanent") + check.Color = accentColor + return check.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.allowApproval, "Allow") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.denyApproval, "Deny") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.cancelApproval, "Cancel") + }), + ) + }), + ) +} + func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions { return material.List(u.theme, &u.lifecycleList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, @@ -1793,6 +1909,62 @@ func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions { }) } +func (u *ui) pendingApproval() (apiapproval.Request, bool) { + pending := u.state.PendingApprovals() + if len(pending) == 0 { + return apiapproval.Request{}, false + } + return pending[0], true +} + +func (u *ui) resolvePendingApproval(outcome apiapproval.Outcome) error { + request, ok := u.pendingApproval() + if !ok { + return fmt.Errorf("no pending approval") + } + return u.state.ResolveApproval(request.ID, outcome) +} + +func approvalResourceText(request apiapproval.Request) string { + switch request.Resource.Kind { + case apitokens.ResourceEntry: + if strings.TrimSpace(request.Resource.EntryID) != "" { + return "Entry " + request.Resource.EntryID + } + case apitokens.ResourceGroup: + if len(request.Resource.Path) > 0 { + return strings.Join(request.Resource.Path, " / ") + } + } + return "Vault root" +} + +func approvalFact(theme *material.Theme, title, primary, secondary string) layout.Widget { + return 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(theme, unit.Sp(12), strings.ToUpper(title)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(theme, unit.Sp(16), strings.TrimSpace(primary)) + lbl.Color = theme.Palette.Fg + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if strings.TrimSpace(secondary) == "" { + return layout.Dimensions{} + } + lbl := material.Label(theme, unit.Sp(13), strings.TrimSpace(secondary)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + } +} + func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { diff --git a/main_test.go b/main_test.go index d4f3ad6..79d25cd 100644 --- a/main_test.go +++ b/main_test.go @@ -18,6 +18,8 @@ import ( "gioui.org/unit" "gioui.org/widget" + "git.julianfamily.org/keepassgo/apiapproval" + "git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/passwords" "git.julianfamily.org/keepassgo/session" @@ -1937,6 +1939,43 @@ func TestUIShowsLifecycleSetupOnlyBeforeVaultIsOpened(t *testing.T) { } } +func TestUIPendingApprovalUsesFirstPendingRequest(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.state.Approvals = &mainStubApprovalManager{ + pending: []apiapproval.Request{ + {ID: "approval-1", TokenName: "CLI", Operation: apitokens.OperationListEntries}, + {ID: "approval-2", TokenName: "Browser", Operation: apitokens.OperationReadEntry}, + }, + } + + request, ok := u.pendingApproval() + if !ok { + t.Fatal("pendingApproval() ok = false, want true") + } + if request.ID != "approval-1" { + t.Fatalf("pendingApproval().ID = %q, want approval-1", request.ID) + } +} + +func TestUIResolvePendingApprovalDelegatesToApprovalManager(t *testing.T) { + t.Parallel() + + manager := &mainStubApprovalManager{ + pending: []apiapproval.Request{{ID: "approval-1"}}, + } + u := newUIWithModel("desktop", vault.Model{}) + u.state.Approvals = manager + + if err := u.resolvePendingApproval(apiapproval.OutcomeDenyPermanent); err != nil { + t.Fatalf("resolvePendingApproval() error = %v", err) + } + if manager.lastID != "approval-1" || manager.lastOutcome != apiapproval.OutcomeDenyPermanent { + t.Fatalf("resolvePendingApproval() delegated (%q, %q), want (approval-1, deny-permanent)", manager.lastID, manager.lastOutcome) + } +} + func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) { t.Parallel() @@ -2991,3 +3030,19 @@ func writeKDBXMainTestFile(t *testing.T, path string, model vault.Model, key vau t.Fatalf("WriteFile(%s) error = %v", path, err) } } + +type mainStubApprovalManager struct { + pending []apiapproval.Request + lastID string + lastOutcome apiapproval.Outcome +} + +func (m mainStubApprovalManager) Pending() []apiapproval.Request { + return append([]apiapproval.Request(nil), m.pending...) +} + +func (m *mainStubApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) { + m.lastID = id + m.lastOutcome = outcome + return apiapproval.Request{ID: id}, nil, nil +}