Repository: joinmarket-webui/joinmarket-webui
Pull request #235 (2022-05-02 11:15:11)

Formik validation in Wallet component
One more step in the direction of using Formik for every form throughout the app.

This PR splits the former unified form for locking/unlocking a wallet into a `WalletUnlockForm` and a `WalletLockForm`.

The `WalletLockForm` really consists of a submit button only, so maybe it's not even necessary to wrap it in a form (I do not know if it has benefits for screen readers, etc.).. The button was in a form before, so it is in a form now – willing to change it if someone has feedback on that, as passing empty `initialValues` and noop `validate` method to Formik feels rather clumsy.

As far as I noticed, there are no visual changes.
Functionality change introduced: Before, the lock button just triggered locking the current wallet. After, the "lock" action now knows which "wallet" it belongs to. In practice this currently does not matter (as the lock button is only visible for the active wallet anyway).



Patch:
diff --git a/src/components/App.jsx b/src/components/App.jsx
index e4908ef..ff1daaf 100644
--- a/src/components/App.jsx
+++ b/src/components/App.jsx
@@ -46,10 +46,10 @@ export default function App() {
     [setCurrentWallet]
   )
 
-  const stopWallet = () => {
+  const stopWallet = useCallback(() => {
     clearSession()
     setCurrentWallet(null)
-  }
+  }, [setCurrentWallet])
 
   // update the connection indicator based on the websocket connection state
   useEffect(() => {
diff --git a/src/components/CreateWallet.jsx b/src/components/CreateWallet.jsx
index 5bdf4cd..4361003 100644
--- a/src/components/CreateWallet.jsx
+++ b/src/components/CreateWallet.jsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react'
+import React, { useState, useEffect, useCallback } from 'react'
 import * as rb from 'react-bootstrap'
 import { Link, useNavigate } from 'react-router-dom'
 import { Trans, useTranslation } from 'react-i18next'
@@ -59,10 +59,13 @@ const WalletCreationForm = ({ createWallet }) => {
     return errors
   }
 
-  const onSubmit = (values, { setSubmitting }) => {
-    const { walletName, password } = values
-    createWallet(walletName, password, () => setSubmitting(false))
-  }
+  const onSubmit = useCallback(
+    async (values) => {
+      const { walletName, password } = values
+      await createWallet(walletName, password)
+    },
+    [createWallet]
+  )
 
   return (
     <Formik initialValues={initialValues} validate={validate} onSubmit={onSubmit}>
@@ -125,7 +128,7 @@ const WalletCreationForm = ({ createWallet }) => {
             </rb.Form.Group>
             <rb.Button variant="dark" className={styles.button} type="submit" disabled={isSubmitting}>
               {isSubmitting ? (
-                <div>
+                <div className="d-flex justify-content-center align-items-center">
                   <rb.Spinner
                     as="span"
                     animation="border"
@@ -336,21 +339,22 @@ export default function CreateWallet({ startWallet }) {
   const [alert, setAlert] = useState(null)
   const [createdWallet, setCreatedWallet] = useState(null)
 
-  const createWallet = async (walletName, password, onFinished) => {
-    setAlert(null)
-
-    try {
-      const res = await Api.postWalletCreate({ walletname: walletName, password })
-      const body = await (res.ok ? res.json() : Api.Helper.throwError(res))
-
-      const { seedphrase, token, walletname: createdWalletName } = body
-      setCreatedWallet({ name: createdWalletName, seedphrase, password, token })
-      onFinished(true)
-    } catch (e) {
-      setAlert({ variant: 'danger', message: e.message })
-      onFinished(null, e)
-    }
-  }
+  const createWallet = useCallback(
+    async (walletName, password) => {
+      setAlert(null)
+
+      try {
+        const res = await Api.postWalletCreate({ walletname: walletName, password })
+        const body = await (res.ok ? res.json() : Api.Helper.throwError(res))
+
+        const { seedphrase, token, walletname: createdWalletName } = body
+        setCreatedWallet({ name: createdWalletName, seedphrase, password, token })
+      } catch (e) {
+        setAlert({ variant: 'danger', message: e.message })
+      }
+    },
+    [setAlert, setCreatedWallet]
+  )
 
   const walletConfirmed = () => {
     if (createdWallet.name && createdWallet.token) {
diff --git a/src/components/Earn.jsx b/src/components/Earn.jsx
index a0caa39..4ba688c 100644
--- a/src/components/Earn.jsx
+++ b/src/components/Earn.jsx
@@ -301,9 +301,8 @@ export default function Earn() {
     return errors
   }
 
-  const onSubmit = async (values, { setSubmitting }) => {
+  const onSubmit = async (values) => {
     if (isLoading || isSending || isWaitingMakerStart || isWaitingMakerStop) {
-      setSubmitting(false)
       return
     }
 
@@ -327,8 +326,6 @@ export default function Earn() {
     } catch (e) {
       setServiceInfoAlert(null)
       setAlert({ variant: 'danger', message: e.message })
-    } finally {
-      setSubmitting(false)
     }
   }
 
diff --git a/src/components/Send.jsx b/src/components/Send.jsx
index 3a38806..c434e53 100644
--- a/src/components/Send.jsx
+++ b/src/components/Send.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useCallback } from 'react'
+import React, { useEffect } from 'react'
 import { useState } from 'react'
 import { Link, useLocation } from 'react-router-dom'
 import { Trans, useTranslation } from 'react-i18next'
@@ -179,11 +179,23 @@ export default function Send() {
 
   const location = useLocation()
   const [alert, setAlert] = useState(null)
+
+  const [paymentSuccessfulInfoAlert, setPaymentSuccessfulInfoAlert] = useState(null)
+  const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState([])
+  const [waitForTakerToFinish, setWaitForTakerToFinish] = useState(false)
+  const [takerStartedInfoAlert, setTakerStartedInfoAlert] = useState(null)
+
   const [isLoading, setIsLoading] = useState(true)
   const [isSending, setIsSending] = useState(false)
+  const [isOperationDisabled, setIsOperationDisabled] = useState(false)
 
-  const isOperationDisabled = useCallback(() => {
-    return serviceInfo && (serviceInfo.makerRunning || serviceInfo.coinjoinInProgress)
+  useEffect(() => {
+    const coinjoinInProgress = serviceInfo && serviceInfo.coinjoinInProgress
+    const makerRunning = serviceInfo && serviceInfo.makerRunning
+    setIsOperationDisabled(makerRunning || coinjoinInProgress)
+
+    setWaitForTakerToFinish(coinjoinInProgress)
+    setTakerStartedInfoAlert((current) => (coinjoinInProgress ? current : null))
   }, [serviceInfo])
 
   const [isCoinjoin, setIsCoinjoin] = useState(IS_COINJOIN_DEFAULT_VAL)
@@ -231,7 +243,55 @@ export default function Send() {
     }
   }, [isSweep])
 
+  // This callback is responsible for updating the `isLoading` flag while the
+  // wallet is synchronizing. The wallet needs some time after a tx is sent
+  // to reflect the changes internally. In order to show the actual balance,
+  // all outputs in `waitForUtxosToBeSpent` must have been removed from the
+  // wallet's utxo set.
   useEffect(() => {
+    if (waitForUtxosToBeSpent.length === 0) return
+
+    setIsLoading(true)
+    const abortCtrl = new AbortController()
+
+    // Delaying the poll requests gives the wallet some time to synchronize
+    // the utxo set and reduces amount of http requests
+    const initialDelayInMs = 250
+    const timer = setTimeout(() => {
+      if (abortCtrl.signal.aborted) return
+
+      reloadCurrentWalletInfo({ signal: abortCtrl.signal })
+        .then((data) => {
+          if (abortCtrl.signal.aborted) return
+
+          const outputs = data.data.utxos.utxos.map((it) => it.utxo)
+          const utxosStillPresent = waitForUtxosToBeSpent.filter((it) => outputs.includes(it))
+          setWaitForUtxosToBeSpent([...utxosStillPresent])
+          setIsLoading(utxosStillPresent.length > 0)
+        })
+        .catch((err) => {
+          if (abortCtrl.signal.aborted) return
+
+          // Stop waiting for wallet synchronization on errors, but inform
+          // the user that loading the wallet info failed
+          setWaitForUtxosToBeSpent([])
+          setIsLoading(false)
+
+          const message = err.message || t('send.error_loading_wallet_failed')
+          setAlert({ variant: 'danger', message })
+        })
+    }, initialDelayInMs)
+
+    return () => {
+      abortCtrl.abort()
+      clearTimeout(timer)
+    }
+  }, [waitForUtxosToBeSpent, reloadCurrentWalletInfo, t])
+
+  useEffect(() => {
+    if (waitForTakerToFinish) return
+    if (waitForUtxosToBeSpent.length > 0) return
+
     const abortCtrl = new AbortController()
 
     setAlert(null)
@@ -267,10 +327,11 @@ export default function Send() {
     )
 
     return () => abortCtrl.abort()
-  }, [wallet, reloadCurrentWalletInfo, reloadServiceInfo, t])
+  }, [waitForTakerToFinish, waitForUtxosToBeSpent, wallet, reloadCurrentWalletInfo, reloadServiceInfo, t])
 
   const sendPayment = async (account, destination, amount_sats) => {
     setAlert(null)
+    setPaymentSuccessfulInfoAlert(null)
     setIsSending(true)
 
     const requestContext = { walletName: wallet.name, token: wallet.token }
@@ -279,17 +340,16 @@ export default function Send() {
     try {
       const res = await Api.postDirectSend(requestContext, { mixdepth: account, destination, amount_sats })
 
-      setIsSending(false)
-
       if (res.ok) {
         const {
-          txinfo: { outputs },
+          txinfo: { outputs, inputs },
         } = await res.json()
         const output = outputs.find((o) => o.address === destination)
-        setAlert({
+        setPaymentSuccessfulInfoAlert({
           variant: 'success',
           message: t('send.alert_payment_successful', { amount: output.value_sats, address: output.address }),
         })
+        setWaitForUtxosToBeSpent(inputs.map((it) => it.outpoint))
         success = true
       } else {
         const message = await Api.Helper.extractErrorMessage(res)
@@ -300,6 +360,8 @@ export default function Send() {
         )
         setAlert({ variant: 'danger', message: displayMessage })
       }
+
+      setIsSending(false)
     } catch (e) {
       setIsSending(false)
       setAlert({ variant: 'danger', message: e.message })
@@ -322,12 +384,13 @@ export default function Send() {
         counterparties,
       })
 
-      setIsSending(false)
-
       if (res.ok) {
         const data = await res.json()
         console.log(data)
-        setAlert({ variant: 'success', message: t('send.alert_coinjoin_started') })
+        setTakerStartedInfoAlert({
+          variant: 'success',
+          message: t('send.alert_coinjoin_started'),
+        })
         success = true
       } else {
         const message = await Api.Helper.extractErrorMessage(res)
@@ -340,6 +403,8 @@ export default function Send() {
 
         setAlert({ variant: 'danger', message: displayMessage })
       }
+
+      setIsSending(false)
     } catch (e) {
       setIsSending(false)
       setAlert({ variant: 'danger', message: e.message })
@@ -351,7 +416,7 @@ export default function Send() {
   const onSubmit = async (e) => {
     e.preventDefault()
 
-    if (isOperationDisabled()) return
+    if (isOperationDisabled) return
 
     const form = e.currentTarget
     const isValid = formIsValid
@@ -370,10 +435,11 @@ export default function Send() {
 
       if (success) {
         setDestination(initialDestination)
-        setAccount(initialAccount)
         setAmount(initialAmount)
         setNumCollaborators(initialNumCollaborators(minNumCollaborators))
         setIsCoinjoin(IS_COINJOIN_DEFAULT_VAL)
+        setIsSweep(false)
+
         form.reset()
       }
     }
@@ -513,7 +579,7 @@ export default function Send() {
         }`}
       >
         <PageTitle title={t('send.title')} subtitle={t('send.subtitle')} />
-        <rb.Fade in={isOperationDisabled()} mountOnEnter={true} unmountOnExit={true}>
+        <rb.Fade in={isOperationDisabled} mountOnEnter={true} unmountOnExit={true}>
           <>
             {serviceInfo?.makerRunning && (
               <Link to={routes.earn} className={styles.unstyled}>
@@ -541,6 +607,13 @@ export default function Send() {
           </rb.Alert>
         )}
 
+        {paymentSuccessfulInfoAlert && (
+          <rb.Alert variant={paymentSuccessfulInfoAlert.variant}>{paymentSuccessfulInfoAlert.message}</rb.Alert>
+        )}
+        {takerStartedInfoAlert && (
+          <rb.Alert variant={takerStartedInfoAlert.variant}>{takerStartedInfoAlert.message}</rb.Alert>
+        )}
+
         <rb.Form id="send-form" onSubmit={onSubmit} noValidate className={styles['maker-running-form']}>
           <rb.Form.Group className="mb-4 flex-grow-1" controlId="account">
             <rb.Form.Label>
@@ -557,7 +630,7 @@ export default function Send() {
                 required
                 className={`${styles['select']} slashed-zeroes`}
                 isInvalid={!isValidAccount(account)}
-                disabled={isOperationDisabled()}
+                disabled={isOperationDisabled}
               >
                 {walletInfo &&
                   walletInfo.data.display.walletinfo.accounts
@@ -592,13 +665,13 @@ export default function Send() {
                     required
                     onChange={(e) => setAmount(parseInt(e.target.value, 10))}
                     isInvalid={amount !== null && !isValidAmount(amount, isSweep)}
-                    disabled={isSweep || isOperationDisabled()}
+                    disabled={isSweep || isOperationDisabled}
                   />
                   <rb.Button
                     variant="outline-dark"
                     className={styles['button-sweep']}
                     onClick={() => setIsSweep(!isSweep)}
-                    disabled={isOperationDisabled()}
+                    disabled={isOperationDisabled}
                   >
                     {isSweep ? (
                       <div className={styles['button-sweep-item']}>{t('send.button_clear_sweep')}</div>
@@ -636,7 +709,7 @@ export default function Send() {
                 required
                 onChange={(e) => setDestination(e.target.value)}
                 isInvalid={destination !== null && !isValidAddress(destination)}
-                disabled={isOperationDisabled()}
+                disabled={isOperationDisabled}
               />
             )}
             <rb.Form.Control.Feedback type="invalid">{t('send.feedback_invalid_recipient')}</rb.Form.Control.Feedback>
@@ -646,7 +719,7 @@ export default function Send() {
               label={t('send.toggle_coinjoin')}
               initialValue={isCoinjoin}
               onToggle={(isToggled) => setIsCoinjoin(isToggled)}
-              disabled={isLoading || isOperationDisabled()}
+              disabled={isLoading || isOperationDisabled}
             />
           </rb.Form.Group>
         </rb.Form>
@@ -655,13 +728,13 @@ export default function Send() {
             numCollaborators={numCollaborators}
             setNumCollaborators={setNumCollaborators}
             minNumCollaborators={minNumCollaborators}
-            disabled={isLoading || isOperationDisabled()}
+            disabled={isLoading || isOperationDisabled}
           />
         )}
         <rb.Button
           variant="dark"
           type="submit"
-          disabled={isOperationDisabled() || isSending || !formIsValid}
+          disabled={isOperationDisabled || isLoading || isSending || !formIsValid}
           className={`${styles['button']} ${styles['send-button']} mt-4`}
           form="send-form"
         >
diff --git a/src/components/Wallet.jsx b/src/components/Wallet.jsx
index e2c1a79..56e3c2f 100644
--- a/src/components/Wallet.jsx
+++ b/src/components/Wallet.jsx
@@ -1,8 +1,9 @@
-import React, { useState } from 'react'
+import React, { useState, useCallback, useEffect } from 'react'
 import { Link, useNavigate } from 'react-router-dom'
+import { Formik } from 'formik'
 import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
-import { serialize, walletDisplayName } from '../utils'
+import { walletDisplayName } from '../utils'
 import * as Api from '../libs/JmWalletApi'
 import { EarnIndicator, JoiningIndicator } from './ActivityIndicators'
 import { routes } from '../constants/routes'
@@ -36,6 +37,98 @@ function ConfirmLockModal({ show = false, body, onHide, onConfirm }) {
   return <ConfirmModal show={show} onHide={onHide} title={title} body={body} footer={footer} />
 }
 
+const WalletLockForm = ({ walletName, lockWallet }) => {
+  const { t } = useTranslation()
+
+  const onSubmit = useCallback(async () => {
+    await lockWallet(walletName, { confirmed: false })
+  }, [walletName, lockWallet])
+
+  return (
+    <Formik initialValues={{}} validate={() => ({})} onSubmit={onSubmit}>
+      {({ handleSubmit, isSubmitting }) => (
+        <rb.Form onSubmit={handleSubmit} noValidate>
+          <Link className="btn btn-outline-dark me-2" to={routes.wallet}>
+            {t('wallets.wallet_preview.button_open')}
+          </Link>
+          <rb.Button variant="outline-dark" type="submit" disabled={isSubmitting}>
+            {isSubmitting ? (
+              <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" />
+                {t('wallets.wallet_preview.button_locking')}
+              </div>
+            ) : (
+              t('wallets.wallet_preview.button_lock')
+            )}
+          </rb.Button>
+        </rb.Form>
+      )}
+    </Formik>
+  )
+}
+
+const WalletUnlockForm = ({ walletName, unlockWallet }) => {
+  const { t } = useTranslation()
+
+  const initialValues = {
+    password: '',
+  }
+  const validate = (values) => {
+    const errors = {}
+    if (!values.password) {
+      errors.password = t('wallets.wallet_preview.feedback_missing_password')
+    }
+    return errors
+  }
+
+  const onSubmit = useCallback(
+    async (values) => {
+      const { password } = values
+      await unlockWallet(walletName, password)
+    },
+    [walletName, unlockWallet]
+  )
+
+  return (
+    <Formik initialValues={initialValues} validate={validate} onSubmit={onSubmit} validateOnBlur={false}>
+      {({ handleSubmit, handleChange, handleBlur, values, touched, errors, isSubmitting }) => (
+        <rb.Form onSubmit={handleSubmit} noValidate>
+          <rb.InputGroup hasValidation={true}>
+            <rb.Form.Control
+              name="password"
+              type="password"
+              placeholder={t('wallets.wallet_preview.placeholder_password')}
+              disabled={isSubmitting}
+              onChange={handleChange}
+              onBlur={handleBlur}
+              value={values.password}
+              isInvalid={touched.password && errors.password}
+            />
+            <rb.Button variant="outline-dark" className="py-1 px-3" type="submit" disabled={isSubmitting}>
+              {isSubmitting ? (
+                <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"
+                  />
+                  {t('wallets.wallet_preview.button_unlocking')}
+                </div>
+              ) : (
+                t('wallets.wallet_preview.button_unlock')
+              )}
+            </rb.Button>
+            <rb.Form.Control.Feedback type="invalid">{errors.password}</rb.Form.Control.Feedback>
+          </rb.InputGroup>
+        </rb.Form>
+      )}
+    </Formik>
+  )
+}
+
 export default function Wallet({
   name,
   noneActive,
@@ -50,58 +143,89 @@ export default function Wallet({
   ...props
 }) {
   const { t } = useTranslation()
-  const [validated, setValidated] = useState(false)
-  const [isLocking, setIsLocking] = useState(false)
-  const [isUnlocking, setIsUnlocking] = useState(false)
   const [showLockConfirmModal, setShowLockConfirmModal] = useState(false)
+  const [confirmLockBody, setConfirmLockBody] = useState(<></>)
 
   const navigate = useNavigate()
 
-  const unlockWallet = async (walletName, password) => {
-    setAlert(null)
-    setIsUnlocking(true)
-    try {
-      const res = await Api.postWalletUnlock({ walletName }, { password })
-      const body = await (res.ok ? res.json() : Api.Helper.throwError(res))
+  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
+      }
 
-      setIsUnlocking(false)
+      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('/wallet')
-    } catch (e) {
-      setIsUnlocking(false)
+        const { walletname: unlockedWalletName, token } = body
+        startWallet(unlockedWalletName, token)
+        navigate('/wallet')
+      } catch (e) {
+        const message = e.message.replace('Wallet', walletName)
+        setAlert({ variant: 'danger', dismissible: false, message })
+      }
+    },
+    [currentWallet, name, setAlert, startWallet, t, navigate]
+  )
 
-      const message = e.message.replace('Wallet', walletName)
-      setAlert({ variant: 'danger', message })
-    }
-  }
+  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 lockWallet = async () => {
-    setAlert(null)
-    setIsLocking(true)
+      const needsLockConfirmation = !confirmed && (coinjoinInProgress || makerRunning)
+      if (needsLockConfirmation) {
+        setShowLockConfirmModal(true)
+        return
+      }
 
-    try {
-      const { name: walletName, token } = currentWallet
+      setAlert(null)
 
-      const res = await Api.getWalletLock({ walletName, token })
+      try {
+        const { name: walletName, token } = currentWallet
 
-      setIsLocking(false)
+        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()
-      }
+        // 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.json()
-      if (res.ok) {
+        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),
@@ -109,175 +233,85 @@ export default function Wallet({
             : t('wallets.wallet_preview.alert_wallet_locked_successfully', {
                 walletName: walletDisplayName(lockedWalletName),
               }),
-          dismissible: false,
         })
-      } else {
-        setAlert({ variant: 'danger', message: body.message })
+      } catch (e) {
+        setAlert({ variant: 'danger', dismissible: false, message: e.message })
       }
-    } catch (e) {
-      setIsLocking(false)
-      setAlert({ variant: 'danger', message: e.message })
-    }
-  }
-
-  const onSubmit = (e) => {
-    e.preventDefault()
-
-    const form = e.currentTarget
-    const isValid = form.checkValidity()
-    setValidated(true)
-
-    if (isValid) {
-      const { action, password } = serialize(form)
-
-      switch (action) {
-        case 'unlock': {
-          if (currentWallet) {
-            setAlert({
-              variant: 'warning',
-              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) }),
-            })
-          } else {
-            unlockWallet(name, password)
-          }
-          break
-        }
-        case 'lock': {
-          const needsLockConfirmation = coinjoinInProgress || makerRunning
-          if (needsLockConfirmation) {
-            setShowLockConfirmModal(true)
-          } else {
-            lockWallet()
-          }
-          break
-        }
-        default:
-          break
-      }
-      setValidated(false)
-    }
-  }
-
-  const showLockOptions = isActive && hasToken
-  const showUnlockOptions = noneActive || (isActive && !hasToken) || (!hasToken && !makerRunning && !coinjoinInProgress)
+    },
+    [currentWallet, coinjoinInProgress, makerRunning, setAlert, stopWallet, t]
+  )
 
-  const confirmLockBody = (() => {
+  useEffect(() => {
     const serviceRunningInfoText =
       (makerRunning && t('wallets.wallet_preview.modal_lock_wallet_maker_running_text')) ||
       (coinjoinInProgress && t('wallets.wallet_preview.modal_lock_wallet_coinjoin_in_progress_text'))
-    return (
+
+    setConfirmLockBody(
       <>
         {serviceRunningInfoText}
         {serviceRunningInfoText ? ' ' : ''}
         {t('wallets.wallet_preview.modal_lock_wallet_alternative_action_text')}
       </>
     )
-  })()
+  }, [makerRunning, coinjoinInProgress, 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 (
     <>
       <ConfirmLockModal
         show={showLockConfirmModal}
         body={confirmLockBody}
-        onConfirm={() => {
-          setShowLockConfirmModal(false)
-          lockWallet()
-        }}
+        onConfirm={onLockConfirmed}
         onHide={() => {
           setShowLockConfirmModal(false)
         }}
       />
       <rb.Card {...props}>
         <rb.Card.Body>
-          <rb.Form onSubmit={onSubmit} validated={validated} noValidate>
-            <div className="d-flex justify-content-between align-items-center flex-wrap">
-              <div className="py-1">
-                <rb.Card.Title>
-                  {isActive ? (
-                    <span style={{ position: 'relative' }}>
-                      <Link className="wallet-name" to={routes.wallet}>
-                        {walletDisplayName(name)}
-                      </Link>
-                      {makerRunning && <EarnIndicator isOn={true} />}
-                      {coinjoinInProgress && <JoiningIndicator isOn={true} className="text-success" />}
-                    </span>
-                  ) : (
-                    <>{walletDisplayName(name)}</>
-                  )}
-                </rb.Card.Title>
+          <div className="d-flex justify-content-between align-items-center flex-wrap">
+            <div className="py-1">
+              <rb.Card.Title>
                 {isActive ? (
-                  <span className="text-success">{t('wallets.wallet_preview.wallet_active')}</span>
-                ) : (
-                  <span className="text-muted">{t('wallets.wallet_preview.wallet_inactive')}</span>
-                )}
-              </div>
-              <div>
-                {showLockOptions ? (
-                  <>
-                    <Link className="btn btn-outline-dark me-2" to={routes.wallet}>
-                      {t('wallets.wallet_preview.button_open')}
+                  <span style={{ position: 'relative' }}>
+                    <Link className="wallet-name" to={routes.wallet}>
+                      {walletDisplayName(name)}
                     </Link>
-                    <rb.FormControl type="hidden" name="action" value="lock" />
-                    <rb.Button variant="outline-dark" type="submit" disabled={isLocking}>
-                      {isLocking ? (
-                        <>
-                          <rb.Spinner
-                            as="span"
-                            animation="border"
-                            size="sm"
-                            role="status"
-                            aria-hidden="true"
-                            className="me-2"
-                          />
-                          {t('wallets.wallet_preview.button_locking')}
-                        </>
-                      ) : (
-                        <>{t('wallets.wallet_preview.button_lock')}</>
-                      )}
-                    </rb.Button>
-                  </>
+                    {makerRunning && <EarnIndicator isOn={true} />}
+                    {coinjoinInProgress && <JoiningIndicator isOn={true} className="text-success" />}
+                  </span>
                 ) : (
-                  showUnlockOptions && (
-                    <rb.InputGroup hasValidation={true}>
-                      <rb.FormControl
-                        type="password"
-                        placeholder={t('wallets.wallet_preview.placeholder_password')}
-                        name="password"
-                        disabled={isUnlocking}
-                        required
-                      />
-                      <rb.FormControl type="hidden" name="action" value="unlock" />
-                      <rb.Button variant="outline-dark" className="py-1 px-3" type="submit" disabled={isUnlocking}>
-                        {isUnlocking ? (
-                          <>
-                            <rb.Spinner
-                              as="span"
-                              animation="border"
-                              size="sm"
-                              role="status"
-                              aria-hidden="true"
-                              className="me-2"
-                            />
-                            {t('wallets.wallet_preview.button_unlocking')}
-                          </>
-                        ) : (
-                          <>{t('wallets.wallet_preview.button_unlock')}</>
-                        )}
-                      </rb.Button>
-                      <rb.Form.Control.Feedback type="invalid">
-                        {t('wallets.wallet_preview.feedback_missing_password')}
-                      </rb.Form.Control.Feedback>
-                    </rb.InputGroup>
-                  )
+                  <>{walletDisplayName(name)}</>
                 )}
-              </div>
+              </rb.Card.Title>
+              {isActive ? (
+                <span className="text-success">{t('wallets.wallet_preview.wallet_active')}</span>
+              ) : (
+                <span className="text-muted">{t('wallets.wallet_preview.wallet_inactive')}</span>
+              )}
+            </div>
+            <div>
+              {showLockOptions ? (
+                <WalletLockForm walletName={name} lockWallet={lockWallet} />
+              ) : (
+                showUnlockOptions && <WalletUnlockForm walletName={name} unlockWallet={unlockWallet} />
+              )}
             </div>
-          </rb.Form>
+          </div>
         </rb.Card.Body>
       </rb.Card>
     </>
diff --git a/src/utils.ts b/src/utils.ts
index 09ac7ab..0f7e51d 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,8 +1,6 @@
 export const BTC = 'BTC'
 export const SATS = 'sats'
 
-export const serialize = (form: HTMLFormElement) => Object.fromEntries(new FormData(form).entries())
-
 export const walletDisplayName = (name: string) => name.replace('.jmdat', '')
 
 export const displayDate = (string: string) => new Date(string).toLocaleString()


Test patch:
diff --git a/src/components/Wallet.test.jsx b/src/components/Wallet.test.jsx
index c001292..c5bb97e 100644
--- a/src/components/Wallet.test.jsx
+++ b/src/components/Wallet.test.jsx
@@ -81,11 +81,14 @@ describe('<Wallet />', () => {
     expect(screen.getByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
     expect(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
 
-    await act(async () => {
+    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)
 
@@ -110,11 +113,14 @@ describe('<Wallet />', () => {
     expect(screen.getByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
     expect(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
 
-    await act(async () => {
+    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)
 
@@ -126,6 +132,7 @@ describe('<Wallet />', () => {
     expect(mockedNavigate).not.toHaveBeenCalled()
     expect(mockSetAlert).toHaveBeenCalledWith({
       variant: 'danger',
+      dismissible: false,
       message: apiErrorMessage.replace('Wallet', dummyWalletName),
     })
   })
@@ -170,8 +177,8 @@ describe('<Wallet />', () => {
     expect(mockStopWallet).toHaveBeenCalled()
     expect(mockSetAlert).toHaveBeenCalledWith({
       variant: 'success',
-      message: `wallets.wallet_preview.alert_wallet_locked_successfully`,
       dismissible: false,
+      message: `wallets.wallet_preview.alert_wallet_locked_successfully`,
     })
   })
 
@@ -207,6 +214,7 @@ describe('<Wallet />', () => {
     expect(mockStopWallet).not.toHaveBeenCalled()
     expect(mockSetAlert).toHaveBeenCalledWith({
       variant: 'danger',
+      dismissible: false,
       message: apiErrorMessage,
     })
   })
@@ -243,6 +251,7 @@ describe('<Wallet />', () => {
     expect(mockStopWallet).toHaveBeenCalled()
     expect(mockSetAlert).toHaveBeenCalledWith({
       variant: 'danger',
+      dismissible: false,
       message: apiErrorMessage,
     })
   })
@@ -299,7 +308,7 @@ describe('<Wallet />', () => {
       expect(screen.queryByText('wallets.wallet_preview.modal_lock_wallet_title')).not.toBeInTheDocument()
       expect(screen.getByText('wallets.wallet_preview.button_lock')).toBeInTheDocument()
 
-      act(() => {
+      await act(async () => {
         // click on the "lock" button
         const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
         user.click(lockWalletButton)
@@ -320,7 +329,7 @@ describe('<Wallet />', () => {
 
       expect(mockStopWallet).not.toHaveBeenCalled()
 
-      act(() => {
+      await act(async () => {
         const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
         user.click(lockWalletButton)
       })