diff --git a/e2e/specs/external-auth.spec.ts b/e2e/specs/external-auth.spec.ts index 12ec4f54..f36f2cd9 100644 --- a/e2e/specs/external-auth.spec.ts +++ b/e2e/specs/external-auth.spec.ts @@ -104,7 +104,11 @@ async function configureLDAPViaUI(page: Page) { await page.goto('/settings?tab=oauth') await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible() - const ldapSwitch = page.getByRole('switch').first() + const ldapSection = page + .locator('div.rounded-lg.border') + .filter({ has: page.getByText(/^LDAP$/) }) + .first() + const ldapSwitch = ldapSection.getByRole('switch') if ((await ldapSwitch.getAttribute('data-state')) !== 'checked') { await ldapSwitch.click() } diff --git a/pkg/ai/handler.go b/pkg/ai/handler.go index 3f1c4d63..fde36bc7 100644 --- a/pkg/ai/handler.go +++ b/pkg/ai/handler.go @@ -196,33 +196,35 @@ func HandleGetGeneralSetting(c *gin.Context) { } hasAIAPIKey := strings.TrimSpace(string(setting.AIAPIKey)) != "" c.JSON(http.StatusOK, gin.H{ - "aiAgentEnabled": setting.AIAgentEnabled, - "aiProvider": setting.AIProvider, - "aiModel": setting.AIModel, - "aiApiKey": "", - "aiApiKeyConfigured": hasAIAPIKey, - "aiBaseUrl": setting.AIBaseURL, - "aiMaxTokens": setting.AIMaxTokens, - "kubectlEnabled": setting.KubectlEnabled, - "kubectlImage": setting.KubectlImage, - "nodeTerminalImage": setting.NodeTerminalImage, - "enableAnalytics": setting.EnableAnalytics, - "enableVersionCheck": setting.EnableVersionCheck, + "aiAgentEnabled": setting.AIAgentEnabled, + "aiProvider": setting.AIProvider, + "aiModel": setting.AIModel, + "aiApiKey": "", + "aiApiKeyConfigured": hasAIAPIKey, + "aiBaseUrl": setting.AIBaseURL, + "aiMaxTokens": setting.AIMaxTokens, + "kubectlEnabled": setting.KubectlEnabled, + "kubectlImage": setting.KubectlImage, + "nodeTerminalImage": setting.NodeTerminalImage, + "enableAnalytics": setting.EnableAnalytics, + "enableVersionCheck": setting.EnableVersionCheck, + "passwordLoginDisabled": setting.PasswordLoginDisabled, }) } type UpdateGeneralSettingRequest struct { - AIAgentEnabled bool `json:"aiAgentEnabled"` - AIProvider string `json:"aiProvider"` - AIModel string `json:"aiModel"` - AIAPIKey *string `json:"aiApiKey"` - AIBaseURL string `json:"aiBaseUrl"` - AIMaxTokens int `json:"aiMaxTokens"` - KubectlEnabled bool `json:"kubectlEnabled"` - KubectlImage string `json:"kubectlImage"` - NodeTerminalImage string `json:"nodeTerminalImage"` - EnableAnalytics bool `json:"enableAnalytics"` - EnableVersionCheck bool `json:"enableVersionCheck"` + AIAgentEnabled bool `json:"aiAgentEnabled"` + AIProvider string `json:"aiProvider"` + AIModel string `json:"aiModel"` + AIAPIKey *string `json:"aiApiKey"` + AIBaseURL string `json:"aiBaseUrl"` + AIMaxTokens int `json:"aiMaxTokens"` + KubectlEnabled bool `json:"kubectlEnabled"` + KubectlImage string `json:"kubectlImage"` + NodeTerminalImage string `json:"nodeTerminalImage"` + EnableAnalytics bool `json:"enableAnalytics"` + EnableVersionCheck bool `json:"enableVersionCheck"` + PasswordLoginDisabled *bool `json:"passwordLoginDisabled"` } func HandleUpdateGeneralSetting(c *gin.Context) { @@ -298,6 +300,9 @@ func HandleUpdateGeneralSetting(c *gin.Context) { "enable_analytics": req.EnableAnalytics, "enable_version_check": req.EnableVersionCheck, } + if req.PasswordLoginDisabled != nil { + updates["password_login_disabled"] = *req.PasswordLoginDisabled + } if shouldUpdateAIAPIKey { updates["ai_api_key"] = model.SecretString(aiAPIKey) } @@ -310,18 +315,19 @@ func HandleUpdateGeneralSetting(c *gin.Context) { hasAIAPIKey := strings.TrimSpace(string(updated.AIAPIKey)) != "" c.JSON(http.StatusOK, gin.H{ - "aiAgentEnabled": updated.AIAgentEnabled, - "aiProvider": updated.AIProvider, - "aiModel": updated.AIModel, - "aiApiKey": "", - "aiApiKeyConfigured": hasAIAPIKey, - "aiBaseUrl": updated.AIBaseURL, - "aiMaxTokens": updated.AIMaxTokens, - "kubectlEnabled": updated.KubectlEnabled, - "kubectlImage": updated.KubectlImage, - "nodeTerminalImage": updated.NodeTerminalImage, - "enableAnalytics": updated.EnableAnalytics, - "enableVersionCheck": updated.EnableVersionCheck, + "aiAgentEnabled": updated.AIAgentEnabled, + "aiProvider": updated.AIProvider, + "aiModel": updated.AIModel, + "aiApiKey": "", + "aiApiKeyConfigured": hasAIAPIKey, + "aiBaseUrl": updated.AIBaseURL, + "aiMaxTokens": updated.AIMaxTokens, + "kubectlEnabled": updated.KubectlEnabled, + "kubectlImage": updated.KubectlImage, + "nodeTerminalImage": updated.NodeTerminalImage, + "enableAnalytics": updated.EnableAnalytics, + "enableVersionCheck": updated.EnableVersionCheck, + "passwordLoginDisabled": updated.PasswordLoginDisabled, }) } diff --git a/pkg/auth/login_handler.go b/pkg/auth/login_handler.go index 51e20383..2df456bb 100644 --- a/pkg/auth/login_handler.go +++ b/pkg/auth/login_handler.go @@ -15,7 +15,16 @@ import ( ) func (h *AuthHandler) GetProviders(c *gin.Context) { - credentialProviders := []string{model.AuthProviderPassword} + var credentialProviders []string + + generalSetting, err := model.GetGeneralSetting() + if err != nil { + klog.Warningf("Failed to load general setting for providers: %v", err) + } + if generalSetting == nil || !generalSetting.PasswordLoginDisabled { + credentialProviders = append(credentialProviders, model.AuthProviderPassword) + } + oauthProviders := uniqueStrings(h.manager.GetAvailableProviders()) setting, err := model.GetLDAPSetting() @@ -67,6 +76,11 @@ func (h *AuthHandler) Login(c *gin.Context) { } func (h *AuthHandler) PasswordLogin(c *gin.Context) { + setting, err := model.GetGeneralSetting() + if err == nil && setting.PasswordLoginDisabled { + c.JSON(http.StatusForbidden, gin.H{"error": "Password login is disabled"}) + return + } h.handleCredentialLogin(c, model.AuthProviderPassword, h.authenticatePasswordUser) } diff --git a/pkg/model/general_setting.go b/pkg/model/general_setting.go index bf1a1eb7..4bd2a88f 100644 --- a/pkg/model/general_setting.go +++ b/pkg/model/general_setting.go @@ -41,6 +41,7 @@ type GeneralSetting struct { NodeTerminalImage string `json:"nodeTerminalImage" gorm:"column:node_terminal_image;type:varchar(255);not null;default:'busybox:latest'"` EnableAnalytics bool `json:"enableAnalytics" gorm:"column:enable_analytics;type:boolean;not null;default:true"` EnableVersionCheck bool `json:"enableVersionCheck" gorm:"column:enable_version_check;type:boolean;not null;default:true"` + PasswordLoginDisabled bool `json:"passwordLoginDisabled" gorm:"column:password_login_disabled;type:boolean;not null;default:false"` JWTSecret SecretString `json:"-" gorm:"column:jwt_secret;type:text"` GlobalSidebarPreference string `json:"-" gorm:"column:global_sidebar_preference;type:text"` } diff --git a/ui/src/components/settings/authentication-management.tsx b/ui/src/components/settings/authentication-management.tsx index f24c2bf7..216fc702 100644 --- a/ui/src/components/settings/authentication-management.tsx +++ b/ui/src/components/settings/authentication-management.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { IconKey } from '@tabler/icons-react' +import { IconAlertTriangle, IconKey } from '@tabler/icons-react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -7,7 +7,9 @@ import { toast } from 'sonner' import { LDAPSetting, LDAPSettingUpdateRequest, + updateGeneralSetting, updateLDAPSetting, + useGeneralSetting, useLDAPSetting, } from '@/lib/api' import { translateError } from '@/lib/utils' @@ -65,20 +67,39 @@ export function AuthenticationManagement() { const { t } = useTranslation() const queryClient = useQueryClient() const { data, error, isError, isLoading, refetch } = useLDAPSetting() + const { data: generalSetting } = useGeneralSetting() const [formData, setFormData] = useState( createDefaultSettings ) + const [passwordLoginEnabled, setPasswordLoginEnabled] = useState(true) useEffect(() => { setFormData(toFormData(data)) }, [data]) + useEffect(() => { + if (generalSetting) { + setPasswordLoginEnabled(!generalSetting.passwordLoginDisabled) + } + }, [generalSetting]) + const mutation = useMutation({ - mutationFn: updateLDAPSetting, + mutationFn: (params: { + ldap: LDAPSettingUpdateRequest + passwordLoginDisabled: boolean + }) => + Promise.all([ + updateLDAPSetting(params.ldap), + updateGeneralSetting({ + ...generalSetting!, + passwordLoginDisabled: params.passwordLoginDisabled, + }), + ]), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ldap-setting'], }) + queryClient.invalidateQueries({ queryKey: ['general-setting'] }) toast.success( t( 'authenticationManagement.messages.updated', @@ -121,7 +142,10 @@ export function AuthenticationManagement() { payload.bindPassword = formData.bindPassword } - mutation.mutate(payload) + mutation.mutate({ + ldap: payload, + passwordLoginDisabled: !passwordLoginEnabled, + }) } if (isLoading && !data) { @@ -166,6 +190,52 @@ export function AuthenticationManagement() { +
+
+
+ +

+ {t( + 'authenticationManagement.password.description', + 'Allow users to sign in with a username and password.' + )} +

+
+ +
+ + {!passwordLoginEnabled && ( +
+
+ +
+

+ {t( + 'authenticationManagement.password.warning.title', + 'Warning: You may lose access!' + )} +

+

+ {t( + 'authenticationManagement.password.warning.description', + 'Verify that your LDAP or OAuth provider is working before saving. Without a working alternative login method, you will be locked out and can only recover by resetting the database.' + )} +

+
+
+
+ )} +
+
@@ -427,7 +497,10 @@ export function AuthenticationManagement() {
-
diff --git a/ui/src/lib/api/admin.ts b/ui/src/lib/api/admin.ts index 64a80073..418c091d 100644 --- a/ui/src/lib/api/admin.ts +++ b/ui/src/lib/api/admin.ts @@ -354,6 +354,7 @@ export interface GeneralSetting { nodeTerminalImage: string enableAnalytics: boolean enableVersionCheck: boolean + passwordLoginDisabled: boolean } export interface GeneralSettingUpdateRequest { @@ -368,6 +369,7 @@ export interface GeneralSettingUpdateRequest { nodeTerminalImage?: string enableAnalytics?: boolean enableVersionCheck?: boolean + passwordLoginDisabled?: boolean } export type CredentialProvider = 'password' | 'ldap'