From 637779b264320420c894afe99d0be44c44115d7c Mon Sep 17 00:00:00 2001 From: lvlcn-t <75443136+lvlcn-t@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:30:55 +0200 Subject: [PATCH] feat(tui): wire credentials CRUD end-to-end Final PR in the TUI CRUD series. Adds create, edit, rename, and delete for credentials, completing CRUD across all three tabs. - CredentialsTab uses the CRUD browse variant, binding n/e/r/ctrl+d. - credentialForm is type-driven: the type field is validated against the four supported types; all conditional fields (client id/secret/cert, token source, and the OIDC issuer/client-id/redirect/scopes) are shown, and credentialFromValues assembles only the fields relevant to the chosen type, letting the domain validate the result. On edit the name is locked read-only. - Dispatch maps intent to CreateCredential/UpdateCredential/ RenameCredential/DeleteCredential; rename cascades to referencing contexts and delete reports orphaned contexts in the status line. - Removed the now-unused view-only browse constructor; all tabs are CRUD. Tests cover service-principal and workload-identity (OAuth2) create, invalid-type rejection, in-place edit with a type change, rename with reference cascade, and delete. Signed-off-by: lvlcn-t <75443136+lvlcn-t@users.noreply.github.com> --- tui/tabs/browse.go | 11 +-- tui/tabs/credential_items.go | 105 +++++++++++++++++++++ tui/tabs/credentials.go | 2 +- tui/tabs/credentials_flow_test.go | 149 ++++++++++++++++++++++++++++++ tui/tabs/tab.go | 11 +++ tui/tabs/tabs.go | 47 ++++++++++ tui/tabs/tabs_test.go | 5 +- 7 files changed, 317 insertions(+), 13 deletions(-) create mode 100644 tui/tabs/credentials_flow_test.go diff --git a/tui/tabs/browse.go b/tui/tabs/browse.go index da40b66..a329c84 100644 --- a/tui/tabs/browse.go +++ b/tui/tabs/browse.go @@ -19,16 +19,7 @@ type browseTab struct { keys tabKeys } -func newBrowseTab(s *state.UI, rebuild func(*config.Store) []list.Item, l listBuilder) browseTab { //nolint:gocritic // irrelevant on startup - tk := newTabKeys( - keys.New(keys.Enter).WithHelp("view").WithAliases(keys.View, keys.Describe).Bind(), - key.Binding{}, - keys.New(keys.Escape).WithHelp("close").Bind(), - ) - return buildBrowseTab(s, rebuild, l, tk) -} - -// newCRUDBrowseTab is like newBrowseTab but also binds create, edit, rename, and +// newCRUDBrowseTab builds a browse tab that also binds create, edit, rename, and // delete keys so the tab can emit CRUD actions. func newCRUDBrowseTab(s *state.UI, rebuild func(*config.Store) []list.Item, l listBuilder) browseTab { //nolint:gocritic // irrelevant on startup tk := newTabKeys( diff --git a/tui/tabs/credential_items.go b/tui/tabs/credential_items.go index d7c50f2..170088e 100644 --- a/tui/tabs/credential_items.go +++ b/tui/tabs/credential_items.go @@ -9,6 +9,7 @@ import ( "github.com/lvlcn-t/azctx/config" "github.com/lvlcn-t/azctx/keyvault" "github.com/lvlcn-t/azctx/tui/details" + "github.com/lvlcn-t/azctx/tui/form" ) var ( @@ -125,3 +126,107 @@ func (i *CredentialItem) workloadIdentityRows() []details.Row { return rows } + +// credentialForm builds the create or edit form for a credential. All fields +// are shown; only the ones relevant to the chosen type are used at submit time, +// where the domain validates the result. On edit the name is locked read-only. +func credentialForm(intent formIntent, item details.Item) form.Model { + var c config.Credential + title := "New credential" + readonly := false + if cred, ok := item.(*CredentialItem); ok && intent == intentEdit { + c = cred.Credential + title = "Edit credential" + readonly = true + } + + azure := c.Details.Azure + oauth := config.OAuth2Source{} + tokenFile := "" + if c.Details.Token.OAuth2 != nil { + oauth = *c.Details.Token.OAuth2 + } + if c.Details.Token.File != nil { + tokenFile = c.Details.Token.File.Path + } + return form.New(title, []form.Field{ + {Key: fieldName, Label: labelName, Placeholder: "my-credential", Value: c.Name, Required: true, ReadOnly: readonly}, + { + Key: fieldType, Label: "Type", Value: c.Details.Type.String(), Required: true, + Placeholder: "user | service-principal | managed-identity | workload-identity", + Validate: credentialTypeValidator, + }, + {Key: fieldClientID, Label: "Client ID", Value: azure.ClientID, Placeholder: "for sp/mi/wif"}, + {Key: fieldClientSecret, Label: "Client Secret", Value: azure.ClientSecret, Placeholder: "for service-principal"}, + {Key: fieldCertPath, Label: "Cert Path", Value: azure.ClientCertificatePath, Placeholder: "for service-principal"}, + {Key: fieldTokenSource, Label: "Token Source", Value: c.Details.Token.Source.String(), Placeholder: "file | oauth2 (for wif)"}, + {Key: fieldTokenFile, Label: "Token File", Value: tokenFile, Placeholder: "for wif file source"}, + {Key: fieldIssuer, Label: "OIDC Issuer", Value: oauth.Issuer, Placeholder: "for wif oauth2"}, + {Key: fieldOIDCClientID, Label: "OIDC Client ID", Value: oauth.ClientID, Placeholder: "for wif oauth2"}, + {Key: fieldRedirectURI, Label: "Redirect URI", Value: oauth.RedirectURI, Placeholder: "optional"}, + {Key: fieldScopes, Label: "Scopes", Value: strings.Join(oauth.Scopes, ","), Placeholder: "comma-separated"}, + }) +} + +// credentialTypeValidator rejects an unsupported credential type. +func credentialTypeValidator(value string) error { + _, err := config.NewCredentialType(value) + return err +} + +// credentialFromValues assembles a config.Credential from submitted form values. +// Only the fields relevant to the chosen type are populated; the domain +// validates the result. +func credentialFromValues(values map[string]string) *config.Credential { + credType, _ := config.NewCredentialType(values[fieldType]) + + d := config.CredentialDetails{ + Type: credType, + Azure: config.AzureCredential{ + ClientID: values[fieldClientID], + ClientSecret: values[fieldClientSecret], + ClientCertificatePath: values[fieldCertPath], + }, + } + + if credType == config.CredentialTypeWorkloadIdentity { + d.Token = tokenFromValues(values) + } + + return &config.Credential{Name: values[fieldName], Details: d} +} + +// tokenFromValues builds the token details for a workload-identity credential. +func tokenFromValues(values map[string]string) config.TokenDetails { + source := config.TokenSource(values[fieldTokenSource]) + token := config.TokenDetails{Source: source} + + switch source { + case config.TokenSourceFile: + token.File = &config.FileSource{Path: values[fieldTokenFile]} + case config.TokenSourceOAuth2: + token.OAuth2 = &config.OAuth2Source{ + Issuer: values[fieldIssuer], + ClientID: values[fieldOIDCClientID], + RedirectURI: values[fieldRedirectURI], + Scopes: splitScopes(values[fieldScopes]), + } + } + + return token +} + +// splitScopes parses a comma-separated scope list, trimming blanks. +func splitScopes(raw string) []string { + if raw == "" { + return nil + } + + var scopes []string + for _, s := range strings.Split(raw, ",") { + if trimmed := strings.TrimSpace(s); trimmed != "" { + scopes = append(scopes, trimmed) + } + } + return scopes +} diff --git a/tui/tabs/credentials.go b/tui/tabs/credentials.go index 1872184..be78383 100644 --- a/tui/tabs/credentials.go +++ b/tui/tabs/credentials.go @@ -10,6 +10,6 @@ type CredentialsTab struct { func credentialsTab(s *state.UI, l listBuilder) *CredentialsTab { //nolint:gocritic // irrelevant on startup return &CredentialsTab{ - browseTab: newBrowseTab(s, credentialItems, l), + browseTab: newCRUDBrowseTab(s, credentialItems, l), } } diff --git a/tui/tabs/credentials_flow_test.go b/tui/tabs/credentials_flow_test.go new file mode 100644 index 0000000..eac8a7c --- /dev/null +++ b/tui/tabs/credentials_flow_test.go @@ -0,0 +1,149 @@ +package tabs + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/lvlcn-t/azctx/config" + "github.com/lvlcn-t/azctx/tui/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fieldTab moves focus forward count times. +func fieldTab(tabs *Tabs, count int) { + for range count { + tabs.Update(tea.KeyMsg{Type: tea.KeyTab}) + } +} + +func TestTabs_CreateCredential_ServicePrincipal(t *testing.T) { + path := writeConfig(t, baseConfig()) + tabs := newTabsOn(t, credentialsTabIndex) + + tabs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) + require.True(t, tabs.state.Is(state.FormView)) + + // name (0) + typeRunes(tabs, "ci-sp") + fieldTab(tabs, 1) // -> type + typeRunes(tabs, "service-principal") + fieldTab(tabs, 1) // -> client-id + typeRunes(tabs, "app-1") + fieldTab(tabs, 1) // -> client-secret + typeRunes(tabs, "shhh") + cmd := tabs.Update(tea.KeyMsg{Type: tea.KeyEnter}) + drain(tabs, cmd) + + require.True(t, tabs.state.Is(state.Tabs), "status: %s", tabs.status) + got, found := readConfig(t, path).CredentialByName("ci-sp") + require.True(t, found) + assert.Equal(t, config.CredentialTypeServicePrincipal, got.Details.Type) + assert.Equal(t, "app-1", got.Details.Azure.ClientID) + assert.Equal(t, "shhh", got.Details.Azure.ClientSecret) +} + +func TestTabs_CreateCredential_WorkloadIdentityOAuth2(t *testing.T) { + path := writeConfig(t, baseConfig()) + tabs := newTabsOn(t, credentialsTabIndex) + + tabs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) + + typeRunes(tabs, "wi") + fieldTab(tabs, 1) // type + typeRunes(tabs, "workload-identity") + fieldTab(tabs, 1) // client-id + typeRunes(tabs, "wi-client") + fieldTab(tabs, 3) // -> token-source (skip secret, cert) + typeRunes(tabs, "oauth2") + fieldTab(tabs, 2) // -> issuer (skip token-file) + typeRunes(tabs, "https://issuer.example.com") + fieldTab(tabs, 1) // -> oidc-client-id + typeRunes(tabs, "oidc-client") + fieldTab(tabs, 2) // -> scopes (skip redirect-uri) + typeRunes(tabs, "openid,profile") + cmd := tabs.Update(tea.KeyMsg{Type: tea.KeyEnter}) + drain(tabs, cmd) + + require.True(t, tabs.state.Is(state.Tabs), "status: %s", tabs.status) + got, found := readConfig(t, path).CredentialByName("wi") + require.True(t, found) + assert.Equal(t, config.CredentialTypeWorkloadIdentity, got.Details.Type) + require.NotNil(t, got.Details.Token.OAuth2) + assert.Equal(t, "https://issuer.example.com", got.Details.Token.OAuth2.Issuer) + assert.Equal(t, []string{"openid", "profile"}, got.Details.Token.OAuth2.Scopes) +} + +func TestTabs_CreateCredential_RejectsInvalidType(t *testing.T) { + writeConfig(t, baseConfig()) + tabs := newTabsOn(t, credentialsTabIndex) + + tabs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) + typeRunes(tabs, "bad") + fieldTab(tabs, 1) + typeRunes(tabs, "bogus") + cmd := tabs.Update(tea.KeyMsg{Type: tea.KeyEnter}) + drain(tabs, cmd) + + // Inline validation keeps the form open. + require.True(t, tabs.state.Is(state.FormView)) + assert.Contains(t, tabs.form.View(), "unsupported credential type") +} + +func TestTabs_EditCredential_UpdatesInPlace(t *testing.T) { + path := writeConfig(t, baseConfig()) + tabs := newTabsOn(t, credentialsTabIndex) + + // 'e' on the selected (only) credential 'user'. + tabs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}) + require.True(t, tabs.state.Is(state.FormView)) + require.Equal(t, "user", tabs.form.Values()[fieldName]) + + // user -> managed-identity with a client id. Name is locked; focus starts + // on type. + tabs.Update(tea.KeyMsg{Type: tea.KeyCtrlU}) + typeRunes(tabs, "managed-identity") + fieldTab(tabs, 1) // -> client-id + typeRunes(tabs, "mi-client") + cmd := tabs.Update(tea.KeyMsg{Type: tea.KeyEnter}) + drain(tabs, cmd) + + cfg := readConfig(t, path) + require.Len(t, cfg.Credentials, 1) + assert.Equal(t, "user", cfg.Credentials[0].Name) + assert.Equal(t, config.CredentialTypeManagedIdentity, cfg.Credentials[0].Details.Type) + assert.Equal(t, "mi-client", cfg.Credentials[0].Details.Azure.ClientID) +} + +func TestTabs_RenameCredential(t *testing.T) { + path := writeConfig(t, baseConfig()) + tabs := newTabsOn(t, credentialsTabIndex) + + tabs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}) + require.True(t, tabs.state.Is(state.FormView)) + + typeRunes(tabs, "personal") + cmd := tabs.Update(tea.KeyMsg{Type: tea.KeyEnter}) + drain(tabs, cmd) + + cfg := readConfig(t, path) + require.Len(t, cfg.Credentials, 1) + assert.Equal(t, "personal", cfg.Credentials[0].Name) + // The context referencing 'user' cascaded to 'personal'. + dev, _ := cfg.ContextByName("dev") + assert.Equal(t, "personal", dev.Details.Credential) +} + +func TestTabs_DeleteCredential(t *testing.T) { + path := writeConfig(t, baseConfig()) + tabs := newTabsOn(t, credentialsTabIndex) + + tabs.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) + require.True(t, tabs.state.Is(state.ConfirmView)) + + cmd := tabs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + drain(tabs, cmd) + + _, found := readConfig(t, path).CredentialByName("user") + assert.False(t, found) +} diff --git a/tui/tabs/tab.go b/tui/tabs/tab.go index 0b5b9e6..a405eef 100644 --- a/tui/tabs/tab.go +++ b/tui/tabs/tab.go @@ -19,6 +19,17 @@ const ( fieldTenant = "tenant" fieldCredential = "credential" fieldSubscription = "subscription" + + fieldType = "type" + fieldClientID = "client-id" + fieldClientSecret = "client-secret" + fieldCertPath = "client-certificate-path" + fieldTokenSource = "token-source" + fieldTokenFile = "federated-token-file" + fieldIssuer = "issuer" + fieldOIDCClientID = "oidc-client-id" + fieldRedirectURI = "redirect-uri" + fieldScopes = "scopes" ) // Tab represents a single tab in the UI, responsible for rendering its content and handling interactions. diff --git a/tui/tabs/tabs.go b/tui/tabs/tabs.go index c25fc57..30dcb01 100644 --- a/tui/tabs/tabs.go +++ b/tui/tabs/tabs.go @@ -26,6 +26,11 @@ type Manager interface { UpdateContext(store *config.Store, next config.Context, subscriptionChanged bool) error RenameContext(store *config.Store, oldName, newName string) error DeleteContext(store *config.Store, name string) (contexts.DeleteResult, error) + + CreateCredential(store *config.Store, cred *config.Credential) error + UpdateCredential(store *config.Store, cred *config.Credential) error + RenameCredential(store *config.Store, oldName, newName string) (contexts.RenameResult, error) + DeleteCredential(store *config.Store, name string) (contexts.DeleteResult, error) } // formIntent records what a submitted form should do. @@ -293,6 +298,12 @@ func (t *Tabs) buildForm(intent formIntent, item details.Item) (form.Model, bool } return contextForm(intent, t.state.Config(), item), true + case *CredentialsTab: + if intent == intentRename { + return renameForm("credential", item), true + } + return credentialForm(intent, item), true + default: return form.Model{}, false } @@ -308,6 +319,36 @@ func (t *Tabs) applyForm(values map[string]string) tea.Cmd { return t.applyTenantForm(values) case *ContextsTab: return t.applyContextForm(values) + case *CredentialsTab: + return t.applyCredentialForm(values) + default: + return nil + } +} + +// applyCredentialForm maps a submitted credential form to the matching intent +// method. +func (t *Tabs) applyCredentialForm(values map[string]string) tea.Cmd { + store := t.state.Config() + cred := credentialFromValues(values) + + switch t.intent { + case intentCreate: + err := t.manager.CreateCredential(store, cred) + return t.finish(err, "created credential "+cred.Name) + + case intentEdit: + err := t.manager.UpdateCredential(store, cred) + return t.finish(err, "updated credential "+cred.Name) + + case intentRename: + item, ok := t.pending.(*CredentialItem) + if !ok { + return nil + } + result, err := t.manager.RenameCredential(store, item.Name, values[fieldName]) + return t.finish(err, renameStatus("credential", item.Name, values[fieldName], result.UpdatedContexts)) + default: return nil } @@ -389,6 +430,10 @@ func (t *Tabs) applyDelete() tea.Cmd { } return t.finish(err, status) + case *CredentialItem: + result, err := t.manager.DeleteCredential(t.state.Config(), item.Name) + return t.finish(err, deleteStatus("credential", item.Name, result.OrphanedContexts)) + default: return nil } @@ -432,6 +477,8 @@ func deletableLabel(item details.Item) (string, bool) { return "tenant " + it.Name, true case *ContextItem: return "context " + it.Name, true + case *CredentialItem: + return "credential " + it.Name, true default: return "", false } diff --git a/tui/tabs/tabs_test.go b/tui/tabs/tabs_test.go index f836b8a..c4df8a1 100644 --- a/tui/tabs/tabs_test.go +++ b/tui/tabs/tabs_test.go @@ -14,8 +14,9 @@ import ( // Tab positions in New's slice. const ( - contextsTabIndex = 0 - tenantsTabIndex = 1 + contextsTabIndex = 0 + tenantsTabIndex = 1 + credentialsTabIndex = 2 ) func writeConfig(t *testing.T, cfg *config.Config) string {