Expose API approval prompts to app state and UI

This commit is contained in:
Joe Julian
2026-03-29 23:14:39 -07:00
parent 722d2eefa0
commit a269217c60
4 changed files with 298 additions and 0 deletions
+172
View File
@@ -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 {