Host the gRPC API and add token admin views

This commit is contained in:
Joe Julian
2026-03-30 07:50:34 -07:00
parent 84c188129e
commit 9afddd7a93
9 changed files with 1175 additions and 23 deletions
+211 -16
View File
@@ -25,6 +25,8 @@ import (
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/api"
"git.julianfamily.org/keepassgo/apiaudit"
"git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/appstate"
@@ -128,6 +130,12 @@ type ui struct {
remotePassword widget.Editor
masterPassword widget.Editor
keyFilePath widget.Editor
apiTokenName widget.Editor
apiTokenClientName widget.Editor
apiTokenExpiresAt widget.Editor
apiPolicyOperation widget.Editor
apiPolicyPath widget.Editor
apiPolicyEntryID widget.Editor
entryID widget.Editor
entryTitle widget.Editor
entryUsername widget.Editor
@@ -195,6 +203,8 @@ type ui struct {
showEntries widget.Clickable
showTemplates widget.Clickable
showRecycle widget.Clickable
showAPITokens widget.Clickable
showAPIAudit widget.Clickable
showLocalLifecycle widget.Clickable
showRemoteLifecycle widget.Clickable
showSyncLocal widget.Clickable
@@ -206,7 +216,13 @@ type ui struct {
cancelApproval widget.Clickable
approvalPermanent widget.Bool
rememberRemoteAuth widget.Bool
apiPolicyAllow widget.Bool
apiPolicyGroupScopeW widget.Bool
apiTokenDisabled widget.Bool
entryClicks []widget.Clickable
apiTokenClicks []widget.Clickable
apiPolicyRemoves []widget.Clickable
apiAuditClicks []widget.Clickable
historyClicks []widget.Clickable
attachmentClicks []widget.Clickable
breadcrumbs []widget.Clickable
@@ -221,6 +237,14 @@ type ui struct {
selectedHistoryIndex int
showPassword bool
togglePassword widget.Clickable
copyAPITokenSecret widget.Clickable
issueAPIToken widget.Clickable
saveAPIToken widget.Clickable
rotateAPIToken widget.Clickable
disableAPIToken widget.Clickable
revokeAPIToken widget.Clickable
deleteAPIToken widget.Clickable
addAPIPolicyRule widget.Clickable
phoneSplit widget.Float
splitDrag gesture.Drag
splitBase float32
@@ -256,8 +280,14 @@ type ui struct {
recentRemotes []recentRemoteRecord
recentVaultGroups map[string][]string
deleteGroupPath []string
apiPolicyGroupScope bool
apiTokenSecret string
selectedAuditIndex int
statusExpiresAt time.Time
now func() time.Time
apiHost *api.Host
auditLog *apiaudit.Log
grpcAddress string
}
var (
@@ -274,10 +304,6 @@ const (
errSaveAsPathRequired = "save-as path is required"
)
func newUI(mode string, paths statePaths) *ui {
return newUIWithSession(mode, &session.Manager{}, paths)
}
func newUIWithModel(mode string, model vault.Model) *ui {
return newUIWithState(mode, &uiSession{model: model}, defaultStatePaths(""))
}
@@ -317,6 +343,12 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
syncRemotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•'},
masterPassword: widget.Editor{SingleLine: true, Submit: false},
keyFilePath: widget.Editor{SingleLine: true, Submit: false},
apiTokenName: widget.Editor{SingleLine: true, Submit: false},
apiTokenClientName: widget.Editor{SingleLine: true, Submit: false},
apiTokenExpiresAt: widget.Editor{SingleLine: true, Submit: false},
apiPolicyOperation: widget.Editor{SingleLine: true, Submit: false},
apiPolicyPath: widget.Editor{SingleLine: true, Submit: false},
apiPolicyEntryID: widget.Editor{SingleLine: true, Submit: false},
entryID: widget.Editor{SingleLine: true, Submit: false},
entryTitle: widget.Editor{SingleLine: true, Submit: false},
entryUsername: widget.Editor{SingleLine: true, Submit: false},
@@ -343,6 +375,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
},
state: appstate.State{},
selectedHistoryIndex: -1,
selectedAuditIndex: -1,
lifecycleMode: "local",
defaultSaveAsPath: paths.DefaultSaveAsPath,
recentVaultsPath: paths.RecentVaultsPath,
@@ -352,7 +385,10 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
now: time.Now,
syncSourceMode: syncSourceLocal,
syncDirection: syncDirectionPull,
apiPolicyGroupScope: true,
}
u.apiPolicyAllow.Value = true
u.apiPolicyGroupScopeW.Value = true
u.state.Session = sess
u.phoneSplit.Value = 0.46
u.eyeIcon, _ = widget.NewIcon(icons.ActionVisibility)
@@ -478,6 +514,20 @@ func (u *ui) showRecycleBinSection() {
u.filter()
}
func (u *ui) showAPITokensSection() {
u.resetPasswordPeek()
u.state.ShowSection(appstate.SectionAPITokens)
u.loadSelectedAPITokenIntoEditor()
u.filter()
}
func (u *ui) showAPIAuditSection() {
u.resetPasswordPeek()
u.state.ShowSection(appstate.SectionAPIAudit)
u.selectedAuditIndex = -1
u.filter()
}
func (u *ui) resetPasswordPeek() {
u.showPassword = false
}
@@ -1350,6 +1400,10 @@ func (u *ui) listEmptyMessage() string {
}
}
switch u.state.Section {
case appstate.SectionAPITokens:
return "No API tokens match the current filter."
case appstate.SectionAPIAudit:
return "No API audit events match the current filter."
case appstate.SectionTemplates:
return "Templates are not available in this build."
case appstate.SectionRecycleBin:
@@ -1369,6 +1423,10 @@ func (u *ui) detailPlaceholderMessage() string {
return "Complete the form to create a new item or update the current selection."
}
switch u.state.Section {
case appstate.SectionAPITokens:
return "Select an API token or issue a new one."
case appstate.SectionAPIAudit:
return "Select an audit event to inspect it."
case appstate.SectionTemplates:
return "Select a template or start a reusable entry."
case appstate.SectionRecycleBin:
@@ -1430,6 +1488,8 @@ func (u *ui) noteCurrentVaultPath() {
}
func (u *ui) layout(gtx layout.Context) layout.Dimensions {
u.syncHostedAPI()
u.filter()
u.processShortcuts(gtx)
for u.createVault.Clicked(gtx) {
u.runAction("create vault", u.createVaultAction)
@@ -1480,6 +1540,14 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
u.clearDeleteGroupConfirmation()
u.showRecycleBinSection()
}
for u.showAPITokens.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.showAPITokensSection()
}
for u.showAPIAudit.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.showAPIAuditSection()
}
for u.showLocalLifecycle.Clicked(gtx) {
u.lifecycleMode = "local"
}
@@ -1530,6 +1598,45 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
for u.lockVault.Clicked(gtx) {
u.runAction("lock vault", u.lockAction)
}
for u.issueAPIToken.Clicked(gtx) {
u.runAction("issue API token", u.issueAPITokenAction)
}
for u.saveAPIToken.Clicked(gtx) {
u.runAction("save API token", u.saveAPITokenAction)
}
for u.rotateAPIToken.Clicked(gtx) {
u.runAction("rotate API token", u.rotateAPITokenAction)
}
for u.disableAPIToken.Clicked(gtx) {
u.runAction("disable API token", u.disableAPITokenAction)
}
for u.revokeAPIToken.Clicked(gtx) {
u.runAction("revoke API token", u.revokeAPITokenAction)
}
for u.deleteAPIToken.Clicked(gtx) {
u.runAction("delete API token", u.deleteAPITokenAction)
}
for u.addAPIPolicyRule.Clicked(gtx) {
u.runAction("add API policy rule", u.addAPIPolicyRuleAction)
}
for i := range u.apiPolicyRemoves {
for u.apiPolicyRemoves[i].Clicked(gtx) {
index := i
u.runAction("remove API policy rule", func() error { return u.removeAPIPolicyRuleAction(index) })
}
}
for u.copyAPITokenSecret.Clicked(gtx) {
secret := u.apiTokenSecret
u.runAction("copy API token secret", func() error {
if strings.TrimSpace(secret) == "" {
return fmt.Errorf("no API token secret to copy")
}
if u.clipboardWriter != nil {
return u.clipboardWriter.WriteText(secret)
}
return clipboard.WriteText(secret)
})
}
for u.editEntry.Clicked(gtx) {
u.editingEntry = true
u.loadSelectedEntryIntoEditor()
@@ -1737,6 +1844,15 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
)
}
func (u *ui) syncHostedAPI() {
if u.apiHost == nil {
return
}
if err := u.apiHost.SyncFromLifecycle(); err != nil {
u.state.ErrorMessage = fmt.Sprintf("sync gRPC API: %v", err)
}
}
func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
panel := card
if u.mode == "phone" {
@@ -2116,21 +2232,21 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
}),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() {
if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) {
return layout.Dimensions{}
}
return u.pathBar(gtx)
}),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() {
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
return layout.Dimensions{}
}
return u.groupBar(gtx)
}),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() {
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
return layout.Dimensions{}
}
return u.groupControlsSection(gtx)
@@ -2149,18 +2265,31 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
}),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
if u.isVaultLocked() {
return layout.Dimensions{}
}
label := "Add Entry"
if u.mode == "phone" {
label = "+ " + label
switch u.state.Section {
case appstate.SectionEntries:
label := "Add Entry"
if u.mode == "phone" {
label = "+ " + label
}
btn := material.Button(u.theme, &u.addEntry, label)
return btn.Layout(gtx)
case appstate.SectionAPITokens:
return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token")
default:
return layout.Dimensions{}
}
btn := material.Button(u.theme, &u.addEntry, label)
return btn.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionAPITokens {
return u.apiTokenListPanel(gtx)
}
if u.state.Section == appstate.SectionAPIAudit {
return u.apiAuditListPanel(gtx)
}
if len(u.visible) == 0 {
lbl := material.Label(u.theme, unit.Sp(16), u.listEmptyMessage())
lbl.Color = mutedColor
@@ -2209,6 +2338,24 @@ func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions {
btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9}
return btn.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.showAPITokens, "API Tokens")
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
btn.TextSize = unit.Sp(11)
btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9}
return btn.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.showAPIAudit, "API Audit")
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
btn.TextSize = unit.Sp(11)
btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9}
return btn.Layout(gtx)
}),
)
}
@@ -2395,6 +2542,16 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
layout.Rigid(u.unlockPanel),
}
}
if u.state.Section == appstate.SectionAPITokens {
return []layout.FlexChild{
layout.Flexed(1, u.apiTokenDetailPanel),
}
}
if u.state.Section == appstate.SectionAPIAudit {
return []layout.FlexChild{
layout.Flexed(1, u.apiAuditDetailPanel),
}
}
item, ok := u.selectedEntry()
if !ok && !u.editingEntry {
return []layout.FlexChild{
@@ -2913,10 +3070,12 @@ func fill(c color.NRGBA) layout.Widget {
func main() {
mode := flag.String("mode", "", "window mode: desktop or phone")
stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets")
grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable")
flag.Parse()
resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS))
resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "")
resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS))
width := unit.Dp(1180)
height := unit.Dp(760)
@@ -2933,7 +3092,7 @@ func main() {
options = append(options, app.Size(width, height))
}
w.Option(options...)
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir)); err != nil {
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil {
panic(err)
}
if !strings.EqualFold(runtime.GOOS, "android") {
@@ -2943,9 +3102,27 @@ func main() {
app.Main()
}
func run(w *app.Window, mode string, paths statePaths) error {
func defaultGRPCAddr(goos string) string {
if strings.EqualFold(strings.TrimSpace(goos), "android") {
return "off"
}
return "127.0.0.1:47777"
}
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
var ops op.Ops
ui := newUI(mode, paths)
manager := &session.Manager{}
ui := newUIWithSession(mode, manager, paths)
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
if err != nil {
ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err)
} else if host != nil {
ui.apiHost = host
ui.auditLog = host.Server().AuditLog()
ui.grpcAddress = host.Address()
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
defer func() { _ = host.Stop() }()
}
for {
e := w.Event()
switch e := e.(type) {
@@ -2959,6 +3136,24 @@ func run(w *app.Window, mode string, paths statePaths) error {
}
}
type uiApprovalManager struct {
server *api.Server
}
func (m *uiApprovalManager) Pending() []apiapproval.Request {
if m == nil || m.server == nil {
return nil
}
return m.server.ApprovalBroker().Pending()
}
func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
if m == nil || m.server == nil {
return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured")
}
return m.server.ResolveApproval(id, outcome)
}
type uiSession struct {
model vault.Model
locked bool