Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions apps/ocp-plugin/src/components/common/WithPageLayout.tsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<OrganizationSelector isFirstLogin />
) : (
return (
<>
<PageNavigation />
<PageNavigation authType={AuthType.K8S} />
{children}
</>
);
};
*/

const WithPageLayout = ({ children }: React.PropsWithChildren) => {
return (
<OrganizationGuard>
<>{children}</>
<WithPageLayoutContent>{children}</WithPageLayoutContent>
</OrganizationGuard>
);
};
Expand Down
3 changes: 3 additions & 0 deletions apps/standalone/scripts/setup_env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions apps/standalone/src/app/components/AppLayout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -78,8 +82,6 @@ const AppLayoutContent = () => {
</PageSidebar>
);

const pageId = 'primary-app-container';

const PageSkipToContent = (
<SkipToContent
onClick={(event) => {
Expand All @@ -98,7 +100,7 @@ const AppLayoutContent = () => {
<OrganizationSelector isFirstLogin />
) : (
<>
<PageNavigation />
<PageNavigation authType={authType} />
<Outlet />
</>
)}
Expand Down
14 changes: 1 addition & 13 deletions apps/standalone/src/app/components/AppLayout/AppToolbar.css
Original file line number Diff line number Diff line change
@@ -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);
}
5 changes: 3 additions & 2 deletions apps/standalone/src/app/components/AppLayout/AppToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -69,14 +70,14 @@ const AppToolbar = () => {
const [preferencesModalOpen, setPreferencesModalOpen] = React.useState(false);
const [helpDropdownOpen, setHelpDropdownOpen] = React.useState<boolean>(false);

const { username, authEnabled } = React.useContext(AuthContext);
const { username, authType } = React.useContext(AuthContext);
const [logoutLoading, setLogoutLoading] = React.useState(false);
const [logoutErr, setLogoutErr] = React.useState<string>();
const onUserPreferences = () => setPreferencesModalOpen(true);
const navigate = useNavigate();

let userDropdown = <UserDropdown onUserPreferences={onUserPreferences} />;
if (authEnabled && username) {
if (authType !== AuthType.DISABLED && username) {
userDropdown = (
<UserDropdown username={username} onUserPreferences={onUserPreferences}>
<DropdownItem
Expand Down
54 changes: 40 additions & 14 deletions apps/standalone/src/app/context/AuthContext.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as React from 'react';
import { loginAPI, redirectToLogin } from '../utils/apiCalls';
import { ORGANIZATION_STORAGE_KEY } from '@flightctl/ui-components/src/utils/organizationStorage';
import { AuthType } from '@flightctl/ui-components/src/types/extraTypes';
import { OAUTH_REDIRECT_AFTER_LOGIN_KEY } from '@flightctl/ui-components/src/constants';

const AUTH_DISABLED_STATUS_CODE = 418;
const EXPIRATION = 'expiration';
Expand All @@ -11,38 +13,56 @@ const maxTimeout = 2 ** 31 - 1;

const nowInSeconds = () => 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<AuthContextProps>({
authType: AuthType.DISABLED,
username: '',
authEnabled: true,
loading: false,
error: undefined,
});

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>(AuthType.DISABLED);
const [error, setError] = React.useState<string>();
const refreshRef = React.useRef<NodeJS.Timeout>();

React.useEffect(() => {
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',
},
Expand All @@ -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);
Expand All @@ -69,7 +94,7 @@ export const useAuthContext = () => {
credentials: 'include',
});
if (resp.status === AUTH_DISABLED_STATUS_CODE) {
setAuthEnabled(false);
setAuthType(AuthType.DISABLED);
setLoading(false);
return;
}
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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 };
};
13 changes: 13 additions & 0 deletions apps/standalone/src/app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -310,6 +313,7 @@ const AppRouter = () => {
const { t } = useTranslation();

const { loading, error } = React.useContext(AuthContext);

if (error) {
return (
<Bullseye>
Expand Down Expand Up @@ -347,6 +351,15 @@ const AppRouter = () => {
errorElement: <ErrorPage />,
children: getAppRoutes(t),
},
// Route is only exposed for the standalone app, and it doesn't inherit the app layout
{
path: '/copy-login-command',
element: (
<TitledRoute title={t('Copy login command')}>
<CopyLoginCommandPage />
</TitledRoute>
),
},
]);

return <RouterProvider router={router} />;
Expand Down
33 changes: 25 additions & 8 deletions libs/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 fleet</2> are suspended because their local configuration is newer than the server&apos;s record. These devices will not receive updates until they are resumed._one": "<0>{suspendedCountStr}</0> <2>device in this fleet</2> is suspended because its local configuration is newer than the server&apos;s record. This device will not receive updates until it is resumed.",
Expand Down
Loading