Repository: joinmarket-webui/joinmarket-webui (js)

Issue:
Remove "Skip" button when on mainnet
If the seed quiz can be skipped, it will be skipped. I would hide the button when Jam is running on mainnet. Skipping the quiz on any testnet is fine, of course.

I already got one message from a user who played around with Jam on his Umbrel node, didn't write down the seed, and wiped his Umbrel node soon after - forgetting that he had funds in JoinMarket.

This is bad, and I would prefer to err on the side of not burning sats, even though such a sacrifice could be seen as a donation to all 😅 

Pull request:
Remove skip button quiz screen
Closes #196 

- [x] Show "Skip" button only in dev mode
- [x] Prevent users from leaving the page by mistake, e.g. refreshing the page or closing the tab.
  Unfortunately, for now it wont protect from clicking a link inside the app, ie. the Jam logo
- [x] Minimal tests that verifies "skip" button is not shown when `env := 'test'`

One problem still needs to be addressed: In case the wallet was successfully created, but the backend goes down (for whatever reason), the whole screen is replaced by a "No connection" error message. This should be prevented when `CreateWallet` component is active. It is partly tracked in #149 and covered in #199 

Patch:
diff --git a/src/components/App.jsx b/src/components/App.jsx
index 8420813..f659cff 100644
--- a/src/components/App.jsx
+++ b/src/components/App.jsx
@@ -31,6 +31,8 @@ export default function App() {
   const settings = useSettings()
   const websocketState = useWebsocketState()
 
+  const devMode = process.env.NODE_ENV === 'development'
+
   const startWallet = useCallback(
     (name, token) => {
       setSession({ name, token })
@@ -89,7 +91,7 @@ export default function App() {
           <Routes>
             <Route element={<Layout />}>
               <Route exact path="/" element={<Wallets startWallet={startWallet} stopWallet={stopWallet} />} />
-              <Route path="create-wallet" element={<CreateWallet startWallet={startWallet} />} />
+              <Route path="create-wallet" element={<CreateWallet startWallet={startWallet} devMode={devMode} />} />
               {currentWallet && (
                 <>
                   <Route path="send" element={<Send />} />
diff --git a/src/components/CreateWallet.css b/src/components/CreateWallet.css
new file mode 100644
index 0000000..8102e4b
--- /dev/null
+++ b/src/components/CreateWallet.css
@@ -0,0 +1,14 @@
+.create-wallet form input {
+  height: 3.5rem;
+  width: 100%;
+}
+
+.create-wallet button {
+  height: 3rem;
+  width: 100%;
+}
+
+.seedword-index-backup {
+  width: 5ch;
+  justify-content: right;
+}
diff --git a/src/components/CreateWallet.jsx b/src/components/CreateWallet.jsx
index 8ed0727..3c58f37 100644
--- a/src/components/CreateWallet.jsx
+++ b/src/components/CreateWallet.jsx
@@ -8,6 +8,35 @@ import ToggleSwitch from './ToggleSwitch'
 import { serialize, walletDisplayName } from '../utils'
 import { useServiceInfo } from '../context/ServiceInfoContext'
 import * as Api from '../libs/JmWalletApi'
+import './CreateWallet.css'
+
+const PreventLeavingPageByMistake = () => {
+  // prompt users before refreshing or closing the page when this component is present.
+  // Firefox will show: "This page is asking you to confirm that you want to leave [...]"
+  // Chrome: "Leave site? Changes you made may not be saved."
+  useEffect(() => {
+    const abortCtrl = new AbortController()
+
+    window.addEventListener(
+      'beforeunload',
+      (event) => {
+        // cancel the event as stated by the standard.
+        event.preventDefault()
+
+        // Chrome requires returnValue to be set.
+        event.returnValue = ''
+
+        // return something to trigger a dialog
+        return ''
+      },
+      { signal: abortCtrl.signal }
+    )
+
+    return () => abortCtrl.abort()
+  }, [])
+
+  return <></>
+}
 
 const WalletCreationForm = ({ createWallet, isCreating }) => {
   const { t } = useTranslation()
@@ -28,6 +57,7 @@ const WalletCreationForm = ({ createWallet, isCreating }) => {
 
   return (
     <>
+      {isCreating && <PreventLeavingPageByMistake />}
       <rb.Form onSubmit={onSubmit} validated={validated} noValidate>
         <rb.Form.Group className="mb-4" controlId="walletName">
           <rb.Form.Label>{t('create_wallet.label_wallet_name')}</rb.Form.Label>
@@ -102,11 +132,13 @@ const SeedWordInput = ({ number, targetWord, isValid, setIsValid }) => {
   )
 }
 
-const BackupConfirmation = ({ createdWallet, walletConfirmed, parentStepSetter }) => {
+const BackupConfirmation = ({ createdWallet, walletConfirmed, parentStepSetter, devMode }) => {
+  const seedphrase = createdWallet.seedphrase.split(' ')
+
   const { t } = useTranslation()
   const [seedBackup, setSeedBackup] = useState(false)
-  let seedphrase = createdWallet.seedphrase.split(' ')
   const [seedWordConfirmations, setSeedWordConfirmations] = useState(new Array(seedphrase.length).fill(false))
+  const [showSkipButton] = useState(devMode)
 
   useEffect(() => {
     setSeedBackup(seedWordConfirmations.every((wordConfirmed) => wordConfirmed))
@@ -152,9 +184,13 @@ const BackupConfirmation = ({ createdWallet, walletConfirmed, parentStepSetter }
       </rb.Form>
       {seedBackup && <div className="text-center text-success">{t('create_wallet.feedback_seed_confirmed')}</div>}
 
-      <div className="d-flex mt-4 mb-4 gap-3">
+      <rb.Button variant="dark" onClick={() => walletConfirmed()} disabled={!seedBackup}>
+        {t('create_wallet.confirmation_button_fund_wallet')}
+      </rb.Button>
+
+      <div className="d-flex mt-4 mb-4 gap-4">
         <rb.Button
-          variant="dark"
+          variant="outline-dark"
           disabled={seedBackup}
           onClick={() => {
             parentStepSetter()
@@ -163,19 +199,17 @@ const BackupConfirmation = ({ createdWallet, walletConfirmed, parentStepSetter }
           {t('create_wallet.back_button')}
         </rb.Button>
 
-        <rb.Button variant="dark" onClick={() => walletConfirmed()} disabled={!seedBackup}>
-          {t('create_wallet.confirmation_button_fund_wallet')}
-        </rb.Button>
+        {showSkipButton && (
+          <rb.Button variant="outline-dark" onClick={() => walletConfirmed()} disabled={seedBackup}>
+            {t('create_wallet.skip_button')}
+          </rb.Button>
+        )}
       </div>
-
-      <rb.Button variant="outline-dark" onClick={() => walletConfirmed()} disabled={seedBackup}>
-        {t('create_wallet.skip_button')}
-      </rb.Button>
     </div>
   )
 }
 
-const WalletCreationConfirmation = ({ createdWallet, walletConfirmed }) => {
+const WalletCreationConfirmation = ({ createdWallet, walletConfirmed, devMode }) => {
   const { t } = useTranslation()
   const [userConfirmed, setUserConfirmed] = useState(false)
   const [revealSensitiveInfo, setRevealSensitiveInfo] = useState(false)
@@ -189,54 +223,56 @@ const WalletCreationConfirmation = ({ createdWallet, walletConfirmed }) => {
     setStep(0)
   }
 
-  if (step === 0) {
-    return (
-      <div>
-        <div className="mb-4">
-          <div>{t('create_wallet.confirmation_label_wallet_name')}</div>
-          <div className="fs-4">{walletDisplayName(createdWallet.name)}</div>
-        </div>
-        <div className="mb-4">
-          <Seedphrase seedphrase={createdWallet.seedphrase} isBlurred={!revealSensitiveInfo} />
-        </div>
-        <div className="mb-4">
-          <div>{t('create_wallet.confirmation_label_password')}</div>
-          <div className={`fs-4${revealSensitiveInfo ? '' : ' blurred-text'}`}>
-            {!revealSensitiveInfo ? 'randomrandom' : createdWallet.password}
+  return (
+    <>
+      <PreventLeavingPageByMistake />
+      {step === 0 ? (
+        <div>
+          <div className="mb-4">
+            <div>{t('create_wallet.confirmation_label_wallet_name')}</div>
+            <div className="fs-4">{walletDisplayName(createdWallet.name)}</div>
           </div>
+          <div className="mb-4">
+            <Seedphrase seedphrase={createdWallet.seedphrase} isBlurred={!revealSensitiveInfo} />
+          </div>
+          <div className="mb-4">
+            <div>{t('create_wallet.confirmation_label_password')}</div>
+            <div className={`fs-4${revealSensitiveInfo ? '' : ' blurred-text'}`}>
+              {!revealSensitiveInfo ? 'randomrandom' : createdWallet.password}
+            </div>
+          </div>
+          <div className="mb-2">
+            <ToggleSwitch
+              label={t('create_wallet.confirmation_toggle_reveal_info')}
+              onToggle={(isToggled) => {
+                setRevealSensitiveInfo(isToggled)
+                setSensitiveInfoWasRevealed(true)
+              }}
+            />
+          </div>
+          <div className="mb-4">
+            <ToggleSwitch
+              label={t('create_wallet.confirmation_toggle_info_written_down')}
+              onToggle={(isToggled) => setUserConfirmed(isToggled)}
+            />
+          </div>
+          <rb.Button variant="dark" disabled={!sensitiveInfoWasRevealed || !userConfirmed} onClick={() => setStep(1)}>
+            {t('create_wallet.next_button')}
+          </rb.Button>
         </div>
-        <div className="mb-2">
-          <ToggleSwitch
-            label={t('create_wallet.confirmation_toggle_reveal_info')}
-            onToggle={(isToggled) => {
-              setRevealSensitiveInfo(isToggled)
-              setSensitiveInfoWasRevealed(true)
-            }}
-          />
-        </div>
-        <div className="mb-4">
-          <ToggleSwitch
-            label={t('create_wallet.confirmation_toggle_info_written_down')}
-            onToggle={(isToggled) => setUserConfirmed(isToggled)}
-          />
-        </div>
-        <rb.Button variant="dark" disabled={!sensitiveInfoWasRevealed || !userConfirmed} onClick={() => setStep(1)}>
-          {t('create_wallet.next_button')}
-        </rb.Button>
-      </div>
-    )
-  } else {
-    return (
-      <BackupConfirmation
-        parentStepSetter={childStepSetter}
-        createdWallet={createdWallet}
-        walletConfirmed={walletConfirmed}
-      />
-    )
-  }
+      ) : (
+        <BackupConfirmation
+          parentStepSetter={childStepSetter}
+          createdWallet={createdWallet}
+          walletConfirmed={walletConfirmed}
+          devMode={devMode}
+        />
+      )}
+    </>
+  )
 }
 
-export default function CreateWallet({ startWallet }) {
+export default function CreateWallet({ startWallet, devMode = false }) {
   const { t } = useTranslation()
   const serviceInfo = useServiceInfo()
   const navigate = useNavigate()
@@ -291,7 +327,9 @@ export default function CreateWallet({ startWallet }) {
       )}
       {alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
       {canCreate && <WalletCreationForm createWallet={createWallet} isCreating={isCreating} />}
-      {isCreated && <WalletCreationConfirmation createdWallet={createdWallet} walletConfirmed={walletConfirmed} />}
+      {isCreated && (
+        <WalletCreationConfirmation createdWallet={createdWallet} walletConfirmed={walletConfirmed} devMode={devMode} />
+      )}
       {!canCreate && !isCreated && (
         <rb.Alert variant="warning">
           <Trans i18nKey="create_wallet.alert_other_wallet_unlocked">
diff --git a/src/components/Seedphrase.css b/src/components/Seedphrase.css
new file mode 100644
index 0000000..8ad770d
--- /dev/null
+++ b/src/components/Seedphrase.css
@@ -0,0 +1,16 @@
+.seedphrase {
+  gap: 0.3rem;
+}
+
+.seedword-index {
+  width: 2ch;
+}
+
+.seedphrase > div {
+  background-color: rgba(244, 244, 244, 1);
+  width: 9rem;
+}
+
+:root[data-theme='dark'] .seedphrase > div {
+  background-color: var(--bs-gray-800);
+}
diff --git a/src/components/Seedphrase.jsx b/src/components/Seedphrase.jsx
index a342b83..c372aad 100644
--- a/src/components/Seedphrase.jsx
+++ b/src/components/Seedphrase.jsx
@@ -1,4 +1,5 @@
 import React from 'react'
+import './Seedphrase.css'
 
 export default function Seedphrase({ seedphrase, isBlurred = true }) {
   return (
diff --git a/src/index.css b/src/index.css
index db46bb0..d5bce91 100644
--- a/src/index.css
+++ b/src/index.css
@@ -357,24 +357,7 @@ main {
   }
 }
 
-/* Wallet Creation Styles */
-
-.seedphrase {
-  gap: 0.3rem;
-}
-
-.seedphrase > div {
-  background-color: rgba(244, 244, 244, 1);
-  width: 8rem;
-}
-
-.seedword-index {
-  width: 2ch;
-}
-
-.seedword-index-backup {
-  width: 5ch;
-}
+/* Blurred Text Styles */
 
 .blurred-text {
   color: transparent;
@@ -386,20 +369,6 @@ main {
   text-shadow: 0 0 15px var(--bs-gray-400);
 }
 
-:root[data-theme='dark'] .seedphrase > div {
-  background-color: var(--bs-gray-800);
-}
-
-.create-wallet form input {
-  height: 3.5rem;
-  width: 100%;
-}
-
-.create-wallet button {
-  height: 3rem;
-  width: 100%;
-}
-
 /* Send, Receive, Earn Styles */
 
 .earn form input:not([type='checkbox']) {


Test patch:
diff --git a/src/components/CreateWallet.test.jsx b/src/components/CreateWallet.test.jsx
new file mode 100644
index 0000000..01c358c
--- /dev/null
+++ b/src/components/CreateWallet.test.jsx
@@ -0,0 +1,162 @@
+import React from 'react'
+import user from '@testing-library/user-event'
+import { render, screen, waitFor, waitForElementToBeRemoved } from '../testUtils'
+import { act } from 'react-dom/test-utils'
+
+import * as apiMock from '../libs/JmWalletApi'
+
+import CreateWallet from './CreateWallet'
+
+jest.mock('../libs/JmWalletApi', () => ({
+  ...jest.requireActual('../libs/JmWalletApi'),
+  getSession: jest.fn(),
+  postWalletCreate: jest.fn(),
+}))
+
+const NOOP = () => {}
+
+describe('<CreateWallet />', () => {
+  const setup = (props) => {
+    const startWallet = props?.startWallet || NOOP
+    const devMode = props?.devMode || false
+    render(<CreateWallet startWallet={startWallet} devMode={devMode} />)
+  }
+
+  beforeEach(() => {
+    const neverResolvingPromise = new Promise(() => {})
+    apiMock.getSession.mockReturnValue(neverResolvingPromise)
+  })
+
+  it('should render without errors', () => {
+    act(setup)
+
+    expect(screen.getByText('create_wallet.title')).toBeInTheDocument()
+    expect(screen.getByLabelText('create_wallet.label_wallet_name')).toBeInTheDocument()
+    expect(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name')).toBeInTheDocument()
+    expect(screen.getByLabelText('create_wallet.label_password')).toBeInTheDocument()
+    expect(screen.getByPlaceholderText('create_wallet.placeholder_password')).toBeInTheDocument()
+    expect(screen.getByText('create_wallet.button_create')).toBeInTheDocument()
+  })
+
+  it('should show validation messages to user if form is invalid', () => {
+    act(setup)
+
+    expect(screen.getByText('create_wallet.button_create')).toBeInTheDocument()
+
+    act(() => {
+      // click on the "create" button without filling the form
+      const createWalletButton = screen.getByText('create_wallet.button_create')
+      user.click(createWalletButton)
+    })
+
+    expect(screen.getByText('create_wallet.feedback_invalid_wallet_name')).toBeVisible()
+    expect(screen.getByText('create_wallet.feedback_invalid_password')).toBeVisible()
+  })
+
+  it('should advance to WalletCreationConfirmation after wallet is created', async () => {
+    const walletName = 'wallet'
+
+    apiMock.postWalletCreate.mockResolvedValueOnce({
+      ok: true,
+      json: () =>
+        Promise.resolve({
+          walletname: `${walletName}.jmdat`,
+          token: 'ANY_TOKEN',
+          seedphrase: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
+        }),
+    })
+
+    act(setup)
+
+    expect(screen.getByText('create_wallet.button_create')).toBeInTheDocument()
+    expect(screen.queryByText('create_wallet.title_wallet_created')).not.toBeInTheDocument()
+
+    await act(async () => {
+      user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), walletName)
+      user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), 'password')
+      const createWalletButton = screen.getByText('create_wallet.button_create')
+      user.click(createWalletButton)
+
+      await waitFor(() => screen.findByText(/create_wallet.button_creating/))
+    })
+
+    expect(screen.queryByText('create_wallet.button_create')).not.toBeInTheDocument()
+    expect(screen.getByText('create_wallet.title_wallet_created')).toBeInTheDocument()
+  })
+
+  it('should verify that "skip" button is NOT visible when not in development mode', async () => {
+    const walletName = 'wallet'
+
+    apiMock.postWalletCreate.mockResolvedValueOnce({
+      ok: true,
+      json: () =>
+        Promise.resolve({
+          walletname: `${walletName}.jmdat`,
+          token: 'ANY_TOKEN',
+          seedphrase: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
+        }),
+    })
+
+    act(() => setup({ devMode: false }))
+
+    await act(async () => {
+      user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), walletName)
+      user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), 'password')
+      const createWalletButton = screen.getByText('create_wallet.button_create')
+      user.click(createWalletButton)
+
+      await waitFor(() => screen.findByText(/create_wallet.button_creating/))
+
+      const revealToggle = screen.getByText('create_wallet.confirmation_toggle_reveal_info')
+      user.click(revealToggle)
+
+      const confirmToggle = screen.getByText('create_wallet.confirmation_toggle_info_written_down')
+      user.click(confirmToggle)
+
+      const nextButton = screen.getByText('create_wallet.next_button')
+      user.click(nextButton)
+    })
+
+    expect(screen.queryByText('create_wallet.skip_button')).not.toBeInTheDocument()
+    expect(screen.getByText('create_wallet.back_button')).toBeInTheDocument()
+    expect(screen.getByText('create_wallet.confirmation_button_fund_wallet')).toBeDisabled()
+  })
+
+  it('should verify that "skip" button IS visible in development mode', async () => {
+    const walletName = 'wallet'
+
+    apiMock.postWalletCreate.mockResolvedValueOnce({
+      ok: true,
+      json: () =>
+        Promise.resolve({
+          walletname: `${walletName}.jmdat`,
+          token: 'ANY_TOKEN',
+          seedphrase: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
+        }),
+    })
+
+    act(() => setup({ devMode: true }))
+
+    await act(async () => {
+      user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), walletName)
+      user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), 'password')
+      const createWalletButton = screen.getByText('create_wallet.button_create')
+      user.click(createWalletButton)
+
+      await waitFor(() => screen.findByText(/create_wallet.button_creating/))
+
+      const revealToggle = screen.getByText('create_wallet.confirmation_toggle_reveal_info')
+      user.click(revealToggle)
+
+      const confirmToggle = screen.getByText('create_wallet.confirmation_toggle_info_written_down')
+      user.click(confirmToggle)
+
+      const nextButton = screen.getByText('create_wallet.next_button')
+      user.click(nextButton)
+    })
+
+    expect(screen.getByText('create_wallet.skip_button')).toBeInTheDocument()
+    expect(screen.getByText('create_wallet.back_button')).toBeInTheDocument()
+    expect(screen.getByText('create_wallet.confirmation_button_fund_wallet')).toBeDisabled()
+  })
+})