From 773ccb1cc4f577104a4c75ab02b0556145580d7b Mon Sep 17 00:00:00 2001 From: Celia Amador Date: Fri, 26 Sep 2025 15:02:38 +0200 Subject: [PATCH 1/2] EDM-1982: Add option to copy login command --- .../src/components/common/WithPageLayout.tsx | 14 +- apps/standalone/scripts/setup_env.sh | 3 + .../app/components/AppLayout/AppLayout.tsx | 8 +- .../app/components/AppLayout/AppToolbar.css | 14 +- .../app/components/AppLayout/AppToolbar.tsx | 5 +- .../standalone/src/app/context/AuthContext.ts | 54 +++-- apps/standalone/src/app/routes.tsx | 13 ++ libs/i18n/locales/en/translation.json | 36 ++- .../Masthead/CommandLineToolsPage.tsx | 23 +- .../Masthead/CopyLoginCommandPage.css | 10 + .../Masthead/CopyLoginCommandPage.tsx | 206 ++++++++++++++++++ .../SystemRestore/PendingSyncDevicesAlert.tsx | 11 +- .../src/components/common/CopyButton.tsx | 17 +- .../common/CopyLoginCommandModal.tsx | 76 +++++++ .../src/components/common/PageNavigation.css | 16 ++ .../src/components/common/PageNavigation.tsx | 50 ++++- libs/ui-components/src/constants.ts | 11 +- libs/ui-components/src/types/extraTypes.ts | 7 + libs/ui-components/src/utils/brand.ts | 4 + proxy/app.go | 4 + proxy/auth/aap.go | 8 +- proxy/auth/auth.go | 181 ++++++++++++++- proxy/auth/common.go | 3 +- proxy/auth/oauth.go | 14 +- proxy/auth/oidc.go | 8 +- proxy/config/config.go | 1 + proxy/config/ocp.go | 11 +- 27 files changed, 715 insertions(+), 93 deletions(-) create mode 100644 libs/ui-components/src/components/Masthead/CopyLoginCommandPage.css create mode 100644 libs/ui-components/src/components/Masthead/CopyLoginCommandPage.tsx create mode 100644 libs/ui-components/src/components/common/CopyLoginCommandModal.tsx create mode 100644 libs/ui-components/src/components/common/PageNavigation.css create mode 100644 libs/ui-components/src/utils/brand.ts 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..127ba9506 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,24 @@ "pending device": "pending device", "resource sync": "resource sync", "You are about to resume device <1>{deviceName}": "You are about to resume device <1>{deviceName}", - "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!", + "Copy and run this command in your terminal to authenticate to {{ brandName }}:": "Copy and run this command in your terminal to authenticate to {{ brandName }}:", + "Show more": "Show more", + "Show Less": "Show Less", + "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 +810,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} <2>devices in this fleet 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} <2>device in this fleet 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..f7e84c373 --- /dev/null +++ b/libs/ui-components/src/components/Masthead/CopyLoginCommandPage.tsx @@ -0,0 +1,206 @@ +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); + + // Truncate the command for initial display (show first part + ellipsis) + const truncatedCommand = + loginCommand.length > TRUNCATED_COMMAND_LENGTH + ? `${loginCommand.substring(0, TRUNCATED_COMMAND_LENGTH)}` + : loginCommand; + + return ( + + + + + + {t('Copy and run this command in your terminal to authenticate to {{ brandName }}:', { brandName })} + + + + + , + ]} + > + + {/* When the full command is shown, hide the truncated command. This allows the full command to be copied using the keyboard */} + {isExpanded ? '' : `${truncatedCommand}...`} + + {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 = window.location.search.split('sessionId=')[1]; + + 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 ( + + + + + + + + + + + + + + + + {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..0072ce152 100644 --- a/libs/ui-components/src/components/common/CopyButton.tsx +++ b/libs/ui-components/src/components/common/CopyButton.tsx @@ -13,12 +13,27 @@ 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: NodeJS.Timeout; + if (copied) { + timeout = setTimeout(() => { + setCopied(false); + }, 1000); + } + return () => { + 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..83846e4a1 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 = 'AAP', +} 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..25215aca6 100644 --- a/proxy/auth/auth.go +++ b/proxy/auth/auth.go @@ -1,11 +1,15 @@ 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" @@ -17,19 +21,35 @@ type ExpiresInResp struct { } 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 +} + +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), + } authConfig, internalAuthUrl, err := getAuthInfo(apiTlsConfig) if err != nil { return nil, err @@ -51,7 +71,7 @@ func NewAuth(apiTlsConfig *tls.Config) (*AuthHandler, error) { 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 +98,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 +113,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 +148,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 +170,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 +182,150 @@ 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 +} + +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 { From 75cdd4cdac26d12b7b6ce60ab000e5b05c4b7483 Mon Sep 17 00:00:00 2001 From: Celia Amador Date: Tue, 21 Oct 2025 11:44:07 +0200 Subject: [PATCH 2/2] Fixes from code review --- libs/i18n/locales/en/translation.json | 3 - .../Masthead/CopyLoginCommandPage.tsx | 49 ++++++------ .../src/components/common/CopyButton.tsx | 9 ++- .../common/CopyLoginCommandModal.tsx | 18 +++-- libs/ui-components/src/types/extraTypes.ts | 2 +- proxy/auth/auth.go | 76 ++++++++++++++++++- 6 files changed, 115 insertions(+), 42 deletions(-) diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 127ba9506..f17bffb6f 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -592,10 +592,7 @@ "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!", - "Copy and run this command in your terminal to authenticate to {{ brandName }}:": "Copy and run this command in your terminal to authenticate to {{ brandName }}:", "Show more": "Show more", - "Show Less": "Show Less", - "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", diff --git a/libs/ui-components/src/components/Masthead/CopyLoginCommandPage.tsx b/libs/ui-components/src/components/Masthead/CopyLoginCommandPage.tsx index f7e84c373..9340b893d 100644 --- a/libs/ui-components/src/components/Masthead/CopyLoginCommandPage.tsx +++ b/libs/ui-components/src/components/Masthead/CopyLoginCommandPage.tsx @@ -38,11 +38,8 @@ const LoginCommandCopy = ({ loginCommand, brandName }: { loginCommand: string; b const { t } = useTranslation(); const [isExpanded, setIsExpanded] = React.useState(false); - // Truncate the command for initial display (show first part + ellipsis) - const truncatedCommand = - loginCommand.length > TRUNCATED_COMMAND_LENGTH - ? `${loginCommand.substring(0, TRUNCATED_COMMAND_LENGTH)}` - : loginCommand; + const hasShowMore = loginCommand.length > TRUNCATED_COMMAND_LENGTH; + const visibleCommand = hasShowMore ? `${loginCommand.substring(0, TRUNCATED_COMMAND_LENGTH)}...` : loginCommand; return ( @@ -50,7 +47,7 @@ const LoginCommandCopy = ({ loginCommand, brandName }: { loginCommand: string; b - {t('Copy and run this command in your terminal to authenticate to {{ brandName }}:', { brandName })} + {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 ? '' : `${truncatedCommand}...`} - - {loginCommand} - - setIsExpanded(isExpanded)} - contentId="code-block-expand" - direction={isExpanded ? 'up' : 'down'} - > - {isExpanded ? t('Show Less') : t('Show More')} - + {isExpanded ? '' : visibleCommand} + {hasShowMore && ( + <> + + {loginCommand} + + setIsExpanded(isExpanded)} + contentId="code-block-expand" + direction={isExpanded ? 'up' : 'down'} + > + {isExpanded ? t('Show less') : t('Show more')} + + + )} @@ -101,7 +102,7 @@ const CopyLoginCommandBody = ({ brandName }: { brandName: string }) => { const [loginCommand, setLoginCommand] = React.useState(''); const [tokenNotFound, setTokenNotFound] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(''); - const sessionId = window.location.search.split('sessionId=')[1]; + const sessionId = new URLSearchParams(window.location.search).get('sessionId'); React.useEffect(() => { const getSessionToken = async () => { diff --git a/libs/ui-components/src/components/common/CopyButton.tsx b/libs/ui-components/src/components/common/CopyButton.tsx index 0072ce152..07081a438 100644 --- a/libs/ui-components/src/components/common/CopyButton.tsx +++ b/libs/ui-components/src/components/common/CopyButton.tsx @@ -21,14 +21,16 @@ const CopyButton = ({ ariaLabel, text, variant }: CopyButtonProps) => { }; React.useEffect(() => { - let timeout: NodeJS.Timeout; + let timeout: ReturnType | undefined; if (copied) { timeout = setTimeout(() => { setCopied(false); }, 1000); } return () => { - clearTimeout(timeout); + if (timeout) { + clearTimeout(timeout); + } }; }, [copied]); @@ -37,9 +39,10 @@ const CopyButton = ({ ariaLabel, text, variant }: CopyButtonProps) => {