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
+211 -207
View File
@@ -165,210 +165,214 @@ const (
) )
type ui struct { type ui struct {
mode string mode string
theme *material.Theme theme *material.Theme
logoHorizontal paint.ImageOp logoHorizontal paint.ImageOp
splashSquare paint.ImageOp splashSquare paint.ImageOp
search widget.Editor search widget.Editor
vaultPath widget.Editor vaultPath widget.Editor
saveAsPath widget.Editor saveAsPath widget.Editor
remoteBaseURL widget.Editor remoteBaseURL widget.Editor
remotePath widget.Editor remotePath widget.Editor
remoteUsername widget.Editor remoteUsername widget.Editor
remotePassword widget.Editor remotePassword widget.Editor
masterPassword widget.Editor masterPassword widget.Editor
keyFilePath widget.Editor keyFilePath widget.Editor
apiTokenName widget.Editor apiTokenName widget.Editor
apiTokenClientName widget.Editor apiTokenClientName widget.Editor
apiTokenExpiresAt widget.Editor apiTokenExpiresAt widget.Editor
apiPolicyOperation widget.Editor apiPolicyOperation widget.Editor
apiPolicyPath widget.Editor apiPolicyPath widget.Editor
apiPolicyEntryID widget.Editor apiPolicyEntryID widget.Editor
securityCipher widget.Editor securityCipher widget.Editor
securityKDF widget.Editor securityKDF widget.Editor
entryID widget.Editor entryID widget.Editor
entryTitle widget.Editor entryTitle widget.Editor
entryUsername widget.Editor entryUsername widget.Editor
entryPassword widget.Editor entryPassword widget.Editor
entryURL widget.Editor entryURL widget.Editor
entryNotes widget.Editor entryNotes widget.Editor
entryTags widget.Editor entryTags widget.Editor
entryPath widget.Editor entryPath widget.Editor
entryFields widget.Editor entryFields widget.Editor
customFieldKeys []widget.Editor customFieldKeys []widget.Editor
customFieldValues []widget.Editor customFieldValues []widget.Editor
historyIndex widget.Editor historyIndex widget.Editor
groupName widget.Editor groupName widget.Editor
groupParentPath widget.Editor groupParentPath widget.Editor
passwordProfile widget.Editor passwordProfile widget.Editor
attachmentName widget.Editor attachmentName widget.Editor
attachmentPath widget.Editor attachmentPath widget.Editor
exportAttachmentPath widget.Editor exportAttachmentPath widget.Editor
list widget.List list widget.List
groupList widget.List groupList widget.List
detailList widget.List detailList widget.List
apiPolicyList widget.List apiPolicyList widget.List
lifecycleList widget.List lifecycleList widget.List
copyUser widget.Clickable copyUser widget.Clickable
copyPass widget.Clickable copyPass widget.Clickable
copyURL widget.Clickable copyURL widget.Clickable
lockVault widget.Clickable lockVault widget.Clickable
unlockVault widget.Clickable unlockVault widget.Clickable
createVault widget.Clickable createVault widget.Clickable
openVault widget.Clickable openVault widget.Clickable
saveVault widget.Clickable saveVault widget.Clickable
saveAsVault widget.Clickable saveAsVault widget.Clickable
openRemote widget.Clickable openRemote widget.Clickable
changeMasterKey widget.Clickable changeMasterKey widget.Clickable
synchronizeVault widget.Clickable synchronizeVault widget.Clickable
toggleSyncMenu widget.Clickable toggleSyncMenu widget.Clickable
openAdvancedSync widget.Clickable openAdvancedSync widget.Clickable
openSecuritySettings widget.Clickable openSecuritySettings widget.Clickable
closeAdvancedSync widget.Clickable closeAdvancedSync widget.Clickable
closeSecuritySettings widget.Clickable closeSecuritySettings widget.Clickable
runAdvancedSync widget.Clickable runAdvancedSync widget.Clickable
saveSecuritySettings widget.Clickable saveSecuritySettings widget.Clickable
editEntry widget.Clickable editEntry widget.Clickable
cancelEdit widget.Clickable cancelEdit widget.Clickable
pickVaultPath widget.Clickable pickVaultPath widget.Clickable
pickKeyFile widget.Clickable pickKeyFile widget.Clickable
pickSyncLocalPath widget.Clickable pickSyncLocalPath widget.Clickable
clearVaultSelection widget.Clickable clearVaultSelection widget.Clickable
clearRemoteSelection widget.Clickable clearRemoteSelection widget.Clickable
dismissBanner widget.Clickable dismissBanner widget.Clickable
addEntry widget.Clickable addEntry widget.Clickable
saveEntry widget.Clickable saveEntry widget.Clickable
duplicateEntry widget.Clickable duplicateEntry widget.Clickable
deleteEntry widget.Clickable deleteEntry widget.Clickable
restoreEntry widget.Clickable restoreEntry widget.Clickable
saveTemplate widget.Clickable saveTemplate widget.Clickable
deleteTemplate widget.Clickable deleteTemplate widget.Clickable
instantiateTemplate widget.Clickable instantiateTemplate widget.Clickable
addAttachment widget.Clickable addAttachment widget.Clickable
replaceAttachment widget.Clickable replaceAttachment widget.Clickable
removeAttachment widget.Clickable removeAttachment widget.Clickable
exportAttachment widget.Clickable exportAttachment widget.Clickable
restoreHistory widget.Clickable restoreHistory widget.Clickable
generatePassword widget.Clickable generatePassword widget.Clickable
goToRootGroup widget.Clickable goToRootGroup widget.Clickable
goToParentGroup widget.Clickable goToParentGroup widget.Clickable
createGroup widget.Clickable createGroup widget.Clickable
moveGroup widget.Clickable moveGroup widget.Clickable
renameGroup widget.Clickable renameGroup widget.Clickable
deleteGroup widget.Clickable deleteGroup widget.Clickable
confirmDeleteGroup widget.Clickable confirmDeleteGroup widget.Clickable
cancelDeleteGroup widget.Clickable cancelDeleteGroup widget.Clickable
addCustomField widget.Clickable addCustomField widget.Clickable
toggleGroupControls widget.Clickable toggleGroupControls widget.Clickable
toggleLifecycleAdvanced widget.Clickable toggleLifecycleAdvanced widget.Clickable
toggleHistory widget.Clickable toggleHistory widget.Clickable
togglePasswordInline widget.Clickable togglePasswordInline widget.Clickable
toggleSyncPassword widget.Clickable toggleSyncPassword widget.Clickable
showEntries widget.Clickable showEntries widget.Clickable
showTemplates widget.Clickable showTemplates widget.Clickable
showRecycle widget.Clickable showRecycle widget.Clickable
showAPITokens widget.Clickable showAPITokens widget.Clickable
showAPIAudit widget.Clickable showAPIAudit widget.Clickable
showLocalLifecycle widget.Clickable showLocalLifecycle widget.Clickable
showRemoteLifecycle widget.Clickable showRemoteLifecycle widget.Clickable
showSyncLocal widget.Clickable showSyncLocal widget.Clickable
showSyncRemote widget.Clickable showSyncRemote widget.Clickable
showSyncPull widget.Clickable showSyncPull widget.Clickable
showSyncPush widget.Clickable showSyncPush widget.Clickable
allowApproval widget.Clickable allowApproval widget.Clickable
denyApproval widget.Clickable denyApproval widget.Clickable
cancelApproval widget.Clickable cancelApproval widget.Clickable
cancelLifecycleProgress widget.Clickable cancelLifecycleProgress widget.Clickable
retryLifecycleOpen widget.Clickable retryLifecycleOpen widget.Clickable
approvalPermanent widget.Bool approvalPermanent widget.Bool
rememberRemoteAuth widget.Bool rememberRemoteAuth widget.Bool
apiPolicyAllow widget.Bool apiPolicyAllow widget.Bool
apiPolicyGroupScopeW widget.Bool apiPolicyGroupScopeW widget.Bool
apiTokenDisabled widget.Bool apiTokenDisabled widget.Bool
entryClicks []widget.Clickable entryClicks []widget.Clickable
apiTokenClicks []widget.Clickable apiTokenClicks []widget.Clickable
apiPolicyRemoves []widget.Clickable apiPolicyRemoves []widget.Clickable
apiAuditClicks []widget.Clickable apiAuditClicks []widget.Clickable
historyClicks []widget.Clickable apiAuditTokenFilters []widget.Clickable
attachmentClicks []widget.Clickable apiAuditDecisionFilters []widget.Clickable
breadcrumbs []widget.Clickable apiAuditOperationFilters []widget.Clickable
groupClicks []widget.Clickable clearAPIAuditFilters widget.Clickable
recentVaultClicks []widget.Clickable historyClicks []widget.Clickable
recentRemoteClicks []widget.Clickable attachmentClicks []widget.Clickable
removeCustomFields []widget.Clickable breadcrumbs []widget.Clickable
state appstate.State groupClicks []widget.Clickable
visible []entry recentVaultClicks []widget.Clickable
currentPath []string recentRemoteClicks []widget.Clickable
syncedPath []string removeCustomFields []widget.Clickable
selectedHistoryIndex int state appstate.State
showPassword bool visible []entry
generatedPasswordDraft bool currentPath []string
togglePassword widget.Clickable syncedPath []string
copyAPITokenSecret widget.Clickable selectedHistoryIndex int
issueAPIToken widget.Clickable showPassword bool
saveAPIToken widget.Clickable generatedPasswordDraft bool
rotateAPIToken widget.Clickable togglePassword widget.Clickable
disableAPIToken widget.Clickable copyAPITokenSecret widget.Clickable
revokeAPIToken widget.Clickable issueAPIToken widget.Clickable
deleteAPIToken widget.Clickable saveAPIToken widget.Clickable
addAPIPolicyRule widget.Clickable rotateAPIToken widget.Clickable
phoneSplit widget.Float disableAPIToken widget.Clickable
splitDrag gesture.Drag revokeAPIToken widget.Clickable
splitBase float32 deleteAPIToken widget.Clickable
splitStartY float32 addAPIPolicyRule widget.Clickable
phoneSpan int phoneSplit widget.Float
eyeIcon *widget.Icon splitDrag gesture.Drag
eyeOffIcon *widget.Icon splitBase float32
copyIcon *widget.Icon splitStartY float32
expandMoreIcon *widget.Icon phoneSpan int
expandLessIcon *widget.Icon eyeIcon *widget.Icon
chevronDownIcon *widget.Icon eyeOffIcon *widget.Icon
settingsIcon *widget.Icon copyIcon *widget.Icon
clipboardWriter clipboard.Writer expandMoreIcon *widget.Icon
loadingMessage string expandLessIcon *widget.Icon
loadingActionLabel string chevronDownIcon *widget.Icon
lifecycleMode string settingsIcon *widget.Icon
syncSourceMode syncSourceMode clipboardWriter clipboard.Writer
syncDirection syncDirection loadingMessage string
syncLocalPath widget.Editor loadingActionLabel string
syncRemoteBaseURL widget.Editor lifecycleMode string
syncRemotePath widget.Editor syncSourceMode syncSourceMode
syncRemoteUsername widget.Editor syncDirection syncDirection
syncRemotePassword widget.Editor syncLocalPath widget.Editor
syncDialogOpen bool syncRemoteBaseURL widget.Editor
syncMenuOpen bool syncRemotePath widget.Editor
securityDialogOpen bool syncRemoteUsername widget.Editor
showSyncPassword bool syncRemotePassword widget.Editor
keyboardFocus focusID syncDialogOpen bool
defaultSaveAsPath string syncMenuOpen bool
recentVaultsPath string securityDialogOpen bool
uiPreferencesPath string showSyncPassword bool
recentRemotesPath string keyboardFocus focusID
autofillCachePath string defaultSaveAsPath string
editingEntry bool recentVaultsPath string
groupControlsHidden bool uiPreferencesPath string
lifecycleAdvancedHidden bool recentRemotesPath string
historyHidden bool autofillCachePath string
recentVaults []string editingEntry bool
recentRemotes []recentRemoteRecord groupControlsHidden bool
recentVaultGroups map[string][]string lifecycleAdvancedHidden bool
recentVaultUsedAt map[string]time.Time historyHidden bool
entriesState entriesSectionState recentVaults []string
deleteGroupPath []string recentRemotes []recentRemoteRecord
apiPolicyGroupScope bool recentVaultGroups map[string][]string
apiTokenSecret string recentVaultUsedAt map[string]time.Time
selectedAuditIndex int entriesState entriesSectionState
statusExpiresAt time.Time deleteGroupPath []string
now func() time.Time apiPolicyGroupScope bool
apiHost *api.Host apiTokenSecret string
auditLog *apiaudit.Log selectedAuditIndex int
grpcAddress string statusExpiresAt time.Time
backgroundResults chan backgroundActionResult now func() time.Time
backgroundActionSerial int apiHost *api.Host
activeBackgroundAction int auditLog *apiaudit.Log
lastLifecycleAction string grpcAddress string
requestMasterPassFocus bool backgroundResults chan backgroundActionResult
invalidate func() backgroundActionSerial int
activeBackgroundAction int
lastLifecycleAction string
requestMasterPassFocus bool
invalidate func()
} }
type backgroundActionResult struct { type backgroundActionResult struct {
@@ -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)
+26 -5
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{}
} }
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, if u.lifecycleMode == "remote" {
layout.Rigid(labeledEditorHelp(u.theme, "Cipher", "Used for new vaults and future saves. Supported values: aes256, chacha20.", &u.securityCipher, false)), return layout.Dimensions{}
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), }
layout.Rigid(labeledEditorHelp(u.theme, "KDF", "Used for new vaults and future saves. Supported values: aes-kdf, argon2.", &u.securityKDF, false)), 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), "Vault settings")
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 {