1216 lines
42 KiB
Go
1216 lines
42 KiB
Go
package appui
|
|
|
|
import (
|
|
"fmt"
|
|
"image/color"
|
|
"strings"
|
|
"time"
|
|
|
|
"gioui.org/layout"
|
|
"gioui.org/unit"
|
|
"gioui.org/widget"
|
|
"gioui.org/widget/material"
|
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
|
apiui "git.julianfamily.org/keepassgo/internal/appui/api"
|
|
)
|
|
|
|
type apiAuditQuickFilter = apiui.AuditQuickFilter
|
|
|
|
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 := apiui.AuditDecisionLabel(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 := apiui.AuditOperationLabel(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) ensureAPIPolicyEditClickables(count int) []widget.Clickable {
|
|
if count <= 0 {
|
|
u.apiPolicyEdits = nil
|
|
return nil
|
|
}
|
|
if len(u.apiPolicyEdits) == count {
|
|
return u.apiPolicyEdits
|
|
}
|
|
clicks := make([]widget.Clickable, count)
|
|
copy(clicks, u.apiPolicyEdits)
|
|
u.apiPolicyEdits = clicks
|
|
return clicks
|
|
}
|
|
|
|
func (u *ui) loadSelectedAPITokenIntoEditor() {
|
|
u.selectedAPIPolicyIndex = -1
|
|
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.ensureAPIPolicyEditClickables(0)
|
|
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.ensureAPIPolicyEditClickables(len(token.Policies))
|
|
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 apiui.Operations() {
|
|
if operation == value {
|
|
return value, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("unknown API operation %q", text)
|
|
}
|
|
|
|
func (u *ui) apiPolicyRuleFromEditor() (apitokens.PolicyRule, error) {
|
|
operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text())
|
|
if err != nil {
|
|
return apitokens.PolicyRule{}, 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 apitokens.PolicyRule{}, 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 apitokens.PolicyRule{}, fmt.Errorf("entry id is required for entry scope")
|
|
}
|
|
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID}
|
|
}
|
|
return rule, nil
|
|
}
|
|
|
|
func (u *ui) addAPIPolicyRuleAction() error {
|
|
token, ok := u.selectedAPIToken()
|
|
if !ok {
|
|
return fmt.Errorf("no API token selected")
|
|
}
|
|
rule, err := u.apiPolicyRuleFromEditor()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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) editAPIPolicyRuleAction(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)
|
|
}
|
|
rule := token.Policies[index]
|
|
u.selectedAPIPolicyIndex = index
|
|
u.apiPolicyOperation.SetText(string(rule.Operation))
|
|
u.apiPolicyAllow.Value = rule.Effect == apitokens.EffectAllow
|
|
if rule.Resource.Kind == apitokens.ResourceEntry {
|
|
u.apiPolicyGroupScope = false
|
|
u.apiPolicyGroupScopeW.Value = false
|
|
u.apiPolicyEntryID.SetText(strings.TrimSpace(rule.Resource.EntryID))
|
|
u.apiPolicyPath.SetText("")
|
|
return nil
|
|
}
|
|
u.apiPolicyGroupScope = true
|
|
u.apiPolicyGroupScopeW.Value = true
|
|
u.apiPolicyPath.SetText(strings.Join(rule.Resource.Path, " / "))
|
|
u.apiPolicyEntryID.SetText("")
|
|
return nil
|
|
}
|
|
|
|
func (u *ui) saveAPIPolicyRuleAction() error {
|
|
token, ok := u.selectedAPIToken()
|
|
if !ok {
|
|
return fmt.Errorf("no API token selected")
|
|
}
|
|
index := u.selectedAPIPolicyIndex
|
|
if index < 0 || index >= len(token.Policies) {
|
|
return fmt.Errorf("no API policy rule selected")
|
|
}
|
|
rule, err := u.apiPolicyRuleFromEditor()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, existing := range token.Policies {
|
|
if i != index && uiHasPolicyRule([]apitokens.PolicyRule{existing}, rule) {
|
|
token.Policies = append(token.Policies[:index], token.Policies[index+1:]...)
|
|
if err := u.state.UpsertAPIToken(token); err != nil {
|
|
return err
|
|
}
|
|
u.loadSelectedAPITokenIntoEditor()
|
|
return nil
|
|
}
|
|
}
|
|
token.Policies[index] = rule
|
|
if err := u.state.UpsertAPIToken(token); err != nil {
|
|
return err
|
|
}
|
|
u.loadSelectedAPITokenIntoEditor()
|
|
return nil
|
|
}
|
|
|
|
func (u *ui) apiPolicyGroupPathSummary() string {
|
|
path := parsePath(u.apiPolicyPath.Text())
|
|
if len(path) == 0 {
|
|
return "No group selected"
|
|
}
|
|
return strings.Join(path, " / ")
|
|
}
|
|
|
|
func (u *ui) apiPolicyEntrySummary() string {
|
|
id := strings.TrimSpace(u.apiPolicyEntryID.Text())
|
|
if id == "" {
|
|
return "No entry selected"
|
|
}
|
|
if item, ok := u.selectedEntry(); ok && item.ID == id {
|
|
if strings.TrimSpace(item.Title) != "" {
|
|
return item.Title + " (" + id + ")"
|
|
}
|
|
}
|
|
return id
|
|
}
|
|
|
|
func (u *ui) useCurrentGroupForPolicyAction() error {
|
|
u.syncCurrentPath()
|
|
path := u.displayPath()
|
|
if len(path) == 0 {
|
|
return fmt.Errorf("navigate to a group first")
|
|
}
|
|
u.apiPolicyGroupScope = true
|
|
u.apiPolicyGroupScopeW.Value = true
|
|
u.apiPolicyPath.SetText(strings.Join(path, " / "))
|
|
u.apiPolicyEntryID.SetText("")
|
|
return nil
|
|
}
|
|
|
|
func (u *ui) useSelectedEntryForPolicyAction() error {
|
|
item, ok := u.selectedEntry()
|
|
if !ok || strings.TrimSpace(item.ID) == "" {
|
|
return fmt.Errorf("select an entry first")
|
|
}
|
|
u.apiPolicyGroupScope = false
|
|
u.apiPolicyGroupScopeW.Value = false
|
|
u.apiPolicyEntryID.SetText(item.ID)
|
|
return nil
|
|
}
|
|
|
|
func (u *ui) clearAPIPolicyTargetAction() error {
|
|
u.apiPolicyPath.SetText("")
|
|
u.apiPolicyEntryID.SetText("")
|
|
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) cancelAPIPolicyEditAction() error {
|
|
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 := apiui.AuditEventSearchTerms(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, apiui.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, apiui.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()
|
|
editClicks := u.ensureAPIPolicyEditClickables(0)
|
|
removeClicks := u.ensureAPIPolicyRemoveClickables(0)
|
|
if ok {
|
|
editClicks = u.ensureAPIPolicyEditClickables(len(token.Policies))
|
|
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, &editClicks[index], "Edit")
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.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 {
|
|
actionLabel := "Add Rule"
|
|
title := "Policy Composer"
|
|
description := "Rules are evaluated per operation. Explicit deny rules override allow rules."
|
|
if 0 <= u.selectedAPIPolicyIndex {
|
|
actionLabel = "Save Rule"
|
|
title = "Policy Editor"
|
|
description = "Editing an existing rule. Save the updated scope or cancel to return to a blank composer."
|
|
}
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
lbl := material.Label(u.theme, unit.Sp(14), title)
|
|
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), description)
|
|
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(apiui.Operations()), ", "), &u.apiPolicyOperation, false)),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.apiPolicyGroupScopeW.Value {
|
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(detailLine(u.theme, "Group Path", u.apiPolicyGroupPathSummary())),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.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.useCurrentGroupForPolicy, "Use Current Group")
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
layout.Rigid(detailLine(u.theme, "Entry", u.apiPolicyEntrySummary())),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.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.useSelectedEntryForPolicy, "Use Selected Entry")
|
|
}),
|
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
})
|
|
}),
|
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.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 {
|
|
if 0 <= u.selectedAPIPolicyIndex {
|
|
return tonedButton(gtx, u.theme, &u.saveAPIPolicyRule, actionLabel)
|
|
}
|
|
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, actionLabel)
|
|
}),
|
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
if u.selectedAPIPolicyIndex < 0 {
|
|
return layout.Dimensions{}
|
|
}
|
|
return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
return tonedButton(gtx, u.theme, &u.cancelAPIPolicyEdit, "Cancel Edit")
|
|
})
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
})
|
|
},
|
|
)
|
|
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, " / ")
|
|
}
|