Security hardening: authentication, input validation, OWASP compliance, architecture improvements, and CSP fixes for browser mode (#942)
This commit is contained in:
115
app/src/browserEventBus.ts
Normal file
115
app/src/browserEventBus.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Browser-specific EventBus implementation using Socket.io
|
||||
// This file contains the socket.io-client dependency which belongs in the app layer
|
||||
import io, { Socket } from 'socket.io-client'
|
||||
import { SocketIOClientEventBus } from '../../events/EventSystem/SocketIOClientEventBus'
|
||||
import { Rpc } from '../../events/EventSystem/Rpc'
|
||||
|
||||
// Get auth from sessionStorage or use empty (will show login dialog)
|
||||
let username = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('mqtt-explorer-username') || '' : ''
|
||||
let password = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('mqtt-explorer-password') || '' : ''
|
||||
|
||||
// Connect to the server (same origin in browser mode)
|
||||
const socket: Socket = io({
|
||||
auth: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
reconnectionAttempts: Infinity,
|
||||
transports: ['websocket', 'polling'],
|
||||
autoConnect: false, // Don't auto-connect, we'll connect manually after checking credentials
|
||||
})
|
||||
|
||||
// Handle connection errors
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('Socket connection error:', error.message)
|
||||
|
||||
// Check if it's an authentication error
|
||||
if (error.message.includes('Invalid credentials') ||
|
||||
error.message.includes('Authentication required') ||
|
||||
error.message.includes('Too many')) {
|
||||
// Clear invalid credentials from sessionStorage
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.removeItem('mqtt-explorer-username')
|
||||
sessionStorage.removeItem('mqtt-explorer-password')
|
||||
}
|
||||
|
||||
// Dispatch custom event that BrowserAuthWrapper can listen to
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('mqtt-auth-error', {
|
||||
detail: { message: error.message }
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log('Socket disconnected:', reason)
|
||||
})
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Socket connected successfully')
|
||||
|
||||
// Dispatch custom event that BrowserAuthWrapper can listen to
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('mqtt-auth-success', {
|
||||
detail: { message: 'Authentication successful' }
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Update socket authentication credentials and attempt to reconnect
|
||||
* @param newUsername New username
|
||||
* @param newPassword New password
|
||||
*/
|
||||
export function updateSocketAuth(newUsername: string, newPassword: string) {
|
||||
username = newUsername
|
||||
password = newPassword
|
||||
|
||||
// Update socket auth
|
||||
socket.auth = {
|
||||
username: newUsername,
|
||||
password: newPassword,
|
||||
}
|
||||
|
||||
// Store in sessionStorage
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.setItem('mqtt-explorer-username', newUsername)
|
||||
sessionStorage.setItem('mqtt-explorer-password', newPassword)
|
||||
}
|
||||
|
||||
// Disconnect if connected, then reconnect with new credentials
|
||||
if (socket.connected) {
|
||||
socket.disconnect()
|
||||
}
|
||||
socket.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect the socket (used on initial page load)
|
||||
*/
|
||||
export function connectSocket() {
|
||||
if (!socket.connected) {
|
||||
socket.connect()
|
||||
}
|
||||
}
|
||||
|
||||
export const rendererEvents = new SocketIOClientEventBus(socket)
|
||||
export const rendererRpc = new Rpc(rendererEvents)
|
||||
|
||||
// Export socket instance for error monitoring
|
||||
export const browserSocket = socket
|
||||
|
||||
// In browser mode, the backend is on the server
|
||||
// For compatibility, export same instances (renderer communicates with server backend via socket)
|
||||
export const backendEvents = rendererEvents
|
||||
export const backendRpc = rendererRpc
|
||||
|
||||
// Re-export all events from the events module so imports work correctly
|
||||
export * from '../../events/Events'
|
||||
export * from '../../events/EventsV2'
|
||||
export * from '../../events/EventSystem/EventDispatcher'
|
||||
export * from '../../events/EventSystem/EventBusInterface'
|
||||
@@ -1,17 +1,18 @@
|
||||
import * as React from 'react'
|
||||
import { LoginDialog } from './LoginDialog'
|
||||
import { updateSocketAuth, connectSocket } from '../browserEventBus'
|
||||
import { isBrowserMode } from '../utils/browserMode'
|
||||
|
||||
interface BrowserAuthWrapperProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const isBrowserMode =
|
||||
typeof window !== 'undefined' && (typeof process === 'undefined' || process.env?.BROWSER_MODE === 'true')
|
||||
|
||||
export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
|
||||
const [isAuthenticated, setIsAuthenticated] = React.useState(false)
|
||||
const [loginError, setLoginError] = React.useState<string | undefined>()
|
||||
const [showLogin, setShowLogin] = React.useState(false)
|
||||
const [showLogin, setShowLogin] = React.useState(isBrowserMode) // Show login initially in browser mode
|
||||
const [waitTimeSeconds, setWaitTimeSeconds] = React.useState<number | undefined>()
|
||||
const [isConnecting, setIsConnecting] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isBrowserMode) {
|
||||
@@ -20,34 +21,86 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
|
||||
return
|
||||
}
|
||||
|
||||
// Listen for successful authentication from socket
|
||||
const handleAuthSuccess = (event: CustomEvent) => {
|
||||
console.log('Authentication successful')
|
||||
setIsAuthenticated(true)
|
||||
setShowLogin(false)
|
||||
setLoginError(undefined)
|
||||
setWaitTimeSeconds(undefined)
|
||||
setIsConnecting(false)
|
||||
}
|
||||
|
||||
// Listen for authentication errors from socket
|
||||
const handleAuthError = (event: CustomEvent) => {
|
||||
const errorMessage = event.detail?.message || 'Authentication failed'
|
||||
console.error('Authentication error:', errorMessage)
|
||||
|
||||
// Clear authentication state
|
||||
setIsAuthenticated(false)
|
||||
setShowLogin(true)
|
||||
setIsConnecting(false)
|
||||
|
||||
// Extract wait time from error message (e.g., "Please wait 30 seconds")
|
||||
const waitTimeMatch = errorMessage.match(/(\d+)\s+seconds?/)
|
||||
if (waitTimeMatch) {
|
||||
const seconds = parseInt(waitTimeMatch[1], 10)
|
||||
// Add a few seconds margin to the countdown
|
||||
setWaitTimeSeconds(seconds + 3)
|
||||
} else {
|
||||
setWaitTimeSeconds(undefined)
|
||||
}
|
||||
|
||||
// Set user-friendly error message based on error type
|
||||
// Error messages from server already include wait times
|
||||
if (errorMessage.includes('Too many failed authentication attempts')) {
|
||||
setLoginError(errorMessage)
|
||||
} else if (errorMessage.includes('Invalid credentials')) {
|
||||
setLoginError(errorMessage)
|
||||
} else if (errorMessage.includes('Authentication required')) {
|
||||
setLoginError('Please enter your username and password.')
|
||||
setWaitTimeSeconds(undefined)
|
||||
} else {
|
||||
setLoginError('Authentication failed. Please try again.')
|
||||
setWaitTimeSeconds(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('mqtt-auth-success', handleAuthSuccess as EventListener)
|
||||
window.addEventListener('mqtt-auth-error', handleAuthError as EventListener)
|
||||
|
||||
// Check if already authenticated
|
||||
const username = sessionStorage.getItem('mqtt-explorer-username')
|
||||
const password = sessionStorage.getItem('mqtt-explorer-password')
|
||||
|
||||
if (username && password) {
|
||||
// Try to use stored credentials
|
||||
setIsAuthenticated(true)
|
||||
// Credentials exist, try to connect with them
|
||||
setIsConnecting(true)
|
||||
connectSocket()
|
||||
} else {
|
||||
// Show login dialog
|
||||
// No credentials, show login dialog
|
||||
setShowLogin(true)
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mqtt-auth-success', handleAuthSuccess as EventListener)
|
||||
window.removeEventListener('mqtt-auth-error', handleAuthError as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogin = async (username: string, password: string) => {
|
||||
const handleLogin = (username: string, password: string) => {
|
||||
try {
|
||||
// Store credentials in session storage
|
||||
sessionStorage.setItem('mqtt-explorer-username', username)
|
||||
sessionStorage.setItem('mqtt-explorer-password', password)
|
||||
|
||||
// The socket will use these credentials on next connection
|
||||
setIsAuthenticated(true)
|
||||
setShowLogin(false)
|
||||
// Clear any previous error
|
||||
setLoginError(undefined)
|
||||
|
||||
// Reload to reinitialize socket with new auth
|
||||
window.location.reload()
|
||||
setWaitTimeSeconds(undefined)
|
||||
setIsConnecting(true)
|
||||
|
||||
// Update socket auth and reconnect (no page reload needed)
|
||||
updateSocketAuth(username, password)
|
||||
} catch (error) {
|
||||
setLoginError('Login failed. Please check your credentials.')
|
||||
console.error('Failed to update socket auth:', error)
|
||||
setLoginError('Failed to connect. Please try again.')
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +110,7 @@ export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <LoginDialog open={showLogin} onLogin={handleLogin} error={loginError} />
|
||||
return <LoginDialog open={showLogin} onLogin={handleLogin} error={loginError} waitTimeSeconds={waitTimeSeconds} />
|
||||
}
|
||||
|
||||
return <>{props.children}</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { memo } from 'react'
|
||||
import { alpha as fade } from '@mui/material/styles'
|
||||
import { Fade, Grow, Paper, Popper, Typography, useTheme } from '@mui/material'
|
||||
import { alpha as fade, useTheme } from '@mui/material/styles'
|
||||
import { Fade, Grow, Paper, Popper, Typography } from '@mui/material'
|
||||
import { Tooltip } from './Model'
|
||||
|
||||
function TooltipComponent(props: { tooltip?: Tooltip }) {
|
||||
|
||||
@@ -39,7 +39,12 @@ function InterpolationSettings(props: {
|
||||
|
||||
const menuItems = React.useMemo(() => {
|
||||
return curves.map(curve => (
|
||||
<MenuItem key={curve} onClick={callbacks[curve]} selected={props.chart.interpolation === curve}>
|
||||
<MenuItem
|
||||
key={curve}
|
||||
onClick={callbacks[curve]}
|
||||
selected={props.chart.interpolation === curve}
|
||||
data-menu-item={curve.replace(/_/g, ' ')}
|
||||
>
|
||||
<Typography variant="inherit">{curve.replace(/_/g, ' ')}</Typography>
|
||||
</MenuItem>
|
||||
))
|
||||
|
||||
@@ -65,37 +65,37 @@ function ChartSettings(props: {
|
||||
return (
|
||||
<span>
|
||||
<Menu id="long-menu" anchorEl={props.anchorEl.current} open={props.open} onClose={props.close}>
|
||||
<MenuItem key="range" onClick={toggleRange}>
|
||||
<MenuItem key="range" onClick={toggleRange} data-menu-item="Y-Axis range (Values)">
|
||||
<ListItemIcon>
|
||||
<BarChart />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Y-Axis range (Values)</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="timeRange" onClick={toggleTimeRange}>
|
||||
<MenuItem key="timeRange" onClick={toggleTimeRange} data-menu-item="X-Axis range (Time)">
|
||||
<ListItemIcon>
|
||||
<BarChart />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">X-Axis range (Time)</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="interpolation" onClick={toggleInterpolation}>
|
||||
<MenuItem key="interpolation" onClick={toggleInterpolation} data-menu-item="Curve interpolation">
|
||||
<ListItemIcon>
|
||||
<MultilineChart />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Curve interpolation</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="size" onClick={toggleSize}>
|
||||
<MenuItem key="size" onClick={toggleSize} data-menu-item="Size">
|
||||
<ListItemIcon>
|
||||
<Sort />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Size</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="color" onClick={toggleColor}>
|
||||
<MenuItem key="color" onClick={toggleColor} data-menu-item="Color">
|
||||
<ListItemIcon>
|
||||
<ColorLens />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Color</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="clear" onClick={props.resetDataAction}>
|
||||
<MenuItem key="clear" onClick={props.resetDataAction} data-menu-item="Clear data">
|
||||
<ListItemIcon>
|
||||
<Clear />
|
||||
</ListItemIcon>
|
||||
|
||||
@@ -65,6 +65,7 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
|
||||
color="secondary"
|
||||
onClick={() => props.managerActions.addSubscription({ topic, qos }, props.connection.id)}
|
||||
variant="contained"
|
||||
data-testid="add-subscription-button"
|
||||
>
|
||||
<Add /> Add
|
||||
</Button>
|
||||
@@ -99,6 +100,7 @@ const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={props.managerActions.toggleAdvancedSettings}
|
||||
data-testid="back-button"
|
||||
>
|
||||
<Undo /> Back
|
||||
</Button>
|
||||
|
||||
@@ -9,10 +9,9 @@ import { connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { isBrowserMode } from '../../utils/browserMode'
|
||||
|
||||
// Check if we're in browser mode
|
||||
const isBrowserMode =
|
||||
typeof window !== 'undefined' && (typeof process === 'undefined' || process.env?.BROWSER_MODE === 'true')
|
||||
// Use browser or desktop file selection based on mode
|
||||
const CertSelector: any = isBrowserMode ? BrowserCertificateFileSelection : CertificateFileSelection
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -8,7 +8,7 @@ function ConnectButton(props: { connecting: boolean; classes: any; toggle: () =>
|
||||
|
||||
if (connecting) {
|
||||
return (
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={toggle}>
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={toggle} data-testid="abort-button">
|
||||
<ConnectionHealthIndicator />
|
||||
Abort
|
||||
</Button>
|
||||
@@ -16,7 +16,7 @@ function ConnectButton(props: { connecting: boolean; classes: any; toggle: () =>
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={toggle}>
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={toggle} data-testid="connect-button">
|
||||
<PowerSettingsNew /> Connect
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -236,6 +236,7 @@ function ConnectionSettings(props: Props) {
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={props.managerActions.toggleAdvancedSettings}
|
||||
data-testid="advanced-button"
|
||||
>
|
||||
<Settings /> Advanced
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import ChartPanel from '../ChartPanel'
|
||||
import ReactSplitPane from 'react-split-pane'
|
||||
import ReactSplitPaneImport from 'react-split-pane'
|
||||
import Tree from '../Tree'
|
||||
import { AppState } from '../../reducers'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
@@ -9,6 +9,9 @@ import { List } from 'immutable'
|
||||
import { Sidebar } from '../Sidebar'
|
||||
import { useResizeDetector } from 'react-resize-detector'
|
||||
|
||||
// Type cast to any to work around React 18 compatibility issues with react-split-pane 0.1.x
|
||||
const ReactSplitPane = ReactSplitPaneImport as any
|
||||
|
||||
interface Props {
|
||||
heightProperty: any
|
||||
paneDefaults: any
|
||||
@@ -75,7 +78,7 @@ function ContentView(props: Props) {
|
||||
split="vertical"
|
||||
minSize={0}
|
||||
size={sidebarWidth}
|
||||
onChange={setSidebarWidth}
|
||||
onChange={(size: number) => setSidebarWidth(size)}
|
||||
onDragFinished={closeSidebarCompletelyIfItSitsOnTheEdge}
|
||||
allowResize={true}
|
||||
style={{ height: '100%' }}
|
||||
@@ -92,7 +95,7 @@ function ContentView(props: Props) {
|
||||
style={{ height: 'calc(100vh - 64px)' }}
|
||||
pane1Style={{ maxHeight: '100%' }}
|
||||
pane2Style={{ borderTop: '1px solid #999', display: 'flex' }}
|
||||
onChange={setHeight}
|
||||
onChange={(size: number) => setHeight(size)}
|
||||
onDragFinished={closeDrawerCompletelyIfItSitsOnTheEdge}
|
||||
>
|
||||
<Tree />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import CloudOff from '@mui/icons-material/CloudOff'
|
||||
import Logout from '@mui/icons-material/Logout'
|
||||
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
|
||||
const ConnectionHealthIndicatorAny = ConnectionHealthIndicator as any
|
||||
import Menu from '@mui/icons-material/Menu'
|
||||
@@ -12,6 +13,7 @@ import { connect } from 'react-redux'
|
||||
import { connectionActions, globalActions, settingsActions } from '../../actions'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { isBrowserMode } from '../../utils/browserMode'
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
title: {
|
||||
@@ -35,6 +37,9 @@ const styles = (theme: Theme) => ({
|
||||
disconnect: {
|
||||
margin: 'auto 8px auto auto',
|
||||
},
|
||||
logout: {
|
||||
margin: 'auto 0 auto 8px',
|
||||
},
|
||||
disconnectLabel: {
|
||||
color: theme.palette.primary.contrastText,
|
||||
},
|
||||
@@ -56,6 +61,22 @@ class TitleBar extends React.PureComponent<Props, {}> {
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
private handleLogout = async () => {
|
||||
// Disconnect first
|
||||
this.props.actions.connection.disconnect()
|
||||
|
||||
// Clear credentials from sessionStorage
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.removeItem('mqtt-explorer-username')
|
||||
sessionStorage.removeItem('mqtt-explorer-password')
|
||||
}
|
||||
|
||||
// Reload page to reset all state and show login dialog
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { actions, classes } = this.props
|
||||
|
||||
@@ -79,9 +100,19 @@ class TitleBar extends React.PureComponent<Props, {}> {
|
||||
className={classes.disconnect}
|
||||
sx={{ color: 'primary.contrastText' }}
|
||||
onClick={actions.connection.disconnect}
|
||||
data-testid="disconnect-button"
|
||||
>
|
||||
Disconnect <CloudOff className={classes.disconnectIcon} />
|
||||
</Button>
|
||||
{isBrowserMode && (
|
||||
<Button
|
||||
className={classes.logout}
|
||||
sx={{ color: 'primary.contrastText' }}
|
||||
onClick={this.handleLogout}
|
||||
>
|
||||
Logout <Logout className={classes.disconnectIcon} />
|
||||
</Button>
|
||||
)}
|
||||
<ConnectionHealthIndicatorAny withBackground={true} />
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
@@ -5,53 +5,102 @@ interface LoginDialogProps {
|
||||
open: boolean
|
||||
onLogin: (username: string, password: string) => void
|
||||
error?: string
|
||||
waitTimeSeconds?: number
|
||||
}
|
||||
|
||||
export function LoginDialog(props: LoginDialogProps) {
|
||||
const [username, setUsername] = React.useState('')
|
||||
const [password, setPassword] = React.useState('')
|
||||
const [countdown, setCountdown] = React.useState<number | undefined>(props.waitTimeSeconds)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// Update countdown when waitTimeSeconds prop changes
|
||||
React.useEffect(() => {
|
||||
setCountdown(props.waitTimeSeconds)
|
||||
}, [props.waitTimeSeconds])
|
||||
|
||||
// Countdown timer
|
||||
React.useEffect(() => {
|
||||
if (countdown === undefined || countdown <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCountdown(prev => {
|
||||
if (prev === undefined || prev <= 1) {
|
||||
return undefined
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [countdown])
|
||||
|
||||
const handleLogin = () => {
|
||||
if (countdown !== undefined && countdown > 0) {
|
||||
// Don't allow login during countdown
|
||||
return
|
||||
}
|
||||
if (!username || !password) {
|
||||
// Don't allow empty credentials
|
||||
return
|
||||
}
|
||||
props.onLogin(username, password)
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleLogin()
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = countdown !== undefined && countdown > 0
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} disableEscapeKeyDown onClose={(event, reason) => { if (reason !== 'backdropClick') { /* Allow closing only via escape if needed */ } }}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogTitle>Login to MQTT Explorer</DialogTitle>
|
||||
<DialogContent>
|
||||
{props.error && (
|
||||
<Typography color="error" style={{ marginBottom: 16 }}>
|
||||
{props.error}
|
||||
</Typography>
|
||||
)}
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Username"
|
||||
type="text"
|
||||
fullWidth
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Password"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button type="submit" color="primary" variant="contained">
|
||||
Login
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
<DialogTitle>Login to MQTT Explorer</DialogTitle>
|
||||
<DialogContent>
|
||||
{props.error && (
|
||||
<Typography color="error" style={{ marginBottom: 16 }}>
|
||||
{props.error}
|
||||
</Typography>
|
||||
)}
|
||||
{countdown !== undefined && countdown > 0 && (
|
||||
<Typography color="warning" style={{ marginBottom: 16, fontWeight: 'bold' }}>
|
||||
Please wait {countdown} seconds before trying again...
|
||||
</Typography>
|
||||
)}
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Username"
|
||||
type="text"
|
||||
fullWidth
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={isDisabled}
|
||||
required
|
||||
data-testid="username-input"
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Password"
|
||||
type="password"
|
||||
fullWidth
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={isDisabled}
|
||||
required
|
||||
data-testid="password-input"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleLogin} color="primary" variant="contained" disabled={isDisabled}>
|
||||
{isDisabled ? `Wait ${countdown}s` : 'Login'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { InputLabel, Switch, Theme, Tooltip } from '@mui/material'
|
||||
import { withStyles } from '@mui/styles'
|
||||
const sha1 = require('sha1')
|
||||
|
||||
function BooleanSwitch(props: { title: string; value: boolean; tooltip: string; action: () => void; classes: any }) {
|
||||
function BooleanSwitch(props: { title: string; value: boolean; tooltip: string; action: () => void; classes: any; 'data-testid'?: string }) {
|
||||
const { tooltip, value, action, title, classes } = props
|
||||
|
||||
const clickHandler = (e: React.MouseEvent) => {
|
||||
@@ -20,7 +20,13 @@ function BooleanSwitch(props: { title: string; value: boolean; tooltip: string;
|
||||
</InputLabel>
|
||||
</Tooltip>
|
||||
<Tooltip title={tooltip}>
|
||||
<Switch name={`toggle-${sha1(title)}`} checked={value} onChange={action} color="primary" />
|
||||
<Switch
|
||||
name={`toggle-${sha1(title)}`}
|
||||
checked={value}
|
||||
onChange={action}
|
||||
color="primary"
|
||||
data-testid={props['data-testid']}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
SelectChangeEvent,
|
||||
Typography,
|
||||
Tooltip,
|
||||
} from '@mui/material'
|
||||
@@ -137,6 +138,7 @@ class Settings extends React.PureComponent<Props, {}> {
|
||||
tooltip="Enable dark theme"
|
||||
value={theme === 'dark'}
|
||||
action={actions.settings.toggleTheme}
|
||||
data-testid="dark-mode-toggle"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -168,7 +170,7 @@ class Settings extends React.PureComponent<Props, {}> {
|
||||
)
|
||||
}
|
||||
|
||||
private onChangeAutoExpand = (e: React.ChangeEvent<{ value: unknown }>) => {
|
||||
private onChangeAutoExpand = (e: SelectChangeEvent<number>) => {
|
||||
this.props.actions.settings.setAutoExpandLimit(parseInt(String(e.target.value), 10))
|
||||
}
|
||||
|
||||
@@ -200,7 +202,7 @@ class Settings extends React.PureComponent<Props, {}> {
|
||||
)
|
||||
}
|
||||
|
||||
private onChangeSorting = (e: React.ChangeEvent<{ value: unknown }>) => {
|
||||
private onChangeSorting = (e: SelectChangeEvent<TopicOrder>) => {
|
||||
this.props.actions.settings.setTopicOrder(e.target.value as TopicOrder)
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ function HistoryDrawer(props: Props) {
|
||||
invisible={!visible}
|
||||
badgeContent={props.items.length}
|
||||
color="primary"
|
||||
data-testid="message-history"
|
||||
>
|
||||
{expanded ? '▼ History' : '▶ History'}
|
||||
</Badge>
|
||||
|
||||
@@ -8,7 +8,7 @@ import RetainSwitch from './RetainSwitch'
|
||||
import TopicInput from './TopicInput'
|
||||
import { AppState } from '../../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Button, Fab, Tooltip, useTheme } from '@mui/material'
|
||||
import { Button, Fab, Tooltip } from '@mui/material'
|
||||
import { connect } from 'react-redux'
|
||||
import { EditorModeSelect } from './EditorModeSelect'
|
||||
import { globalActions, publishActions } from '../../../actions'
|
||||
@@ -41,7 +41,6 @@ function useHistory(): [Array<Message>, (topic: string, payload?: string) => voi
|
||||
}
|
||||
|
||||
function Publish(props: Props) {
|
||||
const theme = useTheme()
|
||||
const editorRef = useRef<AceEditor>()
|
||||
const [history, amendToHistory] = useHistory()
|
||||
|
||||
|
||||
@@ -6,7 +6,23 @@ import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { globalActions } from '../../actions'
|
||||
|
||||
const copy = require('copy-text-to-clipboard')
|
||||
// Fallback for older browsers or when clipboard API is not available
|
||||
const copyTextFallback = require('copy-text-to-clipboard')
|
||||
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
// Try modern Clipboard API first (works in browser with HTTPS)
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Clipboard API failed, using fallback:', error)
|
||||
}
|
||||
|
||||
// Fallback to copy-text-to-clipboard library
|
||||
return copyTextFallback(text)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value?: string
|
||||
@@ -26,15 +42,24 @@ class Copy extends React.PureComponent<Props, State> {
|
||||
this.state = { didCopy: false }
|
||||
}
|
||||
|
||||
private handleClick = (event: React.MouseEvent) => {
|
||||
private handleClick = async (event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
|
||||
copy(this.props.value ?? this.props.getValue?.())
|
||||
this.props.actions.global.showNotification('Copied to clipboard')
|
||||
this.setState({ didCopy: true })
|
||||
setTimeout(() => {
|
||||
this.setState({ didCopy: false })
|
||||
}, 1500)
|
||||
const text = this.props.value ?? this.props.getValue?.()
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
const success = await copyToClipboard(text)
|
||||
if (success) {
|
||||
this.props.actions.global.showNotification('Copied to clipboard')
|
||||
this.setState({ didCopy: true })
|
||||
setTimeout(() => {
|
||||
this.setState({ didCopy: false })
|
||||
}, 1500)
|
||||
} else {
|
||||
this.props.actions.global.showNotification('Failed to copy to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
@@ -45,7 +70,7 @@ class Copy extends React.PureComponent<Props, State> {
|
||||
)
|
||||
|
||||
return (
|
||||
<CustomIconButton onClick={this.handleClick} tooltip="Copy to clipboard">
|
||||
<CustomIconButton onClick={this.handleClick} tooltip="Copy to clipboard" data-testid="copy-button">
|
||||
<div style={{ marginTop: '2px' }}>{icon}</div>
|
||||
</CustomIconButton>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ interface Props {
|
||||
classes: any
|
||||
style?: React.CSSProperties
|
||||
children?: React.ReactNode
|
||||
'data-testid'?: string
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
@@ -38,7 +39,12 @@ class CustomIconButton extends React.PureComponent<Props, {}> {
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<IconButton className={this.props.classes.button} style={this.props.style} onClick={this.onClick}>
|
||||
<IconButton
|
||||
className={this.props.classes.button}
|
||||
style={this.props.style}
|
||||
onClick={this.onClick}
|
||||
data-testid={this.props['data-testid']}
|
||||
>
|
||||
<Tooltip title={this.props.tooltip} classes={{ popper: this.props.classes.tooltip }}>
|
||||
<span className={this.props.classes.label}>{this.props.children}</span>
|
||||
</Tooltip>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { applyMiddleware, compose, createStore } from 'redux'
|
||||
import { batchDispatchMiddleware } from 'redux-batched-actions'
|
||||
import { connect, Provider } from 'react-redux'
|
||||
import { ThemeProvider } from '@mui/material/styles'
|
||||
import { ThemeProvider as LegacyThemeProvider } from '@mui/styles'
|
||||
import './utils/tracking'
|
||||
import { themes } from './theme'
|
||||
import { BrowserAuthWrapper } from './components/BrowserAuthWrapper'
|
||||
@@ -16,10 +17,13 @@ const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ||
|
||||
const store = createStore(reducers, composeEnhancers(applyMiddleware(reduxThunk, batchDispatchMiddleware)))
|
||||
|
||||
function ApplicationRenderer(props: { theme: 'light' | 'dark' }) {
|
||||
const theme = props.theme === 'light' ? themes.lightTheme : themes.darkTheme
|
||||
return (
|
||||
<ThemeProvider theme={props.theme === 'light' ? themes.lightTheme : themes.darkTheme}>
|
||||
<App />
|
||||
<Demo />
|
||||
<ThemeProvider theme={theme}>
|
||||
<LegacyThemeProvider theme={theme}>
|
||||
<App />
|
||||
<Demo />
|
||||
</LegacyThemeProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
6
app/src/utils/browserMode.ts
Normal file
6
app/src/utils/browserMode.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Utility to detect if the application is running in browser mode
|
||||
* Browser mode is when the app runs in a web browser (not Electron desktop app)
|
||||
*/
|
||||
export const isBrowserMode =
|
||||
typeof window !== 'undefined' && (typeof process === 'undefined' || process.env?.BROWSER_MODE === 'true')
|
||||
@@ -1,5 +1,6 @@
|
||||
// Browser-specific webpack configuration
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin'
|
||||
// Extends the base webpack.config.mjs with minimal browser-specific overrides
|
||||
import baseConfig from './webpack.config.mjs'
|
||||
import webpack from 'webpack'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
@@ -9,99 +10,87 @@ const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
export default {
|
||||
entry: {
|
||||
app: './src/index.tsx',
|
||||
bugtracking: './src/utils/bugtracking.ts',
|
||||
},
|
||||
output: {
|
||||
chunkFilename: '[name].bundle.js',
|
||||
filename: '[name].bundle.js',
|
||||
path: `${__dirname}/build`,
|
||||
},
|
||||
optimization: {
|
||||
minimize: false,
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
minSize: 30000,
|
||||
minChunks: 1,
|
||||
maxAsyncRequests: 5,
|
||||
maxInitialRequests: 3,
|
||||
automaticNameDelimiter: '~',
|
||||
cacheGroups: {
|
||||
vendors: {
|
||||
test: /[\\/]node_modules[\\/](react|react-dom|@material-ui|popper\.js|react|react-redux|prop-types|jss|redux|scheduler|react-transition-group)[\\/]/,
|
||||
name: 'vendors',
|
||||
chunks: 'all',
|
||||
priority: -10,
|
||||
},
|
||||
default: {
|
||||
name: 'default',
|
||||
minChunks: 2,
|
||||
priority: -20,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
runtimeChunk: 'single',
|
||||
},
|
||||
devServer: {
|
||||
hot: true,
|
||||
liveReload: true,
|
||||
},
|
||||
target: 'web', // Changed from 'electron-renderer' to 'web'
|
||||
mode: 'production',
|
||||
devtool: 'source-map',
|
||||
...baseConfig,
|
||||
|
||||
// Browser target instead of electron-renderer
|
||||
target: 'web',
|
||||
|
||||
// Browser-specific module resolution
|
||||
resolve: {
|
||||
extensions: ['.ts', '.mjs', '.m.js', '.tsx', '.js', '.json'],
|
||||
modules: ['node_modules', path.resolve(__dirname, 'node_modules')],
|
||||
...baseConfig.resolve,
|
||||
modules: [
|
||||
path.resolve(__dirname, 'node_modules'), // App-level node_modules (priority for browser deps)
|
||||
path.resolve(__dirname, '..', 'node_modules'), // Root-level node_modules
|
||||
'node_modules',
|
||||
],
|
||||
alias: {
|
||||
electron: path.resolve(__dirname, './src/mocks/electron.ts'),
|
||||
},
|
||||
fallback: {
|
||||
// Browser fallbacks for Node.js modules
|
||||
path: 'path-browserify',
|
||||
fs: false,
|
||||
crypto: false,
|
||||
url: 'url/',
|
||||
os: 'os-browserify/browser',
|
||||
|
||||
events: 'events/',
|
||||
},
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
|
||||
// Browser-specific plugins
|
||||
plugins: [
|
||||
// Replace base config's DefinePlugin with one that includes both NODE_ENV and BROWSER_MODE
|
||||
...baseConfig.plugins.filter(plugin => !(plugin instanceof webpack.DefinePlugin)),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
|
||||
'process.env.BROWSER_MODE': JSON.stringify('true'),
|
||||
}),
|
||||
// Replace events/index with browser-specific version that excludes IPC EventBus
|
||||
new webpack.NormalModuleReplacementPlugin(/^\.\.\/\.\.\/\.\.\/events$/, resource => {
|
||||
// Point to browser event bus when importing from '../../../events'
|
||||
resource.request = path.resolve(__dirname, 'src', 'browserEventBus.ts')
|
||||
}),
|
||||
new webpack.NormalModuleReplacementPlugin(/^\.\.\/\.\.\/\.\.\/\.\.\/events$/, resource => {
|
||||
// Point to browser event bus when importing from '../../../../events'
|
||||
resource.request = path.resolve(__dirname, 'src', 'browserEventBus.ts')
|
||||
}),
|
||||
// Replace EventSystem/EventBus directly as well
|
||||
new webpack.NormalModuleReplacementPlugin(/events[\\/]EventSystem[\\/]EventBus$/, resource => {
|
||||
resource.request = path.resolve(__dirname, 'src', 'browserEventBus.ts')
|
||||
}),
|
||||
// Exclude IPC-based EventBus files completely
|
||||
new webpack.IgnorePlugin({
|
||||
resourceRegExp: /IpcRendererEventBus\.ts$/,
|
||||
}),
|
||||
new webpack.IgnorePlugin({
|
||||
resourceRegExp: /IpcMainEventBus\.ts$/,
|
||||
}),
|
||||
],
|
||||
|
||||
// Cache directory
|
||||
cache: {
|
||||
...baseConfig.cache,
|
||||
cacheDirectory: path.resolve(__dirname, '.webpack-cache'),
|
||||
},
|
||||
|
||||
// Dev server configuration for browser mode development
|
||||
devServer: {
|
||||
static: {
|
||||
directory: path.resolve(__dirname),
|
||||
publicPath: '/',
|
||||
},
|
||||
compress: true,
|
||||
port: 8080, // Different port from backend server (3000)
|
||||
hot: true,
|
||||
historyApiFallback: true,
|
||||
proxy: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
transpileOnly: true, // Skip type checking, we already did it with tsc
|
||||
},
|
||||
},
|
||||
],
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
{ enforce: 'pre', test: /\.js$/, loader: 'source-map-loader' },
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpg|gif)$/i,
|
||||
type: 'asset/resource',
|
||||
// Proxy API, auth, and socket.io requests to backend server
|
||||
context: ['/socket.io', '/api', '/auth'],
|
||||
target: 'http://localhost:3000',
|
||||
ws: true, // Enable WebSocket proxying
|
||||
changeOrigin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({ template: './index.html', file: './build/index.html', inject: false }),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.BROWSER_MODE': JSON.stringify('true'),
|
||||
}),
|
||||
new webpack.NormalModuleReplacementPlugin(/EventSystem[\\/]EventBus$/, resource => {
|
||||
console.log('Replacing EventBus:', resource.request)
|
||||
resource.request = resource.request.replace(/EventBus$/, 'BrowserEventBus')
|
||||
}),
|
||||
],
|
||||
externals: {},
|
||||
cache: false,
|
||||
}
|
||||
|
||||
@@ -7,27 +7,31 @@ import { dirname } from 'path'
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV !== 'production'
|
||||
|
||||
export default {
|
||||
entry: {
|
||||
app: './src/index.tsx',
|
||||
bugtracking: './src/utils/bugtracking.ts',
|
||||
},
|
||||
output: {
|
||||
chunkFilename: '[name].bundle.js',
|
||||
filename: '[name].bundle.js',
|
||||
chunkFilename: isDevelopment ? '[name].js' : '[name].[contenthash:8].js',
|
||||
filename: isDevelopment ? '[name].bundle.js' : '[name].[contenthash:8].bundle.js',
|
||||
path: `${__dirname}/build`,
|
||||
pathinfo: false,
|
||||
},
|
||||
optimization: {
|
||||
minimize: false,
|
||||
runtimeChunk: 'single',
|
||||
splitChunks: {
|
||||
minimize: !isDevelopment,
|
||||
removeAvailableModules: false,
|
||||
removeEmptyChunks: false,
|
||||
runtimeChunk: isDevelopment ? false : 'single',
|
||||
splitChunks: isDevelopment ? false : {
|
||||
chunks: 'all',
|
||||
minSize: 30000,
|
||||
minChunks: 1,
|
||||
maxAsyncRequests: 5,
|
||||
maxInitialRequests: 3,
|
||||
automaticNameDelimiter: '~',
|
||||
// name: true,
|
||||
cacheGroups: {
|
||||
vendors: {
|
||||
test: /[\\/]node_modules[\\/](react|react-dom|@material-ui|popper\.js|react|react-redux|prop-types|jss|redux|scheduler|react-transition-group)[\\/]/,
|
||||
@@ -45,34 +49,36 @@ export default {
|
||||
},
|
||||
},
|
||||
devServer: {
|
||||
// contentBase: './dist', // content not from webpack
|
||||
hot: true,
|
||||
liveReload: true,
|
||||
liveReload: false,
|
||||
},
|
||||
target: 'electron-renderer',
|
||||
mode: 'production',
|
||||
devtool: 'source-map',
|
||||
mode: isDevelopment ? 'development' : 'production',
|
||||
devtool: isDevelopment ? 'eval-cheap-module-source-map' : 'source-map',
|
||||
resolve: {
|
||||
// Add '.ts' and '.tsx' as resolvable extensions.
|
||||
extensions: ['.ts', '.mjs', '.m.js', '.tsx', '.js', '.json', '.node'],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
// All files with a '.ts' or '.tsx' extension will be handled by 'awesome-typescript-loader'.
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'ts-loader',
|
||||
// options: {
|
||||
// configFile: './tsconfig.json',
|
||||
// },
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
experimentalWatchApi: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
// All output '.js' files will have any sourcemaps re-processed by 'source-map-loader'.
|
||||
{ enforce: 'pre', test: /\.js$/, loader: 'source-map-loader' },
|
||||
...(isDevelopment ? [] : [{
|
||||
enforce: 'pre',
|
||||
test: /\.js$/,
|
||||
loader: 'source-map-loader',
|
||||
exclude: /node_modules\/ace-builds/,
|
||||
}]),
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
@@ -81,36 +87,23 @@ export default {
|
||||
test: /\.(png|jpg|gif)$/i,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
// {
|
||||
// test: /\.node$/,
|
||||
// use: {
|
||||
// loader: 'node-loader',
|
||||
// options: {
|
||||
// modules: true,
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
],
|
||||
},
|
||||
// node: { global: true },
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({ template: './index.html', file: './build/index.html', inject: false }),
|
||||
// new BundleAnalyzerPlugin(),
|
||||
// new webpack.IgnorePlugin({
|
||||
// resourceRegExp: /\.\/build\/Debug\/addon/,
|
||||
// contextRegExp: /heapdump$/
|
||||
// }),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
|
||||
}),
|
||||
],
|
||||
|
||||
// When importing a module whose path matches one of the following, just
|
||||
// assume a corresponding global variable exists and use that instead.
|
||||
// This is important because it allows us to avoid bundling all of our
|
||||
// dependencies, which allows browsers to cache those libraries between builds.
|
||||
externals: {
|
||||
// "react": "React",
|
||||
// "react-dom": "ReactDOM"
|
||||
},
|
||||
externals: {},
|
||||
cache: {
|
||||
type: 'filesystem',
|
||||
buildDependencies: {
|
||||
config: [__filename],
|
||||
},
|
||||
},
|
||||
performance: {
|
||||
hints: isDevelopment ? false : 'warning',
|
||||
},
|
||||
stats: isDevelopment ? 'errors-warnings' : 'normal',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user