diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 61066dbbd..99d86a17e 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -19,9 +19,7 @@ This document describes all environment variables and configuration options avai | `FLIGHTCTL_SERVER_INSECURE_SKIP_VERIFY` | Skip backend server TLS verification | `false` | `true`, `false` | | `FLIGHTCTL_CLI_ARTIFACTS_SERVER` | CLI artifacts server URL | `http://localhost:8090` | `https://cli.flightctl.example.com` | | `FLIGHTCTL_ALERTMANAGER_PROXY` | AlertManager proxy server URL | `https://localhost:8443` | `https://alerts.flightctl.example.com` | -| `INTERNAL_AUTH_URL` | Internal authentication URL | _(empty)_ | `https://auth.internal.example.com` | | `AUTH_INSECURE_SKIP_VERIFY` | Skip auth server TLS verification | `false` | `true`, `false` | -| `AUTH_CLIENT_ID` | OAuth client ID for authentication | `flightctl` | Custom client ID | | `TLS_CERT` | Path to TLS certificate | _(empty)_ | `/path/to/server.crt` | | `TLS_KEY` | Path to TLS private key | _(empty)_ | `/path/to/server.key` | | `API_PORT` | UI proxy server port | `3001` | `8080`, `3000`, etc. | diff --git a/apps/ocp-plugin/src/components/AppContext/AppContext.tsx b/apps/ocp-plugin/src/components/AppContext/AppContext.tsx index 074e095d1..eba8c9e15 100644 --- a/apps/ocp-plugin/src/components/AppContext/AppContext.tsx +++ b/apps/ocp-plugin/src/components/AppContext/AppContext.tsx @@ -53,7 +53,12 @@ const appRoutes = { [ROUTE.RESOURCE_SYNC_DETAILS]: '/edge/resourcesyncs', [ROUTE.ENROLLMENT_REQUESTS]: '/edge/enrollmentrequests', [ROUTE.ENROLLMENT_REQUEST_DETAILS]: '/edge/enrollmentrequests', + // Unimplemented UI routes for OCP plugin [ROUTE.COMMAND_LINE_TOOLS]: '/', // CLI downloads are shown embedded in OCP's CLI downloads page and not as an independent route + [ROUTE.AUTH_PROVIDERS]: '/', // Authentication providers must be defined in the OpenShift Console, not through Flight Control + [ROUTE.AUTH_PROVIDER_CREATE]: '/', + [ROUTE.AUTH_PROVIDER_EDIT]: '/', + [ROUTE.AUTH_PROVIDER_DETAILS]: '/', }; export const useValuesAppContext = (): AppContextProps => { diff --git a/apps/ocp-plugin/src/utils/apiCalls.ts b/apps/ocp-plugin/src/utils/apiCalls.ts index 0e7396341..99fce6370 100644 --- a/apps/ocp-plugin/src/utils/apiCalls.ts +++ b/apps/ocp-plugin/src/utils/apiCalls.ts @@ -63,8 +63,10 @@ const handleAlertsJSONResponse = async (response: Response): Promise => { throw new Error(`Error ${response.status}: ${response.statusText}`); } - // For 500/501 errors, return the status code for detection - if (response.status === 500 || response.status === 501) { + // The UI proxy should convert alert errors 401 to 501, but we guard against 401 here as well. + const isAlertsDisabled = response.status === 500 || response.status === 501 || response.status === 401; + if (isAlertsDisabled) { + // Return the status code to correctly detect that alerts are disabled throw new Error(`${response.status}`); } diff --git a/apps/standalone/src/app/components/Login/LoginPage.tsx b/apps/standalone/src/app/components/Login/LoginPage.tsx index 9c78ebd78..8e52da6be 100644 --- a/apps/standalone/src/app/components/Login/LoginPage.tsx +++ b/apps/standalone/src/app/components/Login/LoginPage.tsx @@ -1,190 +1,166 @@ import * as React from 'react'; -import { - Alert, - Bullseye, - Button, - Card, - CardBody, - CardFooter, - FormGroup, - FormHelperText, - FormSection, - HelperText, - HelperTextItem, - Stack, - StackItem, - Text, - TextArea, - TextContent, - TextVariants, - Title, -} from '@patternfly/react-core'; - -import { ORGANIZATION_STORAGE_KEY } from '@flightctl/ui-components/src/utils/organizationStorage'; -import FlightCtlForm from '@flightctl/ui-components/src/components/form/FlightCtlForm'; +import { Alert, Bullseye, Spinner } from '@patternfly/react-core'; + +import { AuthConfig, AuthProvider } from '@flightctl/types'; +import { ProviderType } from '@flightctl/ui-components/src/types/extraTypes'; +import ProviderSelector from '@flightctl/ui-components/src/components/Login/ProviderSelector'; +import TokenLoginForm from '@flightctl/ui-components/src/components/Login/TokenLoginForm'; +import { useFetch } from '@flightctl/ui-components/src/hooks/useFetch'; import { useTranslation } from '@flightctl/ui-components/src/hooks/useTranslation'; +import { getProviderDisplayName } from '@flightctl/ui-components/src/utils/authProvider'; + +import LoginPageLayout from './LoginPageLayout'; import { loginAPI } from '../../utils/apiCalls'; -const EXPIRATION = 'expiration'; +const redirectToProviderLogin = async (provider: AuthProvider) => { + const response = await fetch(`${loginAPI}?provider=${provider.metadata.name}`); -const nowInSeconds = () => Math.floor(Date.now() / 1000); + if (!response.ok) { + throw new Error(`Login redirect failed with status ${response.status}`); + } -// Simple JWT format validation - checks if token has 3 parts separated by dots -export const isValidJwtTokenFormat = (token: string): boolean => { - if (!token) return false; - const parts = token.split('.'); - if (parts.length !== 3) return false; - // Check that each part contains only valid base64url characters - const base64urlPattern = /^[A-Za-z0-9_-]+$/; - return parts.every((part) => part.length > 0 && base64urlPattern.test(part)); + const { url } = (await response.json()) as { url?: string }; + if (!url) { + throw new Error('Login redirect URL missing in response'); + } + window.location.href = url; }; -export const LoginPage = () => { +const LoginPage = () => { const { t } = useTranslation(); - const [token, setToken] = React.useState(''); - const [validationError, setValidationError] = React.useState(''); - const [isSubmitting, setIsSubmitting] = React.useState(false); - const [submitError, setSubmitError] = React.useState(); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setSubmitError(undefined); - setIsSubmitting(true); - - try { - localStorage.removeItem(EXPIRATION); - localStorage.removeItem(ORGANIZATION_STORAGE_KEY); - - const resp = await fetch(loginAPI, { - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - method: 'POST', - body: JSON.stringify({ - token: token.trim(), - }), - }); - - if (!resp.ok) { - let errorMessage = t('Authentication failed'); - try { - const contentType = resp.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - const errorData = (await resp.json()) as { error?: string }; - errorMessage = errorData.error || errorMessage; - } else { - // Fallback for non-JSON responses - const text = await resp.text(); - if (text) { - errorMessage = text; + const { get } = useFetch(); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(); + const [providers, setProviders] = React.useState([]); + const [userSelectedProvider, setUserSelectedProvider] = React.useState(null); + const [defaultProviderName, setDefaultProviderName] = React.useState(''); + const [isRedirecting, setIsRedirecting] = React.useState(false); + + const handleProviderSelect = async (provider: AuthProvider) => { + // Prevent multiple clicks while redirect is in progress + if (isRedirecting) { + return; + } + + setUserSelectedProvider(provider); + + // For k8s token providers, we will show the TokenLoginForm. + // For other providers, we will redirect to their OAuth flow. + if (provider.spec.providerType !== ProviderType.K8s) { + setIsRedirecting(true); + try { + await redirectToProviderLogin(provider); + } catch (err) { + setIsRedirecting(false); + setUserSelectedProvider(null); + setError( + t('Failed to initiate login with {{ providerName}} ', { + providerName: getProviderDisplayName(provider, t) || (provider.metadata.name as string), + }), + ); + } + } + }; + + React.useEffect(() => { + const loadAuthConfig = async () => { + try { + const config = await get('auth/config'); + const providers = (config?.providers || []) + .filter((provider) => provider.spec.enabled !== false) + .sort((a, b) => { + if (a.metadata.name === config.defaultProvider) { + return -1; + } + if (b.metadata.name === config.defaultProvider) { + return 1; + } + return 0; + }); + if (providers.length > 0) { + setProviders(providers); + setDefaultProviderName(config.defaultProvider || ''); + if (providers.length === 1 && providers[0].spec.providerType !== ProviderType.K8s) { + setIsRedirecting(true); + try { + await redirectToProviderLogin(providers[0]); + } catch (err) { + setIsRedirecting(false); + setError(t('Failed to initiate login')); } } - } catch (parseErr) { - // If parsing fails, use default error message - errorMessage = t('Authentication failed'); + } else { + setError(t('No authentication providers found. Please contact your administrator.')); } - setSubmitError(errorMessage); - setIsSubmitting(false); - return; + } catch (err) { + setError(t('Failed to load the authentication providers')); + } finally { + setLoading(false); } + }; - const expiration = (await resp.json()) as { expiresIn: number }; - if (expiration.expiresIn) { - const now = nowInSeconds(); - localStorage.setItem(EXPIRATION, `${now + expiration.expiresIn}`); - } + void loadAuthConfig(); + // Prevent the UI going to a loop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + {error} + + + ); + } - // Redirect to home page after successful login - window.location.href = '/'; - } catch (err) { - setSubmitError(t('Failed to authenticate. Please check your token and try again.')); - setIsSubmitting(false); + let content: React.ReactNode = null; + + const selectedProvider = userSelectedProvider || (providers.length === 1 ? providers[0] : null); + if (selectedProvider) { + if (selectedProvider.spec.providerType === ProviderType.K8s) { + content = ( + { + setUserSelectedProvider(null); + } + : undefined + } + /> + ); + } else { + content = ( + <> + {t('Redirecting to login for {{ provider }}...', { + provider: getProviderDisplayName(selectedProvider, t) || selectedProvider.metadata.name, + })} + + + ); } - }; + } else { + content = ( + + ); + } - return ( - - - - - - - {t('Enter your Kubernetes token')} - - - - - - {t('Enter your Kubernetes service account token to authenticate with the cluster.')} - - {t('You can find this token in your Kubernetes service account credentials.')} - - - - - - -