From 420ce5a201f00898a597a33e71ab4a8902b21b12 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Fri, 24 May 2024 15:20:36 +0200 Subject: [PATCH 01/13] fix: fixed an issue that could prevent SSO logins depending on the type of SSO - slightly improved encapsulation of SSO method information Ticket: None Changelog: Title Signed-off-by: Manuel Zedel --- src/js/actions/organizationActions.js | 13 ++++--- src/js/actions/userActions.js | 7 ++-- src/js/actions/userActions.test.js | 5 ++- .../settings/organization/organization.js | 23 ++++++------- .../organization/organization.test.js | 19 ++++------- .../settings/organization/ssoconfig.test.js | 2 +- .../settings/organization/ssoeditor.test.js | 2 +- src/js/constants/organizationConstants.js | 34 ++++++++++++++++--- src/js/helpers.js | 4 --- tests/__mocks__/userHandlers.js | 2 +- 10 files changed, 63 insertions(+), 48 deletions(-) diff --git a/src/js/actions/organizationActions.js b/src/js/actions/organizationActions.js index 04bf3b4743..3816b5c49e 100644 --- a/src/js/actions/organizationActions.js +++ b/src/js/actions/organizationActions.js @@ -26,9 +26,10 @@ import { RECEIVE_SSO_CONFIGS, RECEIVE_WEBHOOK_EVENTS, SET_AUDITLOG_STATE, - SET_ORGANIZATION + SET_ORGANIZATION, + SSO_TYPES } from '../constants/organizationConstants'; -import { deepCompare, getSsoByContentType } from '../helpers'; +import { deepCompare } from '../helpers'; import { getCurrentSession, getTenantCapabilities } from '../selectors'; import { commonErrorFallback, commonErrorHandler, setFirstLoginAfterSignup, setSnackbar } from './appActions'; import { deviceAuthV2, iotManagerBaseURL } from './deviceActions'; @@ -319,8 +320,8 @@ const getSsoConfigById = config => dispatch => Api.get(`${ssoIdpApiUrlv1}/${config.id}`) .catch(err => dispatch(ssoConfigActionErrorHandler(err, 'read'))) .then(({ data, headers }) => { - const sso = getSsoByContentType(headers['content-type']); - return sso ? Promise.resolve({ ...config, config: data, type: sso.id }) : Promise.reject('Not supported SSO config content type.'); + const sso = Object.values(SSO_TYPES).find(({ contentType }) => contentType === headers['content-type']); + return sso ? Promise.resolve({ ...config, config: data, type: sso.id }) : Promise.reject('Unsupported SSO config content type.'); }); export const getSsoConfigs = () => dispatch => @@ -328,8 +329,6 @@ export const getSsoConfigs = () => dispatch => .catch(err => commonErrorHandler(err, 'There was an error retrieving SSO configurations', dispatch, commonErrorFallback)) .then(({ data }) => Promise.all(data.map(config => Promise.resolve(dispatch(getSsoConfigById(config))))) - .then(configs => { - return dispatch({ type: RECEIVE_SSO_CONFIGS, value: configs }); - }) + .then(configs => dispatch({ type: RECEIVE_SSO_CONFIGS, value: configs })) .catch(err => commonErrorHandler(err, err, dispatch, '')) ); diff --git a/src/js/actions/userActions.js b/src/js/actions/userActions.js index 7b3fe71bbf..6658c1d20d 100644 --- a/src/js/actions/userActions.js +++ b/src/js/actions/userActions.js @@ -20,9 +20,9 @@ import GeneralApi, { apiRoot } from '../api/general-api'; import UsersApi from '../api/users-api'; import { cleanUp, maxSessionAge, setSessionInfo } from '../auth'; import { HELPTOOLTIPS } from '../components/helptips/helptooltips'; -import { getSsoStartUrlById } from '../components/settings/organization/ssoconfig.js'; import * as AppConstants from '../constants/appConstants'; import { APPLICATION_JSON_CONTENT_TYPE, APPLICATION_JWT_CONTENT_TYPE } from '../constants/appConstants'; +import { SSO_TYPES } from '../constants/organizationConstants.js'; import { ALL_RELEASES } from '../constants/releaseConstants.js'; import * as UserConstants from '../constants/userConstants'; import { duplicateFilter, extractErrorMessage, isEmpty, preformatWithRequestID } from '../helpers'; @@ -81,8 +81,9 @@ export const loginUser = (userData, stayLoggedIn) => dispatch => // If the content type is application/json then backend returned SSO configuration. // user should be redirected to the start sso url to finish login process. if (contentType.includes(APPLICATION_JSON_CONTENT_TYPE)) { - const { id } = response; - const ssoLoginUrl = getSsoStartUrlById(id); + const { id, kind } = response; + const type = kind.split('/')[1]; + const ssoLoginUrl = SSO_TYPES[type].getStartUrl(id); window.location.replace(ssoLoginUrl); return; } diff --git a/src/js/actions/userActions.test.js b/src/js/actions/userActions.test.js index a6b6c13869..78e75213cb 100644 --- a/src/js/actions/userActions.test.js +++ b/src/js/actions/userActions.test.js @@ -19,7 +19,6 @@ import Cookies from 'universal-cookie'; import { inventoryDevice } from '../../../tests/__mocks__/deviceHandlers'; import { accessTokens, defaultPassword, defaultState, receivedPermissionSets, receivedRoles, testSsoId, token, userId } from '../../../tests/mockData'; import { HELPTOOLTIPS } from '../components/helptips/helptooltips'; -import { getSsoStartUrlById } from '../components/settings/organization/ssoconfig.js'; import { SET_ANNOUNCEMENT, SET_ENVIRONMENT_DATA, @@ -54,7 +53,7 @@ import { UNGROUPED_GROUP } from '../constants/deviceConstants'; import { SET_DEMO_ARTIFACT_PORT, SET_ONBOARDING_COMPLETE } from '../constants/onboardingConstants'; -import { RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS } from '../constants/organizationConstants'; +import { RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, getSamlStartUrl } from '../constants/organizationConstants'; import { RECEIVE_RELEASES, SET_RELEASES_LIST_STATE } from '../constants/releaseConstants'; import { CREATED_ROLE, @@ -507,7 +506,7 @@ describe('user actions', () => { jest.runOnlyPendingTimers(); jest.runAllTicks(); }); - expect(replaceSpy).toHaveBeenCalledWith(getSsoStartUrlById(testSsoId)); + expect(replaceSpy).toHaveBeenCalledWith(getSamlStartUrl(testSsoId)); }); it('should prevent logging in with a limited user', async () => { jest.clearAllMocks(); diff --git a/src/js/components/settings/organization/organization.js b/src/js/components/settings/organization/organization.js index 02a8be272a..1550f13b44 100644 --- a/src/js/components/settings/organization/organization.js +++ b/src/js/components/settings/organization/organization.js @@ -34,7 +34,7 @@ import { } from '../../../actions/organizationActions'; import { TIMEOUTS } from '../../../constants/appConstants'; import { SSO_TYPES } from '../../../constants/organizationConstants.js'; -import { createFileDownload, getSsoByType, toggle } from '../../../helpers'; +import { createFileDownload, toggle } from '../../../helpers'; import { getCurrentSession, getFeatures, getIsEnterprise, getIsPreview, getOrganization, getSsoConfig, getUserRoles } from '../../../selectors'; import ExpandableAttribute from '../../common/expandable-attribute'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; @@ -87,7 +87,7 @@ export const Organization = () => { const [isConfiguringSSO, setIsConfiguringSSO] = useState(false); const [isResettingSSO, setIsResettingSSO] = useState(false); const [showTokenWarning, setShowTokenWarning] = useState(false); - const [newSso, setNewSso] = useState(undefined); + const [newSso, setNewSso] = useState(''); const [selectedSsoItem, setSelectedSsoItem] = useState(undefined); const isEnterprise = useSelector(getIsEnterprise); const { isAdmin } = useSelector(getUserRoles); @@ -111,7 +111,7 @@ export const Organization = () => { setHasSingleSignOn(!!ssoConfig); setIsConfiguringSSO(!!ssoConfig); if (ssoConfig) { - setSelectedSsoItem(getSsoByType(ssoConfig.type)); + setSelectedSsoItem(SSO_TYPES[ssoConfig.type]); } }, [ssoConfig]); @@ -119,7 +119,7 @@ export const Organization = () => { const onSaveSSOSettings = useCallback( (id, config) => { - const { contentType } = getSsoByType(selectedSsoItem.id); + const { contentType } = SSO_TYPES[selectedSsoItem.type]; if (isResettingSSO) { return dispatch(deleteSsoConfig(ssoConfig)).then(() => setIsResettingSSO(false)); } @@ -159,19 +159,18 @@ export const Organization = () => { if (ssoConfig) { setNewSso(type); } else { - setSelectedSsoItem(getSsoByType(type)); + setSelectedSsoItem(SSO_TYPES[type]); } }, [ssoConfig] ); - const changeSSO = () => { + const changeSSO = () => dispatch(deleteSsoConfig(ssoConfig)).then(() => { - setSelectedSsoItem(getSsoByType(newSso)); + setSelectedSsoItem(SSO_TYPES[newSso]); setIsConfiguringSSO(true); - setNewSso(undefined); + setNewSso(''); }); - }; return (
@@ -230,10 +229,10 @@ export const Organization = () => { {isConfiguringSSO && (
- Select type - {SSO_TYPES.map(item => ( - + {Object.values(SSO_TYPES).map(item => ( +
{item.title}
))} diff --git a/src/js/components/settings/organization/organization.test.js b/src/js/components/settings/organization/organization.test.js index 515270b823..d83b801a67 100644 --- a/src/js/components/settings/organization/organization.test.js +++ b/src/js/components/settings/organization/organization.test.js @@ -101,30 +101,25 @@ describe('MyOrganization Component', () => { const ui = ; const { rerender } = render(ui, { preloadedState: { ...preloadedState, users: { ...preloadedState.users, currentSession: getSessionInfo() } } }); await waitFor(() => rerender(ui)); - await act(async () => {}); + await act(async () => { + jest.runOnlyPendingTimers(); + jest.runAllTicks(); + }); expect(screen.getByText(/text editor/i)).toBeVisible(); await user.click(screen.getByText(/text editor/i)); await waitFor(() => rerender(ui)); expect(screen.getByText(/import from a file/i)).toBeVisible(); await user.upload(screen.getByText(/import from a file/i).previousSibling, file); await waitFor(() => expect(document.querySelector(`.${drawerClasses.root}`)).toBeVisible()); - await act(async () => { - await user.click(screen.getByTestId('CloseIcon')); - }); + await user.click(screen.getByTestId('CloseIcon')); await waitFor(() => rerender(ui)); await waitFor(() => expect(document.querySelector(`.${drawerClasses.root}`)).not.toBeInTheDocument()); await waitFor(() => expect(screen.getByRole('checkbox')).toBeChecked()); while (screen.queryByText(/entity id/i)) { - await act(async () => { - await user.click(screen.getByRole('checkbox')); - await user.click(screen.getByRole('button', { name: /save/i })); - }); + await user.click(screen.getByRole('checkbox')); + await user.click(screen.getByRole('button', { name: /save/i })); await waitFor(() => rerender(ui)); } - const input = document.querySelector('input[type=file]'); - await user.upload(input, file); - await waitFor(() => rerender(ui)); - expect(document.querySelector(`.${drawerClasses.root}`)).not.toBeInTheDocument(); }); }); diff --git a/src/js/components/settings/organization/ssoconfig.test.js b/src/js/components/settings/organization/ssoconfig.test.js index a7d86f41ee..749a6e8a96 100644 --- a/src/js/components/settings/organization/ssoconfig.test.js +++ b/src/js/components/settings/organization/ssoconfig.test.js @@ -21,7 +21,7 @@ import SSOConfig from './ssoconfig'; describe('SamlConfig Component', () => { it('renders correctly', async () => { const { baseElement } = render( - not quite right
` }} onCancel={jest.fn} onSave={jest.fn} /> + not quite right
` }} onCancel={jest.fn} onSave={jest.fn} /> ); const view = baseElement.firstChild; expect(view).toMatchSnapshot(); diff --git a/src/js/components/settings/organization/ssoeditor.test.js b/src/js/components/settings/organization/ssoeditor.test.js index 20928138a7..aae62d15e9 100644 --- a/src/js/components/settings/organization/ssoeditor.test.js +++ b/src/js/components/settings/organization/ssoeditor.test.js @@ -23,7 +23,7 @@ describe('SSOEditor Component', () => { const config = '
not quite right
'; const { baseElement } = render( `${window.location.origin}${useradmApiUrl}/auth/sso/${id}/login`; +export const getOidcStartUrl = id => `${window.location.origin}${useradmApiUrl}/oidc/${id}/start`; + +export const SSO_TYPES = { + saml: { + id: 'saml', + type: 'saml', + title: 'SAML', + metadataFormat: XML_METADATA_FORMAT, + editorLanguage: XML_METADATA_FORMAT, + contentType: 'application/samlmetadata+xml', + getStartUrl: getSamlStartUrl, + configDetails: [ + { key: 'entityID', label: 'Entity ID', getValue: id => `${window.location.origin}${useradmApiUrl}/sso/sp/metadata/${id}` }, + { key: 'acs', label: 'ACS URL', getValue: id => `${window.location.origin}${useradmApiUrl}/auth/sso/${id}/acs` }, + { key: 'startURL', label: 'Start URL', getValue: getSamlStartUrl } + ] + }, + oidc: { + id: 'oidc', + type: 'oidc', + title: 'OpenID Connect', + metadataFormat: JSON_METADATA_FORMAT, + editorLanguage: JSON_METADATA_FORMAT, + contentType: 'application/json', + getStartUrl: getOidcStartUrl, + configDetails: [{ key: 'startURL', label: 'Start Url', getValue: getOidcStartUrl }] + } +}; export const AUDIT_LOGS_TYPES = [ { title: 'Artifact', queryParameter: 'object_type', value: 'artifact' }, diff --git a/src/js/helpers.js b/src/js/helpers.js index c31cea3d86..6c55d05356 100644 --- a/src/js/helpers.js +++ b/src/js/helpers.js @@ -25,7 +25,6 @@ import { deploymentStatesToSubstatesWithSkipped } from './constants/deploymentConstants'; import { ATTRIBUTE_SCOPES, DEVICE_FILTERING_OPTIONS } from './constants/deviceConstants'; -import { SSO_TYPES } from './constants/organizationConstants.js'; const isEncoded = uri => { uri = uri || ''; @@ -530,6 +529,3 @@ export const getISOStringBoundaries = currentDate => { }; export const isDarkMode = mode => mode === DARK_MODE; - -export const getSsoByType = type => SSO_TYPES.find(item => item.id === type); -export const getSsoByContentType = contentType => SSO_TYPES.find(item => item.contentType === contentType); diff --git a/tests/__mocks__/userHandlers.js b/tests/__mocks__/userHandlers.js index 615097644d..0004789581 100644 --- a/tests/__mocks__/userHandlers.js +++ b/tests/__mocks__/userHandlers.js @@ -24,7 +24,7 @@ export const userHandlers = [ const [user, password] = authInfo.split(':'); if (!password) { - return HttpResponse.json({ id: testSsoId }); + return HttpResponse.json({ id: testSsoId, kind: 'sso/saml' }); } else if (password !== defaultPassword) { return new HttpResponse(null, { status: 401 }); } else if (user.includes('limited')) { From ccaae1a65c54a3e8d1fae35603a2870a9c074dcb Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Fri, 24 May 2024 15:22:04 +0200 Subject: [PATCH 02/13] fix: fixed SSO information not being adjusted depending on the type of SSO configured Ticket: MEN-7277 Changelog: Title Signed-off-by: Manuel Zedel --- .../settings/organization/ssoconfig.js | 17 ++++------------- .../settings/organization/ssoconfig.test.js | 7 ++++++- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/js/components/settings/organization/ssoconfig.js b/src/js/components/settings/organization/ssoconfig.js index 02fe62fdd3..feb1d16953 100644 --- a/src/js/components/settings/organization/ssoconfig.js +++ b/src/js/components/settings/organization/ssoconfig.js @@ -20,8 +20,7 @@ import { Button } from '@mui/material'; import { listItemTextClasses } from '@mui/material/ListItemText'; import { makeStyles } from 'tss-react/mui'; -import { XML_METADATA_FORMAT } from '../../../constants/organizationConstants.js'; -import { useradmApiUrl } from '../../../constants/userConstants'; +import { SSO_TYPES, XML_METADATA_FORMAT } from '../../../constants/organizationConstants.js'; import { toggle } from '../../../helpers'; import ExpandableAttribute from '../../common/expandable-attribute'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; @@ -56,20 +55,12 @@ const useStyles = makeStyles()(theme => ({ } })); -export const getSsoStartUrlById = id => `${window.location.origin}${useradmApiUrl}/auth/sso/${id}/login`; - -const defaultDetails = [ - { key: 'entityID', label: 'Entity ID', getValue: id => `${window.location.origin}${useradmApiUrl}/sso/sp/metadata/${id}` }, - { key: 'acs', label: 'ACS URL', getValue: id => `${window.location.origin}${useradmApiUrl}/auth/sso/${id}/acs` }, - { key: 'startURL', label: 'Start URL', getValue: id => getSsoStartUrlById(id) } -]; - export const SSOConfig = ({ ssoItem, config, onCancel, onSave, setSnackbar, token }) => { const [configDetails, setConfigDetails] = useState([]); const [fileContent, setFileContentState] = useState(''); const [hasSSOConfig, setHasSSOConfig] = useState(false); const [isEditing, setIsEditing] = useState(false); - const { id, ...content } = config || {}; + const { id, type, ...content } = config || {}; const configContent = content.config || ''; // file content should be text, otherwise editor will fail @@ -81,9 +72,9 @@ export const SSOConfig = ({ ssoItem, config, onCancel, onSave, setSnackbar, toke setHasSSOConfig(!!config); setFileContent(configContent); if (config?.id) { - setConfigDetails(defaultDetails.map(item => ({ ...item, value: item.getValue(config.id) }))); + setConfigDetails(SSO_TYPES[type].configDetails.map(item => ({ ...item, value: item.getValue(config.id) }))); } - }, [config, configContent]); + }, [config, configContent, type]); const onCancelSSOSettings = () => { setHasSSOConfig(!!config); diff --git a/src/js/components/settings/organization/ssoconfig.test.js b/src/js/components/settings/organization/ssoconfig.test.js index 749a6e8a96..365148c2d0 100644 --- a/src/js/components/settings/organization/ssoconfig.test.js +++ b/src/js/components/settings/organization/ssoconfig.test.js @@ -21,7 +21,12 @@ import SSOConfig from './ssoconfig'; describe('SamlConfig Component', () => { it('renders correctly', async () => { const { baseElement } = render( - not quite right` }} onCancel={jest.fn} onSave={jest.fn} /> + not quite right`, type: SSO_TYPES.saml.type }} + onCancel={jest.fn} + onSave={jest.fn} + /> ); const view = baseElement.firstChild; expect(view).toMatchSnapshot(); From 7ac48f1eee9882dc64fac85a9e661160ef459719 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Tue, 28 May 2024 22:26:02 +0200 Subject: [PATCH 03/13] feat: gave device deployment log files more descriptive file names TIcket: MEN-7221 Changelog: Title Signed-off-by: Manuel Zedel --- src/js/components/common/dialogs/log.js | 16 +++++----------- src/js/components/deployments/report.js | 8 +++++++- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/js/components/common/dialogs/log.js b/src/js/components/common/dialogs/log.js index cb80ae786a..ad1ad22b88 100644 --- a/src/js/components/common/dialogs/log.js +++ b/src/js/components/common/dialogs/log.js @@ -16,6 +16,7 @@ import CopyToClipboard from 'react-copy-to-clipboard'; import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; +import { createFileDownload } from '../../../helpers'; import { Code } from '../copy-code'; const wrapperStyle = { marginRight: 10, display: 'inline-block' }; @@ -23,25 +24,18 @@ const wrapperStyle = { marginRight: 10, display: 'inline-block' }; const dialogTypes = { 'deviceLog': { title: 'Deployment log for device', - filename: 'deviceLog' + filename: ({ device, releaseName, date }) => `deployment-log-${device}-${releaseName}-${date}.log` }, 'configUpdateLog': { title: 'Config update log for device', - filename: 'updateLog' - }, - 'monitorLog': { - title: 'Alert log for device', - filename: 'monitorLog' + filename: () => 'configuration-update.log' } }; -export const LogDialog = ({ logData = '', onClose, type = 'deviceLog' }) => { +export const LogDialog = ({ context = {}, logData = '', onClose, type = 'deviceLog' }) => { const [copied, setCopied] = useState(false); - const exportLog = () => { - const uriContent = `data:application/octet-stream,${encodeURIComponent(logData)}`; - window.open(uriContent, dialogTypes[type].filename); - }; + const exportLog = () => createFileDownload(logData, dialogTypes[type].filename(context), ''); return ( diff --git a/src/js/components/deployments/report.js b/src/js/components/deployments/report.js index 152737d57f..932b33af38 100644 --- a/src/js/components/deployments/report.js +++ b/src/js/components/deployments/report.js @@ -282,7 +282,13 @@ export const DeploymentReport = ({ abort, onClose, past, retry, type }) => { onAbort={abort} innerRef={rolloutSchedule} /> - {Boolean(deviceId.length) && setDeviceId('')} />} + {Boolean(deviceId.length) && ( + setDeviceId('')} + /> + )} From a3349d6d4129035f6791bd6af98fc484a41dc62e Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Tue, 28 May 2024 22:27:01 +0200 Subject: [PATCH 04/13] feat: made log viewer wider to ease going through deployment logs Ticket: MEN-7220 Changelog: Title Signed-off-by: Manuel Zedel --- .../common/dialogs/__snapshots__/log.test.js.snap | 6 +++--- src/js/components/common/dialogs/log.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/js/components/common/dialogs/__snapshots__/log.test.js.snap b/src/js/components/common/dialogs/__snapshots__/log.test.js.snap index c5a7944f54..f6968bb002 100644 --- a/src/js/components/common/dialogs/__snapshots__/log.test.js.snap +++ b/src/js/components/common/dialogs/__snapshots__/log.test.js.snap @@ -80,7 +80,7 @@ exports[`LogDialog Component renders correctly 1`] = ` -ms-flex-direction: column; flex-direction: column; max-height: calc(100% - 64px); - max-width: 600px; + max-width: 1200px; } @media print { @@ -90,7 +90,7 @@ exports[`LogDialog Component renders correctly 1`] = ` } } -@media (max-width:663.95px) { +@media (max-width:1263.95px) { .emotion-3.MuiDialog-paperScrollBody { max-width: calc(100% - 64px); } @@ -375,7 +375,7 @@ exports[`LogDialog Component renders correctly 1`] = ` >