diff --git a/src/components/App.jsx b/src/components/App.jsx index 34b160f0..4877d19d 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -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 && ( <> }> - } /> + } + /> {currentWallet && ( <> } /> diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx index 2d7e51f3..80bbd771 100644 --- a/src/components/Settings.jsx +++ b/src/components/Settings.jsx @@ -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 }) diff --git a/src/components/Wallet.jsx b/src/components/Wallet.jsx index 33dfb007..45ede87b 100644 --- a/src/components/Wallet.jsx +++ b/src/components/Wallet.jsx @@ -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 ( <> - setShowLockConfirmModal(false)} - onConfirm={onLockConfirmed} - />
@@ -269,11 +138,11 @@ export default function Wallet({ )}
- {showLockOptions ? ( + {lockWallet ? ( ) : (
- {showUnlockOptions && } + {unlockWallet && }
)} diff --git a/src/components/Wallet.test.jsx b/src/components/Wallet.test.jsx index d84001e7..edd2fbac 100644 --- a/src/components/Wallet.test.jsx +++ b/src/components/Wallet.test.jsx @@ -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('', () => { 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( ) } @@ -63,29 +49,23 @@ describe('', () => { 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('', () => { 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 ' - 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('', () => { }) 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('', () => { 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('', () => { 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() - } - ) }) diff --git a/src/components/Wallets.jsx b/src/components/Wallets.jsx index e2af5b04..1881121a 100644 --- a/src/components/Wallets.jsx +++ b/src/components/Wallets.jsx @@ -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 ( -
- - {alert && } - {isLoading ? ( -
-
- ) : ( - walletList?.map((wallet, index) => ( - - )) - )} -
- + <> +
+ + {alert && } + {isLoading ? (
- - {t('wallets.button_new_wallet')} +
- + ) : ( + 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 ( + + ) + }) + )} +
+ +
+ + {t('wallets.button_new_wallet')} +
+ +
-
+ + setShowLockConfirmModal(false)} + onConfirm={onLockConfirmed} + /> + ) } diff --git a/src/components/Wallets.test.jsx b/src/components/Wallets.test.jsx index 3648f98e..7df48174 100644 --- a/src/components/Wallets.test.jsx +++ b/src/components/Wallets.test.jsx @@ -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('', () => { - const setup = () => { - render() + const mockStartWallet = jest.fn() + const mockStopWallet = jest.fn() + + const setup = ({ currentWallet = null }) => { + render() } beforeEach(() => { @@ -27,7 +41,7 @@ describe('', () => { 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('', () => { 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('', () => { json: () => Promise.resolve({ wallets: [] }), }) - act(setup) + act(() => setup({})) expect(screen.getByText('wallets.text_loading')).toBeInTheDocument() @@ -101,7 +115,7 @@ describe('', () => { 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('', () => { expect(callToActionButton.classList.contains('btn')).toBe(true) expect(callToActionButton.classList.contains('btn-lg')).toBe(false) }) + + describe(' 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 ' + + 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() + } + ) + }) })