refactor: move lock/unlock requests from Wallet to Wallets (#351)

* refactor: move lock/unlock wallet api calls from child component to parent

* refactor: control Wallet component appearance from parent

* fix: prevent state update on an unmounted component on Settings page
This commit is contained in:
Thebora Kompanioni 2022-06-28 13:14:50 +02:00 committed by GitHub
parent a958ba6d88
commit cac503fb35
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 517 additions and 434 deletions

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { Route, Routes, Navigate } from 'react-router-dom'
import * as rb from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
@ -38,7 +38,7 @@ export default function App() {
const [showBetaWarning, setShowBetaWarning] = useState(false)
const [showCheatsheet, setShowCheatsheet] = useState(false)
const cheatsheetEnabled = currentWallet
const cheatsheetEnabled = useMemo(() => !!currentWallet, [currentWallet])
const startWallet = useCallback(
(name, token) => {
@ -122,7 +122,10 @@ export default function App() {
{!sessionConnectionError && (
<>
<Route element={<Layout />}>
<Route path={routes.home} element={<Wallets startWallet={startWallet} stopWallet={stopWallet} />} />
<Route
path={routes.home}
element={<Wallets currentWallet={currentWallet} startWallet={startWallet} stopWallet={stopWallet} />}
/>
{currentWallet && (
<>
<Route path={routes.jam} element={<Jam />} />

View file

@ -124,14 +124,13 @@ export default function Settings({ stopWallet }) {
const { name: walletName, token } = currentWallet
const res = await Api.getWalletLock({ walletName, token })
setLockingWallet(false)
if (res.ok || res.status === 401) {
stopWallet()
navigate(destination)
} else {
await Api.Helper.throwError(res)
}
setLockingWallet(false)
navigate(destination)
} catch (e) {
setLockingWallet(false)
setAlert({ variant: 'danger', dismissible: false, message: e.message })

View file

@ -1,11 +1,9 @@
import React, { useState, useCallback, useEffect } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import React, { useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Formik } from 'formik'
import * as rb from 'react-bootstrap'
import { ConfirmModal } from './Modal'
import { useTranslation } from 'react-i18next'
import { walletDisplayName } from '../utils'
import * as Api from '../libs/JmWalletApi'
import { TabActivityIndicator, JoiningIndicator } from './ActivityIndicators'
import { routes } from '../constants/routes'
import styles from './Wallet.module.css'
@ -104,146 +102,17 @@ const WalletUnlockForm = ({ walletName, unlockWallet }) => {
export default function Wallet({
name,
noneActive,
lockWallet,
unlockWallet,
isActive,
hasToken,
makerRunning,
coinjoinInProgress,
currentWallet,
startWallet,
stopWallet,
setAlert,
...props
}) {
const { t } = useTranslation()
const [showLockConfirmModal, setShowLockConfirmModal] = useState(false)
const navigate = useNavigate()
const unlockWallet = useCallback(
async (walletName, password) => {
if (currentWallet) {
setAlert({
variant: 'warning',
dismissible: false,
message:
currentWallet.name === name
? // unlocking same wallet
t('wallets.wallet_preview.alert_wallet_already_unlocked', { walletName: walletDisplayName(name) })
: // unlocking another wallet while one is already unlocked
t('wallets.wallet_preview.alert_other_wallet_unlocked', { walletName: walletDisplayName(name) }),
})
return
}
setAlert(null)
try {
const res = await Api.postWalletUnlock({ walletName }, { password })
const body = await (res.ok ? res.json() : Api.Helper.throwError(res))
const { walletname: unlockedWalletName, token } = body
startWallet(unlockedWalletName, token)
navigate(routes.wallet)
} catch (e) {
const message = e.message.replace('Wallet', walletName)
setAlert({ variant: 'danger', dismissible: false, message })
}
},
[currentWallet, name, setAlert, startWallet, t, navigate]
)
const lockWallet = useCallback(
async (lockableWalletName, { confirmed = false }) => {
if (!currentWallet || currentWallet.name !== lockableWalletName) {
setAlert({
variant: 'warning',
dismissible: false,
message: currentWallet
? // locking another wallet while active one is still unlocked
t('wallets.wallet_preview.alert_other_wallet_unlocked', {
walletName: walletDisplayName(currentWallet.name),
})
: // locking without active wallet
t('wallets.wallet_preview.alert_wallet_already_locked', {
walletName: walletDisplayName(lockableWalletName),
}),
})
return
}
const needsLockConfirmation = !confirmed && (coinjoinInProgress || makerRunning)
if (needsLockConfirmation) {
setShowLockConfirmModal(true)
return
}
setAlert(null)
try {
const { name: walletName, token } = currentWallet
const res = await Api.getWalletLock({ walletName, token })
// On status OK or UNAUTHORIZED, stop the wallet and clear all local
// information. The token might have become invalid or another one might have been
// issued for the same wallet, etc.
// In any case, the user has no access to the wallet anymore.
if (res.ok || res.status === 401) {
stopWallet()
}
const body = await (res.ok ? res.json() : Api.Helper.throwError(res))
const { walletname: lockedWalletName, already_locked } = body
setAlert({
variant: already_locked ? 'warning' : 'success',
dismissible: false,
message: already_locked
? t('wallets.wallet_preview.alert_wallet_already_locked', {
walletName: walletDisplayName(lockedWalletName),
})
: t('wallets.wallet_preview.alert_wallet_locked_successfully', {
walletName: walletDisplayName(lockedWalletName),
}),
})
} catch (e) {
setAlert({ variant: 'danger', dismissible: false, message: e.message })
}
},
[currentWallet, coinjoinInProgress, makerRunning, setAlert, stopWallet, t]
)
useEffect(() => {
if (!currentWallet) {
setShowLockConfirmModal(false)
}
}, [currentWallet])
const onLockConfirmed = useCallback(async () => {
if (!currentWallet) return
setShowLockConfirmModal(false)
await lockWallet(currentWallet.name, { confirmed: true })
}, [currentWallet, lockWallet])
const showLockOptions = isActive && hasToken
const showUnlockOptions = noneActive || (isActive && !hasToken) || (!hasToken && !makerRunning && !coinjoinInProgress)
return (
<>
<ConfirmModal
isShown={showLockConfirmModal}
title={t('wallets.wallet_preview.modal_lock_wallet_title')}
body={
(makerRunning
? t('wallets.wallet_preview.modal_lock_wallet_maker_running_text')
: t('wallets.wallet_preview.modal_lock_wallet_coinjoin_in_progress_text')) +
' ' +
t('wallets.wallet_preview.modal_lock_wallet_alternative_action_text')
}
onCancel={() => setShowLockConfirmModal(false)}
onConfirm={onLockConfirmed}
/>
<rb.Card {...props}>
<rb.Card.Body>
<div className="w-100 d-flex justify-content-between align-items-center flex-wrap py-1">
@ -269,11 +138,11 @@ export default function Wallet({
)}
</div>
{showLockOptions ? (
{lockWallet ? (
<WalletLockForm walletName={name} lockWallet={lockWallet} />
) : (
<div className={`w-100 mt-3 mt-md-0 ${styles['wallet-password-input']}`}>
{showUnlockOptions && <WalletUnlockForm walletName={name} unlockWallet={unlockWallet} />}
{unlockWallet && <WalletUnlockForm walletName={name} unlockWallet={unlockWallet} />}
</div>
)}
</div>

View file

@ -1,5 +1,5 @@
import React from 'react'
import { render, screen, waitFor, waitForElementToBeRemoved } from '../testUtils'
import { render, screen, waitFor } from '../testUtils'
import { act } from 'react-dom/test-utils'
import user from '@testing-library/user-event'
import * as apiMock from '../libs/JmWalletApi'
@ -10,45 +10,31 @@ import Wallet from './Wallet'
jest.mock('../libs/JmWalletApi', () => ({
...jest.requireActual('../libs/JmWalletApi'),
getSession: jest.fn(),
postWalletUnlock: jest.fn(),
getWalletLock: jest.fn(),
}))
const mockedNavigate = jest.fn()
jest.mock('react-router-dom', () => {
return {
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}
})
describe('<Wallet />', () => {
const dummyWalletName = 'dummy.jmdat'
const dummyToken = 'dummyToken'
const dummyPassword = 'correct horse battery staple'
const mockStartWallet = jest.fn()
const mockStopWallet = jest.fn()
const mockSetAlert = jest.fn()
const mockUnlockWallet = jest.fn()
const mockLockWallet = jest.fn()
const setup = ({
name,
lockWallet = undefined,
unlockWallet = undefined,
isActive = false,
hasToken = false,
makerRunning = false,
coinjoinInProgress = false,
currentWallet = null,
}) => {
render(
<Wallet
name={name}
lockWallet={lockWallet}
unlockWallet={unlockWallet}
isActive={isActive}
hasToken={hasToken}
makerRunning={makerRunning}
coinjoinInProgress={coinjoinInProgress}
currentWallet={currentWallet}
startWallet={mockStartWallet}
stopWallet={mockStopWallet}
setAlert={mockSetAlert}
makerRunning={makerRunning}
/>
)
}
@ -63,29 +49,23 @@ describe('<Wallet />', () => {
expect(screen.getByText(walletDisplayName(dummyWalletName))).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.wallet_inactive')).toBeInTheDocument()
expect(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
expect(screen.queryByPlaceholderText('wallets.wallet_preview.placeholder_password')).not.toBeInTheDocument()
expect(screen.queryByText('wallets.wallet_preview.button_unlock')).not.toBeInTheDocument()
expect(screen.queryByText('wallets.wallet_preview.button_open')).not.toBeInTheDocument()
expect(screen.queryByText('wallets.wallet_preview.button_lock')).not.toBeInTheDocument()
})
it('should unlock inactive wallet successfully', async () => {
apiMock.postWalletUnlock.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ walletname: dummyWalletName, token: dummyToken }),
})
await act(async () => setup({ name: dummyWalletName }))
await act(async () => setup({ name: dummyWalletName, unlockWallet: mockUnlockWallet }))
expect(screen.getByText('wallets.wallet_preview.wallet_inactive')).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
expect(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
expect(screen.queryByText('wallets.wallet_preview.button_open')).not.toBeInTheDocument()
expect(screen.queryByText('wallets.wallet_preview.button_lock')).not.toBeInTheDocument()
act(() => {
user.type(
screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password'),
'correct horse battery staple'
)
user.paste(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password'), dummyPassword)
})
await act(async () => {
@ -96,49 +76,34 @@ describe('<Wallet />', () => {
await waitFor(() => screen.findByText('wallets.wallet_preview.button_unlock'))
})
expect(mockStartWallet).toHaveBeenCalledWith(dummyWalletName, dummyToken)
expect(mockedNavigate).toHaveBeenCalledWith('/wallet')
expect(mockUnlockWallet).toHaveBeenCalledWith(dummyWalletName, dummyPassword)
})
it('should add alert if unlocking of inactive wallet fails', async () => {
const apiErrorMessage = 'ANY_ERROR_MESSAGE with template <Wallet>'
apiMock.postWalletUnlock.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ message: apiErrorMessage }),
})
it('should provide ability to unlock active wallet (i.e. when auth token is missing)', () => {
act(() =>
setup({
name: dummyWalletName,
isActive: true,
unlockWallet: mockUnlockWallet,
})
)
act(() => setup({ name: dummyWalletName }))
expect(screen.getByText('wallets.wallet_preview.wallet_inactive')).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
expect(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
act(() => {
user.type(
screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password'),
'correct horse battery staple'
)
})
await act(async () => {
const unlockWalletButton = screen.getByText('wallets.wallet_preview.button_unlock')
user.click(unlockWalletButton)
await waitFor(() => screen.findByText(/wallets.wallet_preview.button_unlocking/))
await waitFor(() => screen.findByText('wallets.wallet_preview.button_unlock'))
})
expect(mockStartWallet).not.toHaveBeenCalled()
expect(mockedNavigate).not.toHaveBeenCalled()
expect(mockSetAlert).toHaveBeenCalledWith({
variant: 'danger',
dismissible: false,
message: apiErrorMessage.replace('Wallet', dummyWalletName),
})
expect(screen.getByText(walletDisplayName(dummyWalletName))).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.wallet_active')).toBeInTheDocument()
expect(screen.queryByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
expect(screen.queryByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
expect(screen.queryByText('wallets.wallet_preview.button_open')).not.toBeInTheDocument()
expect(screen.queryByText('wallets.wallet_preview.button_lock')).not.toBeInTheDocument()
})
it('should render active wallet without errors', () => {
act(() => setup({ name: dummyWalletName, isActive: true, hasToken: true }))
act(() =>
setup({
name: dummyWalletName,
isActive: true,
lockWallet: mockLockWallet,
})
)
expect(screen.getByText(walletDisplayName(dummyWalletName))).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.wallet_active')).toBeInTheDocument()
@ -149,17 +114,11 @@ describe('<Wallet />', () => {
})
it('should lock active wallet successfully', async () => {
apiMock.getWalletLock.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ walletname: dummyWalletName, already_locked: false }),
})
act(() =>
setup({
name: dummyWalletName,
isActive: true,
hasToken: true,
currentWallet: { name: dummyWalletName, token: dummyToken },
lockWallet: mockLockWallet,
})
)
@ -174,97 +133,7 @@ describe('<Wallet />', () => {
await waitFor(() => screen.findByText('wallets.wallet_preview.button_lock'))
})
expect(mockStopWallet).toHaveBeenCalled()
expect(mockSetAlert).toHaveBeenCalledWith({
variant: 'success',
dismissible: false,
message: `wallets.wallet_preview.alert_wallet_locked_successfully`,
})
})
it('should add alert if locking active wallet fails', async () => {
const apiErrorMessage = 'ANY_ERROR_MESSAGE'
apiMock.getWalletLock.mockResolvedValueOnce({
ok: false,
status: 500, // any other error status than 401
json: () => Promise.resolve({ message: apiErrorMessage }),
})
act(() =>
setup({
name: dummyWalletName,
isActive: true,
hasToken: true,
currentWallet: { name: dummyWalletName, token: dummyToken },
})
)
expect(screen.getByText('wallets.wallet_preview.wallet_active')).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.button_lock')).toBeInTheDocument()
await act(async () => {
const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
user.click(lockWalletButton)
await waitFor(() => screen.findByText(/wallets.wallet_preview.button_locking/))
await waitFor(() => screen.findByText('wallets.wallet_preview.button_lock'))
})
// on errors other than 401, stopWallet should not have been called
expect(mockStopWallet).not.toHaveBeenCalled()
expect(mockSetAlert).toHaveBeenCalledWith({
variant: 'danger',
dismissible: false,
message: apiErrorMessage,
})
})
it('should add alert but clear wallet if locking active wallet fails with UNAUTHORIZED', async () => {
const apiErrorMessage = 'Invalid credentials.'
apiMock.getWalletLock.mockResolvedValueOnce({
ok: false,
status: 401,
json: () => Promise.resolve({ message: apiErrorMessage }),
})
act(() =>
setup({
name: dummyWalletName,
isActive: true,
hasToken: true,
currentWallet: { name: dummyWalletName, token: dummyToken },
})
)
expect(screen.getByText('wallets.wallet_preview.wallet_active')).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.button_lock')).toBeInTheDocument()
await act(async () => {
const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
user.click(lockWalletButton)
await waitFor(() => screen.findByText(/wallets.wallet_preview.button_locking/))
await waitFor(() => screen.findByText('wallets.wallet_preview.button_lock'))
})
// on 401 errors, stopWallet *should* have been called
expect(mockStopWallet).toHaveBeenCalled()
expect(mockSetAlert).toHaveBeenCalledWith({
variant: 'danger',
dismissible: false,
message: apiErrorMessage,
})
})
it('should provide ability to unlock active wallet when token is missing', () => {
act(() => setup({ name: dummyWalletName, isActive: true, hasToken: false }))
expect(screen.getByText(walletDisplayName(dummyWalletName))).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.wallet_active')).toBeInTheDocument()
expect(screen.queryByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
expect(screen.queryByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
expect(screen.queryByText('wallets.wallet_preview.button_open')).not.toBeInTheDocument()
expect(screen.queryByText('wallets.wallet_preview.button_lock')).not.toBeInTheDocument()
expect(mockLockWallet).toHaveBeenCalledWith(dummyWalletName, { confirmed: false })
})
it('should display earn indicator when maker is running', () => {
@ -280,71 +149,4 @@ describe('<Wallet />', () => {
expect(document.querySelector('.joining-indicator')).toBeInTheDocument()
expect(document.querySelector('.earn-indicator')).toBeNull()
})
it.each`
makerRunning | coinjoinInProgress | expectedModalBody
${true} | ${false} | ${'wallets.wallet_preview.modal_lock_wallet_maker_running_text wallets.wallet_preview.modal_lock_wallet_alternative_action_text'}
${false} | ${true} | ${'wallets.wallet_preview.modal_lock_wallet_coinjoin_in_progress_text wallets.wallet_preview.modal_lock_wallet_alternative_action_text'}
`(
'should confirm locking wallet if maker ($makerRunning) or taker ($coinjoinInProgress) is running',
async ({ makerRunning, coinjoinInProgress, expectedModalBody }) => {
apiMock.getWalletLock.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ walletname: dummyWalletName, already_locked: false }),
})
act(() =>
setup({
name: dummyWalletName,
isActive: true,
hasToken: true,
makerRunning,
coinjoinInProgress,
currentWallet: { name: dummyWalletName, token: dummyToken },
})
)
// modal is initially not shown
expect(screen.queryByText('wallets.wallet_preview.modal_lock_wallet_title')).not.toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.button_lock')).toBeInTheDocument()
await act(async () => {
// click on the "lock" button
const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
user.click(lockWalletButton)
})
// modal appeared
expect(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title')).toBeInTheDocument()
expect(screen.getByText(expectedModalBody)).toBeInTheDocument()
expect(screen.getByText('modal.confirm_button_accept')).toBeInTheDocument()
expect(screen.getByText('modal.confirm_button_reject')).toBeInTheDocument()
act(() => {
// click on the modal's "cancel" button
const lockWalletButton = screen.getByText('modal.confirm_button_reject')
user.click(lockWalletButton)
})
await waitForElementToBeRemoved(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title'))
expect(mockStopWallet).not.toHaveBeenCalled()
await act(async () => {
const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
user.click(lockWalletButton)
})
// modal appeared
expect(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title')).toBeInTheDocument()
act(() => {
// click on the modal's "confirm" button
const lockWalletButton = screen.getByText('modal.confirm_button_accept')
user.click(lockWalletButton)
})
await waitForElementToBeRemoved(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title'))
expect(mockStopWallet).toHaveBeenCalled()
}
)
})

View file

@ -1,16 +1,16 @@
import React, { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import React, { useMemo, useEffect, useCallback, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import * as rb from 'react-bootstrap'
import Sprite from './Sprite'
import Alert from './Alert'
import Wallet from './Wallet'
import PageTitle from './PageTitle'
import { useCurrentWallet } from '../context/WalletContext'
import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
import { useTranslation } from 'react-i18next'
import { walletDisplayName } from '../utils'
import * as Api from '../libs/JmWalletApi'
import { routes } from '../constants/routes'
import { ConfirmModal } from './Modal'
function arrayEquals(a, b) {
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => val === b[index])
@ -24,14 +24,124 @@ function sortWallets(wallets, activeWalletName = null) {
}
}
export default function Wallets({ startWallet, stopWallet }) {
export default function Wallets({ currentWallet, startWallet, stopWallet }) {
const { t } = useTranslation()
const currentWallet = useCurrentWallet()
const navigate = useNavigate()
const serviceInfo = useServiceInfo()
const reloadServiceInfo = useReloadServiceInfo()
const [walletList, setWalletList] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [alert, setAlert] = useState(null)
const [showLockConfirmModal, setShowLockConfirmModal] = useState(false)
const coinjoinInProgress = useMemo(() => serviceInfo && serviceInfo.coinjoinInProgress, [serviceInfo])
const makerRunning = useMemo(() => serviceInfo && serviceInfo.makerRunning, [serviceInfo])
const unlockWallet = useCallback(
async (walletName, password) => {
if (currentWallet) {
setAlert({
variant: 'warning',
dismissible: false,
message:
currentWallet.name === walletName
? // unlocking same wallet
t('wallets.wallet_preview.alert_wallet_already_unlocked', { walletName: walletDisplayName(walletName) })
: // unlocking another wallet while one is already unlocked
t('wallets.wallet_preview.alert_other_wallet_unlocked', { walletName: walletDisplayName(walletName) }),
})
return
}
setAlert(null)
try {
const res = await Api.postWalletUnlock({ walletName }, { password })
const body = await (res.ok ? res.json() : Api.Helper.throwError(res))
const { walletname: unlockedWalletName, token } = body
startWallet(unlockedWalletName, token)
navigate(routes.wallet)
} catch (e) {
const message = e.message.replace('Wallet', walletName)
setAlert({ variant: 'danger', dismissible: false, message })
}
},
[currentWallet, setAlert, startWallet, t, navigate]
)
const lockWallet = useCallback(
async (lockableWalletName, { confirmed = false }) => {
if (!currentWallet || currentWallet.name !== lockableWalletName) {
setAlert({
variant: 'warning',
dismissible: false,
message: currentWallet
? // locking another wallet while active one is still unlocked
t('wallets.wallet_preview.alert_other_wallet_unlocked', {
walletName: walletDisplayName(currentWallet.name),
})
: // locking without active wallet
t('wallets.wallet_preview.alert_wallet_already_locked', {
walletName: walletDisplayName(lockableWalletName),
}),
})
return
}
const needsLockConfirmation = !confirmed && (coinjoinInProgress || makerRunning)
if (needsLockConfirmation) {
setShowLockConfirmModal(true)
return
}
setAlert(null)
try {
const { name: walletName, token } = currentWallet
const res = await Api.getWalletLock({ walletName, token })
// On status OK or UNAUTHORIZED, stop the wallet and clear all local
// information. The token might have become invalid or another one might have been
// issued for the same wallet, etc.
// In any case, the user has no access to the wallet anymore.
if (res.ok || res.status === 401) {
stopWallet()
}
const body = await (res.ok ? res.json() : Api.Helper.throwError(res))
const { walletname: lockedWalletName, already_locked } = body
setAlert({
variant: already_locked ? 'warning' : 'success',
dismissible: false,
message: already_locked
? t('wallets.wallet_preview.alert_wallet_already_locked', {
walletName: walletDisplayName(lockedWalletName),
})
: t('wallets.wallet_preview.alert_wallet_locked_successfully', {
walletName: walletDisplayName(lockedWalletName),
}),
})
} catch (e) {
setAlert({ variant: 'danger', dismissible: false, message: e.message })
}
},
[currentWallet, coinjoinInProgress, makerRunning, setAlert, stopWallet, t]
)
useEffect(() => {
if (!currentWallet) {
setShowLockConfirmModal(false)
}
}, [currentWallet])
const onLockConfirmed = useCallback(async () => {
if (!currentWallet) return
setShowLockConfirmModal(false)
await lockWallet(currentWallet.name, { confirmed: true })
}, [currentWallet, lockWallet])
useEffect(() => {
if (walletList && serviceInfo) {
@ -76,50 +186,71 @@ export default function Wallets({ startWallet, stopWallet }) {
}, [currentWallet, reloadServiceInfo, t])
return (
<div className="wallets">
<PageTitle
title={t('wallets.title')}
subtitle={walletList?.length === 0 ? t('wallets.subtitle_no_wallets') : null}
center={true}
/>
{alert && <Alert {...alert} />}
{isLoading ? (
<div className="d-flex justify-content-center align-items-center">
<rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" />
<span>{t('wallets.text_loading')}</span>
</div>
) : (
walletList?.map((wallet, index) => (
<Wallet
key={wallet}
name={wallet}
noneActive={!serviceInfo?.walletName}
isActive={serviceInfo?.walletName === wallet}
hasToken={currentWallet && currentWallet.token && currentWallet.name === serviceInfo?.walletName}
makerRunning={serviceInfo?.makerRunning}
coinjoinInProgress={serviceInfo?.coinjoinInProgress}
currentWallet={currentWallet}
startWallet={startWallet}
stopWallet={stopWallet}
setAlert={setAlert}
className={`bg-transparent rounded-0 border-start-0 border-end-0 ${
index === 0 ? 'border-top-1' : 'border-top-0'
}`}
/>
))
)}
<div className="d-flex justify-content-center">
<Link
to={routes.createWallet}
className={`btn mt-4 ${walletList?.length === 0 ? 'btn-lg btn-dark' : 'btn-outline-dark'}`}
data-testid="new-wallet-btn"
>
<>
<div className="wallets">
<PageTitle
title={t('wallets.title')}
subtitle={walletList?.length === 0 ? t('wallets.subtitle_no_wallets') : null}
center={true}
/>
{alert && <Alert {...alert} />}
{isLoading ? (
<div className="d-flex justify-content-center align-items-center">
<Sprite symbol="plus" width="24" height="24" className="me-2" />
<span>{t('wallets.button_new_wallet')}</span>
<rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" />
<span>{t('wallets.text_loading')}</span>
</div>
</Link>
) : (
walletList?.map((wallet, index) => {
const noneActive = !serviceInfo?.walletName
const isActive = serviceInfo?.walletName === wallet
const hasToken = currentWallet && currentWallet.token && currentWallet.name === serviceInfo?.walletName
const showLockOptions = isActive && hasToken
const showUnlockOptions =
noneActive || (isActive && !hasToken) || (!hasToken && !makerRunning && !coinjoinInProgress)
return (
<Wallet
key={wallet}
name={wallet}
lockWallet={showLockOptions ? lockWallet : undefined}
unlockWallet={showUnlockOptions ? unlockWallet : undefined}
isActive={isActive}
makerRunning={makerRunning}
coinjoinInProgress={coinjoinInProgress}
className={`bg-transparent rounded-0 border-start-0 border-end-0 ${
index === 0 ? 'border-top-1' : 'border-top-0'
}`}
/>
)
})
)}
<div className="d-flex justify-content-center">
<Link
to={routes.createWallet}
className={`btn mt-4 ${walletList?.length === 0 ? 'btn-lg btn-dark' : 'btn-outline-dark'}`}
data-testid="new-wallet-btn"
>
<div className="d-flex justify-content-center align-items-center">
<Sprite symbol="plus" width="24" height="24" className="me-2" />
<span>{t('wallets.button_new_wallet')}</span>
</div>
</Link>
</div>
</div>
</div>
<ConfirmModal
isShown={showLockConfirmModal}
title={t('wallets.wallet_preview.modal_lock_wallet_title')}
body={
(makerRunning
? t('wallets.wallet_preview.modal_lock_wallet_maker_running_text')
: t('wallets.wallet_preview.modal_lock_wallet_coinjoin_in_progress_text')) +
' ' +
t('wallets.wallet_preview.modal_lock_wallet_alternative_action_text')
}
onCancel={() => setShowLockConfirmModal(false)}
onConfirm={onLockConfirmed}
/>
</>
)
}

View file

@ -1,6 +1,7 @@
import React from 'react'
import { render, screen, waitForElementToBeRemoved } from '../testUtils'
import { render, screen, waitFor, waitForElementToBeRemoved } from '../testUtils'
import { act } from 'react-dom/test-utils'
import user from '@testing-library/user-event'
import * as apiMock from '../libs/JmWalletApi'
@ -10,11 +11,24 @@ jest.mock('../libs/JmWalletApi', () => ({
...jest.requireActual('../libs/JmWalletApi'),
getSession: jest.fn(),
getWalletAll: jest.fn(),
postWalletUnlock: jest.fn(),
getWalletLock: jest.fn(),
}))
const mockedNavigate = jest.fn()
jest.mock('react-router-dom', () => {
return {
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}
})
describe('<Wallets />', () => {
const setup = () => {
render(<Wallets />)
const mockStartWallet = jest.fn()
const mockStopWallet = jest.fn()
const setup = ({ currentWallet = null }) => {
render(<Wallets currentWallet={currentWallet} startWallet={mockStartWallet} stopWallet={mockStopWallet} />)
}
beforeEach(() => {
@ -27,7 +41,7 @@ describe('<Wallets />', () => {
apiMock.getSession.mockResolvedValueOnce(neverResolvingPromise)
apiMock.getWalletAll.mockResolvedValueOnce(neverResolvingPromise)
act(setup)
act(() => setup({}))
expect(screen.getByText('wallets.title')).toBeInTheDocument()
expect(screen.getByText('wallets.text_loading')).toBeInTheDocument()
@ -42,7 +56,7 @@ describe('<Wallets />', () => {
ok: false,
})
act(setup)
act(() => setup({}))
expect(screen.getByText('wallets.title')).toBeInTheDocument()
expect(screen.getByText('wallets.button_new_wallet')).toBeInTheDocument()
@ -69,7 +83,7 @@ describe('<Wallets />', () => {
json: () => Promise.resolve({ wallets: [] }),
})
act(setup)
act(() => setup({}))
expect(screen.getByText('wallets.text_loading')).toBeInTheDocument()
@ -101,7 +115,7 @@ describe('<Wallets />', () => {
json: () => Promise.resolve({ wallets: ['wallet0.jmdat', 'wallet1.jmdat'] }),
})
act(setup)
act(() => setup({}))
expect(screen.getByText('wallets.text_loading')).toBeInTheDocument()
await waitForElementToBeRemoved(screen.getByText('wallets.text_loading'))
@ -115,4 +129,269 @@ describe('<Wallets />', () => {
expect(callToActionButton.classList.contains('btn')).toBe(true)
expect(callToActionButton.classList.contains('btn-lg')).toBe(false)
})
describe('<Wallets /> lock/unlock flow', () => {
const dummyWalletName = 'dummy.jmdat'
const dummyToken = 'dummyToken'
const dummyPassword = 'correct horse battery staple'
it('should unlock inactive wallet successfully', async () => {
apiMock.getSession.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
session: false,
maker_running: false,
coinjoin_in_process: false,
wallet_name: 'None',
}),
})
apiMock.getWalletAll.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ wallets: [dummyWalletName] }),
})
apiMock.postWalletUnlock.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ walletname: dummyWalletName, token: dummyToken }),
})
await act(async () => setup({}))
expect(screen.getByText('wallets.wallet_preview.wallet_inactive')).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
expect(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
act(() => {
user.paste(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password'), dummyPassword)
})
await act(async () => {
const unlockWalletButton = screen.getByText('wallets.wallet_preview.button_unlock')
user.click(unlockWalletButton)
await waitFor(() => screen.findByText(/wallets.wallet_preview.button_unlocking/))
await waitFor(() => screen.findByText('wallets.wallet_preview.button_unlock'))
})
expect(mockStartWallet).toHaveBeenCalledWith(dummyWalletName, dummyToken)
expect(mockedNavigate).toHaveBeenCalledWith('/wallet')
})
it('should add alert if unlocking of inactive wallet fails', async () => {
const apiErrorMessage = 'ANY_ERROR_MESSAGE with template <Wallet>'
apiMock.getSession.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
session: false,
maker_running: false,
coinjoin_in_process: false,
wallet_name: 'None',
}),
})
apiMock.getWalletAll.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ wallets: [dummyWalletName] }),
})
apiMock.postWalletUnlock.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ message: apiErrorMessage }),
})
await act(async () => setup({}))
expect(screen.getByText('wallets.wallet_preview.wallet_inactive')).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
expect(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
act(() => {
user.paste(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password'), dummyPassword)
})
await act(async () => {
const unlockWalletButton = screen.getByText('wallets.wallet_preview.button_unlock')
user.click(unlockWalletButton)
await waitFor(() => screen.findByText(/wallets.wallet_preview.button_unlocking/))
await waitFor(() => screen.findByText('wallets.wallet_preview.button_unlock'))
})
expect(mockStartWallet).not.toHaveBeenCalled()
expect(mockedNavigate).not.toHaveBeenCalled()
expect(screen.getByText(apiErrorMessage.replace('Wallet', dummyWalletName))).toBeInTheDocument()
})
it('should lock active wallet successfully', async () => {
apiMock.getSession.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
session: true,
maker_running: false,
coinjoin_in_process: false,
wallet_name: dummyWalletName,
}),
})
apiMock.getWalletAll.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ wallets: [dummyWalletName] }),
})
apiMock.getWalletLock.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ walletname: dummyWalletName, already_locked: false }),
})
await act(async () =>
setup({
currentWallet: {
name: dummyWalletName,
token: dummyToken,
},
})
)
expect(screen.getByText('wallets.wallet_preview.wallet_active')).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.button_lock')).toBeInTheDocument()
await act(async () => {
const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
user.click(lockWalletButton)
await waitFor(() => screen.findByText(/wallet_preview.button_locking/))
await waitFor(() => screen.findByText('wallets.wallet_preview.button_lock'))
})
expect(mockStopWallet).toHaveBeenCalled()
expect(screen.getByText('wallets.wallet_preview.alert_wallet_locked_successfully')).toBeInTheDocument()
})
it('should add alert but clear wallet if locking active wallet fails with UNAUTHORIZED', async () => {
const apiErrorMessage = 'Invalid credentials.'
apiMock.getSession.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
session: true,
maker_running: false,
coinjoin_in_process: false,
wallet_name: dummyWalletName,
}),
})
apiMock.getWalletAll.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ wallets: [dummyWalletName] }),
})
apiMock.getWalletLock.mockResolvedValueOnce({
ok: false,
status: 401,
json: () => Promise.resolve({ message: apiErrorMessage }),
})
await act(async () =>
setup({
currentWallet: {
name: dummyWalletName,
token: dummyToken,
},
})
)
expect(screen.getByText('wallets.wallet_preview.wallet_active')).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.button_lock')).toBeInTheDocument()
await act(async () => {
const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
user.click(lockWalletButton)
await waitFor(() => screen.findByText(/wallets.wallet_preview.button_locking/))
await waitFor(() => screen.findByText('wallets.wallet_preview.button_lock'))
})
// on 401 errors, stopWallet *should* have been called
expect(mockStopWallet).toHaveBeenCalled()
expect(screen.getByText(apiErrorMessage)).toBeInTheDocument()
})
it.each`
makerRunning | coinjoinInProgress | expectedModalBody
${true} | ${false} | ${'wallets.wallet_preview.modal_lock_wallet_maker_running_text wallets.wallet_preview.modal_lock_wallet_alternative_action_text'}
${false} | ${true} | ${'wallets.wallet_preview.modal_lock_wallet_coinjoin_in_progress_text wallets.wallet_preview.modal_lock_wallet_alternative_action_text'}
`(
'should confirm locking wallet if maker ($makerRunning) or taker ($coinjoinInProgress) is running',
async ({ makerRunning, coinjoinInProgress, expectedModalBody }) => {
apiMock.getSession.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
session: true,
maker_running: makerRunning,
coinjoin_in_process: coinjoinInProgress,
wallet_name: dummyWalletName,
}),
})
apiMock.getWalletAll.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ wallets: [dummyWalletName] }),
})
apiMock.getWalletLock.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ walletname: dummyWalletName, already_locked: false }),
})
await act(async () =>
setup({
currentWallet: {
name: dummyWalletName,
token: dummyToken,
},
})
)
// modal is initially not shown
expect(screen.queryByText('wallets.wallet_preview.modal_lock_wallet_title')).not.toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.button_lock')).toBeInTheDocument()
await act(async () => {
// click on the "lock" button
const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
user.click(lockWalletButton)
})
// modal appeared
expect(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title')).toBeInTheDocument()
expect(screen.getByText(expectedModalBody)).toBeInTheDocument()
expect(screen.getByText('modal.confirm_button_accept')).toBeInTheDocument()
expect(screen.getByText('modal.confirm_button_reject')).toBeInTheDocument()
act(() => {
// click on the modal's "cancel" button
const lockWalletButton = screen.getByText('modal.confirm_button_reject')
user.click(lockWalletButton)
})
await waitForElementToBeRemoved(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title'))
expect(mockStopWallet).not.toHaveBeenCalled()
await act(async () => {
const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
user.click(lockWalletButton)
})
// modal appeared
expect(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title')).toBeInTheDocument()
act(() => {
// click on the modal's "confirm" button
const lockWalletButton = screen.getByText('modal.confirm_button_accept')
user.click(lockWalletButton)
})
await waitForElementToBeRemoved(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title'))
expect(mockStopWallet).toHaveBeenCalled()
}
)
})
})