Skip to content

Commit

Permalink
Merge pull request #4398 from mendersoftware/master
Browse files Browse the repository at this point in the history
Sync staging with master
  • Loading branch information
tranchitella committed May 9, 2024
2 parents ca51617 + 7b552c7 commit c4d09a6
Show file tree
Hide file tree
Showing 28 changed files with 1,518 additions and 464 deletions.
1 change: 0 additions & 1 deletion src/js/actions/appActions.js
Expand Up @@ -67,7 +67,6 @@ const featureFlags = [
'hasDeltaProgress',
'hasDeviceConfig',
'hasDeviceConnect',
'hasReleaseTags',
'hasReporting',
'hasMonitor',
'isEnterprise'
Expand Down
60 changes: 41 additions & 19 deletions src/js/actions/userActions.js
Expand Up @@ -20,7 +20,10 @@ 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 { ALL_RELEASES } from '../constants/releaseConstants.js';
import * as UserConstants from '../constants/userConstants';
import { duplicateFilter, extractErrorMessage, isEmpty, preformatWithRequestID } from '../helpers';
import { getCurrentUser, getOnboardingState, getTooltipsState, getUserSettings as getUserSettingsSelector } from '../selectors';
Expand All @@ -44,18 +47,26 @@ const {
useradmApiUrlv2
} = UserConstants;

const handleLoginError = (err, has2FA) => dispatch => {
const errorText = extractErrorMessage(err);
const is2FABackend = errorText.includes('2fa');
if (is2FABackend && !has2FA) {
return Promise.reject({ error: '2fa code missing' });
}
const twoFAError = is2FABackend ? ' and verification code' : '';
const errorMessage = `There was a problem logging in. Please check your email${
twoFAError ? ',' : ' and'
} password${twoFAError}. If you still have problems, contact an administrator.`;
return Promise.reject(dispatch(setSnackbar(preformatWithRequestID(err.response, errorMessage), null, 'Copy to clipboard')));
};
const handleLoginError =
(err, { token2fa: has2FA, password }) =>
dispatch => {
const errorText = extractErrorMessage(err);
const is2FABackend = errorText.includes('2fa');
if (is2FABackend && !has2FA) {
return Promise.reject({ error: '2fa code missing' });
}
if (password === undefined) {
// Enterprise supports two-steps login. On the first step you can enter only email
// and in case of SSO set up you will receive a redirect URL
// otherwise you will receive 401 status code and password field will be shown.
return Promise.reject();
}
const twoFAError = is2FABackend ? ' and verification code' : '';
const errorMessage = `There was a problem logging in. Please check your email${
twoFAError ? ',' : ' and'
} password${twoFAError}. If you still have problems, contact an administrator.`;
return Promise.reject(dispatch(setSnackbar(preformatWithRequestID(err.response, errorMessage), null, 'Copy to clipboard')));
};

/*
User management
Expand All @@ -64,11 +75,20 @@ export const loginUser = (userData, stayLoggedIn) => dispatch =>
UsersApi.postLogin(`${useradmApiUrl}/auth/login`, { ...userData, no_expiry: stayLoggedIn })
.catch(err => {
cleanUp();
return Promise.resolve(dispatch(handleLoginError(err, userData['token2fa'])));
return Promise.resolve(dispatch(handleLoginError(err, userData)));
})
.then(res => {
const token = res.text;
if (!token) {
.then(({ text: response, contentType }) => {
// 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);
window.location.replace(ssoLoginUrl);
return;
}

const token = response;
if (contentType !== APPLICATION_JWT_CONTENT_TYPE || !token) {
return;
}
// save token to local storage & set maxAge if noexpiry checkbox not checked
Expand Down Expand Up @@ -464,13 +484,13 @@ export const getRoles = () => (dispatch, getState) =>
})
.catch(() => console.log('Role retrieval failed - likely accessing a non-RBAC backend'));

const deriveImpliedAreaPermissions = (area, areaPermissions) => {
const deriveImpliedAreaPermissions = (area, areaPermissions, skipPermissions = []) => {
const highestAreaPermissionLevelSelected = areaPermissions.reduce(
(highest, current) => (uiPermissionsById[current].permissionLevel > highest ? uiPermissionsById[current].permissionLevel : highest),
1
);
return uiPermissionsByArea[area].uiPermissions.reduce((permissions, current) => {
if (current.permissionLevel < highestAreaPermissionLevelSelected || areaPermissions.includes(current.value)) {
if ((current.permissionLevel < highestAreaPermissionLevelSelected || areaPermissions.includes(current.value)) && !skipPermissions.includes(current.value)) {
permissions.push(current.value);
}
return permissions;
Expand All @@ -483,7 +503,9 @@ const deriveImpliedAreaPermissions = (area, areaPermissions) => {
*/
const transformAreaRoleDataToScopedPermissionsSets = (area, areaPermissions, excessiveAccessSelector) => {
const permissionSetObject = areaPermissions.reduce((accu, { item, uiPermissions }) => {
const impliedPermissions = deriveImpliedAreaPermissions(area, uiPermissions);
// if permission area is release and item is release tag (not all releases) then exclude upload permission as it cannot be applied to tags
const skipPermissions = scopedPermissionAreas.releases.key === area && item !== ALL_RELEASES ? [uiPermissionsById.upload.value] : [];
const impliedPermissions = deriveImpliedAreaPermissions(area, uiPermissions, skipPermissions);
accu = impliedPermissions.reduce((itemPermissionAccu, impliedPermission) => {
const permissionSetState = itemPermissionAccu[uiPermissionsById[impliedPermission].permissionSets[area]] ?? {
type: uiPermissionsByArea[area].scope,
Expand Down
14 changes: 13 additions & 1 deletion src/js/actions/userActions.test.js
Expand Up @@ -11,13 +11,15 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { act } from '@testing-library/react';
import configureMockStore from 'redux-mock-store';
import { thunk } from 'redux-thunk';
import Cookies from 'universal-cookie';

import { inventoryDevice } from '../../../tests/__mocks__/deviceHandlers';
import { accessTokens, defaultPassword, defaultState, receivedPermissionSets, receivedRoles, token, userId } from '../../../tests/mockData';
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,
Expand Down Expand Up @@ -497,6 +499,16 @@ describe('user actions', () => {
expect(storeActions.length).toEqual(expectedActions.length);
expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action));
});
it('should redirect on SSO login', async () => {
const store = mockStore({ ...defaultState });
const replaceSpy = jest.spyOn(window.location, 'replace');
await store.dispatch(loginUser({ email: 'test@example.com' }));
await act(async () => {
jest.runOnlyPendingTimers();
jest.runAllTicks();
});
expect(replaceSpy).toHaveBeenCalledWith(getSsoStartUrlById(testSsoId));
});
it('should prevent logging in with a limited user', async () => {
jest.clearAllMocks();
window.localStorage.getItem.mockReturnValue(JSON.stringify({ token: 'limitedToken' }));
Expand Down
4 changes: 3 additions & 1 deletion src/js/api/users-api.js
Expand Up @@ -18,7 +18,9 @@ import { commonRequestConfig } from './general-api';

const Api = {
postLogin: (url, { email: username, password, ...body }) =>
axios.post(url, body, { ...commonRequestConfig, auth: { username, password } }).then(res => ({ text: res.data, code: res.status })),
axios
.post(url, body, { ...commonRequestConfig, auth: { username, password } })
.then(res => ({ text: res.data, code: res.status, contentType: res.headers?.['content-type'] })),
putVerifyTFA: (url, userData) => {
let body = {};
if (userData.hasOwnProperty('token2fa')) {
Expand Down
49 changes: 45 additions & 4 deletions src/js/components/common/detailstable.js
Expand Up @@ -15,7 +15,7 @@ import React from 'react';

// material ui
import { Sort as SortIcon } from '@mui/icons-material';
import { Table, TableBody, TableCell, TableHead, TableRow } from '@mui/material';
import { Checkbox, Table, TableBody, TableCell, TableHead, TableRow } from '@mui/material';
import { makeStyles } from 'tss-react/mui';

import { SORTING_OPTIONS } from '../../constants/appConstants';
Expand All @@ -32,12 +32,48 @@ const useStyles = makeStyles()(() => ({
}
}));

export const DetailsTable = ({ className = '', columns, items, onChangeSorting, onItemClick, sort = {}, style = {}, tableRef }) => {
export const DetailsTable = ({
className = '',
columns,
items,
onChangeSorting,
onItemClick,
sort = {},
style = {},
tableRef,
onRowSelected = undefined,
selectedRows = []
}) => {
const { classes } = useStyles();

const onRowSelection = selectedRow => {
let updatedSelection = [...selectedRows];
const selectedIndex = updatedSelection.indexOf(selectedRow);
if (selectedIndex === -1) {
updatedSelection.push(selectedRow);
} else {
updatedSelection.splice(selectedIndex, 1);
}
onRowSelected(updatedSelection);
};

const onSelectAllClick = () => {
let newSelectedRows = Array.from({ length: items.length }, (_, index) => index);
if (selectedRows.length && selectedRows.length <= items.length) {
newSelectedRows = [];
}
onRowSelected(newSelectedRows);
};

return (
<Table className={`margin-bottom ${className}`} style={style} ref={tableRef}>
<TableHead className={classes.header}>
<TableRow>
{onRowSelected !== undefined && (
<TableCell>
<Checkbox indeterminate={false} checked={selectedRows.length === items.length} onChange={onSelectAllClick} />
</TableCell>
)}
{columns.map(({ extras, key, renderTitle, sortable, title }) => (
<TableCell key={key} className={`columnHeader ${sortable ? '' : 'nonSortable'}`} onClick={() => (sortable ? onChangeSorting(key) : null)}>
{renderTitle ? renderTitle(extras) : title}
Expand All @@ -48,9 +84,14 @@ export const DetailsTable = ({ className = '', columns, items, onChangeSorting,
</TableHead>
<TableBody>
{items.map((item, index) => (
<TableRow className={onItemClick ? 'clickable' : ''} hover key={item.id || index} onClick={() => (onItemClick ? onItemClick(item) : null)}>
<TableRow className={onItemClick ? 'clickable' : ''} hover key={item.id || index}>
{onRowSelected !== undefined && (
<TableCell>
<Checkbox checked={selectedRows.includes(index)} onChange={() => onRowSelection(index)} />
</TableCell>
)}
{columns.map(column => (
<TableCell className="relative" key={column.key}>
<TableCell className="relative" key={column.key} onClick={() => (onItemClick ? onItemClick(item) : null)}>
{column.render(item, column.extras)}
</TableCell>
))}
Expand Down
17 changes: 14 additions & 3 deletions src/js/components/login/login.js
Expand Up @@ -28,7 +28,7 @@ import { loginUser, logoutUser } from '../../actions/userActions';
import { getToken } from '../../auth';
import { TIMEOUTS, locations } from '../../constants/appConstants';
import { useradmApiUrl } from '../../constants/userConstants';
import { getCurrentUser, getFeatures } from '../../selectors';
import { getCurrentUser, getFeatures, getIsEnterprise } from '../../selectors';
import { clearAllRetryTimers } from '../../utils/retrytimer';
import Form from '../common/forms/form';
import PasswordInput from '../common/forms/passwordinput';
Expand Down Expand Up @@ -141,6 +141,14 @@ export const Login = () => {
const dispatch = useDispatch();
const currentUser = useSelector(getCurrentUser);
const { isHosted } = useSelector(getFeatures);
const isEnterprise = useSelector(getIsEnterprise);
const [showPassword, setShowPassword] = useState(!isEnterprise);

useEffect(() => {
if (isEnterprise) {
setShowPassword(false);
}
}, [isEnterprise]);

useEffect(() => {
clearAllRetryTimers(message => dispatch(setSnackbar(message)));
Expand Down Expand Up @@ -171,9 +179,12 @@ export const Login = () => {
if (err?.error?.includes('2fa')) {
setHas2FA(true);
}
if (!showPassword) {
setShowPassword(true);
}
});
},
[dispatch, noExpiry]
[dispatch, noExpiry, showPassword]
);

const onOAuthClick = ({ target: { textContent } }) => {
Expand All @@ -198,7 +209,7 @@ export const Login = () => {
{isHosted && <OAuthHeader type="Log in" buttonProps={{ onClick: onOAuthClick }} />}
<Form className={classes.form} showButtons={true} buttonColor="primary" onSubmit={onLoginClick} submitLabel="Log in">
<TextInput hint="Your email" label="Your email" id="email" required={true} validations="isLength:1,isEmail,trim" />
<PasswordInput className="margin-bottom-small" id="password" label="Password" required={true} />
{showPassword && <PasswordInput className="margin-bottom-small" id="password" label="Password" required={true} />}
{isHosted ? (
<div className="flexbox">
<Link className={classes.link} to="/password">
Expand Down

0 comments on commit c4d09a6

Please sign in to comment.