Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 1 addition & 10 deletions tui/tabs/browse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
105 changes: 105 additions & 0 deletions tui/tabs/credential_items.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion tui/tabs/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}
149 changes: 149 additions & 0 deletions tui/tabs/credentials_flow_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
11 changes: 11 additions & 0 deletions tui/tabs/tab.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions tui/tabs/tabs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading