Repository: polkadot-js/extension (ts)

Issue:
Warn when typing passwords with caps lock on
I accidentally had my caps lock on when creating the account and typing the password.
It worked fine, but later on I couldn't unlock the account (since caps lock was off).

I think it would make sense to warn users if Caps Lock is on whenever typing password (create account or confirm transaction).

Pull request description:
unify warning and error and add capslock warning
- closes #540
- refactor to unify the input error and warning
- fly-by bug fix

This is how a warning + error looks like on signing
![warning](https://user-images.githubusercontent.com/33178835/100132600-f02de600-2e85-11eb-977d-904f0cd1a383.gif)

And here on the derivation flow
![warning-3](https://user-images.githubusercontent.com/33178835/100133153-af829c80-2e86-11eb-8952-86b6765caf2c.gif)



Patch:
diff --git a/packages/extension-ui/src/Popup/Authorize/Request.tsx b/packages/extension-ui/src/Popup/Authorize/Request.tsx
index 3602e49b..a28b5ac2 100644
--- a/packages/extension-ui/src/Popup/Authorize/Request.tsx
+++ b/packages/extension-ui/src/Popup/Authorize/Request.tsx
@@ -60,7 +60,9 @@ function Request ({ authId, className, isFirst, request: { origin }, url }: Prop
         </div>
         {isFirst && (
           <>
-            <Warning className='warningMargin'>{t<string>('Only approve this request if you trust the application. Approving gives the application access to the addresses of your accounts.')}</Warning>
+            <Warning className='warningMargin'>
+              {t<string>('Only approve this request if you trust the application. Approving gives the application access to the addresses of your accounts.')}
+            </Warning>
             <Button
               className='acceptButton'
               onClick={_onApprove}
diff --git a/packages/extension-ui/src/Popup/CreateAccount/Mnemonic.tsx b/packages/extension-ui/src/Popup/CreateAccount/Mnemonic.tsx
index 2aa667bf..9da39429 100644
--- a/packages/extension-ui/src/Popup/CreateAccount/Mnemonic.tsx
+++ b/packages/extension-ui/src/Popup/CreateAccount/Mnemonic.tsx
@@ -39,7 +39,9 @@ function Mnemonic ({ onNextStep, seed }: Props): React.ReactElement<Props> {
         onCopy={_onCopy}
         seed={seed}
       />
-      <Warning>{t<string>("Please write down your wallet's mnemonic seed and keep it in a safe place. The mnemonic can be used to restore your wallet. Keep it carefully to not lose your assets.")}</Warning>
+      <Warning>
+        {t<string>("Please write down your wallet's mnemonic seed and keep it in a safe place. The mnemonic can be used to restore your wallet. Keep it carefully to not lose your assets.")}
+      </Warning>
       <VerticalSpace />
       <Checkbox
         checked={isMnemonicSaved}
diff --git a/packages/extension-ui/src/Popup/Derive/SelectParent.tsx b/packages/extension-ui/src/Popup/Derive/SelectParent.tsx
index 831099b7..15f560a5 100644
--- a/packages/extension-ui/src/Popup/Derive/SelectParent.tsx
+++ b/packages/extension-ui/src/Popup/Derive/SelectParent.tsx
@@ -7,7 +7,7 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState }
 import styled from 'styled-components';
 import { assert } from '@polkadot/util';
 
-import { AccountContext, ActionContext, Address, ButtonArea, Checkbox, InputWithLabel, Label, NextStepButton, VerticalSpace } from '../../components';
+import { AccountContext, ActionContext, Address, ButtonArea, Checkbox, InputWithLabel, Label, NextStepButton, VerticalSpace, Warning } from '../../components';
 import useTranslation from '../../hooks/useTranslation';
 import { validateAccount, validateDerivationPath } from '../../messaging';
 import { nextDerivationPath } from '../../util/nextDerivationPath';
@@ -132,7 +132,14 @@ function SelectParent ({ className, isLocked, onDerivationConfirmed, parentAddre
               type='password'
               value={parentPassword}
             />
-            {!!parentPassword && !isProperParentPassword && <div className='error'>{t('Wrong password')}</div>}
+            {!!parentPassword && !isProperParentPassword && (
+              <Warning
+                isBelowInput
+                isDanger
+              >
+                {t('Wrong password')}
+              </Warning>
+            )}
           </div>
           {isProperParentPassword && (
             <>
@@ -143,7 +150,14 @@ function SelectParent ({ className, isLocked, onDerivationConfirmed, parentAddre
                 parentAddress={parentAddress}
                 parentPassword={parentPassword}
               />
-              {!suriPath && <div className='error'>{t('Incorrect derivation path')}</div>}
+              {!suriPath && (
+                <Warning
+                  isBelowInput
+                  isDanger
+                >
+                  {t('Incorrect derivation path')}
+                </Warning>
+              )}
             </>
           )}
         </div>
@@ -175,8 +189,7 @@ function SelectParent ({ className, isLocked, onDerivationConfirmed, parentAddre
   );
 }
 
-export default styled(SelectParent)(({ theme }: ThemeProps) => `
-
+export default styled(SelectParent)`
   .smallerMargin {
     margin-top: 0;
     margin-bottom: 10px;
@@ -190,12 +203,4 @@ export default styled(SelectParent)(({ theme }: ThemeProps) => `
       pointer-events: none;
     }
   }
-
-  .error {
-    display: block;
-    margin-top: -10px;
-    font-size: ${theme.labelFontSize};
-    line-height: ${theme.labelLineHeight};
-    color: ${theme.errorColor};
-  }
-`);
+`;
diff --git a/packages/extension-ui/src/Popup/Export.tsx b/packages/extension-ui/src/Popup/Export.tsx
index 20bae1fa..f4e67a53 100644
--- a/packages/extension-ui/src/Popup/Export.tsx
+++ b/packages/extension-ui/src/Popup/Export.tsx
@@ -64,10 +64,7 @@ function Export ({ className, match: { params: { address } } }: Props): React.Re
       />
       <div className={className}>
         <Address address={address}>
-          <Warning
-            className='movedWarning'
-            isDanger
-          >
+          <Warning className='movedWarning'>
             {t<string>("You are exporting your account. Keep it safe and don't share it with anyone.")}
           </Warning>
           <div className='actionArea'>
diff --git a/packages/extension-ui/src/Popup/Forget.tsx b/packages/extension-ui/src/Popup/Forget.tsx
index 87f1d9ad..646ee125 100644
--- a/packages/extension-ui/src/Popup/Forget.tsx
+++ b/packages/extension-ui/src/Popup/Forget.tsx
@@ -40,10 +40,7 @@ function Forget ({ className, match: { params: { address } } }: Props): React.Re
       />
       <div className={className}>
         <Address address={address}>
-          <Warning
-            className='movedWarning'
-            isDanger
-          >
+          <Warning className='movedWarning'>
             {t<string>('You are about to remove the account. This means that you will not be able to access it via this extension anymore. If you wish to recover it, you would need to use the seed.')}
           </Warning>
           <div className='actionArea'>
diff --git a/packages/extension-ui/src/Popup/Metadata/Request.tsx b/packages/extension-ui/src/Popup/Metadata/Request.tsx
index 6530d78f..f24e34ec 100644
--- a/packages/extension-ui/src/Popup/Metadata/Request.tsx
+++ b/packages/extension-ui/src/Popup/Metadata/Request.tsx
@@ -71,7 +71,9 @@ function Request ({ className, metaId, request, url }: Props): React.ReactElemen
         </tr>
       </Table>
       <div className='requestInfo'>
-        <Warning className='requestWarning'>{t<string>('This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata.')}</Warning>
+        <Warning className='requestWarning'>
+          {t<string>('This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata.')}
+        </Warning>
         <Button
           className='btnAccept'
           onClick={_onApprove}
diff --git a/packages/extension-ui/src/Popup/Signing/Unlock.tsx b/packages/extension-ui/src/Popup/Signing/Unlock.tsx
index 1b3799c1..3137d35f 100644
--- a/packages/extension-ui/src/Popup/Signing/Unlock.tsx
+++ b/packages/extension-ui/src/Popup/Signing/Unlock.tsx
@@ -1,15 +1,12 @@
 // Copyright 2019-2020 @polkadot/extension-ui authors & contributors
 // SPDX-License-Identifier: Apache-2.0
 
-import { ThemeProps } from '@polkadot/extension-ui/types';
-
 import React, { useCallback } from 'react';
-import styled from 'styled-components';
 
-import { InputWithLabel } from '../../components';
+import { InputWithLabel, Warning } from '../../components';
 import useTranslation from '../../hooks/useTranslation';
 
-interface Props extends ThemeProps {
+interface Props {
   className?: string;
   error?: string | null;
   isBusy: boolean;
@@ -43,15 +40,16 @@ function Unlock ({ className, error, isBusy, onSign, password, setError, setPass
         value={password}
         withoutMargin={true}
       />
-      {error && <div className='error'>{error}</div>}
+      {error && (
+        <Warning
+          isBelowInput
+          isDanger
+        >
+          {error}
+        </Warning>
+      )}
     </div>
   );
 }
 
-export default React.memo(styled(Unlock)(({ theme }: ThemeProps) => `
-  .error {
-    font-size: ${theme.labelFontSize};
-    line-height: ${theme.labelLineHeight};
-    color: ${theme.errorColor};
-  }
-`));
+export default React.memo(Unlock);
diff --git a/packages/extension-ui/src/components/InputWithLabel.tsx b/packages/extension-ui/src/components/InputWithLabel.tsx
index c69efdc3..ae8801dd 100644
--- a/packages/extension-ui/src/components/InputWithLabel.tsx
+++ b/packages/extension-ui/src/components/InputWithLabel.tsx
@@ -1,11 +1,12 @@
 // Copyright 2019-2020 @polkadot/extension-ui authors & contributors
 // SPDX-License-Identifier: Apache-2.0
 
-import React, { useCallback } from 'react';
+import React, { useCallback, useState } from 'react';
 import styled from 'styled-components';
 
 import Label from './Label';
 import { Input } from './TextInputs';
+import Warning from './Warning';
 
 interface Props {
   className?: string;
@@ -25,11 +26,21 @@ interface Props {
 }
 
 function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused, isReadOnly, label = '', onBlur, onChange, onEnter, placeholder, type = 'text', value, withoutMargin }: Props): React.ReactElement<Props> {
-  const _checkEnter = useCallback(
+  const [isCapsLock, setIsCapsLock] = useState(false);
+
+  const _checkKey = useCallback(
     (event: React.KeyboardEvent<HTMLInputElement>): void => {
       onEnter && event.key === 'Enter' && onEnter();
+
+      if (type === 'password') {
+        if (event.getModifierState('CapsLock')) {
+          setIsCapsLock(true);
+        } else {
+          setIsCapsLock(false);
+        }
+      }
     },
-    [onEnter]
+    [onEnter, type]
   );
 
   const _onChange = useCallback(
@@ -52,7 +63,7 @@ function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused
         disabled={disabled}
         onBlur={onBlur}
         onChange={_onChange}
-        onKeyPress={_checkEnter}
+        onKeyPress={_checkKey}
         placeholder={placeholder}
         readOnly={isReadOnly}
         spellCheck={false}
@@ -60,6 +71,9 @@ function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused
         value={value}
         withError={isError}
       />
+      { isCapsLock && (
+        <Warning isBelowInput >Warning: Caps lock is on</Warning>
+      )}
     </Label>
   );
 }
@@ -69,5 +83,9 @@ export default styled(InputWithLabel)`
 
   &.withoutMargin {
     margin-bottom: 0px;
+
+   + .error {
+      margin-top: 6px;
+    }
   }
 `;
diff --git a/packages/extension-ui/src/components/TextInputs.ts b/packages/extension-ui/src/components/TextInputs.ts
index 70016004..169f89ca 100644
--- a/packages/extension-ui/src/components/TextInputs.ts
+++ b/packages/extension-ui/src/components/TextInputs.ts
@@ -11,9 +11,9 @@ interface Props extends ThemeProps {
 
 const TextInput = css(({ theme, withError }: Props) => `
   background: ${theme.inputBackground};
-  border-color: ${withError ? theme.errorBorderColor : theme.inputBorderColor};
   border-radius: ${theme.borderRadius};
   border: 1px solid ${theme.inputBorderColor};
+  border-color: ${withError ? theme.errorBorderColor : theme.inputBorderColor};
   box-sizing: border-box;
   color: ${withError ? theme.errorColor : theme.textColor};
   display: block;
diff --git a/packages/extension-ui/src/components/ValidatedInput.tsx b/packages/extension-ui/src/components/ValidatedInput.tsx
index 660e98ad..cd0facb0 100644
--- a/packages/extension-ui/src/components/ValidatedInput.tsx
+++ b/packages/extension-ui/src/components/ValidatedInput.tsx
@@ -1,13 +1,11 @@
 // Copyright 2019-2020 @polkadot/extension-ui authors & contributors
 // SPDX-License-Identifier: Apache-2.0
 
-import { ThemeProps } from '../types';
-
 import React, { useState, useEffect } from 'react';
-import styled from 'styled-components';
 
 import useIsMounted from '../hooks/useIsMounted';
 import { Result, Validator } from '../util/validators';
+import Warning from './Warning';
 
 interface BasicProps {
   isError?: boolean;
@@ -52,17 +50,16 @@ function ValidatedInput<T extends Record<string, unknown>> ({ className, compone
         onChange={setValue}
         value={value}
       />
-      {Result.isError(validationResult) && <span className='error'>{validationResult.error.errorDescription}</span>}
+      {Result.isError(validationResult) && (
+        <Warning
+          isBelowInput
+          isDanger
+        >
+          {validationResult.error.errorDescription}
+        </Warning>
+      )}
     </div>
   );
 }
 
-export default styled(ValidatedInput)(({ theme }: ThemeProps) => `
-  .error {
-    display: block;
-    margin-top: -10px;
-    font-size: ${theme.labelFontSize};
-    line-height: ${theme.labelLineHeight};
-    color: ${theme.errorColor};
-  }
-`);
+export default ValidatedInput;
diff --git a/packages/extension-ui/src/components/Warning.tsx b/packages/extension-ui/src/components/Warning.tsx
index 0bf48869..d3824a72 100644
--- a/packages/extension-ui/src/components/Warning.tsx
+++ b/packages/extension-ui/src/components/Warning.tsx
@@ -12,30 +12,48 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 interface Props extends ThemeProps {
   children: React.ReactNode;
   className?: string;
+  isBelowInput?: boolean;
   isDanger?: boolean;
 }
 
-function Warning ({ children, className }: Props): React.ReactElement<Props> {
+function Warning ({ children, className = '', isBelowInput, isDanger }: Props): React.ReactElement<Props> {
   return (
-    <div className={className}>
-      <div>
-        <FontAwesomeIcon
-          className='warningImage'
-          icon={faExclamationTriangle}
-        />
-      </div>
-      <div>{children}</div>
+    <div className={`${className} ${isDanger ? 'danger' : ''} ${isBelowInput ? 'belowInput' : ''}`}>
+      <FontAwesomeIcon
+        className='warningImage'
+        icon={faExclamationTriangle}
+      />
+      <div className='warning-message'>{children}</div>
     </div>
   );
 }
 
-export default React.memo(styled(Warning)(({ isDanger, theme }: Props) => `
+export default React.memo(styled(Warning)<Props>(({ isDanger, theme }: Props) => `
   display: flex;
   flex-direction: row;
-  padding-left: ${isDanger ? '18px' : ''};
+  padding-left: 18px;
   color: ${theme.subTextColor};
   margin-right: 20px;
-  border-left: ${isDanger ? `0.25rem solid ${theme.buttonBackgroundDanger}` : ''};
+  margin-top: 6px;
+  border-left: ${`0.25rem solid ${theme.iconWarningColor}`};
+
+  &.belowInput {
+    font-size: ${theme.labelFontSize};
+    line-height: ${theme.labelLineHeight};
+
+    &.danger {
+      margin-top: -10px;
+    }
+  }
+
+  &.danger {
+    border-left-color: ${theme.buttonBackgroundDanger};
+  }
+
+  .warning-message {
+    display: flex;
+    align-items: center;
+  }
 
   .warningImage {
     margin: 5px 10px 5px 0;


Test patch:
diff --git a/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx b/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx
index a7a53507..b462d83c 100644
--- a/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx
+++ b/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx
@@ -42,6 +42,12 @@ describe('Create Account', () => {
     wrapper.update();
   };
 
+  const capsLockOn = async (input: ReactWrapper): Promise<void> => {
+    input.simulate('keyPress', { getModifierState: () => true });
+    await act(flushAllPromises);
+    wrapper.update();
+  };
+
   const enterName = (name: string): Promise<void> => type(wrapper.find('input').first(), name);
   const password = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').first(), password);
   const repeat = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').last(), password);
@@ -102,7 +108,7 @@ describe('Create Account', () => {
     it('after typing less than 3 characters into name input, password input is not visible', async () => {
       await enterName('ab');
       expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(true);
-      expect(wrapper.find('span.error').first().text()).toBe('Account name is too short');
+      expect(wrapper.find('.warning-message').first().text()).toBe('Account name is too short');
       expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
       expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
       expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
@@ -122,10 +128,17 @@ describe('Create Account', () => {
       expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
     });
 
+    it('password with caps lock should show a warning', async () => {
+      await enterName('abc').then(password('abcde'));
+      await capsLockOn(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input));
+
+      expect(wrapper.find('.warning-message').first().text()).toBe('Warning: Caps lock is on');
+    });
+
     it('password shorter than 6 characters should be not valid', async () => {
       await enterName('abc').then(password('abcde'));
       expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input).prop('withError')).toBe(true);
-      expect(wrapper.find('span.error').text()).toBe('Password is too short');
+      expect(wrapper.find('.warning-message').text()).toBe('Password is too short');
       expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1);
       expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
       expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
@@ -133,14 +146,14 @@ describe('Create Account', () => {
 
     it('submit button is not visible until both passwords are equal', async () => {
       await enterName('abc').then(password('abcdef')).then(repeat('abcdeg'));
-      expect(wrapper.find('span.error').text()).toBe('Passwords do not match');
+      expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match');
       expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(true);
       expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
     });
 
     it('submit button is visible when both passwords are equal', async () => {
       await enterName('abc').then(password('abcdef')).then(repeat('abcdef'));
-      expect(wrapper.find('span.error')).toHaveLength(0);
+      expect(wrapper.find('.warning-message')).toHaveLength(0);
       expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(false);
       expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
     });
@@ -171,7 +184,7 @@ describe('Create Account', () => {
 
     it('first password changes - button is not disabled', async () => {
       await type(wrapper.find('input[type="password"]').first(), 'aaaaaa');
-      expect(wrapper.find('span.error').text()).toBe('Passwords do not match');
+      expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match');
       expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
     });
 
diff --git a/packages/extension-ui/src/Popup/Derive/Derive.test.tsx b/packages/extension-ui/src/Popup/Derive/Derive.test.tsx
index 4f00f7ed..6d89a3ab 100644
--- a/packages/extension-ui/src/Popup/Derive/Derive.test.tsx
+++ b/packages/extension-ui/src/Popup/Derive/Derive.test.tsx
@@ -115,7 +115,7 @@ describe('Derive', () => {
     });
 
     it('No error is visible when first loading the page', () => {
-      expect(wrapper.find('.error')).toHaveLength(0);
+      expect(wrapper.find('Warning')).toHaveLength(0);
     });
 
     it('"Create derived account" is disabled when password is not set', () => {
@@ -133,7 +133,8 @@ describe('Derive', () => {
       const button = wrapper.find('[data-button-action="create derived account"] button');
 
       expect(button.prop('disabled')).toBe(true);
-      expect(wrapper.find('.error')).toHaveLength(1);
+      expect(wrapper.find('.warning-message')).toHaveLength(1);
+      expect(wrapper.find('.warning-message').first().text()).toEqual('Wrong password');
     });
 
     it('The error disappears when typing a new password and "Create derived account" is enabled', async () => {
@@ -147,7 +148,7 @@ describe('Derive', () => {
       const button = wrapper.find('[data-button-action="create derived account"] button');
 
       expect(button.prop('disabled')).toBe(false);
-      expect(wrapper.find('.error')).toHaveLength(0);
+      expect(wrapper.find('.warning-message')).toHaveLength(0);
     });
 
     it('"Create derived account" is enabled when password is set', async () => {
@@ -156,7 +157,7 @@ describe('Derive', () => {
       const button = wrapper.find('[data-button-action="create derived account"] button');
 
       expect(button.prop('disabled')).toBe(false);
-      expect(wrapper.find('.error')).toHaveLength(0);
+      expect(wrapper.find('.warning-message')).toHaveLength(0);
     });
 
     it('An error is visible and "Create derived account" is disabled when suri is incorrect', async () => {
@@ -169,7 +170,8 @@ describe('Derive', () => {
       const button = wrapper.find('[data-button-action="create derived account"] button');
 
       expect(button.prop('disabled')).toBe(true);
-      expect(wrapper.find('.error')).toHaveLength(1);
+      expect(wrapper.find('.warning-message')).toHaveLength(1);
+      expect(wrapper.find('.warning-message').first().text()).toEqual('Incorrect derivation path');
     });
 
     it('The error disappears and "Create derived account" is enabled when typing a new suri', async () => {
@@ -183,7 +185,7 @@ describe('Derive', () => {
       const button = wrapper.find('[data-button-action="create derived account"] button');
 
       expect(button.prop('disabled')).toBe(false);
-      expect(wrapper.find('.error')).toHaveLength(0);
+      expect(wrapper.find('Warning')).toHaveLength(0);
     });
 
     it('takes selected address from URL as parent account', () => {