mirror of
https://github.com/OpenNebula/one.git
synced 2025-03-21 14:50:08 +03:00
(cherry picked from commit f0e093f49e36b4fe287d78a47fe2750923b1755a)
This commit is contained in:
parent
248422cb50
commit
7e068a886a
@ -80,7 +80,6 @@ export default function ansiHTML(text) {
|
||||
if (ot) {
|
||||
// If current sequence has been opened, close it.
|
||||
if (~ansiCodes.indexOf(seq)) {
|
||||
// eslint-disable-line no-extra-boolean-cast
|
||||
ansiCodes.pop()
|
||||
|
||||
return '</span>'
|
||||
|
@ -87,7 +87,6 @@ const Content = ({ data, setFormData }) => {
|
||||
...section,
|
||||
name,
|
||||
label: <Translate word={name} />,
|
||||
// eslint-disable-next-line react/display-name
|
||||
renderContent: () => (
|
||||
<TabContent {...{ data, setFormData, hypervisor, control }} />
|
||||
),
|
||||
|
@ -51,6 +51,7 @@ const AuthLayout = ({ subscriptions = [], children }) => {
|
||||
return () => {
|
||||
endpoints.forEach((endpoint) => {
|
||||
endpoint.unsubscribe()
|
||||
endpoint.abort()
|
||||
})
|
||||
}
|
||||
}, [dispatch, jwt])
|
||||
|
@ -39,7 +39,7 @@ import { sentenceCase } from 'client/utils'
|
||||
const Header = () => {
|
||||
const { isOneAdmin } = useAuth()
|
||||
const { fixMenu } = useGeneralApi()
|
||||
const { appTitle, title, isBeta } = useGeneral()
|
||||
const { appTitle, title, isBeta, withGroupSwitcher } = useGeneral()
|
||||
const appAsSentence = useMemo(() => sentenceCase(appTitle), [appTitle])
|
||||
|
||||
return (
|
||||
@ -104,7 +104,7 @@ const Header = () => {
|
||||
>
|
||||
<User />
|
||||
<View />
|
||||
{!isOneAdmin && <Group />}
|
||||
{!isOneAdmin && withGroupSwitcher && <Group />}
|
||||
<Zone />
|
||||
</Stack>
|
||||
</Toolbar>
|
||||
|
@ -30,6 +30,7 @@ export const BY = {
|
||||
export const _APPS = defaultApps
|
||||
export const APPS = Object.keys(defaultApps)
|
||||
export const APPS_IN_BETA = [_APPS.sunstone.name]
|
||||
export const APPS_WITH_SWITCHER = [_APPS.sunstone.name]
|
||||
export const APP_URL = defaultAppName ? `/${defaultAppName}` : ''
|
||||
export const WEBSOCKET_URL = `${APP_URL}/websockets`
|
||||
export const STATIC_FILES_URL = `${APP_URL}/client/assets`
|
||||
@ -52,6 +53,7 @@ export const LANGUAGES_URL = `${STATIC_FILES_URL}/languages`
|
||||
|
||||
export const ONEADMIN_ID = '0'
|
||||
export const SERVERADMIN_ID = '1'
|
||||
export const ONEADMIN_GROUP_ID = '0'
|
||||
|
||||
export const FILTER_POOL = {
|
||||
PRIMARY_GROUP_RESOURCES: '-4',
|
||||
|
@ -197,6 +197,8 @@ module.exports = {
|
||||
|
||||
/* errors */
|
||||
SessionExpired: 'Sorry, your session has expired',
|
||||
OnlyForOneadminGroup:
|
||||
'Only members of the oneadmin group can access OneProvision functionality',
|
||||
SomethingWrong: 'Something go wrong',
|
||||
CannotConnectOneFlow: 'Cannot connect to OneFlow server',
|
||||
CannotConnectOneProvision: 'Cannot connect to OneProvision server',
|
||||
|
@ -44,9 +44,9 @@ function Login() {
|
||||
const classes = loginStyles()
|
||||
const isMobile = useMediaQuery((theme) => theme.breakpoints.only('xs'))
|
||||
|
||||
const { logout } = useAuthApi()
|
||||
const { error: otherError, isLoginInProgress: needGroupToContinue } =
|
||||
useAuth()
|
||||
const { logout } = useAuthApi()
|
||||
|
||||
const [changeAuthGroup, changeAuthGroupState] = useChangeAuthGroupMutation()
|
||||
const [login, loginState] = useLoginMutation()
|
||||
|
@ -1,51 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
|
||||
* *
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
|
||||
* not use this file except in compliance with the License. You may obtain *
|
||||
* a copy of the License at *
|
||||
* *
|
||||
* http://www.apache.org/licenses/LICENSE-2.0 *
|
||||
* *
|
||||
* Unless required by applicable law or agreed to in writing, software *
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, *
|
||||
* 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 { ReactElement } from 'react'
|
||||
|
||||
import SunstoneApp from 'client/apps/sunstone'
|
||||
import ProvisionApp from 'client/apps/provision'
|
||||
import LoadingScreen from 'client/components/LoadingScreen'
|
||||
|
||||
import { isDevelopment, isBackend } from 'client/utils'
|
||||
import { _APPS, APPS } from 'client/constants'
|
||||
|
||||
/**
|
||||
* Render App by url: http://<host:port>/fireedge/<APP>.
|
||||
*
|
||||
* @param {object} props - Props from server
|
||||
* @returns {ReactElement} Returns App
|
||||
*/
|
||||
const DevelopmentApp = (props) => {
|
||||
let appName = ''
|
||||
|
||||
if (isDevelopment() && !isBackend()) {
|
||||
appName = window.location.pathname
|
||||
.split(/\//gi)
|
||||
.filter((sub) => sub?.length > 0)
|
||||
.find((resource) => APPS.includes(resource))
|
||||
}
|
||||
|
||||
return (
|
||||
{
|
||||
[_APPS.provision.name]: <ProvisionApp {...props} />,
|
||||
[_APPS.sunstone.name]: <SunstoneApp {...props} />,
|
||||
}[appName] ?? <LoadingScreen />
|
||||
)
|
||||
}
|
||||
|
||||
DevelopmentApp.displayName = 'DevelopmentApp'
|
||||
|
||||
export default DevelopmentApp
|
@ -1,23 +0,0 @@
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
|
||||
* *
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
|
||||
* not use this file except in compliance with the License. You may obtain *
|
||||
* a copy of the License at *
|
||||
* *
|
||||
* http://www.apache.org/licenses/LICENSE-2.0 *
|
||||
* *
|
||||
* Unless required by applicable law or agreed to in writing, software *
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, *
|
||||
* 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 { render } from 'react-dom'
|
||||
|
||||
import { createStore } from 'client/store'
|
||||
import App from 'client/dev/_app'
|
||||
|
||||
const { store } = createStore({ initState: window.__PRELOADED_STATE__ })
|
||||
|
||||
render(<App store={store} />, document.getElementById('root'))
|
@ -13,39 +13,45 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
import { createApi } from '@reduxjs/toolkit/query/react'
|
||||
|
||||
import { Actions, Commands } from 'server/routes/api/auth/routes'
|
||||
import { dismissSnackbar } from 'client/features/General/actions'
|
||||
import { actions } from 'client/features/Auth/slice'
|
||||
import userApi from 'client/features/OneApi/user'
|
||||
|
||||
import { storage } from 'client/utils'
|
||||
import { APP_URL, JWT_NAME, FILTER_POOL, ONEADMIN_ID } from 'client/constants'
|
||||
import http from 'client/utils/rest'
|
||||
import { requestConfig, storage } from 'client/utils'
|
||||
import { JWT_NAME, FILTER_POOL, ONEADMIN_ID } from 'client/constants'
|
||||
|
||||
const { ALL_RESOURCES, PRIMARY_GROUP_RESOURCES } = FILTER_POOL
|
||||
|
||||
const authApi = createApi({
|
||||
reducerPath: 'authApi',
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: `${APP_URL}/api/`,
|
||||
prepareHeaders: (headers, { getState }) => {
|
||||
const token = getState().auth.jwt
|
||||
baseQuery: async ({ params, command, needState }, { getState, signal }) => {
|
||||
try {
|
||||
const config = requestConfig(params, command)
|
||||
const response = await http.request({ ...config, signal })
|
||||
const state = needState ? getState() : {}
|
||||
|
||||
// If we have a token set in state,
|
||||
// let's assume that we should be passing it.
|
||||
token && headers.set('authorization', `Bearer ${token}`)
|
||||
return { data: response.data ?? {}, meta: { state } }
|
||||
} catch (axiosError) {
|
||||
const { message, data = {}, status, statusText } = axiosError
|
||||
const { message: messageFromServer, data: errorFromOned } = data
|
||||
|
||||
return headers
|
||||
},
|
||||
}),
|
||||
const error = message ?? errorFromOned ?? messageFromServer ?? statusText
|
||||
|
||||
return { error: { status: status, data: error } }
|
||||
}
|
||||
},
|
||||
endpoints: (builder) => ({
|
||||
getAuthUser: builder.query({
|
||||
/**
|
||||
* @returns {object} Information about authenticated user
|
||||
* @throws Fails when response isn't code 200
|
||||
*/
|
||||
query: () => ({ url: 'user/info' }),
|
||||
transformResponse: (response) => response?.data?.USER,
|
||||
query: () => ({ command: { path: '/user/info' } }),
|
||||
transformResponse: (response) => response?.USER,
|
||||
async onQueryStarted(_, { queryFulfilled, dispatch }) {
|
||||
try {
|
||||
const { data: user } = await queryFulfilled
|
||||
@ -55,23 +61,31 @@ const authApi = createApi({
|
||||
}),
|
||||
login: builder.mutation({
|
||||
/**
|
||||
* @param {object} data - User credentials
|
||||
* @param {string} data.user - Username
|
||||
* @param {string} data.token - Password
|
||||
* @param {boolean} [data.remember] - Remember session
|
||||
* @param {string} [data.token2fa] - Token for Two factor authentication
|
||||
* Login in the interface.
|
||||
*
|
||||
* @param {object} params - User credentials
|
||||
* @param {string} params.user - Username
|
||||
* @param {string} params.token - Password
|
||||
* @param {boolean} [params.remember] - Remember session
|
||||
* @param {string} [params.token2fa] - Token for Two factor authentication
|
||||
* @returns {object} Response data from request
|
||||
* @throws Fails when response isn't code 200
|
||||
*/
|
||||
query: (data) => ({ url: 'auth', method: 'POST', body: data }),
|
||||
transformResponse: (response) => {
|
||||
const { id, token } = response?.data
|
||||
query: (params) => {
|
||||
const name = Actions.AUTHENTICATION
|
||||
const command = { name, ...Commands[name] }
|
||||
|
||||
return { params, command, needState: true }
|
||||
},
|
||||
transformResponse: (response, meta) => {
|
||||
const { id, token } = response
|
||||
const { withGroupSwitcher } = meta?.state?.general ?? {}
|
||||
const isOneAdmin = id === ONEADMIN_ID
|
||||
|
||||
return {
|
||||
jwt: token,
|
||||
user: { ID: id },
|
||||
isLoginInProgress: !!token && !isOneAdmin,
|
||||
isLoginInProgress: withGroupSwitcher && !!token && !isOneAdmin,
|
||||
}
|
||||
},
|
||||
async onQueryStarted({ remember }, { queryFulfilled, dispatch }) {
|
||||
|
@ -18,13 +18,14 @@ import { createSlice } from '@reduxjs/toolkit'
|
||||
import { actions as authActions } from 'client/features/Auth/slice'
|
||||
import * as actions from 'client/features/General/actions'
|
||||
import { generateKey } from 'client/utils'
|
||||
import { APPS_IN_BETA } from 'client/constants'
|
||||
import { APPS_IN_BETA, APPS_WITH_SWITCHER } from 'client/constants'
|
||||
|
||||
const initial = {
|
||||
zone: 0,
|
||||
title: null,
|
||||
appTitle: null,
|
||||
isBeta: false,
|
||||
withGroupSwitcher: false,
|
||||
isLoading: false,
|
||||
isFixMenu: false,
|
||||
|
||||
@ -56,10 +57,12 @@ const { name, reducer } = createSlice({
|
||||
...state,
|
||||
title: payload,
|
||||
}))
|
||||
.addCase(actions.changeAppTitle, (state, { payload }) => {
|
||||
const isBeta = APPS_IN_BETA?.includes(String(payload).toLowerCase())
|
||||
.addCase(actions.changeAppTitle, (state, { payload: appTitle }) => {
|
||||
const lowerAppTitle = String(appTitle).toLowerCase()
|
||||
const isBeta = APPS_IN_BETA?.includes(lowerAppTitle)
|
||||
const withGroupSwitcher = APPS_WITH_SWITCHER?.includes(lowerAppTitle)
|
||||
|
||||
return { ...state, appTitle: payload, isBeta }
|
||||
return { ...state, appTitle, isBeta, withGroupSwitcher }
|
||||
})
|
||||
.addCase(actions.changeZone, (state, { payload }) => ({
|
||||
...state,
|
||||
|
@ -99,12 +99,7 @@ const oneApi = createApi({
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
error: {
|
||||
status: status,
|
||||
data: message ?? data?.data ?? statusText,
|
||||
},
|
||||
}
|
||||
return { error: { status: status, data: error } }
|
||||
}
|
||||
},
|
||||
refetchOnMountOrArgChange: 30,
|
||||
|
@ -14,8 +14,9 @@
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { isRejectedWithValue, Middleware, Dispatch } from '@reduxjs/toolkit'
|
||||
import { actions } from 'client/features/Auth/slice'
|
||||
import { T } from 'client/constants'
|
||||
|
||||
import * as Auth from 'client/features/Auth/slice'
|
||||
import { T, ONEADMIN_GROUP_ID } from 'client/constants'
|
||||
|
||||
/**
|
||||
* @param {{ dispatch: Dispatch }} params - Redux parameters
|
||||
@ -26,7 +27,27 @@ export const unauthenticatedMiddleware =
|
||||
(next) =>
|
||||
(action) => {
|
||||
if (isRejectedWithValue(action) && action.payload.status === 401) {
|
||||
dispatch(actions.logout(T.SessionExpired))
|
||||
dispatch(Auth.actions.logout(T.SessionExpired))
|
||||
}
|
||||
|
||||
return next(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ dispatch: Dispatch, getState: function():object }} params - Redux parameters
|
||||
* @returns {Middleware} - Middleware to logout when user isn't in oneadmin group
|
||||
*/
|
||||
export const onlyForOneadminMiddleware =
|
||||
({ dispatch, getState }) =>
|
||||
(next) =>
|
||||
(action) => {
|
||||
const groups = getState()?.[Auth.name]?.user?.GROUPS?.ID
|
||||
|
||||
if (!Auth.actions.logout.match(action) && groups) {
|
||||
const ensuredGroups = [groups].flat()
|
||||
|
||||
!ensuredGroups.includes(ONEADMIN_GROUP_ID) &&
|
||||
dispatch(Auth.actions.logout(T.OnlyForOneadminGroup))
|
||||
}
|
||||
|
||||
return next(action)
|
||||
|
@ -17,8 +17,12 @@ import { hydrate, render } from 'react-dom'
|
||||
|
||||
import { createStore } from 'client/store'
|
||||
import App from 'client/apps/provision'
|
||||
import { onlyForOneadminMiddleware } from 'client/features/middleware'
|
||||
|
||||
const { store } = createStore({ initState: window.__PRELOADED_STATE__ })
|
||||
const { store } = createStore({
|
||||
initState: window.__PRELOADED_STATE__,
|
||||
extraMiddleware: [onlyForOneadminMiddleware],
|
||||
})
|
||||
|
||||
const rootHTML = document.getElementById('root')?.innerHTML
|
||||
const renderMethod = rootHTML !== '' ? hydrate : render
|
||||
|
@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and *
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
import { configureStore, EnhancedStore } from '@reduxjs/toolkit'
|
||||
import { configureStore, Middleware, EnhancedStore } from '@reduxjs/toolkit'
|
||||
import { setupListeners } from '@reduxjs/toolkit/query/react'
|
||||
|
||||
import { isDevelopment } from 'client/utils'
|
||||
@ -27,9 +27,10 @@ import { unauthenticatedMiddleware } from 'client/features/middleware'
|
||||
/**
|
||||
* @param {object} props - Props
|
||||
* @param {object} props.initState - Initial state
|
||||
* @param {Middleware[]} props.extraMiddleware - Extra middleware to apply on store
|
||||
* @returns {{ store: EnhancedStore }} Configured Redux Store
|
||||
*/
|
||||
export const createStore = ({ initState = {} }) => {
|
||||
export const createStore = ({ initState = {}, extraMiddleware = [] }) => {
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
[Auth.name]: Auth.reducer,
|
||||
@ -42,6 +43,7 @@ export const createStore = ({ initState = {} }) => {
|
||||
getDefaultMiddleware({
|
||||
immutableCheck: true,
|
||||
}).concat([
|
||||
...extraMiddleware,
|
||||
unauthenticatedMiddleware,
|
||||
authApi.middleware,
|
||||
oneApi.middleware,
|
||||
|
@ -17,10 +17,11 @@
|
||||
const {
|
||||
httpMethod,
|
||||
from: fromData,
|
||||
} = require('server/utils/constants/defaults')
|
||||
} = require('../../../utils/constants/defaults')
|
||||
|
||||
const { POST } = httpMethod
|
||||
const { postBody } = fromData
|
||||
|
||||
const basepath = '/auth'
|
||||
const AUTHENTICATION = 'authentication'
|
||||
|
||||
|
@ -45,32 +45,24 @@ languages.map((language) =>
|
||||
const router = Router()
|
||||
|
||||
router.get('*', (req, res) => {
|
||||
let app = 'dev'
|
||||
let title = 'FireEdge'
|
||||
const defaultTitle = 'FireEdge'
|
||||
const context = {}
|
||||
let store = ''
|
||||
let component = ''
|
||||
let css = ''
|
||||
let storeRender = ''
|
||||
|
||||
// production
|
||||
if (
|
||||
env &&
|
||||
(!env.NODE_ENV || (env.NODE_ENV && env.NODE_ENV !== defaultWebpackMode))
|
||||
) {
|
||||
const apps = Object.keys(defaultApps)
|
||||
const parseUrl = req.url
|
||||
.split(/\//gi)
|
||||
.filter((sub) => sub && sub.length > 0)
|
||||
const isProduction =
|
||||
!env?.NODE_ENV || (env?.NODE_ENV && env?.NODE_ENV !== defaultWebpackMode)
|
||||
|
||||
parseUrl.forEach((element) => {
|
||||
if (element && apps.includes(element)) {
|
||||
app = element
|
||||
title = element
|
||||
}
|
||||
})
|
||||
const apps = Object.keys(defaultApps)
|
||||
const appName = req.url
|
||||
.split(/\//gi)
|
||||
.filter((sub) => sub?.length > 0)
|
||||
.find((resource) => apps.includes(resource))
|
||||
|
||||
const App = require(`../../../client/apps/${app}/index.js`).default
|
||||
if (isProduction) {
|
||||
const App = require(`../../../client/apps/${appName}/index.js`).default
|
||||
const sheets = new ServerStyleSheets()
|
||||
const composeEnhancer =
|
||||
(root && root.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose
|
||||
@ -92,11 +84,11 @@ router.get('*', (req, res) => {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>${upperCaseFirst(title)} by OpenNebula</title>
|
||||
<link rel="icon" type="image/png" href="${STATIC_FILES_URL}/favicon/${app}/favicon.ico">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="${STATIC_FILES_URL}/favicon/${app}/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="${STATIC_FILES_URL}/favicon/${app}/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="${STATIC_FILES_URL}/favicon/${app}/favicon-16x16.png">
|
||||
<title>${upperCaseFirst(appName ?? defaultTitle)} by OpenNebula</title>
|
||||
<link rel="icon" type="image/png" href="${STATIC_FILES_URL}/favicon/${appName}/favicon.ico">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="${STATIC_FILES_URL}/favicon/${appName}/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="${STATIC_FILES_URL}/favicon/${appName}/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="${STATIC_FILES_URL}/favicon/${appName}/favicon-16x16.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
@ -106,7 +98,11 @@ router.get('*', (req, res) => {
|
||||
<div id="root">${component}</div>
|
||||
${storeRender}
|
||||
<script>${`langs = ${JSON.stringify(scriptLanguages)}`}</script>
|
||||
<script src='${APP_URL}/client/bundle.${app}.js'></script>
|
||||
${
|
||||
isProduction
|
||||
? `<script src='${APP_URL}/client/bundle.${appName}.js'></script>`
|
||||
: `<script src='${APP_URL}/client/bundle.dev.js'></script>`
|
||||
}
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
@ -14,6 +14,12 @@
|
||||
* limitations under the License. *
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const {
|
||||
defaultWebpackMode,
|
||||
defaultApps,
|
||||
defaultAppName,
|
||||
} = require('./src/server/utils/constants/defaults')
|
||||
|
||||
const getDevConfiguration = () => {
|
||||
try {
|
||||
const path = require('path')
|
||||
@ -21,11 +27,6 @@ const getDevConfiguration = () => {
|
||||
const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin')
|
||||
const TimeFixPlugin = require('time-fix-plugin')
|
||||
|
||||
const {
|
||||
defaultWebpackMode,
|
||||
defaultAppName,
|
||||
} = require('./src/server/utils/constants/defaults')
|
||||
|
||||
const appName = defaultAppName ? `/${defaultAppName}` : ''
|
||||
|
||||
/** @type {webpack.Configuration} */
|
||||
@ -33,7 +34,9 @@ const getDevConfiguration = () => {
|
||||
mode: defaultWebpackMode,
|
||||
entry: [
|
||||
'webpack-hot-middleware/client',
|
||||
path.resolve(__dirname, 'src/client/dev/index.js'),
|
||||
...Object.keys(defaultApps).map((app) =>
|
||||
path.resolve(__dirname, `src/client/${app}.js`)
|
||||
),
|
||||
],
|
||||
output: {
|
||||
filename: 'bundle.dev.js',
|
||||
|
Loading…
x
Reference in New Issue
Block a user