diff --git a/cmd/app/input_components.go b/cmd/app/input_components.go index b8cc39df..ee221b20 100644 --- a/cmd/app/input_components.go +++ b/cmd/app/input_components.go @@ -16,8 +16,10 @@ import ( // InputComponentState manages interactive input components type InputComponentState struct { - searchInput textinput.Model - commandInput textinput.Model + searchInput textinput.Model + commandInput textinput.Model + usernameInput textinput.Model + passwordInput textinput.Model } // NewInputComponents creates a new input component state @@ -34,9 +36,25 @@ func NewInputComponents() *InputComponentState { commandInput.CharLimit = 200 commandInput.SetWidth(50) + // Create username input for login + usernameInput := textinput.New() + usernameInput.Placeholder = "Username" + usernameInput.CharLimit = 100 + usernameInput.SetWidth(30) + + // Create password input for login (masked) + passwordInput := textinput.New() + passwordInput.Placeholder = "Password" + passwordInput.CharLimit = 100 + passwordInput.SetWidth(30) + passwordInput.EchoMode = textinput.EchoPassword + passwordInput.EchoCharacter = '•' + return &InputComponentState{ - searchInput: searchInput, - commandInput: commandInput, + searchInput: searchInput, + commandInput: commandInput, + usernameInput: usernameInput, + passwordInput: passwordInput, } } @@ -1256,6 +1274,74 @@ func (m *Model) handleSortCommand(arg string) (*Model, tea.Cmd) { } } +// Login input methods + +// UpdateUsernameInput updates the username textinput component +func (ic *InputComponentState) UpdateUsernameInput(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + ic.usernameInput, cmd = ic.usernameInput.Update(msg) + return cmd +} + +// UpdatePasswordInput updates the password textinput component +func (ic *InputComponentState) UpdatePasswordInput(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + ic.passwordInput, cmd = ic.passwordInput.Update(msg) + return cmd +} + +// FocusUsernameInput focuses the username input +func (ic *InputComponentState) FocusUsernameInput() { + ic.usernameInput.Focus() +} + +// FocusPasswordInput focuses the password input +func (ic *InputComponentState) FocusPasswordInput() { + ic.passwordInput.Focus() +} + +// BlurLoginInputs removes focus from login inputs +func (ic *InputComponentState) BlurLoginInputs() { + ic.usernameInput.Blur() + ic.passwordInput.Blur() +} + +// GetUsernameValue returns current username input value +func (ic *InputComponentState) GetUsernameValue() string { + return ic.usernameInput.Value() +} + +// GetPasswordValue returns current password input value +func (ic *InputComponentState) GetPasswordValue() string { + return ic.passwordInput.Value() +} + +// SetUsernameValue sets the username input value +func (ic *InputComponentState) SetUsernameValue(value string) { + ic.usernameInput.SetValue(value) +} + +// SetPasswordValue sets the password input value +func (ic *InputComponentState) SetPasswordValue(value string) { + ic.passwordInput.SetValue(value) +} + +// ClearLoginInputs clears both login inputs +func (ic *InputComponentState) ClearLoginInputs() { + ic.usernameInput.SetValue("") + ic.passwordInput.SetValue("") +} + +// RenderUsernameInput renders the username input view +func (ic *InputComponentState) RenderUsernameInput() string { + return ic.usernameInput.View() +} + +// RenderPasswordInput renders the password input view +func (ic *InputComponentState) RenderPasswordInput() string { + return ic.passwordInput.View() +} + // local helpers func maxInt(a, b int) int { if a > b { diff --git a/cmd/app/input_handlers.go b/cmd/app/input_handlers.go index ee5e3368..a51dec99 100644 --- a/cmd/app/input_handlers.go +++ b/cmd/app/input_handlers.go @@ -1196,6 +1196,116 @@ func (m *Model) executeResourceSync() (tea.Model, tea.Cmd) { ) } +// handleLoginModeKeys handles input when in login mode +func (m *Model) handleLoginModeKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // If loading, only allow cancel + if m.state.Modals.LoginLoading { + switch msg.String() { + case "esc", "ctrl+c": + m.state.Modals.LoginLoading = false + return m, nil + } + return m, nil + } + + switch msg.String() { + case "tab", "down": + // Cycle through fields: username(0) -> password(1) -> save(2) -> login(3) -> cancel(4) + m.state.Modals.LoginFieldFocus = (m.state.Modals.LoginFieldFocus + 1) % 5 + return m.updateLoginFocus() + case "shift+tab", "up": + // Reverse cycle + m.state.Modals.LoginFieldFocus = (m.state.Modals.LoginFieldFocus + 4) % 5 + return m.updateLoginFocus() + case "enter": + switch m.state.Modals.LoginFieldFocus { + case 0: // Username field - move to password + m.state.Modals.LoginFieldFocus = 1 + return m.updateLoginFocus() + case 1: // Password field - move to save checkbox + m.state.Modals.LoginFieldFocus = 2 + return m.updateLoginFocus() + case 2: // Save checkbox - toggle and move to login button + m.state.Modals.LoginSaveCredentials = !m.state.Modals.LoginSaveCredentials + m.state.Modals.LoginFieldFocus = 3 + return m.updateLoginFocus() + case 3: // Login button - submit + return m.submitLogin() + case 4: // Cancel button - cancel login + return m.cancelLogin() + } + return m, nil + case " ": + // Space toggles checkbox when focused + if m.state.Modals.LoginFieldFocus == 2 { + m.state.Modals.LoginSaveCredentials = !m.state.Modals.LoginSaveCredentials + } + return m, nil + case "esc": + return m.cancelLogin() + case "ctrl+c": + return m.cancelLogin() + default: + // Pass to focused input + switch m.state.Modals.LoginFieldFocus { + case 0: + cmd := m.inputComponents.UpdateUsernameInput(msg) + m.state.Modals.LoginUsername = m.inputComponents.GetUsernameValue() + return m, cmd + case 1: + cmd := m.inputComponents.UpdatePasswordInput(msg) + return m, cmd + } + return m, nil + } +} + +// updateLoginFocus updates which login input is focused +func (m *Model) updateLoginFocus() (tea.Model, tea.Cmd) { + m.inputComponents.BlurLoginInputs() + switch m.state.Modals.LoginFieldFocus { + case 0: + m.inputComponents.FocusUsernameInput() + case 1: + m.inputComponents.FocusPasswordInput() + } + return m, nil +} + +// submitLogin handles login form submission +func (m *Model) submitLogin() (tea.Model, tea.Cmd) { + username := m.inputComponents.GetUsernameValue() + password := m.inputComponents.GetPasswordValue() + + if username == "" || password == "" { + errMsg := "Username and password are required" + m.state.Modals.LoginError = &errMsg + return m, nil + } + + // Clear error and start loading + m.state.Modals.LoginError = nil + m.state.Modals.LoginLoading = true + m.inputComponents.BlurLoginInputs() + + // Send login submit message + return m, func() tea.Msg { + return model.LoginSubmitMsg{ + Username: username, + Password: password, + SaveCredentials: m.state.Modals.LoginSaveCredentials, + } + } +} + +// cancelLogin handles login cancellation +func (m *Model) cancelLogin() (tea.Model, tea.Cmd) { + m.inputComponents.BlurLoginInputs() + m.inputComponents.ClearLoginInputs() + m.state.Mode = model.ModeAuthRequired + return m, nil +} + // handleAuthRequiredModeKeys handles input when authentication is required func (m *Model) handleAuthRequiredModeKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { @@ -1384,6 +1494,8 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleK9sContextSelectKeys(msg) case model.ModeK9sError: return m.handleK9sErrorModeKeys(msg) + case model.ModeLogin, model.ModeLoginLoading: + return m.handleLoginModeKeys(msg) } // Tree view keys when in normal mode. diff --git a/cmd/app/main.go b/cmd/app/main.go index 067d923a..3521e63d 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -39,6 +39,16 @@ func (e *PortForwardModeError) Error() string { return "ArgoCD is configured for port-forward mode" } +// NoTokenError indicates that no auth token was found but server URL is available +type NoTokenError struct { + ServerURL string + Insecure bool +} + +func (e *NoTokenError) Error() string { + return "no auth token found" +} + // appVersion is the Argonaut version shown in the ASCII banner. // Override at build time: go build -ldflags "-X main.appVersion=1.16.0" var appVersion = "dev" @@ -323,6 +333,16 @@ func main() { // Set mode to show core detection view m.state.Mode = model.ModeCoreDetected m.state.Server = nil + } else if noTokenErr, isNoToken := err.(*NoTokenError); isNoToken { + // No token found - set up for auto-login + cblog.With("component", "app").Info("No auth token found, will attempt auto-login", "serverURL", noTokenErr.ServerURL) + m.state.Server = &model.Server{ + BaseURL: noTokenErr.ServerURL, + Insecure: noTokenErr.Insecure, + } + // Initialize login modal state with server URL + m.initLoginModal(noTokenErr.ServerURL, noTokenErr.Insecure) + // Mode will be set by validateAuthentication() based on auto-login result } else { cblog.With("component", "app").Error("Could not load Argo CD config", "err", err) cblog.With("component", "app").Info("Please run 'argocd login' to configure and authenticate") @@ -428,6 +448,15 @@ func loadArgoConfig(overridePath string) (*model.Server, error) { return nil, &CoreModeError{} } } + + // Check if it's a missing token error - return NoTokenError with server URL for login + if strings.Contains(err.Error(), "auth token") || strings.Contains(err.Error(), "no token") || strings.Contains(err.Error(), "argocd login") { + // Try to get server URL without token for login flow + if serverURL, insecure, urlErr := cfg.GetServerURLForCurrentContext(); urlErr == nil { + return nil, &NoTokenError{ServerURL: serverURL, Insecure: insecure} + } + } + return nil, fmt.Errorf("failed to parse server config: %w", err) } diff --git a/cmd/app/model.go b/cmd/app/model.go index f54a04d5..9e8d6028 100644 --- a/cmd/app/model.go +++ b/cmd/app/model.go @@ -616,6 +616,32 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(func() tea.Msg { return model.SetModeMsg{Mode: model.ModeAuthRequired} }) + // Login messages + case model.AutoLoginAttemptMsg: + // Set mode to show loading while attempting auto-login + m.state.Mode = model.ModeLoginLoading + m.state.Modals.LoginServerURL = msg.ServerURL + m.state.Modals.InitialLoading = false + return m, m.handleAutoLoginAttempt(msg) + + case model.AutoLoginResultMsg: + return m.handleAutoLoginResult(msg) + + case model.LoginSubmitMsg: + return m, m.handleLoginSubmit(msg) + + case model.LoginSuccessMsg: + return m.handleLoginSuccess(msg) + + case model.LoginErrorMsg: + return m.handleLoginError(msg) + + case model.LoginCancelMsg: + m.inputComponents.BlurLoginInputs() + m.inputComponents.ClearLoginInputs() + m.state.Mode = model.ModeAuthRequired + return m, nil + // Navigation update messages case model.NavigationUpdateMsg: if msg.NewView != nil { diff --git a/cmd/app/model_auth.go b/cmd/app/model_auth.go new file mode 100644 index 00000000..e579bf65 --- /dev/null +++ b/cmd/app/model_auth.go @@ -0,0 +1,211 @@ +package main + +import ( + "context" + "time" + + tea "charm.land/bubbletea/v2" + cblog "github.com/charmbracelet/log" + "github.com/darksworm/argonaut/pkg/auth" + "github.com/darksworm/argonaut/pkg/model" +) + +// handleAutoLoginAttempt attempts to authenticate using stored credentials +func (m *Model) handleAutoLoginAttempt(msg model.AutoLoginAttemptMsg) tea.Cmd { + return func() tea.Msg { + log := cblog.With("component", "auth") + serverURL := msg.ServerURL + insecure := msg.Insecure + + if serverURL == "" { + log.Debug("No server URL for auto-login") + return model.AutoLoginResultMsg{ + Success: false, + Error: nil, // No error, just no server configured + } + } + + log.Debug("Attempting auto-login", "serverURL", serverURL) + + authManager := auth.NewAuthManager(auth.AuthManagerConfig{ + ServerURL: serverURL, + Insecure: insecure, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result, err := authManager.TryAutoLogin(ctx) + if err != nil { + log.Debug("Auto-login failed", "err", err) + return model.AutoLoginResultMsg{ + Success: false, + Error: err, + } + } + + log.Info("Auto-login successful", "username", result.Username) + return model.AutoLoginResultMsg{ + Success: true, + Token: result.Token, + Username: result.Username, + } + } +} + +// handleLoginSubmit handles the login form submission +func (m *Model) handleLoginSubmit(msg model.LoginSubmitMsg) tea.Cmd { + return func() tea.Msg { + log := cblog.With("component", "auth") + serverURL := m.state.Modals.LoginServerURL + + if serverURL == "" { + return model.LoginErrorMsg{Error: "No server configured"} + } + + log.Debug("Attempting login", "serverURL", serverURL, "username", msg.Username) + + // Get insecure setting from the server state if available + insecure := false + if m.state.Server != nil { + insecure = m.state.Server.Insecure + } + + authManager := auth.NewAuthManager(auth.AuthManagerConfig{ + ServerURL: serverURL, + Insecure: insecure, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result, err := authManager.LoginWithCredentials(ctx, msg.Username, msg.Password, msg.SaveCredentials) + if err != nil { + log.Debug("Login failed", "err", err) + return model.LoginErrorMsg{Error: err.Error()} + } + + log.Info("Login successful", "username", msg.Username) + return model.LoginSuccessMsg{ + Token: result.Token, + Username: result.Username, + } + } +} + +// handleLoginSuccess processes successful login +func (m *Model) handleLoginSuccess(msg model.LoginSuccessMsg) (tea.Model, tea.Cmd) { + log := cblog.With("component", "auth") + log.Info("Processing login success") + + // Update server with new token + if m.state.Server == nil { + m.state.Server = &model.Server{ + BaseURL: m.state.Modals.LoginServerURL, + } + } + m.state.Server.Token = msg.Token + + // Clear login state + m.state.Modals.LoginLoading = false + m.state.Modals.LoginError = nil + m.inputComponents.ClearLoginInputs() + + // Transition to loading mode to fetch apps + m.state.Mode = model.ModeLoading + + // Start loading apps + return m, m.validateAuthentication() +} + +// handleLoginError processes login error +func (m *Model) handleLoginError(msg model.LoginErrorMsg) (tea.Model, tea.Cmd) { + log := cblog.With("component", "auth") + log.Debug("Login error", "error", msg.Error) + + m.state.Modals.LoginLoading = false + m.state.Modals.LoginError = &msg.Error + + // Re-focus username input + m.state.Modals.LoginFieldFocus = 0 + m.inputComponents.FocusUsernameInput() + + return m, nil +} + +// handleAutoLoginResult processes the result of auto-login attempt +func (m *Model) handleAutoLoginResult(msg model.AutoLoginResultMsg) (tea.Model, tea.Cmd) { + log := cblog.With("component", "auth") + + if msg.Success { + log.Info("Auto-login succeeded, updating server token") + + // Update server with new token + if m.state.Server == nil { + m.state.Server = &model.Server{ + BaseURL: m.state.Modals.LoginServerURL, + } + } + m.state.Server.Token = msg.Token + + // Transition to loading mode + m.state.Mode = model.ModeLoading + return m, m.validateAuthentication() + } + + // Auto-login failed, show login modal + log.Debug("Auto-login failed, showing login modal") + + // Pre-fill username if we have stored credentials + if m.state.Modals.LoginServerURL != "" { + authManager := auth.NewAuthManager(auth.AuthManagerConfig{ + ServerURL: m.state.Modals.LoginServerURL, + }) + if storedUsername := authManager.GetStoredUsername(); storedUsername != "" { + m.inputComponents.SetUsernameValue(storedUsername) + m.state.Modals.LoginUsername = storedUsername + } + } + + // Show login modal + m.state.Mode = model.ModeLogin + m.state.Modals.LoginFieldFocus = 0 + m.state.Modals.LoginSaveCredentials = true // Default to save + m.inputComponents.FocusUsernameInput() + + return m, nil +} + +// initLoginModal initializes the login modal with server URL +func (m *Model) initLoginModal(serverURL string, insecure bool) { + m.state.Modals.LoginServerURL = serverURL + m.state.Modals.LoginFieldFocus = 0 + m.state.Modals.LoginSaveCredentials = true + m.state.Modals.LoginError = nil + m.state.Modals.LoginLoading = false + m.inputComponents.ClearLoginInputs() + + // Store insecure setting + if m.state.Server == nil { + m.state.Server = &model.Server{ + BaseURL: serverURL, + Insecure: insecure, + } + } +} + +// triggerAutoLogin creates a command to attempt auto-login +func (m *Model) triggerAutoLogin() tea.Cmd { + serverURL := m.state.Modals.LoginServerURL + insecure := false + if m.state.Server != nil { + insecure = m.state.Server.Insecure + } + + return func() tea.Msg { + return model.AutoLoginAttemptMsg{ + ServerURL: serverURL, + Insecure: insecure, + } + } +} diff --git a/cmd/app/model_init.go b/cmd/app/model_init.go index 9e392b85..8dab02dd 100644 --- a/cmd/app/model_init.go +++ b/cmd/app/model_init.go @@ -118,6 +118,15 @@ func (m *Model) validateAuthentication() tea.Cmd { return model.SetModeMsg{Mode: model.ModeAuthRequired} } + // If we have a server URL but no token, trigger auto-login + if m.state.Server.Token == "" { + cblog.With("component", "auth").Info("No token configured, attempting auto-login") + return model.AutoLoginAttemptMsg{ + ServerURL: m.state.Server.BaseURL, + Insecure: m.state.Server.Insecure, + } + } + // Create API service to validate authentication appService := api.NewApplicationService(m.state.Server) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -137,8 +146,12 @@ func (m *Model) validateAuthentication() tea.Cmd { return model.SetModeMsg{Mode: model.ModeConnectionError} } - // Otherwise, it's likely an authentication issue - return model.SetModeMsg{Mode: model.ModeAuthRequired} + // Token is invalid/expired - trigger auto-login to try stored credentials + cblog.With("component", "auth").Info("Token invalid, attempting auto-login") + return model.AutoLoginAttemptMsg{ + ServerURL: m.state.Server.BaseURL, + Insecure: m.state.Server.Insecure, + } } cblog.With("component", "auth").Info("Authentication validated successfully") diff --git a/cmd/app/view.go b/cmd/app/view.go index c6549011..783ef332 100644 --- a/cmd/app/view.go +++ b/cmd/app/view.go @@ -307,6 +307,8 @@ func (m *Model) View() tea.View { content = m.renderConnectionErrorView() case model.ModeCoreDetected: content = m.renderCoreDetectedView() + case model.ModeLogin, model.ModeLoginLoading: + content = m.renderLoginView() default: content = m.renderMainLayout() } diff --git a/cmd/app/view_login.go b/cmd/app/view_login.go new file mode 100644 index 00000000..b36ac416 --- /dev/null +++ b/cmd/app/view_login.go @@ -0,0 +1,201 @@ +package main + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/darksworm/argonaut/pkg/model" +) + +// renderLoginModal renders the login modal for authentication +func (m *Model) renderLoginModal() string { + serverURL := m.state.Modals.LoginServerURL + if serverURL == "" { + serverURL = "—" + } + + // Modal width: centered and reasonably sized + half := m.state.Terminal.Cols / 2 + modalWidth := min(max(50, half), m.state.Terminal.Cols-6) + innerWidth := max(0, modalWidth-4) // border(2)+padding(2) + + // Styles + dim := lipgloss.NewStyle().Foreground(dimColor) + bold := lipgloss.NewStyle().Foreground(whiteBright).Bold(true) + labelStyle := lipgloss.NewStyle().Foreground(cyanBright).Bold(true) + errorStyle := lipgloss.NewStyle().Foreground(outOfSyncColor) + + // Active/inactive button styles + inactiveFG := ensureContrastingForeground(inactiveBG, whiteBright) + active := lipgloss.NewStyle().Background(magentaBright).Foreground(textOnAccent).Bold(true).Padding(0, 2) + inactive := lipgloss.NewStyle().Background(inactiveBG).Foreground(inactiveFG).Padding(0, 2) + + // Center helper + center := lipgloss.NewStyle().Width(innerWidth).Align(lipgloss.Center) + left := lipgloss.NewStyle().Width(innerWidth).Align(lipgloss.Left) + + // Title + title := center.Render(bold.Render("Login to ArgoCD")) + + // Server URL + serverLine := left.Render(dim.Render("Server: ") + bold.Render(serverURL)) + + // Username input + usernameLabel := labelStyle.Render("Username:") + // Set input width based on modal + inputWidth := max(20, innerWidth-15) + m.inputComponents.usernameInput.SetWidth(inputWidth) + usernameInput := m.inputComponents.RenderUsernameInput() + usernameLine := left.Render(usernameLabel + " " + usernameInput) + + // Password input + passwordLabel := labelStyle.Render("Password:") + m.inputComponents.passwordInput.SetWidth(inputWidth) + passwordInput := m.inputComponents.RenderPasswordInput() + passwordLine := left.Render(passwordLabel + " " + passwordInput) + + // Save credentials checkbox + checkboxStyle := lipgloss.NewStyle().Foreground(cyanBright) + checkbox := "[ ]" + if m.state.Modals.LoginSaveCredentials { + checkbox = "[x]" + } + // Highlight checkbox if focused + checkboxText := "Save credentials to keychain" + if m.state.Modals.LoginFieldFocus == 2 { + checkbox = checkboxStyle.Bold(true).Render(checkbox) + checkboxText = bold.Render(checkboxText) + } else { + checkbox = dim.Render(checkbox) + checkboxText = dim.Render(checkboxText) + } + saveLine := left.Render(checkbox + " " + checkboxText) + + // Buttons + loginBtn := inactive.Render("Login") + cancelBtn := inactive.Render("Cancel") + if m.state.Modals.LoginFieldFocus == 3 { + loginBtn = active.Render("Login") + } + if m.state.Modals.LoginFieldFocus == 4 { + cancelBtn = active.Render("Cancel") + } + buttons := lipgloss.JoinHorizontal(lipgloss.Center, loginBtn, strings.Repeat(" ", 4), cancelBtn) + buttonsLine := center.Render(buttons) + + // Error message (if any) + var errorLine string + if m.state.Modals.LoginError != nil && *m.state.Modals.LoginError != "" { + errorLine = center.Render(errorStyle.Render(*m.state.Modals.LoginError)) + } + + // Loading indicator + var loadingLine string + if m.state.Modals.LoginLoading { + loadingLine = center.Render(dim.Render("Logging in...")) + } + + // Build modal content + var lines []string + lines = append(lines, title) + lines = append(lines, "") + lines = append(lines, serverLine) + lines = append(lines, "") + lines = append(lines, usernameLine) + lines = append(lines, passwordLine) + lines = append(lines, "") + lines = append(lines, saveLine) + lines = append(lines, "") + lines = append(lines, buttonsLine) + if errorLine != "" { + lines = append(lines, "") + lines = append(lines, errorLine) + } + if loadingLine != "" { + lines = append(lines, "") + lines = append(lines, loadingLine) + } + + body := strings.Join(lines, "\n") + + // Modal wrapper with border + wrapper := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(cyanBright). + Padding(1, 2). + Width(modalWidth) + + // Add outer whitespace + outer := lipgloss.NewStyle().Padding(1, 1) + return outer.Render(wrapper.Render(body)) +} + +// renderLoginLoadingView renders a loading view during auto-login +func (m *Model) renderLoginLoadingView() string { + serverURL := m.state.Modals.LoginServerURL + if serverURL == "" { + serverURL = "ArgoCD" + } + + // Modal width: centered and reasonably sized + half := m.state.Terminal.Cols / 2 + modalWidth := min(max(40, half), m.state.Terminal.Cols-6) + innerWidth := max(0, modalWidth-4) + + // Styles + bold := lipgloss.NewStyle().Foreground(whiteBright).Bold(true) + dim := lipgloss.NewStyle().Foreground(dimColor) + + // Center helper + center := lipgloss.NewStyle().Width(innerWidth).Align(lipgloss.Center) + + // Content + title := center.Render(bold.Render("Authenticating...")) + serverLine := center.Render(dim.Render("Connecting to " + serverURL)) + spinnerLine := center.Render(m.spinner.View()) + + body := strings.Join([]string{title, "", serverLine, "", spinnerLine}, "\n") + + // Modal wrapper with border + wrapper := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(cyanBright). + Padding(1, 2). + Width(modalWidth) + + // Add outer whitespace + outer := lipgloss.NewStyle().Padding(1, 1) + return outer.Render(wrapper.Render(body)) +} + +// renderLoginView renders the full login view (modal centered on screen) +func (m *Model) renderLoginView() string { + var modal string + if m.state.Mode == model.ModeLoginLoading { + modal = m.renderLoginLoadingView() + } else { + modal = m.renderLoginModal() + } + + // Center the modal on screen + modalHeight := strings.Count(modal, "\n") + 1 + modalWidth := lipgloss.Width(modal) + + // Calculate padding to center + topPadding := max(0, (m.state.Terminal.Rows-modalHeight)/2) + leftPadding := max(0, (m.state.Terminal.Cols-modalWidth)/2) + + // Build centered view + var lines []string + for i := 0; i < topPadding; i++ { + lines = append(lines, "") + } + + // Split modal into lines and add left padding + modalLines := strings.Split(modal, "\n") + for _, line := range modalLines { + lines = append(lines, strings.Repeat(" ", leftPadding)+line) + } + + return strings.Join(lines, "\n") +} diff --git a/go.mod b/go.mod index 75ee1608..b7ca5606 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.3.3 // indirect @@ -33,7 +34,9 @@ require ( github.com/clipperhouse/displaywidth v0.6.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -44,6 +47,7 @@ require ( github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/zalando/go-keyring v0.2.6 // indirect golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect diff --git a/go.sum b/go.sum index d6c154f6..83e93f6e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM= charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4= charm.land/bubbletea/v2 v2.0.0-rc.2 h1:TdTbUOFzbufDJmSz/3gomL6q+fR6HwfY+P13hXQzD7k= @@ -39,10 +41,14 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY= @@ -85,6 +91,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= diff --git a/pkg/auth/keychain.go b/pkg/auth/keychain.go new file mode 100644 index 00000000..2c2369ef --- /dev/null +++ b/pkg/auth/keychain.go @@ -0,0 +1,206 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + + "github.com/zalando/go-keyring" +) + +const ( + serviceName = "argonaut" + tokenKey = "token" + usernameKey = "username" + passwordKey = "password" +) + +// ErrKeychainUnavailable indicates that the system keychain is not available +var ErrKeychainUnavailable = errors.New("keychain is not available on this system") + +// ErrNotFound indicates that the requested credential was not found +var ErrNotFound = errors.New("credential not found in keychain") + +// KeychainStore provides cross-platform credential storage +type KeychainStore interface { + StoreToken(serverURL, token string) error + LoadToken(serverURL string) (string, error) + DeleteToken(serverURL string) error + StoreCredentials(serverURL, username, password string) error + LoadCredentials(serverURL string) (username, password string, err error) + DeleteCredentials(serverURL string) error +} + +// DefaultKeychainStore uses the system keychain via go-keyring +type DefaultKeychainStore struct{} + +// NewKeychainStore creates a new keychain store +func NewKeychainStore() KeychainStore { + return &DefaultKeychainStore{} +} + +// serverKey creates a unique key for a server URL using a truncated hash +func serverKey(serverURL string) string { + hash := sha256.Sum256([]byte(serverURL)) + return hex.EncodeToString(hash[:8]) // First 16 chars of hex = 8 bytes +} + +// StoreToken stores an auth token for a server +func (k *DefaultKeychainStore) StoreToken(serverURL, token string) error { + key := fmt.Sprintf("%s:%s:%s", serviceName, serverKey(serverURL), tokenKey) + err := keyring.Set(serviceName, key, token) + if err != nil { + if isKeychainUnavailable(err) { + return ErrKeychainUnavailable + } + return fmt.Errorf("failed to store token in keychain: %w", err) + } + return nil +} + +// LoadToken retrieves an auth token for a server +func (k *DefaultKeychainStore) LoadToken(serverURL string) (string, error) { + key := fmt.Sprintf("%s:%s:%s", serviceName, serverKey(serverURL), tokenKey) + token, err := keyring.Get(serviceName, key) + if err != nil { + if isNotFound(err) { + return "", ErrNotFound + } + if isKeychainUnavailable(err) { + return "", ErrKeychainUnavailable + } + return "", fmt.Errorf("failed to load token from keychain: %w", err) + } + return token, nil +} + +// DeleteToken removes an auth token for a server +func (k *DefaultKeychainStore) DeleteToken(serverURL string) error { + key := fmt.Sprintf("%s:%s:%s", serviceName, serverKey(serverURL), tokenKey) + err := keyring.Delete(serviceName, key) + if err != nil { + if isNotFound(err) { + return nil // Already deleted, not an error + } + if isKeychainUnavailable(err) { + return ErrKeychainUnavailable + } + return fmt.Errorf("failed to delete token from keychain: %w", err) + } + return nil +} + +// StoreCredentials stores username and password for a server +func (k *DefaultKeychainStore) StoreCredentials(serverURL, username, password string) error { + urlKey := serverKey(serverURL) + + // Store username + usernameFullKey := fmt.Sprintf("%s:%s:%s", serviceName, urlKey, usernameKey) + if err := keyring.Set(serviceName, usernameFullKey, username); err != nil { + if isKeychainUnavailable(err) { + return ErrKeychainUnavailable + } + return fmt.Errorf("failed to store username in keychain: %w", err) + } + + // Store password + passwordFullKey := fmt.Sprintf("%s:%s:%s", serviceName, urlKey, passwordKey) + if err := keyring.Set(serviceName, passwordFullKey, password); err != nil { + if isKeychainUnavailable(err) { + return ErrKeychainUnavailable + } + return fmt.Errorf("failed to store password in keychain: %w", err) + } + + return nil +} + +// LoadCredentials retrieves username and password for a server +func (k *DefaultKeychainStore) LoadCredentials(serverURL string) (string, string, error) { + urlKey := serverKey(serverURL) + + // Load username + usernameFullKey := fmt.Sprintf("%s:%s:%s", serviceName, urlKey, usernameKey) + username, err := keyring.Get(serviceName, usernameFullKey) + if err != nil { + if isNotFound(err) { + return "", "", ErrNotFound + } + if isKeychainUnavailable(err) { + return "", "", ErrKeychainUnavailable + } + return "", "", fmt.Errorf("failed to load username from keychain: %w", err) + } + + // Load password + passwordFullKey := fmt.Sprintf("%s:%s:%s", serviceName, urlKey, passwordKey) + password, err := keyring.Get(serviceName, passwordFullKey) + if err != nil { + if isNotFound(err) { + return "", "", ErrNotFound + } + if isKeychainUnavailable(err) { + return "", "", ErrKeychainUnavailable + } + return "", "", fmt.Errorf("failed to load password from keychain: %w", err) + } + + return username, password, nil +} + +// DeleteCredentials removes username and password for a server +func (k *DefaultKeychainStore) DeleteCredentials(serverURL string) error { + urlKey := serverKey(serverURL) + + // Delete username + usernameFullKey := fmt.Sprintf("%s:%s:%s", serviceName, urlKey, usernameKey) + if err := keyring.Delete(serviceName, usernameFullKey); err != nil && !isNotFound(err) { + if isKeychainUnavailable(err) { + return ErrKeychainUnavailable + } + return fmt.Errorf("failed to delete username from keychain: %w", err) + } + + // Delete password + passwordFullKey := fmt.Sprintf("%s:%s:%s", serviceName, urlKey, passwordKey) + if err := keyring.Delete(serviceName, passwordFullKey); err != nil && !isNotFound(err) { + if isKeychainUnavailable(err) { + return ErrKeychainUnavailable + } + return fmt.Errorf("failed to delete password from keychain: %w", err) + } + + return nil +} + +// isNotFound checks if the error indicates the credential was not found +func isNotFound(err error) bool { + return errors.Is(err, keyring.ErrNotFound) +} + +// isKeychainUnavailable checks if the error indicates the keychain is unavailable +func isKeychainUnavailable(err error) bool { + // go-keyring doesn't have a specific error for unavailable, + // but certain errors indicate the keychain isn't accessible + if err == nil { + return false + } + errStr := err.Error() + // Check for common indicators of unavailable keychain + return errors.Is(err, keyring.ErrUnsupportedPlatform) || + containsAny(errStr, "secret service", "dbus", "keychain", "credential manager") +} + +func containsAny(s string, substrs ...string) bool { + for _, substr := range substrs { + if len(s) >= len(substr) { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + } + } + return false +} diff --git a/pkg/auth/manager.go b/pkg/auth/manager.go new file mode 100644 index 00000000..9ae8f278 --- /dev/null +++ b/pkg/auth/manager.go @@ -0,0 +1,164 @@ +package auth + +import ( + "context" + "errors" + "fmt" + + cblog "github.com/charmbracelet/log" +) + +// AuthManager coordinates authentication flow +type AuthManager struct { + keychain KeychainStore + sessionSvc *SessionService + serverURL string + insecure bool +} + +// AuthManagerConfig holds configuration for AuthManager +type AuthManagerConfig struct { + ServerURL string + Insecure bool + Keychain KeychainStore // Optional, uses default if nil +} + +// AuthResult represents the result of an authentication attempt +type AuthResult struct { + Token string + Username string +} + +// NewAuthManager creates a new auth manager +func NewAuthManager(cfg AuthManagerConfig) *AuthManager { + keychain := cfg.Keychain + if keychain == nil { + keychain = NewKeychainStore() + } + + sessionSvc := NewSessionService(SessionServiceConfig{ + BaseURL: cfg.ServerURL, + Insecure: cfg.Insecure, + }) + + return &AuthManager{ + keychain: keychain, + sessionSvc: sessionSvc, + serverURL: cfg.ServerURL, + insecure: cfg.Insecure, + } +} + +// TryAutoLogin attempts to authenticate using stored credentials from keychain +// Returns the token if successful, or an error if auto-login failed +func (m *AuthManager) TryAutoLogin(ctx context.Context) (*AuthResult, error) { + log := cblog.With("component", "auth-manager") + + // First, try to load a stored token + token, err := m.keychain.LoadToken(m.serverURL) + if err == nil && token != "" { + log.Debug("Found stored token, validating...") + // Validate the token + if err := m.sessionSvc.ValidateToken(ctx, token); err == nil { + log.Info("Auto-login successful using stored token") + return &AuthResult{Token: token}, nil + } + log.Debug("Stored token is invalid, will try credentials") + } + + // Token not found or invalid, try stored credentials + username, password, err := m.keychain.LoadCredentials(m.serverURL) + if err != nil { + if errors.Is(err, ErrNotFound) { + log.Debug("No stored credentials found") + return nil, fmt.Errorf("no stored credentials: %w", ErrNotFound) + } + if errors.Is(err, ErrKeychainUnavailable) { + log.Debug("Keychain unavailable") + return nil, fmt.Errorf("keychain unavailable: %w", ErrKeychainUnavailable) + } + return nil, fmt.Errorf("failed to load credentials: %w", err) + } + + log.Debug("Found stored credentials, attempting login...") + + // Try to login with stored credentials + newToken, err := m.sessionSvc.Login(ctx, username, password) + if err != nil { + log.Debug("Auto-login with stored credentials failed", "err", err) + return nil, fmt.Errorf("login with stored credentials failed: %w", err) + } + + // Store the new token + if storeErr := m.keychain.StoreToken(m.serverURL, newToken); storeErr != nil { + log.Warn("Failed to store new token in keychain", "err", storeErr) + // Not fatal, continue + } + + log.Info("Auto-login successful using stored credentials") + return &AuthResult{Token: newToken, Username: username}, nil +} + +// LoginWithCredentials authenticates with the provided credentials +// If saveCredentials is true, stores them in the keychain for future auto-login +func (m *AuthManager) LoginWithCredentials(ctx context.Context, username, password string, saveCredentials bool) (*AuthResult, error) { + log := cblog.With("component", "auth-manager") + + log.Debug("Attempting login with provided credentials", "username", username) + + token, err := m.sessionSvc.Login(ctx, username, password) + if err != nil { + return nil, fmt.Errorf("login failed: %w", err) + } + + log.Info("Login successful", "username", username) + + // Store the token in keychain + if storeErr := m.keychain.StoreToken(m.serverURL, token); storeErr != nil { + log.Warn("Failed to store token in keychain", "err", storeErr) + // Not fatal, continue + } + + // Optionally store credentials for future auto-login + if saveCredentials { + if storeErr := m.keychain.StoreCredentials(m.serverURL, username, password); storeErr != nil { + log.Warn("Failed to store credentials in keychain", "err", storeErr) + // Not fatal, continue + } else { + log.Debug("Stored credentials in keychain for future auto-login") + } + } + + return &AuthResult{Token: token, Username: username}, nil +} + +// GetStoredUsername retrieves the stored username for pre-filling the login form +func (m *AuthManager) GetStoredUsername() string { + username, _, err := m.keychain.LoadCredentials(m.serverURL) + if err != nil { + return "" + } + return username +} + +// ClearStoredCredentials removes all stored credentials for the server +func (m *AuthManager) ClearStoredCredentials() error { + log := cblog.With("component", "auth-manager") + + if err := m.keychain.DeleteToken(m.serverURL); err != nil { + log.Warn("Failed to delete token from keychain", "err", err) + } + + if err := m.keychain.DeleteCredentials(m.serverURL); err != nil { + log.Warn("Failed to delete credentials from keychain", "err", err) + return err + } + + log.Info("Cleared stored credentials from keychain") + return nil +} + +// ValidateToken checks if a token is still valid +func (m *AuthManager) ValidateToken(ctx context.Context, token string) error { + return m.sessionSvc.ValidateToken(ctx, token) +} diff --git a/pkg/auth/session.go b/pkg/auth/session.go new file mode 100644 index 00000000..6db48a9d --- /dev/null +++ b/pkg/auth/session.go @@ -0,0 +1,177 @@ +package auth + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" +) + +// SessionService handles ArgoCD authentication API calls +type SessionService struct { + baseURL string + httpClient *http.Client + insecure bool +} + +// SessionServiceConfig holds configuration for SessionService +type SessionServiceConfig struct { + BaseURL string + Insecure bool +} + +// LoginRequest represents the ArgoCD session create request +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// LoginResponse represents the ArgoCD session create response +type LoginResponse struct { + Token string `json:"token"` +} + +// UserInfo represents the ArgoCD user info response +type UserInfo struct { + LoggedIn bool `json:"loggedIn"` + Username string `json:"username"` + Iss string `json:"iss"` +} + +// NewSessionService creates a new session service +func NewSessionService(cfg SessionServiceConfig) *SessionService { + transport := &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + IdleConnTimeout: 30 * time.Second, + MaxIdleConns: 10, + MaxIdleConnsPerHost: 2, + } + + if cfg.Insecure { + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + } + } + + return &SessionService{ + baseURL: cfg.BaseURL, + httpClient: &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + }, + insecure: cfg.Insecure, + } +} + +// Login authenticates with ArgoCD and returns a JWT token +func (s *SessionService) Login(ctx context.Context, username, password string) (string, error) { + reqBody := LoginRequest{ + Username: username, + Password: password, + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("failed to marshal login request: %w", err) + } + + url := s.baseURL + "/api/v1/session" + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes)) + if err != nil { + return "", fmt.Errorf("failed to create login request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("login request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read login response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + // Try to parse error message from response + var errResp struct { + Error string `json:"error"` + Message string `json:"message"` + } + if json.Unmarshal(body, &errResp) == nil && (errResp.Error != "" || errResp.Message != "") { + msg := errResp.Error + if msg == "" { + msg = errResp.Message + } + return "", fmt.Errorf("login failed: %s", msg) + } + return "", fmt.Errorf("login failed with status %d: %s", resp.StatusCode, string(body)) + } + + var loginResp LoginResponse + if err := json.Unmarshal(body, &loginResp); err != nil { + return "", fmt.Errorf("failed to parse login response: %w", err) + } + + if loginResp.Token == "" { + return "", fmt.Errorf("login succeeded but no token returned") + } + + return loginResp.Token, nil +} + +// ValidateToken checks if a token is still valid by calling the userinfo endpoint +func (s *SessionService) ValidateToken(ctx context.Context, token string) error { + url := s.baseURL + "/api/v1/session/userinfo" + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create userinfo request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("userinfo request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return ErrTokenInvalid + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("userinfo request failed with status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// ErrTokenInvalid indicates the token is invalid or expired +var ErrTokenInvalid = fmt.Errorf("token is invalid or expired") + +// IsConnectionError checks if an error is a network/connection error +func IsConnectionError(err error) bool { + if err == nil { + return false + } + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "connection refused") || + strings.Contains(errStr, "no such host") || + strings.Contains(errStr, "network is unreachable") || + strings.Contains(errStr, "timeout") || + strings.Contains(errStr, "dial tcp") || + strings.Contains(errStr, "i/o timeout") +} diff --git a/pkg/config/cli_config.go b/pkg/config/cli_config.go index 42fccaab..ae274e0f 100644 --- a/pkg/config/cli_config.go +++ b/pkg/config/cli_config.go @@ -179,6 +179,18 @@ func (c *ArgoCLIConfig) GetCurrentToken() (string, error) { return "", fmt.Errorf("user %s not found in ArgoCD config", currentUser) } +// GetServerURLForCurrentContext returns the server URL for the current context +// without requiring a token. Used for login flow. +func (c *ArgoCLIConfig) GetServerURLForCurrentContext() (string, bool, error) { + serverConfig, err := c.GetCurrentServerConfig() + if err != nil { + return "", false, err + } + + baseURL := ensureHTTPS(serverConfig.Server, serverConfig.PlainText) + return baseURL, serverConfig.Insecure, nil +} + // ToServerConfig converts the ArgoCD CLI config to our internal Server model func (c *ArgoCLIConfig) ToServerConfig() (*model.Server, error) { serverConfig, err := c.GetCurrentServerConfig() diff --git a/pkg/model/messages.go b/pkg/model/messages.go index 8a6dbb94..00f8d64b 100644 --- a/pkg/model/messages.go +++ b/pkg/model/messages.go @@ -467,3 +467,45 @@ type ChangelogLoadedMsg struct { Content string Error error } + +// Login Messages - for automatic login functionality + +// LoginSubmitMsg is sent when user submits the login form +type LoginSubmitMsg struct { + Username string + Password string + SaveCredentials bool +} + +// LoginSuccessMsg is sent when login succeeds +type LoginSuccessMsg struct { + Token string + Username string +} + +// LoginErrorMsg is sent when login fails +type LoginErrorMsg struct { + Error string +} + +// AutoLoginAttemptMsg triggers an automatic login attempt using stored credentials +type AutoLoginAttemptMsg struct { + ServerURL string + Insecure bool +} + +// AutoLoginResultMsg is the result of an automatic login attempt +type AutoLoginResultMsg struct { + Success bool + Token string + Username string + Error error +} + +// LoginCancelMsg is sent when user cancels the login modal +type LoginCancelMsg struct{} + +// LoginFieldFocusMsg changes focus within the login form +type LoginFieldFocusMsg struct { + Field int // 0=username, 1=password, 2=save, 3=login, 4=cancel +} diff --git a/pkg/model/state.go b/pkg/model/state.go index e058f9e2..ef4ada84 100644 --- a/pkg/model/state.go +++ b/pkg/model/state.go @@ -165,6 +165,13 @@ type ModalState struct { ChangelogLoading bool `json:"changelogLoading"` // K9s error modal state K9sError *string `json:"k9sError,omitempty"` + // Login modal state + LoginServerURL string `json:"loginServerURL,omitempty"` + LoginUsername string `json:"loginUsername,omitempty"` + LoginError *string `json:"loginError,omitempty"` + LoginSaveCredentials bool `json:"loginSaveCredentials"` + LoginFieldFocus int `json:"loginFieldFocus"` // 0=username, 1=password, 2=save checkbox, 3=login btn, 4=cancel btn + LoginLoading bool `json:"loginLoading"` } // AppState represents the complete application state for Bubbletea diff --git a/pkg/model/types.go b/pkg/model/types.go index db7cdd98..2528990f 100644 --- a/pkg/model/types.go +++ b/pkg/model/types.go @@ -44,6 +44,8 @@ const ( ModeK9sContextSelect Mode = "k9s-context-select" ModeK9sError Mode = "k9s-error" ModeConfirmResourceSync Mode = "confirm-resource-sync" + ModeLogin Mode = "login" // Login prompt modal + ModeLoginLoading Mode = "login-loading" // Login in progress ) // App represents an ArgoCD application