diff --git a/apps/ocp-plugin/src/components/common/WithPageLayout.tsx b/apps/ocp-plugin/src/components/common/WithPageLayout.tsx
index ef3a31a23..e2029dfbb 100644
--- a/apps/ocp-plugin/src/components/common/WithPageLayout.tsx
+++ b/apps/ocp-plugin/src/components/common/WithPageLayout.tsx
@@ -1,27 +1,23 @@
import * as React from 'react';
import OrganizationGuard from '@flightctl/ui-components/src/components/common/OrganizationGuard';
+import PageNavigation from '@flightctl/ui-components/src/components/common/PageNavigation';
+import { AuthType } from '@flightctl/ui-components/src/types/extraTypes';
// Restore WithPageLayoutContent when organizations are enabled for OCP plugin
// The context is still needed since "useOrganizationGuardContext" is used in common components
-/*
const WithPageLayoutContent = ({ children }: React.PropsWithChildren) => {
- const { isOrganizationSelectionRequired } = useOrganizationGuardContext();
-
- return isOrganizationSelectionRequired ? (
-
- ) : (
+ return (
<>
-
+
{children}
>
);
};
-*/
const WithPageLayout = ({ children }: React.PropsWithChildren) => {
return (
- <>{children}>
+ {children}
);
};
diff --git a/apps/standalone/scripts/setup_env.sh b/apps/standalone/scripts/setup_env.sh
index 42084a732..91425f454 100755
--- a/apps/standalone/scripts/setup_env.sh
+++ b/apps/standalone/scripts/setup_env.sh
@@ -29,6 +29,8 @@ ENABLE_ORGANIZATIONS=${ENABLE_ORGANIZATIONS:-false}
# Set core environment variables for kind development
export FLIGHTCTL_SERVER_INSECURE_SKIP_VERIFY='true'
export FLIGHTCTL_SERVER="https://$EXTERNAL_IP:3443"
+export FLIGHTCTL_SERVER_EXTERNAL="https://api.$EXTERNAL_IP.nip.io:3443"
+
# CLI Artifacts - conditionally set or unset
if [ "$ENABLE_CLI_ARTIFACTS" = "true" ]; then
@@ -54,6 +56,7 @@ fi
echo "Environment variables set:" >&2
echo " FLIGHTCTL_SERVER_INSECURE_SKIP_VERIFY=$FLIGHTCTL_SERVER_INSECURE_SKIP_VERIFY" >&2
echo " FLIGHTCTL_SERVER=$FLIGHTCTL_SERVER" >&2
+echo " FLIGHTCTL_SERVER_EXTERNAL=$FLIGHTCTL_SERVER_EXTERNAL" >&2
echo " FLIGHTCTL_CLI_ARTIFACTS_SERVER=${FLIGHTCTL_CLI_ARTIFACTS_SERVER:-'(disabled)'}" >&2
echo " FLIGHTCTL_ALERTMANAGER_PROXY=${FLIGHTCTL_ALERTMANAGER_PROXY:-'(disabled)'}" >&2
echo " ORGANIZATIONS_ENABLED=$ORGANIZATIONS_ENABLED" >&2
diff --git a/apps/standalone/src/app/components/AppLayout/AppLayout.tsx b/apps/standalone/src/app/components/AppLayout/AppLayout.tsx
index 2cf99a431..0df17ccf8 100644
--- a/apps/standalone/src/app/components/AppLayout/AppLayout.tsx
+++ b/apps/standalone/src/app/components/AppLayout/AppLayout.tsx
@@ -23,14 +23,18 @@ import { useTranslation } from '@flightctl/ui-components/src/hooks/useTranslatio
import logo from '@fctl-assets/bgimages/flight-control-logo.svg';
import rhemLogo from '@fctl-assets/bgimages/RHEM-logo.svg';
+import { AuthContext } from '../../context/AuthContext';
import AppNavigation from './AppNavigation';
import AppToolbar from './AppToolbar';
+const pageId = 'primary-app-container';
+
const AppLayoutContent = () => {
const { t } = useTranslation();
const [isSidebarOpen, setIsSidebarOpen] = React.useState(true);
const { isOrganizationSelectionRequired } = useOrganizationGuardContext();
+ const { authType } = React.useContext(AuthContext);
const onSidebarToggle = () => {
setIsSidebarOpen((prevIsOpen) => !prevIsOpen);
@@ -78,8 +82,6 @@ const AppLayoutContent = () => {
);
- const pageId = 'primary-app-container';
-
const PageSkipToContent = (
{
@@ -98,7 +100,7 @@ const AppLayoutContent = () => {
) : (
<>
-
+
>
)}
diff --git a/apps/standalone/src/app/components/AppLayout/AppToolbar.css b/apps/standalone/src/app/components/AppLayout/AppToolbar.css
index 86ea0f369..8b4c08b25 100644
--- a/apps/standalone/src/app/components/AppLayout/AppToolbar.css
+++ b/apps/standalone/src/app/components/AppLayout/AppToolbar.css
@@ -1,15 +1,3 @@
-.fctl-app_toolbar,
-.fctl-subnav_toolbar {
+.fctl-app_toolbar {
justify-content: flex-end;
}
-
-/* Extra navigation bar for global actions (organization switcher, copy login command, etc.) */
-/* We make it as tall as the navigation menu items on the left */
-#global-actions-masthead {
- padding: 0;
- --pf-v5-c-masthead--m-display-inline__content--MinHeight: 3.5rem;
-}
-
-#global-actions-masthead .fctl-subnav_toolbar {
- --pf-v5-c-toolbar--BackgroundColor: var(--pf-v5-global--BackgroundColor--dark-300);
-}
diff --git a/apps/standalone/src/app/components/AppLayout/AppToolbar.tsx b/apps/standalone/src/app/components/AppLayout/AppToolbar.tsx
index fb5bd6397..2e656153f 100644
--- a/apps/standalone/src/app/components/AppLayout/AppToolbar.tsx
+++ b/apps/standalone/src/app/components/AppLayout/AppToolbar.tsx
@@ -20,6 +20,7 @@ import { useTranslation } from '@flightctl/ui-components/src/hooks/useTranslatio
import { ROUTE, useNavigate } from '@flightctl/ui-components/src/hooks/useNavigate';
import { getErrorMessage } from '@flightctl/ui-components/src/utils/error';
+import { AuthType } from '@flightctl/ui-components/src/types/extraTypes';
import { AuthContext } from '../../context/AuthContext';
import { logout } from '../../utils/apiCalls';
@@ -69,14 +70,14 @@ const AppToolbar = () => {
const [preferencesModalOpen, setPreferencesModalOpen] = React.useState(false);
const [helpDropdownOpen, setHelpDropdownOpen] = React.useState(false);
- const { username, authEnabled } = React.useContext(AuthContext);
+ const { username, authType } = React.useContext(AuthContext);
const [logoutLoading, setLogoutLoading] = React.useState(false);
const [logoutErr, setLogoutErr] = React.useState();
const onUserPreferences = () => setPreferencesModalOpen(true);
const navigate = useNavigate();
let userDropdown = ;
- if (authEnabled && username) {
+ if (authType !== AuthType.DISABLED && username) {
userDropdown = (
Math.round(Date.now() / 1000);
+const secondarySessionRedirectPages = ['copy-login-command'];
+
type AuthContextProps = {
+ authType: AuthType;
username: string;
loading: boolean;
- authEnabled: boolean;
error: string | undefined;
};
export const AuthContext = React.createContext({
+ authType: AuthType.DISABLED,
username: '',
- authEnabled: true,
loading: false,
error: undefined,
});
@@ -28,7 +32,7 @@ export const AuthContext = React.createContext({
export const useAuthContext = () => {
const [username, setUsername] = React.useState('');
const [loading, setLoading] = React.useState(true);
- const [authEnabled, setAuthEnabled] = React.useState(true);
+ const [authType, setAuthType] = React.useState(AuthType.DISABLED);
const [error, setError] = React.useState();
const refreshRef = React.useRef();
@@ -36,13 +40,29 @@ export const useAuthContext = () => {
const getUserInfo = async () => {
let callbackErr: string | null = null;
if (window.location.pathname === '/callback') {
- localStorage.removeItem(EXPIRATION);
- localStorage.removeItem(ORGANIZATION_STORAGE_KEY);
const searchParams = new URLSearchParams(window.location.search);
const code = searchParams.get('code');
callbackErr = searchParams.get('error');
+
if (code) {
- const resp = await fetch(loginAPI, {
+ // Some pages require the user to re-authenticate for security reasons.
+ // The token generated for these "secondary sessions" is independent of the primary session token.
+ const redirectAfterLogin = localStorage.getItem(OAUTH_REDIRECT_AFTER_LOGIN_KEY);
+ const isPrimarySession = !secondarySessionRedirectPages.includes(redirectAfterLogin || '');
+
+ let loginEndpoint: string;
+ if (isPrimarySession) {
+ loginEndpoint = loginAPI;
+ localStorage.removeItem(ORGANIZATION_STORAGE_KEY);
+ localStorage.removeItem(EXPIRATION);
+ } else {
+ // Do not clear the localStorage items, otherwise they would be unset for the primary session too
+ // We will force a new authentication flow that will allow us to retrieve the token from a newly generated sessionId
+ loginEndpoint = `${loginAPI}/create-session-token`;
+ }
+
+ // In both cases, we trigger a new login flow
+ const resp = await fetch(loginEndpoint, {
headers: {
'Content-Type': 'application/json',
},
@@ -52,11 +72,16 @@ export const useAuthContext = () => {
code: code,
}),
});
- const expiration = (await resp.json()) as { expiresIn: number };
- if (expiration.expiresIn) {
+
+ if (isPrimarySession) {
+ const newLoginResponse = (await resp.json()) as { expiresIn: number };
const now = nowInSeconds();
- localStorage.setItem(EXPIRATION, `${now + expiration.expiresIn}`);
+ localStorage.setItem(EXPIRATION, `${now + newLoginResponse.expiresIn}`);
lastRefresh = now;
+ } else {
+ const newLoginResponse = (await resp.json()) as { sessionId: string };
+ localStorage.removeItem(OAUTH_REDIRECT_AFTER_LOGIN_KEY);
+ window.location.href = `/${redirectAfterLogin}?sessionId=${newLoginResponse.sessionId || ''}`;
}
} else if (callbackErr) {
setError(callbackErr);
@@ -69,7 +94,7 @@ export const useAuthContext = () => {
credentials: 'include',
});
if (resp.status === AUTH_DISABLED_STATUS_CODE) {
- setAuthEnabled(false);
+ setAuthType(AuthType.DISABLED);
setLoading(false);
return;
}
@@ -81,8 +106,9 @@ export const useAuthContext = () => {
setError('Failed to get user info');
return;
}
- const info = (await resp.json()) as { username: string };
+ const info = (await resp.json()) as { username: string; authType: AuthType };
setUsername(info.username);
+ setAuthType(info.authType);
setLoading(false);
} catch (err) {
// eslint-disable-next-line
@@ -98,7 +124,7 @@ export const useAuthContext = () => {
React.useEffect(() => {
if (!loading) {
const scheduleRefresh = () => {
- if (!authEnabled) {
+ if (authType === AuthType.DISABLED) {
return;
}
const expiresAt = parseInt(localStorage.getItem(EXPIRATION) || '0', 10);
@@ -138,7 +164,7 @@ export const useAuthContext = () => {
scheduleRefresh();
}
return () => clearTimeout(refreshRef.current);
- }, [loading, authEnabled]);
+ }, [loading, authType]);
- return { username, loading, authEnabled, error };
+ return { username, loading, authType, error };
};
diff --git a/apps/standalone/src/app/routes.tsx b/apps/standalone/src/app/routes.tsx
index a01bb916f..8eb648256 100644
--- a/apps/standalone/src/app/routes.tsx
+++ b/apps/standalone/src/app/routes.tsx
@@ -68,6 +68,9 @@ const PendingEnrollmentRequestsBadge = React.lazy(
const CommandLineToolsPage = React.lazy(
() => import('@flightctl/ui-components/src/components/Masthead/CommandLineToolsPage'),
);
+const CopyLoginCommandPage = React.lazy(
+ () => import('@flightctl/ui-components/src/components/Masthead/CopyLoginCommandPage'),
+);
export type ExtendedRouteObject = RouteObject & {
title?: string;
@@ -310,6 +313,7 @@ const AppRouter = () => {
const { t } = useTranslation();
const { loading, error } = React.useContext(AuthContext);
+
if (error) {
return (
@@ -347,6 +351,15 @@ const AppRouter = () => {
errorElement: ,
children: getAppRoutes(t),
},
+ // Route is only exposed for the standalone app, and it doesn't inherit the app layout
+ {
+ path: '/copy-login-command',
+ element: (
+
+
+
+ ),
+ },
]);
return ;
diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json
index 062c9fe10..f17bffb6f 100644
--- a/libs/i18n/locales/en/translation.json
+++ b/libs/i18n/locales/en/translation.json
@@ -34,6 +34,7 @@
"Resource sync": "Resource sync",
"Failed to login": "Failed to login",
"Try again": "Try again",
+ "Copy login command": "Copy login command",
"No devices": "No devices",
"Restricted Access": "Restricted Access",
"You don't have access to this section.": "You don't have access to this section.",
@@ -62,7 +63,13 @@
"Reload": "Reload",
"Cancel": "Cancel",
"Download": "Download",
+ "Copied!": "Copied!",
"Copy text": "Copy text",
+ "{{ brandName }} CLI authentication": "{{ brandName }} CLI authentication",
+ "Copy and run this command in your terminal to authenticate with {{ brandName }}:": "Copy and run this command in your terminal to authenticate with {{ brandName }}:",
+ "Loading...": "Loading...",
+ "Next steps": "Next steps",
+ "After running this command, you'll be authenticated and can use the {{ brandName }} CLI to manage your edge devices from your terminal.": "After running this command, you'll be authenticated and can use the {{ brandName }} CLI to manage your edge devices from your terminal.",
"New label": "New label",
"Add label": "Add label",
"Unexpected error occurred": "Unexpected error occurred",
@@ -79,6 +86,8 @@
"Continue": "Continue",
"Select Organization": "Select Organization",
"Change Organization": "Change Organization",
+ "You will be directed to login in order to generate your login token command": "You will be directed to login in order to generate your login token command",
+ "Get login command": "Get login command",
"Technology preview features provide early access to upcoming product innovations, enabling you to test functionality and provide feedback during the development process.": "Technology preview features provide early access to upcoming product innovations, enabling you to test functionality and provide feedback during the development process.",
"Technology preview description": "Technology preview description",
"Technology preview": "Technology preview",
@@ -577,13 +586,21 @@
"pending device": "pending device",
"resource sync": "resource sync",
"You are about to resume device <1>{deviceName}1>": "You are about to resume device <1>{deviceName}1>",
- "No {{ productName }} command line tools were found for this deployment at this time.": "No {{ productName }} command line tools were found for this deployment at this time.",
- "Could not list the {{ productName }} command line tools": "Could not list the {{ productName }} command line tools",
+ "No {{ brandName }} command line tools were found for this deployment at this time.": "No {{ brandName }} command line tools were found for this deployment at this time.",
+ "Could not list the {{ brandName }} command line tools": "Could not list the {{ brandName }} command line tools",
"Download flightctl CLI for {{ os }} for {{ arch }}": "Download flightctl CLI for {{ os }} for {{ arch }}",
- "Red Hat Edge Manager": "Red Hat Edge Manager",
- "Flight Control": "Flight Control",
- "With the {{ productName }} command line interface, you can manage your fleets, devices and repositories from a terminal.": "With the {{ productName }} command line interface, you can manage your fleets, devices and repositories from a terminal.",
- "Command line tools are not available for download in this {{ productName }} installation.": "Command line tools are not available for download in this {{ productName }} installation.",
+ "With the {{ brandName }} command line interface, you can manage your fleets, devices and repositories from a terminal.": "With the {{ brandName }} command line interface, you can manage your fleets, devices and repositories from a terminal.",
+ "Command line tools are not available for download in this {{ brandName }} installation.": "Command line tools are not available for download in this {{ brandName }} installation.",
+ "Login successful!": "Login successful!",
+ "Show more": "Show more",
+ "Failed to obtain session token": "Failed to obtain session token",
+ "This URL can only be used with a valid session ID": "This URL can only be used with a valid session ID",
+ "The login command for this session is no longer available": "The login command for this session is no longer available",
+ "Error getting session token": "Error getting session token",
+ "This session's login token was already retrieved once, or you used the wrong URL.": "This session's login token was already retrieved once, or you used the wrong URL.",
+ "Please return to {{ brandName }} and request a new login command.": "Please return to {{ brandName }} and request a new login command.",
+ "Back to {{ brandName }}": "Back to {{ brandName }}",
+ "CLI authentication portal": "CLI authentication portal",
"System default": "System default",
"Light": "Light",
"Dark": "Dark",
@@ -790,8 +807,8 @@
"Overall status of application workloads.": "Overall status of application workloads.",
"Overall status of device hardware and operating system.": "Overall status of device hardware and operating system.",
"Current system configuration vs. latest system configuration.": "Current system configuration vs. latest system configuration.",
- "{{brand}} is waiting for the device to connect and report its status. It will report a ʼPending syncʼ status until it is able to reconnect. If it has configuration conflicts, it will report a ʼSuspendedʼ status and require manual action to resume.": "{{brand}} is waiting for the device to connect and report its status. It will report a ʼPending syncʼ status until it is able to reconnect. If it has configuration conflicts, it will report a ʼSuspendedʼ status and require manual action to resume.",
- "{{brand}} is waiting for devices to connect and report their status. Devices will report a ʼPending syncʼ status until they are able to connect. Devices with configuration conflicts will report a ʼSuspendedʼ status and require manual action to resume.": "{{brand}} is waiting for devices to connect and report their status. Devices will report a ʼPending syncʼ status until they are able to connect. Devices with configuration conflicts will report a ʼSuspendedʼ status and require manual action to resume.",
+ "{{ brandName }} is waiting for the device to connect and report its status. It will report a ʼPending syncʼ status until it is able to reconnect. If it has configuration conflicts, it will report a ʼSuspendedʼ status and require manual action to resume.": "{{ brandName }} is waiting for the device to connect and report its status. It will report a ʼPending syncʼ status until it is able to reconnect. If it has configuration conflicts, it will report a ʼSuspendedʼ status and require manual action to resume.",
+ "{{ brandName }} is waiting for devices to connect and report their status. Devices will report a ʼPending syncʼ status until they are able to connect. Devices with configuration conflicts will report a ʼSuspendedʼ status and require manual action to resume.": "{{ brandName }} is waiting for devices to connect and report their status. Devices will report a ʼPending syncʼ status until they are able to connect. Devices with configuration conflicts will report a ʼSuspendedʼ status and require manual action to resume.",
"System recovery complete": "System recovery complete",
"This device is suspended because its local configuration is newer than the server's record. It will not receive updates until it is resumed.": "This device is suspended because its local configuration is newer than the server's record. It will not receive updates until it is resumed.",
"<0>{suspendedCountStr}0> <2>devices in this fleet2> are suspended because their local configuration is newer than the server's record. These devices will not receive updates until they are resumed._one": "<0>{suspendedCountStr}0> <2>device in this fleet2> is suspended because its local configuration is newer than the server's record. This device will not receive updates until it is resumed.",
diff --git a/libs/ui-components/src/components/Masthead/CommandLineToolsPage.tsx b/libs/ui-components/src/components/Masthead/CommandLineToolsPage.tsx
index 55baf9cf2..0b8e9adca 100644
--- a/libs/ui-components/src/components/Masthead/CommandLineToolsPage.tsx
+++ b/libs/ui-components/src/components/Masthead/CommandLineToolsPage.tsx
@@ -21,9 +21,10 @@ import { useTranslation } from '../../hooks/useTranslation';
import { useAppContext } from '../../hooks/useAppContext';
import { getErrorMessage } from '../../utils/error';
import { CliArtifactsResponse } from '../../types/extraTypes';
+import { getBrandName } from '../../utils/brand';
type CommandLineToolsContentProps = {
- productName: string;
+ brandName: string;
loading: boolean;
loadError?: string;
artifactsResponse?: CliArtifactsResponse;
@@ -37,7 +38,7 @@ const getArtifactUrl = (baseUrl: string, artifact: CommandLineArtifact) => {
};
const CommandLineToolsContent = ({
- productName,
+ brandName,
loading,
loadError,
artifactsResponse,
@@ -52,8 +53,8 @@ const CommandLineToolsContent = ({
const cliArtifacts = artifactsResponse?.artifacts || [];
if (cliArtifacts.length === 0) {
- errorMessage = t('No {{ productName }} command line tools were found for this deployment at this time.', {
- productName,
+ errorMessage = t('No {{ brandName }} command line tools were found for this deployment at this time.', {
+ brandName,
});
}
@@ -62,7 +63,7 @@ const CommandLineToolsContent = ({
{errorMessage}
@@ -134,7 +135,7 @@ const CommandLineToolsPage = () => {
void getLinks();
}, [proxyFetch]);
- const productName = settings.isRHEM ? t('Red Hat Edge Manager') : t('Flight Control');
+ const brandName = getBrandName(settings);
return (
@@ -152,16 +153,16 @@ const CommandLineToolsPage = () => {
{t(
- 'With the {{ productName }} command line interface, you can manage your fleets, devices and repositories from a terminal.',
+ 'With the {{ brandName }} command line interface, you can manage your fleets, devices and repositories from a terminal.',
{
- productName,
+ brandName,
},
)}
{hasArtifactsEnabled ? (
{
) : (
- {t('Command line tools are not available for download in this {{ productName }} installation.', {
- productName,
+ {t('Command line tools are not available for download in this {{ brandName }} installation.', {
+ brandName,
})}
)}
diff --git a/libs/ui-components/src/components/Masthead/CopyLoginCommandPage.css b/libs/ui-components/src/components/Masthead/CopyLoginCommandPage.css
new file mode 100644
index 000000000..608d4866e
--- /dev/null
+++ b/libs/ui-components/src/components/Masthead/CopyLoginCommandPage.css
@@ -0,0 +1,10 @@
+.fctl-login-command__codeblock {
+ white-space: pre-wrap;
+ word-break: break-all;
+ user-select: all;
+}
+
+.fctl-login-command__copy-content {
+ /* Keep a fixed width to prevent a change in size when the full command is displayed */
+ width: 80vw;
+}
diff --git a/libs/ui-components/src/components/Masthead/CopyLoginCommandPage.tsx b/libs/ui-components/src/components/Masthead/CopyLoginCommandPage.tsx
new file mode 100644
index 000000000..9340b893d
--- /dev/null
+++ b/libs/ui-components/src/components/Masthead/CopyLoginCommandPage.tsx
@@ -0,0 +1,207 @@
+import * as React from 'react';
+import {
+ Alert,
+ Bullseye,
+ Button,
+ CodeBlock,
+ CodeBlockAction,
+ CodeBlockCode,
+ ExpandableSection,
+ ExpandableSectionToggle,
+ Page,
+ PageSection,
+ Spinner,
+ Stack,
+ StackItem,
+ Title,
+} from '@patternfly/react-core';
+import ArrowLeftIcon from '@patternfly/react-icons/dist/js/icons/arrow-left-icon';
+
+import { useFetch } from '../../hooks/useFetch';
+import { useTranslation } from '../../hooks/useTranslation';
+import { useAppContext } from '../../hooks/useAppContext';
+import { getErrorMessage } from '../../utils/error';
+import { getBrandName } from '../../utils/brand';
+import CopyButton from '../common/CopyButton';
+
+import './CopyLoginCommandPage.css';
+
+type SessionTokenResponse = {
+ token: string;
+ serviceUrl: string;
+};
+
+const DEFAULT_API_URL = '';
+const TRUNCATED_COMMAND_LENGTH = 80;
+
+const LoginCommandCopy = ({ loginCommand, brandName }: { loginCommand: string; brandName: string }) => {
+ const { t } = useTranslation();
+ const [isExpanded, setIsExpanded] = React.useState(false);
+
+ const hasShowMore = loginCommand.length > TRUNCATED_COMMAND_LENGTH;
+ const visibleCommand = hasShowMore ? `${loginCommand.substring(0, TRUNCATED_COMMAND_LENGTH)}...` : loginCommand;
+
+ return (
+
+
+
+
+
+ {t('Copy and run this command in your terminal to authenticate with {{ brandName }}:', { brandName })}
+
+
+
+
+ ,
+ ]}
+ >
+
+ {/* When the full command is shown, hide the truncated command. This allows the full command to be copied using the keyboard */}
+ {isExpanded ? '' : visibleCommand}
+ {hasShowMore && (
+ <>
+
+ {loginCommand}
+
+ setIsExpanded(isExpanded)}
+ contentId="code-block-expand"
+ direction={isExpanded ? 'up' : 'down'}
+ >
+ {isExpanded ? t('Show less') : t('Show more')}
+
+ >
+ )}
+
+
+
+
+
+ {t(
+ "After running this command, you'll be authenticated and can use the {{ brandName }} CLI to manage your edge devices from your terminal.",
+ { brandName },
+ )}
+
+
+
+ );
+};
+
+const CopyLoginCommandBody = ({ brandName }: { brandName: string }) => {
+ const { t } = useTranslation();
+ const { proxyFetch } = useFetch();
+
+ const [loginCommand, setLoginCommand] = React.useState('');
+ const [tokenNotFound, setTokenNotFound] = React.useState(false);
+ const [errorMessage, setErrorMessage] = React.useState('');
+ const sessionId = new URLSearchParams(window.location.search).get('sessionId');
+
+ React.useEffect(() => {
+ const getSessionToken = async () => {
+ try {
+ const response = await proxyFetch(`/login/get-session-token?sessionId=${sessionId}`, {
+ credentials: 'include',
+ });
+ if (response.status !== 200) {
+ const tokenNotFound = response.status === 404;
+ if (tokenNotFound) {
+ setTokenNotFound(tokenNotFound);
+ }
+ throw new Error(t('Failed to obtain session token'));
+ }
+ const sessionToken = (await response.json()) as SessionTokenResponse;
+ setLoginCommand(`flightctl login ${sessionToken.serviceUrl || DEFAULT_API_URL} --token=${sessionToken.token}`);
+ } catch (error) {
+ setErrorMessage(getErrorMessage(error));
+ }
+ };
+ if (sessionId) {
+ void getSessionToken();
+ } else {
+ setErrorMessage(t('This URL can only be used with a valid session ID'));
+ }
+ }, [sessionId, proxyFetch, t]);
+
+ if (errorMessage) {
+ const title = tokenNotFound
+ ? t('The login command for this session is no longer available')
+ : t('Error getting session token');
+ const message = tokenNotFound
+ ? t("This session's login token was already retrieved once, or you used the wrong URL.")
+ : errorMessage;
+ return (
+
+ {message}
+
+ {t('Please return to {{ brandName }} and request a new login command.', { brandName })}
+
+ );
+ } else if (!loginCommand) {
+ return ;
+ }
+
+ return ;
+};
+
+const CopyLoginCommandPage = () => {
+ const { t } = useTranslation();
+
+ const { settings } = useAppContext();
+ const brandName = getBrandName(settings);
+
+ const onSwitchBack = () => {
+ // Try to focus back to the original tab, then close the current one
+ (window.opener as Window)?.focus();
+ window.close();
+ };
+
+ return (
+
+
+
+
+ } onClick={onSwitchBack}>
+ {t('Back to {{ brandName }}', { brandName })}
+
+
+
+
+
+
+
+
+
+
+
+ {brandName}
+
+
+
+
+ {t('CLI authentication portal')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CopyLoginCommandPage;
diff --git a/libs/ui-components/src/components/SystemRestore/PendingSyncDevicesAlert.tsx b/libs/ui-components/src/components/SystemRestore/PendingSyncDevicesAlert.tsx
index 6798751cb..2efc96563 100644
--- a/libs/ui-components/src/components/SystemRestore/PendingSyncDevicesAlert.tsx
+++ b/libs/ui-components/src/components/SystemRestore/PendingSyncDevicesAlert.tsx
@@ -3,13 +3,12 @@ import { Alert } from '@patternfly/react-core';
import { useAppContext } from '../../hooks/useAppContext';
import { useTranslation } from '../../hooks/useTranslation';
+import { getBrandName } from '../../utils/brand';
interface PendingSyncDevicesAlertProps {
forSingleDevice?: boolean;
}
-const getBrand = (isRHEM: boolean | undefined) => (isRHEM ? 'RHEM' : 'Flight Control');
-
export const PendingSyncDevicesAlert = ({ forSingleDevice }: PendingSyncDevicesAlertProps) => {
const { t } = useTranslation();
const { settings } = useAppContext();
@@ -17,14 +16,14 @@ export const PendingSyncDevicesAlert = ({ forSingleDevice }: PendingSyncDevicesA
const getMessage = () => {
if (forSingleDevice) {
return t(
- '{{brand}} is waiting for the device to connect and report its status. It will report a ʼPending syncʼ status until it is able to reconnect. If it has configuration conflicts, it will report a ʼSuspendedʼ status and require manual action to resume.',
- { brand: getBrand(settings.isRHEM) },
+ '{{ brandName }} is waiting for the device to connect and report its status. It will report a ʼPending syncʼ status until it is able to reconnect. If it has configuration conflicts, it will report a ʼSuspendedʼ status and require manual action to resume.',
+ { brandName: getBrandName(settings) },
);
}
return t(
- '{{brand}} is waiting for devices to connect and report their status. Devices will report a ʼPending syncʼ status until they are able to connect. Devices with configuration conflicts will report a ʼSuspendedʼ status and require manual action to resume.',
- { brand: getBrand(settings.isRHEM) },
+ '{{ brandName }} is waiting for devices to connect and report their status. Devices will report a ʼPending syncʼ status until they are able to connect. Devices with configuration conflicts will report a ʼSuspendedʼ status and require manual action to resume.',
+ { brandName: getBrandName(settings) },
);
};
diff --git a/libs/ui-components/src/components/common/CopyButton.tsx b/libs/ui-components/src/components/common/CopyButton.tsx
index 38fe07e9f..07081a438 100644
--- a/libs/ui-components/src/components/common/CopyButton.tsx
+++ b/libs/ui-components/src/components/common/CopyButton.tsx
@@ -13,18 +13,36 @@ interface CopyButtonProps {
const CopyButton = ({ ariaLabel, text, variant }: CopyButtonProps) => {
const { t } = useTranslation();
+ const [copied, setCopied] = React.useState(false);
+
const onCopy = () => {
void navigator.clipboard.writeText(text);
+ setCopied(true);
};
+ React.useEffect(() => {
+ let timeout: ReturnType | undefined;
+ if (copied) {
+ timeout = setTimeout(() => {
+ setCopied(false);
+ }, 1000);
+ }
+ return () => {
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ };
+ }, [copied]);
+
return (
-
+
+
+
{showOrganizationSelection && (
{
}}
/>
)}
+ {/* For the OCP plugin, we just show a modal. We'll obtain the External API URL and display the login command */}
+ {showCopyLoginCommandModal && setShowCopyLoginCommandModal(false)} />}
>
);
};
diff --git a/libs/ui-components/src/constants.ts b/libs/ui-components/src/constants.ts
index 20a0bba44..f60dc413b 100644
--- a/libs/ui-components/src/constants.ts
+++ b/libs/ui-components/src/constants.ts
@@ -1,6 +1,5 @@
-const APP_TITLE = 'Edge Manager';
-const API_VERSION = 'v1alpha1';
-const PAGE_SIZE = 15;
-const EVENT_PAGE_SIZE = 200; // It's 500 in OCP console
-
-export { APP_TITLE, API_VERSION, PAGE_SIZE, EVENT_PAGE_SIZE };
+export const APP_TITLE = 'Edge Manager';
+export const API_VERSION = 'v1alpha1';
+export const PAGE_SIZE = 15;
+export const EVENT_PAGE_SIZE = 200; // It's 500 in OCP console
+export const OAUTH_REDIRECT_AFTER_LOGIN_KEY = 'oauthRedirectAfterLogin';
diff --git a/libs/ui-components/src/types/extraTypes.ts b/libs/ui-components/src/types/extraTypes.ts
index 22344b714..22d7b36da 100644
--- a/libs/ui-components/src/types/extraTypes.ts
+++ b/libs/ui-components/src/types/extraTypes.ts
@@ -75,3 +75,10 @@ export type AlertManagerAlert = {
};
receivers: Array<{ name: string }>;
};
+
+export enum AuthType {
+ DISABLED = 'DISABLED',
+ OIDC = 'OIDC',
+ K8S = 'K8S',
+ AAP = 'AAPGateway',
+}
diff --git a/libs/ui-components/src/utils/brand.ts b/libs/ui-components/src/utils/brand.ts
new file mode 100644
index 000000000..4252f5317
--- /dev/null
+++ b/libs/ui-components/src/utils/brand.ts
@@ -0,0 +1,4 @@
+import { AppContextProps } from '../hooks/useAppContext';
+
+export const getBrandName = (settings: AppContextProps['settings']): string =>
+ settings.isRHEM ? 'Red Hat Edge Manager' : 'Flight Control';
diff --git a/proxy/app.go b/proxy/app.go
index e3331d32f..924fecbf9 100644
--- a/proxy/app.go
+++ b/proxy/app.go
@@ -77,6 +77,10 @@ func main() {
}
apiRouter.HandleFunc("/login", authHandler.Login)
apiRouter.HandleFunc("/login/info", authHandler.GetUserInfo)
+ // Creates a new session token. Returns a session ID that can be used once to get the actual API token.
+ apiRouter.HandleFunc("/login/create-session-token", authHandler.CreateSessionToken)
+ // Returns the token associated to the sessionId. The token can only be retrieved once.
+ apiRouter.HandleFunc("/login/get-session-token", authHandler.GetSessionToken)
apiRouter.HandleFunc("/login/refresh", authHandler.Refresh)
apiRouter.HandleFunc("/logout", authHandler.Logout)
} else {
diff --git a/proxy/auth/aap.go b/proxy/auth/aap.go
index cac7b709f..81883afac 100644
--- a/proxy/auth/aap.go
+++ b/proxy/auth/aap.go
@@ -162,6 +162,10 @@ func (a *AAPAuthHandler) RefreshToken(refreshToken string) (TokenData, *int64, e
return refreshOAuthToken(refreshToken, a.internalClient)
}
-func (a *AAPAuthHandler) GetLoginRedirectURL() string {
- return loginRedirect(a.client)
+func (a *AAPAuthHandler) GetLoginRedirectURL(forceReauth bool) string {
+ return loginRedirect(a.client, forceReauth)
+}
+
+func (a *AAPAuthHandler) GetAuthType() string {
+ return "AAPGateway"
}
diff --git a/proxy/auth/auth.go b/proxy/auth/auth.go
index 1e2a37646..c7fe55bb8 100644
--- a/proxy/auth/auth.go
+++ b/proxy/auth/auth.go
@@ -1,35 +1,62 @@
package auth
import (
+ "crypto/rand"
"crypto/tls"
+ "encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
+ "sync"
+ "time"
"github.com/flightctl/flightctl-ui/config"
"github.com/flightctl/flightctl-ui/log"
"github.com/flightctl/flightctl/api/v1alpha1"
)
+const (
+ DefaultTokenCleanupInterval = 10 * time.Minute // How often to run cleanup
+ DefaultTokenMaxAge = 15 * time.Minute // Max age before forced removal
+)
+
type ExpiresInResp struct {
ExpiresIn *int64 `json:"expiresIn"`
}
type UserInfoResponse struct {
+ AuthType string `json:"authType"`
Username string `json:"username,omitempty"`
}
+type SessionTokenResponse struct {
+ Token string `json:"token"`
+ ServiceUrl string `json:"serviceUrl"`
+}
+
type RedirectResponse struct {
Url string `json:"url"`
}
type AuthHandler struct {
- provider AuthProvider
+ provider AuthProvider
+ sessionMutex sync.Mutex
+ apiTokenMap map[string]*ApiToken
+ stopTokenCleanup chan struct{}
+}
+
+type ApiToken struct {
+ Token string
+ ExpiresIn *int64
+ CreatedAt time.Time
}
func NewAuth(apiTlsConfig *tls.Config) (*AuthHandler, error) {
- auth := AuthHandler{}
+ auth := AuthHandler{
+ apiTokenMap: make(map[string]*ApiToken),
+ stopTokenCleanup: make(chan struct{}),
+ }
authConfig, internalAuthUrl, err := getAuthInfo(apiTlsConfig)
if err != nil {
return nil, err
@@ -48,10 +75,16 @@ func NewAuth(apiTlsConfig *tls.Config) (*AuthHandler, error) {
default:
err = fmt.Errorf("unknown auth type: %s", authConfig.AuthType)
}
+
+ if err == nil && auth.provider != nil {
+ // Start the cleanup routine for expired tokens
+ go auth.startTokenCleanup()
+ }
+
return &auth, err
}
-func (a AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
+func (a *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
if a.provider == nil {
w.WriteHeader(http.StatusTeapot)
return
@@ -78,7 +111,9 @@ func (a AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
}
respondWithToken(w, tokenData, expires)
} else {
- loginUrl := a.provider.GetLoginRedirectURL()
+
+ forceReauth := r.URL.Query().Get("force") == "true"
+ loginUrl := a.provider.GetLoginRedirectURL(forceReauth)
response, err := json.Marshal(RedirectResponse{Url: loginUrl})
if err != nil {
log.GetLogger().WithError(err).Warn("Failed to marshal response")
@@ -91,7 +126,7 @@ func (a AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
}
}
-func (a AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
+func (a *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
if a.provider == nil {
w.WriteHeader(http.StatusTeapot)
return
@@ -126,7 +161,7 @@ func respondWithToken(w http.ResponseWriter, tokenData TokenData, expires *int64
}
}
-func (a AuthHandler) GetUserInfo(w http.ResponseWriter, r *http.Request) {
+func (a *AuthHandler) GetUserInfo(w http.ResponseWriter, r *http.Request) {
if a.provider == nil {
w.WriteHeader(http.StatusTeapot)
return
@@ -148,7 +183,7 @@ func (a AuthHandler) GetUserInfo(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(resp.StatusCode)
return
}
- userInfo := UserInfoResponse{Username: username}
+ userInfo := UserInfoResponse{Username: username, AuthType: a.provider.GetAuthType()}
res, err := json.Marshal(userInfo)
if err != nil {
log.GetLogger().WithError(err).Warn("Failed to marshal user info")
@@ -160,7 +195,205 @@ func (a AuthHandler) GetUserInfo(w http.ResponseWriter, r *http.Request) {
}
}
-func (a AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
+// Creates a cryptographically secure random session ID
+func generateSessionId() (string, error) {
+ bytes := make([]byte, 16)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}
+
+func (a *AuthHandler) storeApiTokenMapping(tokenData TokenData, expires *int64) (string, error) {
+ sessionId, err := generateSessionId()
+ if err != nil {
+ return "", err
+ }
+
+ session := &ApiToken{
+ Token: tokenData.Token,
+ ExpiresIn: expires,
+ CreatedAt: time.Now(),
+ }
+
+ a.sessionMutex.Lock()
+ a.apiTokenMap[sessionId] = session
+ a.sessionMutex.Unlock()
+
+ return sessionId, nil
+}
+
+// Gets the auth token associated to a given sessionId.
+// Deletes the mapping immediately after retrieval so it can only be used once.
+func (a *AuthHandler) getSingleUseApiTokenMapping(sessionId string) (*ApiToken, bool) {
+ a.sessionMutex.Lock()
+ defer a.sessionMutex.Unlock()
+
+ session, exists := a.apiTokenMap[sessionId]
+ if exists {
+ delete(a.apiTokenMap, sessionId)
+ }
+ return session, exists
+}
+
+// Cleans up expired and old tokens that were never retrieved through getSingleUseApiTokenMapping
+func (a *AuthHandler) startTokenCleanup() {
+ ticker := time.NewTicker(DefaultTokenCleanupInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ a.cleanupExpiredTokens()
+ case <-a.stopTokenCleanup:
+ return
+ }
+ }
+}
+
+// Removes expired tokens and tokens older than DefaultMaxTokenAge from the apiTokenMap
+func (a *AuthHandler) cleanupExpiredTokens() {
+ a.sessionMutex.Lock()
+ defer a.sessionMutex.Unlock()
+
+ now := time.Now()
+ deletedTokens := 0
+
+ for sessionId, token := range a.apiTokenMap {
+ shouldRemove := false
+
+ if now.Sub(token.CreatedAt) > DefaultTokenMaxAge {
+ // Remove tokens older than the directed max age
+ shouldRemove = true
+ } else if token.ExpiresIn != nil {
+ // Remove expired tokens (if ExpiresIn is set)
+ expiration := token.CreatedAt.Add(time.Duration(*token.ExpiresIn) * time.Second)
+ if now.After(expiration) {
+ shouldRemove = true
+ }
+ }
+
+ if shouldRemove {
+ delete(a.apiTokenMap, sessionId)
+ deletedTokens++
+ }
+ }
+
+ if deletedTokens > 0 {
+ log.GetLogger().WithFields(map[string]interface{}{
+ "deletedTokens": deletedTokens,
+ "remainingTokens": len(a.apiTokenMap),
+ }).Info("Cleaned up expired API tokens")
+ }
+}
+
+func (a *AuthHandler) StopTokenCleanup() {
+ close(a.stopTokenCleanup)
+}
+
+func (a *AuthHandler) CreateSessionToken(w http.ResponseWriter, r *http.Request) {
+ if a.provider == nil {
+ w.WriteHeader(http.StatusTeapot)
+ return
+ }
+ if r.Method == http.MethodPost {
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ log.GetLogger().WithError(err).Warn("Failed to read request body")
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+
+ loginParams := LoginParameters{}
+ err = json.Unmarshal(body, &loginParams)
+ if err != nil {
+ log.GetLogger().WithError(err).Warn("Failed to unmarshal request body")
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ tokenData, expires, err := a.provider.GetToken(loginParams)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ // Store the token, mapping it to a new sessionId that can be used to access the token only once.
+ sessionId, err := a.storeApiTokenMapping(tokenData, expires)
+ if err != nil {
+ log.GetLogger().WithError(err).Warn("Failed to store API token session")
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ // Keep the token stored internally and return the associated sessionId
+ response := map[string]string{"sessionId": sessionId}
+ jsonResponse, err := json.Marshal(response)
+ if err != nil {
+ log.GetLogger().WithError(err).Warn("Failed to marshal session response")
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write(jsonResponse); err != nil {
+ log.GetLogger().WithError(err).Warn("Failed to write session response")
+ }
+ } else {
+ // Always force fresh authentication. This allow us to get a different token than the current user token
+ loginUrl := a.provider.GetLoginRedirectURL(true)
+
+ response, err := json.Marshal(RedirectResponse{Url: loginUrl})
+ if err != nil {
+ log.GetLogger().WithError(err).Warn("Failed to marshal response")
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ if _, err := w.Write(response); err != nil {
+ log.GetLogger().WithError(err).Warn("Failed to write response")
+ }
+ }
+}
+
+func getExternalServiceUrl() string {
+ if config.FctlApiUrlExternal == "" {
+ return config.FctlApiUrl
+ }
+ return config.FctlApiUrlExternal
+}
+
+func (a *AuthHandler) GetSessionToken(w http.ResponseWriter, r *http.Request) {
+ if a.provider == nil {
+ w.WriteHeader(http.StatusTeapot)
+ return
+ }
+
+ sessionId := r.URL.Query().Get("sessionId")
+ if sessionId == "" {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+
+ // Retrieve the token associated to the sessionId, and delete the mapping so it can not be used again.
+ session, exists := a.getSingleUseApiTokenMapping(sessionId)
+ if !exists {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ response := SessionTokenResponse{ServiceUrl: getExternalServiceUrl(), Token: session.Token}
+ jsonResponse, err := json.Marshal(response)
+ if err != nil {
+ log.GetLogger().WithError(err).Warn("Failed to marshal API token response")
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write(jsonResponse); err != nil {
+ log.GetLogger().WithError(err).Warn("Failed to write API token response")
+ }
+}
+
+func (a *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if a.provider == nil {
w.WriteHeader(http.StatusTeapot)
return
diff --git a/proxy/auth/common.go b/proxy/auth/common.go
index fd42d0dbe..ae3e8c3bb 100644
--- a/proxy/auth/common.go
+++ b/proxy/auth/common.go
@@ -29,7 +29,8 @@ type AuthProvider interface {
GetUserInfo(token string) (string, *http.Response, error)
RefreshToken(refreshToken string) (TokenData, *int64, error)
Logout(token string) (string, error)
- GetLoginRedirectURL() string
+ GetLoginRedirectURL(forceReauth bool) string
+ GetAuthType() string
}
func setCookie(w http.ResponseWriter, value TokenData) error {
diff --git a/proxy/auth/oauth.go b/proxy/auth/oauth.go
index 99ae15373..3ca461637 100644
--- a/proxy/auth/oauth.go
+++ b/proxy/auth/oauth.go
@@ -22,9 +22,19 @@ func refreshOAuthToken(refreshToken string, client *osincli.Client) (TokenData,
return executeOAuthFlow(req)
}
-func loginRedirect(client *osincli.Client) string {
+func loginRedirect(client *osincli.Client, forceReauth bool) string {
authorizeRequest := client.NewAuthorizeRequest(osincli.CODE)
- return authorizeRequest.GetAuthorizeUrl().String()
+
+ url := authorizeRequest.GetAuthorizeUrl()
+
+ // Add prompt=login to force re-authentication if requested
+ if forceReauth {
+ query := url.Query()
+ query.Add("prompt", "login")
+ url.RawQuery = query.Encode()
+ }
+
+ return url.String()
}
func executeOAuthFlow(req *osincli.AccessRequest) (TokenData, *int64, error) {
diff --git a/proxy/auth/oidc.go b/proxy/auth/oidc.go
index 547d6213f..66a7d2389 100644
--- a/proxy/auth/oidc.go
+++ b/proxy/auth/oidc.go
@@ -194,6 +194,10 @@ func (o *OIDCAuthHandler) RefreshToken(refreshToken string) (TokenData, *int64,
return refreshOAuthToken(refreshToken, o.internalClient)
}
-func (a *OIDCAuthHandler) GetLoginRedirectURL() string {
- return loginRedirect(a.client)
+func (a *OIDCAuthHandler) GetLoginRedirectURL(forceReauth bool) string {
+ return loginRedirect(a.client, forceReauth)
+}
+
+func (o *OIDCAuthHandler) GetAuthType() string {
+ return "OIDC"
}
diff --git a/proxy/config/config.go b/proxy/config/config.go
index 59cd8307b..be18f1cde 100644
--- a/proxy/config/config.go
+++ b/proxy/config/config.go
@@ -8,6 +8,7 @@ import (
var (
BridgePort = ":" + getEnvVar("API_PORT", "3001")
FctlApiUrl = getEnvUrlVar("FLIGHTCTL_SERVER", "https://localhost:3443")
+ FctlApiUrlExternal = getEnvUrlVar("FLIGHTCTL_SERVER_EXTERNAL", "")
FctlApiInsecure = getEnvVar("FLIGHTCTL_SERVER_INSECURE_SKIP_VERIFY", "false")
FctlCliArtifactsUrl = getEnvUrlVar("FLIGHTCTL_CLI_ARTIFACTS_SERVER", "http://localhost:8090")
AlertManagerApiUrl = getEnvUrlVar("FLIGHTCTL_ALERTMANAGER_PROXY", "https://localhost:8443")
diff --git a/proxy/config/ocp.go b/proxy/config/ocp.go
index 5415e72c2..7f22ed1de 100644
--- a/proxy/config/ocp.go
+++ b/proxy/config/ocp.go
@@ -6,14 +6,21 @@ import (
)
type OcpConfig struct {
- RBACNs string `json:"rbacNs"`
+ RBACNs string `json:"rbacNs"`
+ ExternalApiUrl string `json:"externalApiUrl"`
}
type OcpConfigHandler struct{}
func (c *OcpConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
+ externalApiUrl := FctlApiUrlExternal
+ if externalApiUrl == "" {
+ externalApiUrl = FctlApiUrl
+ }
+
ocpConfig := OcpConfig{
- RBACNs: RBACNs,
+ RBACNs: RBACNs,
+ ExternalApiUrl: externalApiUrl,
}
resp, err := json.Marshal(ocpConfig)
if err != nil {