From f205e753e851d6a2c06e89c71967eb4c597916d9 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 1 Apr 2026 17:12:16 -0700 Subject: [PATCH] Refine API token management UI --- main.go | 4 + main_test.go | 97 +++++++++++ ui_api.go | 470 ++++++++++++++++++++++++++++++++------------------- 3 files changed, 394 insertions(+), 177 deletions(-) diff --git a/main.go b/main.go index 381d47b..ba58008 100644 --- a/main.go +++ b/main.go @@ -182,6 +182,7 @@ type ui struct { list widget.List groupList widget.List detailList widget.List + apiPolicyList widget.List lifecycleList widget.List copyUser widget.Clickable copyPass widget.Clickable @@ -431,6 +432,9 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) detailList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, + apiPolicyList: widget.List{ + List: layout.List{Axis: layout.Vertical}, + }, lifecycleList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, diff --git a/main_test.go b/main_test.go index 87f37dc..e33da3b 100644 --- a/main_test.go +++ b/main_test.go @@ -300,6 +300,103 @@ func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) { } } +func TestAPITokenStatusSummary(t *testing.T) { + t.Parallel() + + expiresAt := time.Date(2026, 4, 1, 15, 4, 5, 0, time.UTC) + revokedAt := time.Date(2026, 4, 2, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + token apitokens.Token + want string + }{ + { + name: "active non expiring", + token: apitokens.Token{}, + want: "Active · No expiration · 0 policy rules", + }, + { + name: "disabled expiring single rule", + token: apitokens.Token{ + Disabled: true, + ExpiresAt: &expiresAt, + Policies: []apitokens.PolicyRule{{}}, + }, + want: "Disabled · Expires " + expiresAt.Local().Format(time.RFC3339) + " · 1 policy rule", + }, + { + name: "revoked overrides disabled", + token: apitokens.Token{ + Disabled: true, + RevokedAt: &revokedAt, + Policies: []apitokens.PolicyRule{{}, {}}, + }, + want: "Revoked · No expiration · 2 policy rules", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := apiTokenStatusSummary(tt.token); got != tt.want { + t.Fatalf("apiTokenStatusSummary(%#v) = %q, want %q", tt.token, got, tt.want) + } + }) + } +} + +func TestAPITokenManagementSummaryText(t *testing.T) { + t.Parallel() + + token := apitokens.Token{ + Name: "Browser Extension", + ClientName: "firefox", + } + + if got := apiTokenManagementTitle(apitokens.Token{}, false); got != "Issue API Token" { + t.Fatalf("apiTokenManagementTitle(no selection) = %q, want %q", got, "Issue API Token") + } + if got := apiTokenManagementTitle(token, true); got != "Browser Extension" { + t.Fatalf("apiTokenManagementTitle(%#v) = %q, want %q", token, got, "Browser Extension") + } + if got := apiTokenManagementSubtitle(apitokens.Token{}, false); got != "Create a scoped gRPC credential, then select it here to inspect identity, lifecycle, and policy rules." { + t.Fatalf("apiTokenManagementSubtitle(no selection) = %q, want default management guidance", got) + } + if got := apiTokenManagementSubtitle(token, true); got != "firefox · Active · No expiration · 0 policy rules" { + t.Fatalf("apiTokenManagementSubtitle(%#v) = %q, want %q", token, got, "firefox · Active · No expiration · 0 policy rules") + } +} + +func TestPolicyRulePartsFormatsGroupAndEntryResources(t *testing.T) { + t.Parallel() + + groupRule := apitokens.PolicyRule{ + Effect: apitokens.EffectAllow, + Operation: apitokens.OperationListEntries, + Resource: apitokens.Resource{ + Kind: apitokens.ResourceGroup, + Path: []string{"Root", "Internet"}, + }, + } + entryRule := apitokens.PolicyRule{ + Effect: apitokens.EffectDeny, + Operation: apitokens.OperationCopyPassword, + Resource: apitokens.Resource{ + Kind: apitokens.ResourceEntry, + EntryID: "vault-console", + }, + } + + if effect, operation, resource := policyRuleParts(groupRule); effect != "ALLOW" || operation != string(apitokens.OperationListEntries) || resource != "Root / Internet" { + t.Fatalf("policyRuleParts(groupRule) = (%q, %q, %q), want (%q, %q, %q)", effect, operation, resource, "ALLOW", apitokens.OperationListEntries, "Root / Internet") + } + if effect, operation, resource := policyRuleParts(entryRule); effect != "DENY" || operation != string(apitokens.OperationCopyPassword) || resource != "Entry: vault-console" { + t.Fatalf("policyRuleParts(entryRule) = (%q, %q, %q), want (%q, %q, %q)", effect, operation, resource, "DENY", apitokens.OperationCopyPassword, "Entry: vault-console") + } +} + func TestUIAPITokenDetailPanelHandlesMissingRemoveClickables(t *testing.T) { t.Parallel() diff --git a/ui_api.go b/ui_api.go index 07ecccc..ba79d8f 100644 --- a/ui_api.go +++ b/ui_api.go @@ -296,7 +296,7 @@ func (u *ui) apiAuditEvents() []apiaudit.Event { func policyRuleParts(rule apitokens.PolicyRule) (string, string, string) { effect := strings.ToUpper(string(rule.Effect)) operation := string(rule.Operation) - resource := "/" + resource := "Vault root" if rule.Resource.Kind == apitokens.ResourceEntry { resource = "Entry: " + rule.Resource.EntryID } else if len(rule.Resource.Path) > 0 { @@ -305,6 +305,48 @@ func policyRuleParts(rule apitokens.PolicyRule) (string, string, string) { 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 { @@ -336,23 +378,21 @@ func (u *ui) apiTokenRow(gtx layout.Context, click *widget.Clickable, idx int, t 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 { - text := "Non-expiring" - if token.ExpiresAt != nil { - text = "Expires " + token.ExpiresAt.Local().Format(time.RFC3339) - } - if token.Disabled { - text = "Disabled · " + text - } - if token.RevokedAt != nil { - text = "Revoked · " + text - } - lbl := material.Label(u.theme, unit.Sp(12), text) + 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) }), @@ -427,9 +467,40 @@ 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 { - lbl := material.Label(u.theme, unit.Sp(12), "Grant scoped gRPC access to external tools. Search matches token name, client, or expiration.") - lbl.Color = mutedColor - return lbl.Layout(gtx) + 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 { @@ -483,189 +554,234 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions { } rows := []layout.Widget{ func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(20), "API Token") - lbl.Color = accentColor - return lbl.Layout(gtx) - }, - layout.Spacer{Height: unit.Dp(8)}.Layout, - func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "Identity") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }, - layout.Spacer{Height: unit.Dp(4)}.Layout, - labeledEditor(u.theme, "Name", &u.apiTokenName, false), - layout.Spacer{Height: unit.Dp(6)}.Layout, - labeledEditor(u.theme, "Client Name", &u.apiTokenClientName, false), - layout.Spacer{Height: unit.Dp(6)}.Layout, - labeledEditorHelp(u.theme, "Expires At", "Optional RFC3339 timestamp, for example 2026-04-01T15:04:05Z.", &u.apiTokenExpiresAt, false), - layout.Spacer{Height: unit.Dp(6)}.Layout, - func(gtx layout.Context) layout.Dimensions { - return material.CheckBox(u.theme, &u.apiTokenDisabled, "Disabled").Layout(gtx) + 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(6)}.Layout, - detailLine(u.theme, "Token ID", token.ID), - layout.Spacer{Height: unit.Dp(6)}.Layout, - detailLine(u.theme, "Created", token.CreatedAt.Local().Format(time.RFC3339)), - ) - if token.RevokedAt != nil { - rows = append(rows, - layout.Spacer{Height: unit.Dp(6)}.Layout, - detailLine(u.theme, "Revoked", token.RevokedAt.Local().Format(time.RFC3339)), - ) - } - } - if strings.TrimSpace(u.apiTokenSecret) != "" { rows = append(rows, layout.Spacer{Height: unit.Dp(10)}.Layout, func(gtx layout.Context) layout.Dimensions { - return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return 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(13), "ONE-TIME SECRET") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(15), u.apiTokenSecret) + lbl := 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 tonedButton(gtx, u.theme, &u.copyAPITokenSecret, "Copy Secret") + 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, - layout.Spacer{Height: unit.Dp(10)}.Layout, func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "Token actions") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }, - layout.Spacer{Height: unit.Dp(6)}.Layout, - 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{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.Spacer{Height: unit.Dp(14)}.Layout, - func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(16), "Policy Rules") - lbl.Color = accentColor - return lbl.Layout(gtx) - }, - layout.Spacer{Height: unit.Dp(8)}.Layout, - ) - if ok && len(token.Policies) > 0 { - for i, rule := range token.Policies { - index := i - effect, operation, resource := policyRuleParts(rule) - rows = append(rows, - 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.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), effect) - lbl.Color = accentColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), operation) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &removeClicks[index], "Remove") - }), - ) - }), - 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), resource) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - ) - }) - }, - layout.Spacer{Height: unit.Dp(6)}.Layout, - ) - } - } else { - rows = append(rows, func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(13), "No explicit rules yet. Approval prompts can create permanent rules.") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }) - } - rows = append(rows, - layout.Spacer{Height: unit.Dp(10)}.Layout, - func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "New rule") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }, - layout.Spacer{Height: unit.Dp(6)}.Layout, - func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), "Rules are evaluated per operation. Explicit deny rules override allow rules.") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }, - layout.Spacer{Height: unit.Dp(6)}.Layout, - func(gtx layout.Context) layout.Dimensions { - return material.CheckBox(u.theme, &u.apiPolicyAllow, "Allow rule (unchecked means deny rule)").Layout(gtx) - }, - layout.Spacer{Height: unit.Dp(6)}.Layout, - func(gtx layout.Context) layout.Dimensions { - return material.CheckBox(u.theme, &u.apiPolicyGroupScopeW, "Group scope (unchecked means exact entry scope)").Layout(gtx) - }, - layout.Spacer{Height: unit.Dp(6)}.Layout, - labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false), - layout.Spacer{Height: unit.Dp(6)}.Layout, - labeledEditorHelp(u.theme, "Group Path", "Used when group scope is enabled.", &u.apiPolicyPath, false), - layout.Spacer{Height: unit.Dp(6)}.Layout, - labeledEditorHelp(u.theme, "Entry ID", "Used when group scope is disabled.", &u.apiPolicyEntryID, false), - layout.Spacer{Height: unit.Dp(6)}.Layout, - func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule") + return 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 {