diff --git a/main.go b/main.go index ae2b6f5..3cdf4a3 100644 --- a/main.go +++ b/main.go @@ -165,210 +165,214 @@ const ( ) type ui struct { - mode string - theme *material.Theme - logoHorizontal paint.ImageOp - splashSquare paint.ImageOp - search widget.Editor - vaultPath widget.Editor - saveAsPath widget.Editor - remoteBaseURL widget.Editor - remotePath widget.Editor - remoteUsername widget.Editor - remotePassword widget.Editor - masterPassword widget.Editor - keyFilePath widget.Editor - apiTokenName widget.Editor - apiTokenClientName widget.Editor - apiTokenExpiresAt widget.Editor - apiPolicyOperation widget.Editor - apiPolicyPath widget.Editor - apiPolicyEntryID widget.Editor - securityCipher widget.Editor - securityKDF widget.Editor - entryID widget.Editor - entryTitle widget.Editor - entryUsername widget.Editor - entryPassword widget.Editor - entryURL widget.Editor - entryNotes widget.Editor - entryTags widget.Editor - entryPath widget.Editor - entryFields widget.Editor - customFieldKeys []widget.Editor - customFieldValues []widget.Editor - historyIndex widget.Editor - groupName widget.Editor - groupParentPath widget.Editor - passwordProfile widget.Editor - attachmentName widget.Editor - attachmentPath widget.Editor - exportAttachmentPath widget.Editor - list widget.List - groupList widget.List - detailList widget.List - apiPolicyList widget.List - lifecycleList widget.List - copyUser widget.Clickable - copyPass widget.Clickable - copyURL widget.Clickable - lockVault widget.Clickable - unlockVault widget.Clickable - createVault widget.Clickable - openVault widget.Clickable - saveVault widget.Clickable - saveAsVault widget.Clickable - openRemote widget.Clickable - changeMasterKey widget.Clickable - synchronizeVault widget.Clickable - toggleSyncMenu widget.Clickable - openAdvancedSync widget.Clickable - openSecuritySettings widget.Clickable - closeAdvancedSync widget.Clickable - closeSecuritySettings widget.Clickable - runAdvancedSync widget.Clickable - saveSecuritySettings widget.Clickable - editEntry widget.Clickable - cancelEdit widget.Clickable - pickVaultPath widget.Clickable - pickKeyFile widget.Clickable - pickSyncLocalPath widget.Clickable - clearVaultSelection widget.Clickable - clearRemoteSelection widget.Clickable - dismissBanner widget.Clickable - addEntry widget.Clickable - saveEntry widget.Clickable - duplicateEntry widget.Clickable - deleteEntry widget.Clickable - restoreEntry widget.Clickable - saveTemplate widget.Clickable - deleteTemplate widget.Clickable - instantiateTemplate widget.Clickable - addAttachment widget.Clickable - replaceAttachment widget.Clickable - removeAttachment widget.Clickable - exportAttachment widget.Clickable - restoreHistory widget.Clickable - generatePassword widget.Clickable - goToRootGroup widget.Clickable - goToParentGroup widget.Clickable - createGroup widget.Clickable - moveGroup widget.Clickable - renameGroup widget.Clickable - deleteGroup widget.Clickable - confirmDeleteGroup widget.Clickable - cancelDeleteGroup widget.Clickable - addCustomField widget.Clickable - toggleGroupControls widget.Clickable - toggleLifecycleAdvanced widget.Clickable - toggleHistory widget.Clickable - togglePasswordInline widget.Clickable - toggleSyncPassword widget.Clickable - showEntries widget.Clickable - showTemplates widget.Clickable - showRecycle widget.Clickable - showAPITokens widget.Clickable - showAPIAudit widget.Clickable - showLocalLifecycle widget.Clickable - showRemoteLifecycle widget.Clickable - showSyncLocal widget.Clickable - showSyncRemote widget.Clickable - showSyncPull widget.Clickable - showSyncPush widget.Clickable - allowApproval widget.Clickable - denyApproval widget.Clickable - cancelApproval widget.Clickable - cancelLifecycleProgress widget.Clickable - retryLifecycleOpen widget.Clickable - approvalPermanent widget.Bool - rememberRemoteAuth widget.Bool - apiPolicyAllow widget.Bool - apiPolicyGroupScopeW widget.Bool - apiTokenDisabled widget.Bool - entryClicks []widget.Clickable - apiTokenClicks []widget.Clickable - apiPolicyRemoves []widget.Clickable - apiAuditClicks []widget.Clickable - historyClicks []widget.Clickable - attachmentClicks []widget.Clickable - breadcrumbs []widget.Clickable - groupClicks []widget.Clickable - recentVaultClicks []widget.Clickable - recentRemoteClicks []widget.Clickable - removeCustomFields []widget.Clickable - state appstate.State - visible []entry - currentPath []string - syncedPath []string - selectedHistoryIndex int - showPassword bool - generatedPasswordDraft bool - togglePassword widget.Clickable - copyAPITokenSecret widget.Clickable - issueAPIToken widget.Clickable - saveAPIToken widget.Clickable - rotateAPIToken widget.Clickable - disableAPIToken widget.Clickable - revokeAPIToken widget.Clickable - deleteAPIToken widget.Clickable - addAPIPolicyRule widget.Clickable - phoneSplit widget.Float - splitDrag gesture.Drag - splitBase float32 - splitStartY float32 - phoneSpan int - eyeIcon *widget.Icon - eyeOffIcon *widget.Icon - copyIcon *widget.Icon - expandMoreIcon *widget.Icon - expandLessIcon *widget.Icon - chevronDownIcon *widget.Icon - settingsIcon *widget.Icon - clipboardWriter clipboard.Writer - loadingMessage string - loadingActionLabel string - lifecycleMode string - syncSourceMode syncSourceMode - syncDirection syncDirection - syncLocalPath widget.Editor - syncRemoteBaseURL widget.Editor - syncRemotePath widget.Editor - syncRemoteUsername widget.Editor - syncRemotePassword widget.Editor - syncDialogOpen bool - syncMenuOpen bool - securityDialogOpen bool - showSyncPassword bool - keyboardFocus focusID - defaultSaveAsPath string - recentVaultsPath string - uiPreferencesPath string - recentRemotesPath string - autofillCachePath string - editingEntry bool - groupControlsHidden bool - lifecycleAdvancedHidden bool - historyHidden bool - recentVaults []string - recentRemotes []recentRemoteRecord - recentVaultGroups map[string][]string - recentVaultUsedAt map[string]time.Time - entriesState entriesSectionState - deleteGroupPath []string - apiPolicyGroupScope bool - apiTokenSecret string - selectedAuditIndex int - statusExpiresAt time.Time - now func() time.Time - apiHost *api.Host - auditLog *apiaudit.Log - grpcAddress string - backgroundResults chan backgroundActionResult - backgroundActionSerial int - activeBackgroundAction int - lastLifecycleAction string - requestMasterPassFocus bool - invalidate func() + mode string + theme *material.Theme + logoHorizontal paint.ImageOp + splashSquare paint.ImageOp + search widget.Editor + vaultPath widget.Editor + saveAsPath widget.Editor + remoteBaseURL widget.Editor + remotePath widget.Editor + remoteUsername widget.Editor + remotePassword widget.Editor + masterPassword widget.Editor + keyFilePath widget.Editor + apiTokenName widget.Editor + apiTokenClientName widget.Editor + apiTokenExpiresAt widget.Editor + apiPolicyOperation widget.Editor + apiPolicyPath widget.Editor + apiPolicyEntryID widget.Editor + securityCipher widget.Editor + securityKDF widget.Editor + entryID widget.Editor + entryTitle widget.Editor + entryUsername widget.Editor + entryPassword widget.Editor + entryURL widget.Editor + entryNotes widget.Editor + entryTags widget.Editor + entryPath widget.Editor + entryFields widget.Editor + customFieldKeys []widget.Editor + customFieldValues []widget.Editor + historyIndex widget.Editor + groupName widget.Editor + groupParentPath widget.Editor + passwordProfile widget.Editor + attachmentName widget.Editor + attachmentPath widget.Editor + exportAttachmentPath widget.Editor + list widget.List + groupList widget.List + detailList widget.List + apiPolicyList widget.List + lifecycleList widget.List + copyUser widget.Clickable + copyPass widget.Clickable + copyURL widget.Clickable + lockVault widget.Clickable + unlockVault widget.Clickable + createVault widget.Clickable + openVault widget.Clickable + saveVault widget.Clickable + saveAsVault widget.Clickable + openRemote widget.Clickable + changeMasterKey widget.Clickable + synchronizeVault widget.Clickable + toggleSyncMenu widget.Clickable + openAdvancedSync widget.Clickable + openSecuritySettings widget.Clickable + closeAdvancedSync widget.Clickable + closeSecuritySettings widget.Clickable + runAdvancedSync widget.Clickable + saveSecuritySettings widget.Clickable + editEntry widget.Clickable + cancelEdit widget.Clickable + pickVaultPath widget.Clickable + pickKeyFile widget.Clickable + pickSyncLocalPath widget.Clickable + clearVaultSelection widget.Clickable + clearRemoteSelection widget.Clickable + dismissBanner widget.Clickable + addEntry widget.Clickable + saveEntry widget.Clickable + duplicateEntry widget.Clickable + deleteEntry widget.Clickable + restoreEntry widget.Clickable + saveTemplate widget.Clickable + deleteTemplate widget.Clickable + instantiateTemplate widget.Clickable + addAttachment widget.Clickable + replaceAttachment widget.Clickable + removeAttachment widget.Clickable + exportAttachment widget.Clickable + restoreHistory widget.Clickable + generatePassword widget.Clickable + goToRootGroup widget.Clickable + goToParentGroup widget.Clickable + createGroup widget.Clickable + moveGroup widget.Clickable + renameGroup widget.Clickable + deleteGroup widget.Clickable + confirmDeleteGroup widget.Clickable + cancelDeleteGroup widget.Clickable + addCustomField widget.Clickable + toggleGroupControls widget.Clickable + toggleLifecycleAdvanced widget.Clickable + toggleHistory widget.Clickable + togglePasswordInline widget.Clickable + toggleSyncPassword widget.Clickable + showEntries widget.Clickable + showTemplates widget.Clickable + showRecycle widget.Clickable + showAPITokens widget.Clickable + showAPIAudit widget.Clickable + showLocalLifecycle widget.Clickable + showRemoteLifecycle widget.Clickable + showSyncLocal widget.Clickable + showSyncRemote widget.Clickable + showSyncPull widget.Clickable + showSyncPush widget.Clickable + allowApproval widget.Clickable + denyApproval widget.Clickable + cancelApproval widget.Clickable + cancelLifecycleProgress widget.Clickable + retryLifecycleOpen widget.Clickable + approvalPermanent widget.Bool + rememberRemoteAuth widget.Bool + apiPolicyAllow widget.Bool + apiPolicyGroupScopeW widget.Bool + apiTokenDisabled widget.Bool + entryClicks []widget.Clickable + apiTokenClicks []widget.Clickable + apiPolicyRemoves []widget.Clickable + apiAuditClicks []widget.Clickable + apiAuditTokenFilters []widget.Clickable + apiAuditDecisionFilters []widget.Clickable + apiAuditOperationFilters []widget.Clickable + clearAPIAuditFilters widget.Clickable + historyClicks []widget.Clickable + attachmentClicks []widget.Clickable + breadcrumbs []widget.Clickable + groupClicks []widget.Clickable + recentVaultClicks []widget.Clickable + recentRemoteClicks []widget.Clickable + removeCustomFields []widget.Clickable + state appstate.State + visible []entry + currentPath []string + syncedPath []string + selectedHistoryIndex int + showPassword bool + generatedPasswordDraft bool + togglePassword widget.Clickable + copyAPITokenSecret widget.Clickable + issueAPIToken widget.Clickable + saveAPIToken widget.Clickable + rotateAPIToken widget.Clickable + disableAPIToken widget.Clickable + revokeAPIToken widget.Clickable + deleteAPIToken widget.Clickable + addAPIPolicyRule widget.Clickable + phoneSplit widget.Float + splitDrag gesture.Drag + splitBase float32 + splitStartY float32 + phoneSpan int + eyeIcon *widget.Icon + eyeOffIcon *widget.Icon + copyIcon *widget.Icon + expandMoreIcon *widget.Icon + expandLessIcon *widget.Icon + chevronDownIcon *widget.Icon + settingsIcon *widget.Icon + clipboardWriter clipboard.Writer + loadingMessage string + loadingActionLabel string + lifecycleMode string + syncSourceMode syncSourceMode + syncDirection syncDirection + syncLocalPath widget.Editor + syncRemoteBaseURL widget.Editor + syncRemotePath widget.Editor + syncRemoteUsername widget.Editor + syncRemotePassword widget.Editor + syncDialogOpen bool + syncMenuOpen bool + securityDialogOpen bool + showSyncPassword bool + keyboardFocus focusID + defaultSaveAsPath string + recentVaultsPath string + uiPreferencesPath string + recentRemotesPath string + autofillCachePath string + editingEntry bool + groupControlsHidden bool + lifecycleAdvancedHidden bool + historyHidden bool + recentVaults []string + recentRemotes []recentRemoteRecord + recentVaultGroups map[string][]string + recentVaultUsedAt map[string]time.Time + entriesState entriesSectionState + deleteGroupPath []string + apiPolicyGroupScope bool + apiTokenSecret string + selectedAuditIndex int + statusExpiresAt time.Time + now func() time.Time + apiHost *api.Host + auditLog *apiaudit.Log + grpcAddress string + backgroundResults chan backgroundActionResult + backgroundActionSerial int + activeBackgroundAction int + lastLifecycleAction string + requestMasterPassFocus bool + invalidate func() } type backgroundActionResult struct { @@ -2265,7 +2269,7 @@ func (u *ui) listEmptyState() emptyState { case appstate.SectionAPIAudit: return emptyState{ 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: return emptyState{ @@ -2293,7 +2297,7 @@ func (u *ui) listEmptyState() emptyState { case appstate.SectionAPIAudit: return emptyState{ 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: return emptyState{ @@ -2332,7 +2336,7 @@ func (u *ui) detailPlaceholderMessage() string { case appstate.SectionAPITokens: return "Select an API token, issue a new one, or search to narrow the list." 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: return "Select a template or start a reusable entry." case appstate.SectionRecycleBin: diff --git a/main_test.go b/main_test.go index 69e2ae0..0e24934 100644 --- a/main_test.go +++ b/main_test.go @@ -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) { t.Parallel() diff --git a/ui_api.go b/ui_api.go index ba79d8f..921b6a6 100644 --- a/ui_api.go +++ b/ui_api.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "image/color" "strings" "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 { tokens, err := u.state.APITokens() if err != nil { @@ -274,15 +399,7 @@ func (u *ui) apiAuditEvents() []apiaudit.Event { } 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, - }, " ")) + haystack := apiAuditEventSearchTerms(event) if strings.Contains(haystack, query) { 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 { 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, layout.Rigid(func(gtx layout.Context) layout.Dimensions { 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(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 return lbl.Layout(gtx) }), 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 { 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 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 { token, ok := u.selectedAPIToken() removeClicks := u.ensureAPIPolicyRemoveClickables(0) diff --git a/ui_forms.go b/ui_forms.go index e9ef73f..9c261ca 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -269,11 +269,28 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { if busy || u.lifecycleAdvancedHidden { return layout.Dimensions{} } - 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(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)), - ) + if u.lifecycleMode == "remote" { + return 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), "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(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 { 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 {