Refine audit UI and vault settings placement

This commit is contained in:
Joe Julian
2026-04-01 17:11:34 -07:00
parent 99a798afbd
commit 4d35e7237d
4 changed files with 541 additions and 223 deletions
+7 -3
View File
@@ -289,6 +289,10 @@ type ui struct {
apiTokenClicks []widget.Clickable apiTokenClicks []widget.Clickable
apiPolicyRemoves []widget.Clickable apiPolicyRemoves []widget.Clickable
apiAuditClicks []widget.Clickable apiAuditClicks []widget.Clickable
apiAuditTokenFilters []widget.Clickable
apiAuditDecisionFilters []widget.Clickable
apiAuditOperationFilters []widget.Clickable
clearAPIAuditFilters widget.Clickable
historyClicks []widget.Clickable historyClicks []widget.Clickable
attachmentClicks []widget.Clickable attachmentClicks []widget.Clickable
breadcrumbs []widget.Clickable breadcrumbs []widget.Clickable
@@ -2265,7 +2269,7 @@ func (u *ui) listEmptyState() emptyState {
case appstate.SectionAPIAudit: case appstate.SectionAPIAudit:
return emptyState{ return emptyState{
Title: "No matching audit events", Title: "No matching audit events",
Body: fmt.Sprintf("No audit events match %q. Clear or refine Search vault to filter by token, decision, operation, or resource.", query), Body: fmt.Sprintf("No audit events match %q. Clear the search or try a different quick filter.", query),
} }
case appstate.SectionTemplates: case appstate.SectionTemplates:
return emptyState{ return emptyState{
@@ -2293,7 +2297,7 @@ func (u *ui) listEmptyState() emptyState {
case appstate.SectionAPIAudit: case appstate.SectionAPIAudit:
return emptyState{ return emptyState{
Title: "No API audit events yet", Title: "No API audit events yet",
Body: "Approval prompts, denials, and token actions will appear here.", Body: "Connect a trusted client, respond to approval prompts, or issue a token to start recording activity.",
} }
case appstate.SectionTemplates: case appstate.SectionTemplates:
return emptyState{ return emptyState{
@@ -2332,7 +2336,7 @@ func (u *ui) detailPlaceholderMessage() string {
case appstate.SectionAPITokens: case appstate.SectionAPITokens:
return "Select an API token, issue a new one, or search to narrow the list." return "Select an API token, issue a new one, or search to narrow the list."
case appstate.SectionAPIAudit: case appstate.SectionAPIAudit:
return "Select an audit event to inspect it, or filter the list with Search vault." return "Select an audit event to inspect it, or use Search vault or the quick filters above."
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:
+64
View File
@@ -636,6 +636,70 @@ func TestUIAPIAuditSectionShowsRecordedEvents(t *testing.T) {
} }
} }
func TestUIAPIAuditEventsMatchFriendlyQuickFilters(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",
Operation: apitokens.OperationCopyPassword,
Message: "approved",
})
u.auditLog.Record(apiaudit.Event{
Type: apiaudit.EventApprovalDenied,
TokenName: "CLI",
Operation: apitokens.OperationListEntries,
Message: "denied",
})
u.showAPIAuditSection()
u.search.SetText("Allowed")
if got := u.apiAuditEvents(); len(got) != 1 || got[0].Type != apiaudit.EventApprovalAllowed {
t.Fatalf("apiAuditEvents() with Allowed = %#v, want allowed event", got)
}
u.search.SetText("copy password")
if got := u.apiAuditEvents(); len(got) != 1 || got[0].Operation != apitokens.OperationCopyPassword {
t.Fatalf("apiAuditEvents() with copy password = %#v, want copy_password event", got)
}
u.search.SetText("CLI")
if got := u.apiAuditEvents(); len(got) != 1 || got[0].TokenName != "CLI" {
t.Fatalf("apiAuditEvents() with CLI = %#v, want CLI token event", got)
}
}
func TestUIAPIAuditMessagesGuideQuickFilters(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.showAPIAuditSection()
if got := u.listEmptyMessage(); got != "No API audit events yet. Connect a trusted client, respond to approval prompts, or issue a token to start recording activity." {
t.Fatalf("listEmptyMessage() = %q, want updated API audit guidance", got)
}
if got := u.detailPlaceholderMessage(); got != "Select an audit event to inspect it, or use Search vault or the quick filters above." {
t.Fatalf("detailPlaceholderMessage() = %q, want quick-filter guidance", got)
}
u.search.SetText("allowed")
if got := u.listEmptyMessage(); got != `No audit events match "allowed". Clear the search or try a different quick filter.` {
t.Fatalf("listEmptyMessage() with search = %q, want quick-filter empty-state guidance", got)
}
}
func TestUILifecycleSecuritySettingsSummaryMovesAdvancedFieldsOutOfOpenFlow(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
if got := u.lifecycleSecuritySettingsSummary(); got != "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices." {
t.Fatalf("lifecycleSecuritySettingsSummary() = %q, want focused lifecycle guidance", got)
}
}
func TestUISelectedEntryFollowsApplicationStateSelection(t *testing.T) { func TestUISelectedEntryFollowsApplicationStateSelection(t *testing.T) {
t.Parallel() t.Parallel()
+240 -11
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"image/color"
"strings" "strings"
"time" "time"
@@ -27,6 +28,130 @@ func apiOperations() []apitokens.Operation {
} }
} }
type apiAuditQuickFilter struct {
Label string
Query string
}
func apiAuditDecisionLabel(eventType apiaudit.EventType) string {
switch eventType {
case apiaudit.EventApprovalRequested:
return "Requested"
case apiaudit.EventApprovalAllowed:
return "Allowed"
case apiaudit.EventApprovalDenied:
return "Denied"
case apiaudit.EventApprovalCanceled:
return "Canceled"
case apiaudit.EventApprovalTimedOut:
return "Timed Out"
case apiaudit.EventAuthRejected:
return "Auth Rejected"
default:
return strings.ReplaceAll(string(eventType), "_", " ")
}
}
func apiAuditOperationLabel(operation apitokens.Operation) string {
if strings.TrimSpace(string(operation)) == "" {
return "Other"
}
return strings.ReplaceAll(string(operation), "_", " ")
}
func compactAuditFilterLabel(label string) string {
label = strings.TrimSpace(label)
if len(label) <= 22 {
return label
}
return label[:19] + "..."
}
func apiAuditEventSearchTerms(event apiaudit.Event) string {
parts := []string{
string(event.Type),
apiAuditDecisionLabel(event.Type),
event.TokenName,
event.ClientName,
string(event.Operation),
apiAuditOperationLabel(event.Operation),
strings.Join(event.Resource.Path, " / "),
event.Resource.EntryID,
event.Message,
}
switch event.Type {
case apiaudit.EventApprovalAllowed:
parts = append(parts, "allow approved")
case apiaudit.EventApprovalDenied:
parts = append(parts, "deny denied")
case apiaudit.EventApprovalRequested:
parts = append(parts, "prompt requested")
case apiaudit.EventApprovalCanceled:
parts = append(parts, "cancel canceled")
case apiaudit.EventApprovalTimedOut:
parts = append(parts, "timeout timed out")
case apiaudit.EventAuthRejected:
parts = append(parts, "rejected unauthorized")
}
return strings.ToLower(strings.Join(parts, " "))
}
func apiAuditFilterButtons(clicks *[]widget.Clickable, filters []apiAuditQuickFilter) []widget.Clickable {
if len(filters) == 0 {
*clicks = nil
return nil
}
if len(*clicks) < len(filters) {
next := make([]widget.Clickable, len(filters))
copy(next, *clicks)
*clicks = next
}
return (*clicks)[:len(filters)]
}
func (u *ui) apiAuditQuickFilters(events []apiaudit.Event) ([]apiAuditQuickFilter, []apiAuditQuickFilter, []apiAuditQuickFilter) {
tokenSeen := map[string]struct{}{}
decisionSeen := map[apiaudit.EventType]struct{}{}
operationSeen := map[apitokens.Operation]struct{}{}
var tokens []apiAuditQuickFilter
var decisions []apiAuditQuickFilter
var operations []apiAuditQuickFilter
for _, event := range events {
if name := strings.TrimSpace(event.TokenName); name != "" {
if _, ok := tokenSeen[name]; !ok {
tokenSeen[name] = struct{}{}
tokens = append(tokens, apiAuditQuickFilter{Label: name, Query: name})
}
}
if _, ok := decisionSeen[event.Type]; !ok {
decisionSeen[event.Type] = struct{}{}
label := apiAuditDecisionLabel(event.Type)
decisions = append(decisions, apiAuditQuickFilter{Label: label, Query: label})
}
if strings.TrimSpace(string(event.Operation)) == "" {
continue
}
if _, ok := operationSeen[event.Operation]; ok {
continue
}
operationSeen[event.Operation] = struct{}{}
label := apiAuditOperationLabel(event.Operation)
operations = append(operations, apiAuditQuickFilter{Label: label, Query: label})
}
if len(tokens) > 4 {
tokens = tokens[:4]
}
if len(decisions) > 5 {
decisions = decisions[:5]
}
if len(operations) > 4 {
operations = operations[:4]
}
return tokens, decisions, operations
}
func (u *ui) apiTokens() []apitokens.Token { func (u *ui) apiTokens() []apitokens.Token {
tokens, err := u.state.APITokens() tokens, err := u.state.APITokens()
if err != nil { if err != nil {
@@ -274,15 +399,7 @@ func (u *ui) apiAuditEvents() []apiaudit.Event {
} }
filtered := make([]apiaudit.Event, 0, len(events)) filtered := make([]apiaudit.Event, 0, len(events))
for _, event := range events { for _, event := range events {
haystack := strings.ToLower(strings.Join([]string{ haystack := apiAuditEventSearchTerms(event)
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) { if strings.Contains(haystack, query) {
filtered = append(filtered, event) filtered = append(filtered, event)
} }
@@ -516,6 +633,11 @@ func (u *ui) apiTokenListPanel(gtx layout.Context) layout.Dimensions {
func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions { func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions {
events := u.apiAuditEvents() events := u.apiAuditEvents()
allEvents := []apiaudit.Event(nil)
if u.auditLog != nil {
allEvents = u.auditLog.Events()
}
tokenFilters, decisionFilters, operationFilters := u.apiAuditQuickFilters(allEvents)
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 {
text := "Local gRPC audit history" text := "Local gRPC audit history"
@@ -528,14 +650,18 @@ func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions {
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Filter by typing a token name, decision, operation, or resource in Search vault.") lbl := material.Label(u.theme, unit.Sp(12), "Filter by token, decision, or operation. Use the quick filters below or type a resource path in Search vault.")
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.apiAuditQuickFilterPanel(gtx, tokenFilters, decisionFilters, operationFilters)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
if len(events) == 0 { if len(events) == 0 {
lbl := material.Label(u.theme, unit.Sp(14), "No audit events yet. Approval prompts, denials, token changes, and filled requests will appear here.") lbl := material.Label(u.theme, unit.Sp(14), u.listEmptyMessage())
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
} }
@@ -546,6 +672,109 @@ func (u *ui) apiAuditListPanel(gtx layout.Context) layout.Dimensions {
) )
} }
func (u *ui) apiAuditQuickFilterPanel(gtx layout.Context, tokenFilters, decisionFilters, operationFilters []apiAuditQuickFilter) layout.Dimensions {
hasTokens := len(tokenFilters) > 0
hasDecisions := len(decisionFilters) > 0
hasOperations := len(operationFilters) > 0
query := strings.TrimSpace(u.search.Text())
if !hasTokens && !hasDecisions && !hasOperations && query == "" {
return layout.Dimensions{}
}
children := make([]layout.FlexChild, 0, 8)
if query != "" {
children = append(children,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.auditQuickFilterButton(gtx, &u.clearAPIAuditFilters, "Clear filters", false, "")
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
)
}
if hasTokens {
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.apiAuditQuickFilterRow(gtx, "Tokens", tokenFilters, &u.apiAuditTokenFilters)
}))
}
if hasDecisions {
if len(children) > 0 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.apiAuditQuickFilterRow(gtx, "Decisions", decisionFilters, &u.apiAuditDecisionFilters)
}))
}
if hasOperations {
if len(children) > 0 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.apiAuditQuickFilterRow(gtx, "Operations", operationFilters, &u.apiAuditOperationFilters)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
}
func (u *ui) apiAuditQuickFilterRow(gtx layout.Context, title string, filters []apiAuditQuickFilter, clicks *[]widget.Clickable) layout.Dimensions {
buttons := apiAuditFilterButtons(clicks, filters)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), title)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(460)) {
column := make([]layout.FlexChild, 0, len(filters)*2)
for i := range filters {
if i > 0 {
column = append(column, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
filter := filters[i]
click := &buttons[i]
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
column = append(column, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
}))
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, column...)
}
flexChildren := make([]layout.FlexChild, 0, len(filters)*2)
for i := range filters {
if i > 0 {
flexChildren = append(flexChildren, layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout))
}
filter := filters[i]
click := &buttons[i]
selected := strings.EqualFold(strings.TrimSpace(u.search.Text()), strings.TrimSpace(filter.Query))
flexChildren = append(flexChildren, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.auditQuickFilterButton(gtx, click, compactAuditFilterLabel(filter.Label), selected, filter.Query)
}))
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, flexChildren...)
}),
)
}
func (u *ui) auditQuickFilterButton(gtx layout.Context, click *widget.Clickable, label string, selected bool, query string) layout.Dimensions {
for click.Clicked(gtx) {
u.search.SetText(strings.TrimSpace(query))
u.filter()
}
btn := material.Button(u.theme, click, label)
btn.CornerRadius = unit.Dp(10)
btn.TextSize = unit.Sp(11)
btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9}
if selected {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 248, B: 238, A: 255}
} else {
btn.Background = color.NRGBA{R: 231, G: 224, B: 214, A: 255}
btn.Color = accentColor
}
return btn.Layout(gtx)
}
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions { func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
token, ok := u.selectedAPIToken() token, ok := u.selectedAPIToken()
removeClicks := u.ensureAPIPolicyRemoveClickables(0) removeClicks := u.ensureAPIPolicyRemoveClickables(0)
+24 -3
View File
@@ -269,11 +269,28 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
if busy || u.lifecycleAdvancedHidden { if busy || u.lifecycleAdvancedHidden {
return layout.Dimensions{} return layout.Dimensions{}
} }
if u.lifecycleMode == "remote" {
return layout.Dimensions{}
}
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditorHelp(u.theme, "Cipher", "Used for new vaults and future saves. Supported values: aes256, chacha20.", &u.securityCipher, false)), layout.Rigid(func(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), lbl := material.Label(u.theme, unit.Sp(13), "Vault settings")
layout.Rigid(labeledEditorHelp(u.theme, "KDF", "Used for new vaults and future saves. Supported values: aes-kdf, argon2.", &u.securityKDF, false)), lbl.Color = accentColor
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(12), u.lifecycleSecuritySettingsSummary())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Open Vault Settings")
}),
) )
})
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -313,6 +330,10 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
) )
} }
func (u *ui) lifecycleSecuritySettingsSummary() string {
return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices."
}
func (u *ui) lifecycleAdvancedDisclosure(gtx layout.Context) layout.Dimensions { func (u *ui) lifecycleAdvancedDisclosure(gtx layout.Context) layout.Dimensions {
return u.toggleLifecycleAdvanced.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return u.toggleLifecycleAdvanced.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(2)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {