package main import ( "fmt" "image/color" "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, } } 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 { 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) ensureAPIPolicyRemoveClickables(count int) []widget.Clickable { if count <= 0 { u.apiPolicyRemoves = nil return nil } if len(u.apiPolicyRemoves) == count { return u.apiPolicyRemoves } clicks := make([]widget.Clickable, count) copy(clicks, u.apiPolicyRemoves) u.apiPolicyRemoves = clicks return clicks } 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.ensureAPIPolicyRemoveClickables(0) 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 u.ensureAPIPolicyRemoveClickables(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 := apiAuditEventSearchTerms(event) 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 policyRuleParts(rule apitokens.PolicyRule) (string, string, string) { effect := strings.ToUpper(string(rule.Effect)) operation := string(rule.Operation) resource := "Vault root" if rule.Resource.Kind == apitokens.ResourceEntry { resource = "Entry: " + rule.Resource.EntryID } else if len(rule.Resource.Path) > 0 { resource = strings.Join(rule.Resource.Path, " / ") } return effect, operation, resource } func apiTokenStatusSummary(token apitokens.Token) string { parts := []string{"Active"} if token.Disabled { parts[0] = "Disabled" } if token.RevokedAt != nil { parts[0] = "Revoked" } if token.ExpiresAt != nil { parts = append(parts, "Expires "+token.ExpiresAt.Local().Format(time.RFC3339)) } else { parts = append(parts, "No expiration") } if len(token.Policies) == 1 { parts = append(parts, "1 policy rule") } else { parts = append(parts, fmt.Sprintf("%d policy rules", len(token.Policies))) } return strings.Join(parts, " · ") } func apiTokenManagementTitle(token apitokens.Token, ok bool) string { if !ok { return "Issue API Token" } if strings.TrimSpace(token.Name) != "" { return token.Name } return "Unnamed API Token" } func apiTokenManagementSubtitle(token apitokens.Token, ok bool) string { if !ok { return "Create a scoped gRPC credential, then select it here to inspect identity, lifecycle, and policy rules." } client := strings.TrimSpace(token.ClientName) if client == "" { client = "No client name" } return client + " · " + apiTokenStatusSummary(token) } 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(layout.Spacer{Height: unit.Dp(2)}.Layout), 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(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), apiTokenStatusSummary(token)) 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(11), "ID "+token.ID) 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), string(event.Operation)) lbl.Color = mutedColor 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.Rigid(func(gtx layout.Context) layout.Dimensions { return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { selected, ok := u.selectedAPIToken() 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 Directory") 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), "Grant scoped gRPC access to external tools. Search matches token name, client, token id, and policy details.") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { title := fmt.Sprintf("%d tokens managed", len(tokens)) if ok { title = "Selected: " + apiTokenManagementTitle(selected, true) } lbl := material.Label(u.theme, unit.Sp(12), title) lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !ok { return layout.Dimensions{} } lbl := material.Label(u.theme, unit.Sp(11), apiTokenManagementSubtitle(selected, true)) 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(tokens) == 0 { return emptyStatePanel(gtx, u.theme, u.listEmptyState()) } 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() 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" 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.Rigid(func(gtx layout.Context) layout.Dimensions { 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), u.listEmptyMessage()) 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) 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) if ok { removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies)) } rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { return card(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(20), "API Token Management") 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(16), apiTokenManagementTitle(token, ok)) 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), apiTokenManagementSubtitle(token, ok)) lbl.Color = mutedColor return lbl.Layout(gtx) }), ) }) }, } rows = append(rows, layout.Spacer{Height: unit.Dp(10)}.Layout, func(gtx layout.Context) layout.Dimensions { return card(gtx, func(gtx layout.Context) layout.Dimensions { content := []layout.FlexChild{ layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), "Identity") lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(labeledEditor(u.theme, "Name", &u.apiTokenName, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditor(u.theme, "Client Name", &u.apiTokenClientName, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorHelp(u.theme, "Expires At", "Optional RFC3339 timestamp, for example 2026-04-01T15:04:05Z.", &u.apiTokenExpiresAt, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return material.CheckBox(u.theme, &u.apiTokenDisabled, "Disabled").Layout(gtx) }), } if ok { content = append(content, layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(detailLine(u.theme, "Token ID", token.ID)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(detailLine(u.theme, "Created", token.CreatedAt.Local().Format(time.RFC3339))), ) if token.RevokedAt != nil { content = append(content, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(detailLine(u.theme, "Revoked", token.RevokedAt.Local().Format(time.RFC3339))), ) } } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, content...) }) }, ) if ok { rows = append(rows, layout.Spacer{Height: unit.Dp(10)}.Layout, func(gtx layout.Context) layout.Dimensions { return card(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(14), "Lifecycle") lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Save updates, rotate secret material, or shut a token down without leaving this surface.") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(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{Height: unit.Dp(6)}.Layout), layout.Rigid(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.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.Rigid(func(gtx layout.Context) layout.Dimensions { if strings.TrimSpace(u.apiTokenSecret) == "" { return layout.Dimensions{} } return layout.Inset{Top: unit.Dp(10)}.Layout(gtx, 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(12), "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") }), ) }) }) }), ) }) }, layout.Spacer{Height: unit.Dp(10)}.Layout, func(gtx layout.Context) layout.Dimensions { return card(gtx, func(gtx layout.Context) layout.Dimensions { sectionRows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(14), "Policy Rules") lbl.Color = accentColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(4)}.Layout, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Effect, operation, and resource are separated so rules scan quickly while you review token scope.") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(10)}.Layout, } if len(token.Policies) > 0 { for i, rule := range token.Policies { index := i effect, operation, resource := policyRuleParts(rule) sectionRows = append(sectionRows, 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 { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, detailLine(u.theme, "Effect", effect)), layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &removeClicks[index], "Remove") }), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(detailLine(u.theme, "Operation", operation)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(detailLine(u.theme, "Resource", resource)), ) }) }, layout.Spacer{Height: unit.Dp(6)}.Layout, ) } } else { sectionRows = append(sectionRows, 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) }) } return material.List(u.theme, &u.apiPolicyList).Layout(gtx, len(sectionRows), func(gtx layout.Context, i int) layout.Dimensions { return sectionRows[i](gtx) }) }) }, layout.Spacer{Height: unit.Dp(10)}.Layout, ) } rows = append(rows, func(gtx layout.Context) layout.Dimensions { return card(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(14), "Policy Composer") 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), "Rules are evaluated per operation. Explicit deny rules override allow rules.") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return material.CheckBox(u.theme, &u.apiPolicyAllow, "Allow rule (unchecked means deny rule)").Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorHelp(u.theme, "Group Path", "Used when group scope is enabled.", &u.apiPolicyPath, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditorHelp(u.theme, "Entry ID", "Used when group scope is disabled.", &u.apiPolicyEntryID, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(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, func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "Selected event") lbl.Color = mutedColor return lbl.Layout(gtx) }, layout.Spacer{Height: unit.Dp(6)}.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(detailLine(u.theme, "Type", string(event.Type))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(detailLine(u.theme, "Operation", string(event.Operation))), ) }) }, layout.Spacer{Height: unit.Dp(8)}.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, "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, " / ") }