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 fca121f170
commit 84f39b99de
9 changed files with 1175 additions and 23 deletions
+122
View File
@@ -0,0 +1,122 @@
package api
import (
"errors"
"fmt"
"net"
"strings"
"sync"
"git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"google.golang.org/grpc"
)
type DirtyProvider func() bool
type Host struct {
server *Server
grpcServer *grpc.Server
listener net.Listener
lifecycle lifecycleBackend
dirty DirtyProvider
mu sync.Mutex
lastModel vault.Model
started bool
listenAddr string
}
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
addr = strings.TrimSpace(addr)
if addr == "" || strings.EqualFold(addr, "off") {
return nil, nil
}
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
}
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
keepassgov1.RegisterVaultServiceServer(server, service)
host := &Host{
server: service,
grpcServer: server,
listener: listener,
lifecycle: lifecycle,
dirty: dirty,
listenAddr: listener.Addr().String(),
started: true,
}
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
_ = listener.Close()
server.Stop()
return nil, err
}
go func() {
_ = server.Serve(listener)
}()
return host, nil
}
func (h *Host) Address() string {
if h == nil {
return ""
}
return h.listenAddr
}
func (h *Host) Server() *Server {
if h == nil {
return nil
}
return h.server
}
func (h *Host) Stop() error {
if h == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
if !h.started {
return nil
}
h.started = false
h.grpcServer.Stop()
return h.listener.Close()
}
func (h *Host) SyncFromLifecycle() error {
if h == nil || h.lifecycle == nil || h.server == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
model, err := h.lifecycle.Current()
locked := false
switch {
case err == nil:
h.lastModel = model
case errors.Is(err, session.ErrLocked):
locked = true
default:
return err
}
dirty := false
if h.dirty != nil {
dirty = h.dirty()
}
h.server.SetSessionState(h.lastModel, locked, dirty)
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package api
import (
"context"
"net"
"testing"
"git.julianfamily.org/keepassgo/passwords"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"git.julianfamily.org/keepassgo/session"
"git.julianfamily.org/keepassgo/vault"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
t.Parallel()
lifecycle := &session.Manager{}
if err := lifecycle.Create(vault.Model{
Entries: []vault.Entry{
testAPITokenEntry(t),
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
t.Fatalf("Create() error = %v", err)
}
host, err := StartHost("127.0.0.1:0", lifecycle, passwords.DefaultProfiles(), nil, func() bool { return true })
if err != nil {
t.Fatalf("StartHost() error = %v", err)
}
defer func() { _ = host.Stop() }()
conn, err := grpc.NewClient("passthrough:///"+host.Address(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return net.Dial("tcp", host.Address())
}),
)
if err != nil {
t.Fatalf("grpc.NewClient() error = %v", err)
}
defer func() { _ = conn.Close() }()
client := keepassgov1.NewVaultServiceClient(conn)
statusResp, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() error = %v", err)
}
if statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = true, want false")
}
if !statusResp.Dirty {
t.Fatal("GetSessionStatus().Dirty = false, want true from dirty provider")
}
if err := lifecycle.Lock(); err != nil {
t.Fatalf("Lock() error = %v", err)
}
if err := host.SyncFromLifecycle(); err != nil {
t.Fatalf("SyncFromLifecycle() after lock error = %v", err)
}
statusResp, err = client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
if err != nil {
t.Fatalf("GetSessionStatus() after lock error = %v", err)
}
if !statusResp.Locked {
t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock")
}
}
+10 -3
View File
@@ -77,9 +77,16 @@ func (s *Server) AuditLog() *apiaudit.Log {
return s.audit return s.audit
} }
func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, error) { func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
request, _, err := s.approvals.Resolve(id, outcome) return s.approvals.Resolve(id, outcome)
return request, err }
func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.model = model
s.locked = locked
s.dirty = dirty
} }
func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) { func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
+4 -4
View File
@@ -131,7 +131,7 @@ func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
if pending.Operation != apitokens.OperationListEntries { if pending.Operation != apitokens.OperationListEntries {
t.Fatalf("pending.Operation = %q, want %q", pending.Operation, apitokens.OperationListEntries) t.Fatalf("pending.Operation = %q, want %q", pending.Operation, apitokens.OperationListEntries)
} }
if _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil { if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
t.Fatalf("ResolveApproval(allow) error = %v", err) t.Fatalf("ResolveApproval(allow) error = %v", err)
} }
@@ -171,7 +171,7 @@ func TestVaultServicePersistsPermanentDenyApproval(t *testing.T) {
}() }()
pending := waitForServerPendingApproval(t, service, 1)[0] pending := waitForServerPendingApproval(t, service, 1)[0]
if _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeDenyPermanent); err != nil { if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeDenyPermanent); err != nil {
t.Fatalf("ResolveApproval(deny permanent) error = %v", err) t.Fatalf("ResolveApproval(deny permanent) error = %v", err)
} }
@@ -211,7 +211,7 @@ func TestVaultServiceReturnsCanceledForCanceledApproval(t *testing.T) {
}() }()
pending := waitForServerPendingApproval(t, service, 1)[0] pending := waitForServerPendingApproval(t, service, 1)[0]
if _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeCancel); err != nil { if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeCancel); err != nil {
t.Fatalf("ResolveApproval(cancel) error = %v", err) t.Fatalf("ResolveApproval(cancel) error = %v", err)
} }
@@ -259,7 +259,7 @@ func TestVaultServiceRecordsApprovalAuditEvents(t *testing.T) {
}() }()
pending := waitForServerPendingApproval(t, service, 1)[0] pending := waitForServerPendingApproval(t, service, 1)[0]
if _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowPermanent); err != nil { if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowPermanent); err != nil {
t.Fatalf("ResolveApproval(allow permanent) error = %v", err) t.Fatalf("ResolveApproval(allow permanent) error = %v", err)
} }
if err := <-errCh; err != nil { if err := <-errCh; err != nil {
+4
View File
@@ -24,6 +24,8 @@ const (
SectionEntries Section = "" SectionEntries Section = ""
SectionTemplates Section = "templates" SectionTemplates Section = "templates"
SectionRecycleBin Section = "recycle-bin" SectionRecycleBin Section = "recycle-bin"
SectionAPITokens Section = "api-tokens"
SectionAPIAudit Section = "api-audit"
) )
type CurrentSession interface { type CurrentSession interface {
@@ -348,6 +350,8 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry {
return slices.Clone(model.Templates) return slices.Clone(model.Templates)
case SectionRecycleBin: case SectionRecycleBin:
return slices.Clone(model.RecycleBin) return slices.Clone(model.RecycleBin)
case SectionAPITokens, SectionAPIAudit:
return nil
default: default:
return slices.Clone(model.Entries) return slices.Clone(model.Entries)
} }
+4
View File
@@ -52,6 +52,10 @@ func (s Service) writer() Writer {
return systemWriter{} return systemWriter{}
} }
func WriteText(text string) error {
return systemWriter{}.WriteText(text)
}
func findEntry(model vault.Model, entryID string) (vault.Entry, error) { func findEntry(model vault.Model, entryID string) (vault.Entry, error) {
for _, entry := range model.Entries { for _, entry := range model.Entries {
if entry.ID == entryID { if entry.ID == entryID {
+211 -16
View File
@@ -25,6 +25,8 @@ import (
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget" "gioui.org/widget"
"gioui.org/widget/material" "gioui.org/widget/material"
"git.julianfamily.org/keepassgo/api"
"git.julianfamily.org/keepassgo/apiaudit"
"git.julianfamily.org/keepassgo/apiapproval" "git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/appstate" "git.julianfamily.org/keepassgo/appstate"
@@ -128,6 +130,12 @@ type ui struct {
remotePassword widget.Editor remotePassword widget.Editor
masterPassword widget.Editor masterPassword widget.Editor
keyFilePath 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 entryID widget.Editor
entryTitle widget.Editor entryTitle widget.Editor
entryUsername widget.Editor entryUsername widget.Editor
@@ -195,6 +203,8 @@ type ui struct {
showEntries widget.Clickable showEntries widget.Clickable
showTemplates widget.Clickable showTemplates widget.Clickable
showRecycle widget.Clickable showRecycle widget.Clickable
showAPITokens widget.Clickable
showAPIAudit widget.Clickable
showLocalLifecycle widget.Clickable showLocalLifecycle widget.Clickable
showRemoteLifecycle widget.Clickable showRemoteLifecycle widget.Clickable
showSyncLocal widget.Clickable showSyncLocal widget.Clickable
@@ -206,7 +216,13 @@ type ui struct {
cancelApproval widget.Clickable cancelApproval widget.Clickable
approvalPermanent widget.Bool approvalPermanent widget.Bool
rememberRemoteAuth widget.Bool rememberRemoteAuth widget.Bool
apiPolicyAllow widget.Bool
apiPolicyGroupScopeW widget.Bool
apiTokenDisabled widget.Bool
entryClicks []widget.Clickable entryClicks []widget.Clickable
apiTokenClicks []widget.Clickable
apiPolicyRemoves []widget.Clickable
apiAuditClicks []widget.Clickable
historyClicks []widget.Clickable historyClicks []widget.Clickable
attachmentClicks []widget.Clickable attachmentClicks []widget.Clickable
breadcrumbs []widget.Clickable breadcrumbs []widget.Clickable
@@ -221,6 +237,14 @@ type ui struct {
selectedHistoryIndex int selectedHistoryIndex int
showPassword bool showPassword bool
togglePassword widget.Clickable 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 phoneSplit widget.Float
splitDrag gesture.Drag splitDrag gesture.Drag
splitBase float32 splitBase float32
@@ -256,8 +280,14 @@ type ui struct {
recentRemotes []recentRemoteRecord recentRemotes []recentRemoteRecord
recentVaultGroups map[string][]string recentVaultGroups map[string][]string
deleteGroupPath []string deleteGroupPath []string
apiPolicyGroupScope bool
apiTokenSecret string
selectedAuditIndex int
statusExpiresAt time.Time statusExpiresAt time.Time
now func() time.Time now func() time.Time
apiHost *api.Host
auditLog *apiaudit.Log
grpcAddress string
} }
var ( var (
@@ -274,10 +304,6 @@ const (
errSaveAsPathRequired = "save-as path is required" 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 { func newUIWithModel(mode string, model vault.Model) *ui {
return newUIWithState(mode, &uiSession{model: model}, defaultStatePaths("")) 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: '•'}, syncRemotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•'},
masterPassword: widget.Editor{SingleLine: true, Submit: false}, masterPassword: widget.Editor{SingleLine: true, Submit: false},
keyFilePath: 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}, entryID: widget.Editor{SingleLine: true, Submit: false},
entryTitle: widget.Editor{SingleLine: true, Submit: false}, entryTitle: widget.Editor{SingleLine: true, Submit: false},
entryUsername: 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{}, state: appstate.State{},
selectedHistoryIndex: -1, selectedHistoryIndex: -1,
selectedAuditIndex: -1,
lifecycleMode: "local", lifecycleMode: "local",
defaultSaveAsPath: paths.DefaultSaveAsPath, defaultSaveAsPath: paths.DefaultSaveAsPath,
recentVaultsPath: paths.RecentVaultsPath, recentVaultsPath: paths.RecentVaultsPath,
@@ -352,7 +385,10 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
now: time.Now, now: time.Now,
syncSourceMode: syncSourceLocal, syncSourceMode: syncSourceLocal,
syncDirection: syncDirectionPull, syncDirection: syncDirectionPull,
apiPolicyGroupScope: true,
} }
u.apiPolicyAllow.Value = true
u.apiPolicyGroupScopeW.Value = true
u.state.Session = sess u.state.Session = sess
u.phoneSplit.Value = 0.46 u.phoneSplit.Value = 0.46
u.eyeIcon, _ = widget.NewIcon(icons.ActionVisibility) u.eyeIcon, _ = widget.NewIcon(icons.ActionVisibility)
@@ -478,6 +514,20 @@ func (u *ui) showRecycleBinSection() {
u.filter() 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() { func (u *ui) resetPasswordPeek() {
u.showPassword = false u.showPassword = false
} }
@@ -1350,6 +1400,10 @@ func (u *ui) listEmptyMessage() string {
} }
} }
switch u.state.Section { 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: case appstate.SectionTemplates:
return "Templates are not available in this build." return "Templates are not available in this build."
case appstate.SectionRecycleBin: 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." return "Complete the form to create a new item or update the current selection."
} }
switch u.state.Section { 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: case appstate.SectionTemplates:
return "Select a template or start a reusable entry." return "Select a template or start a reusable entry."
case appstate.SectionRecycleBin: case appstate.SectionRecycleBin:
@@ -1430,6 +1488,8 @@ func (u *ui) noteCurrentVaultPath() {
} }
func (u *ui) layout(gtx layout.Context) layout.Dimensions { func (u *ui) layout(gtx layout.Context) layout.Dimensions {
u.syncHostedAPI()
u.filter()
u.processShortcuts(gtx) u.processShortcuts(gtx)
for u.createVault.Clicked(gtx) { for u.createVault.Clicked(gtx) {
u.runAction("create vault", u.createVaultAction) u.runAction("create vault", u.createVaultAction)
@@ -1480,6 +1540,14 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
u.clearDeleteGroupConfirmation() u.clearDeleteGroupConfirmation()
u.showRecycleBinSection() 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) { for u.showLocalLifecycle.Clicked(gtx) {
u.lifecycleMode = "local" u.lifecycleMode = "local"
} }
@@ -1530,6 +1598,45 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
for u.lockVault.Clicked(gtx) { for u.lockVault.Clicked(gtx) {
u.runAction("lock vault", u.lockAction) 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) { for u.editEntry.Clicked(gtx) {
u.editingEntry = true u.editingEntry = true
u.loadSelectedEntryIntoEditor() 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 { func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
panel := card panel := card
if u.mode == "phone" { 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(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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 layout.Dimensions{}
} }
return u.pathBar(gtx) return u.pathBar(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() { if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
return layout.Dimensions{} return layout.Dimensions{}
} }
return u.groupBar(gtx) return u.groupBar(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() { if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
return layout.Dimensions{} return layout.Dimensions{}
} }
return u.groupControlsSection(gtx) 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(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries { if u.isVaultLocked() {
return layout.Dimensions{} return layout.Dimensions{}
} }
label := "Add Entry" switch u.state.Section {
if u.mode == "phone" { case appstate.SectionEntries:
label = "+ " + label 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.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { 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 { if len(u.visible) == 0 {
lbl := material.Label(u.theme, unit.Sp(16), u.listEmptyMessage()) lbl := material.Label(u.theme, unit.Sp(16), u.listEmptyMessage())
lbl.Color = mutedColor 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} btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9}
return btn.Layout(gtx) 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), 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() item, ok := u.selectedEntry()
if !ok && !u.editingEntry { if !ok && !u.editingEntry {
return []layout.FlexChild{ return []layout.FlexChild{
@@ -2913,10 +3070,12 @@ func fill(c color.NRGBA) layout.Widget {
func main() { func main() {
mode := flag.String("mode", "", "window mode: desktop or phone") 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") 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() flag.Parse()
resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS)) resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS))
resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "") resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "")
resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS))
width := unit.Dp(1180) width := unit.Dp(1180)
height := unit.Dp(760) height := unit.Dp(760)
@@ -2933,7 +3092,7 @@ func main() {
options = append(options, app.Size(width, height)) options = append(options, app.Size(width, height))
} }
w.Option(options...) 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) panic(err)
} }
if !strings.EqualFold(runtime.GOOS, "android") { if !strings.EqualFold(runtime.GOOS, "android") {
@@ -2943,9 +3102,27 @@ func main() {
app.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 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 { for {
e := w.Event() e := w.Event()
switch e := e.(type) { 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 { type uiSession struct {
model vault.Model model vault.Model
locked bool locked bool
+109
View File
@@ -19,6 +19,7 @@ import (
"gioui.org/widget" "gioui.org/widget"
"git.julianfamily.org/keepassgo/apiapproval" "git.julianfamily.org/keepassgo/apiapproval"
"git.julianfamily.org/keepassgo/apiaudit"
"git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/apitokens"
"git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords" "git.julianfamily.org/keepassgo/passwords"
@@ -150,6 +151,114 @@ func TestUIChildGroupsComeFromVaultModel(t *testing.T) {
} }
} }
func TestUIAPITokenLifecycleManagement(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
}
u.showAPITokensSection()
u.apiTokenName.SetText("Browser Extension")
u.apiTokenClientName.SetText("firefox")
u.apiTokenExpiresAt.SetText("2026-04-01T15:04:05Z")
if err := u.issueAPITokenAction(); err != nil {
t.Fatalf("issueAPITokenAction() error = %v", err)
}
if strings.TrimSpace(u.apiTokenSecret) == "" {
t.Fatal("apiTokenSecret = empty, want one-time secret")
}
tokens := u.apiTokens()
if len(tokens) != 1 {
t.Fatalf("len(apiTokens()) = %d, want 1", len(tokens))
}
if tokens[0].Name != "Browser Extension" || tokens[0].ClientName != "firefox" {
t.Fatalf("issued token = %#v, want Browser Extension/firefox", tokens[0])
}
if err := u.rotateAPITokenAction(); err != nil {
t.Fatalf("rotateAPITokenAction() error = %v", err)
}
if strings.TrimSpace(u.apiTokenSecret) == "" {
t.Fatal("apiTokenSecret after rotate = empty, want one-time secret")
}
if err := u.disableAPITokenAction(); err != nil {
t.Fatalf("disableAPITokenAction() error = %v", err)
}
disabled, ok := u.selectedAPIToken()
if !ok || !disabled.Disabled {
t.Fatalf("selectedAPIToken() = %#v, want disabled token", disabled)
}
}
func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
}
u.showAPITokensSection()
u.apiTokenName.SetText("CLI")
u.apiTokenClientName.SetText("grpc-cli")
if err := u.issueAPITokenAction(); err != nil {
t.Fatalf("issueAPITokenAction() error = %v", err)
}
u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries))
u.apiPolicyPath.SetText("Root / Internet")
u.apiPolicyAllow.Value = true
u.apiPolicyGroupScopeW.Value = true
if err := u.addAPIPolicyRuleAction(); err != nil {
t.Fatalf("addAPIPolicyRuleAction() error = %v", err)
}
token, ok := u.selectedAPIToken()
if !ok || len(token.Policies) != 1 {
t.Fatalf("selectedAPIToken().Policies = %#v, want 1 rule", token.Policies)
}
if token.Policies[0].Resource.Kind != apitokens.ResourceGroup {
t.Fatalf("rule kind = %q, want group", token.Policies[0].Resource.Kind)
}
if err := u.removeAPIPolicyRuleAction(0); err != nil {
t.Fatalf("removeAPIPolicyRuleAction() error = %v", err)
}
token, ok = u.selectedAPIToken()
if !ok || len(token.Policies) != 0 {
t.Fatalf("selectedAPIToken().Policies after remove = %#v, want empty", token.Policies)
}
}
func TestUIAPIAuditSectionShowsRecordedEvents(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.auditLog = apiaudit.New(10)
u.auditLog.Record(apiaudit.Event{
Type: apiaudit.EventApprovalAllowed,
TokenName: "Browser Extension",
ClientName: "firefox",
Message: "approved",
})
u.showAPIAuditSection()
events := u.apiAuditEvents()
if len(events) != 1 {
t.Fatalf("len(apiAuditEvents()) = %d, want 1", len(events))
}
if events[0].TokenName != "Browser Extension" {
t.Fatalf("apiAuditEvents()[0].TokenName = %q, want %q", events[0].TokenName, "Browser Extension")
}
}
func TestUISelectedEntryFollowsApplicationStateSelection(t *testing.T) { func TestUISelectedEntryFollowsApplicationStateSelection(t *testing.T) {
t.Parallel() t.Parallel()
+639
View File
@@ -0,0 +1,639 @@
package main
import (
"fmt"
"strings"
"time"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"git.julianfamily.org/keepassgo/apiaudit"
"git.julianfamily.org/keepassgo/apitokens"
)
func apiOperations() []apitokens.Operation {
return []apitokens.Operation{
apitokens.OperationListEntries,
apitokens.OperationListGroups,
apitokens.OperationReadEntry,
apitokens.OperationCopyPassword,
apitokens.OperationCopyUsername,
apitokens.OperationCopyURL,
apitokens.OperationMutateEntry,
apitokens.OperationMutateGroup,
apitokens.OperationManageVault,
}
}
func (u *ui) apiTokens() []apitokens.Token {
tokens, err := u.state.APITokens()
if err != nil {
return nil
}
query := strings.ToLower(strings.TrimSpace(u.search.Text()))
if query == "" {
if len(u.apiTokenClicks) < len(tokens) {
u.apiTokenClicks = make([]widget.Clickable, len(tokens))
}
return tokens
}
filtered := make([]apitokens.Token, 0, len(tokens))
for _, token := range tokens {
haystack := strings.ToLower(strings.Join([]string{
token.Name,
token.ClientName,
token.ID,
}, " "))
if strings.Contains(haystack, query) {
filtered = append(filtered, token)
}
}
if len(u.apiTokenClicks) < len(filtered) {
u.apiTokenClicks = make([]widget.Clickable, len(filtered))
}
return filtered
}
func (u *ui) selectedAPIToken() (apitokens.Token, bool) {
tokens, err := u.state.APITokens()
if err != nil {
return apitokens.Token{}, false
}
for _, token := range tokens {
if token.ID == strings.TrimSpace(u.state.SelectedEntryID) {
return token, true
}
}
return apitokens.Token{}, false
}
func (u *ui) loadSelectedAPITokenIntoEditor() {
token, ok := u.selectedAPIToken()
if !ok {
u.apiTokenSecret = ""
u.apiTokenName.SetText("")
u.apiTokenClientName.SetText("")
u.apiTokenExpiresAt.SetText("")
u.apiTokenDisabled.Value = false
u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries))
u.apiPolicyPath.SetText(strings.Join(u.displayPath(), " / "))
u.apiPolicyEntryID.SetText("")
u.apiPolicyAllow.Value = true
u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true
u.apiPolicyRemoves = nil
return
}
u.apiTokenName.SetText(token.Name)
u.apiTokenClientName.SetText(token.ClientName)
if token.ExpiresAt != nil {
u.apiTokenExpiresAt.SetText(token.ExpiresAt.UTC().Format(time.RFC3339))
} else {
u.apiTokenExpiresAt.SetText("")
}
u.apiTokenDisabled.Value = token.Disabled
if len(u.apiPolicyRemoves) < len(token.Policies) {
u.apiPolicyRemoves = make([]widget.Clickable, len(token.Policies))
}
}
func (u *ui) issueAPITokenAction() error {
expiresAt, err := parseAPITokenExpiry(u.apiTokenExpiresAt.Text())
if err != nil {
return err
}
token, secret, err := u.state.IssueAPIToken(strings.TrimSpace(u.apiTokenName.Text()), strings.TrimSpace(u.apiTokenClientName.Text()), expiresAt, u.now())
if err != nil {
return err
}
u.state.SelectedEntryID = token.ID
u.apiTokenSecret = secret
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) saveAPITokenAction() error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
expiresAt, err := parseAPITokenExpiry(u.apiTokenExpiresAt.Text())
if err != nil {
return err
}
token.Name = strings.TrimSpace(u.apiTokenName.Text())
token.ClientName = strings.TrimSpace(u.apiTokenClientName.Text())
token.ExpiresAt = expiresAt
token.Disabled = u.apiTokenDisabled.Value
return u.state.UpsertAPIToken(token)
}
func (u *ui) rotateAPITokenAction() error {
token, secret, err := u.state.RotateAPIToken(strings.TrimSpace(u.state.SelectedEntryID), u.now())
if err != nil {
return err
}
u.state.SelectedEntryID = token.ID
u.apiTokenSecret = secret
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) disableAPITokenAction() error {
if err := u.state.DisableAPIToken(strings.TrimSpace(u.state.SelectedEntryID)); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) revokeAPITokenAction() error {
if err := u.state.RevokeAPIToken(strings.TrimSpace(u.state.SelectedEntryID), u.now()); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) deleteAPITokenAction() error {
id := strings.TrimSpace(u.state.SelectedEntryID)
if id == "" {
return fmt.Errorf("no API token selected")
}
if err := u.state.DeleteAPIToken(id); err != nil {
return err
}
u.state.SelectedEntryID = ""
u.loadSelectedAPITokenIntoEditor()
return nil
}
func parseAPITokenExpiry(text string) (*time.Time, error) {
value := strings.TrimSpace(text)
if value == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil, fmt.Errorf("expiration must use RFC3339, for example 2026-04-01T15:04:05Z")
}
return &parsed, nil
}
func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
value := apitokens.Operation(strings.TrimSpace(text))
for _, operation := range apiOperations() {
if operation == value {
return value, nil
}
}
return "", fmt.Errorf("unknown API operation %q", text)
}
func (u *ui) addAPIPolicyRuleAction() error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text())
if err != nil {
return err
}
rule := apitokens.PolicyRule{
Operation: operation,
Effect: apitokens.EffectDeny,
}
if u.apiPolicyAllow.Value {
rule.Effect = apitokens.EffectAllow
}
u.apiPolicyGroupScope = u.apiPolicyGroupScopeW.Value
if u.apiPolicyGroupScope {
path := parsePath(u.apiPolicyPath.Text())
if len(path) == 0 {
return fmt.Errorf("policy path is required for group scope")
}
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}
} else {
entryID := strings.TrimSpace(u.apiPolicyEntryID.Text())
if entryID == "" {
return fmt.Errorf("entry id is required for entry scope")
}
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID}
}
if !uiHasPolicyRule(token.Policies, rule) {
token.Policies = append(token.Policies, rule)
}
if err := u.state.UpsertAPIToken(token); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) removeAPIPolicyRuleAction(index int) error {
token, ok := u.selectedAPIToken()
if !ok {
return fmt.Errorf("no API token selected")
}
if index < 0 || index >= len(token.Policies) {
return fmt.Errorf("policy index %d out of range", index)
}
token.Policies = append(token.Policies[:index], token.Policies[index+1:]...)
if err := u.state.UpsertAPIToken(token); err != nil {
return err
}
u.loadSelectedAPITokenIntoEditor()
return nil
}
func (u *ui) apiAuditEvents() []apiaudit.Event {
if u.auditLog == nil {
return nil
}
events := u.auditLog.Events()
query := strings.ToLower(strings.TrimSpace(u.search.Text()))
if query == "" {
if len(u.apiAuditClicks) < len(events) {
u.apiAuditClicks = make([]widget.Clickable, len(events))
}
return events
}
filtered := make([]apiaudit.Event, 0, len(events))
for _, event := range events {
haystack := strings.ToLower(strings.Join([]string{
string(event.Type),
event.TokenName,
event.ClientName,
string(event.Operation),
strings.Join(event.Resource.Path, " / "),
event.Resource.EntryID,
event.Message,
}, " "))
if strings.Contains(haystack, query) {
filtered = append(filtered, event)
}
}
if len(u.apiAuditClicks) < len(filtered) {
u.apiAuditClicks = make([]widget.Clickable, len(filtered))
}
return filtered
}
func formatAPIPolicyRule(rule apitokens.PolicyRule) string {
scope := strings.Join(rule.Resource.Path, " / ")
if rule.Resource.Kind == apitokens.ResourceEntry {
scope = "entry " + rule.Resource.EntryID
}
return strings.TrimSpace(strings.Join([]string{
strings.ToUpper(string(rule.Effect)),
string(rule.Operation),
scope,
}, " "))
}
func uiHasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
for _, rule := range rules {
if rule.Effect != target.Effect || rule.Operation != target.Operation {
continue
}
if rule.Resource.Kind != target.Resource.Kind || rule.Resource.EntryID != target.Resource.EntryID {
continue
}
if strings.Join(rule.Resource.Path, "\x00") == strings.Join(target.Resource.Path, "\x00") {
return true
}
}
return false
}
func (u *ui) apiTokenRow(gtx layout.Context, click *widget.Clickable, idx int, token apitokens.Token) layout.Dimensions {
for click.Clicked(gtx) {
u.state.SelectedEntryID = token.ID
u.apiTokenSecret = ""
u.loadSelectedAPITokenIntoEditor()
}
selected := strings.TrimSpace(u.state.SelectedEntryID) == token.ID
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
row := func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, 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(16), token.Name)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), token.ClientName)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
text := "Non-expiring"
if token.ExpiresAt != nil {
text = "Expires " + token.ExpiresAt.Local().Format(time.RFC3339)
}
lbl := material.Label(u.theme, unit.Sp(12), text)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
}
if selected {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
size := gtx.Constraints.Min
if size.X == 0 {
size.X = gtx.Constraints.Max.X
}
if size.Y == 0 {
size.Y = gtx.Constraints.Max.Y
}
return layout.Background{}.Layout(gtx, fill(selectedColor), func(gtx layout.Context) layout.Dimensions {
paintBar := layout.Stack{}.Layout
_ = paintBar
return layout.Dimensions{Size: size}
})
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(selectedColor), row)
}),
)
}
return layout.Background{}.Layout(gtx, fill(panelColor), row)
})
}
func (u *ui) apiAuditRow(gtx layout.Context, click *widget.Clickable, idx int, event apiaudit.Event) layout.Dimensions {
for click.Clicked(gtx) {
u.selectedAuditIndex = idx
}
selected := u.selectedAuditIndex == idx
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
row := func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, 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(15), string(event.Type))
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), event.At.Local().Format(time.RFC3339))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), strings.TrimSpace(event.ClientName))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
}
if selected {
return layout.Background{}.Layout(gtx, fill(selectedColor), row)
}
return layout.Background{}.Layout(gtx, fill(panelColor), row)
})
}
func (u *ui) apiTokenListPanel(gtx layout.Context) layout.Dimensions {
tokens := u.apiTokens()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(tokens) == 0 {
lbl := material.Label(u.theme, unit.Sp(14), "No API tokens match the current filter.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}
return material.List(u.theme, &u.list).Layout(gtx, len(tokens), func(gtx layout.Context, i int) layout.Dimensions {
return u.apiTokenRow(gtx, &u.apiTokenClicks[i], i, tokens[i])
})
}),
)
}
func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions {
events := u.apiAuditEvents()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
text := "Local gRPC audit history"
if strings.TrimSpace(u.grpcAddress) != "" {
text = "Local gRPC audit history at " + u.grpcAddress
}
lbl := material.Label(u.theme, unit.Sp(13), text)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(events) == 0 {
lbl := material.Label(u.theme, unit.Sp(14), "No audit events yet.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}
return material.List(u.theme, &u.list).Layout(gtx, len(events), func(gtx layout.Context, i int) layout.Dimensions {
return u.apiAuditRow(gtx, &u.apiAuditClicks[i], i, events[i])
})
}),
)
}
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
token, ok := u.selectedAPIToken()
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), "API Token")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
labeledEditor(u.theme, "Name", &u.apiTokenName, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditor(u.theme, "Client Name", &u.apiTokenClientName, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Expires At", "Optional RFC3339 timestamp, for example 2026-04-01T15:04:05Z.", &u.apiTokenExpiresAt, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return material.CheckBox(u.theme, &u.apiTokenDisabled, "Disabled").Layout(gtx)
},
}
if ok {
rows = append(rows,
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Token ID", token.ID),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Created", token.CreatedAt.Local().Format(time.RFC3339)),
)
if token.RevokedAt != nil {
rows = append(rows,
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Revoked", token.RevokedAt.Local().Format(time.RFC3339)),
)
}
}
if strings.TrimSpace(u.apiTokenSecret) != "" {
rows = append(rows,
layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return compactCard(gtx, 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(13), "ONE-TIME SECRET")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(15), u.apiTokenSecret)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.copyAPITokenSecret, "Copy Secret")
}),
)
})
},
)
}
rows = append(rows,
layout.Spacer{Height: unit.Dp(10)}.Layout,
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.saveAPIToken, "Save Token") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.rotateAPIToken, "Rotate") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.disableAPIToken, "Disable") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.revokeAPIToken, "Revoke") }),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.deleteAPIToken, "Delete") }),
)
},
layout.Spacer{Height: unit.Dp(14)}.Layout,
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), "Policy Rules")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
)
if ok && len(token.Policies) > 0 {
for i, rule := range token.Policies {
index := i
ruleText := formatAPIPolicyRule(rule)
rows = append(rows,
func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), ruleText)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.apiPolicyRemoves[index], "Remove")
}),
)
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
)
}
} else {
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(13), "No explicit rules yet. Approval prompts can create permanent rules.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
})
}
rows = append(rows,
layout.Spacer{Height: unit.Dp(10)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return material.CheckBox(u.theme, &u.apiPolicyAllow, "Allow rule (unchecked means deny rule)").Layout(gtx)
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx)
},
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Group Path", "Used when group scope is enabled.", &u.apiPolicyPath, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
labeledEditorHelp(u.theme, "Entry ID", "Used when group scope is disabled.", &u.apiPolicyEntryID, false),
layout.Spacer{Height: unit.Dp(6)}.Layout,
func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
},
)
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
}
func (u *ui) apiAuditDetailPanel(gtx layout.Context) layout.Dimensions {
events := u.apiAuditEvents()
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), "API Audit")
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
func(gtx layout.Context) layout.Dimensions {
text := "No audit events yet."
if len(events) > 0 {
text = fmt.Sprintf("%d recent security events recorded.", len(events))
}
lbl := material.Label(u.theme, unit.Sp(14), text)
lbl.Color = mutedColor
return lbl.Layout(gtx)
},
}
if u.selectedAuditIndex >= 0 && u.selectedAuditIndex < len(events) {
event := events[u.selectedAuditIndex]
rows = append(rows,
layout.Spacer{Height: unit.Dp(12)}.Layout,
detailLine(u.theme, "Type", string(event.Type)),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "When", event.At.Local().Format(time.RFC3339)),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Token", event.TokenName),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Client", event.ClientName),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Operation", string(event.Operation)),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Resource", formatAuditResource(event.Resource)),
layout.Spacer{Height: unit.Dp(6)}.Layout,
detailLine(u.theme, "Message", event.Message),
)
}
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
}
func stringOps(ops []apitokens.Operation) []string {
out := make([]string, 0, len(ops))
for _, op := range ops {
out = append(out, string(op))
}
return out
}
func formatAuditResource(resource apitokens.Resource) string {
if resource.Kind == apitokens.ResourceEntry {
return "entry " + resource.EntryID
}
if len(resource.Path) == 0 {
return "/"
}
return strings.Join(resource.Path, " / ")
}