Security hardening: authentication, input validation, OWASP compliance, architecture improvements, and CSP fixes for browser mode (#942)

This commit is contained in:
Copilot
2025-12-22 16:52:42 +01:00
committed by GitHub
parent a7136bd572
commit 6c041cba02
50 changed files with 1943 additions and 734 deletions

115
app/src/browserEventBus.ts Normal file
View 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'

View File

@@ -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}</>

View File

@@ -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 }) {

View File

@@ -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>
))

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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 />
&nbsp;&nbsp;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>
)

View File

@@ -236,6 +236,7 @@ function ConnectionSettings(props: Props) {
variant="contained"
className={classes.button}
onClick={props.managerActions.toggleAdvancedSettings}
data-testid="advanced-button"
>
<Settings /> Advanced
</Button>

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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>
)

View File

@@ -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)
}

View File

@@ -88,6 +88,7 @@ function HistoryDrawer(props: Props) {
invisible={!visible}
badgeContent={props.items.length}
color="primary"
data-testid="message-history"
>
{expanded ? '▼ History' : '▶ History'}
</Badge>

View File

@@ -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()

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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>
)
}

View 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')