diff --git a/app/package.json b/app/package.json
index 5cd59a99..07740afa 100644
--- a/app/package.json
+++ b/app/package.json
@@ -43,7 +43,7 @@
"is-electron": "^2.2.0",
"marked": "^0.8.0",
"material-ui-popup-state": "^1.8.0",
- "mediasoup-client": "^3.6.55",
+ "mediasoup-client": "^3.7.1",
"notistack": "^0.9.5",
"prop-types": "^15.7.2",
"random-string": "^0.2.0",
@@ -51,6 +51,7 @@
"react-cookie-consent": "^2.5.0",
"react-dom": "^16.10.2",
"react-flip-toolkit": "^7.0.9",
+ "react-iframe": "1.8.5",
"react-image-file-resizer": "^0.3.8",
"react-images-upload": "^1.2.0",
"react-intl": "^3.4.0",
@@ -66,7 +67,7 @@
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"riek": "^1.1.0",
- "socket.io-client": "^2.4.0",
+ "socket.io-client": "^4.5.4",
"source-map-explorer": "^2.1.0",
"streamsaver": "^2.0.5",
"typescript": "^4.2.4",
diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js
index f685d574..7533a87f 100644
--- a/app/src/RoomClient.js
+++ b/app/src/RoomClient.js
@@ -2110,6 +2110,24 @@ export default class RoomClient
peerActions.setStopPeerScreenSharingInProgress(peerId, false));
}
+ async toggleIframe(iframeUrl)
+ {
+ store.dispatch(
+ roomActions.setToggleIframeInProgress(true));
+
+ try
+ {
+ await this.sendRequest('toggleIframe', { iframeUrl });
+ }
+ catch (error)
+ {
+ logger.error('toggleIframe() [error:"%o"]', error);
+ }
+
+ store.dispatch(
+ roomActions.setToggleIframeInProgress(false));
+ }
+
async muteAllPeers()
{
logger.debug('muteAllPeers()');
@@ -3194,6 +3212,24 @@ export default class RoomClient
break;
}
+ case 'showIframe':
+ {
+ const { iframeUrl } = notification.data;
+
+ store.dispatch(
+ roomActions.openIframe(iframeUrl));
+
+ break;
+ }
+
+ case 'closeIframe':
+ {
+ store.dispatch(
+ roomActions.closeIframe());
+
+ break;
+ }
+
case 'moderator:clearChat':
{
store.dispatch(chatActions.clearChat());
@@ -3917,6 +3953,7 @@ export default class RoomClient
chatHistory,
fileHistory,
lastNHistory,
+ iframeHistory,
locked,
lobbyPeers,
accessCode
@@ -3984,6 +4021,9 @@ export default class RoomClient
(fileHistory.length > 0) && store.dispatch(
fileActions.addFileHistory(fileHistory));
+ (iframeHistory) && store.dispatch(
+ roomActions.openIframe(iframeHistory));
+
locked ?
store.dispatch(roomActions.setRoomLocked()) :
store.dispatch(roomActions.setRoomUnLocked());
diff --git a/app/src/components/Containers/Me.js b/app/src/components/Containers/Me.js
index 7077d8db..aa6d9768 100644
--- a/app/src/components/Containers/Me.js
+++ b/app/src/components/Containers/Me.js
@@ -3,7 +3,8 @@ import { connect } from 'react-redux';
import {
meProducersSelector,
makePermissionSelector,
- recordingConsentsPeersSelector
+ recordingConsentsPeersSelector,
+ showIframeSelect
} from '../../store/selectors';
import { permissions } from '../../permissions';
import { withRoomContext } from '../../RoomContext';
@@ -60,6 +61,10 @@ const styles = (theme) =>
'&.screen' :
{
order : 2
+ },
+ '&.iframe' :
+ {
+ order : 3
}
},
viewContainer :
@@ -156,6 +161,7 @@ const Me = (props) =>
roomClient,
me,
settings,
+ iframeUrl,
activeSpeaker,
spacing,
style,
@@ -175,7 +181,7 @@ const Me = (props) =>
localRecordingState
} = props;
- // const width = style.width;
+ const width = style.width;
const height = style.height;
@@ -1018,6 +1024,19 @@ const Me = (props) =>
}
+ {iframeUrl &&
+
+
+ }
);
};
@@ -1028,6 +1047,7 @@ Me.propTypes =
advancedMode : PropTypes.bool,
me : appPropTypes.Me.isRequired,
settings : PropTypes.object,
+ iframeUrl : PropTypes.string.isRequired,
activeSpeaker : PropTypes.bool,
micProducer : appPropTypes.Producer,
webcamProducer : appPropTypes.Producer,
@@ -1073,6 +1093,7 @@ const makeMapStateToProps = () =>
me : state.me,
...meProducersSelector(state),
settings : state.settings,
+ iframeUrl : showIframeSelect(state),
activeSpeaker : state.me.id === state.room.activeSpeakerId,
hasAudioPermission : canShareAudio(state),
hasVideoPermission : canShareVideo(state),
diff --git a/app/src/components/JoinDialog.js b/app/src/components/JoinDialog.js
index dd070d00..5a875c9b 100644
--- a/app/src/components/JoinDialog.js
+++ b/app/src/components/JoinDialog.js
@@ -303,24 +303,27 @@ const JoinDialog = ({
};
// TODO: prefix with the Edumeet server HTTP endpoint
- fetch('/auth/check_login_status', {
- credentials : 'include',
- method : 'GET',
- cache : 'no-cache',
- redirect : 'follow',
- referrerPolicy : 'no-referrer' })
- .then((response) => response.json())
- .then((json) =>
- {
- if (json.loggedIn)
+ if (config.loginEnabled)
+ {
+ fetch('/auth/check_login_status', {
+ credentials : 'include',
+ method : 'GET',
+ cache : 'no-cache',
+ redirect : 'follow',
+ referrerPolicy : 'no-referrer' })
+ .then((response) => response.json())
+ .then((json) =>
{
- roomClient.setLoggedIn(json.loggedIn);
- }
- })
- .catch((error) =>
- {
- logger.error('Error checking login status', error);
- });
+ if (json.loggedIn)
+ {
+ roomClient.setLoggedIn(json.loggedIn);
+ }
+ })
+ .catch((error) =>
+ {
+ logger.error('Error checking login status', error);
+ });
+ }
return (
diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListMe.js b/app/src/components/MeetingDrawer/ParticipantList/ListMe.js
index a3c7825a..06fd6000 100644
--- a/app/src/components/MeetingDrawer/ParticipantList/ListMe.js
+++ b/app/src/components/MeetingDrawer/ParticipantList/ListMe.js
@@ -1,19 +1,41 @@
-import React from 'react';
+import React, { useState } from 'react';
import { connect } from 'react-redux';
import { withStyles } from '@material-ui/core/styles';
import { withRoomContext } from '../../../RoomContext';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import * as appPropTypes from '../../appPropTypes';
-import { useIntl } from 'react-intl';
+import { useIntl, FormattedMessage } from 'react-intl';
import IconButton from '@material-ui/core/IconButton';
import Tooltip from '@material-ui/core/Tooltip';
import PanIcon from '@material-ui/icons/PanTool';
+import Button from '@material-ui/core/Button';
import EmptyAvatar from '../../../images/avatar-empty.jpeg';
+import TextField from '@material-ui/core/TextField';
+import { showIframeSelect, makePermissionSelector } from '../../../store/selectors';
+import { permissions } from '../../../permissions';
+import { config } from '../../../config';
+
+const urlPattern = new RegExp(
+ '^(https?:\\/\\/)?' +
+ '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' +
+ '((\\d{1,3}\\.){3}\\d{1,3}))' +
+ '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' +
+ '(\\?[;&a-z\\d%_.~+=-]*)?' +
+ '(\\#[-a-z\\d_]*)?$',
+ 'i'
+);
const styles = (theme) =>
({
root :
+ {
+ display : 'flex',
+ flexDirection : 'column',
+ width : '100%',
+ overflowY : 'auto'
+ },
+ me :
{
width : '100%',
overflow : 'hidden',
@@ -48,70 +70,163 @@ const styles = (theme) =>
const ListMe = (props) =>
{
- const intl = useIntl();
-
const {
roomClient,
me,
+ iframeUrl,
+ toggleIframeInProgress,
+ hasScreenPermission,
settings,
classes
} = props;
+ const intl = useIntl();
+
+ const [ currentUrl, setCurrentUrl ] = useState('');
+
+ const validateUrl = () =>
+ {
+ if (currentUrl === '')
+ return false;
+
+ if (!urlPattern.test(currentUrl))
+ return false;
+
+ if (!currentUrl.toLowerCase().startsWith('https://'))
+ return false;
+
+ return true;
+ };
+
+ const isValidUrl = validateUrl();
+
const picture = me.picture || EmptyAvatar;
return (
-

-
-
- {settings.displayName}
-
-
-
+
+
+ {settings.displayName}
+
+
- {
- e.stopPropagation();
+ placement='bottom'
+ >
+
+ {
+ e.stopPropagation();
+ roomClient.setRaisedHand(!me.raisedHand);
+ }}
+ >
+
+
+
+
- roomClient.setRaisedHand(!me.raisedHand);
+ {hasScreenPermission &&
+
+ setCurrentUrl(event.target.value.trim())}
+ fullWidth
+ />
+ {iframeUrl &&
+
+ }
+ {!iframeUrl &&
+
+ }
+
+ }
);
};
ListMe.propTypes =
{
- roomClient : PropTypes.object.isRequired,
- me : appPropTypes.Me.isRequired,
- settings : PropTypes.object.isRequired,
- classes : PropTypes.object.isRequired
+ roomClient : PropTypes.object.isRequired,
+ me : appPropTypes.Me.isRequired,
+ iframeUrl : PropTypes.string,
+ toggleIframeInProgress : PropTypes.bool,
+ hasScreenPermission : PropTypes.bool.isRequired,
+ settings : PropTypes.object.isRequired,
+ classes : PropTypes.object.isRequired
};
-const mapStateToProps = (state) => ({
- me : state.me,
- settings : state.settings
-});
+const makeMapStateToProps = () =>
+{
+ const canShareScreen =
+ makePermissionSelector(permissions.SHARE_SCREEN);
+
+ const mapStateToProps = (state) => ({
+ me : state.me,
+ iframeUrl : showIframeSelect(state),
+ toggleIframeInProgress : state.room.toggleIframeInProgress,
+ hasScreenPermission : canShareScreen(state),
+ settings : state.settings
+ });
+
+ return mapStateToProps;
+};
export default withRoomContext(connect(
- mapStateToProps,
+ makeMapStateToProps,
null,
null,
{
@@ -119,6 +234,8 @@ export default withRoomContext(connect(
{
return (
prev.me === next.me &&
+ prev.room.iframeUrl === next.room.iframeUrl &&
+ prev.room.toggleIframeInProgress === next.room.toggleIframeInProgress &&
prev.settings === next.settings
);
}
diff --git a/app/src/components/MeetingViews/Democratic.js b/app/src/components/MeetingViews/Democratic.js
index 7452be94..2497d950 100644
--- a/app/src/components/MeetingViews/Democratic.js
+++ b/app/src/components/MeetingViews/Democratic.js
@@ -243,6 +243,7 @@ export default connect(
prev.room.spotlights === next.room.spotlights &&
prev.room.toolbarsVisible === next.room.toolbarsVisible &&
prev.room.hideSelfView === next.room.hideSelfView &&
+ prev.room.iframeUrl === next.room.iframeUrl &&
prev.settings.permanentTopBar === next.settings.permanentTopBar &&
prev.settings.buttonControlBar === next.settings.buttonControlBar &&
prev.settings.aspectRatio === next.settings.aspectRatio &&
diff --git a/app/src/components/MeetingViews/Filmstrip.js b/app/src/components/MeetingViews/Filmstrip.js
index 9bfcaabd..58e9c557 100644
--- a/app/src/components/MeetingViews/Filmstrip.js
+++ b/app/src/components/MeetingViews/Filmstrip.js
@@ -407,6 +407,7 @@ export default withRoomContext(connect(
prev.room.selectedPeers === next.room.selectedPeers &&
prev.room.toolbarsVisible === next.room.toolbarsVisible &&
prev.room.hideSelfView === next.room.hideSelfView &&
+ prev.room.iframeUrl === next.room.iframeUrl &&
prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen &&
prev.settings.permanentTopBar === next.settings.permanentTopBar &&
prev.settings.aspectRatio === next.settings.aspectRatio &&
diff --git a/app/src/components/VideoContainers/VideoView.js b/app/src/components/VideoContainers/VideoView.js
index 7d7f3969..b02c3cdc 100644
--- a/app/src/components/VideoContainers/VideoView.js
+++ b/app/src/components/VideoContainers/VideoView.js
@@ -10,6 +10,10 @@ import SignalCellular0BarIcon from '@material-ui/icons/SignalCellular0Bar';
import SignalCellular1BarIcon from '@material-ui/icons/SignalCellular1Bar';
import SignalCellular2BarIcon from '@material-ui/icons/SignalCellular2Bar';
import SignalCellular3BarIcon from '@material-ui/icons/SignalCellular3Bar';
+import Iframe from 'react-iframe';
+import IconButton from '@material-ui/core/IconButton';
+import FullscreenIcon from '@material-ui/icons/Fullscreen';
+import ReplayIcon from '@material-ui/icons/Replay';
import { AudioAnalyzer } from './AudioAnalyzer';
const logger = new Logger('VideoView');
@@ -172,6 +176,27 @@ const styles = (theme) =>
{
backgroundColor : 'rgb(174, 255, 0, 0.25)'
}
+ },
+ iframeContainer :
+ {
+ display : 'flex',
+ flexFlow : 'column',
+ height : '100%',
+ background : 'white'
+ },
+ iframeMenu :
+ {
+ background : 'white'
+ },
+ iframeMain :
+ {
+ flex : 1,
+ overflow : 'auto',
+ border : 0
+ },
+ iframeButtons :
+ {
+ padding : theme.spacing(1)
}
});
@@ -206,6 +231,8 @@ class VideoView extends React.PureComponent
{
const {
isMe,
+ isIframe,
+ iframeUrl,
isMirrored,
isScreen,
isExtraVideo,
@@ -305,6 +332,7 @@ class VideoView extends React.PureComponent
return (
+ { !isIframe &&
{(audioCodec || videoCodec) &&
@@ -448,7 +476,8 @@ class VideoView extends React.PureComponent
}
-
+ }
+ {!isIframe &&
+ }
+ {isIframe &&
+
+
+
+
+ {
+ const elem = document.getElementById('iframe_iframe');
+ if (elem.requestFullscreen)
+ {
+ elem.requestFullscreen();
+ }
+ else if (elem.mozRequestFullScreen)
+ { /* Firefox */
+ elem.mozRequestFullScreen();
+ }
+ else if (elem.webkitRequestFullscreen)
+ { /* Chrome, Safari and Opera */
+ elem.webkitRequestFullscreen();
+ }
+ else if (elem.msRequestFullscreen)
+ { /* IE/Edge */
+ elem.msRequestFullscreen();
+ }
+ }}
+ >
+
+
+
+ {
+ const elem = document.getElementById('iframe_iframe');
+
+ elem.src = iframeUrl;
+ }}
+ >
+
+
+
+
+
+
+ }
{children}
);
@@ -481,6 +563,8 @@ class VideoView extends React.PureComponent
componentDidMount()
{
+ if (this.props.isIframe) return;
+
const { videoTrack, audioTrack, showAudioAnalyzer } = this.props;
this._setTracks(videoTrack);
@@ -499,6 +583,8 @@ class VideoView extends React.PureComponent
componentWillUnmount()
{
+ if (this.props.isIframe) return;
+
clearInterval(this._videoResolutionTimer);
const { videoElement } = this.refs;
@@ -519,6 +605,8 @@ class VideoView extends React.PureComponent
componentDidUpdate(prevProps)
{
+ if (this.props.isIframe) return;
+
if (prevProps !== this.props)
{
const { videoTrack, audioTrack, showAudioAnalyzer } = this.props;
@@ -636,6 +724,8 @@ VideoView.propTypes =
isMe : PropTypes.bool,
isMirrored : PropTypes.bool,
isScreen : PropTypes.bool,
+ isIframe : PropTypes.bool,
+ iframeUrl : PropTypes.string,
isExtraVideo : PropTypes.bool,
showQuality : PropTypes.bool,
showAudioAnalyzer : PropTypes.bool,
diff --git a/app/src/store/actions/roomActions.js b/app/src/store/actions/roomActions.js
index 1dc0fb95..0f3403d0 100644
--- a/app/src/store/actions/roomActions.js
+++ b/app/src/store/actions/roomActions.js
@@ -142,6 +142,23 @@ export const addSelectedPeer = (peerId) =>
payload : { peerId }
});
+export const openIframe = (iframeUrl) =>
+ ({
+ type : 'OPEN_IFRAME',
+ payload : { iframeUrl }
+ });
+
+export const setToggleIframeInProgress = (flag) =>
+ ({
+ type : 'SET_TOGGLE_IFRAME_IN_PROGRESS',
+ payload : { flag }
+ });
+
+export const closeIframe = () =>
+ ({
+ type : 'CLOSE_IFRAME'
+ });
+
export const removeSelectedPeer = (peerId) =>
({
type : 'REMOVE_SELECTED_PEER',
diff --git a/app/src/store/reducers/room.js b/app/src/store/reducers/room.js
index f03fa93f..a5cb5d6b 100644
--- a/app/src/store/reducers/room.js
+++ b/app/src/store/reducers/room.js
@@ -26,6 +26,8 @@ const initialState =
settingsOpen : false,
extraVideoOpen : false,
hideSelfView : false,
+ iframeUrl : null,
+ toggleIframeInProgress : false,
rolesManagerOpen : false,
helpOpen : false,
aboutOpen : false,
@@ -309,6 +311,23 @@ const room = (state = initialState, action) =>
return { ...state, hideSelfView };
}
+ case 'OPEN_IFRAME':
+ {
+ const { iframeUrl } = action.payload;
+
+ return { ...state, iframeUrl };
+ }
+
+ case 'CLOSE_IFRAME':
+ {
+ const iframeUrl = null;
+
+ return { ...state, iframeUrl };
+ }
+
+ case 'SET_TOGGLE_IFRAME_IN_PROGRESS':
+ return { ...state, toggleIframeInProgress: action.payload.flag };
+
default:
return state;
}
diff --git a/app/src/store/selectors.js b/app/src/store/selectors.js
index bcf79169..f1e85cbe 100644
--- a/app/src/store/selectors.js
+++ b/app/src/store/selectors.js
@@ -20,6 +20,8 @@ const peersKeySelector = createSelector(
(peers) => Object.keys(peers)
);
+export const showIframeSelect = (state) => state.room.iframeUrl;
+
export const peersValueSelector = createSelector(
peersSelector,
(peers) => Object.values(peers)
@@ -186,6 +188,7 @@ export const raisedHandsSelector = createSelector(
export const videoBoxesSelector = createSelector(
isHiddenSelect,
+ showIframeSelect,
spotlightsLengthSelector,
screenProducersSelector,
spotlightScreenConsumerSelector,
@@ -193,6 +196,7 @@ export const videoBoxesSelector = createSelector(
spotlightExtraVideoConsumerSelector,
(
isHidden,
+ iframeUrl,
spotlightsLength,
screenProducers,
screenConsumers,
@@ -202,7 +206,8 @@ export const videoBoxesSelector = createSelector(
{
return spotlightsLength + (isHidden ? 0 : 1) +
(isHidden ? 0 : screenProducers.length) + screenConsumers.length +
- (isHidden ? 0 : extraVideoProducers.length) + extraVideoConsumers.length;
+ (isHidden ? 0 : extraVideoProducers.length) + extraVideoConsumers.length +
+ (isHidden ? 0 : (iframeUrl ? 1 : 0));
}
);
diff --git a/server/lib/Room.js b/server/lib/Room.js
index 23426891..bf0b05ac 100644
--- a/server/lib/Room.js
+++ b/server/lib/Room.js
@@ -262,6 +262,8 @@ class Room extends EventEmitter
this._fileHistory = [];
+ this._iframeHistory = null;
+
this._lastN = [];
this._peers = {};
@@ -310,6 +312,8 @@ class Room extends EventEmitter
this._fileHistory = null;
+ this._iframeHistory = null;
+
this._lobby.close();
this._lobby = null;
@@ -923,6 +927,7 @@ class Room extends EventEmitter
chatHistory : this._chatHistory,
fileHistory : this._fileHistory,
lastNHistory : this._lastN,
+ iframeHistory : this._iframeHistory,
locked : this._locked,
lobbyPeers : lobbyPeers,
accessCode : this._accessCode
@@ -1770,6 +1775,57 @@ class Room extends EventEmitter
break;
}
+ case 'toggleIframe':
+ {
+ if (!peer.joined)
+ throw new Error('Peer not yet joined');
+
+ if (!this._hasPermission(peer, SHARE_SCREEN))
+ throw new Error('Peer not authorized to toggle external app');
+
+ const { iframeUrl } = request.data;
+
+ if (iframeUrl && this._iframeHistory)
+ throw new Error('External app already opened');
+
+ if (!iframeUrl)
+ {
+ this._iframeHistory = null;
+
+ // Spread to others and self
+ this._notification(peer.socket, 'closeIframe', null, true, true);
+ }
+ else
+ {
+
+ const pattern = new RegExp(
+ '^(https?:\\/\\/)?' +
+ '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' +
+ '((\\d{1,3}\\.){3}\\d{1,3}))' +
+ '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' +
+ '(\\?[;&a-z\\d%_.~+=-]*)?' +
+ '(\\#[-a-z\\d_]*)?$',
+ 'i'
+ );
+
+ if (!pattern.test(iframeUrl))
+ throw new Error('Not a valid url for external app');
+
+ if (!iframeUrl.toLowerCase().startsWith('https://'))
+ throw new Error('Only https allowed for external apps');
+
+ this._iframeHistory = iframeUrl;
+
+ // Spread to others and self
+ this._notification(peer.socket, 'showIframe',
+ { iframeUrl }, true, true);
+ }
+ // Return no error
+ cb();
+
+ break;
+ }
+
case 'moderator:stopAllScreenSharing':
{
if (!this._hasPermission(peer, MODERATE_ROOM))
diff --git a/server/package.json b/server/package.json
index 8ea18f86..442a9d92 100644
--- a/server/package.json
+++ b/server/package.json
@@ -27,24 +27,25 @@
"axios": "^0.21.1",
"base-64": "^0.1.0",
"bcrypt": "^5.0.1",
- "body-parser": "^1.19.0",
+ "body-parser": "^1.20.1",
"colors": "^1.4.0",
"compression": "^1.7.4",
"connect-redis": "^4.0.3",
- "convict": "^6.1.0",
+ "convict": "^6.2.3",
"convict-format-with-validator": "^6.0.1",
"cookie-parser": "^1.4.4",
"debug": "^4.3.1",
- "express": "^4.17.1",
- "express-session": "^1.17.0",
+ "express": "^4.18.2",
+ "express-session": "^1.17.3",
"express-socket.io-session": "^1.3.5",
"fast-stats": "^0.0.6",
"helmet": "^3.21.2",
"ims-lti": "^3.0.2",
- "json5": "^2.2.0",
- "jsonwebtoken": "^8.5.1",
- "mediasoup": "3.10.5",
- "openid-client": "^3.7.3",
+ "json5": "^2.2.2",
+ "jsonwebtoken": "^9.0.0",
+ "mediasoup": "3.12.12",
+ "openid-client": "^5.3.1",
+ "os-utils": "^0.0.14",
"passport": "^0.4.0",
"passport-local": "^1.0.0",
"passport-lti": "0.0.7",
@@ -52,7 +53,7 @@
"pidusage": "^2.0.21",
"prom-client": "^13.1.0",
"redis": "v3",
- "socket.io": "^2.4.0",
+ "socket.io": "^4.5.4",
"spdy": "^4.0.2",
"toml": "^3.0.0",
"uuid": "^7.0.2",
@@ -75,7 +76,7 @@
"@typescript-eslint/eslint-plugin": "^4.21.0",
"@typescript-eslint/parser": "^4.21.0",
"eslint": "6.8.0",
- "eslint-plugin-import": "^2.22.1",
+ "eslint-plugin-import": "^2.26.0",
"ts-node": "^9.1.1",
"typescript": "^4.2.4"
}