Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hide dangerous operations behind configuration option #1025

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/app/__tests__/__snapshots__/index.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<App /> should render and match the snapshot 1`] = `
<ModalProvider>
<React.Fragment>
<Helmet
defaultTitle="Oasis Wallet"
defer={true}
Expand Down Expand Up @@ -63,5 +63,6 @@ exports[`<App /> should render and match the snapshot 1`] = `
</Styled(Main)>
</Box>
</Box>
</ModalProvider>
<ModalContainer />
</React.Fragment>
`;
7 changes: 3 additions & 4 deletions src/app/components/AddEscrowForm/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ModalProvider } from 'app/components/Modal'
import { ModalContainer } from 'app/components/Modal'
import { Validator } from 'app/state/staking/types'
import * as React from 'react'
import { Provider } from 'react-redux'
Expand All @@ -12,9 +12,8 @@ import { AddEscrowForm } from '..'
const renderComponent = (store: any, address: string, validatorStatus: Validator['status']) =>
render(
<Provider store={store}>
<ModalProvider>
<AddEscrowForm validatorAddress={address} validatorStatus={validatorStatus} validatorRank={21} />
</ModalProvider>
<AddEscrowForm validatorAddress={address} validatorStatus={validatorStatus} validatorRank={21} />
<ModalContainer />
</Provider>,
)

Expand Down
5 changes: 3 additions & 2 deletions src/app/components/AddEscrowForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
* AddEscrowForm
*
*/
import { useModal } from 'app/components/Modal'
import { Modal } from '../Modal/slice/types'
import { modalActions } from '../Modal/slice'
import { parseRoseStringToBaseUnitString } from 'app/lib/helpers'
import { selectMinStaking } from 'app/state/network/selectors'
import { Validator } from 'app/state/staking/types'
Expand All @@ -24,7 +25,7 @@ interface Props {

export const AddEscrowForm = memo((props: Props) => {
const { t } = useTranslation()
const { launchModal } = useModal()
const launchModal = (modal: Modal) => dispatch(modalActions.launch(modal))
const { error, success } = useSelector(selectTransaction)
const isTop20 = props.validatorRank <= 20
const [showNotice, setShowNotice] = useState(isTop20)
Expand Down
137 changes: 77 additions & 60 deletions src/app/components/Modal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,86 +1,103 @@
import { createContext, useCallback, useContext, useState } from 'react'
import { useEffect, useState } from 'react'
import { Box, Button, Layer, Paragraph } from 'grommet'
import { useTranslation } from 'react-i18next'
import { Alert, Checkmark, Close } from 'grommet-icons'
import { Alert, Checkmark, Close, Configure } from 'grommet-icons'
import { ModalHeader } from 'app/components/Header'
import { AlertBox } from '../AlertBox'
import { selectAllowDangerousSetting } from '../SettingsDialog/slice/selectors'
import { useDispatch, useSelector } from 'react-redux'
import { settingsActions } from '../SettingsDialog/slice'
import { selectCurrentModal } from './slice/selectors'
import { modalActions } from './slice'

interface Modal {
title: string
description: string
handleConfirm: () => void
isDangerous: boolean
}

interface ModalContainerProps {
modal: Modal
closeModal: () => void
}

interface ModalContextProps {
launchModal: (modal: Modal) => void
closeModal: () => void
}
const ModalContainer = () => {
const dispatch = useDispatch()
const { t } = useTranslation()
const modal = useSelector(selectCurrentModal)
const allowDangerous = useSelector(selectAllowDangerousSetting)
const [secsLeft, setSecsLeft] = useState(0)
const closeModal = () => dispatch(modalActions.close())
const confirm = () => {
modal?.handleConfirm()
dispatch(modalActions.close())
}
const { isDangerous, mustWaitSecs } = modal || {}

interface ModalProviderProps {
children: React.ReactNode
}
const forbidden = isDangerous && !allowDangerous
const waitingTime = forbidden
? 0 // If the action is forbidden, there is nothing to wait for
: isDangerous
? mustWaitSecs ?? 10 // For dangerous actions, we require 10 seconds of waiting, unless specified otherwise.
: mustWaitSecs ?? 0 // For normal, non-dangerous operations, just use what was specified

const ModalContext = createContext<ModalContextProps>({} as ModalContextProps)
const openSettings = () => {
dispatch(modalActions.stash())
dispatch(settingsActions.setOpen(true))
}

const ModalContainer = ({ modal, closeModal }: ModalContainerProps) => {
const { t } = useTranslation()
const confirm = useCallback(() => {
modal.handleConfirm()
closeModal()
}, [closeModal, modal])
useEffect(() => {
if (waitingTime) {
setSecsLeft(waitingTime)
const stopCounting = () => window.clearInterval(interval)
const interval = window.setInterval(
() =>
setSecsLeft(seconds => {
const remains = seconds - 1
if (!remains) stopCounting()
return remains
}),
1000,
)
return stopCounting
}
}, [waitingTime])

if (!modal) return null
return (
<Layer modal onEsc={closeModal} onClickOutside={closeModal} background="background-front">
<Box margin="medium">
<ModalHeader>{modal.title}</ModalHeader>
<Paragraph fill>{modal.description}</Paragraph>
{forbidden && (
<AlertBox color={'status-error'}>
{t(
'dangerMode.youDontWantThis',
"You most probably don't want to do this, so I won't allow it. If you really do, then please enable the 'dangerous mode' in wallet settings, and try again.",
)}
</AlertBox>
)}
{isDangerous && allowDangerous && (
<AlertBox color={'status-warning'}>
{t(
'dangerMode.youCanButDoYouWant',
"You most probably shouldn't do this, but since you have specifically enabled 'dangerous mode' in wallet settings, we won't stop you.",
)}
</AlertBox>
)}
<Box direction="row" gap="small" justify="between" pad={{ top: 'large' }}>
<Button
label={t('common.cancel', 'Cancel')}
onClick={closeModal}
secondary
icon={<Close size="18px" />}
/>
<Button
label={t('common.confirm', 'Confirm')}
onClick={confirm}
disabled={modal.isDangerous}
primary={modal.isDangerous}
color={modal.isDangerous ? 'status-error' : ''}
icon={modal.isDangerous ? <Alert size="18px" /> : <Checkmark size="18px" />}
/>
{!forbidden && (
<Button
label={t('common.confirm', 'Confirm') + (secsLeft ? ` (${secsLeft})` : '')}
onClick={confirm}
disabled={!!secsLeft}
primary={modal.isDangerous}
color={modal.isDangerous ? 'status-error' : ''}
icon={modal.isDangerous ? <Alert size="18px" /> : <Checkmark size="18px" />}
/>
)}
{forbidden && (
<Button label={t('menu.settings', 'Settings')} onClick={openSettings} icon={<Configure />} />
)}
</Box>
</Box>
</Layer>
)
}

const ModalProvider = (props: ModalProviderProps) => {
const [modal, setModal] = useState<Modal | null>(null)
const closeModal = useCallback(() => {
setModal(null)
}, [])

return (
<ModalContext.Provider value={{ closeModal, launchModal: setModal }}>
{props.children}
{modal && <ModalContainer modal={modal} closeModal={closeModal} />}
</ModalContext.Provider>
)
}

const useModal = () => {
const context = useContext(ModalContext)
if (context === undefined) {
throw new Error('useModal must be used within a ModalProvider')
}

return context
}

export { ModalProvider, useModal }
export { ModalContainer }
63 changes: 63 additions & 0 deletions src/app/components/Modal/slice/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { PayloadAction } from '@reduxjs/toolkit'
import { createSlice } from 'utils/@reduxjs/toolkit'

import { Modal, ModalState } from './types'

export const initialState: ModalState = {
current: null,
stash: [],
}

const slice = createSlice({
name: 'modal',
initialState,
reducers: {
/**
* Show a new modal
*/
launch(state, action: PayloadAction<Modal>) {
if (state.current) {
state.stash.push(state.current)
}
state.current = action.payload
},

/**
* Close the current modal
*/
close(state) {
state.current = state.stash.pop() || null
},

/**
* Hide the current modal (with the intention of showing in again later)
*
* The semantics is the same as with git stash.
*/
stash(state) {
if (!state.current) {
throw new Error("You can't call hideModal if no model is shown!")
}
state.stash.push(state.current)
state.current = null
},

/**
* Show the previously hidden modal again.
*
* The semantics is the same as with `git stash pop`.
*/
stashPop(state) {
if (state.current) {
throw new Error("You can't call showModal when a modal is already visible!")
}
const latest = state.stash.pop()
if (!latest) return
state.current = latest
},
},
})

export const { actions: modalActions } = slice

export default slice.reducer
8 changes: 8 additions & 0 deletions src/app/components/Modal/slice/selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createSelector } from '@reduxjs/toolkit'

import { RootState } from 'types'
import { initialState } from '.'

const selectSlice = (state: RootState) => state.modal || initialState

export const selectCurrentModal = createSelector([selectSlice], settings => settings.current)
24 changes: 24 additions & 0 deletions src/app/components/Modal/slice/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export interface Modal {
title: string
description: string
handleConfirm: () => void

/**
* Is this a dangerous operation?
*
* If marked as such, it will only be possible to execute it if the wallet is configured to run in dangerous mode.
*
* It also automatically implies a mandatory waiting time of 10 sec, unless specified otherwise.
*/
isDangerous: boolean

/**
* How long does the user have to wait before he can actually confirm the action?
*/
mustWaitSecs?: number
}

export interface ModalState {
current: Modal | null
stash: Modal[]
}
30 changes: 30 additions & 0 deletions src/app/components/SettingsButton/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react'
import { SidebarButton } from '../Sidebar'
import { Configure } from 'grommet-icons'
import { useTranslation } from 'react-i18next'
import { SettingsDialog } from '../SettingsDialog'
import { selectIsSettingsDialogOpen } from '../SettingsDialog/slice/selectors'
import { useDispatch, useSelector } from 'react-redux'
import { settingsActions } from '../SettingsDialog/slice'
import { modalActions } from '../Modal/slice'

export const SettingsButton = () => {
const dispatch = useDispatch()
const layerVisibility = useSelector(selectIsSettingsDialogOpen)
const { t } = useTranslation()
const setLayerVisibility = (value: boolean) => dispatch(settingsActions.setOpen(value))
const closeSettings = () => {
dispatch(settingsActions.setOpen(false))
dispatch(modalActions.stashPop())
}
return (
<>
<SidebarButton
icon={<Configure />}
label={t('menu.settings', 'Settings')}
onClick={() => setLayerVisibility(true)}
/>
{layerVisibility && <SettingsDialog closeHandler={closeSettings} />}
</>
)
}
Loading