Skip to content
Merged
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
4 changes: 2 additions & 2 deletions Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ ENV NODE_OPTIONS='--max-old-space-size=8192'
RUN npm ci
RUN npm run build

FROM registry.access.redhat.com/ubi9/go-toolset:1.23.9-1751538372 as proxy-build
FROM registry.access.redhat.com/ubi9/go-toolset:1.24.6-1762373805 as proxy-build
WORKDIR /app
COPY proxy /app
USER 0
RUN CGO_ENABLED=1 CGO_CFLAGS=-flto GOEXPERIMENT=strictfipsruntime go build

FROM quay.io/flightctl/flightctl-base:9.6-1758714456
FROM quay.io/flightctl/flightctl-base:9.6-1762316544
COPY --from=ui-build /app/apps/standalone/dist /app/proxy/dist
COPY --from=proxy-build /app/flightctl-ui /app/proxy
WORKDIR /app/proxy
Expand Down
4 changes: 2 additions & 2 deletions Containerfile.ocp
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ ENV NODE_OPTIONS='--max-old-space-size=8192'
RUN npm ci
RUN npm run build:ocp

FROM registry.access.redhat.com/ubi9/go-toolset:1.23.9-1751538372 as proxy-build
FROM registry.access.redhat.com/ubi9/go-toolset:1.24.6-1762373805 as proxy-build
WORKDIR /app
COPY proxy /app
USER 0
RUN CGO_ENABLED=1 CGO_CFLAGS=-flto GOEXPERIMENT=strictfipsruntime go build

FROM quay.io/flightctl/flightctl-base:9.6-1758714456
FROM quay.io/flightctl/flightctl-base:9.6-1762316544
COPY --from=ui-build /app/apps/ocp-plugin/dist /app/proxy/dist
COPY --from=proxy-build /app/flightctl-ui /app/proxy
WORKDIR /app/proxy
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Monorepo containing UIs for [Flight Control](https://github.com/flightctl/flight

## Prerequisites

- `Git`, `Node.js v22.x`, `npm v10.x`, `rsync`, `go` (>= 1.23)
- `Git`, `Node.js v22.x`, `npm v10.x`, `rsync`, `go` (>= 1.24)

## Building

Expand Down
190 changes: 190 additions & 0 deletions apps/standalone/src/app/components/Login/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
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 { useTranslation } from '@flightctl/ui-components/src/hooks/useTranslation';
import { loginAPI } from '../../utils/apiCalls';

const EXPIRATION = 'expiration';

const nowInSeconds = () => Math.floor(Date.now() / 1000);

// 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));
};

export const LoginPage = () => {
const { t } = useTranslation();
const [token, setToken] = React.useState('');
const [validationError, setValidationError] = React.useState<string>('');
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [submitError, setSubmitError] = React.useState<string>();

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;
}
}
} catch (parseErr) {
// If parsing fails, use default error message
errorMessage = t('Authentication failed');
}
setSubmitError(errorMessage);
setIsSubmitting(false);
return;
}

const expiration = (await resp.json()) as { expiresIn: number };
if (expiration.expiresIn) {
const now = nowInSeconds();
localStorage.setItem(EXPIRATION, `${now + expiration.expiresIn}`);
}

// Redirect to home page after successful login
window.location.href = '/';
} catch (err) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should show the error somewhere ? Otherwise it will be really hard to debug

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll do.
In the full branch the UI proxy returns different error messages and the UI shows them, but it's also worth having it here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the invalid/expired token. Will address more use cases if needed in the full branch.

setSubmitError(t('Failed to authenticate. Please check your token and try again.'));
setIsSubmitting(false);
}
};

return (
<Bullseye>
<Card isLarge>
<CardBody>
<Stack hasGutter style={{ '--pf-v5-l-stack--m-gutter--Gap': '1.5rem' } as React.CSSProperties}>
<StackItem>
<Title headingLevel="h2" size="xl">
{t('Enter your Kubernetes token')}
</Title>
</StackItem>

<StackItem>
<TextContent>
<Text>{t('Enter your Kubernetes service account token to authenticate with the cluster.')}</Text>
<Text component={TextVariants.small}>
{t('You can find this token in your Kubernetes service account credentials.')}
</Text>
</TextContent>
</StackItem>
<StackItem>
<FlightCtlForm>
<FormGroup label={t('Service account token')} isRequired>
<TextArea
id="accessToken"
value={token}
onChange={(_event, tokenVal) => {
if (tokenVal && !isValidJwtTokenFormat(tokenVal)) {
setValidationError(
t('Invalid token format. Expected a JWT token with format: header.payload.signature'),
);
} else {
setValidationError('');
}
if (submitError) {
setSubmitError(undefined);
}
setToken(tokenVal);
}}
placeholder={t('Enter your Kubernetes token...')}
rows={10}
isRequired
isDisabled={isSubmitting}
autoFocus
validated={validationError ? 'error' : 'default'}
/>
{validationError && (
<FormHelperText>
<HelperText>
<HelperTextItem variant="error">{validationError}</HelperTextItem>
</HelperText>
</FormHelperText>
)}
</FormGroup>

<Alert variant="warning" title={t('Keep your token secure')} isInline>
{t(
'Never share your service account token. It provides full access to your Kubernetes cluster resources.',
)}
</Alert>

{submitError && (
<FormSection>
<Alert variant="danger" title={t('Authentication failed')} isInline>
{submitError}
</Alert>
</FormSection>
)}
</FlightCtlForm>
</StackItem>
</Stack>
</CardBody>
<CardFooter>
<Button
variant="primary"
isDisabled={!token || !!validationError || isSubmitting}
isLoading={isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? t('Authenticating...') : t('Login')}
</Button>
</CardFooter>
</Card>
</Bullseye>
);
};
16 changes: 15 additions & 1 deletion apps/standalone/src/app/context/AuthContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export const useAuthContext = () => {

React.useEffect(() => {
const getUserInfo = async () => {
// Skip auth check if we're on the login page
if (window.location.pathname === '/login') {
setLoading(false);
return;
}

let callbackErr: string | null = null;
if (window.location.pathname === '/callback') {
localStorage.removeItem(EXPIRATION);
Expand Down Expand Up @@ -74,7 +80,10 @@ export const useAuthContext = () => {
return;
}
if (resp.status === 401) {
await redirectToLogin();
// Don't redirect if we're already on the login page
if (window.location.pathname !== '/login') {
await redirectToLogin();
}
return;
}
if (resp.status !== 200) {
Expand All @@ -97,6 +106,11 @@ export const useAuthContext = () => {

React.useEffect(() => {
if (!loading) {
// Don't schedule refresh if we're on login page
if (window.location.pathname === '/login') {
return;
}

const scheduleRefresh = () => {
if (!authEnabled) {
return;
Expand Down
5 changes: 5 additions & 0 deletions apps/standalone/src/app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import ErrorBoundary from '@flightctl/ui-components/src/components/common/ErrorB
import AppLayout from './components/AppLayout/AppLayout';
import NotFound from './components/AppLayout/NotFound';
import { AuthContext } from './context/AuthContext';
import { LoginPage } from './components/Login/LoginPage';

const EnrollmentRequestDetails = React.lazy(
() =>
Expand Down Expand Up @@ -341,6 +342,10 @@ const AppRouter = () => {
}

const router = createBrowserRouter([
{
path: '/login',
element: <LoginPage />,
},
{
path: '/',
element: <AppLayout />,
Expand Down
9 changes: 7 additions & 2 deletions apps/standalone/src/app/utils/apiCalls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ export const logout = async () => {
export const redirectToLogin = async () => {
const response = await fetch(loginAPI);
const { url } = (await response.json()) as { url: string };
window.location.href = url;
// If URL is empty, it means token-based auth (k8s) - redirect to login page
// Otherwise, it's OAuth - redirect to external provider
window.location.href = url || '/login';
};

const handleApiJSONResponse = async <R>(response: Response): Promise<R> => {
Expand All @@ -73,7 +75,10 @@ const handleApiJSONResponse = async <R>(response: Response): Promise<R> => {
}

if (response.status === 401) {
await redirectToLogin();
// Don't redirect if we're already on the login page
if (window.location.pathname !== '/login') {
await redirectToLogin();
}
}

throw new Error(await getErrorMsgFromApiResponse(response));
Expand Down
13 changes: 13 additions & 0 deletions libs/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@
"404 Page not found": "404 Page not found",
"We didn't find a page that matches the address you navigated to.": "We didn't find a page that matches the address you navigated to.",
"Take me home": "Take me home",
"Authentication failed": "Authentication failed",
"Failed to authenticate. Please check your token and try again.": "Failed to authenticate. Please check your token and try again.",
"Enter your Kubernetes token": "Enter your Kubernetes token",
"Enter your Kubernetes service account token to authenticate with the cluster.": "Enter your Kubernetes service account token to authenticate with the cluster.",
"You can find this token in your Kubernetes service account credentials.": "You can find this token in your Kubernetes service account credentials.",
"Service account token": "Service account token",
"Invalid token format. Expected a JWT token with format: header.payload.signature": "Invalid token format. Expected a JWT token with format: header.payload.signature",
"Enter your Kubernetes token...": "Enter your Kubernetes token...",
"Keep your token secure": "Keep your token secure",
"Never share your service account token. It provides full access to your Kubernetes cluster resources.": "Never share your service account token. It provides full access to your Kubernetes cluster resources.",
"Authenticating...": "Authenticating...",
"Login": "Login",
"404 Page Not Found": "404 Page Not Found",
"Error page - details should be displayed here": "Error page - details should be displayed here",
"Overview": "Overview",
Expand Down Expand Up @@ -684,6 +696,7 @@
"Resource was deleted successfully": "Resource was deleted successfully",
"Enrollment request": "Enrollment request",
"Template version": "Template version",
"Auth provider": "Auth provider",
"Loading alerts...": "Loading alerts...",
"Error loading alerts": "Error loading alerts",
"Alerts": "Alerts",
Expand Down
20 changes: 18 additions & 2 deletions libs/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/* tslint:disable */
/* eslint-disable */

export type { AapProviderSpec } from './models/AapProviderSpec';
export type { AbsolutePath } from './models/AbsolutePath';
export type { ApplicationContent } from './models/ApplicationContent';
export type { ApplicationEnvVars } from './models/ApplicationEnvVars';
Expand All @@ -13,9 +14,17 @@ export type { ApplicationVolume } from './models/ApplicationVolume';
export type { ApplicationVolumeProviderSpec } from './models/ApplicationVolumeProviderSpec';
export type { ApplicationVolumeStatus } from './models/ApplicationVolumeStatus';
export { AppType } from './models/AppType';
export type { ArtifactApplicationProviderSpec } from './models/ArtifactApplicationProviderSpec';
export type { AuthConfig } from './models/AuthConfig';
export type { AuthOrganizationsConfig } from './models/AuthOrganizationsConfig';
export type { AuthDynamicOrganizationAssignment } from './models/AuthDynamicOrganizationAssignment';
export type { AuthDynamicRoleAssignment } from './models/AuthDynamicRoleAssignment';
export type { AuthOrganizationAssignment } from './models/AuthOrganizationAssignment';
export type { AuthPerUserOrganizationAssignment } from './models/AuthPerUserOrganizationAssignment';
export type { AuthProvider } from './models/AuthProvider';
export type { AuthProviderList } from './models/AuthProviderList';
export type { AuthProviderSpec } from './models/AuthProviderSpec';
export type { AuthRoleAssignment } from './models/AuthRoleAssignment';
export type { AuthStaticOrganizationAssignment } from './models/AuthStaticOrganizationAssignment';
export type { AuthStaticRoleAssignment } from './models/AuthStaticRoleAssignment';
export type { Batch } from './models/Batch';
export type { BatchSequence } from './models/BatchSequence';
export type { CertificateSigningRequest } from './models/CertificateSigningRequest';
Expand Down Expand Up @@ -114,15 +123,18 @@ export type { InlineApplicationProviderSpec } from './models/InlineApplicationPr
export type { InlineConfigProviderSpec } from './models/InlineConfigProviderSpec';
export type { InternalTaskFailedDetails } from './models/InternalTaskFailedDetails';
export type { InternalTaskPermanentlyFailedDetails } from './models/InternalTaskPermanentlyFailedDetails';
export type { K8sProviderSpec } from './models/K8sProviderSpec';
export type { KubernetesSecretProviderSpec } from './models/KubernetesSecretProviderSpec';
export type { LabelList } from './models/LabelList';
export type { LabelSelector } from './models/LabelSelector';
export type { ListMeta } from './models/ListMeta';
export { MatchExpression } from './models/MatchExpression';
export type { MatchExpressions } from './models/MatchExpressions';
export type { MemoryResourceMonitorSpec } from './models/MemoryResourceMonitorSpec';
export type { OAuth2ProviderSpec } from './models/OAuth2ProviderSpec';
export type { ObjectMeta } from './models/ObjectMeta';
export type { ObjectReference } from './models/ObjectReference';
export type { OIDCProviderSpec } from './models/OIDCProviderSpec';
export type { Organization } from './models/Organization';
export type { OrganizationList } from './models/OrganizationList';
export type { OrganizationSpec } from './models/OrganizationSpec';
Expand Down Expand Up @@ -152,6 +164,10 @@ export { RolloutStrategy } from './models/RolloutStrategy';
export type { SshConfig } from './models/SshConfig';
export type { SshRepoSpec } from './models/SshRepoSpec';
export type { Status } from './models/Status';
export { SystemdActiveStateType } from './models/SystemdActiveStateType';
export { SystemdEnableStateType } from './models/SystemdEnableStateType';
export { SystemdLoadStateType } from './models/SystemdLoadStateType';
export type { SystemdUnitStatus } from './models/SystemdUnitStatus';
export type { TemplateVersion } from './models/TemplateVersion';
export type { TemplateVersionList } from './models/TemplateVersionList';
export type { TemplateVersionSpec } from './models/TemplateVersionSpec';
Expand Down
Loading